├── .gitignore ├── .goxc.json ├── .travis.yml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── apps ├── app.go ├── app_bench_test.go ├── app_test.go ├── task.go ├── task_test.go └── testdata │ ├── app.json │ ├── apps.json │ ├── lorem-ipsum.json │ └── tasks.json ├── config ├── config.go └── config_test.go ├── consul ├── agent.go ├── agents.go ├── agents_test.go ├── config.go ├── consul.go ├── consul_stub.go ├── consul_stub_test.go ├── consul_test.go └── consul_test_server.go ├── debian ├── config.json ├── marathon-consul.service └── marathon-consul.upstart ├── events ├── event_handler.go ├── event_handler_test.go ├── sse_events.go ├── sse_events_test.go ├── task_health_change.go └── task_health_change_test.go ├── go.mod ├── go.sum ├── golangcilinter.yaml ├── install_consul.sh ├── main.go ├── marathon ├── config.go ├── marathon.go ├── marathon_stub.go ├── marathon_stub_test.go ├── marathon_test.go ├── streamer.go └── streamer_test.go ├── metrics ├── config.go ├── metrics.go ├── metrics_test.go ├── system_metrics.go └── system_metrics_test.go ├── sentry ├── config.go ├── init.go └── init_test.go ├── service ├── service.go └── service_test.go ├── sse ├── config.go ├── sse.go └── sse_handler.go ├── sync ├── config.go ├── error_marathon_stub_test.go ├── error_service_registry_stub_test.go ├── sync.go ├── sync_bench_test.go └── sync_test.go ├── time ├── time.go └── time_test.go ├── utils ├── apps.go ├── apps_test.go ├── errors.go ├── errors_test.go ├── net.go └── net_test.go └── web ├── config.go ├── health_handler.go └── health_handler_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | .idea 8 | _obj 9 | _test 10 | build 11 | bin 12 | dist 13 | 14 | # Architecture specific extensions/prefixes 15 | *.[568vq] 16 | [568vq].out 17 | 18 | *.cgo1.go 19 | *.cgo2.c 20 | _cgo_defun.c 21 | _cgo_gotypes.go 22 | _cgo_export.* 23 | 24 | _testmain.go 25 | 26 | *.exe 27 | *.test 28 | *.prof 29 | marathon-forwarder 30 | 31 | # goxc 32 | .goxc.local.json 33 | 34 | # coverage 35 | /coverage 36 | -------------------------------------------------------------------------------- /.goxc.json: -------------------------------------------------------------------------------- 1 | { 2 | "ArtifactsDest": "dist", 3 | "TasksExclude": [ 4 | "go-vet", 5 | "go-test", 6 | "deb", 7 | "deb-dev", 8 | "deb-source" 9 | ], 10 | "BuildConstraints": "linux,!arm darwin", 11 | "PackageVersion": "1.5.3", 12 | "TaskSettings": { 13 | "bintray": { 14 | "downloadspage": "bintray.md", 15 | "exclude": "*.tar.gz,*.zip,bintray.md,.goxc-temp", 16 | "include:": "*.deb", 17 | "package": "marathon-consul", 18 | "repository": "deb", 19 | "subject": "allegro" 20 | }, 21 | "publish-github": { 22 | "body": "", 23 | "owner": "allegro", 24 | "repository": "marathon-consul" 25 | } 26 | }, 27 | "ConfigVersion": "0.9" 28 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | dist: trusty 3 | sudo: false 4 | 5 | go: 6 | - 1.13 7 | before_install: 8 | - gem install fpm 9 | - go get github.com/mattn/goveralls 10 | script: 11 | - make release check 12 | - goveralls -coverprofile=coverage/gover.coverprofile -service travis-ci 13 | deploy: 14 | skip_cleanup: true 15 | provider: releases 16 | api_key: 17 | secure: "dK2TbZ6lnH6LPlYhBFXZuJRh7xBRksyKEv2kLCYwS40OHGPQnh+YRlzyNZt8JW+wsSIDslTqN+FaYB62awEZCi3KLsPqWbGd2iAbYHVz7cE0wxVgGtkkENLFjMH0oK4pmS/jQLVj8PWIvOWbTXiKIqZb8ZxkdfFhHfHNNfhL1UU5V8P/xv8fVhC/FVwIvvEdefBmI+GMcSKmi4cLhJS+JUPcoaQn1XHhY9R1PyYGO4uwBR96Gw/NGzmOBv53e2mLGc2LUcEBdIURwG3LEmgB27lPW8Iqa5IZNIrojL35gTA5bQOvhC8VoWExl1H+Fvg6MR603ztEpJYJ3qKhNSIuzpRv7gGtxieu6bwAUbn3PXVpXT1DkwiiFVTmfIrn2V4b5HTfiuVvsHYJ2Lt3GJcx3VPijwH17PGXdZHHy6soJdvoxci5KH76Xy79nEEf+5tZpHo7jGj4OREjHzNe6Sqktz3FbvYQpZVGY2BCjBVxaStd+Ssz5HiG3pVg6IfiC1q8trwxnGHO5EJAO8xabjxOGgtQomzexD2D8+0lfJfk33xh70DCNZDMo4g73bBRPf/TYkHmV1DAQQ0TjZ5lTTsjEHKASipjUzLyKTSebyl1ZPoTCW7mv2iDwE8iuAWhBeuKob7YuPfvCqUtJMQl6K0dEUUYG6zGZyYqln66Q4409cc=" 18 | file_glob: true 19 | file: dist/*/marathon-consul_* 20 | on: 21 | tags: true 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | MAINTAINER Allegro 3 | ADD bin/marathon-consul marathon-consul 4 | EXPOSE 4000 5 | ENTRYPOINT ["/marathon-consul"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := build 2 | 3 | PACKAGES = $(shell go list ./... | grep -v /vendor/) 4 | 5 | TESTARGS ?= -race 6 | 7 | CURRENTDIR = $(shell pwd) 8 | SOURCEDIR = $(CURRENTDIR) 9 | SOURCES := $(shell find $(SOURCEDIR) -name '*.go') 10 | APP_SOURCES := $(shell find $(SOURCEDIR) -name '*.go' -not -path '$(SOURCEDIR)/vendor/*') 11 | 12 | PATH := $(CURRENTDIR)/bin:$(PATH) 13 | 14 | COVERAGEDIR = $(CURRENTDIR)/coverage 15 | 16 | VERSION = $(shell cat .goxc.json | python -c "import json,sys;obj=json.load(sys.stdin);print obj['PackageVersion'];") 17 | 18 | TEMPDIR := $(shell mktemp -d) 19 | LD_FLAGS = -ldflags '-w' -ldflags "-X main.VERSION=$(VERSION)" 20 | 21 | TEST_TARGETS = $(PACKAGES) 22 | 23 | all: build 24 | 25 | deps: 26 | @./install_consul.sh 27 | @mkdir -p $(COVERAGEDIR) 28 | @which gover > /dev/null || \ 29 | (go get github.com/modocache/gover) 30 | @which goxc > /dev/null || \ 31 | (go get github.com/laher/goxc) 32 | @which goimports > /dev/null || \ 33 | (go get golang.org/x/tools/cmd/goimports) 34 | 35 | build-deps: deps format 36 | @mkdir -p bin/ 37 | 38 | build: check build-deps test 39 | CGO_ENABLED=0 go build $(LD_FLAGS) -o bin/marathon-consul 40 | 41 | build-linux: build-deps 42 | CGO_ENABLED=0 GOOS=linux go build -a -tags netgo $(LD_FLAGS) -o bin/marathon-consul 43 | 44 | docker: build-linux 45 | docker build -t allegro/marathon-consul . 46 | 47 | test: deps $(SOURCES) $(TEST_TARGETS) 48 | gover $(COVERAGEDIR) $(COVERAGEDIR)/gover.coverprofile 49 | 50 | $(TEST_TARGETS): 51 | go test -coverprofile=coverage/$(shell basename $@).coverprofile $(TESTARGS) $@ 52 | 53 | check-deps: deps 54 | @which golangci-lint > /dev/null || \ 55 | (GO111MODULE=on go get -v github.com/golangci/golangci-lint/cmd/golangci-lint@v1.25.0) 56 | 57 | check: check-deps $(SOURCES) test 58 | golangci-lint run --config=golangcilinter.yaml ./... 59 | 60 | format: 61 | goimports -w -l $(APP_SOURCES) 62 | 63 | FPM-exists: 64 | @fpm -v || \ 65 | (echo >&2 "FPM must be installed on the system. See https://github.com/jordansissel/fpm"; false) 66 | 67 | deb: FPM-exists build-linux 68 | mkdir -p dist/$(VERSION)/ 69 | cd dist/$(VERSION)/ && \ 70 | fpm -s dir \ 71 | -t deb \ 72 | -n marathon-consul \ 73 | -v $(VERSION) \ 74 | --url="https://github.com/allegro/marathon-consul" \ 75 | --vendor=Allegro \ 76 | --architecture=amd64 \ 77 | --maintainer="Allegro Group " \ 78 | --description "Marathon-consul service (performs Marathon Tasks registration as Consul Services for service discovery) Marathon-consul takes information provided by the Marathon event bus and forwards it to Consul agents. It also re-syncs all the information from Marathon to Consul on startup and repeats it with given interval." \ 79 | --deb-priority optional \ 80 | --workdir $(TEMPDIR) \ 81 | --license "Apache License, version 2.0" \ 82 | ../../bin/marathon-consul=/usr/bin/marathon-consul \ 83 | ../../debian/marathon-consul.service=/etc/systemd/system/marathon-consul.service \ 84 | ../../debian/marathon-consul.upstart=/etc/init/marathon-consul.conf \ 85 | ../../debian/config.json=/etc/marathon-consul.d/config.json 86 | 87 | release: deb deps 88 | goxc 89 | 90 | version: deps 91 | goxc -wc -pv=$(v) 92 | git add .goxc.json 93 | git commit -m "Release $(v)" 94 | 95 | .PHONY: all bump build release deb 96 | -------------------------------------------------------------------------------- /apps/app.go: -------------------------------------------------------------------------------- 1 | package apps 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | 8 | log "github.com/sirupsen/logrus" 9 | ) 10 | 11 | // Only Marathon apps with this label will be registered in Consul 12 | const MarathonConsulLabel = "consul" 13 | const MarathonConsulTagValue = "tag" 14 | 15 | type HealthCheck struct { 16 | Path string `json:"path"` 17 | PortIndex int `json:"portIndex"` 18 | Port int `json:"port"` 19 | Protocol string `json:"protocol"` 20 | GracePeriodSeconds int `json:"gracePeriodSeconds"` 21 | IntervalSeconds int `json:"intervalSeconds"` 22 | TimeoutSeconds int `json:"timeoutSeconds"` 23 | MaxConsecutiveFailures int `json:"maxConsecutiveFailures"` 24 | Command struct { 25 | Value string `json:"value"` 26 | } 27 | } 28 | 29 | type PortDefinition struct { 30 | Labels map[string]string `json:"labels"` 31 | Name string `json:"name,omitempty"` 32 | } 33 | 34 | type Container struct { 35 | PortMappings []PortDefinition `json:"portMappings"` 36 | } 37 | 38 | type appWrapper struct { 39 | App App `json:"app"` 40 | } 41 | 42 | type Apps struct { 43 | Apps []*App `json:"apps"` 44 | } 45 | 46 | type App struct { 47 | Container Container `json:"container"` 48 | Labels map[string]string `json:"labels"` 49 | HealthChecks []HealthCheck `json:"healthChecks"` 50 | ID AppID `json:"id"` 51 | Tasks []Task `json:"tasks"` 52 | PortDefinitions []PortDefinition `json:"portDefinitions"` 53 | } 54 | 55 | // Marathon Application Id (aka PathId) 56 | // Usually in the form of /rootGroup/subGroup/subSubGroup/name 57 | // allowed characters: lowercase letters, digits, hyphens, slash 58 | type AppID string 59 | 60 | func (id AppID) String() string { 61 | return string(id) 62 | } 63 | 64 | func (app App) IsConsulApp() bool { 65 | _, ok := app.Labels[MarathonConsulLabel] 66 | return ok 67 | } 68 | 69 | func (app App) labelsToRawName(labels map[string]string) string { 70 | if value, ok := labels[MarathonConsulLabel]; ok && !isSpecialConsulNameValue(value) { 71 | return value 72 | } 73 | return app.ID.String() 74 | } 75 | 76 | func isSpecialConsulNameValue(name string) bool { 77 | return name == "true" || name == "" 78 | } 79 | 80 | func ParseApps(jsonBlob []byte) ([]*App, error) { 81 | apps := &Apps{} 82 | err := json.Unmarshal(jsonBlob, apps) 83 | 84 | return apps.Apps, err 85 | } 86 | 87 | func ParseApp(jsonBlob []byte) (*App, error) { 88 | wrapper := &appWrapper{} 89 | err := json.Unmarshal(jsonBlob, wrapper) 90 | 91 | return &wrapper.App, err 92 | } 93 | 94 | type RegistrationIntent struct { 95 | Name string 96 | Port int 97 | Tags []string 98 | } 99 | 100 | func (app App) RegistrationIntentsNumber() int { 101 | if !app.IsConsulApp() { 102 | return 0 103 | } 104 | 105 | definitions := app.filterConsulDefinitions(app.extractIndexedPortDefinitions()) 106 | if len(definitions) == 0 { 107 | return 1 108 | } 109 | 110 | return len(definitions) 111 | } 112 | 113 | func (app App) RegistrationIntents(task *Task, nameSeparator string) []RegistrationIntent { 114 | taskPortsCount := len(task.Ports) 115 | indexedPortDefinitions := app.extractIndexedPortDefinitions() 116 | consulPortDefinitions := app.filterConsulDefinitions(indexedPortDefinitions) 117 | tagPlaceholderMapping := createTagPlaceholderMapping(indexedPortDefinitions, task.Ports) 118 | commonTags := labelsToTags(app.Labels, tagPlaceholderMapping) 119 | if len(consulPortDefinitions) == 0 && taskPortsCount != 0 { 120 | return []RegistrationIntent{ 121 | { 122 | Name: app.labelsToName(app.Labels, nameSeparator), 123 | Port: task.Ports[0], 124 | Tags: commonTags, 125 | }, 126 | } 127 | } 128 | 129 | var intents []RegistrationIntent 130 | for _, d := range consulPortDefinitions { 131 | if d.Index >= taskPortsCount { 132 | log.WithField("Id", task.ID.String()).Warnf("Port index (%d) out of bounds should be from range [0,%d)", d.Index, taskPortsCount) 133 | continue 134 | } 135 | intents = append(intents, RegistrationIntent{ 136 | Name: app.labelsToName(d.Labels, nameSeparator), 137 | Port: task.Ports[d.Index], 138 | Tags: append(labelsToTags(d.Labels, tagPlaceholderMapping), commonTags...), 139 | }) 140 | } 141 | return intents 142 | } 143 | 144 | func marathonAppNameToServiceName(name string, nameSeparator string) string { 145 | return strings.Replace(strings.Trim(strings.TrimSpace(name), "/"), "/", nameSeparator, -1) 146 | } 147 | 148 | func labelsToTags(labels map[string]string, tagPlaceholderMapping map[string]string) []string { 149 | tags := make([]string, 0, len(labels)) 150 | for key, value := range labels { 151 | if value == MarathonConsulTagValue { 152 | tags = append(tags, resolvePlaceholders(key, tagPlaceholderMapping)) 153 | } 154 | } 155 | return tags 156 | } 157 | 158 | func createTagPlaceholderMapping(portDefinitions []indexedPortDefinition, ports []int) map[string]string { 159 | mapping := map[string]string{} 160 | for _, d := range portDefinitions { 161 | if d.Name != "" { 162 | placeholder := fmt.Sprintf("{port:%s}", d.Name) 163 | mapping[placeholder] = fmt.Sprint(ports[d.Index]) 164 | } 165 | } 166 | return mapping 167 | } 168 | 169 | func resolvePlaceholders(value string, tagPlaceholderMapping map[string]string) string { 170 | for placeholder, replacement := range tagPlaceholderMapping { 171 | value = strings.Replace(value, placeholder, replacement, -1) 172 | } 173 | 174 | return value 175 | } 176 | 177 | func (app App) labelsToName(labels map[string]string, nameSeparator string) string { 178 | appConsulName := app.labelsToRawName(labels) 179 | serviceName := marathonAppNameToServiceName(appConsulName, nameSeparator) 180 | if serviceName == "" { 181 | log.WithField("AppId", app.ID.String()).WithField("ConsulServiceName", appConsulName). 182 | Warn("Warning! Invalid Consul service name provided for app. Will use default app name instead.") 183 | return marathonAppNameToServiceName(app.ID.String(), nameSeparator) 184 | } 185 | return serviceName 186 | } 187 | 188 | type indexedPortDefinition struct { 189 | Index int 190 | Labels map[string]string 191 | Name string 192 | } 193 | 194 | func (app App) extractIndexedPortDefinitions() []indexedPortDefinition { 195 | var definitions []indexedPortDefinition 196 | for i, d := range app.extractPortDefinitions() { 197 | definitions = append(definitions, indexedPortDefinition{ 198 | Index: i, 199 | Labels: d.Labels, 200 | Name: d.Name, 201 | }) 202 | } 203 | 204 | return definitions 205 | } 206 | 207 | func (app App) filterConsulDefinitions(all []indexedPortDefinition) []indexedPortDefinition { 208 | var consulDefinitions []indexedPortDefinition 209 | for _, d := range all { 210 | if labelName, ok := d.Labels[MarathonConsulLabel]; ok { 211 | multipleDefinitions := strings.Split(labelName, ",") 212 | 213 | for _, name := range multipleDefinitions { 214 | labels := extractLabelsForService(name, d.Labels) 215 | consulDefinitions = append(consulDefinitions, indexedPortDefinition{ 216 | Index: d.Index, 217 | Labels: labels, 218 | Name: name, 219 | }) 220 | } 221 | } 222 | } 223 | 224 | return consulDefinitions 225 | } 226 | 227 | func extractLabelsForService(serviceName string, labels map[string]string) map[string]string { 228 | newLabels := make(map[string]string) 229 | 230 | for key, value := range labels { 231 | valueAndSelector := strings.Split(value, ":") 232 | if len(valueAndSelector) > 1 { 233 | extractedValue := valueAndSelector[0] 234 | serviceSelector := valueAndSelector[1] 235 | 236 | if extractedValue == MarathonConsulTagValue && serviceSelector == serviceName { 237 | newLabels[key] = extractedValue 238 | } 239 | } else if key == MarathonConsulLabel { 240 | newLabels[key] = serviceName 241 | } else { 242 | newLabels[key] = value 243 | } 244 | } 245 | 246 | return newLabels 247 | } 248 | 249 | // Deprecated: Allows for backward compatibility with Marathons' network API 250 | // PortDefinitions are deprecated in favor of Marathons' new PortMappings 251 | // see https://github.com/mesosphere/marathon/pull/5391 252 | func (app App) extractPortDefinitions() []PortDefinition { 253 | var appPortDefinitions []PortDefinition 254 | if len(app.Container.PortMappings) > 0 { 255 | appPortDefinitions = app.Container.PortMappings 256 | } else { 257 | appPortDefinitions = app.PortDefinitions 258 | } 259 | 260 | return appPortDefinitions 261 | } 262 | -------------------------------------------------------------------------------- /apps/app_bench_test.go: -------------------------------------------------------------------------------- 1 | package apps 2 | 3 | import "testing" 4 | 5 | func BenchmarkApp_RegistrationIntents(b *testing.B) { 6 | 7 | // given an average app. 8 | // Over 80% of our apps has only one port 9 | // registered and more then five (5) labels but only 2 of them 10 | // are tags. Over 90% of our application does not have 11 | // special tags on ports. 12 | app := &App{ 13 | ID: "app-name", 14 | Labels: map[string]string{ 15 | "#1": "label", 16 | "#2": "label", 17 | "consul": "true", 18 | "#3": "label", 19 | "#4": "tag", 20 | "#5": "label", 21 | "#6": "tag", 22 | "#7": "label", 23 | }, 24 | PortDefinitions: []PortDefinition{{Labels: map[string]string{"#8": "tag"}}}, 25 | } 26 | task := &Task{ 27 | Ports: []int{0}, 28 | } 29 | 30 | for i := 0; i < b.N; i++ { 31 | app.RegistrationIntents(task, "-") 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /apps/app_test.go: -------------------------------------------------------------------------------- 1 | package apps 2 | 3 | import ( 4 | "io/ioutil" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestExtractPortDefintions(t *testing.T) { 11 | definitions := []PortDefinition{ 12 | { 13 | Labels: map[string]string{"other": "tag"}, 14 | }, 15 | {}, 16 | } 17 | app := App{ 18 | Container: Container{ 19 | PortMappings: definitions, 20 | }, 21 | } 22 | 23 | ports := app.extractPortDefinitions() 24 | assert.Equal(t, app.Container.PortMappings[0].Labels["other"], ports[0].Labels["other"]) 25 | 26 | app = App{PortDefinitions: definitions} 27 | ports = app.extractPortDefinitions() 28 | assert.Equal(t, app.PortDefinitions[0].Labels["other"], ports[0].Labels["other"]) 29 | } 30 | 31 | func TestParseApps(t *testing.T) { 32 | t.Parallel() 33 | 34 | appBlob, _ := ioutil.ReadFile("testdata/apps.json") 35 | 36 | expected := []*App{ 37 | { 38 | HealthChecks: []HealthCheck{ 39 | { 40 | Path: "/", 41 | PortIndex: 0, 42 | Protocol: "HTTP", 43 | GracePeriodSeconds: 5, 44 | IntervalSeconds: 20, 45 | TimeoutSeconds: 20, 46 | MaxConsecutiveFailures: 3, 47 | }, 48 | }, 49 | ID: "/bridged-webapp", 50 | Tasks: []Task{ 51 | { 52 | ID: "test.47de43bd-1a81-11e5-bdb6-e6cb6734eaf8", 53 | AppID: "/test", 54 | Host: "192.168.2.114", 55 | Ports: []int{31315}, 56 | HealthCheckResults: []HealthCheckResult{{Alive: true}}, 57 | }, 58 | { 59 | ID: "test.4453212c-1a81-11e5-bdb6-e6cb6734eaf8", 60 | AppID: "/test", 61 | Host: "192.168.2.114", 62 | Ports: []int{31797}, 63 | }, 64 | }, 65 | }, 66 | } 67 | apps, err := ParseApps(appBlob) 68 | assert.NoError(t, err) 69 | assert.Len(t, apps, 1) 70 | assert.Equal(t, expected, apps) 71 | } 72 | 73 | func TestAppInt(t *testing.T) { 74 | appBlob, _ := ioutil.ReadFile("testdata/lorem-ipsum.json") 75 | app, err := ParseApp(appBlob) 76 | assert.NoError(t, err) 77 | t.Log(app) 78 | 79 | assert.Equal(t, 2, app.RegistrationIntentsNumber()) 80 | 81 | task := Task{Ports: []int{0, 1, 2, 3}} 82 | intents := app.RegistrationIntents(&task, ".") 83 | 84 | assert.Contains(t, intents[0].Tags, "Lorem ipsum dolor sit amet, consectetur adipiscing elit") 85 | assert.Contains(t, intents[1].Tags, "secureConnection:true") 86 | assert.NotContains(t, intents[0].Tags, "secureConnection:true") 87 | assert.NotContains(t, intents[1].Tags, "Lorem ipsum dolor sit amet, consectetur adipiscing elit") 88 | } 89 | 90 | func TestParseApp(t *testing.T) { 91 | t.Parallel() 92 | 93 | appBlob, _ := ioutil.ReadFile("testdata/app.json") 94 | 95 | expected := &App{Labels: map[string]string{"consul": "true", "public": "tag"}, 96 | HealthChecks: []HealthCheck{ 97 | { 98 | Path: "/", 99 | PortIndex: 0, 100 | Protocol: "HTTP", 101 | GracePeriodSeconds: 10, 102 | IntervalSeconds: 5, 103 | TimeoutSeconds: 10, 104 | MaxConsecutiveFailures: 3, 105 | }, 106 | { 107 | Path: "/custom", 108 | Port: 8123, 109 | Protocol: "HTTP", 110 | GracePeriodSeconds: 10, 111 | IntervalSeconds: 5, 112 | TimeoutSeconds: 10, 113 | MaxConsecutiveFailures: 3, 114 | }, 115 | }, 116 | ID: "/myapp", 117 | Tasks: []Task{{ 118 | ID: "myapp.cc49ccc1-9812-11e5-a06e-56847afe9799", 119 | AppID: "/myapp", 120 | Host: "10.141.141.10", 121 | Ports: []int{31678, 122 | 31679, 123 | 31680, 124 | 31681}, 125 | HealthCheckResults: []HealthCheckResult{{Alive: true}}}, 126 | { 127 | ID: "myapp.c8b449f0-9812-11e5-a06e-56847afe9799", 128 | AppID: "/myapp", 129 | Host: "10.141.141.10", 130 | Ports: []int{31307, 131 | 31308, 132 | 31309, 133 | 31310}, 134 | HealthCheckResults: []HealthCheckResult{{Alive: true}}}}} 135 | 136 | app, err := ParseApp(appBlob) 137 | assert.NoError(t, err) 138 | assert.Equal(t, expected, app) 139 | } 140 | 141 | func TestConsulApp(t *testing.T) { 142 | t.Parallel() 143 | 144 | // when 145 | app := &App{ 146 | Labels: map[string]string{"consul": "true"}, 147 | } 148 | 149 | // then 150 | assert.True(t, app.IsConsulApp()) 151 | 152 | // when 153 | app = &App{ 154 | Labels: map[string]string{"consul": "someName", "marathon": "true"}, 155 | } 156 | 157 | // then 158 | assert.True(t, app.IsConsulApp()) 159 | 160 | // when 161 | app = &App{ 162 | Labels: map[string]string{}, 163 | } 164 | 165 | // then 166 | assert.False(t, app.IsConsulApp()) 167 | } 168 | 169 | func TestAppId_String(t *testing.T) { 170 | t.Parallel() 171 | assert.Equal(t, "appId", AppID("appId").String()) 172 | } 173 | 174 | var dummyTask = &Task{ 175 | ID: TaskID("some-task"), 176 | Ports: []int{1337}, 177 | } 178 | 179 | func TestRegistrationIntent_NameWithoutConsulLabel(t *testing.T) { 180 | t.Parallel() 181 | 182 | // given 183 | app := &App{ 184 | ID: "/rootGroup/subGroup/subSubGroup/name", 185 | } 186 | 187 | // when 188 | intent := app.RegistrationIntents(dummyTask, ".")[0] 189 | 190 | // then 191 | assert.Equal(t, "rootGroup.subGroup.subSubGroup.name", intent.Name) 192 | } 193 | 194 | func TestRegistrationIntent_Name(t *testing.T) { 195 | t.Parallel() 196 | 197 | var intentNameTestsData = []struct { 198 | consulLabel string 199 | expectedName string 200 | }{ 201 | {"", "rootGroup-subGroup-subSubGroup-name"}, 202 | {"true", "rootGroup-subGroup-subSubGroup-name"}, 203 | {"/some-other/name", "some-other-name"}, 204 | {" ///", "rootGroup-subGroup-subSubGroup-name"}, 205 | } 206 | 207 | for _, testData := range intentNameTestsData { 208 | // given 209 | app := &App{ 210 | ID: "/rootGroup/subGroup/subSubGroup/name", 211 | Labels: map[string]string{"consul": testData.consulLabel}, 212 | } 213 | 214 | // when 215 | intent := app.RegistrationIntents(dummyTask, "-")[0] 216 | 217 | // then 218 | if intent.Name != testData.expectedName { 219 | t.Errorf("Registration name from consul label '%s' was '%s', expected '%s'", testData.consulLabel, intent.Name, testData.expectedName) 220 | } 221 | } 222 | } 223 | 224 | func TestRegistrationIntent_PickFirstPort(t *testing.T) { 225 | t.Parallel() 226 | 227 | // given 228 | app := &App{ 229 | ID: "name", 230 | } 231 | task := &Task{ 232 | Ports: []int{1234, 5678}, 233 | } 234 | 235 | // when 236 | intent := app.RegistrationIntents(task, "-")[0] 237 | 238 | // then 239 | assert.Equal(t, 1234, intent.Port) 240 | } 241 | 242 | func TestRegistrationIntent_WithTags(t *testing.T) { 243 | t.Parallel() 244 | 245 | // given 246 | app := &App{ 247 | ID: "name", 248 | Labels: map[string]string{"private": "tag", "other": "irrelevant"}, 249 | } 250 | 251 | // when 252 | intent := app.RegistrationIntents(dummyTask, "-")[0] 253 | 254 | // then 255 | assert.Equal(t, []string{"private"}, intent.Tags) 256 | } 257 | 258 | func TestRegistrationIntent_NoOverrideViaPortDefinitionsIfNoConsulLabelThere(t *testing.T) { 259 | t.Parallel() 260 | 261 | // given 262 | app := &App{ 263 | ID: "app-name", 264 | Labels: map[string]string{"consul": "true", "private": "tag"}, 265 | PortDefinitions: []PortDefinition{ 266 | { 267 | Labels: map[string]string{"other": "tag"}, 268 | }, 269 | {}, 270 | }, 271 | } 272 | task := &Task{ 273 | Ports: []int{1234, 5678}, 274 | } 275 | 276 | // when 277 | intents := app.RegistrationIntents(task, "-") 278 | 279 | // then 280 | assert.Len(t, intents, 1) 281 | assert.Equal(t, "app-name", intents[0].Name) 282 | assert.Equal(t, 1234, intents[0].Port) 283 | assert.Equal(t, []string{"private"}, intents[0].Tags) 284 | } 285 | 286 | func TestRegistrationIntent_DontPanicIfTaskHasNoPorts(t *testing.T) { 287 | t.Parallel() 288 | 289 | // given 290 | app := &App{ 291 | ID: "app-name", 292 | Labels: map[string]string{"consul": "true", "private": "tag"}, 293 | PortDefinitions: []PortDefinition{ 294 | { 295 | Labels: map[string]string{"other": "tag"}, 296 | }, 297 | {}, 298 | }, 299 | } 300 | task := &Task{ 301 | Ports: []int{}, 302 | } 303 | 304 | // when 305 | intents := app.RegistrationIntents(task, "-") 306 | 307 | // then 308 | assert.Empty(t, intents) 309 | } 310 | 311 | func TestRegistrationIntent_OverrideNameAndAddTagsViaPortDefinitions(t *testing.T) { 312 | t.Parallel() 313 | 314 | // given 315 | app := &App{ 316 | ID: "app-name", 317 | Labels: map[string]string{"consul": "true", "private": "tag"}, 318 | PortDefinitions: []PortDefinition{ 319 | { 320 | Labels: map[string]string{"consul": "other-name", "other": "tag"}, 321 | }, 322 | {}, 323 | }, 324 | } 325 | task := &Task{ 326 | Ports: []int{1234, 5678}, 327 | } 328 | 329 | // when 330 | intents := app.RegistrationIntents(task, "-") 331 | 332 | // then 333 | assert.Len(t, intents, 1) 334 | assert.Equal(t, "other-name", intents[0].Name) 335 | assert.Equal(t, 1234, intents[0].Port) 336 | assert.Equal(t, []string{"other", "private"}, intents[0].Tags) 337 | } 338 | 339 | func TestRegistrationIntent_PickDifferentPortViaPortDefinitions(t *testing.T) { 340 | t.Parallel() 341 | 342 | // given 343 | app := &App{ 344 | ID: "app-name", 345 | Labels: map[string]string{"consul": "true", "private": "tag"}, 346 | PortDefinitions: []PortDefinition{ 347 | {}, 348 | { 349 | Labels: map[string]string{"consul": "true"}, 350 | }, 351 | }, 352 | } 353 | task := &Task{ 354 | Ports: []int{1234, 5678}, 355 | } 356 | 357 | // when 358 | intent := app.RegistrationIntents(task, "-")[0] 359 | 360 | // then 361 | assert.Equal(t, 5678, intent.Port) 362 | } 363 | 364 | func TestRegistrationIntent_MultipleIntentsViaPortDefinitionIfMultipleContainConsulLabel(t *testing.T) { 365 | t.Parallel() 366 | 367 | // given 368 | app := &App{ 369 | ID: "app-name", 370 | Labels: map[string]string{"consul": "true", "common-tag": "tag"}, 371 | PortDefinitions: []PortDefinition{ 372 | { 373 | Labels: map[string]string{"consul": "first-name", "first-tag": "tag"}, 374 | }, 375 | { 376 | Labels: map[string]string{"consul": "second-name", "second-tag": "tag"}, 377 | }, 378 | }, 379 | } 380 | task := &Task{ 381 | Ports: []int{1234, 5678}, 382 | } 383 | 384 | // when 385 | intents := app.RegistrationIntents(task, "-") 386 | 387 | // then 388 | assert.Len(t, intents, 2) 389 | assert.Equal(t, "first-name", intents[0].Name) 390 | assert.Equal(t, 1234, intents[0].Port) 391 | assert.Equal(t, []string{"first-tag", "common-tag"}, intents[0].Tags) 392 | assert.Equal(t, "second-name", intents[1].Name) 393 | assert.Equal(t, 5678, intents[1].Port) 394 | assert.Equal(t, []string{"second-tag", "common-tag"}, intents[1].Tags) 395 | } 396 | 397 | func TestRegistrationIntent_PortPlaceholderInPortDefinitionsLabel(t *testing.T) { 398 | t.Parallel() 399 | 400 | // given 401 | app := &App{ 402 | ID: "app-name", 403 | Labels: map[string]string{"consul": "true"}, 404 | PortDefinitions: []PortDefinition{ 405 | { 406 | Labels: map[string]string{"consul": "true", "something:{port:foo}": "tag"}, 407 | }, 408 | { 409 | Name: "foo", 410 | }, 411 | }, 412 | } 413 | task := &Task{ 414 | Ports: []int{1234, 5678}, 415 | } 416 | 417 | // when 418 | intents := app.RegistrationIntents(task, "-") 419 | 420 | // then 421 | assert.Len(t, intents, 1) 422 | assert.Equal(t, "app-name", intents[0].Name) 423 | assert.Equal(t, 1234, intents[0].Port) 424 | assert.Equal(t, []string{"something:5678"}, intents[0].Tags) 425 | } 426 | 427 | func TestRegistrationIntent_PortPlaceholderInPortDefinitionsLabel_NameNotFound(t *testing.T) { 428 | t.Parallel() 429 | 430 | // given 431 | app := &App{ 432 | ID: "app-name", 433 | Labels: map[string]string{"consul": "true"}, 434 | PortDefinitions: []PortDefinition{ 435 | { 436 | Labels: map[string]string{"consul": "true", "something:{port:bar}": "tag"}, 437 | }, 438 | { 439 | Name: "foo", 440 | }, 441 | }, 442 | } 443 | task := &Task{ 444 | Ports: []int{1234, 5678}, 445 | } 446 | 447 | // when 448 | intents := app.RegistrationIntents(task, "-") 449 | 450 | // then 451 | assert.Len(t, intents, 1) 452 | assert.Equal(t, "app-name", intents[0].Name) 453 | assert.Equal(t, 1234, intents[0].Port) 454 | assert.Equal(t, []string{"something:{port:bar}"}, intents[0].Tags) 455 | } 456 | 457 | func TestRegistrationIntent_TaskHasLessPortsThanApp(t *testing.T) { 458 | t.Parallel() 459 | 460 | // given 461 | app := &App{ 462 | ID: "app-name", 463 | Labels: map[string]string{"consul": "true", "common-tag": "tag"}, 464 | PortDefinitions: []PortDefinition{ 465 | { 466 | Labels: map[string]string{"consul": "first-name", "first-tag": "tag"}, 467 | }, 468 | { 469 | Labels: map[string]string{"consul": "second-name", "second-tag": "tag"}, 470 | }, 471 | }, 472 | } 473 | task := &Task{ 474 | Ports: []int{1234}, 475 | } 476 | 477 | // when 478 | intents := app.RegistrationIntents(task, "-") 479 | 480 | // then 481 | assert.Len(t, intents, 1) 482 | assert.Equal(t, "first-name", intents[0].Name) 483 | assert.Equal(t, 1234, intents[0].Port) 484 | assert.Equal(t, []string{"first-tag", "common-tag"}, intents[0].Tags) 485 | } 486 | 487 | func TestRegistrationIntentsNumber(t *testing.T) { 488 | for _, tc := range registrationIntentsNumberTestCases { 489 | t.Run(tc.name, func(t *testing.T) { 490 | t.Parallel() 491 | assert.Equal(t, tc.expected, tc.given.RegistrationIntentsNumber()) 492 | }) 493 | } 494 | } 495 | 496 | var registrationIntentsNumberTestCases = []struct { 497 | name string 498 | given App 499 | expected int 500 | }{ 501 | {"Not a consul app", App{ID: "id"}, 0}, 502 | {"No port definitions", App{ 503 | ID: "id", 504 | Labels: map[string]string{"consul": ""}, 505 | }, 1}, 506 | {"Single port definition", App{ 507 | ID: "id", 508 | Labels: map[string]string{"consul": ""}, 509 | PortDefinitions: []PortDefinition{ 510 | { 511 | Labels: map[string]string{"consul": ""}, 512 | }, 513 | }, 514 | }, 1}, 515 | {"Multiple port definitions", App{ 516 | ID: "id", 517 | Labels: map[string]string{"consul": ""}, 518 | PortDefinitions: []PortDefinition{ 519 | { 520 | Labels: map[string]string{"consul": ""}, 521 | }, 522 | { 523 | Labels: map[string]string{"consul": ""}, 524 | }, 525 | }, 526 | }, 2}, 527 | } 528 | -------------------------------------------------------------------------------- /apps/task.go: -------------------------------------------------------------------------------- 1 | package apps 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | 7 | "github.com/allegro/marathon-consul/time" 8 | ) 9 | 10 | type Task struct { 11 | ID TaskID `json:"id"` 12 | // Timestamp field is not a part of a Marathon task object. 13 | // It's only present in StatusUpdateEventType and we are using this struct for decoding it. 14 | // As well as for Marathon Task. 15 | Timestamp time.Timestamp `json:"timestamp"` 16 | TaskStatus string `json:"taskStatus"` 17 | State string `json:"state"` 18 | AppID AppID `json:"appId"` 19 | Host string `json:"host"` 20 | Ports []int `json:"ports"` 21 | HealthCheckResults []HealthCheckResult `json:"healthCheckResults"` 22 | } 23 | 24 | // Marathon Task ID 25 | // Usually in the form of AppId.uuid with '/' replaced with '_' 26 | type TaskID string 27 | 28 | func (id TaskID) String() string { 29 | return string(id) 30 | } 31 | 32 | func (id TaskID) AppID() AppID { 33 | index := strings.LastIndex(id.String(), ".") 34 | return AppID("/" + strings.Replace(id.String()[0:index], "_", "/", -1)) 35 | } 36 | 37 | type HealthCheckResult struct { 38 | Alive bool `json:"alive"` 39 | } 40 | 41 | type TasksResponse struct { 42 | Tasks []Task `json:"tasks"` 43 | } 44 | 45 | func FindTaskByID(id TaskID, tasks []Task) (Task, bool) { 46 | for _, task := range tasks { 47 | if task.ID == id { 48 | return task, true 49 | } 50 | } 51 | return Task{}, false 52 | } 53 | 54 | func ParseTasks(jsonBlob []byte) ([]Task, error) { 55 | tasks := &TasksResponse{} 56 | err := json.Unmarshal(jsonBlob, tasks) 57 | 58 | return tasks.Tasks, err 59 | } 60 | 61 | func ParseTask(event []byte) (*Task, error) { 62 | task := &Task{} 63 | err := json.Unmarshal(event, task) 64 | return task, err 65 | } 66 | 67 | func (t Task) IsHealthy() bool { 68 | if len(t.HealthCheckResults) < 1 { 69 | return false 70 | } 71 | register := true 72 | for _, healthCheckResult := range t.HealthCheckResults { 73 | register = register && healthCheckResult.Alive && t.State != "TASK_KILLING" 74 | } 75 | return register 76 | } 77 | -------------------------------------------------------------------------------- /apps/task_test.go: -------------------------------------------------------------------------------- 1 | package apps 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "testing" 7 | 8 | "github.com/allegro/marathon-consul/time" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestParseTask(t *testing.T) { 13 | t.Parallel() 14 | 15 | testTask := &Task{ 16 | ID: "my-app_0-1396592784349", 17 | Timestamp: time.Timestamp{}, 18 | AppID: "/my-app", 19 | Host: "slave-1234.acme.org", 20 | Ports: []int{31372}, 21 | HealthCheckResults: []HealthCheckResult{{Alive: true}}, 22 | } 23 | 24 | jsonified, err := json.Marshal(testTask) 25 | assert.Nil(t, err) 26 | 27 | service, err := ParseTask(jsonified) 28 | assert.Nil(t, err) 29 | 30 | assert.Equal(t, testTask.ID, service.ID) 31 | assert.Equal(t, testTask.AppID, service.AppID) 32 | assert.Equal(t, testTask.Host, service.Host) 33 | assert.Equal(t, testTask.Ports, service.Ports) 34 | assert.Equal(t, testTask.HealthCheckResults[0].Alive, service.HealthCheckResults[0].Alive) 35 | } 36 | 37 | func TestParseTasks(t *testing.T) { 38 | t.Parallel() 39 | 40 | tasksBlob, _ := ioutil.ReadFile("testdata/tasks.json") 41 | 42 | expectedTasks := []Task{ 43 | { 44 | ID: "test.47de43bd-1a81-11e5-bdb6-e6cb6734eaf8", 45 | Timestamp: time.Timestamp{}, 46 | AppID: "/test", 47 | Host: "192.168.2.114", 48 | Ports: []int{31315}, 49 | HealthCheckResults: []HealthCheckResult{{Alive: true}}, 50 | }, 51 | { 52 | ID: "test.4453212c-1a81-11e5-bdb6-e6cb6734eaf8", 53 | AppID: "/test", 54 | Host: "192.168.2.114", 55 | Ports: []int{31797}, 56 | }, 57 | } 58 | 59 | tasks, err := ParseTasks(tasksBlob) 60 | assert.Nil(t, err) 61 | assert.Len(t, tasks, 2) 62 | assert.Equal(t, expectedTasks, tasks) 63 | } 64 | 65 | func TestIsHealthy(t *testing.T) { 66 | t.Parallel() 67 | 68 | // given 69 | task := &Task{} 70 | 71 | // when 72 | task.HealthCheckResults = nil 73 | 74 | // then 75 | assert.False(t, task.IsHealthy()) 76 | 77 | // when 78 | task.HealthCheckResults = []HealthCheckResult{} 79 | 80 | // then 81 | assert.False(t, task.IsHealthy()) 82 | 83 | // when 84 | task.HealthCheckResults = []HealthCheckResult{ 85 | {Alive: false}, 86 | } 87 | 88 | // then 89 | assert.False(t, task.IsHealthy()) 90 | 91 | // when 92 | task.HealthCheckResults = []HealthCheckResult{ 93 | {Alive: true}, 94 | {Alive: false}, 95 | } 96 | 97 | // then 98 | assert.False(t, task.IsHealthy()) 99 | 100 | // when 101 | task.HealthCheckResults = []HealthCheckResult{ 102 | {Alive: true}, 103 | {Alive: true}, 104 | } 105 | 106 | // then 107 | assert.True(t, task.IsHealthy()) 108 | 109 | // when 110 | task.State = "TASK_KILLING" 111 | 112 | // then 113 | assert.False(t, task.IsHealthy()) 114 | 115 | // when 116 | task.State = "TASK_RUNNING" 117 | 118 | // then 119 | assert.True(t, task.IsHealthy()) 120 | } 121 | 122 | func TestId_String(t *testing.T) { 123 | t.Parallel() 124 | assert.Equal(t, "id", TaskID("id").String()) 125 | } 126 | 127 | func TestId_AppId(t *testing.T) { 128 | t.Parallel() 129 | id := "pl.allegro_test_app.a7cde60e-0093-11e6-ab55-02aab772a161" 130 | assert.Equal(t, AppID("/pl.allegro/test/app"), TaskID(id).AppID()) 131 | } 132 | 133 | func TestId_AppIdForInvalidIdShouldPanic(t *testing.T) { 134 | t.Parallel() 135 | assert.Panics(t, func() { 136 | a := TaskID("id").AppID() 137 | assert.Nil(t, a) 138 | }) 139 | } 140 | -------------------------------------------------------------------------------- /apps/testdata/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": { 3 | "id": "/myapp", 4 | "cmd": "env && python -m SimpleHTTPServer $PORT0", 5 | "args": null, 6 | "user": null, 7 | "env": {}, 8 | "instances": 2, 9 | "cpus": 0.1, 10 | "mem": 32.0, 11 | "disk": 0.0, 12 | "executor": "", 13 | "constraints": [], 14 | "uris": [], 15 | "storeUrls": [], 16 | "ports": [ 17 | 10002, 18 | 1, 19 | 2, 20 | 3 21 | ], 22 | "requirePorts": false, 23 | "backoffSeconds": 1, 24 | "backoffFactor": 1.15, 25 | "maxLaunchDelaySeconds": 3600, 26 | "container": null, 27 | "healthChecks": [ 28 | { 29 | "path": "/", 30 | "protocol": "HTTP", 31 | "portIndex": 0, 32 | "gracePeriodSeconds": 10, 33 | "intervalSeconds": 5, 34 | "timeoutSeconds": 10, 35 | "maxConsecutiveFailures": 3, 36 | "ignoreHttp1xx": false 37 | }, 38 | { 39 | "path": "/custom", 40 | "protocol": "HTTP", 41 | "port": 8123, 42 | "gracePeriodSeconds": 10, 43 | "intervalSeconds": 5, 44 | "timeoutSeconds": 10, 45 | "maxConsecutiveFailures": 3, 46 | "ignoreHttp1xx": false 47 | } 48 | ], 49 | "dependencies": [], 50 | "upgradeStrategy": { 51 | "minimumHealthCapacity": 1.0, 52 | "maximumOverCapacity": 1.0 53 | }, 54 | "labels": { 55 | "consul": "true", 56 | "public": "tag" 57 | }, 58 | "version": "2015-12-01T10:03:32.003Z", 59 | "tasksStaged": 0, 60 | "tasksRunning": 2, 61 | "tasksHealthy": 2, 62 | "tasksUnhealthy": 0, 63 | "deployments": [], 64 | "tasks": [ 65 | { 66 | "id": "myapp.cc49ccc1-9812-11e5-a06e-56847afe9799", 67 | "host": "10.141.141.10", 68 | "ports": [ 69 | 31678, 70 | 31679, 71 | 31680, 72 | 31681 73 | ], 74 | "startedAt": "2015-12-01T10:03:40.966Z", 75 | "stagedAt": "2015-12-01T10:03:40.890Z", 76 | "version": "2015-12-01T10:03:32.003Z", 77 | "appId": "/myapp", 78 | "healthCheckResults": [ 79 | { 80 | "alive": true, 81 | "consecutiveFailures": 0, 82 | "firstSuccess": "2015-12-01T10:03:42.324Z", 83 | "lastFailure": null, 84 | "lastSuccess": "2015-12-01T10:03:42.324Z", 85 | "taskId": "myapp.cc49ccc1-9812-11e5-a06e-56847afe9799" 86 | } 87 | ] 88 | }, 89 | { 90 | "id": "myapp.c8b449f0-9812-11e5-a06e-56847afe9799", 91 | "host": "10.141.141.10", 92 | "ports": [ 93 | 31307, 94 | 31308, 95 | 31309, 96 | 31310 97 | ], 98 | "startedAt": "2015-12-01T10:03:34.945Z", 99 | "stagedAt": "2015-12-01T10:03:34.877Z", 100 | "version": "2015-12-01T10:03:32.003Z", 101 | "appId": "/myapp", 102 | "healthCheckResults": [ 103 | { 104 | "alive": true, 105 | "consecutiveFailures": 0, 106 | "firstSuccess": "2015-12-01T10:03:37.313Z", 107 | "lastFailure": null, 108 | "lastSuccess": "2015-12-01T10:03:42.337Z", 109 | "taskId": "myapp.c8b449f0-9812-11e5-a06e-56847afe9799" 110 | } 111 | ] 112 | } 113 | ], 114 | "lastTaskFailure": null 115 | } 116 | } -------------------------------------------------------------------------------- /apps/testdata/apps.json: -------------------------------------------------------------------------------- 1 | { 2 | "apps": [ 3 | { 4 | "args": null, 5 | "backoffFactor": 1.15, 6 | "backoffSeconds": 1, 7 | "maxLaunchDelaySeconds": 3600, 8 | "cmd": "python3 -m http.server 8080", 9 | "constraints": [], 10 | "container": { 11 | "docker": { 12 | "image": "python:3", 13 | "network": "BRIDGE", 14 | "portMappings": [ 15 | { 16 | "containerPort": 8080, 17 | "hostPort": 0, 18 | "servicePort": 9000, 19 | "protocol": "tcp" 20 | }, 21 | { 22 | "containerPort": 161, 23 | "hostPort": 0, 24 | "protocol": "udp" 25 | } 26 | ] 27 | }, 28 | "type": "DOCKER", 29 | "volumes": [] 30 | }, 31 | "cpus": 0.5, 32 | "dependencies": [], 33 | "deployments": [], 34 | "disk": 0.0, 35 | "env": {}, 36 | "executor": "", 37 | "healthChecks": [ 38 | { 39 | "command": null, 40 | "gracePeriodSeconds": 5, 41 | "intervalSeconds": 20, 42 | "maxConsecutiveFailures": 3, 43 | "path": "/", 44 | "portIndex": 0, 45 | "protocol": "HTTP", 46 | "timeoutSeconds": 20 47 | } 48 | ], 49 | "id": "/bridged-webapp", 50 | "instances": 2, 51 | "mem": 64.0, 52 | "ports": [ 53 | 10000, 54 | 10001 55 | ], 56 | "requirePorts": false, 57 | "storeUrls": [], 58 | "tasksRunning": 2, 59 | "tasksHealthy": 2, 60 | "tasksUnhealthy": 0, 61 | "tasksStaged": 0, 62 | "upgradeStrategy": { 63 | "minimumHealthCapacity": 1.0 64 | }, 65 | "uris": [], 66 | "user": null, 67 | "version": "2014-09-25T02:26:59.256Z", 68 | "tasks": [ 69 | { 70 | "appId": "/test", 71 | "host": "192.168.2.114", 72 | "id": "test.47de43bd-1a81-11e5-bdb6-e6cb6734eaf8", 73 | "ports": [ 74 | 31315 75 | ], 76 | "stagedAt": "2015-06-24T14:57:06.353Z", 77 | "startedAt": "2015-06-24T14:57:06.466Z", 78 | "version": "2015-06-24T14:56:57.466Z", 79 | "healthCheckResults": [ 80 | { 81 | "alive": true, 82 | "consecutiveFailures": 0, 83 | "firstSuccess": "2015-11-28T18:21:11.957Z", 84 | "lastFailure": null, 85 | "lastSuccess": "2015-11-30T10:08:19.477Z", 86 | "taskId": "bridged-webapp.a9b051fb-95fc-11e5-9571-02818b42970e" 87 | } 88 | ] 89 | }, 90 | { 91 | "appId": "/test", 92 | "host": "192.168.2.114", 93 | "id": "test.4453212c-1a81-11e5-bdb6-e6cb6734eaf8", 94 | "ports": [ 95 | 31797 96 | ], 97 | "stagedAt": "2015-06-24T14:57:00.474Z", 98 | "startedAt": "2015-06-24T14:57:00.611Z", 99 | "version": "2015-06-24T14:56:57.466Z" 100 | } 101 | ] 102 | } 103 | ] 104 | } -------------------------------------------------------------------------------- /apps/testdata/lorem-ipsum.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": { 3 | "id": "lorem/ipsum", 4 | "cmd": "Lorem ipsum dolor sit amet, consectetur adipiscing elit", 5 | "healthChecks": [ 6 | { 7 | "path": "/ping", 8 | "protocol": "HTTP" 9 | } 10 | ], 11 | "labels": { 12 | "artifactId": "Lorem ipsum", 13 | "default-monitoring": "tag", 14 | "hystrix-dashboard": "tag", 15 | "weight:50": "tag", 16 | "consul": "lorem-ipsum" 17 | }, 18 | "portDefinitions": [ 19 | { 20 | "port": 10115, 21 | "protocol": "tcp", 22 | "labels": { 23 | "consul": "lorem-ipsum", 24 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit": "tag" 25 | } 26 | }, 27 | { 28 | "port": 10116, 29 | "protocol": "tcp", 30 | "labels": {} 31 | }, 32 | { 33 | "port": 10130, 34 | "protocol": "tcp", 35 | "labels": { 36 | "consul": "lorem-ipsum-secured", 37 | "secureConnection:true": "tag" 38 | } 39 | }, 40 | { 41 | "port": 10214, 42 | "protocol": "tcp", 43 | "labels": {} 44 | } 45 | ] 46 | } 47 | } -------------------------------------------------------------------------------- /apps/testdata/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "tasks": [ 3 | { 4 | "appId": "/test", 5 | "host": "192.168.2.114", 6 | "id": "test.47de43bd-1a81-11e5-bdb6-e6cb6734eaf8", 7 | "ports": [31315], 8 | "stagedAt": "2015-06-24T14:57:06.353Z", 9 | "startedAt": "2015-06-24T14:57:06.466Z", 10 | "version": "2015-06-24T14:56:57.466Z", 11 | "healthCheckResults":[ 12 | { 13 | "alive":true, 14 | "consecutiveFailures":0, 15 | "firstSuccess":"2015-11-28T18:21:11.957Z", 16 | "lastFailure":null, 17 | "lastSuccess":"2015-11-30T10:08:19.477Z", 18 | "taskId":"bridged-webapp.a9b051fb-95fc-11e5-9571-02818b42970e" 19 | } 20 | ] 21 | }, 22 | { 23 | "appId": "/test", 24 | "host": "192.168.2.114", 25 | "id": "test.4453212c-1a81-11e5-bdb6-e6cb6734eaf8", 26 | "ports": [31797], 27 | "stagedAt": "2015-06-24T14:57:00.474Z", 28 | "startedAt": "2015-06-24T14:57:00.611Z", 29 | "version": "2015-06-24T14:56:57.466Z" 30 | } 31 | ] 32 | } -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "os" 7 | "strings" 8 | "time" 9 | 10 | "github.com/allegro/marathon-consul/consul" 11 | "github.com/allegro/marathon-consul/marathon" 12 | "github.com/allegro/marathon-consul/metrics" 13 | "github.com/allegro/marathon-consul/sentry" 14 | "github.com/allegro/marathon-consul/sse" 15 | "github.com/allegro/marathon-consul/sync" 16 | "github.com/allegro/marathon-consul/web" 17 | flag "github.com/ogier/pflag" 18 | log "github.com/sirupsen/logrus" 19 | ) 20 | 21 | type Config struct { 22 | Consul consul.Config 23 | Web web.Config 24 | SSE sse.Config 25 | Sync sync.Config 26 | Marathon marathon.Config 27 | Metrics metrics.Config 28 | Log struct { 29 | Level string 30 | Format string 31 | File string 32 | Sentry sentry.Config 33 | } 34 | configFile string 35 | } 36 | 37 | var config = &Config{} 38 | 39 | func New() (*Config, error) { 40 | if !flag.Parsed() { 41 | config.parseFlags() 42 | } 43 | flag.Parse() 44 | err := config.loadConfigFromFile() 45 | 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | err = config.setLogOutput() 51 | if err != nil { 52 | return nil, err 53 | } 54 | config.setLogFormat() 55 | err = config.setLogLevel() 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | return config, err 61 | } 62 | 63 | func (config *Config) parseFlags() { 64 | // Consul 65 | flag.StringVar(&config.Consul.Port, "consul-port", "8500", "Consul port") 66 | flag.BoolVar(&config.Consul.Auth.Enabled, "consul-auth", false, "Use Consul with authentication") 67 | flag.StringVar(&config.Consul.Auth.Username, "consul-auth-username", "", "The basic authentication username") 68 | flag.StringVar(&config.Consul.Auth.Password, "consul-auth-password", "", "The basic authentication password") 69 | flag.BoolVar(&config.Consul.SslEnabled, "consul-ssl", false, "Use HTTPS when talking to Consul") 70 | flag.BoolVar(&config.Consul.SslVerify, "consul-ssl-verify", true, "Verify certificates when connecting via SSL") 71 | flag.StringVar(&config.Consul.SslCert, "consul-ssl-cert", "", "Path to an SSL client certificate to use to authenticate to the Consul server") 72 | flag.StringVar(&config.Consul.SslCaCert, "consul-ssl-ca-cert", "", "Path to a CA certificate file, containing one or more CA certificates to use to validate the certificate sent by the Consul server to us") 73 | flag.StringVar(&config.Consul.Token, "consul-token", "", "The Consul ACL token") 74 | flag.StringVar(&config.Consul.Tag, "consul-tag", "marathon", "Common tag name added to every service registered in Consul, should be unique for every Marathon-cluster connected to Consul") 75 | flag.DurationVar(&config.Consul.Timeout.Duration, "consul-timeout", 3*time.Second, "Time limit for requests made by the Consul HTTP client. A Timeout of zero means no timeout") 76 | flag.Uint32Var(&config.Consul.AgentFailuresTolerance, "consul-max-agent-failures", 3, "Max number of consecutive request failures for agent before removal from cache") 77 | flag.Uint32Var(&config.Consul.RequestRetries, "consul-get-services-retry", 3, "Number of retries on failure when performing requests to Consul. Each retry uses different cached agent") 78 | flag.StringVar(&config.Consul.ConsulNameSeparator, "consul-name-separator", ".", "Separator used to create default service name for Consul") 79 | flag.StringVar(&config.Consul.IgnoredHealthChecks, "consul-ignored-healthchecks", "", "A comma separated blacklist of Marathon health check types that will not be migrated to Consul, e.g. command,tcp") 80 | flag.BoolVar(&config.Consul.EnableTagOverride, "consul-enable-tag-override", false, "Disable the anti-entropy feature for all services") 81 | flag.StringVar(&config.Consul.LocalAgentHost, "consul-local-agent-host", "", "Consul Agent hostname or IP that should be used for startup sync") 82 | flag.StringVar(&config.Consul.Dc, "consul-dc", "", "Consul DC where to look for services, all if empty") 83 | 84 | // Web 85 | flag.StringVar(&config.Web.Listen, "listen", ":4000", "Accept connections at this address") 86 | flag.IntVar(&config.Web.QueueSize, "events-queue-size", 1000, "Size of events queue") 87 | flag.IntVar(&config.Web.WorkersCount, "workers-pool-size", 10, "Number of concurrent workers processing events") 88 | flag.Int64Var(&config.Web.MaxEventSize, "event-max-size", 4096, "Maximum size of event to process (bytes)") 89 | 90 | // SSE 91 | flag.IntVar(&config.SSE.Retries, "sse-retries", 0, "Number of times to recover SSE stream.") 92 | flag.DurationVar(&config.SSE.RetryBackoff.Duration, "sse-retry-backoff", 0, "Configuration of initial time between retries to recover SSE stream.") 93 | 94 | // Sync 95 | flag.BoolVar(&config.Sync.Enabled, "sync-enabled", true, "Enable Marathon-consul scheduled sync") 96 | flag.DurationVar(&config.Sync.Interval.Duration, "sync-interval", 15*time.Minute, "Marathon-consul sync interval") 97 | flag.BoolVar(&config.Sync.Force, "sync-force", false, "Force leadership-independent Marathon-consul sync (run always)") 98 | 99 | // Marathon 100 | flag.StringVar(&config.Marathon.Location, "marathon-location", "localhost:8080", "Marathon URL") 101 | flag.StringVar(&config.Marathon.Protocol, "marathon-protocol", "http", "Marathon protocol (http or https)") 102 | flag.StringVar(&config.Marathon.Username, "marathon-username", "", "Marathon username for basic auth") 103 | flag.StringVar(&config.Marathon.Password, "marathon-password", "", "Marathon password for basic auth") 104 | flag.StringVar(&config.Marathon.Leader, "marathon-leader", "", "Marathon cluster-wide node name (defaults to :8080), the some leader specific calls will be made only if the specified node is the current Marathon-leader. Set to `*` to always act like a Leader.") 105 | flag.BoolVar(&config.Marathon.VerifySsl, "marathon-ssl-verify", true, "Verify certificates when connecting via SSL") 106 | flag.DurationVar(&config.Marathon.Timeout.Duration, "marathon-timeout", 30*time.Second, "Time limit for requests made by the Marathon HTTP client. A Timeout of zero means no timeout") 107 | 108 | // Metrics 109 | flag.StringVar(&config.Metrics.Target, "metrics-target", "stdout", "Metrics destination stdout or graphite (empty string disables metrics)") 110 | flag.StringVar(&config.Metrics.Prefix, "metrics-prefix", "default", "Metrics prefix (default is resolved to .") 111 | flag.DurationVar(&config.Metrics.Interval.Duration, "metrics-interval", 30*time.Second, "Metrics reporting interval") 112 | flag.StringVar(&config.Metrics.Addr, "metrics-location", "", "Graphite URL (used when metrics-target is set to graphite)") 113 | 114 | // Log 115 | flag.StringVar(&config.Log.Level, "log-level", "info", "Log level: panic, fatal, error, warn, info, or debug") 116 | flag.StringVar(&config.Log.Format, "log-format", "text", "Log format: JSON, text") 117 | flag.StringVar(&config.Log.File, "log-file", "", "Save logs to file (e.g.: `/var/log/marathon-consul.log`). If empty logs are published to STDERR") 118 | 119 | // Log -> Sentry 120 | flag.StringVar(&config.Log.Sentry.DSN, "sentry-dsn", "", "Sentry DSN. If it's not set sentry will be disabled") 121 | flag.StringVar(&config.Log.Sentry.Env, "sentry-env", "", "Sentry environment") 122 | flag.StringVar(&config.Log.Sentry.Level, "sentry-level", "error", "Sentry alerting level (info|warning|error|fatal|panic)") 123 | flag.DurationVar(&config.Log.Sentry.Timeout.Duration, "sentry-timeout", time.Second, "Sentry hook initialization timeout") 124 | 125 | // General 126 | flag.StringVar(&config.configFile, "config-file", "", "Path to a JSON file to read configuration from. Note: Will override options set earlier on the command line") 127 | } 128 | 129 | func (config *Config) loadConfigFromFile() error { 130 | if config.configFile == "" { 131 | return nil 132 | } 133 | jsonBlob, err := ioutil.ReadFile(config.configFile) 134 | if err != nil { 135 | return err 136 | } 137 | return json.Unmarshal(jsonBlob, config) 138 | } 139 | 140 | func (config *Config) setLogLevel() error { 141 | level, err := log.ParseLevel(config.Log.Level) 142 | if err != nil { 143 | log.WithError(err).WithField("Level", config.Log.Level).Error("Bad level") 144 | return err 145 | } 146 | log.SetLevel(level) 147 | return nil 148 | } 149 | 150 | func (config *Config) setLogOutput() error { 151 | path := config.Log.File 152 | 153 | if len(path) == 0 { 154 | log.SetOutput(os.Stderr) 155 | return nil 156 | } 157 | 158 | f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) 159 | if err != nil { 160 | log.WithError(err).Errorf("error opening file: %s", path) 161 | return err 162 | } 163 | 164 | log.SetOutput(f) 165 | return nil 166 | } 167 | 168 | func (config *Config) setLogFormat() { 169 | format := strings.ToUpper(config.Log.Format) 170 | if format == "JSON" { 171 | log.SetFormatter(&log.JSONFormatter{}) 172 | } else if format == "TEXT" { 173 | log.SetFormatter(&log.TextFormatter{}) 174 | } else { 175 | log.WithField("Format", format).Error("Unknown log format") 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "reflect" 6 | "testing" 7 | "time" 8 | 9 | "github.com/allegro/marathon-consul/consul" 10 | "github.com/allegro/marathon-consul/marathon" 11 | "github.com/allegro/marathon-consul/metrics" 12 | "github.com/allegro/marathon-consul/sentry" 13 | "github.com/allegro/marathon-consul/sse" 14 | "github.com/allegro/marathon-consul/sync" 15 | timeutil "github.com/allegro/marathon-consul/time" 16 | "github.com/allegro/marathon-consul/web" 17 | "github.com/stretchr/testify/assert" 18 | ) 19 | 20 | func TestConfig_NewReturnsErrorWhenFileNotExist(t *testing.T) { 21 | clear() 22 | 23 | // given 24 | os.Args = []string{"./marathon-consul", "--config-file=unknown.json"} 25 | 26 | // when 27 | _, err := New() 28 | 29 | // then 30 | assert.Error(t, err) 31 | } 32 | 33 | func TestConfig_NewReturnsErrorWhenFileIsNotJson(t *testing.T) { 34 | clear() 35 | 36 | // given 37 | os.Args = []string{"./marathon-consul", "--config-file=config.go"} 38 | 39 | // when 40 | _, err := New() 41 | 42 | // then 43 | assert.Error(t, err) 44 | } 45 | 46 | func TestConfig_ShouldReturnErrorForBadLogLevel(t *testing.T) { 47 | clear() 48 | 49 | // given 50 | os.Args = []string{"./marathon-consul", "--log-level=bad"} 51 | 52 | // when 53 | _, err := New() 54 | 55 | // then 56 | assert.Error(t, err) 57 | } 58 | 59 | func TestConfig_ShouldParseFlags(t *testing.T) { 60 | clear() 61 | 62 | // given 63 | os.Args = []string{"./marathon-consul", "--log-level=debug", "--marathon-location=test.host:8080", "--log-format=json"} 64 | 65 | // when 66 | actual, err := New() 67 | 68 | // then 69 | assert.NoError(t, err) 70 | assert.Equal(t, "debug", actual.Log.Level) 71 | assert.Equal(t, "json", actual.Log.Format) 72 | assert.Equal(t, "test.host:8080", actual.Marathon.Location) 73 | } 74 | 75 | func TestConfig_ShouldUseTextFormatterWhenFormatterIsUnknown(t *testing.T) { 76 | clear() 77 | 78 | // given 79 | os.Args = []string{"./marathon-consul", "--log-level=debug", "--log-format=unknown"} 80 | 81 | // when 82 | _, err := New() 83 | 84 | // then 85 | assert.NoError(t, err) 86 | } 87 | 88 | func TestConfig_ShouldBeMergedWithFileDefaultsAndFlags(t *testing.T) { 89 | clear() 90 | expected := &Config{ 91 | Consul: consul.Config{ 92 | Auth: consul.Auth{Enabled: false, 93 | Username: "", 94 | Password: ""}, 95 | Port: "8500", 96 | SslEnabled: false, 97 | SslVerify: true, 98 | SslCert: "", 99 | SslCaCert: "", 100 | Token: "", 101 | Tag: "marathon", 102 | Timeout: timeutil.Interval{Duration: 3 * time.Second}, 103 | RequestRetries: 5, 104 | AgentFailuresTolerance: 3, 105 | ConsulNameSeparator: ".", 106 | EnableTagOverride: false, 107 | LocalAgentHost: "", 108 | }, 109 | Web: web.Config{ 110 | Listen: ":4000", 111 | QueueSize: 1000, 112 | WorkersCount: 10, 113 | MaxEventSize: 4096, 114 | }, 115 | SSE: sse.Config{}, 116 | Sync: sync.Config{ 117 | Interval: timeutil.Interval{Duration: 15 * time.Minute}, 118 | Enabled: true, 119 | Leader: "", 120 | Force: false, 121 | }, 122 | Marathon: marathon.Config{Location: "localhost:8080", 123 | Protocol: "http", 124 | Username: "", 125 | Password: "", 126 | VerifySsl: true, 127 | Timeout: timeutil.Interval{Duration: 30 * time.Second}}, 128 | Metrics: metrics.Config{Target: "stdout", 129 | Prefix: "default", 130 | Interval: timeutil.Interval{Duration: 30 * time.Second}, 131 | Addr: ""}, 132 | Log: struct { 133 | Level, Format, File string 134 | Sentry sentry.Config 135 | }{ 136 | Level: "info", 137 | Format: "text", 138 | File: "", 139 | Sentry: sentry.Config{ 140 | DSN: "", 141 | Env: "", 142 | Level: "error", 143 | Timeout: timeutil.Interval{Duration: time.Second}, 144 | }, 145 | }, 146 | configFile: "../debian/config.json", 147 | } 148 | 149 | os.Args = []string{"./marathon-consul", "--log-level=debug", "--config-file=../debian/config.json", "--marathon-location=localhost:8080"} 150 | actual, err := New() 151 | 152 | assert.NoError(t, err) 153 | assert.Equal(t, expected, actual) 154 | } 155 | 156 | // http://stackoverflow.com/a/29169727/1387612 157 | func clear() { 158 | p := reflect.ValueOf(config).Elem() 159 | p.Set(reflect.Zero(p.Type())) 160 | } 161 | -------------------------------------------------------------------------------- /consul/agent.go: -------------------------------------------------------------------------------- 1 | package consul 2 | 3 | import ( 4 | "fmt" 5 | "sync/atomic" 6 | 7 | consulapi "github.com/hashicorp/consul/api" 8 | log "github.com/sirupsen/logrus" 9 | ) 10 | 11 | type Agent struct { 12 | Client *consulapi.Client 13 | IP string 14 | failures uint32 15 | } 16 | 17 | func (a *Agent) IncFailures() uint32 { 18 | return atomic.AddUint32(&a.failures, 1) 19 | } 20 | 21 | func (a *Agent) ClearFailures() { 22 | atomic.StoreUint32(&a.failures, 0) 23 | } 24 | 25 | func (a *ConcurrentAgents) createAgent(ipAddress string) (*Agent, error) { 26 | client, err := a.newConsulClient(ipAddress) 27 | agent := &Agent{ 28 | Client: client, 29 | IP: ipAddress, 30 | } 31 | return agent, err 32 | } 33 | 34 | func (a *ConcurrentAgents) newConsulClient(ipAddress string) (*consulapi.Client, error) { 35 | config := consulapi.DefaultConfig() 36 | 37 | config.HttpClient = a.client 38 | 39 | config.Address = fmt.Sprintf("%s:%s", ipAddress, a.config.Port) 40 | 41 | if a.config.Token != "" { 42 | config.Token = a.config.Token 43 | } 44 | 45 | if a.config.SslEnabled { 46 | config.Scheme = "https" 47 | } 48 | 49 | if a.config.Auth.Enabled { 50 | config.HttpAuth = &consulapi.HttpBasicAuth{ 51 | Username: a.config.Auth.Username, 52 | Password: a.config.Auth.Password, 53 | } 54 | } 55 | 56 | log.WithFields(log.Fields{ 57 | "Address": config.Address, 58 | "Scheme": config.Scheme, 59 | "Timeout": config.HttpClient.Timeout, 60 | "BasicAuthEnabled": a.config.Auth.Enabled, 61 | "TokenEnabled": a.config.Token != "", 62 | "SslVerificationEnabled": a.config.SslVerify, 63 | }).Debug("Creating Consul client") 64 | 65 | return consulapi.NewClient(config) 66 | } 67 | -------------------------------------------------------------------------------- /consul/agents.go: -------------------------------------------------------------------------------- 1 | package consul 2 | 3 | import ( 4 | "crypto/tls" 5 | "errors" 6 | "math/rand" 7 | "net/http" 8 | "sync" 9 | 10 | "github.com/allegro/marathon-consul/metrics" 11 | "github.com/allegro/marathon-consul/utils" 12 | log "github.com/sirupsen/logrus" 13 | ) 14 | 15 | type Agents interface { 16 | GetAgent(agentAddress string) (agent *Agent, err error) 17 | GetLocalAgent() (agent *Agent, err error) 18 | GetAnyAgent() (agent *Agent, err error) 19 | RemoveAgent(agentAddress string) 20 | } 21 | 22 | type ConcurrentAgents struct { 23 | localAgent *Agent 24 | agents map[string]*Agent 25 | config *Config 26 | lock sync.Mutex 27 | client *http.Client 28 | } 29 | 30 | func NewAgents(config *Config) *ConcurrentAgents { 31 | client := &http.Client{ 32 | Transport: &http.Transport{ 33 | Proxy: http.ProxyFromEnvironment, 34 | TLSClientConfig: &tls.Config{ 35 | InsecureSkipVerify: !config.SslVerify, 36 | }, 37 | }, 38 | Timeout: config.Timeout.Duration, 39 | } 40 | 41 | agents := &ConcurrentAgents{ 42 | agents: make(map[string]*Agent), 43 | config: config, 44 | client: client, 45 | } 46 | if config.LocalAgentHost != "" { 47 | agent, err := agents.GetAgent(config.LocalAgentHost) 48 | if err != nil { 49 | log.WithError(err).WithField("agent", config.LocalAgentHost).Fatal( 50 | "Cannot connect with consul agent. Check if configuration is valid.") 51 | } 52 | agents.localAgent = agent 53 | } 54 | return agents 55 | } 56 | 57 | func (a *ConcurrentAgents) GetAnyAgent() (*Agent, error) { 58 | a.lock.Lock() 59 | defer a.lock.Unlock() 60 | 61 | if len(a.agents) > 0 { 62 | ipAddress := a.getRandomAgentIPAddress() 63 | return a.agents[ipAddress], nil 64 | } 65 | return nil, errors.New("No Consul client available in agents cache") 66 | } 67 | 68 | func (a *ConcurrentAgents) GetLocalAgent() (*Agent, error) { 69 | if a.localAgent == nil { 70 | return nil, errors.New("No local consul agent defined") 71 | } 72 | return a.localAgent, nil 73 | } 74 | 75 | func (a *ConcurrentAgents) getRandomAgentIPAddress() string { 76 | ipAddresses := []string{} 77 | for ipAddress := range a.agents { 78 | ipAddresses = append(ipAddresses, ipAddress) 79 | } 80 | idx := rand.Intn(len(a.agents)) 81 | return ipAddresses[idx] 82 | } 83 | 84 | func (a *ConcurrentAgents) RemoveAgent(agentAddress string) { 85 | a.lock.Lock() 86 | defer a.lock.Unlock() 87 | 88 | if IP, err := utils.HostToIPv4(agentAddress); err != nil { 89 | log.WithError(err).Error("Could not remove agent from cache") 90 | } else { 91 | ipAddress := IP.String() 92 | log.WithField("Address", ipAddress).Info("Removing agent from cache") 93 | delete(a.agents, ipAddress) 94 | a.updateAgentsCacheSizeMetricValue() 95 | } 96 | } 97 | 98 | func (a *ConcurrentAgents) GetAgent(agentAddress string) (*Agent, error) { 99 | a.lock.Lock() 100 | defer a.lock.Unlock() 101 | 102 | IP, err := utils.HostToIPv4(agentAddress) 103 | if err != nil { 104 | return nil, err 105 | } 106 | ipAddress := IP.String() 107 | 108 | if agent, ok := a.agents[ipAddress]; ok { 109 | return agent, nil 110 | } 111 | 112 | newAgent, err := a.createAgent(ipAddress) 113 | if err != nil { 114 | return nil, err 115 | } 116 | a.addAgent(ipAddress, newAgent) 117 | 118 | return newAgent, nil 119 | } 120 | 121 | func (a *ConcurrentAgents) addAgent(agentHost string, agent *Agent) { 122 | a.agents[agentHost] = agent 123 | a.updateAgentsCacheSizeMetricValue() 124 | } 125 | 126 | func (a *ConcurrentAgents) updateAgentsCacheSizeMetricValue() { 127 | metrics.UpdateGauge("consul.agents.cache.size", int64(len(a.agents))) 128 | } 129 | -------------------------------------------------------------------------------- /consul/agents_test.go: -------------------------------------------------------------------------------- 1 | package consul 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestGetAgent(t *testing.T) { 10 | t.Parallel() 11 | // given 12 | agents := NewAgents(&Config{}) 13 | 14 | // when 15 | agent, err := agents.GetAgent("127.0.0.1") 16 | 17 | // then 18 | assert.NotNil(t, agent) 19 | assert.NoError(t, err) 20 | } 21 | 22 | func TestPopulateAgentsCacheWithLocalAgent(t *testing.T) { 23 | t.Parallel() 24 | // when 25 | agents := NewAgents(&Config{LocalAgentHost: "127.0.0.1"}) 26 | 27 | // then 28 | assert.Len(t, agents.agents, 1) 29 | assert.NotNil(t, agents.agents["127.0.0.1"]) 30 | } 31 | 32 | func TestGetAnyAgent_SingleAgentAvailable(t *testing.T) { 33 | t.Parallel() 34 | // given 35 | agents := NewAgents(&Config{}) 36 | 37 | // when 38 | agents.GetAgent("127.0.0.1") // create 39 | agent, err := agents.GetAnyAgent() 40 | 41 | // then 42 | assert.NotNil(t, agent) 43 | assert.Equal(t, "127.0.0.1", agent.IP) 44 | assert.NoError(t, err) 45 | } 46 | 47 | func TestGetAnyAgent(t *testing.T) { 48 | t.Parallel() 49 | // given 50 | agents := NewAgents(&Config{}) 51 | agent1, _ := agents.GetAgent("127.0.0.1") 52 | agent2, _ := agents.GetAgent("127.0.0.2") 53 | agent3, _ := agents.GetAgent("127.0.0.3") 54 | 55 | // when 56 | anyAgent, err := agents.GetAnyAgent() 57 | 58 | // then 59 | assert.NoError(t, err) 60 | assert.Contains(t, []*Agent{agent1, agent2, agent3}, anyAgent) 61 | } 62 | 63 | func TestGetAgent_ShouldResolveAddressToIP(t *testing.T) { 64 | t.Parallel() 65 | // given 66 | agents := NewAgents(&Config{}) 67 | 68 | // when 69 | agent1, _ := agents.GetAgent("127.0.0.1") 70 | agent2, _ := agents.GetAgent("localhost") 71 | 72 | // then 73 | assert.Equal(t, agent1, agent2) 74 | } 75 | 76 | func TestGetAgent_ShouldFailOnWrongHostname(t *testing.T) { 77 | t.Parallel() 78 | // given 79 | agents := NewAgents(&Config{}) 80 | 81 | // when 82 | _, err := agents.GetAgent("wrong hostname") 83 | 84 | // then 85 | assert.Error(t, err) 86 | } 87 | 88 | func TestGetAgent_ShouldFailOnUnknownHostname(t *testing.T) { 89 | t.Parallel() 90 | // given 91 | agents := NewAgents(&Config{}) 92 | 93 | // when 94 | _, err := agents.GetAgent("unknown.host.name.1") 95 | 96 | // then 97 | assert.Error(t, err) 98 | } 99 | 100 | func TestGetAnyAgent_shouldFailOnNoAgentAvailable(t *testing.T) { 101 | t.Parallel() 102 | // given 103 | agents := NewAgents(&Config{}) 104 | 105 | // when 106 | anyAgent, err := agents.GetAnyAgent() 107 | 108 | // then 109 | assert.Nil(t, anyAgent) 110 | assert.NotNil(t, err) 111 | } 112 | 113 | func TestRemoveAgent(t *testing.T) { 114 | t.Parallel() 115 | // given 116 | agents := NewAgents(&Config{}) 117 | agents.GetAgent("127.0.0.1") 118 | agent2, _ := agents.GetAgent("127.0.0.2") 119 | 120 | // when 121 | agents.RemoveAgent("127.0.0.1") 122 | 123 | // then 124 | for i := 0; i < 10; i++ { 125 | agent, err := agents.GetAnyAgent() 126 | assert.Equal(t, agent, agent2) 127 | assert.Equal(t, "127.0.0.2", agent.IP) 128 | assert.NoError(t, err) 129 | } 130 | } 131 | 132 | func TestRemoveAgentTwiceShouldPass(t *testing.T) { 133 | t.Parallel() 134 | // given 135 | agents := NewAgents(&Config{}) 136 | agents.GetAgent("127.0.0.1") 137 | 138 | // when 139 | agents.RemoveAgent("127.0.0.1") 140 | agents.RemoveAgent("127.0.0.1") 141 | 142 | // then 143 | assert.Empty(t, agents.agents) 144 | } 145 | -------------------------------------------------------------------------------- /consul/config.go: -------------------------------------------------------------------------------- 1 | package consul 2 | 3 | import "github.com/allegro/marathon-consul/time" 4 | 5 | type Config struct { 6 | Auth Auth 7 | Port string 8 | SslEnabled bool 9 | SslVerify bool 10 | SslCert string 11 | SslCaCert string 12 | Token string 13 | Tag string 14 | Dc string 15 | Timeout time.Interval 16 | RequestRetries uint32 17 | AgentFailuresTolerance uint32 18 | ConsulNameSeparator string 19 | IgnoredHealthChecks string 20 | EnableTagOverride bool 21 | LocalAgentHost string 22 | } 23 | 24 | type Auth struct { 25 | Enabled bool 26 | Username string 27 | Password string 28 | } 29 | -------------------------------------------------------------------------------- /consul/consul_stub.go: -------------------------------------------------------------------------------- 1 | package consul 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | 7 | "github.com/allegro/marathon-consul/apps" 8 | "github.com/allegro/marathon-consul/service" 9 | consulapi "github.com/hashicorp/consul/api" 10 | ) 11 | 12 | // TODO this should be a service registry stub in the service package, requires abstracting from AgentServiceRegistration 13 | type Stub struct { 14 | sync.RWMutex 15 | services map[service.ID]*consulapi.AgentServiceRegistration 16 | failGetServicesForNames map[string]bool 17 | failRegisterForIDs map[apps.TaskID]bool 18 | failDeregisterByTaskForIDs map[apps.TaskID]bool 19 | failDeregisterForIDs map[service.ID]bool 20 | consul *Consul 21 | } 22 | 23 | func NewConsulStub() *Stub { 24 | return NewConsulStubWithTag("marathon") 25 | } 26 | 27 | func NewConsulStubWithTag(tag string) *Stub { 28 | return &Stub{ 29 | services: make(map[service.ID]*consulapi.AgentServiceRegistration), 30 | failGetServicesForNames: make(map[string]bool), 31 | failRegisterForIDs: make(map[apps.TaskID]bool), 32 | failDeregisterByTaskForIDs: make(map[apps.TaskID]bool), 33 | failDeregisterForIDs: make(map[service.ID]bool), 34 | consul: New(Config{Tag: tag, ConsulNameSeparator: "."}), 35 | } 36 | } 37 | 38 | func (c *Stub) GetAllServices() ([]*service.Service, error) { 39 | c.RLock() 40 | defer c.RUnlock() 41 | var allServices []*service.Service 42 | for _, s := range c.services { 43 | allServices = append(allServices, &service.Service{ 44 | ID: service.ID(s.ID), 45 | Name: s.Name, 46 | Tags: s.Tags, 47 | AgentAddress: s.Address, 48 | }) 49 | } 50 | return allServices, nil 51 | } 52 | 53 | func (c *Stub) FailGetServicesForName(failOnName string) { 54 | c.failGetServicesForNames[failOnName] = true 55 | } 56 | 57 | func (c *Stub) FailRegisterForID(taskID apps.TaskID) { 58 | c.failRegisterForIDs[taskID] = true 59 | } 60 | 61 | func (c *Stub) FailDeregisterByTaskForID(taskID apps.TaskID) { 62 | c.failDeregisterByTaskForIDs[taskID] = true 63 | } 64 | 65 | func (c *Stub) FailDeregisterForID(serviceID service.ID) { 66 | c.failDeregisterForIDs[serviceID] = true 67 | } 68 | 69 | func (c *Stub) GetServices(name string) ([]*service.Service, error) { 70 | c.RLock() 71 | defer c.RUnlock() 72 | if _, ok := c.failGetServicesForNames[name]; ok { 73 | return nil, fmt.Errorf("Consul stub programmed to fail when getting services for name %s", name) 74 | } 75 | var services []*service.Service 76 | for _, s := range c.services { 77 | if s.Name == name && contains(s.Tags, c.consul.config.Tag) { 78 | services = append(services, &service.Service{ 79 | ID: service.ID(s.ID), 80 | Name: s.Name, 81 | Tags: s.Tags, 82 | AgentAddress: s.Address, 83 | }) 84 | } 85 | } 86 | return services, nil 87 | } 88 | 89 | func (c *Stub) Register(task *apps.Task, app *apps.App) error { 90 | c.Lock() 91 | defer c.Unlock() 92 | if _, ok := c.failRegisterForIDs[task.ID]; ok { 93 | return fmt.Errorf("Consul stub programmed to fail when registering task of id %s", task.ID.String()) 94 | } 95 | serviceRegistrations, err := c.consul.marathonTaskToConsulServices(task, app) 96 | if err != nil { 97 | return err 98 | } 99 | for _, r := range serviceRegistrations { 100 | c.services[service.ID(r.ID)] = r 101 | } 102 | return nil 103 | } 104 | 105 | func (c *Stub) RegisterWithoutMarathonTaskTag(task *apps.Task, app *apps.App) { 106 | c.Lock() 107 | defer c.Unlock() 108 | for _, intent := range app.RegistrationIntents(task, c.consul.config.ConsulNameSeparator) { 109 | serviceRegistration := consulapi.AgentServiceRegistration{ 110 | ID: task.ID.String(), 111 | Name: intent.Name, 112 | Port: intent.Port, 113 | Address: task.Host, 114 | Tags: intent.Tags, 115 | Checks: consulapi.AgentServiceChecks{}, 116 | } 117 | c.services[service.ID(serviceRegistration.ID)] = &serviceRegistration 118 | } 119 | } 120 | 121 | func (c *Stub) RegisterOnlyFirstRegistrationIntent(task *apps.Task, app *apps.App) { 122 | c.Lock() 123 | defer c.Unlock() 124 | serviceRegistrations, _ := c.consul.marathonTaskToConsulServices(task, app) 125 | c.services[service.ID(serviceRegistrations[0].ID)] = serviceRegistrations[0] 126 | } 127 | 128 | func (c *Stub) DeregisterByTask(taskID apps.TaskID) error { 129 | c.Lock() 130 | defer c.Unlock() 131 | if _, ok := c.failDeregisterByTaskForIDs[taskID]; ok { 132 | return fmt.Errorf("Consul stub programmed to fail when deregistering task of id %s", taskID.String()) 133 | } 134 | for _, x := range c.servicesMatchingTask(taskID) { 135 | delete(c.services, service.ID(x.ID)) 136 | } 137 | return nil 138 | } 139 | 140 | func (c *Stub) Deregister(toDeregister *service.Service) error { 141 | c.Lock() 142 | defer c.Unlock() 143 | if _, ok := c.failDeregisterForIDs[toDeregister.ID]; ok { 144 | return fmt.Errorf("Consul stub programmed to fail when deregistering service of id %s", toDeregister.ID) 145 | } 146 | delete(c.services, toDeregister.ID) 147 | return nil 148 | } 149 | 150 | func (c *Stub) servicesMatchingTask(taskID apps.TaskID) []*consulapi.AgentServiceRegistration { 151 | matching := []*consulapi.AgentServiceRegistration{} 152 | for _, s := range c.services { 153 | if s.ID == taskID.String() || contains(s.Tags, fmt.Sprintf("marathon-task:%s", taskID.String())) { 154 | matching = append(matching, s) 155 | } 156 | } 157 | return matching 158 | } 159 | 160 | func (c *Stub) RegisteredTaskIDs(serviceName string) []apps.TaskID { 161 | services, _ := c.GetServices(serviceName) 162 | taskIds := []apps.TaskID{} 163 | for _, s := range services { 164 | taskID, _ := s.TaskID() 165 | taskIds = append(taskIds, taskID) 166 | } 167 | return taskIds 168 | } 169 | -------------------------------------------------------------------------------- /consul/consul_stub_test.go: -------------------------------------------------------------------------------- 1 | package consul 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/allegro/marathon-consul/apps" 7 | "github.com/allegro/marathon-consul/utils" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestConsulStub(t *testing.T) { 12 | t.Parallel() 13 | // given 14 | consul := NewConsulStub() 15 | app := utils.ConsulApp("test", 3) 16 | services, err := consul.GetAllServices() 17 | assert.NoError(t, err) 18 | testServices, err := consul.GetServices("test") 19 | assert.NoError(t, err) 20 | 21 | // then 22 | assert.Empty(t, services) 23 | assert.Empty(t, testServices) 24 | 25 | // when 26 | for _, task := range app.Tasks { 27 | err = consul.Register(&task, app) 28 | assert.NoError(t, err) 29 | } 30 | services, err = consul.GetAllServices() 31 | assert.NoError(t, err) 32 | testServices, err = consul.GetServices("test") 33 | assert.NoError(t, err) 34 | 35 | // then 36 | assert.Len(t, services, 3) 37 | assert.Len(t, testServices, 3) 38 | 39 | // when 40 | err = consul.DeregisterByTask(app.Tasks[1].ID) 41 | services, _ = consul.GetAllServices() 42 | taskIds := consul.RegisteredTaskIDs("test") 43 | 44 | // then 45 | assert.NoError(t, err) 46 | assert.Len(t, services, 2) 47 | assert.Contains(t, taskIds, apps.TaskID("test.0")) 48 | assert.Contains(t, taskIds, apps.TaskID("test.2")) 49 | 50 | // given 51 | consul.FailDeregisterByTaskForID(app.Tasks[0].ID) 52 | 53 | // when 54 | err = consul.DeregisterByTask(app.Tasks[0].ID) 55 | 56 | // then 57 | assert.Error(t, err) 58 | 59 | // given 60 | consul.FailRegisterForID(app.Tasks[0].ID) 61 | 62 | // when 63 | err = consul.Register(&app.Tasks[0], app) 64 | 65 | // then 66 | assert.Error(t, err) 67 | 68 | // when 69 | err = consul.DeregisterByTask(app.Tasks[2].ID) 70 | 71 | // then 72 | assert.NoError(t, err) 73 | assert.Len(t, consul.RegisteredTaskIDs("test"), 1) 74 | 75 | // when 76 | app = utils.ConsulApp("other", 2) 77 | for _, task := range app.Tasks { 78 | consul.Register(&task, app) 79 | } 80 | services, _ = consul.GetAllServices() 81 | testServices, _ = consul.GetServices("test") 82 | otherServices, _ := consul.GetServices("other") 83 | 84 | // then 85 | assert.Len(t, services, 3) 86 | assert.Len(t, testServices, 1) 87 | assert.Len(t, otherServices, 2) 88 | } 89 | -------------------------------------------------------------------------------- /consul/consul_test_server.go: -------------------------------------------------------------------------------- 1 | package consul 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "testing" 7 | "time" 8 | 9 | "github.com/hashicorp/consul/testutil" 10 | "github.com/stretchr/testify/assert" 11 | 12 | timeutil "github.com/allegro/marathon-consul/time" 13 | ) 14 | 15 | func CreateTestServer(t *testing.T) *testutil.TestServer { 16 | server, err := testutil.NewTestServerConfig(func(c *testutil.TestServerConfig) { 17 | c.Datacenter = fmt.Sprint("dc-", time.Now().UnixNano()) 18 | c.Ports = testPortConfig(t) 19 | }) 20 | 21 | assert.NoError(t, err) 22 | 23 | return server 24 | } 25 | 26 | func CreateTestServerDatacenter(t *testing.T, dc string) *testutil.TestServer { 27 | server, err := testutil.NewTestServerConfig(func(c *testutil.TestServerConfig) { 28 | c.Datacenter = dc 29 | c.Ports = testPortConfig(t) 30 | }) 31 | 32 | assert.NoError(t, err) 33 | 34 | return server 35 | } 36 | 37 | const MasterToken = "masterToken" 38 | 39 | func CreateSecuredTestServer(t *testing.T) *testutil.TestServer { 40 | server, err := testutil.NewTestServerConfig(func(c *testutil.TestServerConfig) { 41 | c.Datacenter = fmt.Sprint("dc-", time.Now().UnixNano()) 42 | c.Ports = testPortConfig(t) 43 | c.ACLDatacenter = c.Datacenter 44 | c.ACLDefaultPolicy = "deny" 45 | c.ACLMasterToken = MasterToken 46 | }) 47 | 48 | assert.NoError(t, err) 49 | 50 | return server 51 | } 52 | func testPortConfig(t *testing.T) *testutil.TestPortConfig { 53 | ports, err := getPorts(5) 54 | assert.NoError(t, err) 55 | 56 | return &testutil.TestPortConfig{ 57 | DNS: ports[0], 58 | HTTP: ports[1], 59 | SerfLan: ports[2], 60 | SerfWan: ports[3], 61 | Server: ports[4], 62 | } 63 | } 64 | 65 | // Ask the kernel for free open ports that are ready to use 66 | func getPorts(number int) ([]int, error) { 67 | ports := make([]int, number) 68 | listener := make([]*net.TCPListener, number) 69 | defer func() { 70 | for _, l := range listener { 71 | _ = l.Close() 72 | } 73 | 74 | }() 75 | for i := 0; i < number; i++ { 76 | addr, err := net.ResolveTCPAddr("tcp", "localhost:0") 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | listener[i], err = net.ListenTCP("tcp", addr) 82 | if err != nil { 83 | return nil, err 84 | } 85 | ports[i] = listener[i].Addr().(*net.TCPAddr).Port 86 | } 87 | return ports, nil 88 | } 89 | 90 | func ClientAtServer(server *testutil.TestServer) *Consul { 91 | return clientAtServer(server, true) 92 | } 93 | 94 | func ClientAtRemoteServer(server *testutil.TestServer) *Consul { 95 | return clientAtServer(server, false) 96 | } 97 | 98 | func clientAtServer(server *testutil.TestServer, local bool) *Consul { 99 | return consulClientAtAddress(server.Config.Bind, server.Config.Ports.HTTP, local) 100 | } 101 | 102 | func SecuredClientAtServer(server *testutil.TestServer) *Consul { 103 | return secureConsulClientAtAddress(server.Config.Bind, server.Config.Ports.HTTP) 104 | } 105 | 106 | func FailingClient() *Consul { 107 | host, port := "192.0.2.5", 5555 108 | config := Config{ 109 | Port: fmt.Sprintf("%d", port), 110 | ConsulNameSeparator: ".", 111 | EnableTagOverride: true, 112 | } 113 | consul := New(config) 114 | // initialize the agents cache with a single client pointing at provided location 115 | _ = consul.AddAgent(host) 116 | return consul 117 | } 118 | 119 | func consulClientAtAddress(host string, port int, local bool) *Consul { 120 | localAgent := "" 121 | if local { 122 | localAgent = host 123 | } 124 | config := Config{ 125 | Timeout: timeutil.Interval{Duration: 10 * time.Second}, 126 | Port: fmt.Sprintf("%d", port), 127 | ConsulNameSeparator: ".", 128 | EnableTagOverride: true, 129 | LocalAgentHost: localAgent, 130 | } 131 | consul := New(config) 132 | // initialize the agents cache with a single client pointing at provided location 133 | _ = consul.AddAgent(host) 134 | return consul 135 | } 136 | 137 | func secureConsulClientAtAddress(host string, port int) *Consul { 138 | config := Config{ 139 | Timeout: timeutil.Interval{Duration: 10 * time.Second}, 140 | Port: fmt.Sprintf("%d", port), 141 | ConsulNameSeparator: ".", 142 | EnableTagOverride: true, 143 | LocalAgentHost: host, 144 | Token: MasterToken, 145 | } 146 | consul := New(config) 147 | // initialize the agents cache with a single client pointing at provided location 148 | _ = consul.AddAgent(host) 149 | return consul 150 | } 151 | -------------------------------------------------------------------------------- /debian/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "Consul": { 3 | "Auth": { 4 | "Enabled": false, 5 | "Username": "", 6 | "Password": "" 7 | }, 8 | "ConsulNameSeparator": ".", 9 | "Port": "8500", 10 | "SslEnabled": false, 11 | "SslVerify": true, 12 | "SslCert": "", 13 | "SslCaCert": "", 14 | "Token": "", 15 | "Tag": "marathon", 16 | "Timeout": "3s", 17 | "AgentFailuresTolerance": 3, 18 | "RequestRetries": 5, 19 | "IgnoredHealthChecks": "", 20 | "EnableTagOverride": false, 21 | "LocalAgentHost": "" 22 | }, 23 | "Web": { 24 | "Listen": ":4000", 25 | "QueueSize": 1000, 26 | "WorkersCount": 10, 27 | "MaxEventSize": 4096 28 | }, 29 | "SSE": { 30 | "Retries": 0, 31 | "RetryBackoff": "0s" 32 | }, 33 | "Sync": { 34 | "Enabled": true, 35 | "Interval": "15m0s", 36 | "Leader": "", 37 | "Force": false 38 | }, 39 | "Marathon": { 40 | "Location": "localhost:8080", 41 | "Protocol": "http", 42 | "Username": "", 43 | "Password": "", 44 | "VerifySsl": true, 45 | "Timeout": "30s" 46 | }, 47 | "Metrics": { 48 | "Target": "stdout", 49 | "Prefix": "default", 50 | "Interval": "30s", 51 | "Addr": "" 52 | }, 53 | "Log": { 54 | "Level": "info", 55 | "Format": "text", 56 | "File": "", 57 | "Sentry": { 58 | "DSN": "", 59 | "Env": "", 60 | "Timeout": "1s", 61 | "Level": "error" 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /debian/marathon-consul.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Marathon-consul service (performs Marathon Tasks registration as Consul Services for service discovery) 3 | Requires=network-online.target 4 | After=network-online.target 5 | 6 | [Service] 7 | ExecStart=/usr/bin/marathon-consul --config-file=/etc/marathon-consul.d/config.json 8 | ExecReload=/bin/kill -HUP $MAINPID 9 | Restart=on-failure 10 | KillSignal=SIGINT 11 | 12 | [Install] 13 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /debian/marathon-consul.upstart: -------------------------------------------------------------------------------- 1 | description "Marathon-consul service (performs Marathon Tasks registration as Consul Services for service discovery)" 2 | 3 | start on runlevel [2345] 4 | stop on runlevel [!2345] 5 | 6 | respawn 7 | 8 | script 9 | echo $$ > /var/run/marathon-consul.pid 10 | exec marathon-consul --config-file=/etc/marathon-consul.d/config.json 11 | end script 12 | 13 | post-stop script 14 | rm -f /var/run/marathon-consul.pid 15 | end script 16 | -------------------------------------------------------------------------------- /events/event_handler.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "time" 8 | 9 | log "github.com/sirupsen/logrus" 10 | 11 | "github.com/allegro/marathon-consul/apps" 12 | "github.com/allegro/marathon-consul/marathon" 13 | "github.com/allegro/marathon-consul/metrics" 14 | "github.com/allegro/marathon-consul/service" 15 | ) 16 | 17 | type Event struct { 18 | Timestamp time.Time 19 | EventType string 20 | Body []byte 21 | } 22 | 23 | type EventHandler struct { 24 | id int 25 | serviceRegistry service.Registry 26 | marathon marathon.Marathoner 27 | eventQueue <-chan Event 28 | } 29 | 30 | type StopEvent struct{} 31 | 32 | const ( 33 | StatusUpdateEventType = "status_update_event" 34 | HealthStatusChangedEventType = "health_status_changed_event" 35 | EmptyEventType = "" 36 | ) 37 | 38 | func NewEventHandler(id int, serviceRegistry service.Registry, marathon marathon.Marathoner, eventQueue <-chan Event) *EventHandler { 39 | return &EventHandler{ 40 | id: id, 41 | serviceRegistry: serviceRegistry, 42 | marathon: marathon, 43 | eventQueue: eventQueue, 44 | } 45 | } 46 | 47 | func (fh *EventHandler) Start() chan<- StopEvent { 48 | var e Event 49 | process := func() { 50 | err := fh.handleEvent(e.EventType, e.Body) 51 | if err != nil { 52 | metrics.Mark("events.processing.error") 53 | } else { 54 | metrics.Mark("events.processing.succes") 55 | } 56 | } 57 | 58 | quitChan := make(chan StopEvent) 59 | log.WithField("Id", fh.id).Println("Starting worker") 60 | go func() { 61 | for { 62 | select { 63 | case e = <-fh.eventQueue: 64 | metrics.Mark(fmt.Sprintf("events.handler.%d", fh.id)) 65 | 66 | queueLength := int64(len(fh.eventQueue)) 67 | metrics.UpdateGauge("events.queue.len", queueLength) 68 | queueCapacity := int64(cap(fh.eventQueue)) 69 | 70 | utilization := int64(0) 71 | if queueCapacity > 0 { 72 | utilization = 100 * (queueLength / queueCapacity) 73 | } 74 | metrics.UpdateGauge("events.queue.util", utilization) 75 | 76 | metrics.UpdateGauge("events.queue.delay_ns", time.Since(e.Timestamp).Nanoseconds()) 77 | metrics.Time("events.processing."+e.EventType, process) 78 | case <-quitChan: 79 | log.WithField("Id", fh.id).Info("Stopping worker") 80 | } 81 | } 82 | }() 83 | return quitChan 84 | } 85 | 86 | func (fh *EventHandler) handleEvent(eventType string, body []byte) error { 87 | 88 | body = replaceTaskIDWithID(body) 89 | 90 | switch eventType { 91 | case StatusUpdateEventType: 92 | return fh.handleStatusEvent(body) 93 | case HealthStatusChangedEventType: 94 | return fh.handleHealthyTask(body) 95 | case EmptyEventType: 96 | err := errors.New("Empty event type") 97 | log.WithError(err).Warn("Event type is empty. " + 98 | "This means event was not properly serialized. " + 99 | "This can ocure when connection with Marathon breaks " + 100 | "due to network error or Marathon restarts.") 101 | return err 102 | default: 103 | err := fmt.Errorf("Unsuported event type: %s", eventType) 104 | log.WithError(err).WithField("EventType", eventType).Error("This should never happen. Not handled event type") 105 | return err 106 | } 107 | } 108 | 109 | func (fh *EventHandler) handleHealthyTask(body []byte) error { 110 | taskHealthChange, err := ParseTaskHealthChange(body) 111 | if err != nil { 112 | log.WithError(err).Error("Body generated error") 113 | return err 114 | } 115 | delay := taskHealthChange.Timestamp.Delay() 116 | metrics.UpdateGauge("events.read.delay.current", int64(delay)) 117 | 118 | appID := taskHealthChange.AppID 119 | taskID := taskHealthChange.TaskID() 120 | log.WithField("Id", taskID).Info("Got HealthStatusEvent") 121 | 122 | if !taskHealthChange.Alive { 123 | log.WithField("Id", taskID).Debug("Task is not alive. Not registering") 124 | return nil 125 | } 126 | 127 | app, err := fh.marathon.App(appID) 128 | if err != nil { 129 | log.WithField("Id", taskID).WithError(err).Error("There was a problem obtaining app info") 130 | return err 131 | } 132 | 133 | if !app.IsConsulApp() { 134 | err = fmt.Errorf("%s is not consul app. Missing consul label", app.ID) 135 | log.WithField("Id", taskID).WithError(err).Debug("Skipping app registration in Consul") 136 | return nil 137 | } 138 | 139 | tasks := app.Tasks 140 | 141 | task, found := apps.FindTaskByID(taskID, tasks) 142 | if !found { 143 | log.WithField("Id", taskID).Error("Task not found") 144 | return err 145 | } 146 | 147 | if task.IsHealthy() { 148 | err := fh.serviceRegistry.Register(&task, app) 149 | if err != nil { 150 | log.WithField("Id", task.ID).WithError(err).Error("There was a problem registering task") 151 | return err 152 | } 153 | return nil 154 | } 155 | log.WithField("Id", task.ID).Debug("Task is not healthy. Not registering") 156 | return nil 157 | } 158 | 159 | func (fh *EventHandler) handleStatusEvent(body []byte) error { 160 | task, err := apps.ParseTask(body) 161 | if err != nil { 162 | log.WithError(err).WithField("Body", body).Error("Could not parse event body") 163 | return err 164 | } 165 | delay := task.Timestamp.Delay() 166 | metrics.UpdateGauge("events.read.delay.current", int64(delay)) 167 | 168 | log.WithFields(log.Fields{ 169 | "Id": task.ID, 170 | "TaskStatus": task.TaskStatus, 171 | }).Info("Got StatusEvent") 172 | 173 | switch task.TaskStatus { 174 | case "TASK_FINISHED", "TASK_FAILED", "TASK_KILLING", "TASK_KILLED", "TASK_LOST": 175 | return fh.deregister(task.ID) 176 | default: 177 | log.WithFields(log.Fields{ 178 | "Id": task.ID, 179 | "taskStatus": task.TaskStatus, 180 | }).Debug("Not handled task status") 181 | return nil 182 | } 183 | } 184 | 185 | func (fh *EventHandler) deregister(taskID apps.TaskID) error { 186 | err := fh.serviceRegistry.DeregisterByTask(taskID) 187 | if err != nil { 188 | log.WithField("Id", taskID).WithError(err).Error("There was a problem deregistering task") 189 | } 190 | return err 191 | } 192 | 193 | // for every other use of Tasks, Marathon uses the "id" field for the task ID. 194 | // Here, it uses "taskId", with most of the other fields being equal. We'll 195 | // just swap "taskId" for "id" in the body so that we can successfully parse 196 | // incoming events. 197 | func replaceTaskIDWithID(body []byte) []byte { 198 | return bytes.Replace(body, []byte("taskId"), []byte("id"), -1) 199 | } 200 | -------------------------------------------------------------------------------- /events/event_handler_test.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | "time" 7 | 8 | "github.com/allegro/marathon-consul/apps" 9 | "github.com/allegro/marathon-consul/consul" 10 | "github.com/allegro/marathon-consul/marathon" 11 | "github.com/allegro/marathon-consul/service" 12 | . "github.com/allegro/marathon-consul/utils" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | type handlerStubs struct { 17 | serviceRegistry service.Registry 18 | marathon marathon.Marathoner 19 | } 20 | 21 | // Creates EventHandler and returns nonbuffered event queue that has to be used to send events to handler and 22 | // function that can be used as a synchronization point to wait until previous event has been processed. 23 | // Under the hood synchronization function simply sends a stop signal to the handlers stopChan. 24 | func testEventHandler(stubs handlerStubs) (chan<- Event, func()) { 25 | queue := make(chan Event) 26 | awaitChan := NewEventHandler(0, stubs.serviceRegistry, stubs.marathon, queue).Start() 27 | 28 | return queue, func() { awaitChan <- StopEvent{} } 29 | } 30 | 31 | func TestEventHandler_NotHandleStatusEventWithInvalidBody(t *testing.T) { 32 | t.Parallel() 33 | 34 | // given 35 | serviceRegistry := consul.NewConsulStub() 36 | queue, awaitFunc := testEventHandler(handlerStubs{serviceRegistry: serviceRegistry}) 37 | 38 | body := []byte(`{ 39 | "slaveId":"85e59460-a99e-4f16-b91f-145e0ea595bd-S0", 40 | "taskId":"python_simple.4a7e99d0-9cc1-11e5-b4d8-0a0027000004", 41 | "taskStatus":"TASK_KILLED", 42 | "message":"", 43 | "appId":"/test/app", 44 | "host":"localhost", 45 | "ports": 31372, 46 | "version":"2015-12-07T09:02:48.981Z", 47 | "eventType":"status_update_event", 48 | "timestamp":"2015-12-07T09:02:49.934Z" 49 | }`) 50 | 51 | // when 52 | queue <- Event{EventType: "status_update_event", Timestamp: time.Now(), Body: body} 53 | awaitFunc() 54 | 55 | // then 56 | assert.Empty(t, serviceRegistry.RegisteredTaskIDs("test_app")) 57 | } 58 | 59 | func TestEventHandler_NotHandleStatusEventAboutStartingTask(t *testing.T) { 60 | t.Parallel() 61 | 62 | // given 63 | serviceRegistry := consul.NewConsulStub() 64 | queue, awaitFunc := testEventHandler(handlerStubs{serviceRegistry: serviceRegistry}) 65 | 66 | ignoredTaskStatuses := []string{"TASK_STAGING", "TASK_STARTING", "TASK_RUNNING", "unknown"} 67 | for _, taskStatus := range ignoredTaskStatuses { 68 | body := []byte(`{ 69 | "slaveId":"85e59460-a99e-4f16-b91f-145e0ea595bd-S0", 70 | "taskId":"python_simple.4a7e99d0-9cc1-11e5-b4d8-0a0027000004", 71 | "taskStatus":"` + taskStatus + `", 72 | "message":"", 73 | "appId":"/test/app", 74 | "host":"localhost", 75 | "ports":[ 76 | 31372 77 | ], 78 | "version":"2015-12-07T09:02:48.981Z", 79 | "eventType":"status_update_event", 80 | "timestamp":"2015-12-07T09:02:49.934Z" 81 | }`) 82 | 83 | // when 84 | queue <- Event{EventType: "status_update_event", Timestamp: time.Now(), Body: body} 85 | awaitFunc() 86 | 87 | // then 88 | assert.Empty(t, serviceRegistry.RegisteredTaskIDs("test_app")) 89 | } 90 | } 91 | 92 | func TestEventHandler_HandleStatusEventAboutDeadTask(t *testing.T) { 93 | t.Parallel() 94 | 95 | // given 96 | serviceRegistry := consul.NewConsulStub() 97 | 98 | queue, awaitFunc := testEventHandler(handlerStubs{serviceRegistry: serviceRegistry}) 99 | 100 | taskStatuses := []string{"TASK_FINISHED", "TASK_FAILED", "TASK_KILLED", "TASK_LOST"} 101 | for index, taskStatus := range taskStatuses { 102 | // given 103 | appId := "/test/app" + strconv.Itoa(index) 104 | serviceName := "test.app" + strconv.Itoa(index) 105 | 106 | app := ConsulApp(appId, 3) 107 | for _, task := range app.Tasks { 108 | serviceRegistry.Register(&task, app) 109 | } 110 | 111 | body := []byte(`{ 112 | "slaveId":"85e59460-a99e-4f16-b91f-145e0ea595bd-S0", 113 | "taskId":"` + app.Tasks[1].ID.String() + `", 114 | "taskStatus":"` + taskStatus + `", 115 | "message":"Command terminated with signal Terminated", 116 | "appId":"` + appId + `", 117 | "host":"localhost", 118 | "ports":[ 119 | 31372 120 | ], 121 | "version":"2015-12-07T09:02:48.981Z", 122 | "eventType":"status_update_event", 123 | "timestamp":"2015-12-07T09:33:40.898Z" 124 | }`) 125 | 126 | // when 127 | queue <- Event{EventType: "status_update_event", Timestamp: time.Now(), Body: body} 128 | awaitFunc() 129 | 130 | // then 131 | taskIds := serviceRegistry.RegisteredTaskIDs(serviceName) 132 | assert.Len(t, taskIds, 2) 133 | assert.NotContains(t, taskIds, app.Tasks[1].ID) 134 | assert.Contains(t, taskIds, app.Tasks[0].ID) 135 | assert.Contains(t, taskIds, app.Tasks[2].ID) 136 | } 137 | } 138 | 139 | func TestEventHandler_HandleStatusEventAboutDeadTaskErrOnDeregistration(t *testing.T) { 140 | t.Parallel() 141 | 142 | // given 143 | serviceRegistry := consul.NewConsulStub() 144 | serviceRegistry.FailDeregisterByTaskForID("task.1") 145 | 146 | queue, awaitFunc := testEventHandler(handlerStubs{serviceRegistry: serviceRegistry}) 147 | 148 | body := []byte(`{ 149 | "slaveId":"85e59460-a99e-4f16-b91f-145e0ea595bd-S0", 150 | "taskId":"task.1", 151 | "taskStatus":"TASK_KILLED", 152 | "message":"Command terminated with signal Terminated", 153 | "appId":"/test/app", 154 | "host":"localhost", 155 | "ports":[ 156 | 31372 157 | ], 158 | "version":"2015-12-07T09:02:48.981Z", 159 | "eventType":"status_update_event", 160 | "timestamp":"2015-12-07T09:33:40.898Z" 161 | }`) 162 | 163 | // when 164 | queue <- Event{EventType: "status_update_event", Timestamp: time.Now(), Body: body} 165 | awaitFunc() 166 | 167 | // then 168 | assert.Empty(t, serviceRegistry.RegisteredTaskIDs("test_app")) 169 | } 170 | 171 | func TestEventHandler_NotHandleStatusEventAboutNonConsulAppsDeadTask(t *testing.T) { 172 | t.Parallel() 173 | 174 | // given 175 | app := NonConsulApp("/test/app", 3) 176 | marathon := marathon.MarathonerStubForApps(app) 177 | serviceRegistry := consul.NewConsulStub() 178 | 179 | queue, awaitFunc := testEventHandler(handlerStubs{serviceRegistry: serviceRegistry, marathon: marathon}) 180 | 181 | taskStatuses := []string{"TASK_FINISHED", "TASK_FAILED", "TASK_KILLED", "TASK_LOST"} 182 | for _, taskStatus := range taskStatuses { 183 | body := []byte(`{ 184 | "slaveId":"85e59460-a99e-4f16-b91f-145e0ea595bd-S0", 185 | "taskId":"` + app.Tasks[1].ID.String() + `", 186 | "taskStatus":"` + taskStatus + `", 187 | "message":"Command terminated with signal Terminated", 188 | "appId":"/test/app", 189 | "host":"localhost", 190 | "ports":[ 191 | 31372 192 | ], 193 | "version":"2015-12-07T09:02:48.981Z", 194 | "eventType":"status_update_event", 195 | "timestamp":"2015-12-07T09:33:40.898Z" 196 | }`) 197 | 198 | // when 199 | queue <- Event{EventType: "status_update_event", Timestamp: time.Now(), Body: body} 200 | awaitFunc() 201 | 202 | // then 203 | assert.False(t, marathon.Interactions()) 204 | } 205 | } 206 | 207 | func TestEventHandler_NotHandleHealthStatusEventWhenAppHasNotConsulLabel(t *testing.T) { 208 | t.Parallel() 209 | 210 | // given 211 | app := NonConsulApp("/test/app", 3) 212 | marathon := marathon.MarathonerStubForApps(app) 213 | 214 | queue, awaitFunc := testEventHandler(handlerStubs{marathon: marathon}) 215 | 216 | body := healthStatusChangeEventForTask("test_app.1") 217 | 218 | // when 219 | queue <- Event{EventType: "health_status_changed_event", Timestamp: time.Now(), Body: body} 220 | awaitFunc() 221 | 222 | // then 223 | assert.True(t, marathon.Interactions()) 224 | } 225 | 226 | func TestEventHandler_HandleHealthStatusEvent(t *testing.T) { 227 | t.Parallel() 228 | 229 | // given 230 | app := ConsulApp("/test/app", 3) 231 | 232 | marathon := marathon.MarathonerStubForApps(app) 233 | serviceRegistry := consul.NewConsulStub() 234 | 235 | queue, awaitFunc := testEventHandler(handlerStubs{serviceRegistry: serviceRegistry, marathon: marathon}) 236 | body := healthStatusChangeEventForTask("test_app.1") 237 | 238 | // when 239 | queue <- Event{EventType: "health_status_changed_event", Timestamp: time.Now(), Body: body} 240 | awaitFunc() 241 | 242 | // then 243 | taskIds := serviceRegistry.RegisteredTaskIDs("test.app") 244 | assert.Len(t, taskIds, 1) 245 | assert.Contains(t, taskIds, app.Tasks[1].ID) 246 | assert.True(t, marathon.Interactions()) 247 | } 248 | 249 | func TestEventHandler_HandleHealthStatusEventWithErrorsOnRegistration(t *testing.T) { 250 | t.Parallel() 251 | 252 | // given 253 | app := ConsulApp("/test/app", 3) 254 | 255 | marathon := marathon.MarathonerStubForApps(app) 256 | serviceRegistry := consul.NewConsulStub() 257 | serviceRegistry.FailRegisterForID(app.Tasks[1].ID) 258 | 259 | queue, awaitFunc := testEventHandler(handlerStubs{serviceRegistry: serviceRegistry, marathon: marathon}) 260 | body := healthStatusChangeEventForTask("test_app.1") 261 | 262 | // when 263 | queue <- Event{EventType: "health_status_changed_event", Timestamp: time.Now(), Body: body} 264 | awaitFunc() 265 | 266 | // then 267 | assert.Empty(t, serviceRegistry.RegisteredTaskIDs("test.app")) 268 | assert.True(t, marathon.Interactions()) 269 | } 270 | 271 | func TestEventHandler_NotHandleHealthStatusEventForTaskWithNotAllHealthChecksPassed(t *testing.T) { 272 | t.Parallel() 273 | 274 | // given 275 | app := ConsulApp("/test/app", 3) 276 | app.Tasks[1].HealthCheckResults = []apps.HealthCheckResult{{Alive: true}, {Alive: false}} 277 | marathon := marathon.MarathonerStubForApps(app) 278 | 279 | queue, awaitFunc := testEventHandler(handlerStubs{marathon: marathon}) 280 | body := healthStatusChangeEventForTask("test_app.1") 281 | 282 | // when 283 | queue <- Event{EventType: "health_status_changed_event", Timestamp: time.Now(), Body: body} 284 | awaitFunc() 285 | 286 | // then 287 | assert.True(t, marathon.Interactions()) 288 | } 289 | 290 | func TestEventHandler_NotHandleHealthStatusEventForTaskWithNoHealthCheck(t *testing.T) { 291 | t.Parallel() 292 | 293 | // given 294 | app := ConsulApp("/test/app", 1) 295 | app.Tasks[0].HealthCheckResults = []apps.HealthCheckResult{} 296 | marathon := marathon.MarathonerStubForApps(app) 297 | 298 | queue, awaitFunc := testEventHandler(handlerStubs{marathon: marathon}) 299 | 300 | body := healthStatusChangeEventForTask("/test/app.0") 301 | 302 | // when 303 | queue <- Event{EventType: "health_status_changed_event", Timestamp: time.Now(), Body: body} 304 | awaitFunc() 305 | 306 | // then 307 | assert.True(t, marathon.Interactions()) 308 | } 309 | 310 | func TestEventHandler_NotHandleHealthStatusEventWhenTaskIsNotAlive(t *testing.T) { 311 | t.Parallel() 312 | 313 | // given 314 | app := ConsulApp("/test/app", 1) 315 | marathon := marathon.MarathonerStubForApps(app) 316 | 317 | queue, awaitFunc := testEventHandler(handlerStubs{marathon: marathon}) 318 | 319 | body := []byte(`{ 320 | "appId":"/test/app", 321 | "taskId":"test_app.1", 322 | "version":"2015-12-07T09:02:48.981Z", 323 | "alive":false, 324 | "eventType":"health_status_changed_event", 325 | "timestamp":"2015-12-07T09:33:50.069Z" 326 | }`) 327 | 328 | // when 329 | queue <- Event{EventType: "health_status_changed_event", Timestamp: time.Now(), Body: body} 330 | awaitFunc() 331 | 332 | // then 333 | assert.False(t, marathon.Interactions()) 334 | } 335 | 336 | func TestEventHandler_NotHandleHealthStatusEventWhenBodyIsInvalid(t *testing.T) { 337 | t.Parallel() 338 | 339 | // given 340 | app := ConsulApp("/test/app", 1) 341 | marathon := marathon.MarathonerStubForApps(app) 342 | 343 | queue, awaitFunc := testEventHandler(handlerStubs{marathon: marathon}) 344 | 345 | body := []byte(`{ 346 | "appId":"/test/app", 347 | "taskId":"test_app.1", 348 | "version":123, 349 | "alive":false, 350 | "eventType":"health_status_changed_event", 351 | "timestamp":"2015-12-07T09:33:50.069Z" 352 | }`) 353 | 354 | // when 355 | queue <- Event{EventType: "health_status_changed_event", Timestamp: time.Now(), Body: body} 356 | awaitFunc() 357 | 358 | // then 359 | assert.False(t, marathon.Interactions()) 360 | } 361 | 362 | func TestEventHandler_HandleHealthStatusEventReturn202WhenMarathonReturnedError(t *testing.T) { 363 | t.Parallel() 364 | 365 | // given 366 | app := ConsulApp("/test/app", 3) 367 | marathon := marathon.MarathonerStubForApps(app) 368 | 369 | queue, awaitFunc := testEventHandler(handlerStubs{marathon: marathon}) 370 | 371 | body := []byte(`{ 372 | "appId":"unknown", 373 | "taskId":"unknown.1", 374 | "version":"2015-12-07T09:02:48.981Z", 375 | "alive":true, 376 | "eventType":"health_status_changed_event", 377 | "timestamp":"2015-12-07T09:33:50.069Z" 378 | }`) 379 | 380 | // when 381 | queue <- Event{EventType: "health_status_changed_event", Timestamp: time.Now(), Body: body} 382 | awaitFunc() 383 | 384 | // then 385 | assert.True(t, marathon.Interactions()) 386 | } 387 | 388 | func TestEventHandler_HandleHealthStatusEventWhenTaskIsNotInMarathon(t *testing.T) { 389 | t.Parallel() 390 | 391 | // given 392 | app := ConsulApp("/test/app", 3) 393 | marathon := marathon.MarathonerStubForApps(app) 394 | 395 | queue, awaitFunc := testEventHandler(handlerStubs{marathon: marathon}) 396 | 397 | body := healthStatusChangeEventForTask("unknown.1") 398 | 399 | // when 400 | queue <- Event{EventType: "health_status_changed_event", Timestamp: time.Now(), Body: body} 401 | awaitFunc() 402 | 403 | // then 404 | assert.True(t, marathon.Interactions()) 405 | } 406 | 407 | func healthStatusChangeEventForTask(taskID string) []byte { 408 | return []byte(`{ 409 | "appId":"/test/app", 410 | "taskId":"` + taskID + `", 411 | "version":"2015-12-07T09:02:48.981Z", 412 | "alive":true, 413 | "eventType":"health_status_changed_event", 414 | "timestamp":"2015-12-07T09:33:50.069Z" 415 | }`) 416 | } 417 | -------------------------------------------------------------------------------- /events/sse_events.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "io" 8 | ) 9 | 10 | // Event holds state of parsed fields from marathon EventStream 11 | type SSEEvent struct { 12 | Type string 13 | Body []byte 14 | ID string 15 | Delay string 16 | } 17 | 18 | var ( 19 | lineFeed = []byte("\n") 20 | colon = []byte{':'} 21 | space = []byte{' '} 22 | ) 23 | 24 | func (e *SSEEvent) parseLine(line []byte) bool { 25 | // https://www.w3.org/TR/2011/WD-eventsource-20110208/ 26 | // Quote: Lines must be separated by either a U+000D CARRIAGE RETURN U+000A 27 | // LINE FEED (CRLF) character pair, a single U+000A LINE FEED (LF) character, 28 | // or a single U+000D CARRIAGE RETURN (CR) character. 29 | 30 | //If the line is empty (a blank line) 31 | if len(line) == 0 || bytes.Equal(line, lineFeed) { 32 | //Dispatch the event, as defined below. 33 | return !e.isEmpty() 34 | } 35 | 36 | //If the line starts with a U+003A COLON character (:) 37 | if bytes.HasPrefix(line, colon) { 38 | //Ignore the line. 39 | return false 40 | } 41 | 42 | var field string 43 | var value []byte 44 | //If the line contains a U+003A COLON character (:) 45 | //Collect the characters on the line before the first U+003A COLON character (:), and let field be that string. 46 | split := bytes.SplitN(line, colon, 2) 47 | if len(split) == 2 { 48 | field = string(split[0]) 49 | //Collect the characters on the line after the first U+003A COLON character (:), and let value be that string. 50 | //If value starts with a U+0020 SPACE character, remove it from value. 51 | value = bytes.TrimPrefix(split[1], space) 52 | } else { 53 | //Otherwise, the string is not empty but does not contain a U+003A COLON character (:) 54 | //Process the field using the steps described below, using the whole line as the field name, 55 | //and the empty string as the field value. 56 | field = string(line) 57 | value = []byte{} 58 | 59 | } 60 | stringValue := string(value) 61 | //If the field name is 62 | switch field { 63 | case "event": 64 | //Set the event name buffer to field value. 65 | e.Type = stringValue 66 | case "data": 67 | //If the data buffer is not the empty string, 68 | if len(value) != 0 { 69 | //Append the field value to the data buffer, 70 | //then append a single U+000A LINE FEED (LF) character to the data buffer. 71 | e.Body = append(e.Body, value...) 72 | e.Body = append(e.Body, '\n') 73 | } 74 | case "id": 75 | //Set the last event ID buffer to the field value. 76 | e.ID = stringValue 77 | case "retry": 78 | e.Delay = stringValue 79 | // TODO consider reconnection delay 80 | } 81 | 82 | return false 83 | } 84 | 85 | func (e *SSEEvent) isEmpty() bool { 86 | return e.Type == "" && e.Body == nil && e.ID == "" 87 | } 88 | 89 | func (e *SSEEvent) String() string { 90 | return fmt.Sprintf("Type: %s, Body: %s", e.Type, string(e.Body)) 91 | } 92 | 93 | func ParseSSEEvent(scanner *bufio.Scanner) (SSEEvent, error) { 94 | e := SSEEvent{} 95 | 96 | for dispatch := false; !dispatch; { 97 | if !scanner.Scan() { 98 | return e, io.EOF 99 | } 100 | line := scanner.Bytes() 101 | dispatch = e.parseLine(line) 102 | if err := scanner.Err(); err != nil { 103 | return e, scanner.Err() 104 | } 105 | } 106 | return e, nil 107 | } 108 | 109 | // ScanLines is higtly inspired by the function of the same name from bufio package, 110 | // but is sensitive to CR as line separator 111 | func ScanLines(data []byte, atEOF bool) (advance int, token []byte, err error) { 112 | if atEOF && len(data) == 0 { 113 | return 0, nil, nil 114 | } 115 | pos := lineTerminatorPosition(data) 116 | if pos != 0 { 117 | return pos + 1, dropCR(data[0:pos]), nil 118 | } 119 | // If we're at EOF, we have a final, non-terminated line. Return it. 120 | if atEOF { 121 | return len(data), dropCR(data), nil 122 | } 123 | // Request more data. 124 | return 0, nil, nil 125 | } 126 | 127 | func lineTerminatorPosition(data []byte) int { 128 | // https://www.w3.org/TR/2011/WD-eventsource-20110208/ 129 | // Quote: Lines must be separated by either a U+000D CARRIAGE RETURN U+000A 130 | // LINE FEED (CRLF) character pair, a single U+000A LINE FEED (LF) character, 131 | // or a single U+000D CARRIAGE RETURN (CR) character. 132 | if i := bytes.IndexByte(data, '\n'); i >= 0 { 133 | // We have a full newline-terminated line 134 | return i 135 | } else if i := bytes.IndexByte(data, '\r'); i >= 0 { 136 | // We have a full CR terminated line 137 | return i 138 | } 139 | return 0 140 | } 141 | 142 | // dropCR drops a terminal \r from the data. 143 | func dropCR(data []byte) []byte { 144 | if len(data) > 0 && data[len(data)-1] == '\r' { 145 | return data[0 : len(data)-1] 146 | } 147 | return data 148 | } 149 | -------------------------------------------------------------------------------- /events/sse_events_test.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestEvent_IfEventIsEmptyReturnsFalse(t *testing.T) { 15 | t.Parallel() 16 | // given 17 | event := &SSEEvent{ 18 | Type: "status_update_event", 19 | Body: []byte(`{"id": "simpleId"}`), 20 | ID: "id", 21 | } 22 | // when 23 | expected := false 24 | actual := event.isEmpty() 25 | // then 26 | assert.Equal(t, expected, actual) 27 | } 28 | 29 | func TestEvent_IfEventIsEmptyReturnsTrue(t *testing.T) { 30 | t.Parallel() 31 | // given 32 | event := &SSEEvent{} 33 | // when 34 | expected := true 35 | actual := event.isEmpty() 36 | // then 37 | assert.Equal(t, expected, actual) 38 | } 39 | 40 | func TestParseLine_WhenStautsUpdateEventPassed(t *testing.T) { 41 | t.Parallel() 42 | // given 43 | event := &SSEEvent{} 44 | line0 := []byte("id: 0") 45 | line1 := []byte("event: status_update_event") 46 | line2 := []byte("data: testData") 47 | expected0 := "0" 48 | expected1 := "status_update_event" 49 | expected2 := []byte("testData\n") 50 | // when 51 | event.parseLine(line0) 52 | event.parseLine(line1) 53 | event.parseLine(line2) 54 | // then 55 | assert.Equal(t, expected0, event.ID) 56 | assert.Equal(t, expected1, event.Type) 57 | assert.Equal(t, string(expected2), string(event.Body)) 58 | } 59 | 60 | func TestParseLine_WhenGarbageIsProvidedBodyShouldBeNil(t *testing.T) { 61 | t.Parallel() 62 | // given 63 | event := &SSEEvent{} 64 | line := []byte("garbage data") 65 | expectedBody := []byte(nil) 66 | // when 67 | _ = event.parseLine(line) 68 | // then 69 | assert.Equal(t, expectedBody, event.Body) 70 | } 71 | 72 | func BenchmarkParseLine(b *testing.B) { 73 | // given 74 | longTestData := bytes.Repeat([]byte("testData"), 1) 75 | longLine := append([]byte("data: "), longTestData...) 76 | expectedEvent := &SSEEvent{Body: append(longTestData, []byte("\n")...)} 77 | 78 | var event *SSEEvent 79 | for i := 0; i <= b.N; i++ { 80 | event = &SSEEvent{} 81 | event.parseLine(longLine) 82 | } 83 | 84 | // then 85 | assert.Equal(b, string(expectedEvent.Body), string(event.Body)) 86 | 87 | } 88 | 89 | var parseEventCases = []struct { 90 | in string 91 | expectedEvent SSEEvent 92 | }{ 93 | {": No Event", SSEEvent{}}, 94 | {"event: status_update_event\ndata: testData\n", 95 | SSEEvent{Type: "status_update_event", Body: []byte("testData\n")}, 96 | }, 97 | {"event: status_update_event\ndata: testData\ndummydata", 98 | SSEEvent{Type: "status_update_event", Body: []byte("testData\n")}, 99 | }, 100 | {"event: status_update_event\ndata", 101 | SSEEvent{Type: "status_update_event"}, 102 | }, 103 | {"event: status_update_event\ndata:\n", 104 | SSEEvent{Type: "status_update_event"}, 105 | }, 106 | {"event: some_event\ndata: abc\ndata: def", 107 | SSEEvent{Type: "some_event", Body: []byte("abc\ndef\n")}, 108 | }, 109 | {"event: some_event\ndata: aaa\ndata: ccc\ndata: 10", 110 | SSEEvent{Type: "some_event", Body: []byte("aaa\nccc\n10\n")}, 111 | }, 112 | {"event: some_event\ndata: abc\nid: 12", 113 | SSEEvent{Type: "some_event", Body: []byte("abc\n"), ID: "12"}, 114 | }, 115 | {"data: abc\n", 116 | SSEEvent{Body: []byte("abc\n")}, 117 | }, 118 | } 119 | 120 | func TestParseSSEEvent_TestCases(t *testing.T) { 121 | t.Parallel() 122 | 123 | for _, testCase := range parseEventCases { 124 | reader := strings.NewReader(testCase.in) 125 | sscanner := bufio.NewScanner(reader) 126 | // when 127 | actualEvent, _ := ParseSSEEvent(sscanner) 128 | // then 129 | assert.Equal(t, testCase.expectedEvent, actualEvent) 130 | 131 | } 132 | } 133 | 134 | var parseEventMultipleDataCases = []struct { 135 | in string 136 | expectedEvents []SSEEvent 137 | }{ 138 | {"\n\n\n\n\n\n\n", 139 | []SSEEvent{ 140 | {}, 141 | }, 142 | }, 143 | {"event: status_update_event\ndata: testData\n\nevent: some_event\ndata: someData", 144 | []SSEEvent{ 145 | {Type: "status_update_event", Body: []byte("testData\n")}, 146 | {Type: "some_event", Body: []byte("someData\n")}, 147 | }, 148 | }, 149 | {"event: status_update_event\ndata: testData\n\nid: 13\ndata: someData\n\nid: 14\ndata: abc\n\nid: 15\ndata: def\n", 150 | []SSEEvent{ 151 | {Type: "status_update_event", Body: []byte("testData\n")}, 152 | {ID: "13", Body: []byte("someData\n")}, 153 | {ID: "14", Body: []byte("abc\n")}, 154 | {ID: "15", Body: []byte("def\n")}, 155 | }, 156 | }, 157 | {"data: testData\n\ndata: someData\n\ndata: abc\n\ndata: def\n", 158 | []SSEEvent{ 159 | {Body: []byte("testData\n")}, 160 | {Body: []byte("someData\n")}, 161 | {Body: []byte("abc\n")}, 162 | {Body: []byte("def\n")}, 163 | }, 164 | }, 165 | {"data: testData\nretry: 10\ndummy: dummy field\n\ndata: someData\n\ndata: abc\n\ndata: def\n", 166 | []SSEEvent{ 167 | {Body: []byte("testData\n"), Delay: "10"}, 168 | {Body: []byte("someData\n")}, 169 | {Body: []byte("abc\n")}, 170 | {Body: []byte("def\n")}, 171 | }, 172 | }, 173 | } 174 | 175 | func TestParseSSEEvent_MultipleDataTestCases(t *testing.T) { 176 | t.Parallel() 177 | 178 | for _, testCase := range parseEventMultipleDataCases { 179 | 180 | reader := strings.NewReader(testCase.in) 181 | sscanner := bufio.NewScanner(reader) 182 | 183 | var err error 184 | var actualEvent SSEEvent 185 | for i := 0; len(testCase.expectedEvents) > i && err != fmt.Errorf("EOF"); i++ { 186 | // when 187 | actualEvent, err = ParseSSEEvent(sscanner) 188 | // then 189 | assert.Equal(t, testCase.expectedEvents[i], actualEvent) 190 | } 191 | 192 | } 193 | } 194 | 195 | func BenchmarkParseEvent(b *testing.B) { 196 | var event SSEEvent 197 | expectedEvent := SSEEvent{ 198 | Type: "some event type", 199 | ID: "1", 200 | Body: []byte("some data\nnext data\n"), 201 | Delay: "10", 202 | } 203 | 204 | for i := 0; i <= b.N; i++ { 205 | reader := strings.NewReader("event: some event type\nid: 1\ndata: some data\ndata: next data\nretry: 10\n") 206 | sscanner := bufio.NewScanner(reader) 207 | event, _ = ParseSSEEvent(sscanner) 208 | } 209 | 210 | assert.Equal(b, expectedEvent, event) 211 | 212 | } 213 | 214 | func TestParseSSEEvent_WhenVeryLongLineIsOnStream(t *testing.T) { 215 | t.Parallel() 216 | // given 217 | veryLongEventName := strings.Repeat("a", 1016) 218 | veryLongLine := fmt.Sprintf("event: %s\n", veryLongEventName) 219 | 220 | sreader := strings.NewReader(veryLongLine) 221 | sscanner := bufio.NewScanner(sreader) 222 | expectedEventType := veryLongEventName 223 | // when 224 | event, _ := ParseSSEEvent(sscanner) 225 | // then 226 | assert.Equal(t, expectedEventType, event.Type) 227 | } 228 | 229 | func TestParseSSEEvent_WhenVeryLongLineIsLongerThanMaxLineSize(t *testing.T) { 230 | t.Parallel() 231 | // given 232 | veryLongEventName := strings.Repeat("a", 10240) 233 | veryLongLine := fmt.Sprintf("event: %s\n\n", veryLongEventName) 234 | sreader := strings.NewReader(veryLongLine) 235 | sscanner := bufio.NewScanner(sreader) 236 | buffer := make([]byte, 1024) 237 | sscanner.Buffer(buffer, cap(buffer)) 238 | expectedEventType := "" 239 | // when 240 | event, _ := ParseSSEEvent(sscanner) 241 | // then 242 | assert.Equal(t, expectedEventType, event.Type) 243 | } 244 | 245 | var scanLineCases = []struct { 246 | in []byte 247 | atEOL bool 248 | expectedAdvance int 249 | expectedToken []byte 250 | }{ 251 | {[]byte("abcd\n"), false, 5, []byte("abcd")}, 252 | {[]byte("abcd\r"), false, 5, []byte("abcd")}, 253 | {[]byte("abcd\r\n"), false, 6, []byte("abcd")}, 254 | {[]byte("abcd"), false, 0, []byte(nil)}, 255 | {[]byte("abcd"), true, 4, []byte("abcd")}, 256 | {[]byte("abcd\n"), true, 5, []byte("abcd")}, 257 | } 258 | 259 | func TestScanLine_TestCases(t *testing.T) { 260 | t.Parallel() 261 | for _, testCase := range scanLineCases { 262 | // when 263 | advance, token, err := ScanLines(testCase.in, testCase.atEOL) 264 | // then 265 | require.NoError(t, err) 266 | assert.Equal(t, testCase.expectedAdvance, advance) 267 | assert.Equal(t, testCase.expectedToken, token) 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /events/task_health_change.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "regexp" 7 | 8 | "github.com/allegro/marathon-consul/apps" 9 | "github.com/allegro/marathon-consul/time" 10 | ) 11 | 12 | type TaskHealthChange struct { 13 | Timestamp time.Timestamp `json:"timestamp"` 14 | // Prefer TaskID() instead of ID 15 | ID apps.TaskID `json:"id"` 16 | InstanceID string `json:"instanceId"` 17 | AppID apps.AppID `json:"appId"` 18 | Version string `json:"version"` 19 | Alive bool `json:"alive"` 20 | } 21 | 22 | // Regular expression to extract runSpecId from instanceId 23 | // See: https://github.com/mesosphere/marathon/blob/v1.4.0-RC4/src/main/scala/mesosphere/marathon/core/instance/Instance.scala#L244 24 | var instanceIDRegex = regexp.MustCompile(`^(.+)\.(instance-|marathon-)([^\.]+)$`) 25 | 26 | func (t TaskHealthChange) TaskID() apps.TaskID { 27 | if t.ID != "" { 28 | return t.ID 29 | } 30 | return apps.TaskID(instanceIDRegex.ReplaceAllString(t.InstanceID, "$1.$3")) 31 | } 32 | 33 | func ParseTaskHealthChange(event []byte) (*TaskHealthChange, error) { 34 | task := &TaskHealthChange{} 35 | err := json.Unmarshal(event, task) 36 | 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | // Marathon 1.4 changes this event so it does not contain TaskID. 42 | // We need to validate this event if it contains required fields. 43 | // See: https://phabricator.mesosphere.com/D218#10153 44 | if task.ID == "" && task.InstanceID == "" { 45 | return nil, errors.New("Missing task ID") 46 | } 47 | 48 | return task, nil 49 | } 50 | -------------------------------------------------------------------------------- /events/task_health_change_test.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | gotime "time" 7 | 8 | "github.com/allegro/marathon-consul/time" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func StringToTimestamp(date string) time.Timestamp { 13 | t, err := gotime.Parse(gotime.RFC3339Nano, date) 14 | if err != nil { 15 | return time.Timestamp{Time: gotime.Time{}} 16 | } 17 | return time.Timestamp{Time: t} 18 | } 19 | 20 | var testHealthChange = &TaskHealthChange{ 21 | Timestamp: StringToTimestamp("2014-03-01T23:29:30.158Z"), 22 | ID: "my-app_0-1396592784349", 23 | Alive: true, 24 | AppID: "/my-app", 25 | Version: "2014-04-04T06:26:23.051Z", 26 | } 27 | 28 | func TestHealthChangeParseTask(t *testing.T) { 29 | t.Parallel() 30 | 31 | jsonified, err := json.Marshal(testHealthChange) 32 | assert.Nil(t, err) 33 | 34 | service, err := ParseTaskHealthChange(jsonified) 35 | assert.Nil(t, err) 36 | 37 | assert.Equal(t, testHealthChange.Timestamp, service.Timestamp) 38 | assert.Equal(t, testHealthChange.ID, service.ID) 39 | assert.Equal(t, testHealthChange.Alive, service.Alive) 40 | assert.Equal(t, testHealthChange.AppID, service.AppID) 41 | assert.Equal(t, testHealthChange.Version, service.Version) 42 | } 43 | 44 | func TestHealthChangeParseTaskWithoutData(t *testing.T) { 45 | t.Parallel() 46 | 47 | event, err := ParseTaskHealthChange([]byte("{}")) 48 | assert.Nil(t, event) 49 | assert.EqualError(t, err, "Missing task ID") 50 | } 51 | 52 | func TestHealthChangeParseTaskWithBrokenJson(t *testing.T) { 53 | t.Parallel() 54 | 55 | event, err := ParseTaskHealthChange([]byte("not a Json")) 56 | assert.Nil(t, event) 57 | assert.Error(t, err) 58 | } 59 | 60 | func TestInstanceIDToTaskID(t *testing.T) { 61 | t.Parallel() 62 | 63 | ids := []string{ 64 | "python1.marathon-0bf4660a-cdc0-11e6-87df-0242ee53bf4b", 65 | "python1.instance-0bf4660a-cdc0-11e6-87df-0242ee53bf4b", 66 | "python1.0bf4660a-cdc0-11e6-87df-0242ee53bf4b", 67 | } 68 | 69 | for _, id := range ids { 70 | instance := TaskHealthChange{InstanceID: id} 71 | assert.Equal(t, "python1.0bf4660a-cdc0-11e6-87df-0242ee53bf4b", instance.TaskID().String()) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/allegro/marathon-consul 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/cyberdelia/go-metrics-graphite v0.0.0-20161219230853-39f87cc3b432 7 | github.com/evalphobia/logrus_sentry v0.4.2 8 | github.com/getsentry/raven-go v0.0.0-20170614100719-d175f85701df 9 | github.com/hashicorp/consul v1.2.0 10 | github.com/ogier/pflag v0.0.1 11 | github.com/rcrowley/go-metrics v0.0.0-20160718165337-bdb33529eca3 12 | github.com/sirupsen/logrus v1.4.2 13 | github.com/stretchr/testify v1.4.0 14 | ) 15 | 16 | require ( 17 | github.com/armon/go-metrics v0.3.10 // indirect 18 | github.com/certifi/gocertifi v0.0.0-20170417193930-a9c833d2837d // indirect 19 | github.com/davecgh/go-spew v1.1.1 // indirect 20 | github.com/hashicorp/go-cleanhttp v0.5.0 // indirect 21 | github.com/hashicorp/go-msgpack v1.1.5 // indirect 22 | github.com/hashicorp/go-rootcerts v0.0.0-20160503143440-6bb64b370b90 // indirect 23 | github.com/hashicorp/go-uuid v1.0.0 // indirect 24 | github.com/hashicorp/memberlist v0.3.1 // indirect 25 | github.com/hashicorp/serf v0.8.2-0.20170714182601-bbeddf0b3ab3 // indirect 26 | github.com/konsorten/go-windows-terminal-sequences v1.0.1 // indirect 27 | github.com/kr/pretty v0.1.0 // indirect 28 | github.com/mitchellh/go-homedir v0.0.0-20161203194507-b8bc1bf76747 // indirect 29 | github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77 // indirect 30 | github.com/mitchellh/mapstructure v0.0.0-20180511142126-bb74f1db0675 // indirect 31 | github.com/pascaldekloe/goe v0.1.0 // indirect 32 | github.com/pkg/errors v0.8.1 // indirect 33 | github.com/pmezard/go-difflib v1.0.0 // indirect 34 | golang.org/x/sys v0.0.0-20200122134326-e047566fdf82 // indirect 35 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect 36 | gopkg.in/yaml.v2 v2.2.5 // indirect 37 | ) 38 | -------------------------------------------------------------------------------- /golangcilinter.yaml: -------------------------------------------------------------------------------- 1 | run: 2 | aggregate: true 3 | concurrency: 2 4 | cyclo: 14 5 | deadline: "300s" 6 | skip-files: 7 | - ".*_string.go" 8 | - ".*_test.go" 9 | linters: 10 | disable-all: true 11 | enable: 12 | - deadcode 13 | - errcheck 14 | - gocyclo 15 | - goimports 16 | - golint 17 | - gosimple 18 | - govet 19 | 20 | -------------------------------------------------------------------------------- /install_consul.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | export PATH=./bin:$PATH 4 | 5 | hash consul 2>/dev/null || { 6 | echo "Installing consul..." 7 | 8 | os=`uname -s | awk '{print tolower($0)}'` 9 | arch=`uname -m | awk '{print tolower($0)}'` 10 | 11 | if [ $os = 'linux' ] ; then 12 | if [ $arch = 'x86_64' ] ; then 13 | arch="amd64" 14 | else 15 | arch="386" 16 | fi 17 | elif [ $os = 'darwin' ] ; then 18 | arch="amd64" 19 | else 20 | os="windows" 21 | arch="386" 22 | fi 23 | version="1.2.0" 24 | archive="consul_${version}_${os}_${arch}.zip" 25 | 26 | mkdir -p bin 27 | curl -OLs https://releases.hashicorp.com/consul/$version/$archive 28 | unzip -q "$archive" -d bin 29 | rm "$archive" 30 | consul --version 31 | } 32 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/allegro/marathon-consul/config" 7 | "github.com/allegro/marathon-consul/consul" 8 | "github.com/allegro/marathon-consul/marathon" 9 | "github.com/allegro/marathon-consul/metrics" 10 | "github.com/allegro/marathon-consul/sentry" 11 | "github.com/allegro/marathon-consul/sse" 12 | "github.com/allegro/marathon-consul/sync" 13 | "github.com/allegro/marathon-consul/web" 14 | log "github.com/sirupsen/logrus" 15 | ) 16 | 17 | var VERSION string 18 | 19 | func main() { 20 | log.WithField("Version", VERSION).Info("Starting marathon-consul") 21 | 22 | config, err := config.New() 23 | if err != nil { 24 | log.Fatal(err.Error()) 25 | } 26 | 27 | config.Log.Sentry.Release = VERSION 28 | if sentryErr := sentry.Init(config.Log.Sentry); sentryErr != nil { 29 | log.Fatal(sentryErr) 30 | } 31 | 32 | err = metrics.Init(config.Metrics) 33 | if err != nil { 34 | log.Fatal(err.Error()) 35 | } 36 | 37 | consulInstance := consul.New(config.Consul) 38 | // TODO(tz) - move Leader from sync module to highest level config, access like config.Leader 39 | remote, err := marathon.New(config.Marathon) 40 | if err != nil { 41 | log.Fatal(err.Error()) 42 | } 43 | 44 | sync.New(config.Sync, remote, consulInstance, consulInstance.AddAgentsFromApps).StartSyncServicesJob() 45 | 46 | //TODO: Use context instead of stop function. 47 | var stopSSE sse.Stop 48 | go func() { 49 | stopSSE, err = sse.NewHandler(config.SSE, config.Web, remote, consulInstance) 50 | if err != nil { 51 | log.WithError(err).Fatal("Cannot instantiate SSE handler") 52 | } 53 | }() 54 | defer stopSSE() 55 | 56 | http.HandleFunc("/health", web.HealthHandler) 57 | 58 | log.WithField("Port", config.Web.Listen).Info("Listening") 59 | log.Fatal(http.ListenAndServe(config.Web.Listen, nil)) 60 | } 61 | -------------------------------------------------------------------------------- /marathon/config.go: -------------------------------------------------------------------------------- 1 | package marathon 2 | 3 | import "github.com/allegro/marathon-consul/time" 4 | 5 | type Config struct { 6 | Location string 7 | Protocol string 8 | Username string 9 | Password string 10 | Leader string 11 | VerifySsl bool 12 | Timeout time.Interval 13 | } 14 | -------------------------------------------------------------------------------- /marathon/marathon.go: -------------------------------------------------------------------------------- 1 | package marathon 2 | 3 | import ( 4 | "crypto/tls" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | "net/url" 10 | "os" 11 | "strings" 12 | "time" 13 | 14 | "github.com/allegro/marathon-consul/apps" 15 | "github.com/allegro/marathon-consul/metrics" 16 | log "github.com/sirupsen/logrus" 17 | ) 18 | 19 | type Marathoner interface { 20 | ConsulApps() ([]*apps.App, error) 21 | App(apps.AppID) (*apps.App, error) 22 | Tasks(apps.AppID) ([]apps.Task, error) 23 | Leader() (string, error) 24 | EventStream([]string, int, time.Duration) (*Streamer, error) 25 | IsLeader() (bool, error) 26 | } 27 | 28 | type Marathon struct { 29 | Location string 30 | Protocol string 31 | MyLeader string 32 | username string 33 | password string 34 | client *http.Client 35 | } 36 | 37 | type LeaderResponse struct { 38 | Leader string `json:"leader"` 39 | } 40 | 41 | func New(config Config) (*Marathon, error) { 42 | transport := &http.Transport{ 43 | Proxy: http.ProxyFromEnvironment, 44 | TLSClientConfig: &tls.Config{ 45 | InsecureSkipVerify: !config.VerifySsl, 46 | }, 47 | } 48 | // TODO(tz) - consider passing desiredEvents as config 49 | return &Marathon{ 50 | Location: config.Location, 51 | Protocol: config.Protocol, 52 | MyLeader: config.Leader, 53 | username: config.Username, 54 | password: config.Password, 55 | client: &http.Client{ 56 | Transport: transport, 57 | Timeout: config.Timeout.Duration, 58 | }, 59 | }, nil 60 | } 61 | 62 | func (m Marathon) App(appID apps.AppID) (*apps.App, error) { 63 | log.WithField("Location", m.Location).Debug("Asking Marathon for " + appID) 64 | 65 | body, err := m.get(m.urlWithQuery(fmt.Sprintf("/v2/apps/%s", appID), params{"embed": []string{"apps.tasks"}})) 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | return apps.ParseApp(body) 71 | } 72 | 73 | func (m Marathon) ConsulApps() ([]*apps.App, error) { 74 | log.WithField("Location", m.Location).Debug("Asking Marathon for apps") 75 | body, err := m.get(m.urlWithQuery("/v2/apps", params{"embed": []string{"apps.tasks"}, "label": []string{apps.MarathonConsulLabel}})) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | return apps.ParseApps(body) 81 | } 82 | 83 | func (m Marathon) Tasks(app apps.AppID) ([]apps.Task, error) { 84 | log.WithFields(log.Fields{ 85 | "Location": m.Location, 86 | "Id": app, 87 | }).Debug("asking Marathon for tasks") 88 | 89 | trimmedAppID := strings.Trim(app.String(), "/") 90 | body, err := m.get(m.url(fmt.Sprintf("/v2/apps/%s/tasks", trimmedAppID))) 91 | if err != nil { 92 | return nil, err 93 | } 94 | 95 | return apps.ParseTasks(body) 96 | } 97 | 98 | func (m Marathon) Leader() (string, error) { 99 | log.WithField("Location", m.Location).Debug("Asking Marathon for leader") 100 | 101 | body, err := m.get(m.url("/v2/leader")) 102 | if err != nil { 103 | return "", err 104 | } 105 | 106 | leaderResponse := &LeaderResponse{} 107 | err = json.Unmarshal(body, leaderResponse) 108 | 109 | return leaderResponse.Leader, err 110 | } 111 | 112 | // EventStream method creates Streamer handler which is configured based on marathon 113 | // client and credentials. 114 | func (m Marathon) EventStream(desiredEvents []string, retries int, retryBackoff time.Duration) (*Streamer, error) { 115 | subURL := m.urlWithQuery("/v2/events", params{"event_type": desiredEvents}) 116 | 117 | // Before creating actual streamer, this function blocks until configured leader for this receiver is elected. 118 | // When leaderPoll function successfully exit this instance of marathon-consul, 119 | // consider itself as a new leader and initializes Streamer. 120 | if err := m.leaderPoll(); err != nil { 121 | return nil, fmt.Errorf("Leader poll failed: %s", err) 122 | } 123 | 124 | return &Streamer{ 125 | subURL: subURL, 126 | username: m.username, 127 | password: m.password, 128 | client: &http.Client{ 129 | Transport: m.client.Transport, 130 | }, 131 | retries: retries, 132 | retryBackoff: retryBackoff, 133 | }, nil 134 | } 135 | 136 | // leaderPoll just blocks until configured myleader is equal to 137 | // leader returned from marathon (/v2/leader endpoint) 138 | func (m Marathon) leaderPoll() error { 139 | pollTicker := time.NewTicker(1 * time.Second) 140 | defer pollTicker.Stop() 141 | retries := 5 142 | i := 0 143 | for range pollTicker.C { 144 | leading, err := m.IsLeader() 145 | if err != nil { 146 | if i >= retries { 147 | return fmt.Errorf("Failed to get a leader after %d retries", i) 148 | } 149 | i++ 150 | continue 151 | } 152 | if leading { 153 | metrics.UpdateGauge("leader", int64(1)) 154 | return nil 155 | } 156 | metrics.UpdateGauge("leader", int64(0)) 157 | } 158 | return nil 159 | } 160 | 161 | func (m Marathon) get(url string) ([]byte, error) { 162 | request, err := http.NewRequest("GET", url, nil) 163 | if err != nil { 164 | return nil, err 165 | } 166 | request.Header.Add("Accept", "application/json") 167 | request.Header.Set("User-Agent", "Marathon-Consul") 168 | 169 | log.WithFields(log.Fields{ 170 | "Uri": request.URL.RequestURI(), 171 | "Location": m.Location, 172 | "Protocol": m.Protocol, 173 | }).Debug("Sending GET request to marathon") 174 | 175 | request.SetBasicAuth(m.username, m.password) 176 | var response *http.Response 177 | metrics.Time("marathon.get", func() { response, err = m.client.Do(request) }) 178 | if err != nil { 179 | metrics.Mark("marathon.get.error") 180 | m.logHTTPError(response, err) 181 | return nil, err 182 | } 183 | defer response.Body.Close() 184 | if response.StatusCode != 200 { 185 | metrics.Mark("marathon.get.error") 186 | metrics.Mark(fmt.Sprintf("marathon.get.error.%d", response.StatusCode)) 187 | err = fmt.Errorf("Expected 200 but got %d for %s", response.StatusCode, response.Request.URL.Path) 188 | m.logHTTPError(response, err) 189 | return nil, err 190 | } 191 | 192 | return ioutil.ReadAll(response.Body) 193 | } 194 | 195 | func (m Marathon) logHTTPError(resp *http.Response, err error) { 196 | statusCode := "???" 197 | if resp != nil { 198 | statusCode = fmt.Sprintf("%d", resp.StatusCode) 199 | } 200 | 201 | log.WithFields(log.Fields{ 202 | "Location": m.Location, 203 | "Protocol": m.Protocol, 204 | "statusCode": statusCode, 205 | }).WithError(err).Warning("Error on http request") 206 | } 207 | 208 | func (m Marathon) url(path string) string { 209 | return m.urlWithQuery(path, nil) 210 | } 211 | 212 | type params map[string][]string 213 | 214 | // urlWithQuery returns absolute path to marathon endpoint 215 | // if location is given with path e.g. "localhost:8080/proxy/url", then 216 | // host and path parts are appended to respective url.URL fields 217 | func (m Marathon) urlWithQuery(path string, params params) string { 218 | var marathon url.URL 219 | if strings.Contains(m.Location, "/") { 220 | parts := strings.SplitN(m.Location, "/", 2) 221 | marathon = url.URL{ 222 | Scheme: m.Protocol, 223 | Host: parts[0], 224 | Path: "/" + parts[1] + path, 225 | } 226 | } else { 227 | marathon = url.URL{ 228 | Scheme: m.Protocol, 229 | Host: m.Location, 230 | Path: path, 231 | } 232 | } 233 | 234 | query := marathon.Query() 235 | for key, values := range params { 236 | for _, value := range values { 237 | query.Add(key, value) 238 | } 239 | } 240 | marathon.RawQuery = query.Encode() 241 | return marathon.String() 242 | } 243 | 244 | func (m *Marathon) IsLeader() (bool, error) { 245 | if m.MyLeader == "*" { 246 | log.Debug("Leader detection disable") 247 | return true, nil 248 | } 249 | if m.MyLeader == "" { 250 | if err := m.resolveHostname(); err != nil { 251 | return false, fmt.Errorf("Could not resolve hostname: %v", err) 252 | } 253 | } 254 | leader, err := m.Leader() 255 | return m.MyLeader == leader, err 256 | } 257 | 258 | func (m *Marathon) resolveHostname() error { 259 | hostname, err := os.Hostname() 260 | if err != nil { 261 | return err 262 | } 263 | 264 | u, err := url.Parse(m.url("")) 265 | if err != nil { 266 | return fmt.Errorf("Could not parse marathon location (%s): %v", m.Location, err) 267 | } 268 | 269 | m.MyLeader = fmt.Sprintf("%s:%s", hostname, u.Port()) 270 | log.WithField("Leader", m.MyLeader).Info("Marathon Leader mode set to resolved hostname") 271 | return nil 272 | } 273 | -------------------------------------------------------------------------------- /marathon/marathon_stub.go: -------------------------------------------------------------------------------- 1 | package marathon 2 | 3 | import ( 4 | "errors" 5 | "sync" 6 | "time" 7 | 8 | "github.com/allegro/marathon-consul/apps" 9 | ) 10 | 11 | type MarathonerStub struct { 12 | AppsStub []*apps.App 13 | AppStub map[apps.AppID]*apps.App 14 | TasksStub map[apps.AppID][]apps.Task 15 | MyLeader string 16 | leader string 17 | interactionsMu sync.RWMutex 18 | interactions bool 19 | } 20 | 21 | func (m *MarathonerStub) ConsulApps() ([]*apps.App, error) { 22 | m.noteInteraction() 23 | return m.AppsStub, nil 24 | } 25 | 26 | func (m *MarathonerStub) App(id apps.AppID) (*apps.App, error) { 27 | m.noteInteraction() 28 | if app, ok := m.AppStub[id]; ok { 29 | return app, nil 30 | } 31 | return nil, errors.New("app not found") 32 | } 33 | 34 | func (m *MarathonerStub) Tasks(appID apps.AppID) ([]apps.Task, error) { 35 | m.noteInteraction() 36 | if app, ok := m.TasksStub[appID]; ok { 37 | return app, nil 38 | } 39 | return nil, errors.New("app not found") 40 | } 41 | 42 | func (m *MarathonerStub) Leader() (string, error) { 43 | m.noteInteraction() 44 | return m.leader, nil 45 | } 46 | 47 | func (m *MarathonerStub) EventStream([]string, int, time.Duration) (*Streamer, error) { 48 | return &Streamer{}, nil 49 | } 50 | 51 | func (m *MarathonerStub) IsLeader() (bool, error) { 52 | return m.leader == m.MyLeader, nil 53 | } 54 | 55 | func (m *MarathonerStub) Interactions() bool { 56 | m.interactionsMu.RLock() 57 | defer m.interactionsMu.RUnlock() 58 | return m.interactions 59 | } 60 | 61 | func (m *MarathonerStub) noteInteraction() { 62 | m.interactionsMu.Lock() 63 | defer m.interactionsMu.Unlock() 64 | m.interactions = true 65 | } 66 | 67 | func MarathonerStubWithLeaderForApps(leader, myLeader string, args ...*apps.App) *MarathonerStub { 68 | stub := MarathonerStubForApps(args...) 69 | stub.leader = leader 70 | stub.MyLeader = myLeader 71 | return stub 72 | } 73 | 74 | func MarathonerStubForApps(args ...*apps.App) *MarathonerStub { 75 | appsMap := make(map[apps.AppID]*apps.App) 76 | tasksMap := make(map[apps.AppID][]apps.Task) 77 | 78 | for _, app := range args { 79 | appsMap[app.ID] = app 80 | tasks := []apps.Task{} 81 | for _, task := range app.Tasks { 82 | t := task 83 | tasks = append(tasks, t) 84 | } 85 | tasksMap[app.ID] = tasks 86 | } 87 | 88 | return &MarathonerStub{ 89 | AppsStub: args, 90 | AppStub: appsMap, 91 | TasksStub: tasksMap, 92 | MyLeader: "localhost:8080", 93 | leader: "localhost:8080", 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /marathon/marathon_stub_test.go: -------------------------------------------------------------------------------- 1 | package marathon_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/allegro/marathon-consul/marathon" 7 | "github.com/allegro/marathon-consul/utils" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestMarathonStub(t *testing.T) { 12 | t.Parallel() 13 | // given 14 | m := marathon.MarathonerStubWithLeaderForApps("some.host:1234", "some.host:1234", utils.ConsulApp("/test/app", 3)) 15 | // then 16 | assert.False(t, m.Interactions()) 17 | // when 18 | leader, _ := m.Leader() 19 | // then 20 | assert.True(t, m.Interactions()) 21 | assert.Equal(t, "some.host:1234", leader) 22 | // when 23 | apps, _ := m.ConsulApps() 24 | // then 25 | assert.Len(t, apps, 1) 26 | // when 27 | existingApp, _ := m.App("/test/app") 28 | // then 29 | assert.NotNil(t, existingApp) 30 | //when 31 | notExistingApp, errOnNotExistingApp := m.App("/not/existing/app") 32 | // then 33 | assert.Error(t, errOnNotExistingApp) 34 | assert.Nil(t, notExistingApp) 35 | // when 36 | existingTasks, _ := m.Tasks("/test/app") 37 | // then 38 | assert.Len(t, existingTasks, 3) 39 | // when 40 | notExistingTasks, errOnNotExistingTasks := m.Tasks("/not/existing/app") 41 | // then 42 | assert.Error(t, errOnNotExistingTasks) 43 | assert.Nil(t, notExistingTasks) 44 | } 45 | -------------------------------------------------------------------------------- /marathon/streamer.go: -------------------------------------------------------------------------------- 1 | package marathon 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | "time" 10 | 11 | log "github.com/sirupsen/logrus" 12 | ) 13 | 14 | type Streamer struct { 15 | Scanner *bufio.Scanner 16 | cancel context.CancelFunc 17 | client *http.Client 18 | username string 19 | password string 20 | subURL string 21 | retries int 22 | retryBackoff time.Duration 23 | noRecover bool 24 | } 25 | 26 | func (s *Streamer) Stop() { 27 | s.cancel() 28 | s.noRecover = true 29 | } 30 | 31 | func (s *Streamer) Start() error { 32 | req, err := http.NewRequest("GET", s.subURL, nil) 33 | if err != nil { 34 | return fmt.Errorf("Unable to create request: %s", err) 35 | } 36 | req.SetBasicAuth(s.username, s.password) 37 | req.Header.Set("Accept", "text/event-stream") 38 | ctx, cancel := context.WithCancel(context.Background()) 39 | s.cancel = cancel 40 | req = req.WithContext(ctx) 41 | res, err := s.client.Do(req) 42 | if err != nil { 43 | s.cancel() 44 | return fmt.Errorf("Subscription request errored: %s", err) 45 | } 46 | if res.StatusCode != http.StatusOK { 47 | return fmt.Errorf("Event stream not connected: Expected %d but got %d", http.StatusOK, res.StatusCode) 48 | } 49 | log.WithFields(log.Fields{ 50 | "Host": req.Host, 51 | "URI": req.URL.RequestURI(), 52 | "Method": "GET", 53 | }).Debug("Subsciption success") 54 | s.Scanner = bufio.NewScanner(res.Body) 55 | 56 | return nil 57 | } 58 | 59 | func (s *Streamer) Recover() error { 60 | if s.noRecover { 61 | return errors.New("Streamer is not recoverable") 62 | } 63 | s.cancel() 64 | 65 | err := s.Start() 66 | i := 0 67 | for ; err != nil && i <= s.retries; err = s.Start() { 68 | time.Sleep(s.retryBackoff) 69 | i++ 70 | } 71 | if err != nil { 72 | return fmt.Errorf("Cannot recover Streamer: %s", err) 73 | } 74 | return nil 75 | } 76 | -------------------------------------------------------------------------------- /marathon/streamer_test.go: -------------------------------------------------------------------------------- 1 | package marathon 2 | 3 | import ( 4 | "testing" 5 | 6 | "net/http" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestStreamer_RecoverShouldReturnErrorWhenCantStart(t *testing.T) { 12 | server, transport := stubServer("/v2/events", "") 13 | defer server.Close() 14 | client := &http.Client{Transport: transport} 15 | s := Streamer{client: client} 16 | s.Start() 17 | 18 | err := s.Recover() 19 | 20 | assert.EqualError(t, err, "Cannot recover Streamer: Subscription request errored: Get : unsupported protocol scheme \"\"") 21 | } 22 | 23 | func TestStreamer_StartShouldReturnErrorOnInvalidUrl(t *testing.T) { 24 | s := Streamer{client: http.DefaultClient} 25 | 26 | err := s.Start() 27 | 28 | assert.EqualError(t, err, "Subscription request errored: Get : unsupported protocol scheme \"\"") 29 | } 30 | 31 | func TestStreamer_StartShouldReturnErrorOnNon200Response(t *testing.T) { 32 | server, transport := stubServer("/v2/events", "") 33 | defer server.Close() 34 | client := &http.Client{Transport: transport} 35 | s := Streamer{client: client, subURL: "http://marathon/invalid/path"} 36 | 37 | err := s.Start() 38 | 39 | assert.EqualError(t, err, "Event stream not connected: Expected 200 but got 404") 40 | } 41 | 42 | func TestStreamer_StartShouldReturnNoErrorIfSuccessfulConnects(t *testing.T) { 43 | server, transport := stubServer("/v2/events", "") 44 | defer server.Close() 45 | client := &http.Client{Transport: transport} 46 | s := Streamer{client: client, subURL: "http://marathon/v2/events"} 47 | 48 | err := s.Start() 49 | 50 | assert.NoError(t, err) 51 | } 52 | 53 | func TestStreamer_RecoverShouldReturnNoErrorIfSuccessfulConnects(t *testing.T) { 54 | server, transport := stubServer("/v2/events", "") 55 | defer server.Close() 56 | client := &http.Client{Transport: transport} 57 | s := Streamer{client: client, subURL: "http://marathon/v2/events"} 58 | s.Start() 59 | 60 | err := s.Recover() 61 | 62 | assert.NoError(t, err) 63 | } 64 | 65 | func TestStreamer_StopShouldPreventRecovery(t *testing.T) { 66 | server, transport := stubServer("/v2/events", "") 67 | defer server.Close() 68 | client := &http.Client{Transport: transport} 69 | s := Streamer{client: client, subURL: "http://marathon/v2/events"} 70 | 71 | s.Start() 72 | s.Stop() 73 | err := s.Recover() 74 | 75 | assert.Error(t, err, "Streamer is not recoverable") 76 | } 77 | 78 | func TestStreamer_StopShouldCancelRequest(t *testing.T) { 79 | ready := make(chan bool) 80 | wait := make(chan bool) 81 | server, transport := mockServer(func(w http.ResponseWriter, r *http.Request) { 82 | ready <- true 83 | <-wait 84 | }) 85 | defer server.Close() 86 | client := &http.Client{Transport: transport} 87 | s := Streamer{client: client, subURL: "http://marathon/v2/events"} 88 | 89 | go func() { 90 | err := s.Start() 91 | assert.Error(t, err, "Subscription request errored: Get http://marathon/v2/events: net/http: request canceled") 92 | }() 93 | 94 | <-ready 95 | s.Stop() 96 | wait <- false 97 | } 98 | -------------------------------------------------------------------------------- /metrics/config.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import "github.com/allegro/marathon-consul/time" 4 | 5 | type Config struct { 6 | Target string 7 | Prefix string 8 | Interval time.Interval 9 | Addr string 10 | } 11 | -------------------------------------------------------------------------------- /metrics/metrics.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | //All credits to https://github.com/eBay/fabio/tree/master/metrics 4 | import ( 5 | "errors" 6 | "fmt" 7 | logger "log" 8 | "net" 9 | "net/url" 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | "time" 14 | 15 | graphite "github.com/cyberdelia/go-metrics-graphite" 16 | "github.com/rcrowley/go-metrics" 17 | log "github.com/sirupsen/logrus" 18 | ) 19 | 20 | var pfx string 21 | 22 | func Clear() { 23 | log.Info("Unregistering all metrics.") 24 | metrics.DefaultRegistry.UnregisterAll() 25 | } 26 | 27 | func Mark(name string) { 28 | meter := metrics.GetOrRegisterMeter(name, metrics.DefaultRegistry) 29 | meter.Mark(1) 30 | } 31 | 32 | func Time(name string, function func()) { 33 | timer := metrics.GetOrRegisterTimer(name, metrics.DefaultRegistry) 34 | timer.Time(function) 35 | } 36 | 37 | func UpdateGauge(name string, value int64) { 38 | gauge := metrics.GetOrRegisterGauge(name, metrics.DefaultRegistry) 39 | gauge.Update(value) 40 | } 41 | 42 | func Init(cfg Config) error { 43 | pfx = cfg.Prefix 44 | if pfx == "default" { 45 | prefix, err := defaultPrefix() 46 | if err != nil { 47 | return err 48 | } 49 | pfx = prefix 50 | } 51 | 52 | collectSystemMetrics() 53 | 54 | switch cfg.Target { 55 | case "stdout": 56 | log.Info("Sending metrics to stdout") 57 | return initStdout(cfg.Interval.Duration) 58 | case "graphite": 59 | if cfg.Addr == "" { 60 | return errors.New("metrics: graphite addr missing") 61 | } 62 | 63 | log.Infof("Sending metrics to Graphite on %s as %q", cfg.Addr, pfx) 64 | return initGraphite(cfg.Addr, cfg.Interval.Duration) 65 | case "": 66 | log.Infof("Metrics disabled") 67 | return nil 68 | default: 69 | return fmt.Errorf("Invalid metrics target %s", cfg.Target) 70 | } 71 | } 72 | 73 | func TargetName(service, host, path string, targetURL *url.URL) string { 74 | return strings.Join([]string{ 75 | clean(service), 76 | clean(host), 77 | clean(path), 78 | clean(targetURL.Host), 79 | }, ".") 80 | } 81 | 82 | func clean(s string) string { 83 | if s == "" { 84 | return "_" 85 | } 86 | s = strings.Replace(s, ".", "_", -1) 87 | s = strings.Replace(s, ":", "_", -1) 88 | return strings.ToLower(s) 89 | } 90 | 91 | // stubbed out for testing 92 | var hostname = os.Hostname 93 | 94 | func defaultPrefix() (string, error) { 95 | host, err := hostname() 96 | if err != nil { 97 | log.WithError(err).Error("Problem with detecting prefix") 98 | return "", err 99 | } 100 | exe := filepath.Base(os.Args[0]) 101 | return clean(host) + "." + clean(exe), nil 102 | } 103 | 104 | func initStdout(interval time.Duration) error { 105 | loggerInstance := logger.New(os.Stderr, "localhost: ", logger.Lmicroseconds) 106 | go metrics.Log(metrics.DefaultRegistry, interval, loggerInstance) 107 | return nil 108 | } 109 | 110 | func initGraphite(addr string, interval time.Duration) error { 111 | a, err := net.ResolveTCPAddr("tcp", addr) 112 | if err != nil { 113 | return fmt.Errorf("metrics: cannot connect to Graphite: %s", err) 114 | } 115 | 116 | go graphite.Graphite(metrics.DefaultRegistry, interval, pfx, a) 117 | return nil 118 | } 119 | -------------------------------------------------------------------------------- /metrics/metrics_test.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "os" 7 | "testing" 8 | 9 | "github.com/rcrowley/go-metrics" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestMark(t *testing.T) { 14 | // given 15 | Init(Config{Target: "stdout", Prefix: ""}) 16 | 17 | // expect 18 | assert.Nil(t, metrics.Get("marker")) 19 | 20 | // when 21 | Mark("marker") 22 | 23 | // then 24 | mark, _ := metrics.Get("marker").(metrics.Meter) 25 | assert.Equal(t, int64(1), mark.Count()) 26 | 27 | // when 28 | Mark("marker") 29 | 30 | // then 31 | assert.Equal(t, int64(2), mark.Count()) 32 | 33 | // when 34 | Clear() 35 | 36 | // then 37 | assert.Nil(t, metrics.Get("marker")) 38 | } 39 | 40 | func TestTime(t *testing.T) { 41 | // given 42 | Init(Config{Target: "stdout", Prefix: ""}) 43 | 44 | // expect 45 | assert.Nil(t, metrics.Get("timer")) 46 | 47 | // when 48 | Time("timer", func() {}) 49 | 50 | // then 51 | time, _ := metrics.Get("timer").(metrics.Timer) 52 | assert.Equal(t, int64(1), time.Count()) 53 | 54 | // when 55 | Time("timer", func() {}) 56 | 57 | // then 58 | assert.Equal(t, int64(2), time.Count()) 59 | 60 | // when 61 | Clear() 62 | 63 | // then 64 | assert.Nil(t, metrics.Get("marker")) 65 | } 66 | 67 | func TestUpdateGauge(t *testing.T) { 68 | // given 69 | Init(Config{Target: "stdout", Prefix: ""}) 70 | 71 | // expect 72 | assert.Nil(t, metrics.Get("counter")) 73 | 74 | // when 75 | UpdateGauge("counter", 2) 76 | 77 | // then 78 | gauge := metrics.Get("counter").(metrics.Gauge) 79 | assert.Equal(t, int64(2), gauge.Value()) 80 | 81 | // when 82 | UpdateGauge("counter", 123) 83 | 84 | // then 85 | assert.Equal(t, int64(123), gauge.Value()) 86 | 87 | // when 88 | Clear() 89 | 90 | // then 91 | assert.Nil(t, metrics.Get("marker")) 92 | } 93 | 94 | func TestMetricsInit_ForGraphiteWithNoAddress(t *testing.T) { 95 | err := Init(Config{Target: "graphite", Addr: ""}) 96 | assert.Error(t, err) 97 | } 98 | 99 | func TestMetricsInit_ForGraphiteWithBadAddress(t *testing.T) { 100 | err := Init(Config{Target: "graphite", Addr: "localhost"}) 101 | assert.Error(t, err) 102 | } 103 | 104 | func TestMetricsInit_ForGraphit(t *testing.T) { 105 | err := Init(Config{Target: "graphite", Addr: "localhost:81"}) 106 | assert.NoError(t, err) 107 | } 108 | 109 | func TestMetricsInit_ForUnknownTarget(t *testing.T) { 110 | err := Init(Config{Target: "unknown"}) 111 | assert.Error(t, err) 112 | } 113 | 114 | func TestMetricsInit(t *testing.T) { 115 | // when 116 | err := Init(Config{Prefix: "prefix"}) 117 | 118 | // then 119 | assert.Equal(t, "prefix", pfx) 120 | assert.NoError(t, err) 121 | } 122 | 123 | func TestInit_DefaultPrefix(t *testing.T) { 124 | // given 125 | hostname = func() (string, error) { return "", fmt.Errorf("Some error") } 126 | 127 | // when 128 | err := Init(Config{Prefix: "default"}) 129 | 130 | // then 131 | assert.Error(t, err) 132 | } 133 | 134 | func TestInit_DefaultPrefix_WithErrors(t *testing.T) { 135 | // given 136 | hostname = func() (string, error) { return "myhost", nil } 137 | os.Args = []string{"./myapp"} 138 | 139 | // when 140 | err := Init(Config{Prefix: "default"}) 141 | 142 | // then 143 | assert.NoError(t, err) 144 | assert.Equal(t, "myhost.myapp", pfx) 145 | } 146 | 147 | func TestTargetName(t *testing.T) { 148 | tests := []struct { 149 | service, host, path, target string 150 | name string 151 | }{ 152 | {"s", "h", "p", "http://foo.com/bar", "s.h.p.foo_com"}, 153 | {"s", "", "p", "http://foo.com/bar", "s._.p.foo_com"}, 154 | {"s", "", "", "http://foo.com/bar", "s._._.foo_com"}, 155 | {"", "", "", "http://foo.com/bar", "_._._.foo_com"}, 156 | {"", "", "", "http://foo.com:1234/bar", "_._._.foo_com_1234"}, 157 | {"", "", "", "http://1.2.3.4:1234/bar", "_._._.1_2_3_4_1234"}, 158 | } 159 | 160 | for i, tt := range tests { 161 | u, err := url.Parse(tt.target) 162 | if err != nil { 163 | t.Fatalf("%d: %v", i, err) 164 | } 165 | if got, want := TargetName(tt.service, tt.host, tt.path, u), tt.name; got != want { 166 | t.Errorf("%d: got %q want %q", i, got, want) 167 | } 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /metrics/system_metrics.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "runtime" 5 | 6 | "github.com/rcrowley/go-metrics" 7 | ) 8 | 9 | const allocGauge = "runtime.mem.bytes_allocated_and_not_yet_freed" 10 | const heapObjectsGauge = "runtime.mem.total_number_of_allocated_objects" 11 | const totalPauseGauge = "runtime.mem.pause_total_ns" 12 | const lastPauseGauge = "runtime.mem.last_pause" 13 | 14 | func collectSystemMetrics() { 15 | _ = metrics.Register(allocGauge, baseGauge{value: func(memStats runtime.MemStats) int64 { return int64(memStats.Alloc) }}) 16 | _ = metrics.Register(heapObjectsGauge, baseGauge{value: func(memStats runtime.MemStats) int64 { return int64(memStats.HeapObjects) }}) 17 | _ = metrics.Register(totalPauseGauge, baseGauge{value: func(memStats runtime.MemStats) int64 { return int64(memStats.PauseTotalNs) }}) 18 | _ = metrics.Register(lastPauseGauge, baseGauge{value: func(memStats runtime.MemStats) int64 { return int64(memStats.PauseNs[(memStats.NumGC+255)%256]) }}) 19 | } 20 | 21 | type baseGauge struct { 22 | value func(runtime.MemStats) int64 23 | } 24 | 25 | func (g baseGauge) Value() int64 { 26 | var memStats runtime.MemStats 27 | runtime.ReadMemStats(&memStats) 28 | return g.value(memStats) 29 | } 30 | 31 | func (g baseGauge) Snapshot() metrics.Gauge { return metrics.GaugeSnapshot(g.Value()) } 32 | 33 | func (baseGauge) Update(int64) {} 34 | -------------------------------------------------------------------------------- /metrics/system_metrics_test.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "testing" 5 | 6 | "runtime" 7 | 8 | "github.com/rcrowley/go-metrics" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestMetricShouldBeGauge(t *testing.T) { 13 | t.Parallel() 14 | 15 | // expect 16 | assert.Implements(t, (*metrics.Gauge)(nil), baseGauge{}) 17 | } 18 | 19 | func TestGaugeReturnsValueFromGivenFunction(t *testing.T) { 20 | t.Parallel() 21 | 22 | // given 23 | var counter int64 24 | bg := baseGauge{value: func(_ runtime.MemStats) int64 { 25 | counter++ 26 | return counter 27 | }} 28 | 29 | //when 30 | bg.Value() 31 | bg.Value() 32 | 33 | //then 34 | assert.Equal(t, (int64)(2), counter) 35 | } 36 | 37 | func TestMetricsRegistered(t *testing.T) { 38 | t.Parallel() 39 | 40 | //when 41 | collectSystemMetrics() 42 | 43 | //then 44 | assert.NotNil(t, metrics.Get(allocGauge)) 45 | assert.NotNil(t, metrics.Get(heapObjectsGauge)) 46 | assert.NotNil(t, metrics.Get(totalPauseGauge)) 47 | assert.NotNil(t, metrics.Get(lastPauseGauge)) 48 | } 49 | -------------------------------------------------------------------------------- /sentry/config.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import "github.com/allegro/marathon-consul/time" 4 | 5 | type Config struct { 6 | DSN string 7 | Env string 8 | Level string 9 | Release string 10 | Timeout time.Interval 11 | } 12 | -------------------------------------------------------------------------------- /sentry/init.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "github.com/evalphobia/logrus_sentry" 5 | "github.com/getsentry/raven-go" 6 | log "github.com/sirupsen/logrus" 7 | ) 8 | 9 | func Init(config Config) error { 10 | if config.DSN == "" { 11 | log.Info("Sentry DSN is not configured - Sentry will be disabled") 12 | return nil 13 | } 14 | 15 | client, err := raven.New(config.DSN) 16 | if err != nil { 17 | return err 18 | } 19 | client.SetEnvironment(config.Env) 20 | client.SetRelease(config.Release) 21 | 22 | levels, err := parseLogLevels(config) 23 | if err != nil { 24 | return err 25 | } 26 | 27 | sentryHook, err := logrus_sentry.NewWithClientSentryHook(client, levels) 28 | if err != nil { 29 | return err 30 | } 31 | sentryHook.Timeout = config.Timeout.Duration 32 | log.AddHook(sentryHook) 33 | log.Infof("Enabled Sentry alerting for following logging levels: %v", levels) 34 | 35 | return nil 36 | } 37 | 38 | func parseLogLevels(config Config) ([]log.Level, error) { 39 | boundLevel, err := log.ParseLevel(config.Level) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | var levels []log.Level 45 | 46 | for _, level := range log.AllLevels { 47 | levels = append(levels, level) 48 | if level == boundLevel { 49 | break 50 | } 51 | } 52 | 53 | return levels, nil 54 | } 55 | -------------------------------------------------------------------------------- /sentry/init_test.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/evalphobia/logrus_sentry" 7 | log "github.com/sirupsen/logrus" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestInit_ShouldFailForInvalidDSN(t *testing.T) { 13 | err := Init(Config{ 14 | DSN: "£££", 15 | }) 16 | assert.Error(t, err) 17 | } 18 | 19 | func TestInit_ShouldRegisterLogrusSentryHookForValidDSN(t *testing.T) { 20 | err := Init(Config{ 21 | DSN: "http://login:password@localhost/marathon-consul", 22 | Level: "panic", 23 | }) 24 | require.NoError(t, err) 25 | 26 | stdLog := log.StandardLogger() 27 | 28 | require.NotEmpty(t, stdLog.Hooks[log.PanicLevel]) 29 | assert.IsType(t, &logrus_sentry.SentryHook{}, stdLog.Hooks[log.PanicLevel][0]) 30 | } 31 | -------------------------------------------------------------------------------- /service/service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/allegro/marathon-consul/apps" 9 | ) 10 | 11 | type ID string 12 | 13 | func (id ID) String() string { 14 | return string(id) 15 | } 16 | 17 | type Service struct { 18 | ID ID 19 | Name string 20 | Tags []string 21 | AgentAddress string 22 | EnableTagOverride bool 23 | } 24 | 25 | func (s *Service) TaskID() (apps.TaskID, error) { 26 | for _, tag := range s.Tags { 27 | if strings.HasPrefix(tag, "marathon-task:") { 28 | return apps.TaskID(strings.TrimPrefix(tag, "marathon-task:")), nil 29 | } 30 | } 31 | return apps.TaskID(""), errors.New("marathon-task tag missing") 32 | } 33 | 34 | func MarathonTaskTag(taskID apps.TaskID) string { 35 | return fmt.Sprintf("marathon-task:%s", taskID) 36 | } 37 | 38 | type Registry interface { 39 | GetAllServices() ([]*Service, error) 40 | GetServices(name string) ([]*Service, error) 41 | Register(task *apps.Task, app *apps.App) error 42 | DeregisterByTask(taskID apps.TaskID) error 43 | Deregister(toDeregister *Service) error 44 | } 45 | -------------------------------------------------------------------------------- /service/service_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/allegro/marathon-consul/apps" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestMarathonTaskTAg(t *testing.T) { 11 | t.Parallel() 12 | assert.Equal(t, "marathon-task:my-task", MarathonTaskTag(apps.TaskID("my-task"))) 13 | } 14 | 15 | func TestServiceTaskId(t *testing.T) { 16 | t.Parallel() 17 | // given 18 | service := Service{ 19 | ID: "123", 20 | Name: "abc", 21 | Tags: []string{MarathonTaskTag("my-task")}, 22 | AgentAddress: "localhost", 23 | } 24 | 25 | // when 26 | id, err := service.TaskID() 27 | 28 | // then 29 | assert.Equal(t, apps.TaskID("my-task"), id) 30 | assert.NoError(t, err) 31 | } 32 | 33 | func TestServiceTaskId_NoMarathonTaskTag(t *testing.T) { 34 | t.Parallel() 35 | // given 36 | service := Service{ 37 | ID: "123", 38 | Name: "abc", 39 | Tags: []string{}, 40 | AgentAddress: "localhost", 41 | } 42 | 43 | // when 44 | _, err := service.TaskID() 45 | 46 | // then 47 | assert.Error(t, err) 48 | } 49 | -------------------------------------------------------------------------------- /sse/config.go: -------------------------------------------------------------------------------- 1 | package sse 2 | 3 | import "github.com/allegro/marathon-consul/time" 4 | 5 | type Config struct { 6 | Retries int 7 | RetryBackoff time.Interval 8 | } 9 | -------------------------------------------------------------------------------- /sse/sse.go: -------------------------------------------------------------------------------- 1 | package sse 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "time" 7 | 8 | log "github.com/sirupsen/logrus" 9 | 10 | "github.com/allegro/marathon-consul/events" 11 | "github.com/allegro/marathon-consul/marathon" 12 | "github.com/allegro/marathon-consul/service" 13 | "github.com/allegro/marathon-consul/web" 14 | ) 15 | 16 | type Stop func() 17 | type Handler func(w http.ResponseWriter, r *http.Request) 18 | 19 | func NewHandler(config Config, webConfig web.Config, marathon marathon.Marathoner, serviceOperations service.Registry) (Stop, error) { 20 | stopChannels := make([]chan<- events.StopEvent, webConfig.WorkersCount) 21 | stopFunc := stop(stopChannels) 22 | eventQueue := make(chan events.Event, webConfig.QueueSize) 23 | for i := 0; i < webConfig.WorkersCount; i++ { 24 | handler := events.NewEventHandler(i, serviceOperations, marathon, eventQueue) 25 | stopChannels[i] = handler.Start() 26 | } 27 | 28 | sse, err := newSSEHandler(eventQueue, marathon, webConfig.MaxEventSize, config) 29 | if err != nil { 30 | stopFunc() 31 | return nil, fmt.Errorf("Cannot create SSE handler: %s", err) 32 | } 33 | dispatcherStop, err := sse.start() 34 | if err != nil { 35 | stopFunc() 36 | return nil, fmt.Errorf("Cannot start SSE handler: %s", err) 37 | } 38 | 39 | guardQuit := leaderGuard(sse.Streamer, marathon) 40 | stopChannels = append(stopChannels, dispatcherStop, guardQuit) 41 | 42 | return stop(stopChannels), nil 43 | } 44 | 45 | func stop(channels []chan<- events.StopEvent) Stop { 46 | return func() { 47 | for _, channel := range channels { 48 | channel <- events.StopEvent{} 49 | } 50 | } 51 | } 52 | 53 | // leaderGuard is a watchdog goroutine, 54 | // periodically checks if leader has changed 55 | // if change is detected, passed streamer is stopped - unable to recover 56 | // if this goroutine is quited, agent is stopped - unable to recover 57 | func leaderGuard(s *marathon.Streamer, m marathon.Marathoner) chan<- events.StopEvent { 58 | // TODO(tz) - consider launching this goroutine from marathon, 59 | // no need to pass marathon reciever then ?? 60 | quit := make(chan events.StopEvent) 61 | 62 | go func() { 63 | ticker := time.NewTicker(5 * time.Second) 64 | for { 65 | select { 66 | case <-ticker.C: 67 | if iAMLeader, err := m.IsLeader(); !iAMLeader && err != nil { 68 | // Leader changed, not revocerable. 69 | ticker.Stop() 70 | s.Stop() 71 | log.Error("Tearing down SSE stream, marathon leader changed.") 72 | return 73 | } else if err != nil { 74 | log.WithError(err).Error("Leader Guard error while checking leader.") 75 | } 76 | case <-quit: 77 | log.Info("Recieved quit notification. Quit checker") 78 | ticker.Stop() 79 | s.Stop() 80 | return 81 | } 82 | } 83 | }() 84 | return quit 85 | } 86 | -------------------------------------------------------------------------------- /sse/sse_handler.go: -------------------------------------------------------------------------------- 1 | package sse 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "time" 7 | 8 | log "github.com/sirupsen/logrus" 9 | 10 | "github.com/allegro/marathon-consul/events" 11 | "github.com/allegro/marathon-consul/marathon" 12 | "github.com/allegro/marathon-consul/metrics" 13 | ) 14 | 15 | // HandlerSSE defines handler for marathon event stream, opening and closing 16 | // subscription 17 | type HandlerSSE struct { 18 | config Config 19 | eventQueue chan events.Event 20 | Streamer *marathon.Streamer 21 | maxLineSize int64 22 | } 23 | 24 | func newSSEHandler(eventQueue chan events.Event, service marathon.Marathoner, maxLineSize int64, config Config) (*HandlerSSE, error) { 25 | 26 | streamer, err := service.EventStream( 27 | []string{events.StatusUpdateEventType, events.HealthStatusChangedEventType}, 28 | config.Retries, 29 | config.RetryBackoff.Duration, 30 | ) 31 | if err != nil { 32 | return nil, fmt.Errorf("Unable to start Streamer: %s", err) 33 | } 34 | 35 | return &HandlerSSE{ 36 | config: config, 37 | eventQueue: eventQueue, 38 | Streamer: streamer, 39 | maxLineSize: maxLineSize, 40 | }, nil 41 | } 42 | 43 | // Open connection to marathon v2/events 44 | func (h *HandlerSSE) start() (chan<- events.StopEvent, error) { 45 | if err := h.Streamer.Start(); err != nil { 46 | return nil, fmt.Errorf("Cannot start Streamer: %s", err) 47 | } 48 | 49 | stopChan := make(chan events.StopEvent) 50 | go func() { 51 | <-stopChan 52 | h.stop() 53 | }() 54 | 55 | go func() { 56 | defer h.stop() 57 | 58 | // buffer used for token storage, 59 | // if token is greater than buffer, empty token is stored 60 | buffer := make([]byte, h.maxLineSize) 61 | // configure streamer scanner :) 62 | h.Streamer.Scanner.Buffer(buffer, cap(buffer)) 63 | h.Streamer.Scanner.Split(events.ScanLines) 64 | for { 65 | metrics.Time("events.read", func() { h.handle() }) 66 | } 67 | }() 68 | return stopChan, nil 69 | } 70 | 71 | func (h *HandlerSSE) handle() { 72 | e, err := events.ParseSSEEvent(h.Streamer.Scanner) 73 | if err != nil { 74 | if err == io.EOF { 75 | // Event could be partial at this point 76 | h.enqueueEvent(e) 77 | } 78 | log.WithError(err).Error("Error when parsing the event") 79 | err = h.Streamer.Recover() 80 | if err != nil { 81 | log.WithError(err).Fatalf("Unable to recover streamer") 82 | } 83 | } 84 | metrics.Mark("events.read." + e.Type) 85 | if e.Type != events.StatusUpdateEventType && e.Type != events.HealthStatusChangedEventType { 86 | log.Debugf("%s is not supported", e.Type) 87 | metrics.Mark("events.read.drop") 88 | return 89 | } 90 | h.enqueueEvent(e) 91 | } 92 | 93 | func (h *HandlerSSE) enqueueEvent(e events.SSEEvent) { 94 | select { 95 | case h.eventQueue <- events.Event{Timestamp: time.Now(), EventType: e.Type, Body: e.Body}: 96 | metrics.Mark("events.read.accept") 97 | default: 98 | log.Error("Events queue full. Dropping the event") 99 | metrics.Mark("events.read.drop") 100 | } 101 | } 102 | 103 | // Close connections managed by context 104 | func (h *HandlerSSE) stop() { 105 | h.Streamer.Stop() 106 | } 107 | -------------------------------------------------------------------------------- /sync/config.go: -------------------------------------------------------------------------------- 1 | package sync 2 | 3 | import "github.com/allegro/marathon-consul/time" 4 | 5 | type Config struct { 6 | Enabled bool 7 | Force bool 8 | Interval time.Interval 9 | Leader string 10 | } 11 | -------------------------------------------------------------------------------- /sync/error_marathon_stub_test.go: -------------------------------------------------------------------------------- 1 | package sync 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "github.com/allegro/marathon-consul/apps" 8 | "github.com/allegro/marathon-consul/marathon" 9 | ) 10 | 11 | type errorMarathon struct { 12 | } 13 | 14 | func (m errorMarathon) ConsulApps() ([]*apps.App, error) { 15 | return nil, errors.New("Error") 16 | } 17 | 18 | func (m errorMarathon) App(id apps.AppID) (*apps.App, error) { 19 | return nil, errors.New("Error") 20 | } 21 | 22 | func (m errorMarathon) Tasks(appID apps.AppID) ([]apps.Task, error) { 23 | return nil, errors.New("Error") 24 | } 25 | 26 | func (m errorMarathon) Leader() (string, error) { 27 | return "", errors.New("Error") 28 | } 29 | 30 | func (m errorMarathon) EventStream([]string, int, time.Duration) (*marathon.Streamer, error) { 31 | return &marathon.Streamer{}, errors.New("Error") 32 | } 33 | 34 | func (m errorMarathon) IsLeader() (bool, error) { 35 | return false, errors.New("Error") 36 | } 37 | -------------------------------------------------------------------------------- /sync/error_service_registry_stub_test.go: -------------------------------------------------------------------------------- 1 | package sync 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/allegro/marathon-consul/apps" 7 | "github.com/allegro/marathon-consul/service" 8 | ) 9 | 10 | type errorServiceRegistry struct { 11 | } 12 | 13 | func (c errorServiceRegistry) GetServices(name string) ([]*service.Service, error) { 14 | return nil, errors.New("Error occured") 15 | } 16 | 17 | func (c errorServiceRegistry) GetAllServices() ([]*service.Service, error) { 18 | return nil, errors.New("Error occured") 19 | } 20 | 21 | func (c errorServiceRegistry) Register(task *apps.Task, app *apps.App) error { 22 | return errors.New("Error occured") 23 | } 24 | 25 | func (c errorServiceRegistry) DeregisterByTask(taskID apps.TaskID) error { 26 | return errors.New("Error occured") 27 | } 28 | 29 | func (c errorServiceRegistry) Deregister(toDeregister *service.Service) error { 30 | return errors.New("Error occured") 31 | } 32 | -------------------------------------------------------------------------------- /sync/sync.go: -------------------------------------------------------------------------------- 1 | package sync 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/allegro/marathon-consul/apps" 8 | "github.com/allegro/marathon-consul/marathon" 9 | "github.com/allegro/marathon-consul/metrics" 10 | "github.com/allegro/marathon-consul/service" 11 | log "github.com/sirupsen/logrus" 12 | ) 13 | 14 | type Sync struct { 15 | config Config 16 | marathon marathon.Marathoner 17 | serviceRegistry service.Registry 18 | syncStartedListener startedListener 19 | } 20 | 21 | type startedListener func(apps []*apps.App) 22 | 23 | func New(config Config, marathon marathon.Marathoner, serviceRegistry service.Registry, syncStartedListener startedListener) *Sync { 24 | return &Sync{config, marathon, serviceRegistry, syncStartedListener} 25 | } 26 | 27 | func (s *Sync) StartSyncServicesJob() { 28 | if !s.config.Enabled { 29 | log.Info("Marathon-consul sync disabled") 30 | return 31 | } 32 | 33 | log.WithFields(log.Fields{ 34 | "Interval": s.config.Interval, 35 | "Leader": s.config.Leader, 36 | "Force": s.config.Force, 37 | }).Info("Marathon-consul sync job started") 38 | 39 | ticker := time.NewTicker(s.config.Interval.Duration) 40 | go func() { 41 | if err := s.SyncServices(); err != nil { 42 | log.WithError(err).Error("An error occured while performing sync") 43 | } 44 | for range ticker.C { 45 | if err := s.SyncServices(); err != nil { 46 | log.WithError(err).Error("An error occured while performing sync") 47 | } 48 | } 49 | }() 50 | } 51 | 52 | func (s *Sync) SyncServices() error { 53 | var err error 54 | metrics.Time("sync.services", func() { err = s.syncServices() }) 55 | return err 56 | } 57 | 58 | func (s *Sync) syncServices() error { 59 | if check, err := s.shouldPerformSync(); !check { 60 | metrics.Clear() 61 | return err 62 | } 63 | log.Info("Syncing services started") 64 | 65 | apps, err := s.marathon.ConsulApps() 66 | if err != nil { 67 | return fmt.Errorf("Can't get Marathon apps: %v", err) 68 | } 69 | 70 | s.syncStartedListener(apps) 71 | 72 | services, err := s.serviceRegistry.GetAllServices() 73 | if err != nil { 74 | return fmt.Errorf("Can't get Consul services: %v", err) 75 | } 76 | 77 | registerCount, registerErrorsCount := s.registerAppTasksNotFoundInConsul(apps, services) 78 | deregisterCount, deregisterErrorsCount := s.deregisterConsulServicesNotFoundInMarathon(apps, services) 79 | 80 | metrics.UpdateGauge("sync.register.success", int64(registerCount)) 81 | metrics.UpdateGauge("sync.register.error", int64(registerErrorsCount)) 82 | metrics.UpdateGauge("sync.deregister.success", int64(deregisterCount)) 83 | metrics.UpdateGauge("sync.deregister.error", int64(deregisterErrorsCount)) 84 | 85 | log.Infof("Syncing services finished. Stats, registerd: %d (failed: %d), deregister: %d (failed: %d).", 86 | registerCount, registerErrorsCount, deregisterCount, deregisterErrorsCount) 87 | return nil 88 | } 89 | 90 | func (s *Sync) shouldPerformSync() (bool, error) { 91 | if s.config.Force { 92 | log.Debug("Forcing sync") 93 | return true, nil 94 | } 95 | leading, err := s.marathon.IsLeader() 96 | if err != nil { 97 | return false, fmt.Errorf("Could not get Marathon leader: %v", err) 98 | } 99 | if leading { 100 | log.Debug("Node has leadership") 101 | return true, nil 102 | } 103 | log.Debug("Node is not a leader, skipping sync") 104 | return false, nil 105 | } 106 | 107 | func (s *Sync) deregisterConsulServicesNotFoundInMarathon(marathonApps []*apps.App, services []*service.Service) (deregisterCount int, errorCount int) { 108 | runningTasks := marathonTaskIdsSet(marathonApps) 109 | for _, service := range services { 110 | logFields := log.Fields{ 111 | "Id": service.ID, 112 | "Address": service.AgentAddress, 113 | "Sync": true, 114 | } 115 | if taskIDInTag, err := service.TaskID(); err != nil { 116 | log.WithField("Id", service.ID).WithError(err). 117 | Warn("Couldn't extract marathon task id, deregistering since sync should have reregistered it already") 118 | if err := s.serviceRegistry.Deregister(service); err != nil { 119 | log.WithError(err).WithFields(logFields).Error("Can't deregister service") 120 | errorCount++ 121 | } else { 122 | deregisterCount++ 123 | } 124 | } else if _, isRunning := runningTasks[taskIDInTag]; !isRunning { 125 | // Check latest marathon state to prevent deregistration of live service. 126 | tasks, err := s.marathon.Tasks(taskIDInTag.AppID()) 127 | if err != nil { 128 | log.WithError(err).WithFields(logFields). 129 | Error("Can't get fresh info about app tasks. Will deregister this service.") 130 | } 131 | 132 | _, taskIsRunning := apps.FindTaskByID(taskIDInTag, tasks) 133 | 134 | if !taskIsRunning { 135 | if err := s.serviceRegistry.Deregister(service); err != nil { 136 | log.WithError(err).WithFields(logFields).Error("Can't deregister service") 137 | errorCount++ 138 | } else { 139 | deregisterCount++ 140 | } 141 | } 142 | } else { 143 | log.WithField("Id", service.ID).Debug("Service is running") 144 | } 145 | } 146 | return 147 | } 148 | 149 | func (s *Sync) registerAppTasksNotFoundInConsul(marathonApps []*apps.App, services []*service.Service) (registerCount int, errorCount int) { 150 | registrationsUnderTaskIds := taskIdsInConsulServices(services) 151 | for _, app := range marathonApps { 152 | if !app.IsConsulApp() { 153 | log.WithField("Id", app.ID).Debug("Not a Consul app, skipping registration") 154 | continue 155 | } 156 | expectedRegistrations := app.RegistrationIntentsNumber() 157 | for _, task := range app.Tasks { 158 | registrations := registrationsUnderTaskIds[task.ID] 159 | logFields := log.Fields{ 160 | "Id": task.ID, 161 | "HasRegistrations": registrations, 162 | "ExpectedRegistrations": expectedRegistrations, 163 | "Sync": true, 164 | } 165 | if registrations < expectedRegistrations { 166 | if registrations != 0 { 167 | log.WithFields(logFields).Info("Registering missing service registrations") 168 | } 169 | if task.IsHealthy() { 170 | err := s.serviceRegistry.Register(&task, app) 171 | if err != nil { 172 | log.WithError(err).WithFields(logFields).Error("Can't register task") 173 | errorCount++ 174 | } else { 175 | registerCount++ 176 | } 177 | } else { 178 | log.WithFields(logFields).Debug("Task is not healthy. Not Registering") 179 | } 180 | } else if registrations > expectedRegistrations { 181 | log.WithFields(logFields).Warn("Skipping task with excess registrations") 182 | } else { 183 | log.WithFields(logFields).Debug("Task already registered in Consul") 184 | } 185 | } 186 | } 187 | return 188 | } 189 | 190 | func taskIdsInConsulServices(services []*service.Service) map[apps.TaskID]int { 191 | serviceCounters := make(map[apps.TaskID]int) 192 | for _, service := range services { 193 | if taskID, err := service.TaskID(); err == nil { 194 | serviceCounters[taskID]++ 195 | } 196 | } 197 | return serviceCounters 198 | } 199 | 200 | func marathonTaskIdsSet(marathonApps []*apps.App) map[apps.TaskID]struct{} { 201 | tasksSet := make(map[apps.TaskID]struct{}) 202 | var exists struct{} 203 | for _, app := range marathonApps { 204 | for _, task := range app.Tasks { 205 | tasksSet[task.ID] = exists 206 | } 207 | } 208 | return tasksSet 209 | } 210 | -------------------------------------------------------------------------------- /sync/sync_bench_test.go: -------------------------------------------------------------------------------- 1 | package sync 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/allegro/marathon-consul/apps" 8 | "github.com/allegro/marathon-consul/consul" 9 | "github.com/allegro/marathon-consul/service" 10 | . "github.com/allegro/marathon-consul/utils" 11 | ) 12 | 13 | func BenchmarkDeregisterConsulServicesThatAreNotInMarathonApps10x2(b *testing.B) { 14 | const ( 15 | appsCount = 10 16 | instancesCount = 2 17 | ) 18 | 19 | bench(b, appsCount, instancesCount) 20 | } 21 | 22 | func BenchmarkDeregisterConsulServicesThatAreNotInMarathonApps100x2(b *testing.B) { 23 | const ( 24 | appsCount = 100 25 | instancesCount = 2 26 | ) 27 | 28 | bench(b, appsCount, instancesCount) 29 | } 30 | 31 | func BenchmarkDeregisterConsulServicesThatAreNotInMarathonApps100x100(b *testing.B) { 32 | const ( 33 | appsCount = 100 34 | instancesCount = 100 35 | ) 36 | 37 | bench(b, appsCount, instancesCount) 38 | } 39 | 40 | func bench(b *testing.B, appsCount, instancesCount int) { 41 | apps := marathonApps(appsCount, instancesCount) 42 | instances := instances(appsCount, instancesCount) 43 | sync := New(Config{}, nil, consul.NewConsulStub(), noopSyncStartedListener) 44 | 45 | b.ResetTimer() 46 | for i := 0; i < b.N; i++ { 47 | sync.deregisterConsulServicesNotFoundInMarathon(apps, instances) 48 | } 49 | } 50 | 51 | func marathonApps(appsCount, instancesCount int) []*apps.App { 52 | marathonApps := make([]*apps.App, appsCount) 53 | for i := 0; i < appsCount; i++ { 54 | marathonApps[i] = ConsulApp(fmt.Sprintf("marathon/app/no_%d", i), instancesCount) 55 | } 56 | return marathonApps 57 | } 58 | 59 | func instances(appsCount, instancesCount int) []*service.Service { 60 | createdInstances := make([]*service.Service, appsCount*instancesCount) 61 | for i := 0; i < appsCount*instancesCount; i++ { 62 | app := ConsulApp(fmt.Sprintf("consul/service/no_%d", i), instancesCount) 63 | for _, task := range app.Tasks { 64 | createdInstances[i] = &service.Service{ 65 | ID: service.ID(task.ID.String()), 66 | Name: app.ID.String(), 67 | Tags: []string{"marathon"}, 68 | AgentAddress: task.Host, 69 | } 70 | } 71 | } 72 | return createdInstances 73 | } 74 | -------------------------------------------------------------------------------- /sync/sync_test.go: -------------------------------------------------------------------------------- 1 | package sync 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | "testing" 7 | "time" 8 | 9 | "github.com/allegro/marathon-consul/consul" 10 | "github.com/allegro/marathon-consul/marathon" 11 | . "github.com/allegro/marathon-consul/utils" 12 | "github.com/stretchr/testify/assert" 13 | 14 | "github.com/allegro/marathon-consul/apps" 15 | "github.com/allegro/marathon-consul/service" 16 | timeutil "github.com/allegro/marathon-consul/time" 17 | ) 18 | 19 | var noopSyncStartedListener = func(apps []*apps.App) {} 20 | 21 | func TestSyncJob_ShouldSyncOnLeadership(t *testing.T) { 22 | t.Parallel() 23 | // given 24 | app := ConsulApp("app1", 1) 25 | marathon := marathon.MarathonerStubWithLeaderForApps("current.leader:8080", "current.leader:8080", app) 26 | services := newConsulServicesMock() 27 | sync := New(Config{ 28 | Enabled: true, 29 | Interval: timeutil.Interval{Duration: 10 * time.Millisecond}, 30 | }, marathon, services, noopSyncStartedListener) 31 | 32 | // when 33 | sync.StartSyncServicesJob() 34 | 35 | // then 36 | <-time.After(15 * time.Millisecond) 37 | assert.Equal(t, 2, services.RegistrationsCount(app.Tasks[0].ID.String())) 38 | } 39 | 40 | func TestSyncJob_ShouldNotSyncWhenDisabled(t *testing.T) { 41 | t.Parallel() 42 | // given 43 | app := ConsulApp("app1", 1) 44 | marathon := marathon.MarathonerStubWithLeaderForApps("current.leader:8080", "current.leader:8080", app) 45 | services := newConsulServicesMock() 46 | sync := New(Config{ 47 | Enabled: false, 48 | Interval: timeutil.Interval{Duration: 10 * time.Millisecond}, 49 | }, marathon, services, noopSyncStartedListener) 50 | 51 | // when 52 | sync.StartSyncServicesJob() 53 | 54 | // then 55 | <-time.After(15 * time.Millisecond) 56 | assert.Equal(t, 0, services.RegistrationsCount(app.Tasks[0].ID.String())) 57 | } 58 | 59 | func TestSyncServices_ShouldNotSyncOnNoForceNorLeaderSpecified(t *testing.T) { 60 | t.Parallel() 61 | // given 62 | app := ConsulApp("app1", 1) 63 | marathon := marathon.MarathonerStubWithLeaderForApps("localhost:8080", "", app) 64 | services := newConsulServicesMock() 65 | sync := New(Config{}, marathon, services, noopSyncStartedListener) 66 | 67 | // when 68 | sync.StartSyncServicesJob() 69 | 70 | // then 71 | assert.Zero(t, services.RegistrationsCount(app.Tasks[0].ID.String())) 72 | } 73 | 74 | func TestSyncServices_ShouldNotSyncOnNoLeadership(t *testing.T) { 75 | t.Parallel() 76 | // given 77 | app := ConsulApp("app1", 1) 78 | marathon := marathon.MarathonerStubWithLeaderForApps("leader:8080", "different.node:8090", app) 79 | services := newConsulServicesMock() 80 | sync := New(Config{}, marathon, services, noopSyncStartedListener) 81 | 82 | // when 83 | err := sync.SyncServices() 84 | 85 | // then 86 | assert.NoError(t, err) 87 | assert.Zero(t, services.RegistrationsCount(app.Tasks[0].ID.String())) 88 | } 89 | 90 | func TestSyncServices_ShouldSyncOnForceWithoutLeadership(t *testing.T) { 91 | t.Parallel() 92 | // given 93 | app := ConsulApp("app1", 1) 94 | marathon := marathon.MarathonerStubWithLeaderForApps("leader:8080", "different.node:8090", app) 95 | services := newConsulServicesMock() 96 | sync := New(Config{Force: true}, marathon, services, noopSyncStartedListener) 97 | 98 | // when 99 | err := sync.SyncServices() 100 | 101 | // then 102 | assert.NoError(t, err) 103 | assert.Equal(t, 1, services.RegistrationsCount(app.Tasks[0].ID.String())) 104 | } 105 | 106 | type ConsulServicesMock struct { 107 | sync.RWMutex 108 | registrations map[string]int 109 | } 110 | 111 | func newConsulServicesMock() *ConsulServicesMock { 112 | return &ConsulServicesMock{ 113 | registrations: make(map[string]int), 114 | } 115 | } 116 | 117 | func (c *ConsulServicesMock) GetServices(name string) ([]*service.Service, error) { 118 | return nil, nil 119 | } 120 | 121 | func (c *ConsulServicesMock) GetAllServices() ([]*service.Service, error) { 122 | return nil, nil 123 | } 124 | 125 | func (c *ConsulServicesMock) Register(task *apps.Task, app *apps.App) error { 126 | c.Lock() 127 | defer c.Unlock() 128 | c.registrations[task.ID.String()]++ 129 | return nil 130 | } 131 | 132 | func (c *ConsulServicesMock) RegistrationsCount(instanceID string) int { 133 | c.RLock() 134 | defer c.RUnlock() 135 | return c.registrations[instanceID] 136 | } 137 | 138 | func (c *ConsulServicesMock) DeregisterByTask(taskID apps.TaskID) error { 139 | return nil 140 | } 141 | 142 | func (c *ConsulServicesMock) Deregister(toDeregister *service.Service) error { 143 | return nil 144 | } 145 | 146 | func TestSyncAppsFromMarathonToConsul(t *testing.T) { 147 | t.Parallel() 148 | // given 149 | marathoner := marathon.MarathonerStubForApps( 150 | ConsulApp("app1", 2), 151 | ConsulApp("app2", 1), 152 | NonConsulApp("app3", 1), 153 | ) 154 | 155 | consul := consul.NewConsulStub() 156 | marathonSync := newSyncWithDefaultConfig(marathoner, consul) 157 | 158 | // when 159 | marathonSync.SyncServices() 160 | 161 | // then 162 | services, _ := consul.GetAllServices() 163 | assert.Equal(t, 3, len(services)) 164 | for _, s := range services { 165 | assert.NotEqual(t, "app3", s.Name) 166 | } 167 | } 168 | 169 | func TestSyncAppsFromMarathonToConsul_CustomServiceName(t *testing.T) { 170 | t.Parallel() 171 | // given 172 | app := ConsulApp("app1", 3) 173 | app.Labels["consul"] = "customName" 174 | marathoner := marathon.MarathonerStubForApps(app) 175 | 176 | consul := consul.NewConsulStub() 177 | marathonSync := newSyncWithDefaultConfig(marathoner, consul) 178 | 179 | // when 180 | marathonSync.SyncServices() 181 | 182 | // then 183 | services, _ := consul.GetAllServices() 184 | assert.Equal(t, 3, len(services)) 185 | assert.Equal(t, "customName", services[0].Name) 186 | } 187 | 188 | func TestRemoveInvalidServicesFromConsul(t *testing.T) { 189 | t.Parallel() 190 | // given 191 | marathoner := marathon.MarathonerStubForApps( 192 | ConsulApp("app1-invalid", 1), 193 | ConsulApp("app2", 1), 194 | ) 195 | consul := consul.NewConsulStub() 196 | marathonSync := newSyncWithDefaultConfig(marathoner, consul) 197 | marathonSync.SyncServices() 198 | 199 | // when 200 | marathoner = marathon.MarathonerStubForApps( 201 | ConsulApp("app2", 1), 202 | ) 203 | marathonSync = newSyncWithDefaultConfig(marathoner, consul) 204 | marathonSync.SyncServices() 205 | 206 | // then 207 | services, _ := consul.GetAllServices() 208 | assert.Equal(t, 1, len(services)) 209 | assert.Equal(t, "app2", services[0].Name) 210 | } 211 | 212 | func TestRemoveInvalidServicesFromConsul_WithCustomServiceName(t *testing.T) { 213 | t.Parallel() 214 | // given 215 | invalidApp := ConsulApp("app1-invalid", 1) 216 | invalidApp.Labels["consul"] = "customName" 217 | marathoner := marathon.MarathonerStubForApps( 218 | invalidApp, 219 | ConsulApp("app2", 1), 220 | ) 221 | consul := consul.NewConsulStub() 222 | marathonSync := newSyncWithDefaultConfig(marathoner, consul) 223 | 224 | // when 225 | marathonSync.SyncServices() 226 | 227 | // then 228 | customNameServices, _ := consul.GetServices("customName") 229 | assert.Equal(t, 1, len(customNameServices)) 230 | 231 | // when 232 | marathoner = marathon.MarathonerStubForApps( 233 | ConsulApp("app2", 1), 234 | ) 235 | marathonSync = newSyncWithDefaultConfig(marathoner, consul) 236 | marathonSync.SyncServices() 237 | 238 | // then 239 | customNameServices, _ = consul.GetServices("customName") 240 | assert.Equal(t, 0, len(customNameServices)) 241 | 242 | services, _ := consul.GetAllServices() 243 | assert.Equal(t, 1, len(services)) 244 | assert.Equal(t, "app2", services[0].Name) 245 | } 246 | 247 | func TestSyncOnlyHealthyServices(t *testing.T) { 248 | t.Parallel() 249 | // given 250 | marathoner := marathon.MarathonerStubForApps( 251 | ConsulApp("app1", 1), 252 | ConsulAppWithUnhealthyInstances("app2-one-unhealthy", 2, 1), 253 | ConsulAppWithUnhealthyInstances("app3-all-unhealthy", 2, 2), 254 | ) 255 | consul := consul.NewConsulStub() 256 | marathonSync := newSyncWithDefaultConfig(marathoner, consul) 257 | 258 | // when 259 | marathonSync.SyncServices() 260 | 261 | // then 262 | services, _ := consul.GetAllServices() 263 | assert.Equal(t, 2, len(services)) 264 | for _, s := range services { 265 | assert.NotEqual(t, "app3-all-unhealthy", s.Name) 266 | } 267 | } 268 | 269 | func TestSync_DeregisterServicesWithoutMarathonTaskTag(t *testing.T) { 270 | t.Parallel() 271 | // given 272 | app := ConsulApp("app1", 1) 273 | consul := consul.NewConsulStub() 274 | consul.RegisterWithoutMarathonTaskTag(&app.Tasks[0], app) 275 | marathonSync := newSyncWithDefaultConfig(marathon.MarathonerStubForApps(), consul) 276 | 277 | // when 278 | marathonSync.SyncServices() 279 | 280 | // then 281 | services, _ := consul.GetAllServices() 282 | assert.Empty(t, services) 283 | } 284 | 285 | func TestSync_WithRegisteringProblems(t *testing.T) { 286 | t.Parallel() 287 | // given 288 | marathon := marathon.MarathonerStubForApps(ConsulApp("/test/app", 3)) 289 | consul := consul.NewConsulStub() 290 | consul.FailRegisterForID("test_app.1") 291 | sync := newSyncWithDefaultConfig(marathon, consul) 292 | // when 293 | err := sync.SyncServices() 294 | services, _ := consul.GetAllServices() 295 | // then 296 | assert.NoError(t, err) 297 | assert.Len(t, services, 2) 298 | } 299 | 300 | func TestSync_ShouldRegisterMissingRegistrationInMultiregistrationScenario(t *testing.T) { 301 | t.Parallel() 302 | // given 303 | app := ConsulAppMultipleRegistrations("/test/app", 1, 2) 304 | marathon := marathon.MarathonerStubForApps(app) 305 | consul := consul.NewConsulStub() 306 | 307 | consul.RegisterOnlyFirstRegistrationIntent(&app.Tasks[0], app) 308 | services, _ := consul.GetAllServices() 309 | assert.Len(t, services, 1) 310 | 311 | sync := newSyncWithDefaultConfig(marathon, consul) 312 | 313 | // when 314 | err := sync.SyncServices() 315 | 316 | // then 317 | services, _ = consul.GetAllServices() 318 | assert.NoError(t, err) 319 | assert.Len(t, services, 2) 320 | } 321 | 322 | /* 323 | This may happen if an application configuration is changed, but there are still tasks running the older one, e.g. 324 | the new deployment is still in progress or was cancelled. There's no way to access the original configuration 325 | that was used to start the currently running tasks. In such case, it's possible that a given task has more registrations 326 | than it's now expected from the new application configuration. In order to be safe we don't want to deregister anything, 327 | let someone make the deployment explicitly. 328 | */ 329 | func TestSync_SkipServiceHavingMoreRegistrationsThanExpectedInMultiregistrationScenario(t *testing.T) { 330 | t.Parallel() 331 | // given 332 | app := ConsulAppMultipleRegistrations("/test/app", 1, 2) 333 | marathon := marathon.MarathonerStubForApps(app) 334 | consul := consul.NewConsulStub() 335 | 336 | consul.Register(&app.Tasks[0], app) 337 | services, _ := consul.GetAllServices() 338 | assert.Len(t, services, 2) 339 | 340 | sync := newSyncWithDefaultConfig(marathon, consul) 341 | 342 | // when 343 | app.PortDefinitions[1].Labels = map[string]string{} // make it a single-registration app 344 | err := sync.SyncServices() 345 | 346 | // then 347 | services, _ = consul.GetAllServices() 348 | assert.NoError(t, err) 349 | assert.Len(t, services, 2) 350 | } 351 | 352 | func TestSync_WithDeregisteringProblems(t *testing.T) { 353 | t.Parallel() 354 | // given 355 | marathon := marathon.MarathonerStubForApps() 356 | consulStub := consul.NewConsulStub() 357 | notMarathonApp := ConsulApp("/not/marathon", 1) 358 | for _, task := range notMarathonApp.Tasks { 359 | consulStub.Register(&task, notMarathonApp) 360 | } 361 | allServices, _ := consulStub.GetAllServices() 362 | for _, s := range allServices { 363 | consulStub.FailDeregisterForID(s.ID) 364 | } 365 | sync := newSyncWithDefaultConfig(marathon, consulStub) 366 | 367 | // when 368 | err := sync.SyncServices() 369 | services, _ := consulStub.GetAllServices() 370 | 371 | // then 372 | assert.NoError(t, err) 373 | assert.Len(t, services, 1) 374 | } 375 | 376 | func TestSync_WithDeregisteringFallback(t *testing.T) { 377 | t.Parallel() 378 | // given 379 | marathon := marathon.MarathonerStubForApps() 380 | consulStub := consul.NewConsulStub() 381 | marathonApp := ConsulApp("/test/app", 1) 382 | for _, task := range marathonApp.Tasks { 383 | consulStub.Register(&task, marathonApp) 384 | } 385 | marathon.TasksStub = map[apps.AppID][]apps.Task{ 386 | apps.AppID("/test/app"): {marathonApp.Tasks[0]}, 387 | } 388 | sync := newSyncWithDefaultConfig(marathon, consulStub) 389 | 390 | // when 391 | err := sync.SyncServices() 392 | services, _ := consulStub.GetAllServices() 393 | 394 | // then 395 | assert.NoError(t, err) 396 | assert.Len(t, services, 1) 397 | } 398 | 399 | func TestSync_WithDeregisteringFallbackError(t *testing.T) { 400 | t.Parallel() 401 | // given 402 | marathon := marathon.MarathonerStubForApps() 403 | consulStub := consul.NewConsulStub() 404 | marathonApp := ConsulApp("/test/app", 1) 405 | for _, task := range marathonApp.Tasks { 406 | consulStub.Register(&task, marathonApp) 407 | } 408 | sync := newSyncWithDefaultConfig(marathon, consulStub) 409 | 410 | // when 411 | err := sync.SyncServices() 412 | services, _ := consulStub.GetAllServices() 413 | 414 | // then 415 | assert.NoError(t, err) 416 | assert.Len(t, services, 0) 417 | } 418 | 419 | func TestSync_WithMarathonProblems(t *testing.T) { 420 | t.Parallel() 421 | // given 422 | marathon := errorMarathon{} 423 | sync := newSyncWithDefaultConfig(marathon, nil) 424 | // when 425 | err := sync.SyncServices() 426 | // then 427 | assert.Error(t, err) 428 | } 429 | 430 | func TestSync_WithConsulProblems(t *testing.T) { 431 | t.Parallel() 432 | // given 433 | marathon := marathon.MarathonerStubForApps(ConsulApp("/test/app", 3)) 434 | serviceRegistry := errorServiceRegistry{} 435 | sync := newSyncWithDefaultConfig(marathon, serviceRegistry) 436 | // when 437 | err := sync.SyncServices() 438 | // then 439 | assert.Error(t, err) 440 | } 441 | 442 | func newSyncWithDefaultConfig(marathon marathon.Marathoner, serviceRegistry service.Registry) *Sync { 443 | return New(Config{Enabled: true, Leader: "localhost:8080"}, marathon, serviceRegistry, noopSyncStartedListener) 444 | } 445 | 446 | func TestSync_AddingAgentsFromMarathonTasks(t *testing.T) { 447 | t.Parallel() 448 | 449 | consulServer := consul.CreateTestServer(t) 450 | defer consulServer.Stop() 451 | 452 | consulInstance := consul.New(consul.Config{ 453 | Port: fmt.Sprintf("%d", consulServer.Config.Ports.HTTP), 454 | Tag: "marathon", 455 | }) 456 | app := ConsulApp("serviceA", 2) 457 | app.Tasks[0].Host = consulServer.Config.Bind 458 | app.Tasks[1].Host = consulServer.Config.Bind 459 | marathon := marathon.MarathonerStubWithLeaderForApps("localhost:8080", "localhost:8080", app) 460 | sync := New(Config{}, marathon, consulInstance, consulInstance.AddAgentsFromApps) 461 | 462 | // when 463 | err := sync.SyncServices() 464 | 465 | // then 466 | assert.NoError(t, err) 467 | 468 | // when 469 | services, err := consulInstance.GetAllServices() 470 | 471 | // then 472 | assert.NoError(t, err) 473 | assert.Len(t, services, 2) 474 | 475 | serviceNames := make(map[string]struct{}) 476 | for _, s := range services { 477 | serviceNames[s.Name] = struct{}{} 478 | } 479 | assert.Len(t, serviceNames, 1) 480 | assert.Contains(t, serviceNames, "serviceA") 481 | } 482 | -------------------------------------------------------------------------------- /time/time.go: -------------------------------------------------------------------------------- 1 | package time 2 | 3 | import ( 4 | "encoding/json" 5 | "strconv" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | type Timestamp struct { 11 | time.Time 12 | } 13 | 14 | func (t *Timestamp) UnmarshalJSON(b []byte) (err error) { 15 | s := strings.Trim(string(b), "\"") 16 | if s == "null" { 17 | t.Time = time.Time{} 18 | return 19 | } 20 | t.Time, err = time.Parse(time.RFC3339Nano, s) 21 | return 22 | } 23 | 24 | func (t Timestamp) MarshalJSON() ([]byte, error) { 25 | return json.Marshal(t.String()) 26 | } 27 | 28 | func (t *Timestamp) Delay() time.Duration { 29 | return time.Since(t.Time) 30 | } 31 | 32 | func (t *Timestamp) String() string { 33 | return t.Format(time.RFC3339Nano) 34 | } 35 | 36 | func (t *Timestamp) Missing() bool { 37 | return t.Unix() == (time.Time{}).Unix() 38 | } 39 | 40 | type Interval struct { 41 | time.Duration 42 | } 43 | 44 | func (t *Interval) UnmarshalJSON(b []byte) (err error) { 45 | s := strings.Trim(string(b), "\"") 46 | if s == "null" { 47 | t.Duration = 0 48 | return nil 49 | } else if value, e := strconv.ParseInt(s, 10, 64); e == nil { 50 | t.Duration = time.Duration(value) 51 | return nil 52 | } 53 | t.Duration, err = time.ParseDuration(s) 54 | return err 55 | } 56 | 57 | func (t Interval) MarshalJSON() ([]byte, error) { 58 | return json.Marshal(t.Duration.String()) 59 | } 60 | -------------------------------------------------------------------------------- /time/time_test.go: -------------------------------------------------------------------------------- 1 | package time 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | gotime "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestTimestampParsing(t *testing.T) { 12 | t.Parallel() 13 | in := "2014-03-01T23:29:30.158Z" 14 | bytes, err := json.Marshal(in) 15 | assert.NoError(t, err) 16 | var out Timestamp 17 | err = json.Unmarshal(bytes, &out) 18 | assert.NoError(t, err) 19 | assert.Equal(t, in, out.String()) 20 | } 21 | 22 | func TestDurationIntegerParsing(t *testing.T) { 23 | t.Parallel() 24 | var out Interval 25 | err := json.Unmarshal([]byte("900000000000"), &out) 26 | assert.NoError(t, err) 27 | assert.Equal(t, "15m0s", out.String()) 28 | } 29 | 30 | func TestInvalidDurationParsing(t *testing.T) { 31 | t.Parallel() 32 | var out Interval 33 | err := json.Unmarshal([]byte(`"72xyz"`), &out) 34 | assert.Error(t, err) 35 | assert.Equal(t, "0s", out.String()) 36 | } 37 | 38 | func TestDurationMarshal(t *testing.T) { 39 | t.Parallel() 40 | var out Interval 41 | out.Duration, _ = gotime.ParseDuration("15m") 42 | bytes, err := json.Marshal(out) 43 | assert.NoError(t, err) 44 | err = json.Unmarshal(bytes, &out) 45 | assert.NoError(t, err) 46 | assert.Equal(t, "15m0s", out.String()) 47 | } 48 | -------------------------------------------------------------------------------- /utils/apps.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/allegro/marathon-consul/apps" 8 | ) 9 | 10 | func ConsulApp(name string, instances int) *apps.App { 11 | return app(name, instances, 1, true, 0) 12 | } 13 | 14 | func ConsulAppWithUnhealthyInstances(name string, instances int, unhealthyInstances int) *apps.App { 15 | return app(name, instances, 1, true, unhealthyInstances) 16 | } 17 | 18 | func ConsulAppMultipleRegistrations(name string, instances int, registrations int) *apps.App { 19 | return app(name, instances, registrations, true, 0) 20 | } 21 | 22 | func NonConsulApp(name string, instances int) *apps.App { 23 | return app(name, instances, 1, false, 0) 24 | } 25 | 26 | func app(name string, instances int, registrationsPerInstance int, consul bool, unhealthyInstances int) *apps.App { 27 | var appTasks []apps.Task 28 | for i := 0; i < instances; i++ { 29 | var ports []int 30 | for j := 1; j <= registrationsPerInstance; j++ { 31 | ports = append(ports, 8080+(i*j)+j-1) 32 | } 33 | task := apps.Task{ 34 | AppID: apps.AppID(name), 35 | ID: apps.TaskID(fmt.Sprintf("%s.%d", strings.Replace(strings.Trim(name, "/"), "/", "_", -1), i)), 36 | Ports: ports, 37 | Host: "localhost", 38 | } 39 | if unhealthyInstances > 0 { 40 | unhealthyInstances-- 41 | } else { 42 | task.HealthCheckResults = []apps.HealthCheckResult{ 43 | { 44 | Alive: true, 45 | }, 46 | } 47 | } 48 | appTasks = append(appTasks, task) 49 | } 50 | 51 | labels := make(map[string]string) 52 | if consul { 53 | labels[apps.MarathonConsulLabel] = "true" 54 | } 55 | 56 | app := &apps.App{ 57 | ID: apps.AppID(name), 58 | Tasks: appTasks, 59 | Labels: labels, 60 | } 61 | 62 | if registrationsPerInstance > 1 { 63 | for i := 0; i < registrationsPerInstance; i++ { 64 | app.PortDefinitions = append(app.PortDefinitions, apps.PortDefinition{ 65 | Labels: map[string]string{"consul": ""}, 66 | }) 67 | } 68 | } 69 | 70 | return app 71 | } 72 | -------------------------------------------------------------------------------- /utils/apps_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "github.com/allegro/marathon-consul/apps" 5 | 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestConsulApp(t *testing.T) { 12 | t.Parallel() 13 | expected := &apps.App{Labels: map[string]string{"consul": "true"}, 14 | HealthChecks: []apps.HealthCheck(nil), 15 | ID: "name", 16 | Tasks: []apps.Task{{ID: "name.0", 17 | TaskStatus: "", 18 | AppID: "name", 19 | Host: "localhost", 20 | Ports: []int{8080}, 21 | HealthCheckResults: []apps.HealthCheckResult{{Alive: true}}}, 22 | {ID: "name.1", 23 | TaskStatus: "", 24 | AppID: "name", 25 | Host: "localhost", 26 | Ports: []int{8081}, 27 | HealthCheckResults: []apps.HealthCheckResult{{Alive: true}}}}} 28 | 29 | app := ConsulApp("name", 2) 30 | assert.Equal(t, expected, app) 31 | } 32 | 33 | func TestNonConsulApp(t *testing.T) { 34 | t.Parallel() 35 | expected := &apps.App{Labels: map[string]string{}, 36 | HealthChecks: []apps.HealthCheck(nil), 37 | ID: "name", 38 | Tasks: []apps.Task{{ID: "name.0", 39 | TaskStatus: "", 40 | AppID: "name", 41 | Host: "localhost", 42 | Ports: []int{8080}, 43 | HealthCheckResults: []apps.HealthCheckResult{{Alive: true}}}, 44 | {ID: "name.1", 45 | TaskStatus: "", 46 | AppID: "name", 47 | Host: "localhost", 48 | Ports: []int{8081}, 49 | HealthCheckResults: []apps.HealthCheckResult{{Alive: true}}}}} 50 | 51 | app := NonConsulApp("name", 2) 52 | assert.Equal(t, expected, app) 53 | } 54 | 55 | func TestConsulAppWithUnhelathyInstancesgreaterThanInstances(t *testing.T) { 56 | t.Parallel() 57 | expected := &apps.App{Labels: map[string]string{"consul": "true"}, 58 | HealthChecks: []apps.HealthCheck(nil), 59 | ID: "name", 60 | Tasks: []apps.Task{{ID: "name.0", 61 | TaskStatus: "", 62 | AppID: "name", 63 | Host: "localhost", 64 | Ports: []int{8080}, 65 | HealthCheckResults: nil}, 66 | {ID: "name.1", 67 | TaskStatus: "", 68 | AppID: "name", 69 | Host: "localhost", 70 | Ports: []int{8081}, 71 | HealthCheckResults: nil}}} 72 | 73 | app := ConsulAppWithUnhealthyInstances("name", 2, 5) 74 | assert.Equal(t, expected, app) 75 | } 76 | 77 | func TestConsulAppWithUnhelathyInstances(t *testing.T) { 78 | t.Parallel() 79 | expected := &apps.App{Labels: map[string]string{"consul": "true"}, 80 | HealthChecks: []apps.HealthCheck(nil), 81 | ID: "name", 82 | Tasks: []apps.Task{{ID: "name.0", 83 | TaskStatus: "", 84 | AppID: "name", 85 | Host: "localhost", 86 | Ports: []int{8080}, 87 | HealthCheckResults: nil}, 88 | {ID: "name.1", 89 | TaskStatus: "", 90 | AppID: "name", 91 | Host: "localhost", 92 | Ports: []int{8081}, 93 | HealthCheckResults: []apps.HealthCheckResult{{Alive: true}}}}} 94 | 95 | app := ConsulAppWithUnhealthyInstances("name", 2, 1) 96 | assert.Equal(t, expected, app) 97 | } 98 | -------------------------------------------------------------------------------- /utils/errors.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "fmt" 4 | 5 | func MergeErrorsOrNil(errors []error, description string) error { 6 | if len(errors) == 0 { 7 | return nil 8 | } 9 | 10 | errMessage := fmt.Sprintf("%d errors occured %s", len(errors), description) 11 | for i, err := range errors { 12 | errMessage = fmt.Sprintf("%s\n%d: %s", errMessage, i+1, err.Error()) 13 | } 14 | return fmt.Errorf(errMessage) 15 | } 16 | -------------------------------------------------------------------------------- /utils/errors_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestErrors_shouldMergeErrors(t *testing.T) { 11 | t.Parallel() 12 | // given 13 | errs := []error{errors.New("first"), errors.New("second")} 14 | 15 | // when 16 | err := MergeErrorsOrNil(errs, "testing") 17 | 18 | // then 19 | assert.EqualError(t, err, "2 errors occured testing\n1: first\n2: second") 20 | } 21 | 22 | func TestErrors_shouldReturnNilForEmptyErrors(t *testing.T) { 23 | t.Parallel() 24 | // expect 25 | assert.NoError(t, MergeErrorsOrNil([]error{}, "testing")) 26 | } 27 | -------------------------------------------------------------------------------- /utils/net.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "net" 6 | ) 7 | 8 | func HostToIPv4(host string) (net.IP, error) { 9 | IPs, err := net.LookupIP(host) 10 | if err != nil { 11 | return nil, err 12 | } 13 | for _, IP := range IPs { 14 | if IP.To4() != nil { 15 | return IP, nil 16 | } 17 | } 18 | return nil, errors.New("Could not resolve host to IPv4") 19 | } 20 | -------------------------------------------------------------------------------- /utils/net_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestHostToIPv4_(t *testing.T) { 10 | t.Parallel() 11 | 12 | // given 13 | ip, err := HostToIPv4("2001:cdba:0000:0000:0000:0000:3257:9652") 14 | 15 | // then 16 | assert.Nil(t, ip) 17 | assert.Error(t, err) 18 | 19 | // when 20 | ip, err = HostToIPv4("127.0.0.1") 21 | 22 | // then 23 | assert.Equal(t, "127.0.0.1", ip.String()) 24 | assert.NoError(t, err) 25 | 26 | // when 27 | ip, err = HostToIPv4("127.1.1.12") 28 | 29 | // then 30 | assert.Equal(t, "127.1.1.12", ip.String()) 31 | assert.NoError(t, err) 32 | 33 | // when 34 | ip, err = HostToIPv4("localhost") 35 | 36 | // then 37 | assert.Equal(t, "127.0.0.1", ip.String()) 38 | assert.NoError(t, err) 39 | 40 | // when 41 | ip, err = HostToIPv4("") 42 | 43 | // then 44 | assert.Nil(t, ip) 45 | assert.Error(t, err) 46 | } 47 | -------------------------------------------------------------------------------- /web/config.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | type Config struct { 4 | Listen string 5 | QueueSize int 6 | WorkersCount int 7 | MaxEventSize int64 8 | } 9 | -------------------------------------------------------------------------------- /web/health_handler.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | func HealthHandler(w http.ResponseWriter, _ *http.Request) { 9 | fmt.Fprintln(w, "OK") 10 | } 11 | -------------------------------------------------------------------------------- /web/health_handler_test.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestHealthHandler(t *testing.T) { 13 | t.Parallel() 14 | 15 | req, err := http.NewRequest("GET", "http://example.com/health", bytes.NewBuffer([]byte{})) 16 | assert.Nil(t, err) 17 | 18 | recorder := httptest.NewRecorder() 19 | HealthHandler(recorder, req) 20 | 21 | assert.Equal(t, 200, recorder.Code) 22 | assert.Equal(t, "OK\n", recorder.Body.String()) 23 | } 24 | --------------------------------------------------------------------------------