initial version #1

This commit is contained in:
Umputun 2021-04-01 02:37:28 -05:00
parent b5022affec
commit 8a7b73f41f
20 changed files with 1642 additions and 13 deletions

5
.github/CODEOWNERS vendored Normal file
View File

@ -0,0 +1,5 @@
# These owners will be the default owners for everything in the repo.
# Unless a later match takes precedence, @umputun will be requested for
# review when someone opens a pull request.
* @umputun

53
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,53 @@
name: build
on:
push:
tags: [v*]
branches:
pull_request:
repository_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: set up go 1.16
uses: actions/setup-go@v2
with:
go-version: 1.16
id: go
- name: checkout
uses: actions/checkout@v2
- name: build and test
run: |
go test -v -timeout=100s -covermode=count -coverprofile=$GITHUB_WORKSPACE/profile.cov_tmp ./...
cat $GITHUB_WORKSPACE/profile.cov_tmp | grep -v "mocks" | grep -v "_mock" > $GITHUB_WORKSPACE/profile.cov
working-directory: backend/app
env:
GOFLAGS: "-mod=vendor"
TZ: "America/Chicago"
- 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
GO111MODULE=off go get -u github.com/mattn/goveralls
- name: run linters
run: $GITHUB_WORKSPACE/golangci-lint run --out-format=github-actions
working-directory: backend/app
env:
GOFLAGS: "-mod=vendor"
TZ: "America/Chicago"
- name: build and deploy image
env:
GITHUB_PACKAGE_TOKEN: ${{ secrets.PKG_TOKEN }}
run: |
ref="$(echo ${GITHUB_REF} | cut -d'/' -f3)"
echo GITHUB_REF - $ref
docker login docker.pkg.github.com -u umputun -p ${GITHUB_PACKAGE_TOKEN}
docker build -t docker.pkg.github.com/umputun/docker-proxy/dpx:${ref} .
docker push docker.pkg.github.com/umputun/docker-proxy/dpx:${ref}

15
.gitignore vendored
View File

@ -1,15 +1,4 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
.runconf
var/

116
app/discovery/discovery.go Normal file
View File

@ -0,0 +1,116 @@
// Package discovery provides a common interface for all providers and Match to
// transform source to destination URL.
// Do func starts event loop checking all providers and retrieving lists of rules.
// All lists combined into a merged one.
package discovery
import (
"context"
"regexp"
"sync"
)
//go:generate moq -out provider_mock.go -fmt goimports . Provider
// Service implements discovery with multiple providers and url matcher
type Service struct {
providers []Provider
mappers []UrlMapper
lock sync.RWMutex
}
// UrlMapper contains all info about source and destination routes
type UrlMapper struct {
SrcMatch *regexp.Regexp
Dst string
ProviderID string
}
// Provider defines sources of mappers
type Provider interface {
Events(ctx context.Context) (res <-chan struct{})
List() (res []UrlMapper, err error)
ID() string
}
// NewService makes service with given providers
func NewService(providers []Provider) *Service {
return &Service{providers: providers}
}
// Do runs blocking loop getting events from all providers
// and updating mappers on each event
func (s *Service) Do(ctx context.Context) error {
var evChs []<-chan struct{}
for _, p := range s.providers {
evChs = append(evChs, p.Events(ctx))
}
ch := s.mergeEvents(ctx, evChs...)
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ch:
m := s.mergeLists()
s.lock.Lock()
s.mappers = make([]UrlMapper, len(m))
copy(s.mappers, m)
s.lock.Unlock()
}
}
}
// Match url to all providers mappers
func (s *Service) Match(url string) (string, bool) {
s.lock.RLock()
defer s.lock.RUnlock()
for _, m := range s.mappers {
dest := m.SrcMatch.ReplaceAllString(url, m.Dst)
if url != dest {
return dest, true
}
}
return url, false
}
func (s *Service) mergeLists() (res []UrlMapper) {
for _, p := range s.providers {
lst, err := p.List()
if err != nil {
continue
}
res = append(res, lst...)
}
return res
}
func (s *Service) mergeEvents(ctx context.Context, chs ...<-chan struct{}) <-chan struct{} {
var wg sync.WaitGroup
out := make(chan struct{})
output := func(ctx context.Context, c <-chan struct{}) {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case v, ok := <-c:
if !ok {
return
}
out <- v
}
}
}
wg.Add(len(chs))
for _, c := range chs {
go output(ctx, c)
}
go func() {
wg.Wait()
close(out)
}()
return out
}

View File

@ -0,0 +1,102 @@
package discovery
import (
"context"
"regexp"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestService_Do(t *testing.T) {
p1 := &ProviderMock{
EventsFunc: func(ctx context.Context) <-chan struct{} {
res := make(chan struct{}, 1)
res <- struct{}{}
return res
},
ListFunc: func() ([]UrlMapper, error) {
return []UrlMapper{
{SrcMatch: regexp.MustCompile("^/api/svc1/(.*)"), Dst: "http://127.0.0.1:8080/blah1/$1"},
{SrcMatch: regexp.MustCompile("^/api/svc2/(.*)"), Dst: "http://127.0.0.2:8080/blah2/$1/abc"},
}, nil
},
}
p2 := &ProviderMock{
EventsFunc: func(ctx context.Context) <-chan struct{} {
return make(chan struct{}, 1)
},
ListFunc: func() ([]UrlMapper, error) {
return []UrlMapper{
{SrcMatch: regexp.MustCompile("/api/svc3/xyz"), Dst: "http://127.0.0.3:8080/blah3/xyz"},
}, nil
},
}
svc := NewService([]Provider{p1, p2})
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
err := svc.Do(ctx)
require.Error(t, err)
assert.Equal(t, context.DeadlineExceeded, err)
assert.Equal(t, 3, len(svc.mappers))
assert.Equal(t, 1, len(p1.EventsCalls()))
assert.Equal(t, 1, len(p2.EventsCalls()))
assert.Equal(t, 1, len(p1.ListCalls()))
assert.Equal(t, 1, len(p2.ListCalls()))
}
func TestService_Match(t *testing.T) {
p1 := &ProviderMock{
EventsFunc: func(ctx context.Context) <-chan struct{} {
res := make(chan struct{}, 1)
res <- struct{}{}
return res
},
ListFunc: func() ([]UrlMapper, error) {
return []UrlMapper{
{SrcMatch: regexp.MustCompile("^/api/svc1/(.*)"), Dst: "http://127.0.0.1:8080/blah1/$1"},
{SrcMatch: regexp.MustCompile("^/api/svc2/(.*)"), Dst: "http://127.0.0.2:8080/blah2/$1/abc"},
}, nil
},
}
p2 := &ProviderMock{
EventsFunc: func(ctx context.Context) <-chan struct{} {
return make(chan struct{}, 1)
},
ListFunc: func() ([]UrlMapper, error) {
return []UrlMapper{
{SrcMatch: regexp.MustCompile("/api/svc3/xyz"), Dst: "http://127.0.0.3:8080/blah3/xyz"},
}, nil
},
}
svc := NewService([]Provider{p1, p2})
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
err := svc.Do(ctx)
require.Error(t, err)
assert.Equal(t, context.DeadlineExceeded, err)
assert.Equal(t, 3, len(svc.mappers))
{
res, ok := svc.Match("/api/svc3/xyz")
assert.True(t, ok)
assert.Equal(t, "http://127.0.0.3:8080/blah3/xyz", res)
}
{
res, ok := svc.Match("/api/svc1/1234")
assert.True(t, ok)
assert.Equal(t, "http://127.0.0.1:8080/blah1/1234", res)
}
{
res, ok := svc.Match("/aaa/api/svc1/1234")
assert.False(t, ok)
assert.Equal(t, "/aaa/api/svc1/1234", res)
}
}

View File

@ -0,0 +1,163 @@
package provider
import (
"context"
"fmt"
"regexp"
"strings"
"time"
dclient "github.com/fsouza/go-dockerclient"
log "github.com/go-pkgz/lgr"
"github.com/pkg/errors"
"github.com/umputun/docker-proxy/app/discovery"
)
//go:generate moq -out docker_client_mock.go -skip-ensure -fmt goimports . DockerClient
// Docker emits all changes from all containers states
type Docker struct {
DockerClient DockerClient
Excludes []string
}
// DockerClient defines interface listing containers and subscribing to events
type DockerClient interface {
ListContainers(opts dclient.ListContainersOptions) ([]dclient.APIContainers, error)
AddEventListener(listener chan<- *dclient.APIEvents) error
}
// containerInfo is simplified docker.APIEvents for containers only
type containerInfo struct {
ID string
Name string
TS time.Time
Labels map[string]string
}
var (
upStatuses = []string{"start", "restart"}
downStatuses = []string{"die", "destroy", "stop", "pause"}
)
// Channel gets eventsCh with all containers events
func (s *Docker) Events(ctx context.Context) (res <-chan struct{}) {
eventsCh := make(chan struct{})
go func() {
for {
err := s.events(ctx, s.DockerClient, eventsCh)
if err == context.Canceled || err == context.DeadlineExceeded {
close(eventsCh)
return
}
log.Printf("[WARN] docker events listener failed, restarted, %v", err)
time.Sleep(100 * time.Millisecond)
}
}()
return eventsCh
}
// List all containers and make url mappers
func (s *Docker) List() ([]discovery.UrlMapper, error) {
containers, err := s.listContainers()
if err != nil {
return nil, err
}
var res []discovery.UrlMapper
for _, c := range containers {
srcURL := fmt.Sprintf("^/api/%s/(.*)", c.Name)
destURL := fmt.Sprintf("http://%s:8080/$1", c.Name)
if v, ok := c.Labels["dpx.route"]; ok {
srcURL = v
}
if v, ok := c.Labels["dpx.dest"]; ok {
destURL = fmt.Sprintf("http://%s:8080%s", c.Name, v)
}
srcRegex, err := regexp.Compile(srcURL)
if err != nil {
return nil, errors.Wrapf(err, "invalid src regex %s", srcURL)
}
res = append(res, discovery.UrlMapper{SrcMatch: srcRegex, Dst: destURL})
}
return res, nil
}
func (s *Docker) ID() string { return "docker" }
// activate starts blocking listener for all docker events
// filters everything except "container" type, detects stop/start events and publishes signals to eventsCh
func (s *Docker) events(ctx context.Context, client DockerClient, eventsCh chan struct{}) error {
dockerEventsCh := make(chan *dclient.APIEvents)
if err := client.AddEventListener(dockerEventsCh); err != nil {
return errors.Wrap(err, "can't add even listener")
}
for {
select {
case <-ctx.Done():
return ctx.Err()
case ev, ok := <-dockerEventsCh:
if !ok {
return errors.New("events closed")
}
if ev.Type != "container" {
continue
}
if !contains(ev.Status, upStatuses) && !contains(ev.Status, downStatuses) {
continue
}
log.Printf("[DEBUG] api event %+v", ev)
containerName := strings.TrimPrefix(ev.Actor.Attributes["name"], "/")
if contains(containerName, s.Excludes) {
log.Printf("[DEBUG] container %s excluded", containerName)
continue
}
log.Printf("[INFO] new event %+v", ev)
eventsCh <- struct{}{}
}
}
}
func (s *Docker) listContainers() (res []containerInfo, err error) {
containers, err := s.DockerClient.ListContainers(dclient.ListContainersOptions{All: false})
if err != nil {
return nil, errors.Wrap(err, "can't list containers")
}
log.Printf("[DEBUG] total containers = %d", len(containers))
for _, c := range containers {
if !contains(c.Status, upStatuses) {
continue
}
containerName := strings.TrimPrefix(c.Names[0], "/")
if contains(containerName, s.Excludes) {
log.Printf("[DEBUG] container %s excluded", containerName)
continue
}
event := containerInfo{
Name: containerName,
ID: c.ID,
TS: time.Unix(c.Created/1000, 0),
Labels: c.Labels,
}
log.Printf("[DEBUG] running container added, %+v", event)
res = append(res, event)
}
log.Print("[DEBUG] completed list")
return res, nil
}
func contains(e string, s []string) bool {
for _, a := range s {
if a == e {
return true
}
}
return false
}

View File

@ -0,0 +1,114 @@
// Code generated by moq; DO NOT EDIT.
// github.com/matryer/moq
package provider
import (
"sync"
dclient "github.com/fsouza/go-dockerclient"
)
// DockerClientMock is a mock implementation of DockerClient.
//
// func TestSomethingThatUsesDockerClient(t *testing.T) {
//
// // make and configure a mocked DockerClient
// mockedDockerClient := &DockerClientMock{
// AddEventListenerFunc: func(listener chan<- *dclient.APIEvents) error {
// panic("mock out the AddEventListener method")
// },
// ListContainersFunc: func(opts dclient.ListContainersOptions) ([]dclient.APIContainers, error) {
// panic("mock out the ListContainers method")
// },
// }
//
// // use mockedDockerClient in code that requires DockerClient
// // and then make assertions.
//
// }
type DockerClientMock struct {
// AddEventListenerFunc mocks the AddEventListener method.
AddEventListenerFunc func(listener chan<- *dclient.APIEvents) error
// ListContainersFunc mocks the ListContainers method.
ListContainersFunc func(opts dclient.ListContainersOptions) ([]dclient.APIContainers, error)
// calls tracks calls to the methods.
calls struct {
// AddEventListener holds details about calls to the AddEventListener method.
AddEventListener []struct {
// Listener is the listener argument value.
Listener chan<- *dclient.APIEvents
}
// ListContainers holds details about calls to the ListContainers method.
ListContainers []struct {
// Opts is the opts argument value.
Opts dclient.ListContainersOptions
}
}
lockAddEventListener sync.RWMutex
lockListContainers sync.RWMutex
}
// AddEventListener calls AddEventListenerFunc.
func (mock *DockerClientMock) AddEventListener(listener chan<- *dclient.APIEvents) error {
if mock.AddEventListenerFunc == nil {
panic("DockerClientMock.AddEventListenerFunc: method is nil but DockerClient.AddEventListener was just called")
}
callInfo := struct {
Listener chan<- *dclient.APIEvents
}{
Listener: listener,
}
mock.lockAddEventListener.Lock()
mock.calls.AddEventListener = append(mock.calls.AddEventListener, callInfo)
mock.lockAddEventListener.Unlock()
return mock.AddEventListenerFunc(listener)
}
// AddEventListenerCalls gets all the calls that were made to AddEventListener.
// Check the length with:
// len(mockedDockerClient.AddEventListenerCalls())
func (mock *DockerClientMock) AddEventListenerCalls() []struct {
Listener chan<- *dclient.APIEvents
} {
var calls []struct {
Listener chan<- *dclient.APIEvents
}
mock.lockAddEventListener.RLock()
calls = mock.calls.AddEventListener
mock.lockAddEventListener.RUnlock()
return calls
}
// ListContainers calls ListContainersFunc.
func (mock *DockerClientMock) ListContainers(opts dclient.ListContainersOptions) ([]dclient.APIContainers, error) {
if mock.ListContainersFunc == nil {
panic("DockerClientMock.ListContainersFunc: method is nil but DockerClient.ListContainers was just called")
}
callInfo := struct {
Opts dclient.ListContainersOptions
}{
Opts: opts,
}
mock.lockListContainers.Lock()
mock.calls.ListContainers = append(mock.calls.ListContainers, callInfo)
mock.lockListContainers.Unlock()
return mock.ListContainersFunc(opts)
}
// ListContainersCalls gets all the calls that were made to ListContainers.
// Check the length with:
// len(mockedDockerClient.ListContainersCalls())
func (mock *DockerClientMock) ListContainersCalls() []struct {
Opts dclient.ListContainersOptions
} {
var calls []struct {
Opts dclient.ListContainersOptions
}
mock.lockListContainers.RLock()
calls = mock.calls.ListContainers
mock.lockListContainers.RUnlock()
return calls
}

View File

@ -0,0 +1,64 @@
package provider
import (
"context"
"testing"
"time"
dclient "github.com/fsouza/go-dockerclient"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDocker_List(t *testing.T) {
dc := &DockerClientMock{
ListContainersFunc: func(opts dclient.ListContainersOptions) ([]dclient.APIContainers, error) {
return []dclient.APIContainers{
{Names: []string{"c1"}, Status: "start",
Labels: map[string]string{"dpx.route": "^/api/123/(.*)", "dpx.dest": "/blah/$1"}},
{Names: []string{"c2"}, Status: "start"},
{Names: []string{"c3"}, Status: "stop"},
}, nil
},
}
d := Docker{DockerClient: dc}
res, err := d.List()
require.NoError(t, err)
assert.Equal(t, 2, len(res))
assert.Equal(t, "^/api/123/(.*)", res[0].SrcMatch.String())
assert.Equal(t, "http://c1:8080/blah/$1", res[0].Dst)
assert.Equal(t, "^/api/c2/(.*)", res[1].SrcMatch.String())
assert.Equal(t, "http://c2:8080/$1", res[1].Dst)
}
func TestDocker_Events(t *testing.T) {
dc := &DockerClientMock{
AddEventListenerFunc: func(listener chan<- *dclient.APIEvents) error {
go func() {
time.Sleep(30 * time.Millisecond)
listener <- &dclient.APIEvents{Type: "container", Status: "start",
Actor: dclient.APIActor{Attributes: map[string]string{"name": "/c1"}}}
time.Sleep(30 * time.Millisecond)
listener <- &dclient.APIEvents{Type: "bad", Status: "start",
Actor: dclient.APIActor{Attributes: map[string]string{"name": "/c2"}}}
}()
return nil
},
}
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
d := Docker{DockerClient: dc}
ch := d.Events(ctx)
events := 0
for range ch {
t.Log("event")
events++
}
assert.Equal(t, 1, events)
}

View File

@ -0,0 +1,91 @@
package provider
import (
"bufio"
"context"
"os"
"regexp"
"strings"
"time"
log "github.com/go-pkgz/lgr"
"github.com/pkg/errors"
"github.com/umputun/docker-proxy/app/discovery"
)
// File implements file-based provider
// Each line contains src:dst pairs, i.e. ^/api/svc1/(.*) http://127.0.0:8080/blah/$1
type File struct {
FileName string
CheckInterval time.Duration
Delay time.Duration
}
// Events returns channel updating on file change only
func (d *File) Events(ctx context.Context) <-chan struct{} {
res := make(chan struct{})
// no need to queue multiple events or wait
trySubmit := func(ch chan struct{}) {
select {
case ch <- struct{}{}:
default:
}
}
go func() {
tk := time.NewTicker(d.CheckInterval)
lastModif := time.Time{}
for {
select {
case <-tk.C:
fi, err := os.Stat(d.FileName)
if err != nil {
continue
}
if fi.ModTime() != lastModif {
// don't react on modification right away
if fi.ModTime().Sub(lastModif) < d.Delay {
continue
}
log.Printf("[DEBUG] file %s changed, %s -> %s", d.FileName,
lastModif.Format(time.RFC3339Nano), fi.ModTime().Format(time.RFC3339Nano))
lastModif = fi.ModTime()
trySubmit(res)
}
case <-ctx.Done():
close(res)
tk.Stop()
return
}
}
}()
return res
}
// List all src dst pairs
func (d *File) List() (res []discovery.UrlMapper, err error) {
fh, err := os.Open(d.FileName)
if err != nil {
return nil, errors.Wrapf(err, "can't open %s", d.FileName)
}
defer fh.Close()
s := bufio.NewScanner(fh)
for s.Scan() {
line := s.Text()
elems := strings.Fields(line)
if len(elems) != 2 {
continue
}
rx, err := regexp.Compile(elems[0])
if err != nil {
return nil, errors.Wrapf(err, "can't parse regex %s", elems[0])
}
res = append(res, discovery.UrlMapper{SrcMatch: rx, Dst: elems[1]})
}
return res, s.Err()
}
func (d *File) ID() string { return "file" }

View File

@ -0,0 +1,63 @@
package provider
import (
"context"
"io/ioutil"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFile_Events(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
tmp, err := ioutil.TempFile(os.TempDir(), "dpx-events")
require.NoError(t, err)
tmp.Close()
defer os.Remove(tmp.Name())
f := File{
FileName: tmp.Name(),
CheckInterval: 10 * time.Millisecond,
Delay: 20 * time.Millisecond,
}
go func() {
time.Sleep(30 * time.Millisecond)
assert.NoError(t, ioutil.WriteFile(tmp.Name(), []byte("something"), 0600))
time.Sleep(30 * time.Millisecond)
assert.NoError(t, ioutil.WriteFile(tmp.Name(), []byte("something"), 0600))
time.Sleep(30 * time.Millisecond)
assert.NoError(t, ioutil.WriteFile(tmp.Name(), []byte("something"), 0600))
// all those event will be ignored, submitted too fast
assert.NoError(t, ioutil.WriteFile(tmp.Name(), []byte("something"), 0600))
assert.NoError(t, ioutil.WriteFile(tmp.Name(), []byte("something"), 0600))
assert.NoError(t, ioutil.WriteFile(tmp.Name(), []byte("something"), 0600))
assert.NoError(t, ioutil.WriteFile(tmp.Name(), []byte("something"), 0600))
assert.NoError(t, ioutil.WriteFile(tmp.Name(), []byte("something"), 0600))
}()
ch := f.Events(ctx)
events := 0
for range ch {
t.Log("event")
events++
}
assert.Equal(t, 4, events)
}
func TestFile_List(t *testing.T) {
f := File{FileName: "testdata/routes.txt"}
res, err := f.List()
require.NoError(t, err)
t.Logf("%+v", res)
assert.Equal(t, 3, len(res))
assert.Equal(t, "^/api/svc1/(.*)", res[0].SrcMatch.String())
assert.Equal(t, "http://127.0.0.2:8080/blah2/$1/abc", res[1].Dst)
}

View File

@ -0,0 +1,41 @@
package provider
import (
"context"
"regexp"
"strings"
"github.com/pkg/errors"
"github.com/umputun/docker-proxy/app/discovery"
)
// Static provider, rules are from::to
type Static struct {
Rules []string
}
// Events returns channel updating on file change only
func (s *Static) Events(ctx context.Context) <-chan struct{} {
res := make(chan struct{}, 1)
res <- struct{}{}
return res
}
// List all src dst pairs
func (s *Static) List() (res []discovery.UrlMapper, err error) {
for _, r := range s.Rules {
elems := strings.Split(r, "::")
if len(elems) != 2 {
continue
}
rx, err := regexp.Compile(elems[0])
if err != nil {
return nil, errors.Wrapf(err, "can't parse regex %s", elems[0])
}
res = append(res, discovery.UrlMapper{SrcMatch: rx, Dst: elems[1]})
}
return res, nil
}
func (s *Static) ID() string { return "static" }

View File

@ -0,0 +1,4 @@
^/api/svc1/(.*) http://127.0.0.1:8080/blah1/$1
^/api/svc2/(.*) http://127.0.0.2:8080/blah2/$1/abc
/api/svc3/xyz http://127.0.0.3:8080/blah3/xyz

View File

@ -0,0 +1,146 @@
// Code generated by moq; DO NOT EDIT.
// github.com/matryer/moq
package discovery
import (
"context"
"sync"
)
// Ensure, that ProviderMock does implement Provider.
// If this is not the case, regenerate this file with moq.
var _ Provider = &ProviderMock{}
// ProviderMock is a mock implementation of Provider.
//
// func TestSomethingThatUsesProvider(t *testing.T) {
//
// // make and configure a mocked Provider
// mockedProvider := &ProviderMock{
// EventsFunc: func(ctx context.Context) <-chan struct{} {
// panic("mock out the Events method")
// },
// IDFunc: func() string {
// panic("mock out the ID method")
// },
// ListFunc: func() ([]UrlMapper, error) {
// panic("mock out the List method")
// },
// }
//
// // use mockedProvider in code that requires Provider
// // and then make assertions.
//
// }
type ProviderMock struct {
// EventsFunc mocks the Events method.
EventsFunc func(ctx context.Context) <-chan struct{}
// IDFunc mocks the ID method.
IDFunc func() string
// ListFunc mocks the List method.
ListFunc func() ([]UrlMapper, error)
// calls tracks calls to the methods.
calls struct {
// Events holds details about calls to the Events method.
Events []struct {
// Ctx is the ctx argument value.
Ctx context.Context
}
// ID holds details about calls to the ID method.
ID []struct {
}
// List holds details about calls to the List method.
List []struct {
}
}
lockEvents sync.RWMutex
lockID sync.RWMutex
lockList sync.RWMutex
}
// Events calls EventsFunc.
func (mock *ProviderMock) Events(ctx context.Context) <-chan struct{} {
if mock.EventsFunc == nil {
panic("ProviderMock.EventsFunc: method is nil but Provider.Events was just called")
}
callInfo := struct {
Ctx context.Context
}{
Ctx: ctx,
}
mock.lockEvents.Lock()
mock.calls.Events = append(mock.calls.Events, callInfo)
mock.lockEvents.Unlock()
return mock.EventsFunc(ctx)
}
// EventsCalls gets all the calls that were made to Events.
// Check the length with:
// len(mockedProvider.EventsCalls())
func (mock *ProviderMock) EventsCalls() []struct {
Ctx context.Context
} {
var calls []struct {
Ctx context.Context
}
mock.lockEvents.RLock()
calls = mock.calls.Events
mock.lockEvents.RUnlock()
return calls
}
// ID calls IDFunc.
func (mock *ProviderMock) ID() string {
if mock.IDFunc == nil {
panic("ProviderMock.IDFunc: method is nil but Provider.ID was just called")
}
callInfo := struct {
}{}
mock.lockID.Lock()
mock.calls.ID = append(mock.calls.ID, callInfo)
mock.lockID.Unlock()
return mock.IDFunc()
}
// IDCalls gets all the calls that were made to ID.
// Check the length with:
// len(mockedProvider.IDCalls())
func (mock *ProviderMock) IDCalls() []struct {
} {
var calls []struct {
}
mock.lockID.RLock()
calls = mock.calls.ID
mock.lockID.RUnlock()
return calls
}
// List calls ListFunc.
func (mock *ProviderMock) List() ([]UrlMapper, error) {
if mock.ListFunc == nil {
panic("ProviderMock.ListFunc: method is nil but Provider.List was just called")
}
callInfo := struct {
}{}
mock.lockList.Lock()
mock.calls.List = append(mock.calls.List, callInfo)
mock.lockList.Unlock()
return mock.ListFunc()
}
// ListCalls gets all the calls that were made to List.
// Check the length with:
// len(mockedProvider.ListCalls())
func (mock *ProviderMock) ListCalls() []struct {
} {
var calls []struct {
}
mock.lockList.RLock()
calls = mock.calls.List
mock.lockList.RUnlock()
return calls
}

157
app/main.go Normal file
View File

@ -0,0 +1,157 @@
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"runtime"
"syscall"
"time"
docker "github.com/fsouza/go-dockerclient"
"github.com/go-pkgz/lgr"
"github.com/pkg/errors"
"github.com/umputun/docker-proxy/app/discovery"
"github.com/umputun/docker-proxy/app/discovery/provider"
"github.com/umputun/docker-proxy/app/proxy"
"github.com/umputun/go-flags"
)
var opts struct {
Listen string `short:"l" long:"listen" env:"LISTEN" default:"127.0.0.1:8080" description:"listen on host:port"`
TimeOut time.Duration `short:"t" long:"timeout" env:"TIMEOUT" default:"5s" description:"proxy timeout"`
MaxSize int64 `long:"m" long:"max" env:"MAX_SIZE" default:"64000" description:"max response size"`
GzipEnabled bool `short:"g" long:"gzip" env:"GZIP" description:"enable gz compression"`
Assets struct {
Location string `short:"a" long:"location" env:"LOCATION" default:"" description:"assets location"`
WebRoot string `long:"root" env:"ROOT" default:"/" description:"assets web root"`
} `group:"assets" namespace:"assets" env-namespace:"ASSETS"`
Docker struct {
Enabled bool `long:"enabled" env:"ENABLED" description:"enable docker provider"`
Host string `long:"host" env:"HOST" default:"unix:///var/run/docker.sock" description:"docker host"`
Excluded []string `long:"exclude" env:"EXCLUDE" description:"excluded containers"`
} `group:"docker" namespace:"docker" env-namespace:"DOCKER"`
File struct {
Enabled bool `long:"enabled" env:"ENABLED" description:"enable file provider"`
Name string `long:"name" env:"NAME" default:"dpx.conf" description:"file name"`
CheckInterval time.Duration `long:"interval" env:"INTERVAL" default:"3s" description:"file check interval"`
Delay time.Duration `long:"delay" env:"DELAY" default:"500ms" description:"file event delay"`
} `group:"file" namespace:"file" env-namespace:"FILE"`
Static struct {
Enabled bool `long:"enabled" env:"ENABLED" description:"enable file provider"`
Rules []string `long:"rule" env:"RULES" description:"routing rules"`
} `group:"static" namespace:"static" env-namespace:"STATIC"`
Dbg bool `long:"dbg" env:"DEBUG" description:"debug mode"`
}
var revision = "unknown"
func main() {
fmt.Printf("docker-proxy (dpx) %s\n", revision)
p := flags.NewParser(&opts, flags.PrintErrors|flags.PassDoubleDash|flags.HelpFlag)
p.SubcommandsOptional = true
if _, err := p.Parse(); err != nil {
if err.(*flags.Error).Type != flags.ErrHelp {
log.Printf("[ERROR] cli error: %v", err)
}
os.Exit(1)
}
setupLog(opts.Dbg)
catchSignal()
defer func() {
if x := recover(); x != nil {
log.Printf("[WARN] run time panic:\n%v", x)
panic(x)
}
}()
providers, err := makeProviders()
if err != nil {
log.Fatalf("[ERROR] failed to make providers, %v", err)
}
svc := discovery.NewService(providers)
go func() {
if err := svc.Do(context.Background()); err != nil {
log.Fatalf("[ERROR] discovery failed, %v", err)
}
}()
px := &proxy.Http{
Version: revision,
Matcher: svc,
Address: opts.Listen,
TimeOut: opts.TimeOut,
MaxBodySize: opts.MaxSize,
AssetsLocation: opts.Assets.Location,
AssetsWebRoot: opts.Assets.WebRoot,
GzEnabled: opts.GzipEnabled,
}
if err := px.Do(context.Background()); err != nil {
log.Fatalf("[ERROR] proxy server failed, %v", err)
}
}
func makeProviders() ([]discovery.Provider, error) {
var res []discovery.Provider
if opts.File.Enabled {
res = append(res, &provider.File{
FileName: opts.File.Name,
CheckInterval: opts.File.CheckInterval,
Delay: opts.File.Delay,
})
}
if opts.Docker.Enabled {
client, err := docker.NewClient(opts.Docker.Host)
if err != nil {
return nil, errors.Wrapf(err, "failed to make docker client %s", err)
}
res = append(res, &provider.Docker{DockerClient: client, Excludes: opts.Docker.Excluded})
}
if opts.Static.Enabled {
res = append(res, &provider.Static{Rules: opts.Static.Rules})
}
if len(res) == 0 {
return nil, errors.Errorf("no providers enabled")
}
return res, nil
}
func setupLog(dbg bool) {
logOpts := []lgr.Option{lgr.Msec, lgr.LevelBraces, lgr.StackTraceOnError}
if dbg {
logOpts = []lgr.Option{lgr.Debug, lgr.CallerFile, lgr.CallerFunc, lgr.Msec, lgr.LevelBraces, lgr.StackTraceOnError}
}
lgr.SetupStdLogger(logOpts...)
}
func catchSignal() {
// catch SIGQUIT and print stack traces
sigChan := make(chan os.Signal)
go func() {
for range sigChan {
log.Print("[INFO] SIGQUIT detected")
stacktrace := make([]byte, 8192)
length := runtime.Stack(stacktrace, true)
if length > 8192 {
length = 8192
}
fmt.Println(string(stacktrace[:length]))
}
}()
signal.Notify(sigChan, syscall.SIGQUIT)
}

View File

@ -0,0 +1,48 @@
package middleware
import (
"compress/gzip"
"io"
"io/ioutil"
"net/http"
"strings"
"sync"
)
var gzPool = sync.Pool{
New: func() interface{} { return gzip.NewWriter(ioutil.Discard) },
}
type gzipResponseWriter struct {
io.Writer
http.ResponseWriter
}
func (w *gzipResponseWriter) WriteHeader(status int) {
w.Header().Del("Content-Length")
w.ResponseWriter.WriteHeader(status)
}
func (w *gzipResponseWriter) Write(b []byte) (int, error) {
return w.Writer.Write(b)
}
// Gzip is a middleware compressing response
func Gzip(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
next.ServeHTTP(w, r)
return
}
w.Header().Set("Content-Encoding", "gzip")
gz := gzPool.Get().(*gzip.Writer)
defer gzPool.Put(gz)
gz.Reset(w)
defer gz.Close()
next.ServeHTTP(&gzipResponseWriter{ResponseWriter: w, Writer: gz}, r)
})
}

View File

@ -0,0 +1,62 @@
package middleware
import (
"bytes"
"compress/gzip"
"io/ioutil"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGzip(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := w.Write([]byte("Lorem Ipsum is simply dummy text of the printing and typesetting industry. " +
"Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took " +
"a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries," +
" but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised" +
" in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, " +
"and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum."))
require.NoError(t, err)
})
ts := httptest.NewServer(Gzip(handler))
defer ts.Close()
client := http.Client{}
{
req, err := http.NewRequest("GET", ts.URL+"/something", nil)
require.NoError(t, err)
req.Header.Set("Accept-Encoding", "gzip")
resp, err := client.Do(req)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
defer resp.Body.Close()
b, err := ioutil.ReadAll(resp.Body)
assert.NoError(t, err)
assert.Equal(t, 355, len(b), "compressed size")
gzr, err := gzip.NewReader(bytes.NewBuffer(b))
require.NoError(t, err)
b, err = ioutil.ReadAll(gzr)
require.NoError(t, err)
assert.True(t, strings.HasPrefix(string(b), "Lorem Ipsum"), string(b))
}
{
req, err := http.NewRequest("GET", ts.URL+"/something", nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.Nil(t, err)
assert.Equal(t, 200, resp.StatusCode)
defer resp.Body.Close()
b, err := ioutil.ReadAll(resp.Body)
assert.NoError(t, err)
assert.Equal(t, 576, len(b), "uncompressed size")
}
}

133
app/proxy/proxy.go Normal file
View File

@ -0,0 +1,133 @@
package proxy
import (
"context"
"log"
"net/http"
"net/http/httputil"
"net/url"
"time"
"github.com/go-pkgz/rest"
"github.com/go-pkgz/rest/logger"
"github.com/umputun/docker-proxy/app/proxy/middleware"
)
type Http struct {
Matcher
Address string
TimeOut time.Duration
AssetsLocation string
AssetsWebRoot string
MaxBodySize int64
GzEnabled bool
Version string
}
type Matcher interface {
Match(url string) (string, bool)
}
func (h *Http) Do(ctx context.Context) error {
log.Printf("[INFO] run proxy on %s", h.Address)
if h.AssetsLocation != "" {
log.Printf("[DEBUG] assets file server enabled for %s", h.AssetsLocation)
}
httpServer := &http.Server{
Addr: h.Address,
Handler: h.wrap(h.proxyHandler(),
rest.AppInfo("dpx", "umputun", h.Version),
rest.Ping,
logger.New(logger.Prefix("[DEBUG] PROXY")).Handler,
rest.SizeLimit(h.MaxBodySize),
h.gzipHandler(),
),
ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 120 * time.Second,
IdleTimeout: 30 * time.Second,
}
go func() {
<-ctx.Done()
if err := httpServer.Close(); err != nil {
log.Printf("[ERROR] failed to close proxy server, %v", err)
}
}()
return httpServer.ListenAndServe()
}
func (h *Http) gzipHandler() func(next http.Handler) http.Handler {
gzHandler := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
})
}
if h.GzEnabled {
gzHandler = middleware.Gzip
}
return gzHandler
}
// wrap convert a list of middlewares to nested calls, in reversed order
func (h *Http) wrap(p http.Handler, mws ...func(http.Handler) http.Handler) http.Handler {
res := p
for i := len(mws) - 1; i >= 0; i-- {
res = mws[i](res)
}
return res
}
func (h *Http) proxyHandler() http.HandlerFunc {
type contextKey string
reverseProxy := &httputil.ReverseProxy{
Director: func(r *http.Request) {
ctx := r.Context()
uu := ctx.Value(contextKey("url")).(*url.URL)
r.URL.Path = uu.Path
r.URL.Host = uu.Host
r.URL.Scheme = uu.Scheme
r.Header.Add("X-Forwarded-Host", uu.Host)
r.Header.Add("X-Origin-Host", r.Host)
},
}
reverseProxy.Transport = http.DefaultTransport
reverseProxy.Transport.(*http.Transport).ResponseHeaderTimeout = h.TimeOut
// default assetsHandler disabled, returns error on missing matches
assetsHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("[WARN] mo match for %s", r.URL)
http.Error(w, "Server error", http.StatusBadGateway)
})
if h.AssetsLocation != "" && h.AssetsWebRoot != "" {
fs, err := rest.FileServer(h.AssetsWebRoot, h.AssetsLocation)
if err == nil {
assetsHandler = func(w http.ResponseWriter, r *http.Request) {
fs.ServeHTTP(w, r)
}
}
}
return func(w http.ResponseWriter, r *http.Request) {
u, ok := h.Match(r.URL.Path)
if !ok {
assetsHandler.ServeHTTP(w, r)
return
}
uu, err := url.Parse(u)
if err != nil {
http.Error(w, "Server error", http.StatusBadGateway)
return
}
ctx := context.WithValue(r.Context(), contextKey("url"), uu) // set destination url in request's context
reverseProxy.ServeHTTP(w, r.WithContext(ctx))
}
}

60
app/proxy/proxy_test.go Normal file
View File

@ -0,0 +1,60 @@
package proxy
import (
"context"
"fmt"
"io"
"math/rand"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/umputun/docker-proxy/app/discovery"
"github.com/umputun/docker-proxy/app/discovery/provider"
)
func TestHttp_Do(t *testing.T) {
port := rand.Intn(10000) + 40000
h := Http{TimeOut: 200 * time.Millisecond, Address: fmt.Sprintf("127.0.0.1:%d", port)}
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
ds := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Logf("%v", r)
w.Header().Add("h1", "v1")
w.Write([]byte("response"))
}))
svc := discovery.NewService([]discovery.Provider{
&provider.Static{Rules: []string{"^/api/(.*)::" + ds.URL + "/123/$1"}}})
go func() {
svc.Do(context.Background())
}()
h.Matcher = svc
go func() {
h.Do(ctx)
}()
time.Sleep(10 * time.Millisecond)
client := http.Client{}
resp, err := client.Get("http://127.0.0.1:" + strconv.Itoa(port) + "/api/something")
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
t.Logf("%+v", resp.Header)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t, "response", string(body))
assert.Equal(t, "dpx", resp.Header.Get("App-Name"))
assert.Equal(t, "v1", resp.Header.Get("h1"))
resp, err = client.Get("http://127.0.0.1:" + strconv.Itoa(port) + "/bad/something")
require.NoError(t, err)
assert.Equal(t, http.StatusBadGateway, resp.StatusCode)
}

12
go.mod Normal file
View File

@ -0,0 +1,12 @@
module github.com/umputun/docker-proxy
go 1.16
require (
github.com/fsouza/go-dockerclient v1.7.2
github.com/go-pkgz/lgr v0.10.4
github.com/go-pkgz/rest v1.7.0
github.com/pkg/errors v0.9.1
github.com/stretchr/testify v1.6.1
github.com/umputun/go-flags v1.5.1
)

206
go.sum Normal file
View File

@ -0,0 +1,206 @@
bazil.org/fuse v0.0.0-20160811212531-371fbbdaa898/go.mod h1:Xbm+BRKSBEpa4q4hTSxohYNQpsxXPbPry4JJWOB3LB8=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8=
github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Microsoft/go-winio v0.4.16-0.20201130162521-d1ffc52c7331/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0=
github.com/Microsoft/go-winio v0.4.16 h1:FtSW/jqD+l4ba5iPBj9CODVtgfYAD8w2wS923g/cFDk=
github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0=
github.com/Microsoft/hcsshim v0.8.14 h1:lbPVK25c1cu5xTLITwpUcxoA9vKrKErASPYygvouJns=
github.com/Microsoft/hcsshim v0.8.14/go.mod h1:NtVKoYxQuTLx6gEq0L96c9Ju4JbRJ4nY2ow3VK6a9Lg=
github.com/cilium/ebpf v0.0.0-20200110133405-4032b1d8aae3/go.mod h1:MA5e5Lr8slmEg9bt0VpxxWqJlO4iwu3FBdHUzV7wQVg=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/containerd/cgroups v0.0.0-20200531161412-0dbf7f05ba59 h1:qWj4qVYZ95vLWwqyNJCQg7rDsG5wPdze0UaPolH7DUk=
github.com/containerd/cgroups v0.0.0-20200531161412-0dbf7f05ba59/go.mod h1:pA0z1pT8KYB3TCXK/ocprsh7MAkoW8bZVzPdih9snmM=
github.com/containerd/console v0.0.0-20180822173158-c12b1e7919c1/go.mod h1:Tj/on1eG8kiEhd0+fhSDzsPAFESxzBBvdyEgyryXffw=
github.com/containerd/containerd v1.3.2/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
github.com/containerd/containerd v1.4.3 h1:ijQT13JedHSHrQGWFcGEwzcNKrAGIiZ+jSD5QQG07SY=
github.com/containerd/containerd v1.4.3/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y=
github.com/containerd/continuity v0.0.0-20210208174643-50096c924a4e h1:6JKvHHt396/qabvMhnhUZvWaHZzfVfldxE60TK8YLhg=
github.com/containerd/continuity v0.0.0-20210208174643-50096c924a4e/go.mod h1:EXlVlkqNba9rJe3j7w3Xa924itAMLgZH4UD/Q4PExuQ=
github.com/containerd/fifo v0.0.0-20190226154929-a9fb20d87448/go.mod h1:ODA38xgv3Kuk8dQz2ZQXpnv/UZZUHUCL7pnLehbXgQI=
github.com/containerd/go-runc v0.0.0-20180907222934-5a6d9f37cfa3/go.mod h1:IV7qH3hrUgRmyYrtgEeGWJfWbgcHL9CSRruz2Vqcph0=
github.com/containerd/ttrpc v0.0.0-20190828154514-0e0f228740de/go.mod h1:PvCDdDGpgqzQIzDW1TphrGLssLDZp2GuS+X5DkEJB8o=
github.com/containerd/typeurl v0.0.0-20180627222232-a93fcdb778cd/go.mod h1:Cm3kwCdlkCfMSHURc+r6fwoGH6/F1hH3S4sg0rLFWPc=
github.com/coreos/go-systemd/v22 v22.0.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+1atmu1JpKERPPk=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw=
github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/docker/docker v20.10.3-0.20210216175712-646072ed6524+incompatible h1:Yu2uGErhwEoOT/OxAFe+/SiJCqRLs+pgcS5XKrDXnG4=
github.com/docker/docker v20.10.3-0.20210216175712-646072ed6524+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw=
github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/fsouza/go-dockerclient v1.7.2 h1:bBEAcqLTkpq205jooP5RVroUKiVEWgGecHyeZc4OFjo=
github.com/fsouza/go-dockerclient v1.7.2/go.mod h1:+ugtMCVRwnPfY7d8/baCzZ3uwB0BrG5DB8OzbtxaRz8=
github.com/go-pkgz/lgr v0.10.4 h1:l7qyFjqEZgwRgaQQSEp6tve4A3OU80VrfzpvtEX8ngw=
github.com/go-pkgz/lgr v0.10.4/go.mod h1:CD0s1z6EFpIUplV067gitF77tn25JItzwHNKAPqeCF0=
github.com/go-pkgz/rest v1.7.0 h1:5KHWmYPZaJfd6+Htx8bCJ2InVSHJ85MGh8kuO72SAjU=
github.com/go-pkgz/rest v1.7.0/go.mod h1:FKpgK5FgSqREG323OIU/JpIc0xA7dqay9BmK7LZXTQE=
github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/moby/sys/mount v0.2.0 h1:WhCW5B355jtxndN5ovugJlMFJawbUODuW8fSnEH6SSM=
github.com/moby/sys/mount v0.2.0/go.mod h1:aAivFE2LB3W4bACsUXChRHQ0qKWsetY4Y9V7sxOougM=
github.com/moby/sys/mountinfo v0.4.0 h1:1KInV3Huv18akCu58V7lzNlt+jFmqlu1EaErnEHE/VM=
github.com/moby/sys/mountinfo v0.4.0/go.mod h1:rEr8tzG/lsIZHBtN/JjGG+LMYx9eXgW2JI+6q0qou+A=
github.com/moby/term v0.0.0-20201216013528-df9cb8a40635 h1:rzf0wL0CHVc8CEsgyygG0Mn9CNCCPZqOPaz8RiiHYQk=
github.com/moby/term v0.0.0-20201216013528-df9cb8a40635/go.mod h1:FBS0z0QWA44HXygs7VXDUOGoN/1TV3RuWkLO04am3wc=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v0.0.0-20180430190053-c9281466c8b2/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.0.1 h1:JMemWkRwHx4Zj+fVxWoMCFm/8sYGGrUVojFA6h/TRcI=
github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
github.com/opencontainers/runc v0.0.0-20190115041553-12f6a991201f/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U=
github.com/opencontainers/runc v0.1.1 h1:GlxAyO6x8rfZYN9Tt0Kti5a/cP41iuiO2yYT0IJGY8Y=
github.com/opencontainers/runc v0.1.1/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U=
github.com/opencontainers/runtime-spec v1.0.2/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/procfs v0.0.0-20180125133057-cb4147076ac7/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.7.0 h1:ShrD1U9pZB12TX0cVy0DtePoCH97K8EtX+mg7ZARUtM=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/spf13/cobra v0.0.2-0.20171109065643-2da4a54c5cee/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
github.com/spf13/pflag v1.0.1-0.20171106142849-4c012f6dcd95/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/umputun/go-flags v1.5.1 h1:vRauoXV3Ultt1HrxivSxowbintgZLJE+EcBy5ta3/mY=
github.com/umputun/go-flags v1.5.1/go.mod h1:nTbvsO/hKqe7Utri/NoyN18GR3+EWf+9RrmsdwdhrEc=
github.com/urfave/cli v1.22.2/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opencensus.io v0.22.0 h1:C9hSCOW830chIVkdja34wa6Ky+IzWllkUinR+BtRZd4=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191004110552-13f9640d40b9/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9 h1:SQFwaSi55rU7vdNs9Yr0Z324VNlrF+0wMqRXT4St8ck=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191022100944-742c48ecaeb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200120151820-655fe14d7479/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200831180312-196b9ba8737a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200922070232-aee5d888a860/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210216224549-f992740a1bac h1:9glrpwtNjBYgRpb67AZJKHfzj1stG/8BL5H7In2oTC4=
golang.org/x/sys v0.0.0-20210216224549-f992740a1bac/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201113234701-d7a72108b828 h1:htWEtQEuEVJ4tU/Ngx7Cd/4Q7e3A5Up1owgyBtVsTwk=
golang.org/x/term v0.0.0-20201113234701-d7a72108b828/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=
gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0=
gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=