├── test-fixtures ├── simple.conf ├── second.conf ├── simple.conf.out ├── second.conf.out ├── config.json ├── varnish.vcl └── varnish.vcl.out ├── CHANGELOG.md ├── .gitignore ├── flag_slice_value.go ├── Makefile ├── main_test.go ├── watch_test.go ├── README.md ├── main.go ├── watch.go └── LICENSE /test-fixtures/simple.conf: -------------------------------------------------------------------------------- 1 | global 2 | maxconn 256 3 | 4 | defaults 5 | timeout connect 5000ms 6 | 7 | frontend http-in 8 | bind *:80 9 | default_backend app 10 | 11 | backend app{{range .app}} 12 | {{.}}{{end}} 13 | 14 | listen admin 15 | bind *:8080 16 | stats enable 17 | -------------------------------------------------------------------------------- /test-fixtures/second.conf: -------------------------------------------------------------------------------- 1 | global 2 | maxconn 256 3 | 4 | defaults 5 | timeout connect 5000ms 6 | 7 | frontend http-in 8 | bind *:80 9 | default_backend app2 10 | 11 | backend app2{{range .app}} 12 | {{.}}{{end}} 13 | 14 | listen admin 15 | bind *:8080 16 | stats enable 17 | -------------------------------------------------------------------------------- /test-fixtures/simple.conf.out: -------------------------------------------------------------------------------- 1 | global 2 | maxconn 256 3 | 4 | defaults 5 | timeout connect 5000ms 6 | 7 | frontend http-in 8 | bind *:80 9 | default_backend app 10 | 11 | backend app 12 | server node1_app 127.0.0.1:8000 13 | server node3_app 127.0.0.3:8000 14 | 15 | listen admin 16 | bind *:8080 17 | stats enable 18 | -------------------------------------------------------------------------------- /test-fixtures/second.conf.out: -------------------------------------------------------------------------------- 1 | global 2 | maxconn 256 3 | 4 | defaults 5 | timeout connect 5000ms 6 | 7 | frontend http-in 8 | bind *:80 9 | default_backend app2 10 | 11 | backend app2 12 | server node1_app 127.0.0.1:8000 13 | server node3_app 127.0.0.3:8000 14 | 15 | listen admin 16 | bind *:8080 17 | stats enable 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.2.0 (October 09, 2014) 2 | 3 | * Add the ability to use multiple templates & paths 4 | 5 | ## 0.1.1 (September 29, 2014) 6 | 7 | * Support for `-quiet` and `-max-wait` 8 | * Avoid updates on changes to notes or health check output 9 | * Allow access to other variables in the template (thanks @charliek) 10 | 11 | ## 0.1.0 (August 11, 2014) 12 | 13 | * Initial release 14 | -------------------------------------------------------------------------------- /test-fixtures/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "dry_run": true, 3 | "address": "127.0.0.2:8500", 4 | "templates": ["test-fixtures/simple.conf", "test-fixtures/second.conf"], 5 | "paths": ["output.conf", "output2.conf"], 6 | "reload_command": "echo 'foo' > reload_out", 7 | "backends": [ 8 | "app=foo", 9 | "app=tag.foo", 10 | "app=tag.foo@dc2:8000" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | consul-haproxy 25 | *.conf 26 | 27 | bin/ 28 | build/ 29 | -------------------------------------------------------------------------------- /test-fixtures/varnish.vcl: -------------------------------------------------------------------------------- 1 | import directors; # load the directors 2 | {{range .app}} 3 | backend {{.Node}}_{{.ID}} { 4 | .host = "{{.IP}}"; 5 | .port = "{{.Port}}"; 6 | }{{end}} 7 | 8 | sub vcl_init { 9 | new bar = directors.round_robin(); 10 | {{range .app}} 11 | bar.add_backend({{.Node}}_{{.ID}});{{end}} 12 | } 13 | 14 | sub vcl_recv { 15 | # send all traffic to the bar director: 16 | set req.backend_hint = bar.backend(); 17 | } 18 | -------------------------------------------------------------------------------- /flag_slice_value.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "strings" 4 | 5 | // AppendSliceValue implements the flag.Value interface and allows multiple 6 | // calls to the same variable to append a list. 7 | type AppendSliceValue []string 8 | 9 | func (s *AppendSliceValue) String() string { 10 | return strings.Join(*s, ",") 11 | } 12 | 13 | func (s *AppendSliceValue) Set(value string) error { 14 | if *s == nil { 15 | *s = make([]string, 0, 1) 16 | } 17 | 18 | *s = append(*s, value) 19 | return nil 20 | } 21 | -------------------------------------------------------------------------------- /test-fixtures/varnish.vcl.out: -------------------------------------------------------------------------------- 1 | import directors; # load the directors 2 | 3 | backend node1_app { 4 | .host = "127.0.0.1"; 5 | .port = "8000"; 6 | } 7 | backend node3_app { 8 | .host = "127.0.0.3"; 9 | .port = "8000"; 10 | } 11 | 12 | sub vcl_init { 13 | new bar = directors.round_robin(); 14 | 15 | bar.add_backend(node1_app); 16 | bar.add_backend(node3_app); 17 | } 18 | 19 | sub vcl_recv { 20 | # send all traffic to the bar director: 21 | set req.backend_hint = bar.backend(); 22 | } 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION = "0.2.0" 2 | DEPS = $(go list -f '{{range .TestImports}}{{.}} {{end}}' ./...) 3 | 4 | all: deps 5 | @mkdir -p bin/ 6 | go build -o bin/consul-haproxy 7 | 8 | deps: 9 | go get -d -v ./... 10 | echo $(DEPS) | xargs -n1 go get -d 11 | 12 | test: deps 13 | go list ./... | xargs -n1 go test 14 | 15 | release: deps test 16 | @rm -rf build/ 17 | @mkdir -p build 18 | gox \ 19 | -os="windows darwin linux netbsd freebsd openbsd netbsd" \ 20 | -output="build/{{.Dir}}_$(VERSION)_{{.OS}}_{{.Arch}}/consul-haproxy" 21 | @mkdir -p build/tgz 22 | (cd build && ls | xargs -I {} tar -zcvf tgz/{}.tar.gz {}) 23 | 24 | .PHONY: all deps test 25 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestWatchRE(t *testing.T) { 9 | type match struct { 10 | inp string 11 | parts []string 12 | } 13 | inps := []match{ 14 | {"bar", nil}, 15 | {"bar=", nil}, 16 | {"=zip", nil}, 17 | {"app=bar", []string{"app", "", "bar", "", ""}}, 18 | {"app=tag.bar", []string{"app", "tag.", "bar", "", ""}}, 19 | {"app=bar@dc1", []string{"app", "", "bar", "@dc1", ""}}, 20 | {"app=bar:80", []string{"app", "", "bar", "", ":80"}}, 21 | {"app=bar@dc1:80", []string{"app", "", "bar", "@dc1", ":80"}}, 22 | {"app=tag.bar@dc1:80", []string{"app", "tag.", "bar", "@dc1", ":80"}}, 23 | } 24 | 25 | for _, inp := range inps { 26 | parts := WatchRE.FindStringSubmatch(inp.inp) 27 | if len(parts) == 0 && len(inp.parts) != 0 { 28 | t.Fatalf("unexpected fail: %s", inp.inp) 29 | } 30 | if len(parts) != 0 && len(inp.parts) == 0 { 31 | t.Fatalf("unexpected parse: %s", inp.inp) 32 | } 33 | if len(parts) == 0 && len(inp.parts) == 0 { 34 | continue 35 | } 36 | if !reflect.DeepEqual(parts[1:], inp.parts) { 37 | t.Fatalf("bad: %v %v", parts[1:], inp.parts) 38 | } 39 | } 40 | } 41 | 42 | func TestReadConfig(t *testing.T) { 43 | conf := &Config{} 44 | err := readConfig("test-fixtures/config.json", conf) 45 | if err != nil { 46 | t.Fatalf("err: %v", err) 47 | } 48 | if !conf.DryRun { 49 | t.Fatalf("bad: %v", conf) 50 | } 51 | if conf.Address != "127.0.0.2:8500" { 52 | t.Fatalf("bad: %v", conf) 53 | } 54 | templates := []string{ 55 | "test-fixtures/simple.conf", 56 | "test-fixtures/second.conf", 57 | } 58 | if !reflect.DeepEqual(conf.Templates, templates) { 59 | t.Fatalf("bad: %v", conf) 60 | } 61 | paths := []string{ 62 | "output.conf", 63 | "output2.conf", 64 | } 65 | if !reflect.DeepEqual(conf.Paths, paths) { 66 | 67 | t.Fatalf("bad: %v", conf) 68 | } 69 | if conf.ReloadCommand != "echo 'foo' > reload_out" { 70 | t.Fatalf("bad: %v", conf) 71 | } 72 | backends := []string{ 73 | "app=foo", 74 | "app=tag.foo", 75 | "app=tag.foo@dc2:8000", 76 | } 77 | if !reflect.DeepEqual(conf.Backends, backends) { 78 | t.Fatalf("bad: %v", conf) 79 | } 80 | } 81 | 82 | func TestValidateConfig_Good(t *testing.T) { 83 | conf := &Config{} 84 | err := readConfig("test-fixtures/config.json", conf) 85 | if err != nil { 86 | t.Fatalf("err: %v", err) 87 | } 88 | errs := validateConfig(conf) 89 | if len(errs) > 0 { 90 | t.Fatalf("err: %v", errs) 91 | } 92 | if len(conf.watches) != 3 { 93 | t.Fatalf("bad: %v", conf.watches) 94 | } 95 | wp1 := &WatchPath{ 96 | Spec: "app=foo", 97 | Backend: "app", 98 | Service: "foo", 99 | } 100 | if !reflect.DeepEqual(wp1, conf.watches[0]) { 101 | t.Fatalf("bad: %v", conf.watches[0]) 102 | } 103 | wp2 := &WatchPath{ 104 | Spec: "app=tag.foo", 105 | Backend: "app", 106 | Tag: "tag", 107 | Service: "foo", 108 | } 109 | if !reflect.DeepEqual(wp2, conf.watches[1]) { 110 | t.Fatalf("bad: %v", conf.watches[1]) 111 | } 112 | wp3 := &WatchPath{ 113 | Spec: "app=tag.foo@dc2:8000", 114 | Backend: "app", 115 | Tag: "tag", 116 | Service: "foo", 117 | Datacenter: "dc2", 118 | Port: 8000, 119 | } 120 | if !reflect.DeepEqual(wp3, conf.watches[2]) { 121 | t.Fatalf("bad: %v", conf.watches[2]) 122 | } 123 | } 124 | 125 | func TestValidateConfig_Missing(t *testing.T) { 126 | conf := &Config{} 127 | errs := validateConfig(conf) 128 | if len(errs) != 4 { 129 | t.Fatalf("bad: %v", errs) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /watch_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "github.com/armon/consul-api" 6 | "io/ioutil" 7 | "os" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func TestMaybeRefresh(t *testing.T) { 13 | defer os.Remove("config_out") 14 | defer os.Remove("config_out2") 15 | defer os.Remove("reload_out") 16 | 17 | en1 := &consulapi.ServiceEntry{ 18 | Node: &consulapi.Node{Node: "node1", Address: "127.0.0.1"}, 19 | Service: &consulapi.AgentService{ID: "app", Port: 8000}, 20 | } 21 | en2 := &consulapi.ServiceEntry{ 22 | Node: &consulapi.Node{Node: "node3", Address: "127.0.0.3"}, 23 | Service: &consulapi.AgentService{ID: "app", Port: 8000}, 24 | } 25 | wp1 := &WatchPath{Backend: "app"} 26 | wp2 := &WatchPath{Backend: "app"} 27 | d := &backendData{ 28 | Servers: map[*WatchPath][]*consulapi.ServiceEntry{ 29 | wp1: []*consulapi.ServiceEntry{en1}, 30 | wp2: []*consulapi.ServiceEntry{en2}, 31 | }, 32 | Backends: map[string][]*WatchPath{ 33 | "app": []*WatchPath{wp1, wp2}, 34 | }, 35 | } 36 | templates := []string { 37 | "test-fixtures/simple.conf", 38 | "test-fixtures/varnish.vcl", 39 | } 40 | paths := []string { 41 | "config_out", 42 | "config_out2", 43 | } 44 | conf := &Config{ 45 | watches: []*WatchPath{wp1, wp2}, 46 | Templates: templates, 47 | Paths: paths, 48 | ReloadCommand: "echo 'foo' > reload_out", 49 | } 50 | 51 | // Attempt a refresh 52 | if maybeRefresh(conf, d) { 53 | t.Fatalf("unexpected exit") 54 | } 55 | 56 | // Check config file 57 | out, err := ioutil.ReadFile("config_out") 58 | if err != nil { 59 | t.Fatalf("err: %v", err) 60 | } 61 | expect, err := ioutil.ReadFile("test-fixtures/simple.conf.out") 62 | if err != nil { 63 | t.Fatalf("err: %v", err) 64 | } 65 | if !bytes.Equal(out, expect) { 66 | t.Fatalf("bad: %s", out) 67 | } 68 | 69 | // Check second config file 70 | out2, err := ioutil.ReadFile("config_out2") 71 | if err != nil { 72 | t.Fatalf("err: %v", err) 73 | } 74 | expect2, err := ioutil.ReadFile("test-fixtures/varnish.vcl.out") 75 | if err != nil { 76 | t.Fatalf("err: %v", err) 77 | } 78 | if !bytes.Equal(out2, expect2) { 79 | t.Fatalf("bad: %s", out) 80 | } 81 | 82 | // Check reload fie 83 | bytes, err := ioutil.ReadFile("reload_out") 84 | if err != nil { 85 | t.Fatalf("err: %v", err) 86 | } 87 | if string(bytes) != "foo\n" { 88 | t.Fatalf("bad: %v", bytes) 89 | } 90 | } 91 | 92 | func TestAllWatchesReturned(t *testing.T) { 93 | wp1 := &WatchPath{Backend: "app"} 94 | wp2 := &WatchPath{Backend: "app"} 95 | wp3 := &WatchPath{Backend: "db"} 96 | d := &backendData{ 97 | Servers: map[*WatchPath][]*consulapi.ServiceEntry{ 98 | wp1: nil, 99 | wp2: nil, 100 | }, 101 | } 102 | conf := &Config{ 103 | watches: []*WatchPath{wp1, wp2, wp3}, 104 | } 105 | 106 | if allWatchesReturned(conf, d) { 107 | t.Fatalf("unexpected done") 108 | } 109 | 110 | d.Servers[wp3] = nil 111 | if !allWatchesReturned(conf, d) { 112 | t.Fatalf("expected done") 113 | } 114 | } 115 | 116 | func TestAggregateServers(t *testing.T) { 117 | en1 := &consulapi.ServiceEntry{ 118 | Node: &consulapi.Node{Node: "node1", Address: "127.0.0.1"}, 119 | Service: &consulapi.AgentService{ID: "app", Port: 8000}, 120 | } 121 | en2 := &consulapi.ServiceEntry{ 122 | Node: &consulapi.Node{Node: "node3", Address: "127.0.0.3"}, 123 | Service: &consulapi.AgentService{ID: "app", Port: 8000}, 124 | } 125 | en3 := &consulapi.ServiceEntry{ 126 | Node: &consulapi.Node{Node: "node2", Address: "127.0.0.2"}, 127 | Service: &consulapi.AgentService{ID: "db", Port: 5000}, 128 | } 129 | wp1 := &WatchPath{Backend: "app"} 130 | wp2 := &WatchPath{Backend: "app"} 131 | wp3 := &WatchPath{Backend: "db"} 132 | d := &backendData{ 133 | Servers: map[*WatchPath][]*consulapi.ServiceEntry{ 134 | wp1: []*consulapi.ServiceEntry{en1}, 135 | wp2: []*consulapi.ServiceEntry{en2}, 136 | wp3: []*consulapi.ServiceEntry{en3}, 137 | }, 138 | Backends: map[string][]*WatchPath{ 139 | "app": []*WatchPath{wp1, wp2}, 140 | "db": []*WatchPath{wp3}, 141 | }, 142 | } 143 | agg := aggregateServers(d) 144 | if len(agg) != 2 { 145 | t.Fatalf("Bad: %v", agg) 146 | } 147 | app := agg["app"] 148 | if len(app) != 2 { 149 | t.Fatalf("Bad: %v", app) 150 | } 151 | if app[0] != en1 && app[1] != en2 { 152 | t.Fatalf("Bad: %v", app) 153 | } 154 | db := agg["db"] 155 | if len(db) != 1 { 156 | t.Fatalf("Bad: %v", db) 157 | } 158 | if db[0] != en3 { 159 | t.Fatalf("Bad: %v", db) 160 | } 161 | } 162 | 163 | func TestBuildTemplate(t *testing.T) { 164 | templates := []string { 165 | "test-fixtures/simple.conf", 166 | "test-fixtures/varnish.vcl", 167 | } 168 | expectations := []string { 169 | "test-fixtures/simple.conf.out", 170 | "test-fixtures/varnish.vcl.out", 171 | } 172 | servers := map[string][]*consulapi.ServiceEntry{ 173 | "app": []*consulapi.ServiceEntry{ 174 | &consulapi.ServiceEntry{ 175 | Node: &consulapi.Node{Node: "node1", Address: "127.0.0.1"}, 176 | Service: &consulapi.AgentService{ID: "app", Port: 8000}, 177 | }, 178 | &consulapi.ServiceEntry{ 179 | Node: &consulapi.Node{Node: "node3", Address: "127.0.0.3"}, 180 | Service: &consulapi.AgentService{ID: "app", Port: 8000}, 181 | }, 182 | }, 183 | } 184 | 185 | // Iterate through the list of templates to render 186 | for idx, templatePath := range templates { 187 | out, err := buildTemplate(templatePath, servers) 188 | if err != nil { 189 | t.Fatalf("err: %v", err) 190 | } 191 | 192 | expect, err := ioutil.ReadFile(expectations[idx]) 193 | if err != nil { 194 | t.Fatalf("err: %v", err) 195 | } 196 | 197 | if !bytes.Equal(out, expect) { 198 | t.Fatalf("bad: %s", out) 199 | } 200 | 201 | } 202 | } 203 | 204 | func TestReload(t *testing.T) { 205 | os.Remove("test_out") 206 | conf := &Config{ 207 | ReloadCommand: "echo 'foo' > test_out", 208 | } 209 | if err := reload(conf); err != nil { 210 | t.Fatalf("err: %v", err) 211 | } 212 | bytes, err := ioutil.ReadFile("test_out") 213 | if err != nil { 214 | t.Fatalf("err: %v", err) 215 | } 216 | if string(bytes) != "foo\n" { 217 | t.Fatalf("bad: %v", bytes) 218 | } 219 | os.Remove("test_out") 220 | } 221 | 222 | func TestShouldStop(t *testing.T) { 223 | ch := make(chan struct{}) 224 | if shouldStop(ch) { 225 | t.Fatalf("bad") 226 | } 227 | close(ch) 228 | if !shouldStop(ch) { 229 | t.Fatalf("bad") 230 | } 231 | } 232 | 233 | func TestAsyncNotify(t *testing.T) { 234 | ch := make(chan struct{}, 1) 235 | asyncNotify(ch) 236 | asyncNotify(ch) 237 | asyncNotify(ch) 238 | 239 | select { 240 | case <-ch: 241 | default: 242 | t.Fatalf("should work") 243 | } 244 | select { 245 | case <-ch: 246 | t.Fatalf("should not work") 247 | default: 248 | } 249 | 250 | } 251 | 252 | func TestMin(t *testing.T) { 253 | if min(1, 2) != 1 { 254 | t.Fatalf("Bad") 255 | } 256 | if min(2, 1) != 1 { 257 | t.Fatalf("Bad") 258 | } 259 | } 260 | 261 | func TestBackoff(t *testing.T) { 262 | type val struct { 263 | fail int 264 | expect time.Duration 265 | } 266 | inps := []val{ 267 | {1, 3 * time.Second}, 268 | {2, 6 * time.Second}, 269 | {3, 12 * time.Second}, 270 | {4, 24 * time.Second}, 271 | } 272 | for _, inp := range inps { 273 | if out := backoff(3*time.Second, inp.fail); out != inp.expect { 274 | t.Fatalf("bad: %v %v", inp, out) 275 | } 276 | } 277 | } 278 | 279 | func TestFormatOutput(t *testing.T) { 280 | inp := map[string][]*consulapi.ServiceEntry{ 281 | "foo": []*consulapi.ServiceEntry{ 282 | &consulapi.ServiceEntry{ 283 | Node: &consulapi.Node{Node: "node1", Address: "127.0.0.1"}, 284 | Service: &consulapi.AgentService{ID: "redis", Port: 8000}, 285 | }, 286 | &consulapi.ServiceEntry{ 287 | Node: &consulapi.Node{Node: "node3", Address: "127.0.0.3"}, 288 | Service: &consulapi.AgentService{ID: "redis", Port: 1234}, 289 | }, 290 | }, 291 | "bar": []*consulapi.ServiceEntry{ 292 | &consulapi.ServiceEntry{ 293 | Node: &consulapi.Node{Node: "node2", Address: "127.0.0.2"}, 294 | Service: &consulapi.AgentService{ID: "memcache", Port: 80}, 295 | }, 296 | &consulapi.ServiceEntry{ 297 | Node: &consulapi.Node{Node: "node4", Address: "127.0.0.4"}, 298 | Service: &consulapi.AgentService{ID: "memcache", Port: 10000}, 299 | }, 300 | }, 301 | } 302 | 303 | output := formatOutput(inp) 304 | if len(output) != 2 { 305 | t.Fatalf("bad: %v", output) 306 | } 307 | foo := output["foo"] 308 | if len(foo) != 2 { 309 | t.Fatalf("bad: %v", foo) 310 | } 311 | if foo[0].String() != "server node1_redis 127.0.0.1:8000" { 312 | t.Fatalf("Bad: %v", foo) 313 | } 314 | if foo[1].String() != "server node3_redis 127.0.0.3:1234" { 315 | t.Fatalf("Bad: %v", foo) 316 | } 317 | 318 | bar := output["bar"] 319 | if len(bar) != 2 { 320 | t.Fatalf("bad: %v", bar) 321 | } 322 | if bar[0].String() != "server node2_memcache 127.0.0.2:80" { 323 | t.Fatalf("Bad: %v", bar) 324 | } 325 | if bar[1].String() != "server node4_memcache 127.0.0.4:10000" { 326 | t.Fatalf("Bad: %v", bar) 327 | } 328 | } 329 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DEPRECATED: consul-haproxy 2 | 3 | **Deprecated!** This project is deprecated. Consul HAProxy has been replaced by [Consul Template](https://github.com/hashicorp/consul-template). This repository is kept for history and legacy purposes. Please use Consul Template instead. 4 | 5 | --- 6 | 7 | This project provides `consul-haproxy`, a daemon for dynamically 8 | configuring HAProxy using data from Consul. 9 | 10 | The daemon watches any number of backends for updates, and when 11 | appropriate renders an configuration template for HAProxy and then 12 | invokes a reload command, which can gracefully reload HAProxy. This 13 | allows for a zero-downtime reload of HAProxy which is populated by 14 | Consul. 15 | 16 | ## Download & Compilation 17 | 18 | Download a release from the [releases page](https://github.com/hashicorp/consul-haproxy/releases), or compile from source: 19 | 20 | ``` 21 | $ make 22 | $ ./bin/consul-haproxy -h 23 | ``` 24 | 25 | ## Usage 26 | 27 | The `consul-haproxy` command takes a number of CLI flags: 28 | 29 | * `-addr` - Provides the HTTP address of a Consul agent. By default this 30 | assumes a local agent at "127.0.0.1:8500". 31 | 32 | * `-backend` - Backend specification. Can be provided multiple times. 33 | The specification of a backend is documented below. 34 | 35 | * `-dry` - Dry run. Emit config file to stdout. 36 | 37 | * `-f` - Path to config file, overwrites CLI flags. The format of the 38 | file is documented below. 39 | 40 | * `-in`- Path to a template file. This is the template that is rendered 41 | to generate the configuration file at `-out`. It uses the Golang templating 42 | system. Docs for that are [here](http://golang.org/pkg/text/template/). 43 | Can be provided multiple times. If specified multiple times, specify the 44 | same number of paths with `-out`. 45 | 46 | * `-out` - Path to output configuration file. This path must be writable 47 | by `consul-haproxy` or the file cannot be updated. This can be specified 48 | multiple times. 49 | 50 | * `-reload` - Command to invoke to reload configuration. This command can 51 | be any executable, and should be used to reload HAProxy. This is invoked 52 | only after the configuration file is updated. 53 | 54 | * `-quiet` - Quiet specifies a duration of time to wait for no updates 55 | before writing out the new configuration. This allows for waiting until 56 | a service stabilizes to prevent many different reloads. 57 | 58 | * `-max-wait` - Max wait is used to limit how waiting is done for a quiet 59 | period before forcing a reload. This defaults to 4x the `-quiet` value. 60 | As an example, if `-quiet=30s` but the backends are constantly flapping, 61 | a refresh will be forced after 2 minutes. 62 | 63 | In addition to using CLI flags, `consul-haproxy` can be configured using a 64 | file given the `-f` flag. A configuration file overrides any values given by 65 | the CLI unless otherwise specified. The configuration file should be a JSON 66 | object with the following keys: 67 | 68 | * `address` - Same as `-addr` CLI flag. 69 | * `backends` - A list of backend specifications. This is merged with any 70 | backends provided via the CLI. 71 | * `dry_run` - Same as `-dry` CLI flag. 72 | * `paths` - Same as `-out` CLI flag. . This value should be a list of paths and 73 | is merged with any paths provided via the CLI. 74 | * `reload_command` - Same as `-reload` CLI flag. 75 | * `templates` - Same as `-in` CLI flag. This value should be a list of templates 76 | and is merged with any paths provided via the CLI. 77 | * `quiet` - Same as `-quiet` CLI flag. 78 | * `max_wait` - Same as `-max-wait` CLI flag. 79 | 80 | ## Backend Specification 81 | 82 | One of the key configuration values to `consul-haproxy` is the backends that 83 | it should monitor. These are the service entries that are watched for changes 84 | and used to populate the configuration file. The syntax for a backend is: 85 | 86 | backend_name=tag.service@datacenter:port 87 | 88 | The specification provides a variable name for the template `backend_name`, 89 | which is defined as the entries that match the given tag and service for a specific 90 | datacenter. A port can also be provided, which overrides the port specified by 91 | the service. The tag, datacenter, and port are all optional and can be omitted. 92 | 93 | Below are a few examples: 94 | 95 | * `app=release.webapp` - This defines a template variable `app` which watches for 96 | the `webapp` service, filtering on the `release` tag. 97 | 98 | * `db=mysql@east-aws:5500` - This defines a template variable `db` which watches for 99 | the `mysql` service in the `east-aws` datacenter, using port 5500. 100 | 101 | A useful features is the ability to specify multiple backends with the same variable 102 | name. This causes the nodes to be merged. This can be used to merge nodes with various 103 | tags, or different datacenters together. As an example, we can define: 104 | 105 | app=webapp@dc1 106 | app=webapp@dc2 107 | app=webapp@dc3 108 | 109 | This backend specification sets `app` variable to be the union of the servers 110 | in the `dc1`, `dc2`, and `dc3` datacenters. 111 | 112 | ## Template Language 113 | 114 | The template language is the Golang text/template package, which is 115 | [fully documented here](http://golang.org/pkg/text/template/). However, the 116 | basic usage is quite simple. 117 | 118 | As an example, suppose we define a simple backends with: 119 | 120 | app=webapp@east-aws:8000 121 | cache=redis 122 | 123 | 124 | We might provide a very basic template like: 125 | 126 | global 127 | daemon 128 | maxconn 256 129 | 130 | defaults 131 | mode tcp 132 | timeout connect 5000ms 133 | timeout client 60000ms 134 | timeout server 60000ms 135 | 136 | listen http-in 137 | bind *:80{{range .app}} 138 | {{.}} maxconn 32{{end}} 139 | 140 | listen cache-in 141 | bind *:4444{{range .cache}} 142 | {{.}} maxconn 64{{end}} 143 | 144 | This will populate the `http-in` block with the servers 145 | in the `app` backend, and the `cache-in` with the servers 146 | in the `cache` backend. This template will be re-rendered when 147 | any of those servers changing, allowing for dynamic updates. 148 | 149 | ## Example 150 | 151 | We run the example below against our 152 | [NYC demo server](http://nyc3.demo.consul.io). This lets you set 153 | quickly test consul-haproxy. 154 | 155 | First lets create a simple template: 156 | 157 | global 158 | daemon 159 | maxconn 256 160 | 161 | defaults 162 | mode tcp 163 | timeout connect 5000ms 164 | timeout client 60000ms 165 | timeout server 60000ms 166 | 167 | listen http-in 168 | bind *:8000{{range .c}} 169 | {{.}}{{end}} 170 | 171 | Now, we can run the following to get our output configuration: 172 | 173 | consul-haproxy -addr=demo.consul.io -in in.conf -backend "c=consul@nyc3:80" -backend "c=consul@sfo1:80" -dry 174 | 175 | When this runs, we should see something like the following: 176 | 177 | global 178 | daemon 179 | maxconn 256 180 | 181 | defaults 182 | mode tcp 183 | timeout connect 5000ms 184 | timeout client 60000ms 185 | timeout server 60000ms 186 | 187 | listen http-in 188 | bind *:8000 189 | server 0_nyc3-consul-1_consul 192.241.159.115:80 190 | server 0_nyc3-consul-2_consul 192.241.158.205:80 191 | server 0_nyc3-consul-3_consul 198.199.77.133:80 192 | server 1_sfo1-consul-2_consul 162.243.155.82:80 193 | server 1_sfo1-consul-1_consul 107.170.195.169:80 194 | server 1_sfo1-consul-3_consul 107.170.195.158:80 195 | 196 | ## Varnish Example 197 | 198 | 199 | derived from the varnish director example from the varnish page: https://www.varnish-cache.org/docs/trunk/users-guide/vcl-backends.html#directors 200 | 201 | import directors; # load the directors 202 | {{range .c}} 203 | backend {{.Node}}_{{.ID}} { 204 | .host = "{{.IP}}"; 205 | .port = "{{.Port}}"; 206 | }{{end}} 207 | 208 | sub vcl_init { 209 | new bar = directors.round_robin(); 210 | {{range .c}} 211 | bar.add_backend({{.Node}}_{{.ID}});{{end}} 212 | } 213 | 214 | sub vcl_recv { 215 | # send all traffic to the bar director: 216 | set req.backend_hint = bar.backend(); 217 | } 218 | 219 | Now, we run the following command: 220 | 221 | consul-haproxy -addr=demo.consul.io -in in.conf -backend "c=consul@nyc1:80" -backend "c=consul@sfo1:80" -dry 222 | 223 | The following should return: 224 | 225 | backend 0_nyc1-server-1_consul { 226 | .host = "192.241.159.115"; 227 | .port = "80"; 228 | } 229 | backend 0_nyc1-server-3_consul { 230 | .host = "198.199.77.133"; 231 | .port = "80"; 232 | } 233 | backend 0_nyc1-server-2_consul { 234 | .host = "162.243.162.228"; 235 | .port = "80"; 236 | } 237 | backend 1_sfo1-server-3_consul { 238 | .host = "107.170.196.151"; 239 | .port = "80"; 240 | } 241 | backend 1_sfo1-server-2_consul { 242 | .host = "107.170.195.154"; 243 | .port = "80"; 244 | } 245 | backend 1_sfo1-server-1_consul { 246 | .host = "162.243.153.242"; 247 | .port = "80"; 248 | } 249 | 250 | sub vcl_init { 251 | new bar = directors.round_robin(); 252 | 253 | bar.add_backend(0_nyc1-server-1_consul); 254 | bar.add_backend(0_nyc1-server-3_consul); 255 | bar.add_backend(0_nyc1-server-2_consul); 256 | bar.add_backend(1_sfo1-server-3_consul); 257 | bar.add_backend(1_sfo1-server-2_consul); 258 | bar.add_backend(1_sfo1-server-1_consul); 259 | } 260 | 261 | sub vcl_recv { 262 | # send all traffic to the bar director: 263 | set req.backend_hint = bar.backend(); 264 | } 265 | 266 | 267 | 268 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "flag" 8 | "fmt" 9 | "io/ioutil" 10 | "log" 11 | "os" 12 | "os/signal" 13 | "path/filepath" 14 | "regexp" 15 | "strconv" 16 | "strings" 17 | "syscall" 18 | "time" 19 | 20 | "github.com/mitchellh/mapstructure" 21 | ) 22 | 23 | // WatchRE is used to parse a backend configuration. The config should 24 | // look like "backend=tag.service@datacenter:port". However, the tag, port and 25 | // datacenter are optional, so it can also be provided as "backend=service" 26 | var WatchRE = regexp.MustCompile("^([^=]+)=([^.]+\\.)?([^.:@]+)(@[^.:]+)?(:[0-9]+)?$") 27 | 28 | // WatchPath represents a path we need to watch 29 | type WatchPath struct { 30 | Spec string 31 | Backend string 32 | Service string 33 | Tag string 34 | Datacenter string 35 | Port int 36 | } 37 | 38 | // Config is used to configure the HAProxy connector 39 | type Config struct { 40 | // DryRun is used to avoid actually modifying the file 41 | // or reloading HAProxy. 42 | DryRun bool `mapstructure:"dry_run"` 43 | 44 | // Address is the Consul HTTP API address 45 | Address string `mapstructure:"address"` 46 | 47 | // Path to the HAProxy template file 48 | Templates []string `mapstructure:"templates"` 49 | 50 | // Path to the HAProxy configuration file to write 51 | Paths []string `mapstructure:"paths"` 52 | 53 | // Command used to reload HAProxy 54 | ReloadCommand string `mapstructure:"reload_command"` 55 | 56 | // Backends are used to specify what we watch. Given as: 57 | // "name=(tag.)service" 58 | Backends []string `mapstructure:"backends"` 59 | 60 | // Quiet is how long we wait for a "quiet" period before 61 | // trigger the re-build and re-load. This allows us to 62 | // wait for the system to reach a quiescent state instead 63 | // of trigger constant updates. 64 | Quiet time.Duration `mapstructure:"quiet"` 65 | 66 | // MaxWait works with Quiet to limit the maximum amount of 67 | // time we wait before forcing a reload to take place. 68 | // In the case of an unstable system (constant flapping), 69 | // this allows progress to be made. Defaults to 4x the 70 | // Quiet value if not provided. 71 | MaxWait time.Duration `mapstructure:"max_wait"` 72 | 73 | // watches are the watches we need to track 74 | watches []*WatchPath 75 | } 76 | 77 | func main() { 78 | os.Exit(realMain()) 79 | } 80 | 81 | // getConfig is used to read our configuration 82 | func getConfig() (*Config, error) { 83 | var configFile string 84 | var backends []string 85 | var templates []string 86 | var paths []string 87 | 88 | conf := &Config{} 89 | cmdFlags := flag.NewFlagSet("consul-haproxy", flag.ContinueOnError) 90 | cmdFlags.Usage = usage 91 | cmdFlags.StringVar(&conf.Address, "addr", "127.0.0.1:8500", "consul HTTP API address with port") 92 | cmdFlags.Var((*AppendSliceValue)(&templates), "in", "template path") 93 | cmdFlags.Var((*AppendSliceValue)(&paths), "out", "config path") 94 | cmdFlags.StringVar(&conf.ReloadCommand, "reload", "", "reload command") 95 | cmdFlags.StringVar(&configFile, "f", "", "config file") 96 | cmdFlags.BoolVar(&conf.DryRun, "dry", false, "dry run") 97 | cmdFlags.DurationVar(&conf.Quiet, "quiet", 0, "quiet period") 98 | cmdFlags.DurationVar(&conf.MaxWait, "max-wait", 0, "maximum wait for a quiet period") 99 | cmdFlags.Var((*AppendSliceValue)(&backends), "backend", "backend to populate") 100 | if err := cmdFlags.Parse(os.Args[1:]); err != nil { 101 | return nil, err 102 | } 103 | 104 | // Parse the configuration file if given 105 | if configFile != "" { 106 | if err := readConfig(configFile, conf); err != nil { 107 | return nil, fmt.Errorf("Failed to read config file: %v", err) 108 | } 109 | } 110 | 111 | // Merge the templates, paths, and backends together 112 | conf.Templates = append(conf.Templates, templates...) 113 | conf.Paths = append(conf.Paths, paths...) 114 | conf.Backends = append(conf.Backends, backends...) 115 | return conf, nil 116 | } 117 | 118 | // realMain is the actual entry point, but we wrap it to set 119 | // a proper exit code on return 120 | func realMain() int { 121 | if len(os.Args) == 1 { 122 | usage() 123 | return 1 124 | } 125 | 126 | // Read the configuration 127 | conf, err := getConfig() 128 | if err != nil { 129 | log.Printf("[ERR] %v", err) 130 | return 1 131 | } 132 | 133 | // Sanity check the configuration 134 | if errs := validateConfig(conf); len(errs) != 0 { 135 | for _, err := range errs { 136 | log.Printf("[ERR] %v", err) 137 | } 138 | return 1 139 | } 140 | 141 | // Start watching for changes 142 | stopCh, finishCh := watch(conf) 143 | 144 | // Wait for termination 145 | return waitForTerm(conf, stopCh, finishCh) 146 | } 147 | 148 | // readConfig is used to read a configuration file 149 | func readConfig(path string, config *Config) error { 150 | // Read the file 151 | contents, err := ioutil.ReadFile(path) 152 | if err != nil { 153 | return err 154 | } 155 | 156 | // Decode the file 157 | var raw interface{} 158 | if err := json.NewDecoder(bytes.NewReader(contents)).Decode(&raw); err != nil { 159 | return err 160 | } 161 | 162 | // Map to our output 163 | if err := mapstructure.Decode(raw, config); err != nil { 164 | return err 165 | } 166 | return nil 167 | } 168 | 169 | // validateConfig is used to sanity check the configuration 170 | func validateConfig(conf *Config) (errs []error) { 171 | // Check the template 172 | if len(conf.Templates) == 0 { 173 | errs = append(errs, errors.New("missing template path")) 174 | } else { 175 | for _, t := range conf.Templates { 176 | _, err := ioutil.ReadFile(t) 177 | if err != nil { 178 | errs = append(errs, fmt.Errorf("failed to read template '%s': %v", t, err)) 179 | } 180 | } 181 | } 182 | 183 | if len(conf.Paths) == 0 && !conf.DryRun { 184 | errs = append(errs, errors.New("missing configuration path")) 185 | } 186 | 187 | if (len(conf.Templates) != len(conf.Paths)) && !conf.DryRun { 188 | errs = append(errs, errors.New("number of templates and paths do not match")) 189 | } 190 | 191 | if conf.ReloadCommand == "" && !conf.DryRun { 192 | errs = append(errs, errors.New("missing reload command")) 193 | } 194 | 195 | if len(conf.Backends) == 0 { 196 | errs = append(errs, errors.New("missing backends to populate")) 197 | } 198 | 199 | for _, b := range conf.Backends { 200 | parts := WatchRE.FindStringSubmatch(b) 201 | if parts == nil || len(parts) != 6 { 202 | errs = append(errs, fmt.Errorf("Backend '%s' could not be parsed", b)) 203 | continue 204 | } 205 | var port int 206 | if parts[5] != "" { 207 | p, err := strconv.ParseInt(strings.TrimPrefix(parts[5], ":"), 10, 64) 208 | if err != nil { 209 | errs = append(errs, fmt.Errorf("Backend '%s' port could not be parsed", b)) 210 | continue 211 | } 212 | port = int(p) 213 | } 214 | wp := &WatchPath{ 215 | Spec: parts[0], 216 | Backend: parts[1], 217 | Tag: strings.TrimSuffix(parts[2], "."), 218 | Service: parts[3], 219 | Datacenter: strings.TrimPrefix(parts[4], "@"), 220 | Port: port, 221 | } 222 | conf.watches = append(conf.watches, wp) 223 | } 224 | 225 | // Ensure a non-negative time interval 226 | if conf.Quiet < 0 || conf.MaxWait < 0 { 227 | errs = append(errs, errors.New("Cannot specify a negative time interval")) 228 | } 229 | 230 | // Handle setting a default MaxWait if a quiet period 231 | // is configured 232 | if conf.Quiet != 0 && conf.MaxWait == 0 { 233 | conf.MaxWait = 4 * conf.Quiet 234 | } 235 | 236 | return 237 | } 238 | 239 | // waitForTerm waits until we receive a signal to exit 240 | func waitForTerm(conf *Config, stopCh, finishCh chan struct{}) int { 241 | signalCh := make(chan os.Signal, 1) 242 | signal.Notify(signalCh, os.Interrupt, syscall.SIGHUP) 243 | for { 244 | select { 245 | case sig := <-signalCh: 246 | switch sig { 247 | case syscall.SIGHUP: 248 | // Read the configuration 249 | log.Printf("[INFO] SIGHUP received, reloading configuration...") 250 | newConf, err := getConfig() 251 | if err != nil { 252 | log.Printf("[ERR] Failed to read new config: %v", err) 253 | continue 254 | } 255 | 256 | // Sanity check the configuration 257 | if errs := validateConfig(newConf); len(errs) != 0 { 258 | for _, err := range errs { 259 | log.Printf("[ERR] %v", err) 260 | } 261 | continue 262 | } 263 | 264 | // Switch to the new configuration 265 | conf = newConf 266 | 267 | // Stop the existing watcher 268 | close(stopCh) 269 | 270 | // Start a new watcher 271 | stopCh, finishCh = watch(conf) 272 | log.Printf("[INFO] Configuration reload complete") 273 | 274 | default: 275 | log.Printf("[WARN] Received %v signal, shutting down", sig) 276 | return 0 277 | } 278 | case <-finishCh: 279 | if conf.DryRun { 280 | return 0 281 | } 282 | log.Printf("[WARN] Aborting watching for changes, shutting down") 283 | return 1 284 | } 285 | } 286 | return 0 287 | } 288 | 289 | func usage() { 290 | cmd := filepath.Base(os.Args[0]) 291 | fmt.Fprintf(os.Stderr, strings.TrimSpace(helpText)+"\n\n", cmd) 292 | } 293 | 294 | const helpText = ` 295 | Usage: %s [options] 296 | 297 | Watches a service group in Consul and dynamically configures 298 | an HAProxy backend. The process runs continuously, monitoring 299 | all the backends for changes. When there is a change, the template 300 | file is rendered to a destination path, and a reload command is 301 | invoked. This allows HAProxy configuration to be updated in real 302 | time using Consul. 303 | 304 | Backends are specified using the following syntax: 305 | 306 | app=release.webapp@east-aws:8000 307 | 308 | In this syntax, we are defining a template variable 'app', 309 | which is populated from the 'webapp' service, 'release' tag, in the 310 | 'east-aws' datacenter, using port 8000. If the port is given it 311 | overrides any specified by the service. The tag, datacenter 312 | and port are optional. So we could also specify this as: 313 | 314 | app=webapp 315 | 316 | This exports the 'app' variable as just the nodes in the 'webapp' 317 | service in the local datacenter. Multiple backends can be specified, 318 | and even multiple watches for a given backend. 319 | 320 | For example: 321 | 322 | app=webapp@east-aws 323 | app=webapp@west-aws 324 | 325 | This will watch both the 'east-aws' and 'west-aws' datacenters to 326 | populate the nodes in the 'app' backend. This can be used to merge 327 | multiple tags, datacenters, etc into a single backend. 328 | 329 | Options: 330 | 331 | -addr=127.0.0.1:8500 Provides the HTTP address of a Consul agent. 332 | -backend=spec Backend specification. Can be provided multiple times. 333 | -dry Dry run. Emit config file to stdout. 334 | -f=path Path to config file, overwrites CLI flags 335 | -in=path Path to a template file. Can be provided multiple times. 336 | -out=path Path to output configuration file. Can be provided multiple times. 337 | -reload=cmd Command to invoke to reload configuration 338 | -quiet=0s Period to wait without updates before trigger reload. 339 | -max-wait=0s Maxium time to wait for quiet period. Default 4x of -quiet. 340 | ` 341 | -------------------------------------------------------------------------------- /watch.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "math" 9 | "net" 10 | "os" 11 | "os/exec" 12 | "reflect" 13 | "runtime" 14 | "sync" 15 | "text/template" 16 | "time" 17 | 18 | "github.com/armon/consul-api" 19 | ) 20 | 21 | const ( 22 | // failSleep controls how long to sleep on a failure 23 | failSleep = 5 * time.Second 24 | 25 | // maxFailures controls the maximum number of failures 26 | // before we limit the sleep value 27 | maxFailures = 5 28 | 29 | // waitTime is used to control how long we do a blocking 30 | // query for 31 | waitTime = 60 * time.Second 32 | ) 33 | 34 | type backendData struct { 35 | sync.Mutex 36 | 37 | // Client is a shared Consul client 38 | Client *consulapi.Client 39 | 40 | // Servers maps each watch path to a list of entries 41 | Servers map[*WatchPath][]*consulapi.ServiceEntry 42 | 43 | // Backends maps a backend to a list of watch paths used 44 | // to build up the server list 45 | Backends map[string][]*WatchPath 46 | 47 | // ChangeCh is used to inform of an update 48 | ChangeCh chan struct{} 49 | 50 | // StopCh is used to trigger a stop 51 | StopCh chan struct{} 52 | 53 | // quietTimer is used to wati for quiescence 54 | quietTimer <-chan time.Time 55 | 56 | // maxWaitTimer is used to prevent unbounded waiting 57 | // for quiescence 58 | maxWaitTimer <-chan time.Time 59 | } 60 | 61 | // watch is used to start a long running watcher to handle updates. 62 | // Returns a stopCh, and a finishCh. 63 | func watch(conf *Config) (chan struct{}, chan struct{}) { 64 | stopCh := make(chan struct{}) 65 | finishCh := make(chan struct{}) 66 | go runWatch(conf, stopCh, finishCh) 67 | return stopCh, finishCh 68 | } 69 | 70 | // runWatch is a long running routine that watches with a 71 | // given configuration 72 | func runWatch(conf *Config, stopCh, doneCh chan struct{}) { 73 | defer close(doneCh) 74 | 75 | // Create the consul client 76 | consulConf := consulapi.DefaultConfig() 77 | if conf.Address != "" { 78 | consulConf.Address = conf.Address 79 | } 80 | 81 | // Attempt to contact the agent 82 | client, err := consulapi.NewClient(consulConf) 83 | if err != nil { 84 | log.Printf("[ERR] Failed to initialize consul client: %v", err) 85 | return 86 | } 87 | if _, err := client.Agent().NodeName(); err != nil { 88 | log.Printf("[ERR] Failed to contact consul agent: %v", err) 89 | return 90 | } 91 | 92 | // Create a backend store 93 | data := &backendData{ 94 | Client: client, 95 | Servers: make(map[*WatchPath][]*consulapi.ServiceEntry), 96 | Backends: make(map[string][]*WatchPath), 97 | ChangeCh: make(chan struct{}, 1), 98 | StopCh: stopCh, 99 | } 100 | 101 | // Start the watches 102 | data.Lock() 103 | for idx, watch := range conf.watches { 104 | data.Backends[watch.Backend] = append(data.Backends[watch.Backend], watch) 105 | go runSingleWatch(conf, data, idx, watch) 106 | } 107 | data.Unlock() 108 | 109 | // Monitor for changes or stop 110 | for { 111 | select { 112 | case <-data.ChangeCh: 113 | if maybeRefresh(conf, data) { 114 | return 115 | } 116 | 117 | case <-data.quietTimer: 118 | data.quietTimer = nil 119 | data.maxWaitTimer = nil 120 | if forceRefresh(conf, data) { 121 | return 122 | } 123 | 124 | case <-data.maxWaitTimer: 125 | data.quietTimer = nil 126 | data.maxWaitTimer = nil 127 | if forceRefresh(conf, data) { 128 | return 129 | } 130 | 131 | case <-stopCh: 132 | return 133 | } 134 | } 135 | } 136 | 137 | // maybeRefresh is used to handle a potential config update 138 | func maybeRefresh(conf *Config, data *backendData) (exit bool) { 139 | // Ignore initial updates until all the data is ready 140 | if !allWatchesReturned(conf, data) { 141 | return 142 | } 143 | 144 | // If a quiet period is enabled, start the timer 145 | if conf.Quiet != 0 { 146 | data.quietTimer = time.After(conf.Quiet) 147 | if data.maxWaitTimer == nil { 148 | data.maxWaitTimer = time.After(conf.MaxWait) 149 | } 150 | return 151 | } 152 | 153 | return forceRefresh(conf, data) 154 | } 155 | 156 | // forceRefresh is used to immediately refresh 157 | func forceRefresh(conf *Config, data *backendData) (exit bool) { 158 | // Merge the data for each backend 159 | backendServers := aggregateServers(data) 160 | 161 | // Iterate through the list of templates to render 162 | for idx, templatePath := range conf.Templates { 163 | 164 | // Build the output template 165 | output, err := buildTemplate(templatePath, backendServers) 166 | if err != nil { 167 | log.Printf("[ERR] %v", err) 168 | return true 169 | } 170 | 171 | // Check for a dry run 172 | if conf.DryRun { 173 | fmt.Printf("%s\n", output) 174 | return true 175 | } 176 | 177 | // Write out the configuration 178 | if err := ioutil.WriteFile(conf.Paths[idx], output, 0660); err != nil { 179 | log.Printf("[ERR] Failed to write config file at %s: %v", conf.Paths[idx], err) 180 | return true 181 | } 182 | log.Printf("[INFO] Updated configuration file at %s", conf.Paths[idx]) 183 | } 184 | 185 | // Invoke the reload hook 186 | if err := reload(conf); err != nil { 187 | log.Printf("[ERR] Failed to reload: %v", err) 188 | } else { 189 | log.Printf("[INFO] Completed reload") 190 | } 191 | return 192 | } 193 | 194 | // allWatchesReturned checks if all the watches have some 195 | // data registered. Prevents early template generation. 196 | func allWatchesReturned(conf *Config, data *backendData) bool { 197 | data.Lock() 198 | defer data.Unlock() 199 | return len(data.Servers) >= len(conf.watches) 200 | } 201 | 202 | // aggregateServers merges the watches belonging to each 203 | // backend together to prepare for template generation 204 | func aggregateServers(data *backendData) map[string][]*consulapi.ServiceEntry { 205 | backendServers := make(map[string][]*consulapi.ServiceEntry) 206 | data.Lock() 207 | defer data.Unlock() 208 | for backend, watches := range data.Backends { 209 | var all []*consulapi.ServiceEntry 210 | for _, watch := range watches { 211 | entries := data.Servers[watch] 212 | all = append(all, entries...) 213 | } 214 | backendServers[backend] = all 215 | } 216 | return backendServers 217 | } 218 | 219 | // buildTemplate is used to build the output templates 220 | // from the configuration and server list 221 | func buildTemplate(templatePath string, 222 | servers map[string][]*consulapi.ServiceEntry) ([]byte, error) { 223 | // Format the output 224 | outVars := formatOutput(servers) 225 | 226 | // Read the template 227 | raw, err := ioutil.ReadFile(templatePath) 228 | if err != nil { 229 | return nil, fmt.Errorf("Failed to read template: %v", err) 230 | } 231 | 232 | // Create the template 233 | templ, err := template.New("output").Parse(string(raw)) 234 | if err != nil { 235 | return nil, fmt.Errorf("Failed to parse the template: %v", err) 236 | } 237 | 238 | // Generate the output 239 | var output bytes.Buffer 240 | if err := templ.Execute(&output, outVars); err != nil { 241 | return nil, fmt.Errorf("Failed to generate the template: %v", err) 242 | } 243 | return output.Bytes(), nil 244 | } 245 | 246 | // runSingleWatch is used to query a single watch path for changes 247 | func runSingleWatch(conf *Config, data *backendData, idx int, watch *WatchPath) { 248 | health := data.Client.Health() 249 | opts := &consulapi.QueryOptions{ 250 | WaitTime: waitTime, 251 | } 252 | if watch.Datacenter != "" { 253 | opts.Datacenter = watch.Datacenter 254 | } 255 | 256 | failures := 0 257 | for { 258 | if shouldStop(data.StopCh) { 259 | return 260 | } 261 | entries, qm, err := health.Service(watch.Service, watch.Tag, true, opts) 262 | if err != nil { 263 | log.Printf("[ERR] Failed to fetch service nodes: %v", err) 264 | } 265 | 266 | // Patch the entries as necessary 267 | for _, entry := range entries { 268 | // Modify the node name to prefix with the watch ID. This 269 | // prevents a name conflict on duplicate names 270 | entry.Node.Node = fmt.Sprintf("%d_%s", idx, entry.Node.Node) 271 | 272 | // Patch the port if provided 273 | if watch.Port != 0 { 274 | entry.Service.Port = watch.Port 275 | } 276 | 277 | // Clear the health output to prevent reloading due to changes 278 | // in output text since we don't care. 279 | for _, c := range entry.Checks { 280 | c.Notes = "" 281 | c.Output = "" 282 | } 283 | } 284 | 285 | // Update the entries. If this is the first read, do it on error 286 | data.Lock() 287 | old, ok := data.Servers[watch] 288 | if !ok || (err == nil && !reflect.DeepEqual(old, entries)) { 289 | data.Servers[watch] = entries 290 | asyncNotify(data.ChangeCh) 291 | if !conf.DryRun { 292 | log.Printf("[DEBUG] Updated nodes for %v", watch.Spec) 293 | } 294 | } 295 | data.Unlock() 296 | 297 | // Stop immediately on a dry run 298 | if conf.DryRun { 299 | return 300 | } 301 | 302 | // Check for an error 303 | if err != nil { 304 | failures = min(failures+1, maxFailures) 305 | time.Sleep(backoff(failSleep, failures)) 306 | } else { 307 | failures = 0 308 | opts.WaitIndex = qm.LastIndex 309 | } 310 | } 311 | } 312 | 313 | // reload is used to invoke the reload command 314 | func reload(conf *Config) error { 315 | // Determine the shell invocation based on OS 316 | var shell, flag string 317 | if runtime.GOOS == "windows" { 318 | shell = "cmd" 319 | flag = "/C" 320 | } else { 321 | shell = "/bin/sh" 322 | flag = "-c" 323 | } 324 | 325 | // Create and invoke the command 326 | cmd := exec.Command(shell, flag, conf.ReloadCommand) 327 | cmd.Stdout = os.Stdout 328 | cmd.Stderr = os.Stderr 329 | return cmd.Run() 330 | } 331 | 332 | // shouldStop checks for a closed control channel 333 | func shouldStop(ch chan struct{}) bool { 334 | select { 335 | case <-ch: 336 | return true 337 | default: 338 | return false 339 | } 340 | } 341 | 342 | // asyncNotify is used to notify a channel 343 | func asyncNotify(ch chan struct{}) { 344 | select { 345 | case ch <- struct{}{}: 346 | default: 347 | } 348 | } 349 | 350 | // min returns the min of two ints 351 | func min(a, b int) int { 352 | if a < b { 353 | return a 354 | } 355 | return b 356 | } 357 | 358 | // backoff is used to compute an exponential backoff 359 | func backoff(interval time.Duration, times int) time.Duration { 360 | times-- 361 | return interval * time.Duration(math.Pow(2, float64(times))) 362 | } 363 | 364 | // ServerEntry is the full data structure exposed to 365 | // the template for each server 366 | type ServerEntry struct { 367 | ID string 368 | Service string 369 | Tags []string 370 | Port int 371 | IP net.IP 372 | Node string 373 | } 374 | 375 | // String is the default text representation of a server 376 | func (se *ServerEntry) String() string { 377 | name := fmt.Sprintf("%s_%s", se.Node, se.ID) 378 | addr := &net.TCPAddr{IP: se.IP, Port: se.Port} 379 | return fmt.Sprintf("server %s %s", name, addr) 380 | } 381 | 382 | // formatOutput converts the service entries into a format 383 | // suitable for templating into the HAProxy file 384 | func formatOutput(inp map[string][]*consulapi.ServiceEntry) map[string][]*ServerEntry { 385 | out := make(map[string][]*ServerEntry) 386 | for backend, entries := range inp { 387 | servers := make([]*ServerEntry, len(entries)) 388 | for idx, entry := range entries { 389 | servers[idx] = &ServerEntry{ 390 | ID: entry.Service.ID, 391 | Service: entry.Service.Service, 392 | Tags: entry.Service.Tags, 393 | Port: entry.Service.Port, 394 | IP: net.ParseIP(entry.Node.Address), 395 | Node: entry.Node.Node, 396 | } 397 | } 398 | out[backend] = servers 399 | } 400 | return out 401 | } 402 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License, version 2.0 2 | 3 | 1. Definitions 4 | 5 | 1.1. "Contributor" 6 | 7 | means each individual or legal entity that creates, contributes to the 8 | creation of, or owns Covered Software. 9 | 10 | 1.2. "Contributor Version" 11 | 12 | means the combination of the Contributions of others (if any) used by a 13 | Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | 17 | means Covered Software of a particular Contributor. 18 | 19 | 1.4. "Covered Software" 20 | 21 | means Source Code Form to which the initial Contributor has attached the 22 | notice in Exhibit A, the Executable Form of such Source Code Form, and 23 | Modifications of such Source Code Form, in each case including portions 24 | thereof. 25 | 26 | 1.5. "Incompatible With Secondary Licenses" 27 | means 28 | 29 | a. that the initial Contributor has attached the notice described in 30 | Exhibit B to the Covered Software; or 31 | 32 | b. that the Covered Software was made available under the terms of 33 | version 1.1 or earlier of the License, but not also under the terms of 34 | a Secondary License. 35 | 36 | 1.6. "Executable Form" 37 | 38 | means any form of the work other than Source Code Form. 39 | 40 | 1.7. "Larger Work" 41 | 42 | means a work that combines Covered Software with other material, in a 43 | separate file or files, that is not Covered Software. 44 | 45 | 1.8. "License" 46 | 47 | means this document. 48 | 49 | 1.9. "Licensable" 50 | 51 | means having the right to grant, to the maximum extent possible, whether 52 | at the time of the initial grant or subsequently, any and all of the 53 | rights conveyed by this License. 54 | 55 | 1.10. "Modifications" 56 | 57 | means any of the following: 58 | 59 | a. any file in Source Code Form that results from an addition to, 60 | deletion from, or modification of the contents of Covered Software; or 61 | 62 | b. any new file in Source Code Form that contains any Covered Software. 63 | 64 | 1.11. "Patent Claims" of a Contributor 65 | 66 | means any patent claim(s), including without limitation, method, 67 | process, and apparatus claims, in any patent Licensable by such 68 | Contributor that would be infringed, but for the grant of the License, 69 | by the making, using, selling, offering for sale, having made, import, 70 | or transfer of either its Contributions or its Contributor Version. 71 | 72 | 1.12. "Secondary License" 73 | 74 | means either the GNU General Public License, Version 2.0, the GNU Lesser 75 | General Public License, Version 2.1, the GNU Affero General Public 76 | License, Version 3.0, or any later versions of those licenses. 77 | 78 | 1.13. "Source Code Form" 79 | 80 | means the form of the work preferred for making modifications. 81 | 82 | 1.14. "You" (or "Your") 83 | 84 | means an individual or a legal entity exercising rights under this 85 | License. For legal entities, "You" includes any entity that controls, is 86 | controlled by, or is under common control with You. For purposes of this 87 | definition, "control" means (a) the power, direct or indirect, to cause 88 | the direction or management of such entity, whether by contract or 89 | otherwise, or (b) ownership of more than fifty percent (50%) of the 90 | outstanding shares or beneficial ownership of such entity. 91 | 92 | 93 | 2. License Grants and Conditions 94 | 95 | 2.1. Grants 96 | 97 | Each Contributor hereby grants You a world-wide, royalty-free, 98 | non-exclusive license: 99 | 100 | a. under intellectual property rights (other than patent or trademark) 101 | Licensable by such Contributor to use, reproduce, make available, 102 | modify, display, perform, distribute, and otherwise exploit its 103 | Contributions, either on an unmodified basis, with Modifications, or 104 | as part of a Larger Work; and 105 | 106 | b. under Patent Claims of such Contributor to make, use, sell, offer for 107 | sale, have made, import, and otherwise transfer either its 108 | Contributions or its Contributor Version. 109 | 110 | 2.2. Effective Date 111 | 112 | The licenses granted in Section 2.1 with respect to any Contribution 113 | become effective for each Contribution on the date the Contributor first 114 | distributes such Contribution. 115 | 116 | 2.3. Limitations on Grant Scope 117 | 118 | The licenses granted in this Section 2 are the only rights granted under 119 | this License. No additional rights or licenses will be implied from the 120 | distribution or licensing of Covered Software under this License. 121 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 122 | Contributor: 123 | 124 | a. for any code that a Contributor has removed from Covered Software; or 125 | 126 | b. for infringements caused by: (i) Your and any other third party's 127 | modifications of Covered Software, or (ii) the combination of its 128 | Contributions with other software (except as part of its Contributor 129 | Version); or 130 | 131 | c. under Patent Claims infringed by Covered Software in the absence of 132 | its Contributions. 133 | 134 | This License does not grant any rights in the trademarks, service marks, 135 | or logos of any Contributor (except as may be necessary to comply with 136 | the notice requirements in Section 3.4). 137 | 138 | 2.4. Subsequent Licenses 139 | 140 | No Contributor makes additional grants as a result of Your choice to 141 | distribute the Covered Software under a subsequent version of this 142 | License (see Section 10.2) or under the terms of a Secondary License (if 143 | permitted under the terms of Section 3.3). 144 | 145 | 2.5. Representation 146 | 147 | Each Contributor represents that the Contributor believes its 148 | Contributions are its original creation(s) or it has sufficient rights to 149 | grant the rights to its Contributions conveyed by this License. 150 | 151 | 2.6. Fair Use 152 | 153 | This License is not intended to limit any rights You have under 154 | applicable copyright doctrines of fair use, fair dealing, or other 155 | equivalents. 156 | 157 | 2.7. Conditions 158 | 159 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in 160 | Section 2.1. 161 | 162 | 163 | 3. Responsibilities 164 | 165 | 3.1. Distribution of Source Form 166 | 167 | All distribution of Covered Software in Source Code Form, including any 168 | Modifications that You create or to which You contribute, must be under 169 | the terms of this License. You must inform recipients that the Source 170 | Code Form of the Covered Software is governed by the terms of this 171 | License, and how they can obtain a copy of this License. You may not 172 | attempt to alter or restrict the recipients' rights in the Source Code 173 | Form. 174 | 175 | 3.2. Distribution of Executable Form 176 | 177 | If You distribute Covered Software in Executable Form then: 178 | 179 | a. such Covered Software must also be made available in Source Code Form, 180 | as described in Section 3.1, and You must inform recipients of the 181 | Executable Form how they can obtain a copy of such Source Code Form by 182 | reasonable means in a timely manner, at a charge no more than the cost 183 | of distribution to the recipient; and 184 | 185 | b. You may distribute such Executable Form under the terms of this 186 | License, or sublicense it under different terms, provided that the 187 | license for the Executable Form does not attempt to limit or alter the 188 | recipients' rights in the Source Code Form under this License. 189 | 190 | 3.3. Distribution of a Larger Work 191 | 192 | You may create and distribute a Larger Work under terms of Your choice, 193 | provided that You also comply with the requirements of this License for 194 | the Covered Software. If the Larger Work is a combination of Covered 195 | Software with a work governed by one or more Secondary Licenses, and the 196 | Covered Software is not Incompatible With Secondary Licenses, this 197 | License permits You to additionally distribute such Covered Software 198 | under the terms of such Secondary License(s), so that the recipient of 199 | the Larger Work may, at their option, further distribute the Covered 200 | Software under the terms of either this License or such Secondary 201 | License(s). 202 | 203 | 3.4. Notices 204 | 205 | You may not remove or alter the substance of any license notices 206 | (including copyright notices, patent notices, disclaimers of warranty, or 207 | limitations of liability) contained within the Source Code Form of the 208 | Covered Software, except that You may alter any license notices to the 209 | extent required to remedy known factual inaccuracies. 210 | 211 | 3.5. Application of Additional Terms 212 | 213 | You may choose to offer, and to charge a fee for, warranty, support, 214 | indemnity or liability obligations to one or more recipients of Covered 215 | Software. However, You may do so only on Your own behalf, and not on 216 | behalf of any Contributor. You must make it absolutely clear that any 217 | such warranty, support, indemnity, or liability obligation is offered by 218 | You alone, and You hereby agree to indemnify every Contributor for any 219 | liability incurred by such Contributor as a result of warranty, support, 220 | indemnity or liability terms You offer. You may include additional 221 | disclaimers of warranty and limitations of liability specific to any 222 | jurisdiction. 223 | 224 | 4. Inability to Comply Due to Statute or Regulation 225 | 226 | If it is impossible for You to comply with any of the terms of this License 227 | with respect to some or all of the Covered Software due to statute, 228 | judicial order, or regulation then You must: (a) comply with the terms of 229 | this License to the maximum extent possible; and (b) describe the 230 | limitations and the code they affect. Such description must be placed in a 231 | text file included with all distributions of the Covered Software under 232 | this License. Except to the extent prohibited by statute or regulation, 233 | such description must be sufficiently detailed for a recipient of ordinary 234 | skill to be able to understand it. 235 | 236 | 5. Termination 237 | 238 | 5.1. The rights granted under this License will terminate automatically if You 239 | fail to comply with any of its terms. However, if You become compliant, 240 | then the rights granted under this License from a particular Contributor 241 | are reinstated (a) provisionally, unless and until such Contributor 242 | explicitly and finally terminates Your grants, and (b) on an ongoing 243 | basis, if such Contributor fails to notify You of the non-compliance by 244 | some reasonable means prior to 60 days after You have come back into 245 | compliance. Moreover, Your grants from a particular Contributor are 246 | reinstated on an ongoing basis if such Contributor notifies You of the 247 | non-compliance by some reasonable means, this is the first time You have 248 | received notice of non-compliance with this License from such 249 | Contributor, and You become compliant prior to 30 days after Your receipt 250 | of the notice. 251 | 252 | 5.2. If You initiate litigation against any entity by asserting a patent 253 | infringement claim (excluding declaratory judgment actions, 254 | counter-claims, and cross-claims) alleging that a Contributor Version 255 | directly or indirectly infringes any patent, then the rights granted to 256 | You by any and all Contributors for the Covered Software under Section 257 | 2.1 of this License shall terminate. 258 | 259 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user 260 | license agreements (excluding distributors and resellers) which have been 261 | validly granted by You or Your distributors under this License prior to 262 | termination shall survive termination. 263 | 264 | 6. Disclaimer of Warranty 265 | 266 | Covered Software is provided under this License on an "as is" basis, 267 | without warranty of any kind, either expressed, implied, or statutory, 268 | including, without limitation, warranties that the Covered Software is free 269 | of defects, merchantable, fit for a particular purpose or non-infringing. 270 | The entire risk as to the quality and performance of the Covered Software 271 | is with You. Should any Covered Software prove defective in any respect, 272 | You (not any Contributor) assume the cost of any necessary servicing, 273 | repair, or correction. This disclaimer of warranty constitutes an essential 274 | part of this License. No use of any Covered Software is authorized under 275 | this License except under this disclaimer. 276 | 277 | 7. Limitation of Liability 278 | 279 | Under no circumstances and under no legal theory, whether tort (including 280 | negligence), contract, or otherwise, shall any Contributor, or anyone who 281 | distributes Covered Software as permitted above, be liable to You for any 282 | direct, indirect, special, incidental, or consequential damages of any 283 | character including, without limitation, damages for lost profits, loss of 284 | goodwill, work stoppage, computer failure or malfunction, or any and all 285 | other commercial damages or losses, even if such party shall have been 286 | informed of the possibility of such damages. This limitation of liability 287 | shall not apply to liability for death or personal injury resulting from 288 | such party's negligence to the extent applicable law prohibits such 289 | limitation. Some jurisdictions do not allow the exclusion or limitation of 290 | incidental or consequential damages, so this exclusion and limitation may 291 | not apply to You. 292 | 293 | 8. Litigation 294 | 295 | Any litigation relating to this License may be brought only in the courts 296 | of a jurisdiction where the defendant maintains its principal place of 297 | business and such litigation shall be governed by laws of that 298 | jurisdiction, without reference to its conflict-of-law provisions. Nothing 299 | in this Section shall prevent a party's ability to bring cross-claims or 300 | counter-claims. 301 | 302 | 9. Miscellaneous 303 | 304 | This License represents the complete agreement concerning the subject 305 | matter hereof. If any provision of this License is held to be 306 | unenforceable, such provision shall be reformed only to the extent 307 | necessary to make it enforceable. Any law or regulation which provides that 308 | the language of a contract shall be construed against the drafter shall not 309 | be used to construe this License against a Contributor. 310 | 311 | 312 | 10. Versions of the License 313 | 314 | 10.1. New Versions 315 | 316 | Mozilla Foundation is the license steward. Except as provided in Section 317 | 10.3, no one other than the license steward has the right to modify or 318 | publish new versions of this License. Each version will be given a 319 | distinguishing version number. 320 | 321 | 10.2. Effect of New Versions 322 | 323 | You may distribute the Covered Software under the terms of the version 324 | of the License under which You originally received the Covered Software, 325 | or under the terms of any subsequent version published by the license 326 | steward. 327 | 328 | 10.3. Modified Versions 329 | 330 | If you create software not governed by this License, and you want to 331 | create a new license for such software, you may create and use a 332 | modified version of this License if you rename the license and remove 333 | any references to the name of the license steward (except to note that 334 | such modified license differs from this License). 335 | 336 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 337 | Licenses If You choose to distribute Source Code Form that is 338 | Incompatible With Secondary Licenses under the terms of this version of 339 | the License, the notice described in Exhibit B of this License must be 340 | attached. 341 | 342 | Exhibit A - Source Code Form License Notice 343 | 344 | This Source Code Form is subject to the 345 | terms of the Mozilla Public License, v. 346 | 2.0. If a copy of the MPL was not 347 | distributed with this file, You can 348 | obtain one at 349 | http://mozilla.org/MPL/2.0/. 350 | 351 | If it is not possible or desirable to put the notice in a particular file, 352 | then You may include the notice in a location (such as a LICENSE file in a 353 | relevant directory) where a recipient would be likely to look for such a 354 | notice. 355 | 356 | You may add additional accurate notices of copyright ownership. 357 | 358 | Exhibit B - "Incompatible With Secondary Licenses" Notice 359 | 360 | This Source Code Form is "Incompatible 361 | With Secondary Licenses", as defined by 362 | the Mozilla Public License, v. 2.0. --------------------------------------------------------------------------------