mirror of
https://github.com/umputun/reproxy.git
synced 2024-08-17 12:40:30 +03:00
initial version #1
This commit is contained in:
parent
b5022affec
commit
8a7b73f41f
5
.github/CODEOWNERS
vendored
Normal file
5
.github/CODEOWNERS
vendored
Normal 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
53
.github/workflows/ci.yml
vendored
Normal 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
15
.gitignore
vendored
@ -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
116
app/discovery/discovery.go
Normal 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
|
||||
}
|
102
app/discovery/discovery_test.go
Normal file
102
app/discovery/discovery_test.go
Normal 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)
|
||||
}
|
||||
}
|
163
app/discovery/provider/docker.go
Normal file
163
app/discovery/provider/docker.go
Normal 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
|
||||
}
|
114
app/discovery/provider/docker_client_mock.go
Normal file
114
app/discovery/provider/docker_client_mock.go
Normal 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
|
||||
}
|
64
app/discovery/provider/docker_test.go
Normal file
64
app/discovery/provider/docker_test.go
Normal 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)
|
||||
}
|
91
app/discovery/provider/file.go
Normal file
91
app/discovery/provider/file.go
Normal 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" }
|
63
app/discovery/provider/file_test.go
Normal file
63
app/discovery/provider/file_test.go
Normal 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)
|
||||
}
|
41
app/discovery/provider/static.go
Normal file
41
app/discovery/provider/static.go
Normal 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" }
|
4
app/discovery/provider/testdata/routes.txt
vendored
Normal file
4
app/discovery/provider/testdata/routes.txt
vendored
Normal 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
|
||||
|
146
app/discovery/provider_mock.go
Normal file
146
app/discovery/provider_mock.go
Normal 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
157
app/main.go
Normal 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)
|
||||
}
|
48
app/proxy/middleware/gzip.go
Normal file
48
app/proxy/middleware/gzip.go
Normal 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)
|
||||
})
|
||||
}
|
62
app/proxy/middleware/gzip_test.go
Normal file
62
app/proxy/middleware/gzip_test.go
Normal 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 industry’s 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
133
app/proxy/proxy.go
Normal 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
60
app/proxy/proxy_test.go
Normal 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
12
go.mod
Normal 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
206
go.sum
Normal 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=
|
Loading…
Reference in New Issue
Block a user