Merge pull request #87 from blocklayerhq/input-ux

Better ux for inputs
This commit is contained in:
Andrea Luzzardi 2021-02-02 10:45:48 -08:00 committed by GitHub
commit 6e31193d64
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 543 additions and 110 deletions

View File

@ -93,10 +93,7 @@ which hide the complexity of `dagger compute` (but it will always be available t
Here is an example command, using an example configuration:
```
$ dagger compute \
./examples/simple \
--input 'www: hostname: "www.mysuperapp.com"' \
--input 'www: source: #dagger: compute: [{do:"fetch-git", remote:"https://github.com/samalba/hello-go", ref:"master"}]'
$ dagger compute ./examples/simple --input-string www.host=mysuperapp.com --input-dir www.source=.
```

View File

@ -12,6 +12,12 @@ import (
"github.com/spf13/viper"
)
var (
env *dagger.Env
input *dagger.InputValue
updater *dagger.InputValue
)
var computeCmd = &cobra.Command{
Use: "compute CONFIG",
Short: "Compute a configuration",
@ -27,17 +33,24 @@ var computeCmd = &cobra.Command{
lg := logger.New()
ctx := lg.WithContext(appcontext.Context())
c, err := dagger.NewClient(ctx, dagger.ClientConfig{
Input: viper.GetString("input"),
Updater: localUpdater(args[0]),
})
if err := updater.SourceFlag().Set(args[0]); err != nil {
lg.Fatal().Err(err).Msg("invalid local source")
}
if err := env.SetUpdater(updater.Value()); err != nil {
lg.Fatal().Err(err).Msg("invalid updater script")
}
lg.Debug().Str("input", input.Value().SourceUnsafe()).Msg("Setting input")
if err := env.SetInput(input.Value()); err != nil {
lg.Fatal().Err(err).Msg("invalid input")
}
lg.Debug().Str("env state", env.State().SourceUnsafe()).Msg("creating client")
c, err := dagger.NewClient(ctx, "")
if err != nil {
lg.Fatal().Err(err).Msg("unable to create client")
}
// FIXME: configure which config to compute (duh)
// FIXME: configure inputs
lg.Info().Msg("running")
output, err := c.Compute(ctx)
output, err := c.Compute(ctx, env)
if err != nil {
lg.Fatal().Err(err).Msg("failed to compute")
}
@ -46,18 +59,35 @@ var computeCmd = &cobra.Command{
},
}
func localUpdater(dir string) string {
return fmt.Sprintf(`[
{
do: "local"
dir: "%s"
include: ["*.cue", "cue.mod"]
}
]`, dir)
func init() {
// Why is this stuff here?
// 1. input must be global for flag parsing
// 2. updater must be global for flag parsing
// 3. env must have same compiler as input & updater,
// therefore it must be global too.
//
// FIXME: roll up InputValue into Env?
var err error
env, err = dagger.NewEnv()
if err != nil {
panic(err)
}
func init() {
computeCmd.Flags().String("input", "", "Input overlay")
// Setup --input-* flags
input, err = dagger.NewInputValue(env.Compiler(), "{}")
if err != nil {
panic(err)
}
computeCmd.Flags().Var(input.StringFlag(), "input-string", "TARGET=STRING")
computeCmd.Flags().Var(input.DirFlag(), "input-dir", "TARGET=PATH")
computeCmd.Flags().Var(input.GitFlag(), "input-git", "TARGET=REMOTE#REF")
computeCmd.Flags().Var(input.CueFlag(), "input-cue", "CUE")
// Setup (future) --from-* flags
updater, err = dagger.NewInputValue(env.Compiler(), "[...{do:string, ...}]")
if err != nil {
panic(err)
}
if err := viper.BindPFlags(computeCmd.Flags()); err != nil {
panic(err)

View File

@ -37,19 +37,26 @@ type Client struct {
c *bk.Client
localdirs map[string]string
cfg ClientConfig
}
type ClientConfig struct {
// Buildkit host address, eg. `docker://buildkitd`
Host string
// Script to update the env config, eg . `[{do:"local",dir:"."}]`
Updater string
// Input values to merge on the base config, eg. `www: source: #dagger: compute: [{do:"local",dir:"./src"}]`
Input string
func NewClient(ctx context.Context, host string) (*Client, error) {
if host == "" {
host = os.Getenv("BUILDKIT_HOST")
}
if host == "" {
host = defaultBuildkitHost
}
c, err := bk.New(ctx, host)
if err != nil {
return nil, errors.Wrap(err, "buildkit client")
}
return &Client{
c: c,
}, nil
}
func NewClient(ctx context.Context, cfg ClientConfig) (result *Client, err error) {
// FIXME: return completed *Env, instead of *Value
func (c *Client) Compute(ctx context.Context, env *Env) (o *Value, err error) {
lg := log.Ctx(ctx)
defer func() {
if err != nil {
@ -57,26 +64,11 @@ func NewClient(ctx context.Context, cfg ClientConfig) (result *Client, err error
err = cueErr(err)
}
}()
// Load partial env client-side, to validate & scan local dirs
env, err := NewEnv(cfg.Updater)
if err != nil {
return nil, errors.Wrap(err, "updater")
}
if err := env.SetInput(cfg.Input); err != nil {
return nil, errors.Wrap(err, "input")
}
// Scan local dirs to grant access
localdirs, err := env.LocalDirs(ctx)
if err != nil {
return nil, errors.Wrap(err, "scan local dirs")
}
envsrc, err := env.state.SourceString()
if err != nil {
return nil, err
}
lg.Debug().
Str("func", "NewClient").
Str("env", envsrc).
Msg("loaded partial env client-side")
for label, dir := range localdirs {
abs, err := filepath.Abs(dir)
if err != nil {
@ -84,32 +76,14 @@ func NewClient(ctx context.Context, cfg ClientConfig) (result *Client, err error
}
localdirs[label] = abs
}
// Configure buildkit client
if cfg.Host == "" {
cfg.Host = os.Getenv("BUILDKIT_HOST")
}
if cfg.Host == "" {
cfg.Host = defaultBuildkitHost
}
c, err := bk.New(ctx, cfg.Host)
if err != nil {
return nil, errors.Wrap(err, "buildkit client")
}
return &Client{
c: c,
cfg: cfg,
localdirs: localdirs,
}, nil
}
c.localdirs = localdirs
func (c *Client) Compute(ctx context.Context) (*Value, error) {
lg := log.Ctx(ctx)
cc := &Compiler{}
out, err := cc.EmptyStruct()
// FIXME: merge this into env output.
out, err := env.Compiler().EmptyStruct()
if err != nil {
return nil, err
}
// Spawn Build() goroutine
eg, ctx := errgroup.WithContext(ctx)
events := make(chan *bk.SolveStatus)
@ -118,7 +92,7 @@ func (c *Client) Compute(ctx context.Context) (*Value, error) {
// Spawn build function
eg.Go(func() error {
defer outw.Close()
return c.buildfn(ctx, events, outw)
return c.buildfn(ctx, env, events, outw)
})
// Spawn print function(s)
@ -154,19 +128,28 @@ func (c *Client) Compute(ctx context.Context) (*Value, error) {
// Retrieve output
eg.Go(func() error {
defer outr.Close()
return c.outputfn(ctx, outr, out, cc)
return c.outputfn(ctx, outr, out, env.cc)
})
return out, eg.Wait()
}
func (c *Client) buildfn(ctx context.Context, ch chan *bk.SolveStatus, w io.WriteCloser) error {
func (c *Client) buildfn(ctx context.Context, env *Env, ch chan *bk.SolveStatus, w io.WriteCloser) error {
lg := log.Ctx(ctx)
// Serialize input and updater
input, err := env.Input().SourceString()
if err != nil {
return errors.Wrap(err, "serialize env input")
}
updater, err := env.Updater().Value().SourceString()
if err != nil {
return errors.Wrap(err, "serialize updater script")
}
// Setup solve options
opts := bk.SolveOpt{
FrontendAttrs: map[string]string{
bkInputKey: c.cfg.Input,
bkUpdaterKey: c.cfg.Updater,
bkInputKey: input,
bkUpdaterKey: updater,
},
LocalDirs: c.localdirs,
// FIXME: catch output & return as cue value
@ -183,7 +166,6 @@ func (c *Client) buildfn(ctx context.Context, ch chan *bk.SolveStatus, w io.Writ
lg.Debug().
Interface("localdirs", opts.LocalDirs).
Interface("attrs", opts.FrontendAttrs).
Interface("host", c.cfg.Host).
Msg("spawning buildkit job")
resp, err := c.c.Build(ctx, opts, "", Compute, ch)
if err != nil {

View File

@ -27,10 +27,13 @@ func Compute(ctx context.Context, c bkgw.Client) (r *bkgw.Result, err error) {
if o, exists := c.BuildOpts().Opts[bkUpdaterKey]; exists {
updater = o
}
env, err := NewEnv(updater)
env, err := NewEnv()
if err != nil {
return nil, err
}
if err := env.SetUpdater(updater); err != nil {
return nil, err
}
if err := env.Update(ctx, s); err != nil {
return nil, err
}

View File

@ -35,39 +35,82 @@ type Env struct {
cc *Compiler
}
func NewEnv(updater interface{}) (*Env, error) {
var (
env = &Env{}
cc = &Compiler{}
err error
)
// 1. Updater
if updater == nil {
updater = "[]"
func (env *Env) Updater() *Script {
return env.updater
}
env.updater, err = cc.CompileScript("updater", updater)
// Set the updater script for this environment.
// u may be:
// - A compiled script: *Script
// - A compiled value: *Value
// - A cue source: string, []byte, io.Reader
func (env *Env) SetUpdater(u interface{}) error {
if v, ok := u.(*Value); ok {
updater, err := NewScript(v)
if err != nil {
return nil, err
return errors.Wrap(err, "invalid updater script")
}
// 2. initialize empty values
env.updater = updater
return nil
}
if updater, ok := u.(*Script); ok {
env.updater = updater
return nil
}
if u == nil {
u = "[]"
}
updater, err := env.cc.CompileScript("updater", u)
if err != nil {
return err
}
env.updater = updater
return nil
}
func NewEnv() (*Env, error) {
cc := &Compiler{}
empty, err := cc.EmptyStruct()
if err != nil {
return nil, err
}
env.input = empty
env.base = empty
env.state = empty
env.output = empty
// 3. compiler
env.cc = cc
env := &Env{
cc: cc,
base: empty,
input: empty,
output: empty,
state: empty,
}
if err := env.SetUpdater(nil); err != nil {
return nil, err
}
return env, nil
}
func (env *Env) SetInput(src interface{}) error {
if src == nil {
src = "{}"
func (env *Env) Compiler() *Compiler {
return env.cc
}
input, err := env.cc.Compile("input", src)
func (env *Env) State() *Value {
return env.state
}
func (env *Env) Input() *Value {
return env.input
}
func (env *Env) SetInput(i interface{}) error {
if input, ok := i.(*Value); ok {
return env.set(
env.base,
input,
env.output,
)
}
if i == nil {
i = "{}"
}
input, err := env.cc.Compile("input", i)
if err != nil {
return err
}
@ -98,6 +141,14 @@ func (env *Env) Update(ctx context.Context, s Solver) error {
)
}
func (env *Env) Base() *Value {
return env.base
}
func (env *Env) Output() *Value {
return env.output
}
// Scan all scripts in the environment for references to local directories (do:"local"),
// and return all referenced directory names.
// This is used by clients to grant access to local directories when they are referenced
@ -105,8 +156,15 @@ func (env *Env) Update(ctx context.Context, s Solver) error {
func (env *Env) LocalDirs(ctx context.Context) (map[string]string, error) {
lg := log.Ctx(ctx)
dirs := map[string]string{}
lg.Debug().
Str("func", "Env.LocalDirs").
Str("state", env.state.SourceUnsafe()).
Str("updater", env.updater.Value().SourceUnsafe()).
Msg("starting")
defer func() {
lg.Debug().Str("func", "Env.LocalDirs").Interface("result", dirs).Msg("done")
}()
// 1. Walk env state, scan compute script for each component.
lg.Debug().Msg("walking env client-side for local dirs")
_, err := env.Walk(ctx, func(ctx context.Context, c *Component, out *Fillable) error {
lg.Debug().
Str("func", "Env.LocalDirs").
@ -163,22 +221,24 @@ func (env *Env) Compute(ctx context.Context, s Solver) error {
}
// FIXME: this is just a 3-way merge. Add var args to Value.Merge.
func (env *Env) set(base, input, output *Value) error {
func (env *Env) set(base, input, output *Value) (err error) {
// FIXME: make this cleaner in *Value by keeping intermediary instances
// FIXME: state.CueInst() must return an instance with the same
// contents as state.v, for the purposes of cueflow.
// That is not currently how *Value works, so we prepare the cue
// instance manually.
// --> refactor the Value API to do this for us.
baseInst := base.CueInst()
inputInst := input.CueInst()
outputInst := output.CueInst()
stateInst := env.state.CueInst()
stateInst, err := baseInst.Fill(inputInst.Value())
stateInst, err = stateInst.Fill(base.val)
if err != nil {
return errors.Wrap(err, "merge base & input")
}
stateInst, err = stateInst.Fill(outputInst.Value())
stateInst, err = stateInst.Fill(input.val)
if err != nil {
return errors.Wrap(err, "merge base & input")
}
stateInst, err = stateInst.Fill(output.val)
if err != nil {
return errors.Wrap(err, "merge output with base & input")
}

71
dagger/env_test.go Normal file
View File

@ -0,0 +1,71 @@
package dagger
import (
"context"
"testing"
)
func TestSimpleEnvSet(t *testing.T) {
env, err := NewEnv()
if err != nil {
t.Fatal(err)
}
if err := env.SetInput(`hello: "world"`); err != nil {
t.Fatal(err)
}
hello, err := env.State().Get("hello").String()
if err != nil {
t.Fatal(err)
}
if hello != "world" {
t.Fatal(hello)
}
}
func TestSimpleEnvSetFromInputValue(t *testing.T) {
env, err := NewEnv()
if err != nil {
t.Fatal(err)
}
v, err := env.Compiler().Compile("", `hello: "world"`)
if err != nil {
t.Fatal(err)
}
if err := env.SetInput(v); err != nil {
t.Fatal(err)
}
hello, err := env.State().Get("hello").String()
if err != nil {
t.Fatal(err)
}
if hello != "world" {
t.Fatal(hello)
}
}
func TestEnvInputComponent(t *testing.T) {
env, err := NewEnv()
if err != nil {
t.Fatal(err)
}
v, err := env.Compiler().Compile("", `foo: #dagger: compute: [{do:"local",dir:"."}]`)
if err != nil {
t.Fatal(err)
}
if err := env.SetInput(v); err != nil {
t.Fatal(err)
}
localdirs, err := env.LocalDirs(context.TODO())
if err != nil {
t.Fatal(err)
}
if len(localdirs) != 1 {
t.Fatal(localdirs)
}
if dir, ok := localdirs["."]; !ok || dir != "." {
t.Fatal(localdirs)
}
}

239
dagger/input.go Normal file
View File

@ -0,0 +1,239 @@
package dagger
import (
"encoding/json"
"fmt"
"net/url"
"strings"
"cuelang.org/go/cue"
"github.com/spf13/pflag"
)
// A mutable cue value with an API suitable for user inputs,
// such as command-line flag parsing.
type InputValue struct {
root *Value
cc *Compiler
}
func (iv *InputValue) Value() *Value {
return iv.root
}
func (iv *InputValue) String() string {
s, _ := iv.root.SourceString()
return s
}
func NewInputValue(cc *Compiler, base interface{}) (*InputValue, error) {
root, err := cc.Compile("base", base)
if err != nil {
return nil, err
}
return &InputValue{
cc: cc,
root: root,
}, nil
}
func (iv *InputValue) Set(s string, enc func(string, *Compiler) (interface{}, error)) error {
// Split from eg. 'foo.bar={bla:"bla"}`
k, vRaw := splitkv(s)
v, err := enc(vRaw, iv.cc)
if err != nil {
return err
}
root, err := iv.root.MergePath(v, k)
if err != nil {
return err
}
iv.root = root
return nil
}
// Adapter to receive string values from pflag
func (iv *InputValue) StringFlag() pflag.Value {
return stringFlag{
iv: iv,
}
}
type stringFlag struct {
iv *InputValue
}
func (sf stringFlag) Set(s string) error {
return sf.iv.Set(s, func(s string, _ *Compiler) (interface{}, error) {
return s, nil
})
}
func (sf stringFlag) String() string {
return sf.iv.String()
}
func (sf stringFlag) Type() string {
return "STRING"
}
// DIR FLAG
// Receive a local directory path and translate it into a component
func (iv *InputValue) DirFlag(include ...string) pflag.Value {
if include == nil {
include = []string{}
}
return dirFlag{
iv: iv,
include: include,
}
}
type dirFlag struct {
iv *InputValue
include []string
}
func (f dirFlag) Set(s string) error {
return f.iv.Set(s, func(s string, cc *Compiler) (interface{}, error) {
// FIXME: this is a hack because cue API can't merge into a list
include, err := json.Marshal(f.include)
if err != nil {
return nil, err
}
return cc.Compile("", fmt.Sprintf(
`#dagger: compute: [{do:"local",dir:"%s", include:%s}]`,
s,
include,
))
})
}
func (f dirFlag) String() string {
return f.iv.String()
}
func (f dirFlag) Type() string {
return "PATH"
}
// GIT FLAG
// Receive a git repository reference and translate it into a component
func (iv *InputValue) GitFlag() pflag.Value {
return gitFlag{
iv: iv,
}
}
type gitFlag struct {
iv *InputValue
}
func (f gitFlag) Set(s string) error {
return f.iv.Set(s, func(s string, cc *Compiler) (interface{}, error) {
u, err := url.Parse(s)
if err != nil {
return nil, fmt.Errorf("invalid git url")
}
ref := u.Fragment // eg. #main
u.Fragment = ""
remote := u.String()
return cc.Compile("", fmt.Sprintf(
`#dagger: compute: [{do:"fetch-git", remote:"%s", ref:"%s"}]`,
remote,
ref,
))
})
}
func (f gitFlag) String() string {
return f.iv.String()
}
func (f gitFlag) Type() string {
return "REMOTE,REF"
}
// SOURCE FLAG
// Adapter to receive a simple source description and translate it to a loader script.
// For example 'git+https://github.com/cuelang/cue#master` -> [{do:"git",remote:"https://github.com/cuelang/cue",ref:"master"}]
func (iv *InputValue) SourceFlag() pflag.Value {
return sourceFlag{
iv: iv,
}
}
type sourceFlag struct {
iv *InputValue
}
func (f sourceFlag) Set(s string) error {
return f.iv.Set(s, func(s string, cc *Compiler) (interface{}, error) {
u, err := url.Parse(s)
if err != nil {
return nil, err
}
switch u.Scheme {
case "", "file":
return cc.Compile(
"source",
// FIXME: include only cue files as a shortcut. Make this configurable somehow.
fmt.Sprintf(`[{do:"local",dir:"%s",include:["*.cue","cue.mod"]}]`, u.Host+u.Path),
)
default:
return nil, fmt.Errorf("unsupported source scheme: %q", u.Scheme)
}
})
}
func (f sourceFlag) String() string {
return f.iv.String()
}
func (f sourceFlag) Type() string {
return "PATH | file://PATH | git+ssh://HOST/PATH | git+https://HOST/PATH"
}
// RAW CUE FLAG
// Adapter to receive raw cue values from pflag
func (iv *InputValue) CueFlag() pflag.Value {
return cueFlag{
iv: iv,
}
}
type cueFlag struct {
iv *InputValue
}
func (f cueFlag) Set(s string) error {
return f.iv.Set(s, func(s string, cc *Compiler) (interface{}, error) {
return cc.Compile("cue input", s)
})
}
func (f cueFlag) String() string {
return f.iv.String()
}
func (f cueFlag) Type() string {
return "CUE"
}
// UTILITIES
func splitkv(kv string) (cue.Path, string) {
parts := strings.SplitN(kv, "=", 2)
if len(parts) == 2 {
if parts[0] == "." || parts[0] == "" {
return cue.MakePath(), parts[1]
}
return cue.ParsePath(parts[0]), parts[1]
}
if len(parts) == 1 {
return cue.MakePath(), parts[0]
}
return cue.MakePath(), ""
}

35
dagger/input_test.go Normal file
View File

@ -0,0 +1,35 @@
package dagger
import (
"context"
"testing"
)
func TestEnvInputFlag(t *testing.T) {
env, err := NewEnv()
if err != nil {
t.Fatal(err)
}
input, err := NewInputValue(env.Compiler(), `{}`)
if err != nil {
t.Fatal(err)
}
if err := input.DirFlag().Set("www.source=."); err != nil {
t.Fatal(err)
}
if err := env.SetInput(input.Value()); err != nil {
t.Fatal(err)
}
localdirs, err := env.LocalDirs(context.TODO())
if err != nil {
t.Fatal(err)
}
if len(localdirs) != 1 {
t.Fatal(localdirs)
}
if dir, ok := localdirs["."]; !ok || dir != "." {
t.Fatal(localdirs)
}
}

View File

@ -102,6 +102,11 @@ func (s *Script) Walk(ctx context.Context, fn func(op *Op) error) error {
}
func (s *Script) LocalDirs(ctx context.Context) (map[string]string, error) {
lg := log.Ctx(ctx)
lg.Debug().
Str("func", "Script.LocalDirs").
Str("location", s.Value().Path().String()).
Msg("starting")
dirs := map[string]string{}
err := s.Walk(ctx, func(op *Op) error {
if err := op.Validate("#Local"); err != nil {
@ -115,5 +120,11 @@ func (s *Script) LocalDirs(ctx context.Context) (map[string]string, error) {
dirs[dir] = dir
return nil
})
lg.Debug().
Str("func", "Script.LocalDirs").
Str("location", s.Value().Path().String()).
Interface("err", err).
Interface("result", dirs).
Msg("done")
return dirs, err
}

View File

@ -32,8 +32,7 @@ func wrapValue(v cue.Value, inst *cue.Instance, cc *Compiler) *Value {
}
}
// Fill is a concurrency safe wrapper around cue.Value.Fill()
// This is the only method which changes the value in-place.
// Fill the value in-place, unlike Merge which returns a copy.
func (v *Value) Fill(x interface{}) error {
v.cc.Lock()
defer v.cc.Unlock()
@ -96,6 +95,11 @@ func (v *Value) String() (string, error) {
return v.val.String()
}
func (v *Value) SourceUnsafe() string {
s, _ := v.SourceString()
return s
}
// Proxy function to the underlying cue.Value
func (v *Value) Path() cue.Path {
return v.val.Path()

View File

@ -105,7 +105,7 @@ test::exec(){
test::one "Exec: env valid" --exit=0 --stdout={} \
"$dagger" "${DAGGER_BINARY_ARGS[@]}" compute "$d"/exec/env/valid
test::one "Exec: env with overlay" --exit=0 \
"$dagger" "${DAGGER_BINARY_ARGS[@]}" compute --input 'bar: "overlay environment"' "$d"/exec/env/overlay
"$dagger" "${DAGGER_BINARY_ARGS[@]}" compute --input-cue 'bar: "overlay environment"' "$d"/exec/env/overlay
test::one "Exec: non existent dir" --exit=0 --stdout={} \
"$dagger" "${DAGGER_BINARY_ARGS[@]}" compute "$d"/exec/dir/doesnotexist
@ -202,13 +202,13 @@ test::input() {
"$dagger" "${DAGGER_BINARY_ARGS[@]}" compute "$d"/input/simple
test::one "Input: simple input" --exit=0 --stdout='{"in":"foobar","test":"received: foobar"}' \
"$dagger" "${DAGGER_BINARY_ARGS[@]}" compute --input 'in: "foobar"' "$d"/input/simple
"$dagger" "${DAGGER_BINARY_ARGS[@]}" compute --input-cue 'in: "foobar"' "$d"/input/simple
test::one "Input: default values" --exit=0 --stdout='{"in":"default input","test":"received: default input"}' \
"$dagger" "${DAGGER_BINARY_ARGS[@]}" compute "$d"/input/default
test::one "Input: override default value" --exit=0 --stdout='{"in":"foobar","test":"received: foobar"}' \
"$dagger" "${DAGGER_BINARY_ARGS[@]}" compute --input 'in: "foobar"' "$d"/input/default
"$dagger" "${DAGGER_BINARY_ARGS[@]}" compute --input-cue 'in: "foobar"' "$d"/input/default
}

1
go.mod
View File

@ -11,6 +11,7 @@ require (
github.com/pkg/errors v0.9.1
github.com/rs/zerolog v1.20.0
github.com/spf13/cobra v1.0.0
github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.7.0
github.com/tonistiigi/fsutil v0.0.0-20201103201449-0834f99b7b85
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9