From 7cbe0809d18c896e3af83aac3345aa2dc91fa16f Mon Sep 17 00:00:00 2001 From: nghialv Date: Wed, 22 Jul 2020 17:21:33 +0900 Subject: [PATCH 1/3] Add ability to send notifications to slack --- BUILD.bazel | 1 + hack/expose-generated-go.sh | 2 +- pkg/app/piped/cmd/piped/piped.go | 5 - pkg/app/piped/notifier/matcher.go | 16 +- pkg/app/piped/notifier/matcher_test.go | 6 +- pkg/app/piped/notifier/notifier.go | 2 +- pkg/app/piped/notifier/slack.go | 218 ++++++++++++++++++++++++- pkg/config/piped.go | 1 + pkg/model/deployment.go | 7 + pkg/model/event.go | 28 ++-- pkg/model/event.proto | 1 - 11 files changed, 248 insertions(+), 39 deletions(-) diff --git a/BUILD.bazel b/BUILD.bazel index 79fc3654c7..e0f8370af8 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -55,3 +55,4 @@ genrule( # gazelle:exclude pkg/model/piped_stats.pb.validate.go # gazelle:exclude pkg/model/project.pb.validate.go # gazelle:exclude pkg/model/role.pb.validate.go +# gazelle:exclude pkg/app/piped/notifier/templates.embed.go diff --git a/hack/expose-generated-go.sh b/hack/expose-generated-go.sh index 9b978b5cc2..28dfe9fcd6 100755 --- a/hack/expose-generated-go.sh +++ b/hack/expose-generated-go.sh @@ -125,7 +125,7 @@ for label in $(bazelisk query 'kind(go_embed_data, //...)'); do [[ -d "${package}" ]] || continue # Compute the path where Bazel puts the files. - out_path="bazel-bin/${package}/${OS}_${ARCH}_stripped" + out_path="bazel-bin/${package}" old_links=${package}/${target}.go generated_files=${out_path}/${target}.go diff --git a/pkg/app/piped/cmd/piped/piped.go b/pkg/app/piped/cmd/piped/piped.go index 1eeb250ff1..8ec567d607 100644 --- a/pkg/app/piped/cmd/piped/piped.go +++ b/pkg/app/piped/cmd/piped/piped.go @@ -127,16 +127,11 @@ func (p *piped) run(ctx context.Context, t cli.Telemetry) (runErr error) { }, }) defer func() { - var errMsg string - if runErr != nil { - errMsg = runErr.Error() - } notifier.Notify(model.Event{ Type: model.EventType_EVENT_PIPED_STOPPED, Metadata: &model.EventPipedStopped{ Id: cfg.PipedID, Version: version.Get().Version, - Error: errMsg, }, }) }() diff --git a/pkg/app/piped/notifier/matcher.go b/pkg/app/piped/notifier/matcher.go index a0659c57e5..6035c79832 100644 --- a/pkg/app/piped/notifier/matcher.go +++ b/pkg/app/piped/notifier/matcher.go @@ -43,8 +43,8 @@ func newMatcher(cfg config.NotificationRoute) *matcher { } } -type appIDMetadata interface { - AppID() string +type appNameMetadata interface { + AppName() string } type envIDMetadata interface { @@ -59,11 +59,11 @@ func (m *matcher) Match(event model.Event) bool { return false } - var appID string - if md, ok := event.Metadata.(appIDMetadata); ok { - appID = md.AppID() + var appName string + if md, ok := event.Metadata.(appNameMetadata); ok { + appName = md.AppName() } - if _, ok := m.ignoreApps[appID]; ok && appID != "" { + if _, ok := m.ignoreApps[appName]; ok && appName != "" { return false } @@ -86,8 +86,8 @@ func (m *matcher) Match(event model.Event) bool { return false } } - if len(m.apps) > 0 && appID != "" { - if _, ok := m.apps[appID]; !ok { + if len(m.apps) > 0 && appName != "" { + if _, ok := m.apps[appName]; !ok { return false } } diff --git a/pkg/app/piped/notifier/matcher_test.go b/pkg/app/piped/notifier/matcher_test.go index 1f428808a7..3495e8937f 100644 --- a/pkg/app/piped/notifier/matcher_test.go +++ b/pkg/app/piped/notifier/matcher_test.go @@ -90,7 +90,7 @@ func TestMatch(t *testing.T) { Type: model.EventType_EVENT_DEPLOYMENT_TRIGGERED, Metadata: &model.EventDeploymentTriggered{ Deployment: &model.Deployment{ - ApplicationId: "canary", + ApplicationName: "canary", }, }, }: true, @@ -98,7 +98,7 @@ func TestMatch(t *testing.T) { Type: model.EventType_EVENT_DEPLOYMENT_PLANNED, Metadata: &model.EventDeploymentTriggered{ Deployment: &model.Deployment{ - ApplicationId: "bluegreen", + ApplicationName: "bluegreen", }, }, }: false, @@ -106,7 +106,7 @@ func TestMatch(t *testing.T) { Type: model.EventType_EVENT_DEPLOYMENT_SUCCEEDED, Metadata: &model.EventDeploymentTriggered{ Deployment: &model.Deployment{ - ApplicationId: "not-specified", + ApplicationName: "not-specified", }, }, }: false, diff --git a/pkg/app/piped/notifier/notifier.go b/pkg/app/piped/notifier/notifier.go index 3a78854cab..8cd84900d5 100644 --- a/pkg/app/piped/notifier/notifier.go +++ b/pkg/app/piped/notifier/notifier.go @@ -60,7 +60,7 @@ func NewNotifier(cfg *config.PipedSpec, logger *zap.Logger) (*Notifier, error) { var sd sender switch { case receiver.Slack != nil: - sd = newSlackSender(receiver.Name, *receiver.Slack, logger) + sd = newSlackSender(receiver.Name, *receiver.Slack, cfg.WebURL, logger) case receiver.Webhook != nil: sd = newWebhookSender(receiver.Name, *receiver.Webhook, logger) default: diff --git a/pkg/app/piped/notifier/slack.go b/pkg/app/piped/notifier/slack.go index 7d91008d83..a788fecc70 100644 --- a/pkg/app/piped/notifier/slack.go +++ b/pkg/app/piped/notifier/slack.go @@ -15,7 +15,15 @@ package notifier import ( + "bytes" "context" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "strings" + "time" "go.uber.org/zap" @@ -23,23 +31,221 @@ import ( "github.com/pipe-cd/pipe/pkg/model" ) +const ( + slackUsername = "PipeCD" + slackInfoColor = "#212121" + slackSuccessColor = "#2E7D32" + slackErrorColor = "#AF3F52" + slackWarnColor = "#FFB74D" +) + type slack struct { - name string - config config.NotificationReceiverSlack - logger *zap.Logger + name string + config config.NotificationReceiverSlack + webURL string + httpClient *http.Client + eventCh chan model.Event + gracePeriod time.Duration + logger *zap.Logger } -func newSlackSender(name string, cfg config.NotificationReceiverSlack, logger *zap.Logger) *slack { +func newSlackSender(name string, cfg config.NotificationReceiverSlack, webURL string, logger *zap.Logger) *slack { return &slack{ name: name, config: cfg, - logger: logger.Named("slack"), + webURL: strings.TrimRight(webURL, "/"), + httpClient: &http.Client{ + Timeout: 5 * time.Second, + }, + eventCh: make(chan model.Event, 100), + gracePeriod: 10 * time.Second, + logger: logger.Named("slack"), } } func (s *slack) Run(ctx context.Context) error { - return nil + send := func(ctx context.Context, event model.Event) { + msg, ok := buildSlackMessage(event, s.webURL) + if !ok { + s.logger.Info(fmt.Sprintf("ignore event %s", event.Type.String())) + return + } + if err := s.sendMessage(ctx, msg); err != nil { + s.logger.Error(fmt.Sprintf("unable to send notification to slack: %v", err)) + } + } + + for { + select { + case event := <-s.eventCh: + send(ctx, event) + case <-ctx.Done(): + // TODO: Send all remaining events before exiting. + return nil + } + } } func (s *slack) Notify(event model.Event) { + s.eventCh <- event +} + +func buildSlackMessage(event model.Event, webURL string) (slackMessage, bool) { + var ( + title, link, text string + color = slackInfoColor + timestamp = time.Now().Unix() + fields []slackField + ) + + generateDeploymentEventData := func(d *model.Deployment) { + link = webURL + "/deployments/" + d.Id + // TODO: Use environment name instead of id. + fields = []slackField{ + {"Env", truncateText(d.EnvId, 8), true}, + {"Application", makeSlackLink(d.ApplicationName, webURL+"/applications/"+d.ApplicationId), true}, + {"Kind", strings.ToLower(d.Kind.String()), true}, + {"Deployment", makeSlackLink(truncateText(d.Id, 8), link), true}, + {"Triggered By", d.TriggeredBy(), true}, + {"Started At", makeSlackDate(d.CreatedAt), true}, + } + } + generatePipedEventData := func(id, version string) { + link = webURL + "/settings/piped" + fields = []slackField{ + {"Id", id, true}, + {"Version", version, true}, + } + } + + switch event.Type { + case model.EventType_EVENT_DEPLOYMENT_TRIGGERED: + md := event.Metadata.(*model.EventDeploymentTriggered) + title = fmt.Sprintf("Triggered a new deployment for %q", md.Deployment.ApplicationName) + generateDeploymentEventData(md.Deployment) + break + + case model.EventType_EVENT_DEPLOYMENT_PLANNED: + md := event.Metadata.(*model.EventDeploymentPlanned) + title = fmt.Sprintf("Deployment for %q was planned", md.Deployment.ApplicationName) + text = md.Summary + generateDeploymentEventData(md.Deployment) + break + + case model.EventType_EVENT_DEPLOYMENT_SUCCEEDED: + md := event.Metadata.(*model.EventDeploymentSucceeded) + title = fmt.Sprintf("Deployment for %q was completed successfully", md.Deployment.ApplicationName) + color = slackSuccessColor + generateDeploymentEventData(md.Deployment) + break + + case model.EventType_EVENT_DEPLOYMENT_FAILED: + md := event.Metadata.(*model.EventDeploymentFailed) + title = fmt.Sprintf("Deployment for %q was failed", md.Deployment.ApplicationName) + text = md.Reason + color = slackErrorColor + generateDeploymentEventData(md.Deployment) + break + + case model.EventType_EVENT_DEPLOYMENT_CANCELLED: + md := event.Metadata.(*model.EventDeploymentCancelled) + title = fmt.Sprintf("Deployment for %q was cancelled", md.Deployment.ApplicationName) + text = fmt.Sprintf("Cancelled by %s", md.Commander) + color = slackWarnColor + generateDeploymentEventData(md.Deployment) + break + + case model.EventType_EVENT_PIPED_STARTED: + md := event.Metadata.(*model.EventPipedStarted) + title = "A piped has been started" + generatePipedEventData(md.Id, md.Version) + break + + case model.EventType_EVENT_PIPED_STOPPED: + md := event.Metadata.(*model.EventPipedStarted) + title = "A piped has been stopped" + generatePipedEventData(md.Id, md.Version) + break + + default: + return slackMessage{}, false + } + + return makeSlackMessage(title, link, text, color, timestamp, fields...), true +} + +type slackMessage struct { + Username string `json:"username"` + Attachments []slackAttachment `json:"attachments,omitempty"` +} + +type slackAttachment struct { + Title string `json:"title"` + TitleLink string `json:"title_link"` + Text string `json:"text"` + Fields []slackField `json:"fields"` + Color string `json:"color,omitempty"` + Markdown []string `json:"mrkdwn_in,omitempty"` + Timestamp int64 `json:"ts,omitempty"` +} + +type slackField struct { + Title string `json:"title"` + Value string `json:"value"` + Short bool `json:"short"` +} + +func makeSlackLink(title, url string) string { + return fmt.Sprintf("<%s|%s>", url, title) +} + +func makeSlackDate(unix int64) string { + return fmt.Sprintf("", unix) +} + +func truncateText(text string, max int) string { + if len(text) <= max { + return text + } + return text[:max] + "..." +} + +func makeSlackMessage(title, titleLink, text, color string, timestamp int64, fields ...slackField) slackMessage { + return slackMessage{ + Username: slackUsername, + Attachments: []slackAttachment{slackAttachment{ + Title: title, + TitleLink: titleLink, + Text: text, + Fields: fields, + Color: color, + Markdown: []string{"text"}, + Timestamp: timestamp, + }}, + } +} + +func (s *slack) sendMessage(ctx context.Context, msg slackMessage) error { + buf := &bytes.Buffer{} + if err := json.NewEncoder(buf).Encode(msg); err != nil { + return err + } + + req, err := http.NewRequest("POST", s.config.HookURL, buf) + if err != nil { + return err + } + + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := ioutil.ReadAll(io.LimitReader(resp.Body, 1024*1024)) + return fmt.Errorf("%s from Slack: %s", resp.Status, strings.TrimSpace(string(body))) + } + + return nil } diff --git a/pkg/config/piped.go b/pkg/config/piped.go index cb9a501c25..9c0842c0cd 100644 --- a/pkg/config/piped.go +++ b/pkg/config/piped.go @@ -36,6 +36,7 @@ type PipedSpec struct { PipedID string // The path to the key generated for this piped. PipedKeyFile string + WebURL string `json:"webURL"` // How often to check whether an application should be synced. // Default is 1m. SyncInterval Duration `json:"syncInterval"` diff --git a/pkg/model/deployment.go b/pkg/model/deployment.go index 50d76131ab..c84209d178 100644 --- a/pkg/model/deployment.go +++ b/pkg/model/deployment.go @@ -116,6 +116,13 @@ func (d *Deployment) CommitHash() string { return d.Trigger.Commit.Hash } +func (d *Deployment) TriggeredBy() string { + if d.Trigger.Commander != "" { + return d.Trigger.Commander + } + return d.Trigger.Commit.Author +} + // Clone returns a deep copy of the deployment. func (d *Deployment) Clone() *Deployment { msg := proto.Clone(d) diff --git a/pkg/model/event.go b/pkg/model/event.go index 281402b8d0..792c0d73e9 100644 --- a/pkg/model/event.go +++ b/pkg/model/event.go @@ -34,35 +34,35 @@ func (e Event) Group() EventGroup { } } -func (e *EventDeploymentTriggered) AppID() string { - return e.Deployment.ApplicationId +func (e *EventDeploymentTriggered) AppName() string { + return e.Deployment.ApplicationName } -func (e *EventDeploymentPlanned) AppID() string { - return e.Deployment.ApplicationId +func (e *EventDeploymentPlanned) AppName() string { + return e.Deployment.ApplicationName } -func (e *EventDeploymentApproved) AppID() string { - return e.Deployment.ApplicationId +func (e *EventDeploymentApproved) AppName() string { + return e.Deployment.ApplicationName } -func (e *EventDeploymentRollingBack) AppID() string { - return e.Deployment.ApplicationId +func (e *EventDeploymentRollingBack) AppName() string { + return e.Deployment.ApplicationName } -func (e *EventDeploymentSucceeded) AppID() string { - return e.Deployment.ApplicationId +func (e *EventDeploymentSucceeded) AppName() string { + return e.Deployment.ApplicationName } -func (e *EventDeploymentFailed) AppID() string { - return e.Deployment.ApplicationId +func (e *EventDeploymentFailed) AppName() string { + return e.Deployment.ApplicationName } -func (e *EventApplicationSynced) AppID() string { +func (e *EventApplicationSynced) AppName() string { return e.Application.Id } -func (e *EventApplicationOutOfSync) AppID() string { +func (e *EventApplicationOutOfSync) AppName() string { return e.Application.Id } diff --git a/pkg/model/event.proto b/pkg/model/event.proto index 886f362a5e..294990fa06 100644 --- a/pkg/model/event.proto +++ b/pkg/model/event.proto @@ -99,5 +99,4 @@ message EventPipedStarted { message EventPipedStopped { string id = 1 [(validate.rules).string.min_len = 1]; string version = 2; - string error = 3; } From 79df66a46e6a2b376c94f3f76264ddee43089930 Mon Sep 17 00:00:00 2001 From: nghialv Date: Mon, 27 Jul 2020 17:37:36 +0900 Subject: [PATCH 2/3] Fix to use http request with context --- pkg/app/piped/notifier/slack.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/app/piped/notifier/slack.go b/pkg/app/piped/notifier/slack.go index a788fecc70..f8ca0ed195 100644 --- a/pkg/app/piped/notifier/slack.go +++ b/pkg/app/piped/notifier/slack.go @@ -213,7 +213,7 @@ func truncateText(text string, max int) string { func makeSlackMessage(title, titleLink, text, color string, timestamp int64, fields ...slackField) slackMessage { return slackMessage{ Username: slackUsername, - Attachments: []slackAttachment{slackAttachment{ + Attachments: []slackAttachment{{ Title: title, TitleLink: titleLink, Text: text, @@ -231,7 +231,7 @@ func (s *slack) sendMessage(ctx context.Context, msg slackMessage) error { return err } - req, err := http.NewRequest("POST", s.config.HookURL, buf) + req, err := http.NewRequestWithContext(ctx, "POST", s.config.HookURL, buf) if err != nil { return err } From c3c85176829e443898d65a62552eda8f8a0b786c Mon Sep 17 00:00:00 2001 From: nghialv Date: Mon, 27 Jul 2020 17:54:59 +0900 Subject: [PATCH 3/3] Remove unused break --- pkg/app/piped/notifier/slack.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/pkg/app/piped/notifier/slack.go b/pkg/app/piped/notifier/slack.go index f8ca0ed195..b76bbd5c23 100644 --- a/pkg/app/piped/notifier/slack.go +++ b/pkg/app/piped/notifier/slack.go @@ -123,21 +123,18 @@ func buildSlackMessage(event model.Event, webURL string) (slackMessage, bool) { md := event.Metadata.(*model.EventDeploymentTriggered) title = fmt.Sprintf("Triggered a new deployment for %q", md.Deployment.ApplicationName) generateDeploymentEventData(md.Deployment) - break case model.EventType_EVENT_DEPLOYMENT_PLANNED: md := event.Metadata.(*model.EventDeploymentPlanned) title = fmt.Sprintf("Deployment for %q was planned", md.Deployment.ApplicationName) text = md.Summary generateDeploymentEventData(md.Deployment) - break case model.EventType_EVENT_DEPLOYMENT_SUCCEEDED: md := event.Metadata.(*model.EventDeploymentSucceeded) title = fmt.Sprintf("Deployment for %q was completed successfully", md.Deployment.ApplicationName) color = slackSuccessColor generateDeploymentEventData(md.Deployment) - break case model.EventType_EVENT_DEPLOYMENT_FAILED: md := event.Metadata.(*model.EventDeploymentFailed) @@ -145,7 +142,6 @@ func buildSlackMessage(event model.Event, webURL string) (slackMessage, bool) { text = md.Reason color = slackErrorColor generateDeploymentEventData(md.Deployment) - break case model.EventType_EVENT_DEPLOYMENT_CANCELLED: md := event.Metadata.(*model.EventDeploymentCancelled) @@ -153,19 +149,16 @@ func buildSlackMessage(event model.Event, webURL string) (slackMessage, bool) { text = fmt.Sprintf("Cancelled by %s", md.Commander) color = slackWarnColor generateDeploymentEventData(md.Deployment) - break case model.EventType_EVENT_PIPED_STARTED: md := event.Metadata.(*model.EventPipedStarted) title = "A piped has been started" generatePipedEventData(md.Id, md.Version) - break case model.EventType_EVENT_PIPED_STOPPED: md := event.Metadata.(*model.EventPipedStarted) title = "A piped has been stopped" generatePipedEventData(md.Id, md.Version) - break default: return slackMessage{}, false