diff --git a/cmd/ghz/main.go b/cmd/ghz/main.go index bf57029c..31e69356 100644 --- a/cmd/ghz/main.go +++ b/cmd/ghz/main.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "errors" "fmt" "io/ioutil" "math" @@ -11,10 +12,10 @@ import ( "strings" "time" + "github.com/alecthomas/kingpin" "github.com/bojand/ghz/printer" "github.com/bojand/ghz/runner" "github.com/jinzhu/configor" - "gopkg.in/alecthomas/kingpin.v2" ) var ( @@ -25,49 +26,148 @@ var ( cPath = kingpin.Flag("config", "Path to the JSON or TOML config file that specifies all the test run settings.").PlaceHolder(" ").String() - proto = kingpin.Flag("proto", `The Protocol Buffer .proto file.`).PlaceHolder(" ").String() - protoset = kingpin.Flag("protoset", "The compiled protoset file. Alternative to proto. -proto takes precedence.").PlaceHolder(" ").String() - call = kingpin.Flag("call", `A fully-qualified method name in 'package.Service/method' or 'package.Service.Method' format.`).PlaceHolder(" ").String() - paths = kingpin.Flag("import-paths", "Comma separated list of proto import paths. The current working directory and the directory of the protocol buffer file are automatically added to the import list.").Short('i').PlaceHolder(" ").String() + // Proto + isProtoSet = false + proto = kingpin.Flag("proto", `The Protocol Buffer .proto file.`). + PlaceHolder(" ").IsSetByUser(&isProtoSet).String() - cacert = kingpin.Flag("cacert", "File containing trusted root certificates for verifying the server.").PlaceHolder(" ").String() - cert = kingpin.Flag("cert", "File containing client certificate (public key), to present to the server. Must also provide -key option.").PlaceHolder(" ").String() - key = kingpin.Flag("key", "File containing client private key, to present to the server. Must also provide -cert option.").PlaceHolder(" ").String() - cname = kingpin.Flag("cname", "Server name override when validating TLS certificate - useful for self signed certs.").PlaceHolder(" ").String() - skipVerify = kingpin.Flag("skipTLS", "Skip TLS client verification of the server's certificate chain and host name.").Default("false").Bool() - insecure = kingpin.Flag("insecure", "Use plaintext and insecure connection.").Default("false").Bool() - authority = kingpin.Flag("authority", "Value to be used as the :authority pseudo-header. Only works if -insecure is used.").PlaceHolder(" ").String() + isProtoSetSet = false + protoset = kingpin.Flag("protoset", "The compiled protoset file. Alternative to proto. -proto takes precedence."). + PlaceHolder(" ").IsSetByUser(&isProtoSetSet).String() - c = kingpin.Flag("concurrency", "Number of requests to run concurrently. Total number of requests cannot be smaller than the concurrency level. Default is 50.").Short('c').Default("50").Uint() - n = kingpin.Flag("total", "Number of requests to run. Default is 200.").Short('n').Default("200").Uint() - q = kingpin.Flag("qps", "Rate limit, in queries per second (QPS). Default is no rate limit.").Default("0").Short('q').Uint() - t = kingpin.Flag("timeout", "Timeout for each request. Default is 20s, use 0 for infinite.").Default("20s").Short('t').Duration() - z = kingpin.Flag("duration", "Duration of application to send requests. When duration is reached, application stops and exits. If duration is specified, n is ignored. Examples: -z 10s -z 3m.").Short('z').Default("0").Duration() - x = kingpin.Flag("max-duration", "Maximum duration of application to send requests with n setting respected. If duration is reached before n requests are completed, application stops and exits. Examples: -x 10s -x 3m.").Short('x').Default("0").Duration() + isCallSet = false + call = kingpin.Flag("call", `A fully-qualified method name in 'package.Service/method' or 'package.Service.Method' format.`). + PlaceHolder(" ").IsSetByUser(&isCallSet).String() - zstop = kingpin.Flag("duration-stop", "Specifies how duration stop is reported. Options are close, wait or ignore.").Default("close").String() + isImportSet = false + paths = kingpin.Flag("import-paths", "Comma separated list of proto import paths. The current working directory and the directory of the protocol buffer file are automatically added to the import list."). + Short('i').PlaceHolder(" ").IsSetByUser(&isImportSet).String() - conns = kingpin.Flag("connections", "Number of connections to use. Concurrency is distributed evenly among all the connections. Default is 1.").Default("1").Uint() + // Security + isCACertSet = false + cacert = kingpin.Flag("cacert", "File containing trusted root certificates for verifying the server."). + PlaceHolder(" ").IsSetByUser(&isCACertSet).String() - data = kingpin.Flag("data", "The call data as stringified JSON. If the value is '@' then the request contents are read from stdin.").Short('d').PlaceHolder(" ").String() - dataPath = kingpin.Flag("data-file", "File path for call data JSON file. Examples: /home/user/file.json or ./file.json.").Short('D').PlaceHolder("PATH").PlaceHolder(" ").String() - binData = kingpin.Flag("binary", "The call data comes as serialized binary message or multiple count-prefixed messages read from stdin.").Short('b').Default("false").Bool() - binPath = kingpin.Flag("binary-file", "File path for the call data as serialized binary message or multiple count-prefixed messages.").Short('B').PlaceHolder(" ").String() - md = kingpin.Flag("metadata", "Request metadata as stringified JSON.").Short('m').PlaceHolder(" ").String() - mdPath = kingpin.Flag("metadata-file", "File path for call metadata JSON file. Examples: /home/user/metadata.json or ./metadata.json.").Short('M').PlaceHolder(" ").String() - si = kingpin.Flag("stream-interval", "Interval for stream requests between message sends.").Default("0").Duration() - rmd = kingpin.Flag("reflect-metadata", "Reflect metadata as stringified JSON used only for reflection request.").PlaceHolder(" ").String() + isCertSet = false + cert = kingpin.Flag("cert", "File containing client certificate (public key), to present to the server. Must also provide -key option."). + PlaceHolder(" ").IsSetByUser(&isCertSet).String() - output = kingpin.Flag("output", "Output path. If none provided stdout is used.").Short('o').PlaceHolder(" ").String() - format = kingpin.Flag("format", "Output format. One of: summary, csv, json, pretty, html, influx-summary, influx-details. Default is summary.").Short('O').Default("summary").PlaceHolder(" ").Enum("summary", "csv", "json", "pretty", "html", "influx-summary", "influx-details") + isKeySet = false + key = kingpin.Flag("key", "File containing client private key, to present to the server. Must also provide -cert option."). + PlaceHolder(" ").IsSetByUser(&isKeySet).String() - ct = kingpin.Flag("connect-timeout", "Connection timeout for the initial connection dial. Default is 10s.").Default("10s").Duration() - kt = kingpin.Flag("keepalive", "Keepalive time duration. Only used if present and above 0.").Default("0").Duration() + isCNameSet = false + cname = kingpin.Flag("cname", "Server name override when validating TLS certificate - useful for self signed certs."). + PlaceHolder(" ").IsSetByUser(&isCNameSet).String() + + isSkipSet = false + skipVerify = kingpin.Flag("skipTLS", "Skip TLS client verification of the server's certificate chain and host name."). + Default("false").IsSetByUser(&isSkipSet).Bool() + + isInsecSet = false + insecure = kingpin.Flag("insecure", "Use plaintext and insecure connection."). + Default("false").IsSetByUser(&isInsecSet).Bool() + + isAuthSet = false + authority = kingpin.Flag("authority", "Value to be used as the :authority pseudo-header. Only works if -insecure is used."). + PlaceHolder(" ").IsSetByUser(&isAuthSet).String() + + // Run + isCSet = false + c = kingpin.Flag("concurrency", "Number of requests to run concurrently. Total number of requests cannot be smaller than the concurrency level. Default is 50."). + Short('c').Default("50").IsSetByUser(&isCSet).Uint() + + isNSet = false + n = kingpin.Flag("total", "Number of requests to run. Default is 200."). + Short('n').Default("200").IsSetByUser(&isNSet).Uint() + + isQSet = false + q = kingpin.Flag("qps", "Rate limit, in queries per second (QPS). Default is no rate limit."). + Default("0").Short('q').IsSetByUser(&isQSet).Uint() + + isTSet = false + t = kingpin.Flag("timeout", "Timeout for each request. Default is 20s, use 0 for infinite."). + Default("20s").Short('t').IsSetByUser(&isTSet).Duration() + + isZSet = false + z = kingpin.Flag("duration", "Duration of application to send requests. When duration is reached, application stops and exits. If duration is specified, n is ignored. Examples: -z 10s -z 3m."). + Short('z').Default("0").IsSetByUser(&isZSet).Duration() + + isXSet = false + x = kingpin.Flag("max-duration", "Maximum duration of application to send requests with n setting respected. If duration is reached before n requests are completed, application stops and exits. Examples: -x 10s -x 3m."). + Short('x').Default("0").IsSetByUser(&isXSet).Duration() + + isZStopSet = false + zstop = kingpin.Flag("duration-stop", "Specifies how duration stop is reported. Options are close, wait or ignore."). + Default("close").IsSetByUser(&isZStopSet).String() + + // Data + isDataSet = false + data = kingpin.Flag("data", "The call data as stringified JSON. If the value is '@' then the request contents are read from stdin."). + Short('d').PlaceHolder(" ").IsSetByUser(&isDataSet).String() + + isDataPathSet = false + dataPath = kingpin.Flag("data-file", "File path for call data JSON file. Examples: /home/user/file.json or ./file.json."). + Short('D').PlaceHolder("PATH").PlaceHolder(" ").IsSetByUser(&isDataPathSet).String() + + isBinDataSet = false + binData = kingpin.Flag("binary", "The call data comes as serialized binary message or multiple count-prefixed messages read from stdin."). + Short('b').Default("false").IsSetByUser(&isBinDataSet).Bool() + + isBinDataPathSet = false + binPath = kingpin.Flag("binary-file", "File path for the call data as serialized binary message or multiple count-prefixed messages."). + Short('B').PlaceHolder(" ").IsSetByUser(&isBinDataPathSet).String() + + isMDSet = false + md = kingpin.Flag("metadata", "Request metadata as stringified JSON."). + Short('m').PlaceHolder(" ").IsSetByUser(&isMDSet).String() + + isMDPathSet = false + mdPath = kingpin.Flag("metadata-file", "File path for call metadata JSON file. Examples: /home/user/metadata.json or ./metadata.json."). + Short('M').PlaceHolder(" ").IsSetByUser(&isMDPathSet).String() + + isSISet = false + si = kingpin.Flag("stream-interval", "Interval for stream requests between message sends."). + Default("0").IsSetByUser(&isSISet).Duration() + + isRMDSet = false + rmd = kingpin.Flag("reflect-metadata", "Reflect metadata as stringified JSON used only for reflection request."). + PlaceHolder(" ").IsSetByUser(&isRMDSet).String() + + // Output + isOutputSet = false + output = kingpin.Flag("output", "Output path. If none provided stdout is used."). + Short('o').PlaceHolder(" ").IsSetByUser(&isOutputSet).String() - name = kingpin.Flag("name", "User specified name for the test.").PlaceHolder(" ").String() - tags = kingpin.Flag("tags", "JSON representation of user-defined string tags.").PlaceHolder(" ").String() + isFormatSet = false + format = kingpin.Flag("format", "Output format. One of: summary, csv, json, pretty, html, influx-summary, influx-details. Default is summary."). + Short('O').Default("summary").PlaceHolder(" ").IsSetByUser(&isFormatSet).Enum("summary", "csv", "json", "pretty", "html", "influx-summary", "influx-details") - cpus = kingpin.Flag("cpus", "Number of cpu cores to use.").Default(strconv.FormatUint(uint64(nCPUs), 10)).Uint() + // Connection + isConnSet = false + conns = kingpin.Flag("connections", "Number of connections to use. Concurrency is distributed evenly among all the connections. Default is 1."). + Default("1").IsSetByUser(&isConnSet).Uint() + + isCTSet = false + ct = kingpin.Flag("connect-timeout", "Connection timeout for the initial connection dial. Default is 10s."). + Default("10s").IsSetByUser(&isCTSet).Duration() + + isKTSet = false + kt = kingpin.Flag("keepalive", "Keepalive time duration. Only used if present and above 0."). + Default("0").IsSetByUser(&isKTSet).Duration() + + // Meta + isNameSet = false + name = kingpin.Flag("name", "User specified name for the test."). + PlaceHolder(" ").IsSetByUser(&isNameSet).String() + + isTagsSet = false + tags = kingpin.Flag("tags", "JSON representation of user-defined string tags."). + PlaceHolder(" ").IsSetByUser(&isTagsSet).String() + + isCPUSet = false + cpus = kingpin.Flag("cpus", "Number of cpu cores to use."). + Default(strconv.FormatUint(uint64(nCPUs), 10)).IsSetByUser(&isCPUSet).Uint() host = kingpin.Arg("host", "Host and port to test.").String() ) @@ -80,18 +180,24 @@ func main() { cfgPath := strings.TrimSpace(*cPath) - var cfg *config + var cfg config if cfgPath != "" { - var conf config - err := configor.Load(&conf, cfgPath) + err := configor.Load(&cfg, cfgPath) kingpin.FatalIfError(err, "") - cfg = &conf + args := os.Args[1:] + if len(args) > 1 { + var cmdCfg config + err = createConfigFromArgs(&cmdCfg) + kingpin.FatalIfError(err, "") + + err = mergeConfig(&cfg, &cmdCfg) + kingpin.FatalIfError(err, "") + } } else { + err := createConfigFromArgs(&cfg) - var err error - cfg, err = createConfigFromArgs() kingpin.FatalIfError(err, "") } @@ -189,7 +295,11 @@ func handleError(err error) { } } -func createConfigFromArgs() (*config, error) { +func createConfigFromArgs(cfg *config) error { + if cfg == nil { + return errors.New("config cannot be nil") + } + iPaths := []string{} pathsTrimmed := strings.TrimSpace(*paths) if pathsTrimmed != "" { @@ -200,7 +310,7 @@ func createConfigFromArgs() (*config, error) { if *binData { b, err := ioutil.ReadAll(os.Stdin) if err != nil { - return nil, err + return err } binaryData = b @@ -210,14 +320,14 @@ func createConfigFromArgs() (*config, error) { *md = strings.TrimSpace(*md) if *md != "" { if err := json.Unmarshal([]byte(*md), &metadata); err != nil { - return nil, fmt.Errorf("Error unmarshaling metadata '%v': %v", *md, err.Error()) + return fmt.Errorf("Error unmarshaling metadata '%v': %v", *md, err.Error()) } } var dataObj interface{} if *data != "@" && strings.TrimSpace(*data) != "" { if err := json.Unmarshal([]byte(*data), &dataObj); err != nil { - return nil, fmt.Errorf("Error unmarshaling data '%v': %v", *data, err.Error()) + return fmt.Errorf("Error unmarshaling data '%v': %v", *data, err.Error()) } } @@ -225,7 +335,7 @@ func createConfigFromArgs() (*config, error) { *tags = strings.TrimSpace(*tags) if *tags != "" { if err := json.Unmarshal([]byte(*tags), &tagsMap); err != nil { - return nil, fmt.Errorf("Error unmarshaling tags '%v': %v", *tags, err.Error()) + return fmt.Errorf("Error unmarshaling tags '%v': %v", *tags, err.Error()) } } @@ -233,47 +343,189 @@ func createConfigFromArgs() (*config, error) { *rmd = strings.TrimSpace(*rmd) if *rmd != "" { if err := json.Unmarshal([]byte(*rmd), &rmdMap); err != nil { - return nil, fmt.Errorf("Error unmarshaling reflection metadata '%v': %v", *rmd, err.Error()) + return fmt.Errorf("Error unmarshaling reflection metadata '%v': %v", *rmd, err.Error()) } } - cfg := &config{ - Host: *host, - Proto: *proto, - Protoset: *protoset, - Call: *call, - RootCert: *cacert, - Cert: *cert, - Key: *key, - SkipTLSVerify: *skipVerify, - Insecure: *insecure, - Authority: *authority, - CName: *cname, - N: *n, - C: *c, - Connections: *conns, - QPS: *q, - Z: Duration(*z), - X: Duration(*x), - Timeout: Duration(*t), - ZStop: *zstop, - Data: dataObj, - DataPath: *dataPath, - BinData: binaryData, - BinDataPath: *binPath, - Metadata: &metadata, - MetadataPath: *mdPath, - SI: Duration(*si), - Output: *output, - Format: *format, - ImportPaths: iPaths, - DialTimeout: Duration(*ct), - KeepaliveTime: Duration(*kt), - CPUs: *cpus, - Name: *name, - Tags: &tagsMap, - ReflectMetadata: &rmdMap, - } - - return cfg, nil + cfg.Host = *host + cfg.Proto = *proto + cfg.Protoset = *protoset + cfg.Call = *call + cfg.RootCert = *cacert + cfg.Cert = *cert + cfg.Key = *key + cfg.SkipTLSVerify = *skipVerify + cfg.Insecure = *insecure + cfg.Authority = *authority + cfg.CName = *cname + cfg.N = *n + cfg.C = *c + cfg.QPS = *q + cfg.Z = Duration(*z) + cfg.X = Duration(*x) + cfg.Timeout = Duration(*t) + cfg.ZStop = *zstop + cfg.Data = dataObj + cfg.DataPath = *dataPath + cfg.BinData = binaryData + cfg.BinDataPath = *binPath + cfg.Metadata = &metadata + cfg.MetadataPath = *mdPath + cfg.SI = Duration(*si) + cfg.Output = *output + cfg.Format = *format + cfg.ImportPaths = iPaths + cfg.Connections = *conns + cfg.DialTimeout = Duration(*ct) + cfg.KeepaliveTime = Duration(*kt) + cfg.CPUs = *cpus + cfg.Name = *name + cfg.Tags = &tagsMap + cfg.ReflectMetadata = &rmdMap + + return nil +} + +func mergeConfig(dest *config, src *config) error { + if src == nil || dest == nil { + return errors.New("config cannot be nil") + } + + if isProtoSet { + dest.Proto = src.Proto + } + + if isProtoSetSet { + dest.Protoset = src.Protoset + } + + if isCallSet { + dest.Call = src.Call + } + + if isCACertSet { + dest.RootCert = src.RootCert + } + + if isCertSet { + dest.Cert = src.Cert + } + + if isKeySet { + dest.Key = src.Key + } + + if isSkipSet { + dest.SkipTLSVerify = src.SkipTLSVerify + } + + if isInsecSet { + dest.Insecure = src.Insecure + } + + if isAuthSet { + dest.Authority = src.Authority + } + + if isCNameSet { + dest.CName = src.CName + } + + if isNSet { + dest.N = src.N + } + + if isCSet { + dest.C = src.C + } + + if isQSet { + dest.QPS = src.QPS + } + + if isZSet { + dest.Z = src.Z + } + + if isXSet { + dest.X = src.X + } + + if isTSet { + dest.Timeout = src.Timeout + } + + if isZStopSet { + dest.ZStop = src.ZStop + } + + if isDataSet { + dest.Data = src.Data + } + + if isDataPathSet { + dest.DataPath = src.DataPath + } + + if isBinDataSet { + dest.BinData = src.BinData + } + + if isBinDataPathSet { + dest.BinDataPath = src.BinDataPath + } + + if isMDSet { + dest.Metadata = src.Metadata + } + + if isMDPathSet { + dest.MetadataPath = src.MetadataPath + } + + if isSISet { + dest.SI = src.SI + } + + if isOutputSet { + dest.Output = src.Output + } + + if isFormatSet { + dest.Format = src.Format + } + + if isImportSet { + dest.ImportPaths = src.ImportPaths + } + + if isConnSet { + dest.Connections = src.Connections + } + + if isCTSet { + dest.DialTimeout = src.DialTimeout + } + + if isKTSet { + dest.KeepaliveTime = src.KeepaliveTime + } + + if isCPUSet { + dest.CPUs = src.CPUs + } + + if isNameSet { + dest.Name = src.Name + } + + if isTagsSet { + dest.Tags = src.Tags + } + + if isRMDSet { + dest.ReflectMetadata = src.ReflectMetadata + } + + return nil } diff --git a/go.mod b/go.mod index 5fc3f26c..90750fe8 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/bojand/ghz go 1.13 require ( + github.com/alecthomas/kingpin v1.3.8-0.20191105203113-8c96d1c22481 github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 github.com/bojand/hri v1.1.0 github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect @@ -26,6 +27,5 @@ require ( go.uber.org/multierr v1.2.0 golang.org/x/net v0.0.0-20191021144547-ec77196f6094 google.golang.org/grpc v1.24.0 - gopkg.in/alecthomas/kingpin.v2 v2.2.6 gopkg.in/go-playground/assert.v1 v1.2.1 // indirect ) diff --git a/go.sum b/go.sum index a38c3571..f4322c8e 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= +github.com/alecthomas/kingpin v1.3.8-0.20191105203113-8c96d1c22481 h1:NXM4vkjHeFp3bbp0z/u0AdQRLg6b5LrPeFwgjVHUW58= +github.com/alecthomas/kingpin v1.3.8-0.20191105203113-8c96d1c22481/go.mod h1:b6br6/pDFSfMkBgC96TbpOji05q5pa+v5rIlS0Y6XtI= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= diff --git a/www/docs/examples.md b/www/docs/examples.md index 9dc9b9e7..d044d2fc 100644 --- a/www/docs/examples.md +++ b/www/docs/examples.md @@ -119,11 +119,17 @@ Note that only one of `-proto` or `-protoset` options will be used. `-proto` tak Alternatively `ghz` can be used with [Prototool](https://github.com/uber/prototool) using the [`descriptor-set`](https://github.com/uber/prototool/tree/dev/docs#prototool-descriptor-set) command: ``` -ghz -protoset $(prototool descriptor-set --include-imports --tmp) ... +ghz --protoset $(prototool descriptor-set --include-imports --tmp) ... ``` Finally we can specify all settings, including the target host, conveniently in a JSON or TOML config file. ```sh -ghz -config ./config.json +ghz --config ./config.json +``` + +Config file settings can be combined with command line arguments. CLI options overwrite config file options. + +```sh +ghz --config ./config.json -c 20 -n 1000 ``` diff --git a/www/docs/options.md b/www/docs/options.md index 5dcbef2e..c703ed0c 100644 --- a/www/docs/options.md +++ b/www/docs/options.md @@ -58,6 +58,12 @@ Value to be used as the `:authority` pseudo-header. Only works if `-insecure` is Path to the JSON or TOML [config file](example_config.md) that specifies all the test settings. +Config file settings can be combined with command line arguments. CLI options overwrite config file options. + +```sh +ghz --config=./config.json -c 20 -n 1000 +``` + ### `-c`, `--concurrency` Number of requests to run concurrently. Total number of requests cannot be smaller than the concurrency level. Default is `50`. For example to do requests in series without any concurrency set to `1`. `ghz` takes the `concurrency` argument and spawns that many worker goroutines. By default all goroutine workers share a single connection.