From 7706d8ad60bb34e35067414a46acf51c52b1925e Mon Sep 17 00:00:00 2001 From: Nate Maninger Date: Wed, 12 Jul 2023 12:51:59 -0600 Subject: [PATCH] cmd: add support for yaml configuration file --- README.md | 62 +++++++++- cmd/hostd/consts_default.go | 1 + cmd/hostd/consts_testnet.go | 1 + cmd/hostd/main.go | 227 +++++++++++++++++++++++++----------- cmd/hostd/node.go | 32 +++-- go.mod | 1 + go.sum | 5 + 7 files changed, 250 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index f291d573..6c96159d 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,14 @@ ensuring a smooth user experience across a diverse range of devices. - A project roadmap is available on [GitHub](https://github.com/orgs/SiaFoundation/projects/3) - Setup guides are available at [https://docs.sia.tech](https://docs.sia.tech/hosting/hostd/about-hosting-on-sia) -### Ports +## Configuration + +`hostd` can be configured in multiple ways. Some settings, like the wallet key, +can be configured via environment variables or stdin. Others, like the RHP +ports, can be configured via CLI flags. To simplify more complex configurations, +`hostd` also supports the use of a YAML configuration file for all settings. + +## Default Ports `hostd` uses the following ports: + `9980` - UI and API + `9981` - Sia consensus @@ -30,6 +37,59 @@ ensuring a smooth user experience across a diverse range of devices. + `HOSTD_SEED` - The recovery phrase for the wallet + `HOSTD_LOG_PATH` - changes the path of the log file `hostd.log`. If unset, the log file will be created in the data directory ++ `HOSTD_CONFIG_FILE` - changes the path of the optional config file. If unset, + `hostd` will check for a config file in the current directory + +### CLI Flags +```sh +-bootstrap + bootstrap the gateway and consensus modules +-dir string + directory to store hostd metadata (default ".") +-env + disable stdin prompts for environment variables (default false) +-http string + address to serve API on (default ":9980") +-log.level string + log level (debug, info, warn, error) (default "info") +-log.stdout + log to stdout (default false) +-name string + a friendly name for the host, only used for display +-rpc string + address to listen on for peer connections (default ":9981") +-rhp2 string + address to listen on for RHP2 connections (default ":9982") +-rhp3.tcp string + address to listen on for TCP RHP3 connections (default ":9983") +-rhp3.ws string + address to listen on for WebSocket RHP3 connections (default ":9984") +``` + +### YAML +All environment variables and CLI flags can be set via a YAML config file. The +config file defaults to `hostd.yml` in the current directory, but can be changed +with the `HOSTD_CONFIG_FILE` environment variable. All fields are optional and +default to the same values as the CLI flags. + +```yaml +dataDir: /etc/hostd +recoveryPhrase: indicate nature buzz route rude embody engage confirm aspect potato weapon bid +http: + address: :9980 + password: sia is cool +consensus: + gatewayAddress: :9981 + bootstrap: true +rhp2: + address: :9982 +rhp3: + tcp: :9983 + websocket: :9984 +log: + path: /var/log/hostd + level: info +``` # Building diff --git a/cmd/hostd/consts_default.go b/cmd/hostd/consts_default.go index d3cfda25..6a814b44 100644 --- a/cmd/hostd/consts_default.go +++ b/cmd/hostd/consts_default.go @@ -6,6 +6,7 @@ const ( apiPasswordEnvVariable = "HOSTD_API_PASSWORD" walletSeedEnvVariable = "HOSTD_SEED" logPathEnvVariable = "HOSTD_LOG_PATH" + configPathEnvVariable = "HOSTD_CONFIG_FILE" defaultAPIAddr = "localhost:9980" defaultGatewayAddr = ":9981" diff --git a/cmd/hostd/consts_testnet.go b/cmd/hostd/consts_testnet.go index 20d26c40..73caba38 100644 --- a/cmd/hostd/consts_testnet.go +++ b/cmd/hostd/consts_testnet.go @@ -6,6 +6,7 @@ const ( apiPasswordEnvVariable = "HOSTD_ZEN_API_PASSWORD" walletSeedEnvVariable = "HOSTD_ZEN_SEED" logPathEnvVariable = "HOSTD_ZEN_LOG_PATH" + configPathEnvVariable = "HOSTD_ZEN_CONFIG_FILE" defaultAPIAddr = "localhost:9880" defaultGatewayAddr = ":9881" diff --git a/cmd/hostd/main.go b/cmd/hostd/main.go index 2a026e09..f5ef2bb3 100644 --- a/cmd/hostd/main.go +++ b/cmd/hostd/main.go @@ -14,7 +14,6 @@ import ( "syscall" "time" - "go.sia.tech/core/types" "go.sia.tech/core/wallet" "go.sia.tech/hostd/api" "go.sia.tech/hostd/build" @@ -22,20 +21,77 @@ import ( "go.sia.tech/web/hostd" "go.uber.org/zap" "golang.org/x/term" + "gopkg.in/yaml.v3" +) + +type ( + httpCfg struct { + Address string `yaml:"address"` + Password string `yaml:"password"` + } + + consensusCfg struct { + GatewayAddress string `yaml:"gatewayAddress"` + Bootstrap bool `yaml:"bootstrap"` + Peers []string `toml:"peers,omitempty"` + } + + rhp2Cfg struct { + Address string `yaml:"address"` + } + + rhp3Cfg struct { + TCPAddress string `yaml:"tcp"` + WebSocketAddress string `yaml:"websocket"` + CertPath string `yaml:"certPath"` + KeyPath string `yaml:"keyPath"` + } + + logCfg struct { + Path string `yaml:"path"` + Level string `yaml:"level"` + Stdout bool `yaml:"stdout"` + } + + cfg struct { + Name string `yaml:"name"` + DataDir string `yaml:"dataDir"` + RecoveryPhrase string `yaml:"recoveryPhrase"` + + HTTP httpCfg `yaml:"http"` + Consensus consensusCfg `yaml:"consensus"` + RHP2 rhp2Cfg `yaml:"rhp2"` + RHP3 rhp3Cfg `yaml:"rhp3"` + Log logCfg `yaml:"log"` + } ) var ( - name string - gatewayAddr string - rhp2Addr string - rhp3TCPAddr string - rhp3WSAddr string - apiAddr string - dir string - bootstrap bool - - logLevel string - logStdout bool + config = cfg{ + DataDir: ".", // default to current directory + RecoveryPhrase: os.Getenv(walletSeedEnvVariable), // default to env variable + + HTTP: httpCfg{ + Address: defaultAPIAddr, + Password: os.Getenv(apiPasswordEnvVariable), + }, + Consensus: consensusCfg{ + GatewayAddress: defaultGatewayAddr, + Bootstrap: true, + }, + RHP2: rhp2Cfg{ + Address: defaultRHPv2Addr, + }, + RHP3: rhp3Cfg{ + TCPAddress: defaultRHPv3TCPAddr, + WebSocketAddress: defaultRHPv3WSAddr, + }, + + Log: logCfg{ + Level: "info", + Path: os.Getenv(logPathEnvVariable), + }, + } disableStdin bool ) @@ -46,13 +102,13 @@ func check(context string, err error) { } } -func getAPIPassword() string { - apiPassword := os.Getenv(apiPasswordEnvVariable) - if len(apiPassword) != 0 { - log.Printf("Using %s environment variable.", apiPasswordEnvVariable) - return apiPassword +// mustSetAPIPassword prompts the user to enter an API password if one is not +// already set via environment variable or config file. +func mustSetAPIPassword() { + if len(config.HTTP.Password) != 0 { + return } else if disableStdin { - log.Fatalf("%s must be set via environment variable when running in docker.", apiPasswordEnvVariable) + log.Fatalln("API password must be set via environment variable or config file when --env flag is set") } fmt.Print("Enter API password: ") @@ -60,43 +116,80 @@ func getAPIPassword() string { fmt.Println() if err != nil { log.Fatal(err) + } else if len(pw) == 0 { + log.Fatalln("API password cannot be empty") } - apiPassword = string(pw) - return apiPassword + config.HTTP.Password = string(pw) } -func getWalletKey() types.PrivateKey { - phrase := os.Getenv(walletSeedEnvVariable) - if len(phrase) != 0 { - log.Printf("Using %s environment variable.", walletSeedEnvVariable) +// mustSetWalletkey prompts the user to enter a wallet seed phrase if one is not +// already set via environment variable or config file. +func mustSetWalletkey() { + if len(config.RecoveryPhrase) != 0 { + return } else if disableStdin { - log.Fatalf("%s must be set via environment variable when running in docker.", walletSeedEnvVariable) - } else { - fmt.Print("Enter wallet seed: ") - pw, err := term.ReadPassword(int(os.Stdin.Fd())) - check("Could not read seed phrase:", err) - fmt.Println() - phrase = string(pw) + log.Fatalln("Wallet seed must be set via environment variable or config file when --env flag is set") } - var seed [32]byte - if err := wallet.SeedFromPhrase(&seed, phrase); err != nil { - log.Fatal(err) + + fmt.Print("Enter wallet seed: ") + pw, err := term.ReadPassword(int(os.Stdin.Fd())) + check("Could not read seed phrase:", err) + fmt.Println() + config.RecoveryPhrase = string(pw) +} + +// mustLoadConfig loads the config file specified by the HOSTD_CONFIG_PATH. If +// the config file does not exist, it will not be loaded. +func mustLoadConfig() { + configPath := "hostd.yml" + if str := os.Getenv(configPathEnvVariable); len(str) != 0 { + configPath = str + } + + // If the config file doesn't exist, don't try to load it. + if _, err := os.Stat(configPath); os.IsNotExist(err) { + return + } + + f, err := os.Open(configPath) + if err != nil { + log.Fatal("failed to open config file:", err) + } + defer f.Close() + + dec := yaml.NewDecoder(f) + dec.KnownFields(true) + + if err := dec.Decode(&config); err != nil { + log.Fatal("failed to decode config file:", err) } - return wallet.KeyFromSeed(&seed, 0) } func main() { - flag.StringVar(&name, "name", "", "a friendly name for the host, only used for display") - flag.StringVar(&gatewayAddr, "rpc", defaultGatewayAddr, "address to listen on for peer connections") - flag.StringVar(&rhp2Addr, "rhp2", defaultRHPv2Addr, "address to listen on for RHP2 connections") - flag.StringVar(&rhp3TCPAddr, "rhp3.tcp", defaultRHPv3TCPAddr, "address to listen on for TCP RHP3 connections") - flag.StringVar(&rhp3WSAddr, "rhp3.ws", defaultRHPv3WSAddr, "address to listen on for WebSocket RHP3 connections") - flag.StringVar(&apiAddr, "http", defaultAPIAddr, "address to serve API on") - flag.StringVar(&dir, "dir", ".", "directory to store hostd metadata") - flag.BoolVar(&bootstrap, "bootstrap", true, "bootstrap the gateway and consensus modules") - flag.StringVar(&logLevel, "log.level", "info", "log level (debug, info, warn, error)") - flag.BoolVar(&logStdout, "log.stdout", false, "log to stdout (default false)") + // attempt to load the config file first, command line flags will override + // any values set in the config file + mustLoadConfig() + // set the log path to the data dir if it is not already set + if len(config.Log.Path) == 0 { + config.Log.Path = config.DataDir + } + + // global + flag.StringVar(&config.Name, "name", config.Name, "a friendly name for the host, only used for display") + flag.StringVar(&config.DataDir, "dir", config.DataDir, "directory to store hostd metadata") flag.BoolVar(&disableStdin, "env", false, "disable stdin prompts for environment variables (default false)") + // consensus + flag.StringVar(&config.Consensus.GatewayAddress, "rpc", config.Consensus.GatewayAddress, "address to listen on for peer connections") + flag.BoolVar(&config.Consensus.Bootstrap, "bootstrap", config.Consensus.Bootstrap, "bootstrap the gateway and consensus modules") + // rhp + flag.StringVar(&config.RHP2.Address, "rhp2", config.RHP2.Address, "address to listen on for RHP2 connections") + flag.StringVar(&config.RHP3.TCPAddress, "rhp3.tcp", config.RHP3.TCPAddress, "address to listen on for TCP RHP3 connections") + flag.StringVar(&config.RHP3.WebSocketAddress, "rhp3.ws", config.RHP3.WebSocketAddress, "address to listen on for WebSocket RHP3 connections") + // http + flag.StringVar(&config.HTTP.Address, "http", config.HTTP.Address, "address to serve API on") + // log + flag.StringVar(&config.Log.Level, "log.level", config.Log.Level, "log level (debug, info, warn, error)") + flag.BoolVar(&config.Log.Stdout, "log.stdout", config.Log.Stdout, "log to stdout (default false)") flag.Parse() log.Println("hostd", build.Version()) @@ -118,21 +211,26 @@ func main() { return } - if err := os.MkdirAll(dir, 0700); err != nil { - log.Fatal(err) + // check that the API password and wallet seed are set + mustSetAPIPassword() + mustSetWalletkey() + + var seed [32]byte + if err := wallet.SeedFromPhrase(&seed, config.RecoveryPhrase); err != nil { + log.Fatalln("failed to load wallet:", err) } + walletKey := wallet.KeyFromSeed(&seed, 0) - logPath := dir - if elp := os.Getenv(logPathEnvVariable); len(elp) != 0 { - logPath = elp + if err := os.MkdirAll(config.DataDir, 0700); err != nil { + log.Fatalln("unable to create config directory:", err) } cfg := zap.NewProductionConfig() - cfg.OutputPaths = []string{filepath.Join(logPath, "hostd.log")} - if logStdout { + cfg.OutputPaths = []string{filepath.Join(config.Log.Path, "hostd.log")} + if config.Log.Stdout { cfg.OutputPaths = append(cfg.OutputPaths, "stdout") } - switch logLevel { + switch config.Log.Level { case "debug": cfg.Level = zap.NewAtomicLevelAt(zap.DebugLevel) case "info": @@ -147,35 +245,32 @@ func main() { log.Fatalln("ERROR: failed to create logger:", err) } defer logger.Sync() - if logStdout { + if config.Log.Stdout { zap.RedirectStdLog(logger.Named("stdlog")) } - apiPassword := getAPIPassword() - walletKey := getWalletKey() - - apiListener, err := net.Listen("tcp", apiAddr) + apiListener, err := net.Listen("tcp", config.HTTP.Address) if err != nil { log.Fatal(err) } defer apiListener.Close() - rhpv3WSListener, err := net.Listen("tcp", rhp3WSAddr) + rhpv3WSListener, err := net.Listen("tcp", config.RHP3.WebSocketAddress) if err != nil { log.Fatal(err) } defer rhpv3WSListener.Close() - node, hostKey, err := newNode(gatewayAddr, rhp2Addr, rhp3TCPAddr, dir, bootstrap, walletKey, logger, cfg.Level.Level()) + node, hostKey, err := newNode(walletKey, logger) if err != nil { log.Fatal(err) } defer node.Close() - auth := jape.BasicAuth(apiPassword) + auth := jape.BasicAuth(config.HTTP.Password) web := http.Server{ Handler: webRouter{ - api: auth(api.NewServer(name, hostKey.PublicKey(), node.a, node.g, node.cm, node.tp, node.contracts, node.storage, node.metrics, node.store, node.settings, node.w, logger.Named("api"))), + api: auth(api.NewServer(config.Name, hostKey.PublicKey(), node.a, node.g, node.cm, node.tp, node.contracts, node.storage, node.metrics, node.store, node.settings, node.w, logger.Named("api"))), ui: hostd.Handler(), }, ReadTimeout: 30 * time.Second, @@ -193,7 +288,7 @@ func main() { go func() { err := rhpv3WS.ServeTLS(rhpv3WSListener, "", "") if err != nil && !errors.Is(err, http.ErrServerClosed) { - if logStdout { + if config.Log.Stdout { logger.Error("failed to serve rhpv3 websocket", zap.Error(err)) return } @@ -201,7 +296,7 @@ func main() { } }() - if logStdout { + if config.Log.Stdout { logger.Info("hostd started", zap.String("hostKey", hostKey.PublicKey().String()), zap.String("api", apiListener.Addr().String()), zap.String("p2p", string(node.g.Address())), zap.String("rhp2", node.rhp2.LocalAddr()), zap.String("rhp3", node.rhp3.LocalAddr())) } else { log.Println("api listening on:", apiListener.Addr().String()) @@ -215,7 +310,7 @@ func main() { go func() { err := web.Serve(apiListener) if err != nil && !errors.Is(err, http.ErrServerClosed) { - if logStdout { + if config.Log.Stdout { logger.Error("failed to serve web", zap.Error(err)) return } @@ -226,7 +321,7 @@ func main() { signalCh := make(chan os.Signal, 1) signal.Notify(signalCh, os.Interrupt, syscall.SIGTERM) <-signalCh - if logStdout { + if config.Log.Stdout { logger.Info("shutdown initiated") } else { log.Println("Shutting down...") diff --git a/cmd/hostd/node.go b/cmd/hostd/node.go index 03d756c6..9ee6fd2e 100644 --- a/cmd/hostd/node.go +++ b/cmd/hostd/node.go @@ -163,20 +163,28 @@ func startRHP3(l net.Listener, hostKey types.PrivateKey, cs rhpv3.ChainManager, return rhp3, nil } -func newNode(gatewayAddr, rhp2Addr, rhp3Addr, dir string, bootstrap bool, walletKey types.PrivateKey, logger *zap.Logger, logLevel zapcore.Level) (*node, types.PrivateKey, error) { - gatewayDir := filepath.Join(dir, "gateway") +func newNode(walletKey types.PrivateKey, logger *zap.Logger) (*node, types.PrivateKey, error) { + gatewayDir := filepath.Join(config.DataDir, "gateway") if err := os.MkdirAll(gatewayDir, 0700); err != nil { return nil, types.PrivateKey{}, fmt.Errorf("failed to create gateway dir: %w", err) } - g, err := gateway.NewCustomGateway(gatewayAddr, bootstrap, false, gatewayDir, modules.ProdDependencies) + g, err := gateway.NewCustomGateway(config.Consensus.GatewayAddress, config.Consensus.Bootstrap, false, gatewayDir, modules.ProdDependencies) if err != nil { return nil, types.PrivateKey{}, fmt.Errorf("failed to create gateway: %w", err) } - consensusDir := filepath.Join(dir, "consensus") + + // connect to additional peers from the config file + go func() { + for _, peer := range config.Consensus.Peers { + g.Connect(modules.NetAddress(peer)) + } + }() + + consensusDir := filepath.Join(config.DataDir, "consensus") if err := os.MkdirAll(consensusDir, 0700); err != nil { return nil, types.PrivateKey{}, err } - cs, errCh := consensus.New(g, bootstrap, consensusDir) + cs, errCh := consensus.New(g, config.Consensus.Bootstrap, consensusDir) select { case err := <-errCh: if err != nil { @@ -189,7 +197,7 @@ func newNode(gatewayAddr, rhp2Addr, rhp3Addr, dir string, bootstrap bool, wallet } }() } - tpoolDir := filepath.Join(dir, "tpool") + tpoolDir := filepath.Join(config.DataDir, "tpool") if err := os.MkdirAll(tpoolDir, 0700); err != nil { return nil, types.PrivateKey{}, fmt.Errorf("failed to create tpool dir: %w", err) } @@ -199,13 +207,13 @@ func newNode(gatewayAddr, rhp2Addr, rhp3Addr, dir string, bootstrap bool, wallet } tp := &txpool{stp} - db, err := sqlite.OpenDatabase(filepath.Join(dir, "hostd.db"), logger.Named("sqlite")) + db, err := sqlite.OpenDatabase(filepath.Join(config.DataDir, "hostd.db"), logger.Named("sqlite")) if err != nil { return nil, types.PrivateKey{}, fmt.Errorf("failed to create sqlite store: %w", err) } // create a new zap core by combining the current logger and a custom logging core - core := zapcore.NewTee(logger.Core(), logging.Core(db, logLevel)) + core := zapcore.NewTee(logger.Core(), logging.Core(db, logger.Level())) // reinstantiate the logger with the new core logger = zap.New(core) // reset the logger so queries are logged to the database @@ -224,24 +232,24 @@ func newNode(gatewayAddr, rhp2Addr, rhp3Addr, dir string, bootstrap bool, wallet return nil, types.PrivateKey{}, fmt.Errorf("failed to create wallet: %w", err) } - rhp2Listener, err := net.Listen("tcp", rhp2Addr) + rhp2Listener, err := net.Listen("tcp", config.RHP2.Address) if err != nil { return nil, types.PrivateKey{}, fmt.Errorf("failed to listen on rhp2 addr: %w", err) } - rhp3Listener, err := net.Listen("tcp", rhp3Addr) + rhp3Listener, err := net.Listen("tcp", config.RHP3.TCPAddress) if err != nil { return nil, types.PrivateKey{}, fmt.Errorf("failed to listen on rhp3 addr: %w", err) } - _, rhp2Port, err := net.SplitHostPort(rhp2Addr) + _, rhp2Port, err := net.SplitHostPort(config.RHP2.Address) if err != nil { return nil, types.PrivateKey{}, fmt.Errorf("failed to parse rhp2 addr: %w", err) } discoveredAddr := net.JoinHostPort(g.Address().Host(), rhp2Port) logger.Debug("discovered address", zap.String("addr", discoveredAddr)) - sr, err := settings.NewConfigManager(dir, hostKey, discoveredAddr, db, cm, tp, w, logger.Named("settings")) + sr, err := settings.NewConfigManager(config.DataDir, hostKey, discoveredAddr, db, cm, tp, w, logger.Named("settings")) if err != nil { return nil, types.PrivateKey{}, fmt.Errorf("failed to create settings manager: %w", err) } diff --git a/go.mod b/go.mod index b866d340..3c4440d7 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( golang.org/x/sys v0.7.0 golang.org/x/term v0.7.0 golang.org/x/time v0.3.0 + gopkg.in/yaml.v3 v3.0.1 lukechampine.com/frand v1.4.2 nhooyr.io/websocket v1.8.7 ) diff --git a/go.sum b/go.sum index f21685cb..821e79df 100644 --- a/go.sum +++ b/go.sum @@ -236,7 +236,9 @@ github.com/klauspost/reedsolomon v1.11.7/go.mod h1:4bXRN+cVzMdml6ti7qLouuYi32KHJ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= 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/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= @@ -278,6 +280,7 @@ github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40T github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= 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.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= @@ -701,6 +704,7 @@ google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= @@ -713,6 +717,7 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=