notifier.go 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. package slack
  2. import (
  3. "bytes"
  4. "encoding/json"
  5. "fmt"
  6. "net/http"
  7. "strings"
  8. "time"
  9. "github.com/porter-dev/porter/api/types"
  10. "github.com/porter-dev/porter/internal/models/integrations"
  11. )
  12. type Notifier interface {
  13. Notify(opts *NotifyOpts) error
  14. }
  15. type DeploymentStatus string
  16. const (
  17. StatusHelmDeployed DeploymentStatus = "helm_deployed"
  18. StatusPodCrashed DeploymentStatus = "pod_crashed"
  19. StatusHelmFailed DeploymentStatus = "helm_failed"
  20. )
  21. type NotifyOpts struct {
  22. // ProjectID is the id of the Porter project that this deployment belongs to
  23. ProjectID uint
  24. // ClusterID is the id of the Porter cluster that this deployment belongs to
  25. ClusterID uint
  26. // ClusterName is the name of the cluster that this deployment was deployed in
  27. ClusterName string
  28. // Status is the current status of the deployment.
  29. Status DeploymentStatus
  30. // Info is any additional information about this status, such as an error message if
  31. // the deployment failed.
  32. Info string
  33. // Name is the name of the deployment that this notification refers to.
  34. Name string
  35. // Namespace is the Kubernetes namespace of the deployment that this notification refers to.
  36. Namespace string
  37. URL string
  38. Timestamp *time.Time
  39. Version int
  40. }
  41. type SlackNotifier struct {
  42. slackInts []*integrations.SlackIntegration
  43. Config *types.NotificationConfig
  44. }
  45. func NewSlackNotifier(conf *types.NotificationConfig, slackInts ...*integrations.SlackIntegration) Notifier {
  46. return &SlackNotifier{
  47. slackInts: slackInts,
  48. Config: conf,
  49. }
  50. }
  51. type SlackPayload struct {
  52. Blocks []*SlackBlock `json:"blocks"`
  53. }
  54. type SlackBlock struct {
  55. Type string `json:"type"`
  56. Text *SlackText `json:"text,omitempty"`
  57. }
  58. type SlackText struct {
  59. Type string `json:"type"`
  60. Text string `json:"text"`
  61. }
  62. func (s *SlackNotifier) Notify(opts *NotifyOpts) error {
  63. if s.Config != nil {
  64. if !s.Config.Enabled {
  65. return nil
  66. }
  67. if opts.Status == StatusHelmDeployed && !s.Config.Success {
  68. return nil
  69. }
  70. if opts.Status == StatusPodCrashed && !s.Config.Failure {
  71. return nil
  72. }
  73. if opts.Status == StatusHelmFailed && !s.Config.Failure {
  74. return nil
  75. }
  76. }
  77. // we create a basic payload as a fallback if the detailed payload with "info" fails, due to
  78. // marshaling errors on the Slack API side.
  79. blocks, basicBlocks := getSlackBlocks(opts)
  80. slackPayload := &SlackPayload{
  81. Blocks: blocks,
  82. }
  83. basicSlackPayload := &SlackPayload{
  84. Blocks: basicBlocks,
  85. }
  86. basicPayload, err := json.Marshal(basicSlackPayload)
  87. if err != nil {
  88. return err
  89. }
  90. payload, err := json.Marshal(slackPayload)
  91. if err != nil {
  92. return err
  93. }
  94. basicReqBody := bytes.NewReader(basicPayload)
  95. reqBody := bytes.NewReader(payload)
  96. client := &http.Client{
  97. Timeout: time.Second * 5,
  98. }
  99. for _, slackInt := range s.slackInts {
  100. resp, err := client.Post(string(slackInt.Webhook), "application/json", reqBody)
  101. if err != nil || resp.StatusCode != 200 {
  102. client.Post(string(slackInt.Webhook), "application/json", basicReqBody)
  103. }
  104. }
  105. return nil
  106. }
  107. func getSlackBlocks(opts *NotifyOpts) ([]*SlackBlock, []*SlackBlock) {
  108. res := []*SlackBlock{}
  109. if opts.Status == StatusHelmDeployed || opts.Status == StatusHelmFailed {
  110. res = append(res, getHelmMessageBlock(opts))
  111. } else if opts.Status == StatusPodCrashed {
  112. res = append(res, getPodCrashedMessageBlock(opts))
  113. }
  114. res = append(
  115. res,
  116. getDividerBlock(),
  117. getMarkdownBlock(fmt.Sprintf("*Name:* %s", "`"+opts.Name+"`")),
  118. getMarkdownBlock(fmt.Sprintf("*Namespace:* %s", "`"+opts.Namespace+"`")),
  119. )
  120. if opts.Timestamp != nil {
  121. res = append(res, getMarkdownBlock(fmt.Sprintf(
  122. "*Timestamp:* <!date^%d^Alerted at {date_num} {time_secs}|Alerted at %s>",
  123. opts.Timestamp.Unix(),
  124. opts.Timestamp.Format("2006-01-02 15:04:05 UTC"),
  125. )),
  126. )
  127. }
  128. if opts.Status == StatusHelmDeployed || opts.Status == StatusHelmFailed {
  129. res = append(res, getMarkdownBlock(fmt.Sprintf("*Version:* %d", opts.Version)))
  130. }
  131. basicRes := res
  132. infoBlock := getInfoBlock(opts)
  133. if infoBlock != nil {
  134. res = append(res, infoBlock)
  135. }
  136. return res, basicRes
  137. }
  138. func getDividerBlock() *SlackBlock {
  139. return &SlackBlock{
  140. Type: "divider",
  141. }
  142. }
  143. func getMarkdownBlock(md string) *SlackBlock {
  144. return &SlackBlock{
  145. Type: "section",
  146. Text: &SlackText{
  147. Type: "mrkdwn",
  148. Text: md,
  149. },
  150. }
  151. }
  152. func getHelmMessageBlock(opts *NotifyOpts) *SlackBlock {
  153. var md string
  154. switch opts.Status {
  155. case StatusHelmDeployed:
  156. md = getHelmSuccessMessage(opts)
  157. case StatusHelmFailed:
  158. md = getHelmFailedMessage(opts)
  159. }
  160. return getMarkdownBlock(md)
  161. }
  162. func getPodCrashedMessageBlock(opts *NotifyOpts) *SlackBlock {
  163. md := fmt.Sprintf(
  164. ":x: Your application %s crashed on Porter. <%s|View the application.>",
  165. "`"+opts.Name+"`",
  166. opts.URL,
  167. )
  168. return getMarkdownBlock(md)
  169. }
  170. func getInfoBlock(opts *NotifyOpts) *SlackBlock {
  171. var md string
  172. switch opts.Status {
  173. case StatusHelmFailed:
  174. md = getFailedInfoMessage(opts)
  175. case StatusPodCrashed:
  176. md = getFailedInfoMessage(opts)
  177. default:
  178. return nil
  179. }
  180. return getMarkdownBlock(md)
  181. }
  182. func getHelmSuccessMessage(opts *NotifyOpts) string {
  183. return fmt.Sprintf(
  184. ":rocket: Your application %s was successfully updated on Porter! <%s|View the new release.>",
  185. "`"+opts.Name+"`",
  186. opts.URL,
  187. )
  188. }
  189. func getHelmFailedMessage(opts *NotifyOpts) string {
  190. return fmt.Sprintf(
  191. ":x: Your application %s failed to deploy on Porter. <%s|View the status here.>",
  192. "`"+opts.Name+"`",
  193. opts.URL,
  194. )
  195. }
  196. func getFailedInfoMessage(opts *NotifyOpts) string {
  197. info := opts.Info
  198. // TODO: this casing is quite ugly and looks for particular types of API server
  199. // errors, otherwise it truncates the error message to 200 characters. This should
  200. // handle the errors more gracefully.
  201. if strings.Contains(info, "Invalid value:") {
  202. errArr := strings.Split(info, "Invalid value:")
  203. // look for "unmarshalerDecoder" error
  204. if strings.Contains(info, "unmarshalerDecoder") {
  205. udArr := strings.Split(info, "unmarshalerDecoder:")
  206. info = errArr[0] + udArr[1]
  207. } else {
  208. info = errArr[0] + "..."
  209. }
  210. } else if len(info) > 200 {
  211. info = info[0:200] + "..."
  212. }
  213. return fmt.Sprintf("```\n%s\n```", info)
  214. }