diff --git a/cmd/plugins.go b/cmd/plugins.go index 3f2444fe..d63dc5bd 100644 --- a/cmd/plugins.go +++ b/cmd/plugins.go @@ -33,6 +33,7 @@ import ( elasticsearch "github.com/vjeantet/bitfan/processors/output-elasticsearch" elasticsearch2 "github.com/vjeantet/bitfan/processors/output-elasticsearch2" email "github.com/vjeantet/bitfan/processors/output-email" + httpoutput "github.com/vjeantet/bitfan/processors/output-http" fileoutput "github.com/vjeantet/bitfan/processors/output-file" glusterfsoutput "github.com/vjeantet/bitfan/processors/output-glusterfs" mongodb "github.com/vjeantet/bitfan/processors/output-mongodb" @@ -91,6 +92,7 @@ func init() { initPlugin("output", "glusterfs", glusterfsoutput.New) initPlugin("output", "rabbitmq", rabbitmqoutput.New) initPlugin("output", "email", email.New) + initPlugin("output", "http", httpoutput.New) initPlugin("output", "when", when.New) initPlugin("output", "use", use.New) diff --git a/processors/output-http/docdoc.go b/processors/output-http/docdoc.go new file mode 100644 index 00000000..083acbe4 --- /dev/null +++ b/processors/output-http/docdoc.go @@ -0,0 +1,135 @@ +// Code generated by "bitfanDoc "; DO NOT EDIT +package httpoutput + +import "github.com/vjeantet/bitfan/processors/doc" + +func (p *processor) Doc() *doc.Processor { + return &doc.Processor{ + Name: "httpoutput", + Doc: "", + DocShort: "", + Options: &doc.ProcessorOptions{ + Doc: "", + Options: []*doc.ProcessorOption{ + &doc.ProcessorOption{ + Name: "AddField", + Alias: "add_field", + Doc: "Add a field to an event. Default value is {}", + Required: false, + Type: "hash", + DefaultValue: nil, + ExampleLS: "", + }, + &doc.ProcessorOption{ + Name: "URL", + Alias: "url", + Doc: "This output lets you send events to a generic HTTP(S) endpoint\nThis setting can be dynamic using the %{foo} syntax.", + Required: true, + Type: "string", + DefaultValue: nil, + ExampleLS: "", + }, + &doc.ProcessorOption{ + Name: "Headers", + Alias: "headers", + Doc: "Custom headers to use format is headers => {\"X-My-Header\", \"%{host}\"}. Default value is {}\nThis setting can be dynamic using the %{foo} syntax.", + Required: false, + Type: "hash", + DefaultValue: nil, + ExampleLS: "", + }, + &doc.ProcessorOption{ + Name: "HTTPMethod", + Alias: "http_method", + Doc: "The HTTP Verb. One of \"put\", \"post\", \"patch\", \"delete\", \"get\", \"head\". Default value is \"post\"", + Required: false, + Type: "string", + DefaultValue: nil, + ExampleLS: "", + }, + &doc.ProcessorOption{ + Name: "KeepAlive", + Alias: "keepalive", + Doc: "Turn this on to enable HTTP keepalive support. Default value is true", + Required: false, + Type: "bool", + DefaultValue: nil, + ExampleLS: "", + }, + &doc.ProcessorOption{ + Name: "PoolMax", + Alias: "pool_max", + Doc: "Max number of concurrent connections. Default value is 1", + Required: false, + Type: "int", + DefaultValue: nil, + ExampleLS: "", + }, + &doc.ProcessorOption{ + Name: "ConnectTimeout", + Alias: "connect_timeout", + Doc: "Timeout (in seconds) to wait for a connection to be established. Default value is 10", + Required: false, + Type: "uint", + DefaultValue: nil, + ExampleLS: "", + }, + &doc.ProcessorOption{ + Name: "RequestTimeout", + Alias: "request_timeout", + Doc: "Timeout (in seconds) for the entire request. Default value is 60", + Required: false, + Type: "uint", + DefaultValue: nil, + ExampleLS: "", + }, + &doc.ProcessorOption{ + Name: "Format", + Alias: "format", + Doc: "Set the format of the http body. Now supports only \"json_lines\"", + Required: false, + Type: "string", + DefaultValue: nil, + ExampleLS: "", + }, + &doc.ProcessorOption{ + Name: "RetryableCodes", + Alias: "retryable_codes", + Doc: "If encountered as response codes this plugin will retry these requests", + Required: false, + Type: "array", + DefaultValue: nil, + ExampleLS: "", + }, + &doc.ProcessorOption{ + Name: "IgnorableCodes", + Alias: "ignorable_codes", + Doc: "If you would like to consider some non-2xx codes to be successes\nenumerate them here. Responses returning these codes will be considered successes", + Required: false, + Type: "array", + DefaultValue: nil, + ExampleLS: "", + }, + &doc.ProcessorOption{ + Name: "BatchInterval", + Alias: "batch_interval", + Doc: "", + Required: false, + Type: "uint", + DefaultValue: nil, + ExampleLS: "", + }, + &doc.ProcessorOption{ + Name: "BatchSize", + Alias: "batch_size", + Doc: "", + Required: false, + Type: "uint", + DefaultValue: nil, + ExampleLS: "", + }, + }, + }, + Ports: []*doc.ProcessorPort{}, +} +} \ No newline at end of file diff --git a/processors/output-http/httpoutput.go b/processors/output-http/httpoutput.go new file mode 100644 index 00000000..16f20b7f --- /dev/null +++ b/processors/output-http/httpoutput.go @@ -0,0 +1,229 @@ +//go:generate veinoDoc + +package httpoutput + +import ( + "bytes" + "crypto/tls" + "fmt" + "io" + "io/ioutil" + "net" + "net/http" + "time" + + "github.com/facebookgo/muster" + "github.com/vjeantet/bitfan/processors" +) + +func New() processors.Processor { + return &processor{opt: &options{}} +} + +type processor struct { + httpClient *http.Client + muster muster.Client + processors.Base + opt *options + shutdown bool +} + +type options struct { + // Add a field to an event. Default value is {} + AddField map[string]interface{} `mapstructure:"add_field"` + + // This output lets you send events to a generic HTTP(S) endpoint + // This setting can be dynamic using the %{foo} syntax. + URL string `mapstructure:"url" validate:"required"` + + // Custom headers to use format is headers => {"X-My-Header", "%{host}"}. Default value is {} + // This setting can be dynamic using the %{foo} syntax. + Headers map[string]string `mapstructure:"headers"` + + // The HTTP Verb. One of "put", "post", "patch", "delete", "get", "head". Default value is "post" + HTTPMethod string `mapstructure:"http_method"` + + // Turn this on to enable HTTP keepalive support. Default value is true + KeepAlive bool `mapstructure:"keepalive"` + + // Max number of concurrent connections. Default value is 1 + PoolMax int `mapstructure:"pool_max"` + + // Timeout (in seconds) to wait for a connection to be established. Default value is 10 + ConnectTimeout uint `mapstructure:"connect_timeout"` + + // Timeout (in seconds) for the entire request. Default value is 60 + RequestTimeout uint `mapstructure:"request_timeout"` + + // Set the format of the http body. Now supports only "json_lines" + Format string `mapstructure:"format"` + + // If encountered as response codes this plugin will retry these requests + RetryableCodes []int `mapstructure:"retryable_codes"` + + // If you would like to consider some non-2xx codes to be successes + // enumerate them here. Responses returning these codes will be considered successes + IgnorableCodes []int `mapstructure:"ignorable_codes"` + + BatchInterval uint `mapstructure:"batch_interval"` + BatchSize uint `mapstructure:"batch_size"` + + // Add any number of arbitrary tags to your event. There is no default value for this setting. + // This can help with processing later. Tags can be dynamic and include parts of the event using the %{field} syntax. + // Tags []string `mapstructure:"tags"` +} + +func (p *processor) Configure(ctx processors.ProcessorContext, conf map[string]interface{}) error { + defaults := options{ + HTTPMethod: "post", + KeepAlive: true, + PoolMax: 1, + ConnectTimeout: 5, + RequestTimeout: 30, + Format: "json_lines", + RetryableCodes: []int{429, 500, 502, 503, 504}, + IgnorableCodes: []int{}, + BatchInterval: 5, + BatchSize: 100, + } + p.opt = &defaults + return p.ConfigureAndValidate(ctx, conf, p.opt) +} + +func (p *processor) Receive(e processors.IPacket) error { + // Convert dinamycs fields + processors.Dynamic(&p.opt.URL, e.Fields()) + headers := make(map[string]string) + for k, v := range p.opt.Headers { + processors.Dynamic(&k, e.Fields()) + processors.Dynamic(&v, e.Fields()) + headers[k] = v + } + p.opt.Headers = headers + + var ( + eventBytes []byte + err error + ) + switch p.opt.Format { + case "json_lines": + if eventBytes, err = e.Fields().Json(true); err != nil { + return err + } + eventBytes = append(eventBytes, "\n"...) + default: + return fmt.Errorf("HTTP Output: invalid format '%s'", p.opt.Format) + } + + p.muster.Work <- eventBytes + return nil +} + +func (p *processor) Start(e processors.IPacket) error { + tr := &http.Transport{ + Dial: (&net.Dialer{ + Timeout: time.Duration(p.opt.ConnectTimeout) * time.Second, + KeepAlive: time.Duration(time.Second * 300), + }).Dial, + TLSClientConfig: &tls.Config{ + }, + DisableCompression: true, + DisableKeepAlives: !p.opt.KeepAlive, + MaxIdleConns: p.opt.PoolMax, + MaxIdleConnsPerHost: p.opt.PoolMax, + ExpectContinueTimeout: time.Duration(time.Second * 3), + } + p.httpClient = &http.Client{ + Transport: tr, + Timeout: time.Duration(p.opt.RequestTimeout) * time.Second, + } + p.muster.MaxBatchSize = p.opt.BatchSize + p.muster.BatchTimeout = time.Duration(p.opt.BatchInterval) * time.Second + p.muster.MaxConcurrentBatches = uint(p.opt.PoolMax) + p.muster.PendingWorkCapacity = 0 + p.muster.BatchMaker = func() muster.Batch { return &batch{p: p} } + err := p.muster.Start() + return err +} + +func (p *processor) Stop(e processors.IPacket) error { + p.shutdown = true + return p.muster.Stop() +} + +type batch struct { + p *processor + Items bytes.Buffer + size uint +} + +func (b *batch) Add(item interface{}) { + b.Items.Write(item.([]byte)) + b.size = b.size + 1 +} + +// Once a Batch is ready, it will be Fired. It must call notifier.Done once the +// batch has been processed. +func (b *batch) Fire(notifier muster.Notifier) { + defer notifier.Done() + var ( + err error + req *http.Request + resp *http.Response + ) + for { + req, err = http.NewRequest(b.p.opt.HTTPMethod, b.p.opt.URL, &b.Items) + if err != nil { + b.p.Logger.Errorf("Create request failed with: %s\n", err.Error()) + return + } + for hName, hValue := range b.p.opt.Headers { + req.Header.Set(hName, hValue) + } + for { + if resp, err = b.p.httpClient.Do(req); err == nil { + break + } + b.p.Logger.Error(err) + time.Sleep(time.Second) + if b.p.shutdown { + return + } + } + + io.Copy(ioutil.Discard, resp.Body) + + for _, ignoreCode := range b.p.opt.IgnorableCodes { + if resp.StatusCode == ignoreCode { + b.p.Logger.Debugf("Successfully sent %d messages with status %s\n", b.size, resp.Status) + resp.Body.Close() + return + } + } + if resp.StatusCode >= 200 && resp.StatusCode <= 299 { + b.p.Logger.Debugf("Successfully sent %d messages with status %s\n", b.size, resp.Status) + resp.Body.Close() + return + } + + retry := false + for _, retryCode := range b.p.opt.RetryableCodes { + if resp.StatusCode == retryCode { + retry = true + break + } + } + if retry { + b.p.Logger.Warnf("Server returned %s. Retry send\n", resp.Status) + resp.Body.Close() + req.Body.Close() + time.Sleep(time.Second * 10) + if b.p.shutdown { + return + } + continue + } + b.p.Logger.Errorf("Server returned %s, %d messages was be lost\n", resp.Status, b.size) + return + } +} diff --git a/processors/output-http/readme.md b/processors/output-http/readme.md new file mode 100644 index 00000000..bc3cb728 --- /dev/null +++ b/processors/output-http/readme.md @@ -0,0 +1,128 @@ +# HTTPOUTPUT + + +## Synopsys + + +| SETTING | TYPE | REQUIRED | DEFAULT VALUE | +|-----------------|--------|----------|---------------| +| add_field | hash | false | {} | +| url | string | true | "" | +| headers | hash | false | {} | +| http_method | string | false | "" | +| keepalive | bool | false | ? | +| pool_max | int | false | 0 | +| connect_timeout | uint | false | ? | +| request_timeout | uint | false | ? | +| format | string | false | "" | +| retryable_codes | array | false | [] | +| ignorable_codes | array | false | [] | +| batch_interval | uint | false | ? | +| batch_size | uint | false | ? | + + +## Details + +### add_field +* Value type is hash +* Default value is `{}` + +Add a field to an event. Default value is {} + +### url +* This is a required setting. +* Value type is string +* Default value is `""` + +This output lets you send events to a generic HTTP(S) endpoint +This setting can be dynamic using the %{foo} syntax. + +### headers +* Value type is hash +* Default value is `{}` + +Custom headers to use format is headers => {"X-My-Header", "%{host}"}. Default value is {} +This setting can be dynamic using the %{foo} syntax. + +### http_method +* Value type is string +* Default value is `""` + +The HTTP Verb. One of "put", "post", "patch", "delete", "get", "head". Default value is "post" + +### keepalive +* Value type is bool +* Default value is `?` + +Turn this on to enable HTTP keepalive support. Default value is true + +### pool_max +* Value type is int +* Default value is `0` + +Max number of concurrent connections. Default value is 1 + +### connect_timeout +* Value type is uint +* Default value is `?` + +Timeout (in seconds) to wait for a connection to be established. Default value is 10 + +### request_timeout +* Value type is uint +* Default value is `?` + +Timeout (in seconds) for the entire request. Default value is 60 + +### format +* Value type is string +* Default value is `""` + +Set the format of the http body. Now supports only "json_lines" + +### retryable_codes +* Value type is array +* Default value is `[]` + +If encountered as response codes this plugin will retry these requests + +### ignorable_codes +* Value type is array +* Default value is `[]` + +If you would like to consider some non-2xx codes to be successes +enumerate them here. Responses returning these codes will be considered successes + +### batch_interval +* Value type is uint +* Default value is `?` + + + +### batch_size +* Value type is uint +* Default value is `?` + + + + + +## Configuration blueprint + +``` +httpoutput{ + add_field => {} + url => "" + headers => {} + http_method => "" + keepalive => bool + pool_max => 123 + connect_timeout => uint + request_timeout => uint + format => "" + retryable_codes => [] + ignorable_codes => [] + batch_interval => uint + batch_size => uint +} +``` diff --git a/vendor/github.com/facebookgo/muster/license b/vendor/github.com/facebookgo/muster/license new file mode 100644 index 00000000..c148d646 --- /dev/null +++ b/vendor/github.com/facebookgo/muster/license @@ -0,0 +1,30 @@ +BSD License + +For muster software + +Copyright (c) 2015, Facebook, Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name Facebook nor the names of its contributors may be used to + endorse or promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/github.com/facebookgo/muster/muster.go b/vendor/github.com/facebookgo/muster/muster.go new file mode 100644 index 00000000..6a3d1b42 --- /dev/null +++ b/vendor/github.com/facebookgo/muster/muster.go @@ -0,0 +1,175 @@ +// Package muster provides a framework for writing libraries that internally +// batch operations. +// +// It will be useful to you if you're building an API that benefits from +// performing work in batches for whatever reason. Batching is triggered based +// on a maximum number of items in a batch, and/or based on a timeout for how +// long a batch waits before it is dispatched. For example if you're willing to +// wait for a maximum of a 1m duration, you can just set BatchTimeout and keep +// adding things. Or if you want batches of 50 just set MaxBatchSize and it +// will only fire when the batch is filled. For best results set both. +// +// It would be in your best interest to use this library in a hidden fashion in +// order to avoid unnecessary coupling. You will typically achieve this by +// ensuring your implementation of muster.Batch and the use of muster.Client +// are private. +package muster + +import ( + "errors" + "sync" + "time" + + "github.com/facebookgo/clock" + "github.com/facebookgo/limitgroup" +) + +var errZeroBoth = errors.New( + "muster: MaxBatchSize and BatchTimeout can't both be zero", +) + +type waitGroup interface { + Add(delta int) + Done() + Wait() +} + +// Notifier is used to indicate to the Client when a batch has finished +// processing. +type Notifier interface { + // Calling Done will indicate the batch has finished processing. + Done() +} + +// Batch collects added items. Fire will be called exactly once. The Batch does +// not need to be safe for concurrent access; synchronization will be handled +// by the Client. +type Batch interface { + // This should add the given single item to the Batch. This is the "other + // end" of the Client.Work channel where your application will send items. + Add(item interface{}) + + // Fire off the Batch. It should call Notifier.Done() when it has finished + // processing the Batch. + Fire(notifier Notifier) +} + +// The Client manages the background process that makes, populates & fires +// Batches. +type Client struct { + // Maximum number of items in a batch. If this is zero batches will only be + // dispatched upon hitting the BatchTimeout. It is an error for both this and + // the BatchTimeout to be zero. + MaxBatchSize uint + + // Duration after which to send a pending batch. If this is zero batches will + // only be dispatched upon hitting the MaxBatchSize. It is an error for both + // this and the MaxBatchSize to be zero. + BatchTimeout time.Duration + + // MaxConcurrentBatches determines how many parallel batches we'll allow to + // be "in flight" concurrently. Once these many batches are in flight, the + // PendingWorkCapacity determines when sending to the Work channel will start + // blocking. In other words, once MaxConcurrentBatches hits, the system + // starts blocking. This allows for tighter control over memory utilization. + // If not set, the number of parallel batches in-flight will not be limited. + MaxConcurrentBatches uint + + // Capacity of work channel. If this is zero, the Work channel will be + // blocking. + PendingWorkCapacity uint + + // This function should create a new empty Batch on each invocation. + BatchMaker func() Batch + + // Once this Client has been started, send work items here to add to batch. + Work chan interface{} + + klock clock.Clock + workGroup waitGroup +} + +func (c *Client) clock() clock.Clock { + if c.klock == nil { + return clock.New() + } + return c.klock +} + +// Start the background worker goroutines and get ready for accepting requests. +func (c *Client) Start() error { + if int64(c.BatchTimeout) == 0 && c.MaxBatchSize == 0 { + return errZeroBoth + } + + if c.MaxConcurrentBatches == 0 { + c.workGroup = &sync.WaitGroup{} + } else { + c.workGroup = limitgroup.NewLimitGroup(c.MaxConcurrentBatches + 1) + } + + c.Work = make(chan interface{}, c.PendingWorkCapacity) + c.workGroup.Add(1) // this is the worker itself + go c.worker() + return nil +} + +// Stop gracefully and return once all processing has finished. +func (c *Client) Stop() error { + close(c.Work) + c.workGroup.Wait() + return nil +} + +// Background process. +func (c *Client) worker() { + defer c.workGroup.Done() + var batch = c.BatchMaker() + var count uint + var batchTimer *clock.Timer + var batchTimeout <-chan time.Time + send := func() { + c.workGroup.Add(1) + go batch.Fire(c.workGroup) + batch = c.BatchMaker() + count = 0 + if batchTimer != nil { + batchTimer.Stop() + } + } + recv := func(item interface{}, open bool) bool { + if !open { + if count != 0 { + send() + } + return true + } + batch.Add(item) + count++ + if c.MaxBatchSize != 0 && count >= c.MaxBatchSize { + send() + } else if int64(c.BatchTimeout) != 0 && count == 1 { + batchTimer = c.clock().Timer(c.BatchTimeout) + batchTimeout = batchTimer.C + } + return false + } + for { + // We use two selects in order to first prefer draining the work queue. + select { + case item, open := <-c.Work: + if recv(item, open) { + return + } + default: + select { + case item, open := <-c.Work: + if recv(item, open) { + return + } + case <-batchTimeout: + send() + } + } + } +} diff --git a/vendor/github.com/facebookgo/muster/patents b/vendor/github.com/facebookgo/muster/patents new file mode 100644 index 00000000..1eb0f296 --- /dev/null +++ b/vendor/github.com/facebookgo/muster/patents @@ -0,0 +1,33 @@ +Additional Grant of Patent Rights Version 2 + +"Software" means the muster software distributed by Facebook, Inc. + +Facebook, Inc. ("Facebook") hereby grants to each recipient of the Software +("you") a perpetual, worldwide, royalty-free, non-exclusive, irrevocable +(subject to the termination provision below) license under any Necessary +Claims, to make, have made, use, sell, offer to sell, import, and otherwise +transfer the Software. For avoidance of doubt, no license is granted under +Facebook’s rights in any patent claims that are infringed by (i) modifications +to the Software made by you or any third party or (ii) the Software in +combination with any software or other technology. + +The license granted hereunder will terminate, automatically and without notice, +if you (or any of your subsidiaries, corporate affiliates or agents) initiate +directly or indirectly, or take a direct financial interest in, any Patent +Assertion: (i) against Facebook or any of its subsidiaries or corporate +affiliates, (ii) against any party if such Patent Assertion arises in whole or +in part from any software, technology, product or service of Facebook or any of +its subsidiaries or corporate affiliates, or (iii) against any party relating +to the Software. Notwithstanding the foregoing, if Facebook or any of its +subsidiaries or corporate affiliates files a lawsuit alleging patent +infringement against you in the first instance, and you respond by filing a +patent infringement counterclaim in that lawsuit against that party that is +unrelated to the Software, the license granted hereunder will not terminate +under section (i) of this paragraph due to such counterclaim. + +A "Necessary Claim" is a claim of a patent owned by Facebook that is +necessarily infringed by the Software standing alone. + +A "Patent Assertion" is any lawsuit or other action alleging direct, indirect, +or contributory infringement or inducement to infringe any patent, including a +cross-claim or counterclaim. diff --git a/vendor/github.com/facebookgo/muster/readme.md b/vendor/github.com/facebookgo/muster/readme.md new file mode 100644 index 00000000..579f5cf9 --- /dev/null +++ b/vendor/github.com/facebookgo/muster/readme.md @@ -0,0 +1,4 @@ +muster [![Build Status](https://secure.travis-ci.org/facebookgo/muster.png)](http://travis-ci.org/facebookgo/muster) +====== + +Documentation: http://godoc.org/github.com/facebookgo/muster diff --git a/vendor/vendor.json b/vendor/vendor.json index 1ef59fd6..73432b44 100644 --- a/vendor/vendor.json +++ b/vendor/vendor.json @@ -122,6 +122,12 @@ "revision": "739b9fdbb08757a56b4f12a3e1b119ea229d349a", "revisionTime": "2014-07-25T18:44:12Z" }, + { + "checksumSHA1": "IElCUEuECHxNWiDinhWFpi0U6oQ=", + "path": "github.com/facebookgo/muster", + "revision": "fd3d7953fd52354a74b9f6b3d70d0c9650c4ec2a", + "revisionTime": "2015-07-08T23:28:44Z" + }, { "checksumSHA1": "40Ns85VYa4smQPcewZ7SOdfLnKU=", "path": "github.com/fatih/structs",