├── .gitignore ├── .dockerignore ├── Makefile ├── logger.go ├── Dockerfile ├── internal └── health │ └── health.go ├── go.mod ├── health.go ├── fly.toml ├── config.go ├── app.go ├── README.md ├── proxy.go ├── main.go ├── main_test.go └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | /.envrc 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | bin 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NOW_RFC3339 = $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") 2 | 3 | all: build 4 | 5 | build: 6 | go build -o bin/machine-proxy -ldflags="-X 'github.com/superfly/machine-proxy/buildinfo.buildDate=$(NOW_RFC3339)'" . 7 | -------------------------------------------------------------------------------- /logger.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "log" 4 | 5 | type fakeLogger struct{} 6 | 7 | func (*fakeLogger) Debugf(format string, v ...interface{}) { 8 | log.Printf(format, v...) 9 | } 10 | 11 | func (*fakeLogger) Debug(v ...interface{}) { 12 | log.Print(v...) 13 | } 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.17-alpine AS build 2 | 3 | RUN apk add --no-cache make ca-certificates 4 | WORKDIR /src/ 5 | COPY . /src/ 6 | RUN CGO_ENABLED=0 make build 7 | 8 | FROM scratch 9 | COPY --from=build /src/bin/machine-proxy /bin/machine-proxy 10 | COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 11 | ENTRYPOINT ["/bin/machine-proxy"] 12 | -------------------------------------------------------------------------------- /internal/health/health.go: -------------------------------------------------------------------------------- 1 | package health 2 | 3 | import "sync" 4 | 5 | var ( 6 | mu sync.RWMutex 7 | healthy bool 8 | ) 9 | 10 | func Is() bool { 11 | mu.RLock() 12 | status := healthy 13 | mu.RUnlock() 14 | 15 | return status 16 | } 17 | 18 | func Set() { 19 | mu.Lock() 20 | defer mu.Unlock() 21 | 22 | healthy = true 23 | } 24 | 25 | func Unset() { 26 | mu.Lock() 27 | defer mu.Unlock() 28 | 29 | healthy = false 30 | } 31 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/superfly/machine-proxy 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/azazeal/pause v1.0.6 7 | github.com/superfly/flyctl/api v0.0.0-20211101204329-d6c7938cb64b 8 | ) 9 | 10 | require ( 11 | github.com/PuerkitoBio/rehttp v1.1.0 // indirect 12 | github.com/machinebox/graphql v0.2.2 // indirect 13 | github.com/pkg/errors v0.9.1 // indirect 14 | golang.org/x/net v0.0.0-20210510120150-4163338589ed // indirect 15 | ) 16 | -------------------------------------------------------------------------------- /health.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "time" 7 | 8 | "github.com/superfly/machine-proxy/internal/health" 9 | ) 10 | 11 | func checkAppHealth(ctx context.Context, cfg *config) { 12 | log.Println("entered checkAppHealth") 13 | defer log.Println("exited checkAppHealth") 14 | 15 | loop(ctx, time.Second, func(ctx context.Context) { 16 | // Passing a blank state will return all machines 17 | machines, err := cfg.client.ListMachines(cfg.appName, "") 18 | if err != nil { 19 | log.Printf("failed checking app health: %v", err) 20 | 21 | return 22 | } 23 | 24 | for _, machine := range machines { 25 | if machine.State != "started" { 26 | health.Unset() 27 | 28 | return 29 | } 30 | } 31 | 32 | health.Set() 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file generated for js-machine-proxy on 2021-11-05T17:27:59+01:00 2 | 3 | app = "js-machine-proxy" 4 | 5 | kill_signal = "SIGINT" 6 | kill_timeout = 5 7 | processes = [] 8 | 9 | [env] 10 | 11 | UPSTREAM = "fdaa:0:360a:a7b:2656:8257:b08:2:80" 12 | APP_NAME = "js-machine-test" 13 | 14 | [experimental] 15 | allowed_public_ports = [] 16 | auto_rollback = true 17 | 18 | [[services]] 19 | http_checks = [] 20 | internal_port = 8080 21 | processes = ["app"] 22 | protocol = "tcp" 23 | script_checks = [] 24 | 25 | [services.concurrency] 26 | hard_limit = 25 27 | soft_limit = 20 28 | type = "connections" 29 | 30 | [[services.ports]] 31 | handlers = ["http"] 32 | port = 80 33 | 34 | [[services.ports]] 35 | handlers = ["tls", "http"] 36 | port = 443 37 | 38 | [[services.tcp_checks]] 39 | grace_period = "1s" 40 | interval = "15s" 41 | restart_limit = 0 42 | timeout = "2s" 43 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | 7 | "github.com/superfly/flyctl/api" 8 | ) 9 | 10 | type config struct { 11 | addr string 12 | upstream string 13 | appName string 14 | client *api.Client 15 | } 16 | 17 | func configFromEnv() (*config, error) { 18 | addr, ok := os.LookupEnv("ADDR") 19 | if !ok || addr == "" { 20 | addr = ":8080" 21 | } 22 | 23 | upstream, ok := os.LookupEnv("UPSTREAM") 24 | if !ok || upstream == "" { 25 | return nil, errors.New("$UPSTREAM not defined") 26 | } 27 | 28 | appName, ok := os.LookupEnv("APP_NAME") 29 | if !ok || appName == "" { 30 | return nil, errors.New("$APP_NAME not defined") 31 | } 32 | 33 | accessToken, ok := os.LookupEnv("FLY_ACCESS_TOKEN") 34 | if !ok || appName == "" { 35 | return nil, errors.New("$FLY_ACCESS_TOKEN not defined") 36 | } 37 | 38 | return &config{ 39 | addr: addr, 40 | upstream: upstream, 41 | appName: appName, 42 | client: api.NewClient(accessToken, "machines-proxy", "1.0.0", new(fakeLogger)), 43 | }, nil 44 | } 45 | -------------------------------------------------------------------------------- /app.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "time" 7 | 8 | "github.com/superfly/flyctl/api" 9 | 10 | "github.com/superfly/machine-proxy/internal/health" 11 | ) 12 | 13 | func ensureAppIsRunning(ctx context.Context, cfg *config) { 14 | if health.Is() { 15 | return 16 | } 17 | 18 | machines, err := cfg.client.ListMachines(cfg.appName, "") 19 | if err != nil { 20 | return 21 | } 22 | 23 | for _, machine := range machines { 24 | input := api.StartMachineInput{ 25 | AppID: cfg.appName, 26 | ID: machine.ID, 27 | } 28 | 29 | if machine.State == "stopped" || machine.State == "exited" { 30 | log.Printf("starting %s", machine.ID) 31 | 32 | if _, err := cfg.client.StartMachine(input); err != nil { 33 | log.Printf("failed starting: %v", err) 34 | 35 | return 36 | } 37 | } 38 | } 39 | } 40 | 41 | func isAppRunning(ctx context.Context) (bool, error) { 42 | for ctx.Err() == nil { 43 | if health.Is() { 44 | return true, nil 45 | } 46 | 47 | time.Sleep(100 * time.Millisecond) 48 | } 49 | 50 | return false, ctx.Err() 51 | } 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Boot idle Fly VMs on demand via an http proxy 2 | 3 | This is a proof-of-concept Go proxy that runs as a normal Fly app and proxies 4 | requests to a single Fly VM. Should the VM be shut down, the proxy will start 5 | the VM, blocking on HTTP requests until the VM is ready to handle them. 6 | 7 | While this example is designed for a single VM backend, it could be easily 8 | adapted to map subdomains or other request parameters to specific VMs. [The Fly 9 | API Go adapter](https://pkg.go.dev/github.com/superfly/flyctl/api) allows 10 | storing metadata on individual VM records (machines). These could store information 11 | such a subdomain, and the subdomain-to-machine mapping might be fetched 12 | at boot time by the proxy. 13 | 14 | Currently, boot times vary between 1-2 seconds due the polling health checks against the API. 15 | This could be improved by switching to a new internal API that allows direct communication 16 | with the VM management service (flyd). 17 | 18 | # Setup 19 | 20 | Install Flyctl and get a Fly account if you don't have one yet. 21 | 22 | Setup this app on Fly by cloning this repo and running `fly launch`. Don't deploy yet. 23 | 24 | Next, add your Fly API token (fetch with `fly auth token`) as a secret: 25 | 26 | `fly secrets set FLY_AUTH_TOKEN=mytoken` 27 | 28 | Then, create a new app and a VM to run upstream from the proxy. 29 | 30 | ``` 31 | fly apps create myappname 32 | fly machines run nginx -a myappname 33 | ``` 34 | 35 | Finally, update `fly.toml` `env` section to reflect the new app name and VM internal IPv6 address. 36 | The IPv6 address will be picked up automatically by the next version of this proxy. For now, 37 | you can grab it via the [GraphQL API playground](https://app.fly.io/graphql). 38 | 39 | -------------------------------------------------------------------------------- /proxy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "log" 7 | "net/http" 8 | "time" 9 | ) 10 | 11 | var client = http.Client{ 12 | Timeout: time.Minute, 13 | } 14 | 15 | type proxy struct { 16 | cfg *config 17 | upstream string 18 | } 19 | 20 | func (p *proxy) do(w http.ResponseWriter, req *http.Request) { 21 | log.Println("proxying request") 22 | 23 | //http: Request.RequestURI can't be set in client requests. 24 | //http://golang.org/src/pkg/net/http/client.go 25 | 26 | req.RequestURI = "" 27 | req.URL.Host = p.upstream 28 | req.URL.Scheme = "http" 29 | 30 | log.Printf("request %s %s %s", req.RemoteAddr, req.Method, req.URL) 31 | 32 | res, err := client.Do(req) 33 | if err != nil { 34 | renderCode(w, http.StatusBadGateway) 35 | 36 | log.Printf("failed request: %v", err) 37 | 38 | return 39 | } 40 | defer res.Body.Close() 41 | 42 | log.Println(req.RemoteAddr, " ", res.Status) 43 | 44 | copyHeader(w.Header(), res.Header) 45 | w.WriteHeader(res.StatusCode) 46 | io.Copy(w, res.Body) 47 | } 48 | 49 | func (p *proxy) ServeHTTP(wr http.ResponseWriter, req *http.Request) { 50 | log.Printf("Incoming request %s %s %s", req.RemoteAddr, req.Method, req.URL) 51 | 52 | ctx, cancel := context.WithTimeout(req.Context(), time.Second*30) 53 | defer cancel() 54 | 55 | go ensureAppIsRunning(ctx, p.cfg) 56 | 57 | running, err := isAppRunning(ctx) 58 | switch { 59 | case err == nil: 60 | break 61 | case !running: 62 | renderCode(wr, http.StatusInternalServerError) // failed to run 63 | 64 | return 65 | default: 66 | renderCode(wr, http.StatusGatewayTimeout) // op timed out 67 | 68 | return 69 | } 70 | 71 | p.do(wr, req.Clone(ctx)) 72 | } 73 | 74 | func renderCode(w http.ResponseWriter, code int) { 75 | msg := http.StatusText(code) 76 | 77 | http.Error(w, msg, code) 78 | } 79 | 80 | func copyHeader(dst, src http.Header) { 81 | for key, vals := range src { 82 | dst[key] = append([]string(nil), vals...) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net" 7 | "net/http" 8 | "os" 9 | "os/signal" 10 | "sync" 11 | "syscall" 12 | "time" 13 | 14 | "github.com/azazeal/pause" 15 | "github.com/superfly/flyctl/api" 16 | ) 17 | 18 | func init() { 19 | log.SetOutput(os.Stderr) 20 | log.SetFlags(log.Ldate | log.Lmicroseconds) 21 | } 22 | 23 | func main() { 24 | api.SetBaseURL("https://app.fly.io") 25 | 26 | cfg, err := configFromEnv() 27 | if err != nil { 28 | log.Fatalf("failed loading config: %v", err) 29 | } 30 | 31 | ctx, cancel := signal.NotifyContext(context.Background(), 32 | os.Interrupt, syscall.SIGTERM) 33 | defer cancel() 34 | 35 | run(ctx, cfg) 36 | } 37 | 38 | func run(ctx context.Context, cfg *config) { 39 | var wg sync.WaitGroup 40 | defer wg.Wait() 41 | 42 | wg.Add(1) 43 | go func() { 44 | defer wg.Done() 45 | 46 | checkAppHealth(ctx, cfg) 47 | }() 48 | 49 | wg.Add(1) 50 | go func() { 51 | defer wg.Done() 52 | 53 | serve(ctx, cfg) 54 | }() 55 | } 56 | 57 | func serve(ctx context.Context, cfg *config) { 58 | log.Println("entered serve") 59 | defer log.Println("exited serve") 60 | 61 | handler := &proxy{ 62 | upstream: cfg.upstream, // localhost:10201 63 | cfg: cfg, 64 | } 65 | 66 | loop(ctx, time.Second, func(context.Context) { 67 | l, err := net.Listen("tcp", cfg.addr) 68 | if err != nil { 69 | log.Printf("failed listening on %s: %v", cfg.addr, err) 70 | 71 | return 72 | } 73 | 74 | srv := &http.Server{ 75 | Handler: handler, 76 | ErrorLog: log.New(log.Writer(), log.Prefix(), log.Flags()), 77 | } 78 | 79 | served := make(chan struct{}) 80 | defer close(served) 81 | 82 | go func() { 83 | select { 84 | case <-ctx.Done(): 85 | ctx, cancel := context.WithTimeout(context.Background(), time.Minute>>1) 86 | defer cancel() 87 | 88 | _ = srv.Shutdown(ctx) 89 | case <-served: 90 | break 91 | } 92 | }() 93 | 94 | if err := srv.Serve(l); err != http.ErrServerClosed { 95 | log.Printf("failed listening: %v", err) 96 | } 97 | }) 98 | } 99 | 100 | func loop(ctx context.Context, p time.Duration, fn func(context.Context)) { 101 | for ctx.Err() == nil { 102 | fn(ctx) 103 | 104 | pause.For(ctx, p) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/http/httptest" 7 | "strconv" 8 | "testing" 9 | ) 10 | 11 | func TestSimpleCases(t *testing.T) { 12 | cases := []struct { 13 | fn http.HandlerFunc // upstream handler 14 | code int // expected status code 15 | body string // expected response body 16 | }{ 17 | 0: { 18 | // we create an "upstream" server that renders 200 (hi\n) 19 | fn: func(w http.ResponseWriter, _ *http.Request) { 20 | io.WriteString(w, "hi\n") 21 | }, 22 | code: http.StatusOK, 23 | body: "hi\n", 24 | }, 25 | 1: { 26 | // we create an "upstream" server that renders 200 (hi\n) 27 | fn: func(w http.ResponseWriter, _ *http.Request) { 28 | w.WriteHeader(http.StatusExpectationFailed) 29 | io.WriteString(w, "panos") 30 | }, 31 | code: http.StatusExpectationFailed, 32 | body: "panos", 33 | }, 34 | } 35 | 36 | for caseIndex := range cases { 37 | kase := cases[caseIndex] 38 | 39 | t.Run(strconv.Itoa(caseIndex), func(t *testing.T) { 40 | runTest(t, kase.fn, kase.code, kase.body) 41 | }) 42 | } 43 | } 44 | 45 | func runTest(t *testing.T, fn http.HandlerFunc, code int, body string) { 46 | upstreamServer, proxyServer := setupTest(t, fn) 47 | defer upstreamServer.Close() 48 | defer proxyServer.Close() 49 | 50 | assert(t, proxyServer, code, body) 51 | } 52 | 53 | func setupTest(t *testing.T, fn http.HandlerFunc) (upstreamSrv, proxySrv *httptest.Server) { 54 | t.Helper() 55 | 56 | upstreamSrv = httptest.NewServer(http.HandlerFunc(fn)) 57 | 58 | // we create a proxy server for this upstream 59 | p := &proxy{ 60 | upstream: upstreamSrv.Listener.Addr().String(), 61 | } 62 | 63 | proxySrv = httptest.NewServer(p) 64 | 65 | return 66 | } 67 | 68 | func assert(t *testing.T, proxy *httptest.Server, code int, body string) { 69 | t.Helper() 70 | 71 | // we make a request against the proxy 72 | req, err := http.NewRequest(http.MethodGet, proxy.URL, nil) 73 | if err != nil { 74 | t.Fatalf("failed creating request: %v", err) 75 | } 76 | 77 | res, err := http.DefaultClient.Do(req) 78 | if err != nil { 79 | t.Fatalf("failed making request: %v", err) 80 | } 81 | defer res.Body.Close() 82 | 83 | got, err := io.ReadAll(res.Body) 84 | if err != nil { 85 | t.Fatalf("failed reading response: %v", err) 86 | } 87 | 88 | if res.StatusCode != code { 89 | t.Errorf("expected status code to be %d, got %d", code, res.StatusCode) 90 | } 91 | 92 | if s := string(got); body != s { 93 | t.Errorf(`expected body to be %q, got %q`, body, s) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/PuerkitoBio/rehttp v1.1.0 h1:JFZ7OeK+hbJpTxhNB0NDZT47AuXqCU0Smxfjtph7/Rs= 2 | github.com/PuerkitoBio/rehttp v1.1.0/go.mod h1:LUwKPoDbDIA2RL5wYZCNsQ90cx4OJ4AWBmq6KzWZL1s= 3 | github.com/aybabtme/iocontrol v0.0.0-20150809002002-ad15bcfc95a0 h1:0NmehRCgyk5rljDQLKUO+cRJCnduDyn11+zGZIc9Z48= 4 | github.com/aybabtme/iocontrol v0.0.0-20150809002002-ad15bcfc95a0/go.mod h1:6L7zgvqo0idzI7IO8de6ZC051AfXb5ipkIJ7bIA2tGA= 5 | github.com/azazeal/pause v1.0.6 h1:azBiCE50Gt6TQy9hfw1ey93NB83MNjbOiIa4sdGHx6Y= 6 | github.com/azazeal/pause v1.0.6/go.mod h1:kLXh4F/4iaRI75opg9+3/00P3xFInZXc7TDed9cEDZE= 7 | github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= 8 | github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 9 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 10 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= 12 | github.com/machinebox/graphql v0.2.2 h1:dWKpJligYKhYKO5A2gvNhkJdQMNZeChZYyBbrZkBZfo= 13 | github.com/machinebox/graphql v0.2.2/go.mod h1:F+kbVMHuwrQ5tYgU9JXlnskM8nOaFxCAEolaQybkjWA= 14 | github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= 15 | github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= 16 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 17 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 18 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 19 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 20 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 21 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 22 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 23 | github.com/superfly/flyctl/api v0.0.0-20211101204329-d6c7938cb64b h1:IuUOolS4ODSKc2p4TT4u/X0Ax08gii7J+ENMFTqF1OY= 24 | github.com/superfly/flyctl/api v0.0.0-20211101204329-d6c7938cb64b/go.mod h1:IsHUPpZBKE7myHa6GJBjVso5XDCkOaYwfV+fpBBQ7Wc= 25 | golang.org/x/net v0.0.0-20210510120150-4163338589ed h1:p9UgmWI9wKpfYmgaV/IZKGdXc5qEK45tDwwwDyjS26I= 26 | golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 27 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 28 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 29 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 30 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 31 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 32 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 33 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 34 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 35 | --------------------------------------------------------------------------------