diff --git a/README.md b/README.md index 70541a667..7b5453e25 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ $ kubewatch -h Kubewatch: A watcher for Kubernetes -kubewatch is a Kubernetes watcher that publishes notifications -to Slack/hipchat/mattermost/flock channels. It watches the cluster +kubewatch is a Kubernetes watcher that publishes notifications +to Slack/hipchat/mattermost/flock channels. It watches the cluster for resource changes and notifies them through webhooks. supported webhooks: @@ -23,6 +23,7 @@ supported webhooks: - mattermost - flock - webhook + - smtp Usage: kubewatch [flags] diff --git a/cmd/config.go b/cmd/config.go index 97bef2089..64ca09da6 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -22,9 +22,9 @@ import ( "os" "path/filepath" - "github.com/sirupsen/logrus" "github.com/bitnami-labs/kubewatch/config" "github.com/bitnami-labs/kubewatch/pkg/client" + "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) @@ -98,5 +98,6 @@ func init() { flockConfigCmd, webhookConfigCmd, msteamsConfigCmd, + smtpConfigCmd, ) } diff --git a/cmd/smtp.go b/cmd/smtp.go new file mode 100644 index 000000000..6a5848d82 --- /dev/null +++ b/cmd/smtp.go @@ -0,0 +1,38 @@ +/* +Copyright 2020 VMware + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "fmt" + "os" + + "github.com/bitnami-labs/kubewatch/pkg/handlers/smtp" + "github.com/spf13/cobra" +) + +// smtpConfigCmd represents the smtp subcommand +var smtpConfigCmd = &cobra.Command{ + Use: "smtp", + Short: "specific smtp configuration", + Long: `specific smtp configuration`, + Run: func(cmd *cobra.Command, args []string) { + fmt.Fprintf(os.Stderr, "CLI setters not implemented yet, please edit ~/.kubewatch.yaml directly. Example:\n\n%s", smtp.ConfigExample) + }, +} + +func init() { +} diff --git a/config/config.go b/config/config.go index ef08bfa34..afb17f36f 100644 --- a/config/config.go +++ b/config/config.go @@ -36,6 +36,7 @@ type Handler struct { Flock Flock `json:"flock"` Webhook Webhook `json:"webhook"` MSTeams MSTeams `json:"msteams"` + SMTP SMTP `json:"smtp"` } // Resource contains resource configuration @@ -103,6 +104,25 @@ type MSTeams struct { WebhookURL string `json:"webhookurl"` } +// SMTP contains SMTP configuration. +type SMTP struct { + To string `json:"to" yaml:"to,omitempty"` + From string `json:"from" yaml:"from,omitempty"` + Hello string `json:"hello" yaml:"hello,omitempty"` + Smarthost string `json:"smarthost" yaml:"smarthost,omitempty"` + Subject string `json:"subject" yaml:"subject,omitempty"` + Headers map[string]string `json:"headers" yaml:"headers,omitempty"` + Auth SMTPAuth `json:"auth" yaml:"auth,omitempty"` + RequireTLS bool `json:"requireTLS" yaml:"requireTLS"` +} + +type SMTPAuth struct { + Username string `json:"username" yaml:"username,omitempty"` + Password string `json:"password" yaml:"password,omitempty"` + Secret string `json:"secret" yaml:"secret,omitempty"` + Identity string `json:"identity" yaml:"identity,omitempty"` +} + // New creates new config object func New() (*Config, error) { c := &Config{} diff --git a/docs/design.md b/docs/design.md index b389e9cd7..cf6bc1a6a 100644 --- a/docs/design.md +++ b/docs/design.md @@ -36,6 +36,7 @@ With each event get from k8s and matched filtering from configuration, it is pas - `Mattermost`: which send notification to Mattermost channel based on information from config - `MS Teams`: which send notification to MS Team incoming webhook based on information from config - `Slack`: which send notification to Slack channel based on information from config + - `Smtp`: which sends notifications to email recipients using a SMTP server obtained from config More handlers will be added in future. diff --git a/go.mod b/go.mod index 42502b28c..599a2e88a 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/magiconair/properties v1.7.4 // indirect github.com/mitchellh/mapstructure v0.0.0-20180111000720-b4575eea38cc // indirect + github.com/mkmik/multierror v0.3.0 github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect github.com/pelletier/go-toml v1.0.1 // indirect github.com/sirupsen/logrus v1.6.0 diff --git a/go.sum b/go.sum index ee273885f..16f48ae48 100644 --- a/go.sum +++ b/go.sum @@ -91,6 +91,7 @@ github.com/magiconair/properties v1.7.4/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czP github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mitchellh/mapstructure v0.0.0-20180111000720-b4575eea38cc h1:5T6hzGUO5OrL6MdYXYoLQtRWJDDgjdlOVBn9mIqGY1g= github.com/mitchellh/mapstructure v0.0.0-20180111000720-b4575eea38cc/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mkmik/multierror v0.3.0/go.mod h1:wjBYXRpDhh+8mIp+iLBOq0kZ3Y4ICTncojwvP8LUYLQ= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180320133207-05fbef0ca5da/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= diff --git a/pkg/client/run.go b/pkg/client/run.go index 874fe0710..03cd25bc9 100644 --- a/pkg/client/run.go +++ b/pkg/client/run.go @@ -27,6 +27,7 @@ import ( "github.com/bitnami-labs/kubewatch/pkg/handlers/mattermost" "github.com/bitnami-labs/kubewatch/pkg/handlers/msteam" "github.com/bitnami-labs/kubewatch/pkg/handlers/slack" + "github.com/bitnami-labs/kubewatch/pkg/handlers/smtp" "github.com/bitnami-labs/kubewatch/pkg/handlers/webhook" ) @@ -54,6 +55,8 @@ func ParseEventHandler(conf *config.Config) handlers.Handler { eventHandler = new(webhook.Webhook) case len(conf.Handler.MSTeams.WebhookURL) > 0: eventHandler = new(msteam.MSTeams) + case len(conf.Handler.SMTP.Smarthost) > 0 || len(conf.Handler.SMTP.To) > 0: + eventHandler = new(smtp.SMTP) default: eventHandler = new(handlers.Default) } diff --git a/pkg/handlers/handler.go b/pkg/handlers/handler.go index c5b96b98e..8bbdfdb55 100644 --- a/pkg/handlers/handler.go +++ b/pkg/handlers/handler.go @@ -23,6 +23,7 @@ import ( "github.com/bitnami-labs/kubewatch/pkg/handlers/mattermost" "github.com/bitnami-labs/kubewatch/pkg/handlers/msteam" "github.com/bitnami-labs/kubewatch/pkg/handlers/slack" + "github.com/bitnami-labs/kubewatch/pkg/handlers/smtp" "github.com/bitnami-labs/kubewatch/pkg/handlers/webhook" ) @@ -45,6 +46,7 @@ var Map = map[string]interface{}{ "flock": &flock.Flock{}, "webhook": &webhook.Webhook{}, "ms-teams": &msteam.MSTeams{}, + "smtp": &smtp.SMTP{}, } // Default handler implements Handler interface, diff --git a/pkg/handlers/smtp/client.go b/pkg/handlers/smtp/client.go new file mode 100644 index 000000000..16c7bcd2f --- /dev/null +++ b/pkg/handlers/smtp/client.go @@ -0,0 +1,296 @@ +/* +Copyright 2020 VMWare + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* + This code is adapted from https://github.com/prometheus/alertmanager/blob/a75cd02786dfecd25e2469fc4df5d920e6b9c226/notify/email/email.go +*/ + +package smtp + +import ( + "bytes" + "context" + "crypto/tls" + "fmt" + "log" + "math/rand" + "mime" + "mime/multipart" + "mime/quotedprintable" + "net" + "net/mail" + "net/smtp" + "net/textproto" + "os" + "strings" + "time" + + "github.com/bitnami-labs/kubewatch/config" + "github.com/mkmik/multierror" + "github.com/sirupsen/logrus" +) + +func sendEmail(conf config.SMTP, msg string) error { + ctx := context.Background() + + host, port, err := net.SplitHostPort(conf.Smarthost) + if err != nil { + return err + } + + var ( + c *smtp.Client + conn net.Conn + success = false + ) + + tlsConfig := &tls.Config{} + if port == "465" { + + if tlsConfig.ServerName == "" { + tlsConfig.ServerName = host + } + + conn, err = tls.Dial("tcp", conf.Smarthost, tlsConfig) + if err != nil { + return fmt.Errorf("establish TLS connection to server: %w", err) + } + } else { + var ( + d = net.Dialer{} + err error + ) + conn, err = d.DialContext(ctx, "tcp", conf.Smarthost) + if err != nil { + return fmt.Errorf("establish connection to server: %w", err) + } + } + c, err = smtp.NewClient(conn, host) + if err != nil { + conn.Close() + return fmt.Errorf("create SMTP client: %w", err) + } + defer func() { + // Try to clean up after ourselves but don't log anything if something has failed. + if err := c.Quit(); success && err != nil { + logrus.Warnf("failed to close SMTP connection: %v", err) + } + }() + + if conf.Hello != "" { + err = c.Hello(conf.Hello) + if err != nil { + return fmt.Errorf("send EHLO command: %w", err) + } + } + + // Global Config guarantees RequireTLS is not nil. + if conf.RequireTLS { + if ok, _ := c.Extension("STARTTLS"); !ok { + return fmt.Errorf("'require_tls' is true (default) but %q does not advertise the STARTTLS extension", conf.Smarthost) + } + if tlsConfig.ServerName == "" { + tlsConfig.ServerName = host + } + + if err := c.StartTLS(tlsConfig); err != nil { + return fmt.Errorf("send STARTTLS command: %w", err) + } + } + + if ok, mech := c.Extension("AUTH"); ok { + auth, err := auth(conf.Auth, host, mech) + if err != nil { + return fmt.Errorf("find auth mechanism: %w", err) + } + if auth != nil { + if err := c.Auth(auth); err != nil { + return fmt.Errorf("%T auth: %w", auth, err) + } + } + } + + addrs, err := mail.ParseAddressList(conf.From) + if err != nil { + return fmt.Errorf("parse 'from' addresses: %w", err) + } + if len(addrs) != 1 { + return fmt.Errorf("must be exactly one 'from' address (got: %d)", len(addrs)) + } + if err = c.Mail(addrs[0].Address); err != nil { + return fmt.Errorf("send MAIL command: %w", err) + } + addrs, err = mail.ParseAddressList(conf.To) + if err != nil { + return fmt.Errorf("parse 'to' addresses: %w", err) + } + for _, addr := range addrs { + if err = c.Rcpt(addr.Address); err != nil { + return fmt.Errorf("send RCPT command: %w", err) + } + } + + // Send the email headers and body. + message, err := c.Data() + if err != nil { + return fmt.Errorf("send DATA command: %w", err) + } + defer message.Close() + + if conf.Headers == nil { + conf.Headers = map[string]string{} + } + if _, ok := conf.Headers["Subject"]; !ok { + s := conf.Subject + if s == "" { + s = defaultSubject + } + conf.Headers["Subject"] = s + } + if _, ok := conf.Headers["To"]; !ok { + conf.Headers["To"] = conf.To + } + if _, ok := conf.Headers["From"]; !ok { + conf.Headers["From"] = conf.From + } + + buffer := &bytes.Buffer{} + for header, value := range conf.Headers { + fmt.Fprintf(buffer, "%s: %s\r\n", header, mime.QEncoding.Encode("utf-8", value)) + } + + hostname, err := os.Hostname() + if err != nil { + return err + } + if _, ok := conf.Headers["Message-Id"]; !ok { + fmt.Fprintf(buffer, "Message-Id: %s\r\n", fmt.Sprintf("<%d.%d@%s>", time.Now().UnixNano(), rand.Uint64(), hostname)) + } + + multipartBuffer := &bytes.Buffer{} + multipartWriter := multipart.NewWriter(multipartBuffer) + + fmt.Fprintf(buffer, "Date: %s\r\n", time.Now().Format(time.RFC1123Z)) + fmt.Fprintf(buffer, "Content-Type: multipart/alternative; boundary=%s\r\n", multipartWriter.Boundary()) + fmt.Fprintf(buffer, "MIME-Version: 1.0\r\n\r\n") + + _, err = message.Write(buffer.Bytes()) + if err != nil { + return fmt.Errorf("write headers: %w", err) + } + w, err := multipartWriter.CreatePart(textproto.MIMEHeader{ + "Content-Transfer-Encoding": {"quoted-printable"}, + "Content-Type": {"text/plain; charset=UTF-8"}, + }) + if err != nil { + return fmt.Errorf("create part for text template: %w", err) + } + + qw := quotedprintable.NewWriter(w) + _, err = qw.Write([]byte(msg)) + if err != nil { + return fmt.Errorf("write text part: %w", err) + } + err = qw.Close() + if err != nil { + return fmt.Errorf("close text part: %w", err) + } + + err = multipartWriter.Close() + if err != nil { + return fmt.Errorf("close multipartWriter: %w", err) + + } + + _, err = message.Write(multipartBuffer.Bytes()) + if err != nil { + return fmt.Errorf("write body buffer: %w", err) + } + + log.Printf("sending via %s:%s, to: %q, from: %q : %s ", host, port, conf.To, conf.From, msg) + return nil +} + +func auth(conf config.SMTPAuth, host, mechs string) (smtp.Auth, error) { + username := conf.Username + + // If no username is set, keep going without authentication. + if username == "" { + logrus.Debugf("smtp_auth_username is not configured. Attempting to send email without authenticating") + return nil, nil + } + + var errs []error + for _, mech := range strings.Split(mechs, " ") { + switch mech { + case "CRAM-MD5": + secret := string(conf.Secret) + if secret == "" { + errs = append(errs, fmt.Errorf("missing secret for CRAM-MD5 auth mechanism")) + continue + } + return smtp.CRAMMD5Auth(username, secret), nil + + case "PLAIN": + password := string(conf.Password) + if password == "" { + errs = append(errs, fmt.Errorf("missing password for PLAIN auth mechanism")) + continue + } + identity := conf.Identity + + return smtp.PlainAuth(identity, username, password, host), nil + case "LOGIN": + password := string(conf.Password) + if password == "" { + errs = append(errs, fmt.Errorf("missing password for LOGIN auth mechanism")) + continue + } + return LoginAuth(username, password), nil + } + } + if len(errs) == 0 { + errs = append(errs, fmt.Errorf("unknown auth mechanism: %q", mechs)) + } + return nil, multierror.Join(errs) +} + +type loginAuth struct { + username, password string +} + +func LoginAuth(username, password string) smtp.Auth { + return &loginAuth{username, password} +} + +func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { + return "LOGIN", []byte{}, nil +} + +// Used for AUTH LOGIN. (Maybe password should be encrypted) +func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { + if more { + switch strings.ToLower(string(fromServer)) { + case "username:": + return []byte(a.username), nil + case "password:": + return []byte(a.password), nil + default: + return nil, fmt.Errorf("unexpected server challenge") + } + } + return nil, nil +} \ No newline at end of file diff --git a/pkg/handlers/smtp/smtp.go b/pkg/handlers/smtp/smtp.go new file mode 100644 index 000000000..3c266c7a5 --- /dev/null +++ b/pkg/handlers/smtp/smtp.go @@ -0,0 +1,112 @@ +/* +Copyright 2020 VMWare + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* +Package smtp implements an email notification handler for kubewatch. + +See example configuration in the ConfigExample constant. +*/ +package smtp + +import ( + "fmt" + "log" + "time" + + "github.com/bitnami-labs/kubewatch/config" + kbEvent "github.com/bitnami-labs/kubewatch/pkg/event" + "github.com/sirupsen/logrus" +) + +const ( + defaultSubject = "Kubewatch notification" + + // ConfigExample is an example configuration. + ConfigExample = `handler: + smtp: + to: "myteam@mycompany.com" + from: "kubewatch@mycluster.com" + smarthost: smtp.mycompany.com:2525 + subject: Test notification + auth: + username: myusername + password: mypassword + requireTLS: true +` +) + +// SMTP handler implements handler.Handler interface, +// Notify event via email. +type SMTP struct { + cfg config.SMTP +} + +// Init prepares Webhook configuration +func (s *SMTP) Init(c *config.Config) error { + s.cfg = c.Handler.SMTP + + if s.cfg.To == "" { + return fmt.Errorf("smtp `to` conf field is required") + } + if s.cfg.From == "" { + return fmt.Errorf("smtp `from` conf field is required") + } + if s.cfg.Smarthost == "" { + return fmt.Errorf("smtp `smarthost` conf field is required") + } + return nil +} + +// ObjectCreated calls notifyWebhook on event creation +func (s *SMTP) ObjectCreated(obj interface{}) { + notify(s, obj, "created") +} + +// ObjectDeleted calls notifyWebhook on event creation +func (s *SMTP) ObjectDeleted(obj interface{}) { + notify(s, obj, "deleted") +} + +// ObjectUpdated calls notifyWebhook on event creation +func (s *SMTP) ObjectUpdated(oldObj, newObj interface{}) { + notify(s, newObj, "updated") +} + +// TestHandler tests the handler configurarion by sending test messages. +func (s *SMTP) TestHandler() { + send(s.cfg, "test") +} + +func notify(s *SMTP, obj interface{}, action string) { + e := kbEvent.New(obj, action) + msg, err := formatEmail(e, action) + if err != nil { + logrus.Error(err) + return + } + send(s.cfg, msg) + log.Printf("Message successfully sent to %s at %s ", s.cfg.To, time.Now()) +} + +func formatEmail(e kbEvent.Event, action string) (string, error) { + return e.Message(), nil +} + +func send(conf config.SMTP, msg string) { + if err := sendEmail(conf, msg); err != nil { + logrus.Error(err) + } +} diff --git a/pkg/handlers/smtp/smtp_test.go b/pkg/handlers/smtp/smtp_test.go new file mode 100644 index 000000000..db71cfac8 --- /dev/null +++ b/pkg/handlers/smtp/smtp_test.go @@ -0,0 +1,25 @@ +/* +Copyright 2020 VMWare + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package smtp + +import ( + "testing" +) + +func TestSMTP(t *testing.T) { + // TODO(mkmik): setup a in-memory smtp server like https://github.com/bradfitz/go-smtpd +} diff --git a/vendor/github.com/mkmik/multierror/LICENSE b/vendor/github.com/mkmik/multierror/LICENSE new file mode 100644 index 000000000..0f2cc6c7f --- /dev/null +++ b/vendor/github.com/mkmik/multierror/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2016-2017 Marko Mikulicic + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/vendor/github.com/mkmik/multierror/compat.go b/vendor/github.com/mkmik/multierror/compat.go new file mode 100644 index 000000000..2f8315d08 --- /dev/null +++ b/vendor/github.com/mkmik/multierror/compat.go @@ -0,0 +1,25 @@ +// +build !go1.13 + +package multierror + +import "fmt" + +// unwrap returns the result of calling the Unwrap method on err, if err's +// type contains an Unwrap method returning error. +// Otherwise, Unwrap returns nil. +// +// Taken from go1.13 errors.Unwrap. +// TODO: remove when we can stop caring about go <1.13. +func unwrap(err error) error { + u, ok := err.(interface { + Unwrap() error + }) + if !ok { + return nil + } + return u.Unwrap() +} + +func errorSuffix(err error, format string, a ...interface{}) error { + return fmt.Errorf("%v %s", err, fmt.Sprintf(format, a...)) +} diff --git a/vendor/github.com/mkmik/multierror/compat_go113.go b/vendor/github.com/mkmik/multierror/compat_go113.go new file mode 100644 index 000000000..0b10a6415 --- /dev/null +++ b/vendor/github.com/mkmik/multierror/compat_go113.go @@ -0,0 +1,17 @@ +// +build go1.13 + +package multierror + +import ( + "errors" + "fmt" +) + +// unwrap wraps go 1.13 Unwrap method +func unwrap(err error) error { + return errors.Unwrap(err) +} + +func errorSuffix(err error, format string, a ...interface{}) error { + return fmt.Errorf("%w %s", err, fmt.Sprintf(format, a...)) +} diff --git a/vendor/github.com/mkmik/multierror/go.mod b/vendor/github.com/mkmik/multierror/go.mod new file mode 100644 index 000000000..f99471264 --- /dev/null +++ b/vendor/github.com/mkmik/multierror/go.mod @@ -0,0 +1,3 @@ +module github.com/mkmik/multierror + +go 1.12 diff --git a/vendor/github.com/mkmik/multierror/multierror.go b/vendor/github.com/mkmik/multierror/multierror.go new file mode 100644 index 000000000..923573a95 --- /dev/null +++ b/vendor/github.com/mkmik/multierror/multierror.go @@ -0,0 +1,220 @@ +package multierror + +import ( + "bytes" + "fmt" + "strings" +) + +// Error bundles multiple errors and make them obey the error interface +type Error struct { + errs []error + formatter Formatter +} + +// Formatter allows to customize the rendering of the multierror. +type Formatter func(errs []string) string + +var DefaultFormatter = func(errs []string) string { + buf := bytes.NewBuffer(nil) + + fmt.Fprintf(buf, "%d errors occurred:", len(errs)) + for _, line := range errs { + fmt.Fprintf(buf, "\n%s", line) + } + + return buf.String() +} + +func (e *Error) Error() string { + var f Formatter = DefaultFormatter + if e.formatter != nil { + f = e.formatter + } + + var lines []string + for _, err := range e.errs { + lines = append(lines, err.Error()) + } + + return f(lines) +} + +type JoinOption func(*joinOptions) +type joinOptions struct { + formatter Formatter + transformer func([]error) []error +} + +func WithFormatter(f Formatter) JoinOption { + return func(o *joinOptions) { o.formatter = f } +} + +func WithTransformer(t func([]error) []error) JoinOption { + return func(o *joinOptions) { o.transformer = t } +} + +// Join turns a slice of errors into a multierror. +func Join(errs []error, opts ...JoinOption) error { + var o joinOptions + for _, opt := range opts { + opt(&o) + } + if o.transformer != nil { + errs = o.transformer(errs) + } + return &Error{errs: errs, formatter: o.formatter} +} + +// Fold is deprecated, use Join instead. +// +// Fold turns a slice of errors into a multierror. +func Fold(errs []error) error { + return Join(errs) +} + +// Split returns the underlying list of errors wrapped in a multierror. +// If err is not a multierror, then a singleton list is returned. +func Split(err error) []error { + if me, ok := err.(*Error); ok { + return me.errs + } else { + return []error{err} + } +} + +// Unfold is deprecated, use Split instead. +// +// Unfold returns the underlying list of errors wrapped in a multierror. +// If err is not a multierror, then a singleton list is returned. +func Unfold(err error) []error { + return Split(err) +} + +// Append creates a new mutlierror.Error structure or appends the arguments to an existing multierror +// err can be nil, or can be a non-multierror error. +// +// If err is nil and errs has only one element, that element is returned. +// I.e. a singleton error is never treated and (thus rendered) as a multierror. +// This also also effectively allows users to just pipe through the error value of a function call, +// without having to first check whether the error is non-nil. +func Append(err error, errs ...error) error { + if err == nil && len(errs) == 1 { + return errs[0] + } + if len(errs) == 1 && errs[0] == nil { + return err + } + if err == nil { + return Fold(errs) + } + switch err := err.(type) { + case *Error: + err.errs = append(err.errs, errs...) + return err + default: + return Fold(append([]error{err}, errs...)) + } +} + +// Uniq deduplicates a list of errors +func Uniq(errs []error) []error { + type groupingKey struct { + msg string + tagged bool + } + var ordered []groupingKey + grouped := map[groupingKey][]error{} + + for _, err := range errs { + msg, tag := TaggedError(err) + key := groupingKey{ + msg: msg, + tagged: tag != "", + } + if _, ok := grouped[key]; !ok { + ordered = append(ordered, key) + } + grouped[key] = append(grouped[key], err) + } + + var res []error + for _, key := range ordered { + group := grouped[key] + err := group[0] + if key.tagged { + var tags []string + for _, e := range group { + _, tag := TaggedError(e) + tags = append(tags, tag) + } + err = errorSuffix(unwrap(err), "(%s)", strings.Join(tags, ", ")) + } else { + if n := len(group); n > 1 { + err = errorSuffix(err, "repeated %d times", n) + } + } + res = append(res, err) + } + + return res +} + +type TaggableError interface { + // TaggedError is like Error() but splits the error from the tag. + TaggedError() (string, string) +} + +// TaggedError is like Error() but if err implements TaggedError, it will +// invoke TaggeddError() and return error message and the tag. Otherwise the tag will be empty. +func TaggedError(err error) (string, string) { + if te, ok := err.(TaggableError); ok { + return te.TaggedError() + } + return err.Error(), "" +} + +type taggedError struct { + tag string + err error +} + +// Tag wraps an error with a tag. The resulting error implements the TaggableError interface +// and thus the tags can be unwrapped by Uniq in order to deduplicate error messages without loosing +// context. +func Tag(tag string, err error) error { + return taggedError{tag: tag, err: err} +} + +func (t taggedError) Error() string { + return fmt.Sprintf("%s (%s)", t.err.Error(), t.tag) +} + +func (t taggedError) Unwrap() error { + return t.err +} + +func (t taggedError) TaggedError() (string, string) { + return t.err.Error(), t.tag +} + +// Format sets a custom formatter if err is a multierror. +func Format(err error, f Formatter) error { + if me, ok := err.(*Error); ok { + cpy := *me + cpy.formatter = f + return &cpy + } else { + return err + } +} + +// InlineFormatter formats all errors in +func InlineFormatter(errs []string) string { + return strings.Join(errs, "; ") +} + +// Transform applies a transformer to an unfolded multierror and re-wraps the result. +func Transform(err error, fn func([]error) []error) error { + return Fold(fn(Unfold(err))) +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 14ae6cdaf..5abb7e2de 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -62,6 +62,9 @@ github.com/magiconair/properties # github.com/mitchellh/mapstructure v0.0.0-20180111000720-b4575eea38cc ## explicit github.com/mitchellh/mapstructure +# github.com/mkmik/multierror v0.3.0 +## explicit +github.com/mkmik/multierror # github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd github.com/modern-go/concurrent # github.com/modern-go/reflect2 v1.0.1