From 1c1f9d1c3c6000039d38be258134d9f9f49439d9 Mon Sep 17 00:00:00 2001 From: Umputun Date: Tue, 25 May 2021 23:48:22 -0500 Subject: [PATCH] Docker multi routes (#80) * support reproxy.N.something labels for docker #78 * lint: suppress false positive * update linter in ci * update readme * add test for failed container parse --- .github/workflows/ci.yml | 2 +- .golangci.yml | 2 +- README.md | 2 + app/discovery/provider/docker.go | 106 ++++++++++++--------- app/discovery/provider/docker_test.go | 129 +++++++++++++++++++++++++- 5 files changed, 195 insertions(+), 46 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 291eb19..81ed835 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - name: install golangci-lint and goveralls run: | - curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s -- -b $GITHUB_WORKSPACE v1.29.0 + curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s -- -b $GITHUB_WORKSPACE v1.40.0 GO111MODULE=off go get -u github.com/mattn/goveralls - name: run linters diff --git a/.golangci.yml b/.golangci.yml index 283cd95..efbd712 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -37,7 +37,7 @@ linters-settings: linters: enable: - megacheck - - golint + - revive - govet - unconvert - megacheck diff --git a/README.md b/README.md index 450ddb9..971b10c 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ With `--docker.auto`, all containers with exposed port will be considered as rou If no `reproxy.route` defined, the default is `http://:/(.*)`. In case if all proxied source have the same pattern, for example `/api/(.*)` user can define the common prefix (in this case `/api`) for all container-based routes. This can be done with `--docker.prefix` parameter. +Docker provider also allows to define multiple set of `reproxy.N.something` labels to match multiple distinct routes on the same container. This is useful as inn some cases, a container may expose multiple endpoints, for example, public API and some admin API. All the labels above can be used with "N-index", i.e. `reproxy.1.server`, `reproxy.1.port` and so on. N should be in 0 to 9 range. + This is a dynamic provider and any change in container's status will be applied automatically. ### Consul Catalog diff --git a/app/discovery/provider/docker.go b/app/discovery/provider/docker.go index 275238d..66726c1 100644 --- a/app/discovery/provider/docker.go +++ b/app/discovery/provider/docker.go @@ -25,6 +25,8 @@ import ( // in the Dockerfile. // Alternatively labels can alter this. reproxy.route sets source route, and reproxy.dest sets the destination. // Optional reproxy.server enforces match by server name (hostname) and reproxy.ping sets the health check url +// Labels can be presented multiple times with a numeric suffix to provide multiple matches for a single container +// i.e. reproxy.1.server=example.com, reproxy.1.port=12345 and so on type Docker struct { DockerClient DockerClient Excludes []string @@ -68,57 +70,66 @@ func (d *Docker) List() ([]discovery.URLMapper, error) { return nil, err } - res := make([]discovery.URLMapper, 0, len(containers)) + var res []discovery.URLMapper //nolint:prealloc // we don't know the final size for _, c := range containers { + res = append(res, d.parseContainerInfo(c)...) + } + + // sort by len(SrcMatch) to have shorter matches after longer + // this way we can handle possible conflicts with more detailed match triggered before less detailed + sort.Slice(res, func(i, j int) bool { + return len(res[i].SrcMatch.String()) > len(res[j].SrcMatch.String()) + }) + return res, nil +} + +// parseContainerInfo getting URLMappers for up to 10 routes for 0..9 N (reproxy.N.something) +func (d *Docker) parseContainerInfo(c containerInfo) (res []discovery.URLMapper) { + + for n := 0; n < 9; n++ { enabled, explicit := false, false - srcURL := fmt.Sprintf("^/%s/(.*)", c.Name) // default destination + srcURL := fmt.Sprintf("^/%s/(.*)", c.Name) // default src is /container-name/(.*) if d.APIPrefix != "" { - prefix := strings.TrimLeft(d.APIPrefix, "/") - prefix = strings.TrimRight(prefix, "/") - srcURL = fmt.Sprintf("^/%s/%s/(.*)", prefix, c.Name) // default destination with api prefix + prefix := strings.TrimSuffix(strings.TrimPrefix(d.APIPrefix, "/"), "/") + srcURL = fmt.Sprintf("^/%s/%s/(.*)", prefix, c.Name) // default src with api prefix is /api-prefix/container-name/(.*) } - if d.AutoAPI { - enabled = true - } - - port, err := d.matchedPort(c) + port, err := d.matchedPort(c, n) if err != nil { - log.Printf("[DEBUG] container %s disabled, %v", c.Name, err) + log.Printf("[DEBUG] container %s (route: %d) disabled, %v", c.Name, n, err) continue } - destURL := fmt.Sprintf("http://%s:%d/$1", c.IP, port) - pingURL := fmt.Sprintf("http://%s:%d/ping", c.IP, port) - server := "*" + // defaults + destURL, pingURL, server := fmt.Sprintf("http://%s:%d/$1", c.IP, port), fmt.Sprintf("http://%s:%d/ping", c.IP, port), "*" assetsWebRoot, assetsLocation := "", "" - // we don't care about value because disabled will be filtered before - if _, ok := c.Labels["reproxy.enabled"]; ok { + if d.AutoAPI && n == 0 { + enabled = true + } + + if _, ok := d.labelN(c.Labels, n, "enabled"); ok { enabled, explicit = true, true } - if v, ok := c.Labels["reproxy.route"]; ok { + if v, ok := d.labelN(c.Labels, n, "route"); ok { enabled, explicit = true, true srcURL = v } - - if v, ok := c.Labels["reproxy.dest"]; ok { + if v, ok := d.labelN(c.Labels, n, "dest"); ok { enabled, explicit = true, true destURL = fmt.Sprintf("http://%s:%d%s", c.IP, port, v) } - - if v, ok := c.Labels["reproxy.server"]; ok { + if v, ok := d.labelN(c.Labels, n, "server"); ok { enabled = true server = v } - - if v, ok := c.Labels["reproxy.ping"]; ok { + if v, ok := d.labelN(c.Labels, n, "ping"); ok { enabled = true pingURL = fmt.Sprintf("http://%s:%d%s", c.IP, port, v) } - if v, ok := c.Labels["reproxy.assets"]; ok { + if v, ok := d.labelN(c.Labels, n, "assets"); ok { if ae := strings.Split(v, ":"); len(ae) == 2 { enabled = true assetsWebRoot = ae[0] @@ -127,18 +138,19 @@ func (d *Docker) List() ([]discovery.URLMapper, error) { } // should not set anything, handled on matchedPort level. just use to enable implicitly - if _, ok := c.Labels["reproxy.port"]; ok { + if _, ok := d.labelN(c.Labels, n, "port"); ok { enabled = true } if !enabled { - log.Printf("[DEBUG] container %s disabled", c.Name) + log.Printf("[DEBUG] container %s (route: %d) disabled", c.Name, n) continue } srcRegex, err := regexp.Compile(srcURL) if err != nil { - return nil, fmt.Errorf("invalid src regex: %w", err) + log.Printf("[DEBUG] container %s (route: %d) disabled, invalid src regex: %v", c.Name, n, err) + continue } // docker server label may have multiple, comma separated servers @@ -158,37 +170,47 @@ func (d *Docker) List() ([]discovery.URLMapper, error) { mp.AssetsLocation = assetsLocation } res = append(res, mp) - } } - // sort by len(SrcMatch) to have shorter matches after longer - // this way we can handle possible conflicts with more detailed match triggered before less detailed - - sort.Slice(res, func(i, j int) bool { - return len(res[i].SrcMatch.String()) > len(res[j].SrcMatch.String()) - }) - return res, nil + return res } -func (d *Docker) matchedPort(c containerInfo) (port int, err error) { +// matchedPort gets port for route match, default the first exposed port +// if reproxy.N.label found reruns this port only if it is one of exposed by the container +func (d *Docker) matchedPort(c containerInfo, n int) (port int, err error) { port = c.Ports[0] // by default use the first exposed port - if v, ok := c.Labels["reproxy.port"]; ok { - rp, err := strconv.Atoi(v) + + if portLabel, ok := d.labelN(c.Labels, n, "port"); ok { + rp, err := strconv.Atoi(portLabel) if err != nil { - return 0, fmt.Errorf("invalid reproxy port %s: %w", v, err) + return 0, fmt.Errorf("invalid reproxy port %s: %w", portLabel, err) } for _, p := range c.Ports { - // set port to reproxy.port if matched with one of exposed + // set port to reproxy.N.port if matched with one of exposed if p == rp { - port = rp - break + return rp, nil } } + return 0, fmt.Errorf("reproxy port %s not exposed", portLabel) } return port, nil } +// labelN returns label value from reproxy.N.suffix, i.e. reproxy.1.server +func (d *Docker) labelN(labels map[string]string, n int, suffix string) (result string, ok bool) { + switch n { + case 0: + result, ok = labels["reproxy."+suffix] + if !ok { + result, ok = labels["reproxy.0."+suffix] + } + default: + result, ok = labels[fmt.Sprintf("reproxy.%d.%s", n, suffix)] + } + return result, ok +} + // events starts monitoring changes in running containers and sends refresh // notification to eventsCh when change(s) are detected. Blocks caller func (d *Docker) events(ctx context.Context, eventsCh chan<- discovery.ProviderID) error { diff --git a/app/discovery/provider/docker_test.go b/app/discovery/provider/docker_test.go index 7d8d3e9..43eed82 100644 --- a/app/discovery/provider/docker_test.go +++ b/app/discovery/provider/docker_test.go @@ -7,6 +7,7 @@ import ( "log" "net/http" "net/http/httptest" + "strconv" "strings" "testing" "time" @@ -39,10 +40,10 @@ func TestDocker_List(t *testing.T) { Name: "c3", State: "stopped", }, { - Name: "c4", State: "running", IP: "127.0.0.2", Ports: []int{12345}, + Name: "c4", State: "running", IP: "127.0.0.2", Ports: []int{12345}, // not enabled }, { - Name: "c5", State: "running", IP: "127.0.0.122", Ports: []int{2345}, + Name: "c5", State: "running", IP: "127.0.0.122", Ports: []int{2345}, // not enabled Labels: map[string]string{"reproxy.enabled": "false"}, }, }, nil @@ -70,6 +71,98 @@ func TestDocker_List(t *testing.T) { assert.Equal(t, "example.com", res[2].Server) } +func TestDocker_ListMulti(t *testing.T) { + dclient := &DockerClientMock{ + ListContainersFunc: func() ([]containerInfo, error) { + return []containerInfo{ + { + Name: "c0", State: "running", IP: "127.0.0.2", Ports: []int{12348}, + Labels: map[string]string{"reproxy.route": "^/a/(.*)", "reproxy.dest": "/a/$1", + "reproxy.server": "example.com", "reproxy.ping": "/ping"}, + }, + { + Name: "c1", State: "running", IP: "127.0.0.2", Ports: []int{12345}, + Labels: map[string]string{"reproxy.route": "^/api/123/(.*)", "reproxy.dest": "/blah/$1", + "reproxy.server": "example.com", "reproxy.ping": "/ping"}, + }, + { + Name: "c2", State: "running", IP: "127.0.0.3", Ports: []int{12346}, + Labels: map[string]string{"reproxy.enabled": "y"}, + }, + { + Name: "c2m", State: "running", IP: "127.0.0.3", Ports: []int{7890, 56789}, + Labels: map[string]string{ + "reproxy.enabled": "y", + "reproxy.1.route": "/api/1/(.*)", + "reproxy.1.dest": "/blah/1/$1", + }, + }, + { + Name: "c4", State: "running", IP: "127.0.0.12", Ports: []int{12345}, + Labels: map[string]string{"reproxy.port": "12345"}, + }, + { + Name: "c4", State: "running", IP: "127.0.0.12", Ports: []int{12345}, + Labels: map[string]string{"reproxy.port": "12xx345"}, // bad port + }, + { + Name: "c23", State: "running", IP: "127.0.0.3", Ports: []int{12346}, + Labels: map[string]string{"reproxy.enabled": "y", "reproxy.route": "^/api/123/(.***)"}, // bad regex + }, + { + Name: "c3", State: "stopped", + }, + { + Name: "c4", State: "running", IP: "127.0.0.2", Ports: []int{12345}, // not enabled + }, + { + Name: "c4", State: "running", IP: "127.0.0.2", Ports: []int{12345}, // not enabled, port mismatch + Labels: map[string]string{"reproxy.port": "9999"}, + }, + { + Name: "c5", State: "running", IP: "127.0.0.122", Ports: []int{2345}, // not enabled + Labels: map[string]string{"reproxy.enabled": "false"}, + }, + }, nil + }, + } + + d := Docker{DockerClient: dclient} + res, err := d.List() + require.NoError(t, err) + require.Equal(t, 6, len(res)) + + assert.Equal(t, "^/api/123/(.*)", res[0].SrcMatch.String()) + assert.Equal(t, "http://127.0.0.2:12345/blah/$1", res[0].Dst) + assert.Equal(t, "example.com", res[0].Server) + assert.Equal(t, "http://127.0.0.2:12345/ping", res[0].PingURL) + + assert.Equal(t, "/api/1/(.*)", res[1].SrcMatch.String()) + assert.Equal(t, "http://127.0.0.3:7890/blah/1/$1", res[1].Dst) + assert.Equal(t, "http://127.0.0.3:7890/ping", res[1].PingURL) + assert.Equal(t, "*", res[1].Server) + + assert.Equal(t, "^/c2m/(.*)", res[2].SrcMatch.String()) + assert.Equal(t, "http://127.0.0.3:7890/$1", res[2].Dst) + assert.Equal(t, "http://127.0.0.3:7890/ping", res[2].PingURL) + assert.Equal(t, "*", res[2].Server) + + assert.Equal(t, "^/c2/(.*)", res[3].SrcMatch.String()) + assert.Equal(t, "http://127.0.0.3:12346/$1", res[3].Dst) + assert.Equal(t, "http://127.0.0.3:12346/ping", res[3].PingURL) + assert.Equal(t, "*", res[3].Server) + + assert.Equal(t, "^/c4/(.*)", res[4].SrcMatch.String()) + assert.Equal(t, "http://127.0.0.12:12345/$1", res[4].Dst) + assert.Equal(t, "http://127.0.0.12:12345/ping", res[4].PingURL) + assert.Equal(t, "*", res[4].Server) + + assert.Equal(t, "^/a/(.*)", res[5].SrcMatch.String()) + assert.Equal(t, "http://127.0.0.2:12348/a/$1", res[5].Dst) + assert.Equal(t, "http://127.0.0.2:12348/ping", res[5].PingURL) + assert.Equal(t, "example.com", res[5].Server) +} + func TestDocker_ListWithAutoAPI(t *testing.T) { dclient := &DockerClientMock{ ListContainersFunc: func() ([]containerInfo, error) { @@ -235,3 +328,35 @@ func TestDockerClient_error(t *testing.T) { _, err := client.ListContainers() require.EqualError(t, err, "unexpected error from docker daemon: bruh") } + +func TestDocker_labelN(t *testing.T) { + + tbl := []struct { + labels map[string]string + n int + suffix string + res string + ok bool + }{ + {map[string]string{}, 0, "port", "", false}, + {map[string]string{"a": "123", "reproxy.port": "9999"}, 0, "port", "9999", true}, + {map[string]string{"a": "123", "reproxy.0.port": "9999"}, 0, "port", "9999", true}, + {map[string]string{"a": "123", "reproxy.1.port": "9999"}, 0, "port", "", false}, + {map[string]string{"a": "123", "reproxy.1.port": "9999"}, 1, "port", "9999", true}, + {map[string]string{"a": "123", "reproxy.1.port": "9999", "reproxy.0.port": "7777"}, 1, "port", "9999", true}, + {map[string]string{"a": "123", "reproxy.1.port": "9999", "reproxy.0.port": "7777"}, 0, "port", "7777", true}, + } + + d := Docker{} + for i, tt := range tbl { + t.Run(strconv.Itoa(i), func(t *testing.T) { + res, ok := d.labelN(tt.labels, tt.n, tt.suffix) + require.Equal(t, tt.ok, ok) + if !ok { + return + } + assert.Equal(t, tt.res, res) + }) + } + +}