diff --git a/Readme.md b/Readme.md index 12c349d..d782d29 100644 --- a/Readme.md +++ b/Readme.md @@ -6,6 +6,7 @@ - [Up](./up) — Deploy serverless applications and APIs to AWS Lambda - [Go](./go) — Build Go applications +- [Slack](./slack) — Send Slack messages ## Resources diff --git a/slack/Dockerfile b/slack/Dockerfile new file mode 100644 index 0000000..25b2bdc --- /dev/null +++ b/slack/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:1.11 + +LABEL version="1.0.0" +LABEL maintainer="Apex" +LABEL repository="http://github.com/apex/actions" +LABEL homepage="http://github.com/apex/actions/slack" +LABEL "com.github.actions.name"="Slack" +LABEL "com.github.actions.description"="Send a Slack message" +LABEL "com.github.actions.icon"="slack" +LABEL "com.github.actions.color"="white" + +RUN go get github.com/apex/actions/slack/cmd/slack + +ENTRYPOINT ["slack"] diff --git a/slack/Readme.md b/slack/Readme.md new file mode 100644 index 0000000..e83e110 --- /dev/null +++ b/slack/Readme.md @@ -0,0 +1,43 @@ +# Slack + +GitHub Action for sending Slack messages which were defined by previous action(s) in ./slack.json. + +## Secrets + +- `SLACK_WEBHOOK_URL` - *Required* The Slack webhook URL. +- `SLACK_CHANNEL` - *Optional* The Slack channel name. +- `SLACK_USERNAME` - *Optional* The Slack message username. +- `SLACK_ICON` - *Optional* The Slack message icon. + +## Example + +This example sends a Slack notification after a deployment is complete. The `apex/actions/up` +action generates a slack.json to provide a message. + +``` +workflow "Deployment" { + on = "push" + resolves = ["Deploy Notification"] +} + +action "Build" { + uses = "apex/actions/go@master" +} + +action "Deploy" { + needs = "Build" + uses = "apex/actions/up@master" + secrets = ["AWS_SECRET_ACCESS_KEY", "AWS_ACCESS_KEY_ID"] + args = "deploy production" +} + +action "Deploy Notification" { + needs = "Deploy" + uses = "apex/actions/slack@master" + secrets = ["SLACK_WEBHOOK_URL"] +} +``` + +## Links + +- Message format: https://api.slack.com/docs/messages/builder diff --git a/slack/cmd/slack/main.go b/slack/cmd/slack/main.go new file mode 100644 index 0000000..614777c --- /dev/null +++ b/slack/cmd/slack/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "fmt" + "log" + "os" + + "github.com/apex/actions/slack" +) + +func main() { + var msg slack.Message + + // read message + err := slack.ReadMessage("slack.json", &msg) + + if os.IsNotExist(err) { + log.Fatalf("Missing ./slack.json file, a previous action should populate it.") + } + + if err != nil { + log.Fatalf("error reading message: %s", err) + } + + // webhook + webhook := os.Getenv("SLACK_WEBHOOK_URL") + + if webhook == "" { + log.Fatalf("Missing SLACK_WEBHOOK_URL environment variable") + } + + // channel + if s := os.Getenv("SLACK_CHANNEL"); s != "" { + msg.Channel = s + } + + // username + if s := os.Getenv("SLACK_USERNAME"); s != "" { + msg.Username = s + } + + // icon + if s := os.Getenv("SLACK_ICON"); s != "" { + msg.IconURL = s + } + + err = slack.Send(webhook, &msg) + if err != nil { + log.Fatalf("error sending message: %s", err) + } + + fmt.Printf("Slack message sent!\n") +} diff --git a/slack/slack.go b/slack/slack.go new file mode 100644 index 0000000..4e02263 --- /dev/null +++ b/slack/slack.go @@ -0,0 +1,161 @@ +package slack + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + + "github.com/pkg/errors" +) + +// AttachmentField contains information for an attachment field +// An Attachment can contain multiple of these +type AttachmentField struct { + Title string `json:"title"` + Value string `json:"value"` + Short bool `json:"short"` +} + +// AttachmentAction is a button or menu to be included in the attachment. Required when +// using message buttons or menus and otherwise not useful. A maximum of 5 actions may be +// provided per attachment. +type AttachmentAction struct { + Name string `json:"name"` // Required. + Text string `json:"text"` // Required. + Style string `json:"style,omitempty"` // Optional. Allowed values: "default", "primary", "danger". + Type string `json:"type"` // Required. Must be set to "button" or "select". + Value string `json:"value,omitempty"` // Optional. + DataSource string `json:"data_source,omitempty"` // Optional. + MinQueryLength int `json:"min_query_length,omitempty"` // Optional. Default value is 1. + Options []AttachmentActionOption `json:"options,omitempty"` // Optional. Maximum of 100 options can be provided in each menu. + SelectedOptions []AttachmentActionOption `json:"selected_options,omitempty"` // Optional. The first element of this array will be set as the pre-selected option for this menu. + OptionGroups []AttachmentActionOptionGroup `json:"option_groups,omitempty"` // Optional. + Confirm *ConfirmationField `json:"confirm,omitempty"` // Optional. + URL string `json:"url,omitempty"` // Optional. +} + +// AttachmentActionOption the individual option to appear in action menu. +type AttachmentActionOption struct { + Text string `json:"text"` // Required. + Value string `json:"value"` // Required. + Description string `json:"description,omitempty"` // Optional. Up to 30 characters. +} + +// AttachmentActionOptionGroup is a semi-hierarchal way to list available options to appear in action menu. +type AttachmentActionOptionGroup struct { + Text string `json:"text"` // Required. + Options []AttachmentActionOption `json:"options"` // Required. +} + +// ActionCallback specific fields for the action callback. +type ActionCallback struct { + MessageTs string `json:"message_ts"` + AttachmentID string `json:"attachment_id"` + Actions []AttachmentAction `json:"actions"` +} + +// ConfirmationField are used to ask users to confirm actions +type ConfirmationField struct { + Title string `json:"title,omitempty"` // Optional. + Text string `json:"text"` // Required. + OkText string `json:"ok_text,omitempty"` // Optional. Defaults to "Okay" + DismissText string `json:"dismiss_text,omitempty"` // Optional. Defaults to "Cancel" +} + +// Attachment contains all the information for an attachment +type Attachment struct { + Color string `json:"color,omitempty"` + Fallback string `json:"fallback"` + + CallbackID string `json:"callback_id,omitempty"` + ID int `json:"id,omitempty"` + + AuthorID string `json:"author_id,omitempty"` + AuthorName string `json:"author_name,omitempty"` + AuthorSubname string `json:"author_subname,omitempty"` + AuthorLink string `json:"author_link,omitempty"` + AuthorIcon string `json:"author_icon,omitempty"` + + Title string `json:"title,omitempty"` + TitleLink string `json:"title_link,omitempty"` + Pretext string `json:"pretext,omitempty"` + Text string `json:"text"` + + ImageURL string `json:"image_url,omitempty"` + ThumbURL string `json:"thumb_url,omitempty"` + + Fields []AttachmentField `json:"fields,omitempty"` + Actions []AttachmentAction `json:"actions,omitempty"` + MarkdownIn []string `json:"mrkdwn_in,omitempty"` + + Footer string `json:"footer,omitempty"` + FooterIcon string `json:"footer_icon,omitempty"` + + Ts json.Number `json:"ts,omitempty"` +} + +// Message is a Slack message payload. +type Message struct { + ResponseType string `json:"response_tyoe,omitempty"` + Text string `json:"text,omitempty"` + Channel string `json:"channel,omitempty"` + Username string `json:"username,omitempty"` + IconURL string `json:"icon_url,omitempty"` + IconEmoji string `json:"icon_emoji,omitempty"` + UnfurlLinks bool `json:"unfurl_links,omitempty"` + LinkNames string `json:"link_names,omitempty"` + Attachments []*Attachment `json:"attachments,omitempty"` +} + +// ReadMessage reads a message from the given file. +func ReadMessage(path string, msg *Message) error { + b, err := ioutil.ReadFile(path) + if err != nil { + return err + } + + err = json.Unmarshal(b, msg) + if err != nil { + return errors.Wrap(err, "unmarshaling") + } + + return nil +} + +// WriteMessage writes a message to the given file. +func WriteMessage(path string, msg *Message) error { + b, err := json.MarshalIndent(msg, "", " ") + if err != nil { + return errors.Wrap(err, "marshaling") + } + + err = ioutil.WriteFile(path, b, 0755) + if err != nil { + return err + } + + return nil +} + +// Send a message to the given webhook url. +func Send(url string, msg *Message) error { + var buf bytes.Buffer + + err := json.NewEncoder(&buf).Encode(msg) + if err != nil { + return errors.Wrap(err, "marshaling") + } + + res, err := http.Post(url, "application/json", &buf) + if err != nil { + return errors.Wrap(err, "requesting") + } + defer res.Body.Close() + + if res.StatusCode >= 300 { + return errors.Errorf("%s response", res.Status) + } + + return nil +} diff --git a/up/Dockerfile b/up/Dockerfile index eff44a6..06763fe 100644 --- a/up/Dockerfile +++ b/up/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:3.8 +FROM golang:1.11 LABEL version="1.0.0" LABEL maintainer="Apex" @@ -9,11 +9,11 @@ LABEL "com.github.actions.description"="Perform Up application operations" LABEL "com.github.actions.icon"="chevron-up" LABEL "com.github.actions.color"="white" -RUN apk add --update curl jq && rm -rf /var/cache/apk/* - ENV CI true RUN curl -sf https://up.apex.sh/install | sh RUN chmod +x /usr/local/bin/up -ENTRYPOINT ["/usr/local/bin/up"] +RUN go get github.com/apex/actions/up/cmd/up-wrapper + +ENTRYPOINT ["up-wrapper"] CMD ["deploy", "--no-build"] diff --git a/up/Readme.md b/up/Readme.md index 09fa127..274db99 100644 --- a/up/Readme.md +++ b/up/Readme.md @@ -63,3 +63,7 @@ action "Deploy" { args = "deploy production --no-build" } ``` + +## Notes + +This action generates a Slack message upon deployment. diff --git a/up/cmd/up-wrapper/main.go b/up/cmd/up-wrapper/main.go new file mode 100644 index 0000000..c1c7828 --- /dev/null +++ b/up/cmd/up-wrapper/main.go @@ -0,0 +1,91 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/apex/actions/slack" +) + +// Config is the Up configuration. +type Config struct { + Name string `json:"name"` +} + +func main() { + args := os.Args[1:] + + workdir, err := os.Getwd() + if err != nil { + log.Fatalf("error getting working directory: %s", err) + } + + // chdir + for i, arg := range args { + if arg == "-C" { + os.Chdir(args[i+1]) + args = args[i+2:] + } + } + + // is it a deployment? + deploy := len(args) > 0 && args[0] == "deploy" + + // determine the stage + stage := "staging" + if deploy { + for _, arg := range args[1:] { + if !strings.HasPrefix(arg, "--") { + stage = arg + break + } + } + } + + // read app name + f, err := os.Open("up.json") + if err != nil { + log.Fatalf("error opening up.json: %s", err) + } + defer f.Close() + + var c Config + err = json.NewDecoder(f).Decode(&c) + if err != nil { + log.Fatalf("error reading up.json: %s", err) + } + + // proxy to up(1) + start := time.Now() + + cmd := exec.Command("up", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err = cmd.Run() + if err != nil { + log.Fatalf("error: %s", err) + } + + if deploy { + err := slack.WriteMessage(filepath.Join(workdir, "slack.json"), &slack.Message{ + Username: "Up", + Attachments: []*slack.Attachment{ + &slack.Attachment{ + Title: c.Name, + Text: fmt.Sprintf("Deployment to *%s* completed.", stage), + Footer: fmt.Sprintf("Completed in %s", time.Since(start).Round(time.Second)), + }, + }, + }) + + if err != nil { + log.Fatalf("error writing slack message: %s", err) + } + } +}