From 67590aa49ea10d7ab9272e31e74696bc20087c6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kuffel?= Date: Fri, 12 Nov 2021 13:49:23 +0100 Subject: [PATCH 1/3] feat: add plugin support --- _example_plugin/plugin.go | 56 +++++++++++ args.go | 126 +++++++++---------------- common/config.go | 20 ++++ common/headers.go | 33 +++++++ args_test.go => common/headers_test.go | 4 +- common/plugin_interface.go | 11 +++ common/request_body.go | 18 ++++ gocannon.go | 49 +++++++--- http.go | 5 +- http_test.go | 11 ++- integration_test.go | 85 ++++++++++++++--- plugin_loader.go | 38 ++++++++ print.go | 11 ++- 13 files changed, 350 insertions(+), 117 deletions(-) create mode 100644 _example_plugin/plugin.go create mode 100644 common/config.go create mode 100644 common/headers.go rename args_test.go => common/headers_test.go (90%) create mode 100644 common/plugin_interface.go create mode 100644 common/request_body.go create mode 100644 plugin_loader.go diff --git a/_example_plugin/plugin.go b/_example_plugin/plugin.go new file mode 100644 index 0000000..91abd77 --- /dev/null +++ b/_example_plugin/plugin.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + "sync/atomic" + + "github.com/kffl/gocannon/common" +) + +type plugin string + +var config common.Config + +// you can use global variables within the plugin to persist its state between invocations of BeforeRequest +var reqCounter int64 = 0 + +func (p plugin) Startup(cfg common.Config) { + // saving the config for later + // make sure not to mutate the config contents + config = cfg +} + +func (p plugin) BeforeRequest(cid int) (target string, method string, body common.RawRequestBody, headers common.RequestHeaders) { + // there can be multiple invocations of BeforeRequest (with different connections ids) happening in parallel + // therefore it's necessary to ensure thread-safe usage of plugin global variables + + // as an example, we are going to use atomic add operation + // in order to track how many requests were sent so far + reqNum := atomic.AddInt64(&reqCounter, 1) + + headers = *config.Headers + + // every 10th request, we want to add a special header + if reqNum%10 == 0 { + headers = append(*config.Headers, common.RequestHeader{"X-Special-Header", "gocannon"}) + } + + // appending connectionID to the target (i.e. http://target:123/?connection=5) + target = fmt.Sprintf("%s?connection=%d", *config.Target, cid) + + // we leave the HTTP method unchanged (from config) + method = *config.Method + + // and the same for body + // the body shall not be mutated after being passed as a return value, as gocannon uses fasthttp's SetBodyRaw + body = *config.Body + + return +} + +func (p plugin) GetName() string { + return string(p) +} + +// GocannonPlugin is an exported instance of the plugin (the name "GocannonPlugin" is mandatory) +var GocannonPlugin plugin = "Sample Plugin" diff --git a/args.go b/args.go index 169e2c6..cd7bf87 100644 --- a/args.go +++ b/args.go @@ -1,100 +1,64 @@ package main import ( - "fmt" - "strings" + "os" + "github.com/kffl/gocannon/common" "gopkg.in/alecthomas/kingpin.v2" ) -type rawRequestBody []byte - -func (b *rawRequestBody) Set(value string) error { - (*b) = []byte(value) - return nil -} - -func (b *rawRequestBody) String() string { - return fmt.Sprint(*b) -} - -func (b *rawRequestBody) IsCumulative() bool { - return false -} - -func parseRequestBody(s kingpin.Settings) *rawRequestBody { - r := &rawRequestBody{} - s.SetValue((*rawRequestBody)(r)) +func parseRequestBody(s kingpin.Settings) *common.RawRequestBody { + r := &common.RawRequestBody{} + s.SetValue((*common.RawRequestBody)(r)) return r } -type requestHeader struct { - key string - value string -} - -type requestHeaders []requestHeader - -func (r *requestHeaders) Set(value string) error { - tokenized := strings.Split(value, ":") - if len(tokenized) != 2 { - return fmt.Errorf("Header '%s' doesn't match 'Key:Value' format (i.e. 'Content-Type:application/json')", value) - } - h := requestHeader{tokenized[0], tokenized[1]} - (*r) = append(*r, h) - return nil -} - -func (r *requestHeaders) String() string { - return fmt.Sprint(*r) -} - -func (r *requestHeaders) IsCumulative() bool { - return true -} - -func parseRequestHeaders(s kingpin.Settings) *requestHeaders { - r := &requestHeaders{} - s.SetValue((*requestHeaders)(r)) +func parseRequestHeaders(s kingpin.Settings) *common.RequestHeaders { + r := &common.RequestHeaders{} + s.SetValue((*common.RequestHeaders)(r)) return r } -var ( - duration = kingpin.Flag("duration", "Load test duration"). - Short('d'). - Default("10s"). - Duration() - connections = kingpin.Flag("connections", "Maximum number of concurrent connections"). - Short('c'). - Default("50"). - Int() - timeout = kingpin.Flag("timeout", "HTTP client timeout"). +var app = kingpin.New("gocannon", "Performance-focused HTTP benchmarking tool") + +var config = common.Config{ + Duration: app.Flag("duration", "Load test duration"). + Short('d'). + Default("10s"). + Duration(), + Connections: app.Flag("connections", "Maximum number of concurrent connections"). + Short('c'). + Default("50"). + Int(), + Timeout: app.Flag("timeout", "HTTP client timeout"). Short('t'). Default("200ms"). - Duration() - mode = kingpin.Flag("mode", "Statistics collection mode: reqlog (logs each request) or hist (stores histogram of completed requests latencies)"). + Duration(), + Mode: app.Flag("mode", "Statistics collection mode: reqlog (logs each request) or hist (stores histogram of completed requests latencies)"). Default("reqlog"). Short('m'). - String() - outputFile = kingpin.Flag("output", "File to save the request log in CSV format (reqlog mode) or a text file with raw histogram data (hist mode)"). - PlaceHolder("file.csv"). - Short('o'). - String() - interval = kingpin.Flag("interval", "Interval for statistics calculation (reqlog mode)"). - Default("250ms"). - Short('i'). - Duration() - preallocate = kingpin.Flag("preallocate", "Number of requests in req log to preallocate memory for per connection (reqlog mode)"). - Default("1000"). - Int() - method = kingpin.Flag("method", "The HTTP request method (GET, POST, PUT, PATCH or DELETE)").Default("GET").Enum("GET", "POST", "PUT", "PATCH", "DELETE") - body = parseRequestBody(kingpin.Flag("body", "HTTP request body").Short('b').PlaceHolder("\"{data...\"")) - headers = parseRequestHeaders(kingpin.Flag("header", "HTTP request header(s). You can set more than one header by repeating this flag.").Short('h').PlaceHolder("\"k:v\"")) - trustAll = kingpin.Flag("trust-all", "Omit SSL certificate validation").Bool() - target = kingpin.Arg("target", "HTTP target URL with port (i.e. http://localhost:80/test or https://host:443/x)").Required().String() -) + String(), + OutputFile: app.Flag("output", "File to save the request log in CSV format (reqlog mode) or a text file with raw histogram data (hist mode)"). + PlaceHolder("file.csv"). + Short('o'). + String(), + Interval: app.Flag("interval", "Interval for statistics calculation (reqlog mode)"). + Default("250ms"). + Short('i'). + Duration(), + Preallocate: app.Flag("preallocate", "Number of requests in req log to preallocate memory for per connection (reqlog mode)"). + Default("1000"). + Int(), + Method: app.Flag("method", "The HTTP request method (GET, POST, PUT, PATCH or DELETE)").Default("GET").Enum("GET", "POST", "PUT", "PATCH", "DELETE"), + Body: parseRequestBody(app.Flag("body", "HTTP request body").Short('b').PlaceHolder("\"{data...\"")), + Headers: parseRequestHeaders(kingpin.Flag("header", "HTTP request header(s). You can set more than one header by repeating this flag.").Short('h').PlaceHolder("\"k:v\"")), + TrustAll: app.Flag("trust-all", "Omit SSL certificate validation").Bool(), + Plugin: app.Flag("plugin", "Plugin to run Gocannon with").PlaceHolder("/to/p.so").ExistingFile(), + Target: app.Arg("target", "HTTP target URL with port (i.e. http://localhost:80/test or https://host:443/x)").Required().String(), +} -func parseArgs() { - kingpin.Version("0.2.1") - kingpin.Parse() +func parseArgs() error { + app.Version("0.2.1") + _, err := app.Parse(os.Args[1:]) + return err } diff --git a/common/config.go b/common/config.go new file mode 100644 index 0000000..727eb50 --- /dev/null +++ b/common/config.go @@ -0,0 +1,20 @@ +package common + +import "time" + +// Config is a struct containing all of the parsed CLI flags and arguments +type Config struct { + Duration *time.Duration + Connections *int + Timeout *time.Duration + Mode *string + OutputFile *string + Interval *time.Duration + Preallocate *int + Method *string + Body *RawRequestBody + Headers *RequestHeaders + TrustAll *bool + Plugin *string + Target *string +} diff --git a/common/headers.go b/common/headers.go new file mode 100644 index 0000000..64f08bf --- /dev/null +++ b/common/headers.go @@ -0,0 +1,33 @@ +package common + +import ( + "fmt" + "strings" +) + +// RequestHeader represents a single HTTP request header (a key: value pair) +type RequestHeader struct { + Key string + Value string +} + +// RequestHeaders is a slice of request headers that will be added to the request +type RequestHeaders []RequestHeader + +func (r *RequestHeaders) Set(value string) error { + tokenized := strings.Split(value, ":") + if len(tokenized) != 2 { + return fmt.Errorf("Header '%s' doesn't match 'Key:Value' format (i.e. 'Content-Type:application/json')", value) + } + h := RequestHeader{tokenized[0], tokenized[1]} + (*r) = append(*r, h) + return nil +} + +func (r *RequestHeaders) String() string { + return fmt.Sprint(*r) +} + +func (r *RequestHeaders) IsCumulative() bool { + return true +} diff --git a/args_test.go b/common/headers_test.go similarity index 90% rename from args_test.go rename to common/headers_test.go index 0571351..e105266 100644 --- a/args_test.go +++ b/common/headers_test.go @@ -1,4 +1,4 @@ -package main +package common import ( "testing" @@ -7,7 +7,7 @@ import ( ) func TestSetRequestHeaders(t *testing.T) { - r := requestHeaders{} + r := RequestHeaders{} errHeaderOk := r.Set("Content-Type:application/json") errHeaderWrong := r.Set("WrongHeader") diff --git a/common/plugin_interface.go b/common/plugin_interface.go new file mode 100644 index 0000000..aa05fb9 --- /dev/null +++ b/common/plugin_interface.go @@ -0,0 +1,11 @@ +package common + +// GocannonPlugin is an interface that has to be satisfied by a custom gocannnon plugin +type GocannonPlugin interface { + // function called on gocannon startup with a config passed to it + Startup(cfg Config) + // function called before each request is sent + BeforeRequest(cid int) (target string, method string, body RawRequestBody, headers RequestHeaders) + // function that returns the plugin's name + GetName() string +} diff --git a/common/request_body.go b/common/request_body.go new file mode 100644 index 0000000..2b79b25 --- /dev/null +++ b/common/request_body.go @@ -0,0 +1,18 @@ +package common + +import "fmt" + +type RawRequestBody []byte + +func (b *RawRequestBody) Set(value string) error { + (*b) = []byte(value) + return nil +} + +func (b *RawRequestBody) String() string { + return fmt.Sprint(*b) +} + +func (b *RawRequestBody) IsCumulative() bool { + return false +} diff --git a/gocannon.go b/gocannon.go index 0f7a560..5d03bc9 100644 --- a/gocannon.go +++ b/gocannon.go @@ -5,6 +5,7 @@ import ( "os" "sync" + "github.com/kffl/gocannon/common" "github.com/valyala/fasthttp" ) @@ -13,17 +14,27 @@ func exitWithError(err error) { os.Exit(1) } -func runGocannon() error { +func runGocannon(cfg common.Config) error { + var gocannonPlugin common.GocannonPlugin + var err error - c, err := newHTTPClient(*target, *timeout, *connections, *trustAll) + if *cfg.Plugin != "" { + gocannonPlugin, err = loadPlugin(*cfg.Plugin) + if err != nil { + return err + } + gocannonPlugin.Startup(cfg) + } + + c, err := newHTTPClient(*cfg.Target, *cfg.Timeout, *cfg.Connections, *cfg.TrustAll) if err != nil { return err } - n := *connections + n := *cfg.Connections - stats, scErr := newStatsCollector(*mode, n, *preallocate, *timeout) + stats, scErr := newStatsCollector(*cfg.Mode, n, *cfg.Preallocate, *cfg.Timeout) if scErr != nil { return scErr @@ -34,12 +45,22 @@ func runGocannon() error { wg.Add(n) start := makeTimestamp() - stop := start + duration.Nanoseconds() + stop := start + cfg.Duration.Nanoseconds() + + fmt.Printf("gocannon goes brr...\n") for connectionID := 0; connectionID < n; connectionID++ { - go func(c *fasthttp.HostClient, cid int) { + go func(c *fasthttp.HostClient, cid int, p common.GocannonPlugin) { for { - code, start, end := performRequest(c, *target, *method, *body, *headers) + var code int + var start int64 + var end int64 + if p != nil { + plugTarget, plugMethod, plugBody, plugHeaders := p.BeforeRequest(cid) + code, start, end = performRequest(c, plugTarget, plugMethod, plugBody, plugHeaders) + } else { + code, start, end = performRequest(c, *cfg.Target, *cfg.Method, *cfg.Body, *cfg.Headers) + } if end >= stop { break } @@ -47,12 +68,12 @@ func runGocannon() error { stats.RecordResponse(cid, code, start, end) } wg.Done() - }(c, connectionID) + }(c, connectionID, gocannonPlugin) } wg.Wait() - err = stats.CalculateStats(start, stop, *interval, *outputFile) + err = stats.CalculateStats(start, stop, *cfg.Interval, *cfg.OutputFile) if err != nil { return err @@ -65,10 +86,14 @@ func runGocannon() error { } func main() { - parseArgs() - printHeader() + err := parseArgs() + if err != nil { + exitWithError(err) + } + + printHeader(config) - err := runGocannon() + err = runGocannon(config) if err != nil { exitWithError(err) diff --git a/http.go b/http.go index b0dfe55..c55c248 100644 --- a/http.go +++ b/http.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/kffl/gocannon/common" "github.com/valyala/fasthttp" ) @@ -53,7 +54,7 @@ func newHTTPClient( return c, nil } -func performRequest(c *fasthttp.HostClient, target string, method string, body []byte, headers requestHeaders) ( +func performRequest(c *fasthttp.HostClient, target string, method string, body []byte, headers common.RequestHeaders) ( code int, start int64, end int64, ) { req := fasthttp.AcquireRequest() @@ -70,7 +71,7 @@ func performRequest(c *fasthttp.HostClient, target string, method string, body [ req.SetBodyRaw(body) for _, h := range headers { - req.Header.Add(h.key, h.value) + req.Header.Add(h.Key, h.Value) } start = makeTimestamp() diff --git a/http_test.go b/http_test.go index 4c14578..3459a92 100644 --- a/http_test.go +++ b/http_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/kffl/gocannon/common" "github.com/stretchr/testify/assert" ) @@ -47,11 +48,11 @@ func TestPerformRequest(t *testing.T) { timeout := time.Millisecond * 100 c, _ := newHTTPClient("http://localhost:3000/", timeout, 10, true) - r := requestHeaders{} - customHeader := requestHeaders{requestHeader{"Custom-Header", "gocannon"}} + r := common.RequestHeaders{} + customHeader := common.RequestHeaders{common.RequestHeader{Key: "Custom-Header", Value: "gocannon"}} codeOk, _, _ := performRequest(c, "http://localhost:3000/", "GET", []byte(""), r) - testBody := rawRequestBody([]byte("testbody")) + testBody := common.RawRequestBody([]byte("testbody")) codePost, _, _ := performRequest(c, "http://localhost:3000/postonly", "POST", testBody, r) codeISE, _, _ := performRequest(c, "http://localhost:3000/error", "GET", []byte(""), r) codeTimeout, _, _ := performRequest(c, "http://localhost:3000/timeout", "GET", []byte(""), r) @@ -70,7 +71,7 @@ func TestPerformRequestHTTPS(t *testing.T) { timeout := time.Second * 3 c, _ := newHTTPClient("https://dev.kuffel.io:443/", timeout, 1, false) - r := requestHeaders{} + r := common.RequestHeaders{} codeOk, _, _ := performRequest(c, "https://dev.kuffel.io:443/", "GET", []byte(""), r) @@ -83,7 +84,7 @@ func TestPerformRequestHTTPSInvalidCert(t *testing.T) { trustingClient, _ := newHTTPClient(targetBadCert, timeout, 1, true) regularClient, _ := newHTTPClient(targetBadCert, timeout, 1, false) - r := requestHeaders{} + r := common.RequestHeaders{} codeTrusting, _, _ := performRequest(trustingClient, targetBadCert, "GET", []byte(""), r) codeRegular, _, _ := performRequest(regularClient, targetBadCert, "GET", []byte(""), r) diff --git a/integration_test.go b/integration_test.go index ea05a2f..285cc1f 100644 --- a/integration_test.go +++ b/integration_test.go @@ -2,11 +2,13 @@ package main import ( "math" + "os/exec" "sync" "sync/atomic" "testing" "time" + "github.com/kffl/gocannon/common" "github.com/stretchr/testify/assert" "github.com/valyala/fasthttp" ) @@ -18,6 +20,7 @@ func TestGocannon(t *testing.T) { duration := time.Duration(3) * time.Second conns := 50 body := []byte("") + headers := common.RequestHeaders{} c, err := newHTTPClient(target, timeout, conns, true) @@ -35,7 +38,7 @@ func TestGocannon(t *testing.T) { for connectionID := 0; connectionID < conns; connectionID++ { go func(c *fasthttp.HostClient, cid int) { for { - code, start, end := performRequest(c, target, "GET", body, *headers) + code, start, end := performRequest(c, target, "GET", body, headers) if end >= stop { break } @@ -91,14 +94,74 @@ func TestGocannon(t *testing.T) { } func TestGocannonDefaultValues(t *testing.T) { - *duration = time.Second * 1 - *connections = 50 - *timeout = time.Millisecond * 200 - *mode = "reqlog" - *outputFile = "" - *interval = time.Millisecond * 250 - *preallocate = 1000 - *target = "http://localhost:3000/hello" - - assert.Nil(t, runGocannon(), "the load test should be completed without errors") + duration := time.Second * 1 + connections := 50 + timeout := time.Millisecond * 200 + mode := "reqlog" + outputFile := "" + interval := time.Millisecond * 250 + preallocate := 1000 + method := "GET" + body := common.RawRequestBody{} + header := common.RequestHeaders{} + trustAll := false + plugin := "" + target := "http://localhost:3000/hello" + + cfg := common.Config{ + Duration: &duration, + Connections: &connections, + Timeout: &timeout, + Mode: &mode, + OutputFile: &outputFile, + Interval: &interval, + Preallocate: &preallocate, + Method: &method, + Body: &body, + Headers: &header, + TrustAll: &trustAll, + Plugin: &plugin, + Target: &target, + } + + assert.Nil(t, runGocannon(cfg), "the load test should be completed without errors") +} + +func TestGocanonWithPlugin(t *testing.T) { + + err := exec.Command("go", "build", "-race", "-buildmode=plugin", "-o", "_example_plugin/plugin.so", "_example_plugin/plugin.go").Run() + + assert.Nil(t, err, "the plugin should compile without an error") + + duration := time.Second * 1 + connections := 50 + timeout := time.Millisecond * 200 + mode := "hist" + outputFile := "" + interval := time.Millisecond * 250 + preallocate := 1000 + method := "GET" + body := common.RawRequestBody{} + header := common.RequestHeaders{} + trustAll := true + plugin := "_example_plugin/plugin.so" + target := "http://localhost:3000/hello" + + cfg := common.Config{ + Duration: &duration, + Connections: &connections, + Timeout: &timeout, + Mode: &mode, + OutputFile: &outputFile, + Interval: &interval, + Preallocate: &preallocate, + Method: &method, + Body: &body, + Headers: &header, + TrustAll: &trustAll, + Plugin: &plugin, + Target: &target, + } + + assert.Nil(t, runGocannon(cfg), "the load test should be completed without errors") } diff --git a/plugin_loader.go b/plugin_loader.go new file mode 100644 index 0000000..4f4934b --- /dev/null +++ b/plugin_loader.go @@ -0,0 +1,38 @@ +package main + +import ( + "errors" + "fmt" + "plugin" + + "github.com/kffl/gocannon/common" +) + +var ( + ErrPluginOpen = errors.New("could not open plugin") + ErrPluginLookup = errors.New("could not lookup plugin interface") + ErrPluginInterface = errors.New("module symbol doesn't match GocannonPlugin") +) + +func loadPlugin(file string) (common.GocannonPlugin, error) { + + p, err := plugin.Open(file) + if err != nil { + return nil, ErrPluginOpen + } + + pluginSymbol, err := p.Lookup("GocannonPlugin") + if err != nil { + return nil, ErrPluginLookup + } + + var gocannonPlugin common.GocannonPlugin + gocannonPlugin, ok := pluginSymbol.(common.GocannonPlugin) + if !ok { + return nil, ErrPluginInterface + } + + fmt.Printf("Plugin %s loaded.\n", gocannonPlugin.GetName()) + + return gocannonPlugin, nil +} diff --git a/print.go b/print.go index f8ebfbf..dfc2f6f 100644 --- a/print.go +++ b/print.go @@ -1,10 +1,13 @@ package main -import "fmt" +import ( + "fmt" -func printHeader() { - fmt.Printf("Attacking %s with %d connections over %s\n", *target, *connections, *duration) - fmt.Printf("gocannon goes brr...\n") + "github.com/kffl/gocannon/common" +) + +func printHeader(cfg common.Config) { + fmt.Printf("Attacking %s with %d connections over %s\n", *cfg.Target, *cfg.Connections, *cfg.Duration) } func printSummary(s statsCollector) { From e510b65d80e161b1fdf283a794721e7a1b59fbad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kuffel?= Date: Fri, 12 Nov 2021 14:00:44 +0100 Subject: [PATCH 2/3] docs: plugin support --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 8db677e..bef3845 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Flags: -h, --header="k:v" ... HTTP request header(s). You can set more than one header by repeating this flag. --trust-all Omit SSL certificate validation + --plugin=/to/p.so Plugin to run Gocannon with --version Show application version. Args: @@ -112,6 +113,17 @@ Similarly to saving CSV output in request log mode, you can write the histogram 45 [...] ``` +### Custom plugins + +Gocannon supports user-provided plugins, which can customize the requests sent during the load test. A custom plugin has to satisfy the `GocannonPlugin` interface defined in the `common` package, which also contains types required for plugin development. An example implementation with additional comments is provided in `_example_plugin` folder. + +In order to build a plugin, use the following command inside its directory: + +``` +go build -buildmode=plugin -o plugin.so plugin.go +``` + +Once you obtain a shared object (`.so`) file, you can provide a path to it via `--plugin` flag. Bare in mind that a custom plugin `.so` file and the Gocannon binary using it must both be compiled using the same Go version. ## Load testing recommendations From a09e2c187a413841a52b5dce0d964aff8bd0206e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kuffel?= Date: Fri, 12 Nov 2021 14:01:24 +0100 Subject: [PATCH 3/3] chore: bump gocannon version --- CITATION.cff | 2 +- args.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index f4d052d..b9f11ab 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -6,4 +6,4 @@ license: "Apache-2.0" message: "Please use the following citation metadata." repository-code: "https://github.com/kffl/gocannon" title: "Gocannon - Performance-focused HTTP benchmarking tool" -version: "0.2.1" +version: "1.0.0" diff --git a/args.go b/args.go index cd7bf87..39932a8 100644 --- a/args.go +++ b/args.go @@ -58,7 +58,7 @@ var config = common.Config{ } func parseArgs() error { - app.Version("0.2.1") + app.Version("1.0.0") _, err := app.Parse(os.Args[1:]) return err }