notifier.go 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  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. Version int
  39. }
  40. type SlackNotifier struct {
  41. slackInts []*integrations.SlackIntegration
  42. Config *types.NotificationConfig
  43. }
  44. func NewSlackNotifier(conf *types.NotificationConfig, slackInts ...*integrations.SlackIntegration) Notifier {
  45. return &SlackNotifier{
  46. slackInts: slackInts,
  47. Config: conf,
  48. }
  49. }
  50. type SlackPayload struct {
  51. Blocks []*SlackBlock `json:"blocks"`
  52. }
  53. type SlackBlock struct {
  54. Type string `json:"type"`
  55. Text *SlackText `json:"text,omitempty"`
  56. }
  57. type SlackText struct {
  58. Type string `json:"type"`
  59. Text string `json:"text"`
  60. }
  61. func (s *SlackNotifier) Notify(opts *NotifyOpts) error {
  62. if s.Config != nil {
  63. if !s.Config.Enabled {
  64. return nil
  65. }
  66. if opts.Status == StatusHelmDeployed && !s.Config.Success {
  67. return nil
  68. }
  69. if opts.Status == StatusPodCrashed && !s.Config.Failure {
  70. return nil
  71. }
  72. if opts.Status == StatusHelmFailed && !s.Config.Failure {
  73. return nil
  74. }
  75. }
  76. // we create a basic payload as a fallback if the detailed payload with "info" fails, due to
  77. // marshaling errors on the Slack API side.
  78. blocks, basicBlocks := getSlackBlocks(opts)
  79. slackPayload := &SlackPayload{
  80. Blocks: blocks,
  81. }
  82. basicSlackPayload := &SlackPayload{
  83. Blocks: basicBlocks,
  84. }
  85. basicPayload, err := json.Marshal(basicSlackPayload)
  86. if err != nil {
  87. return err
  88. }
  89. payload, err := json.Marshal(slackPayload)
  90. if err != nil {
  91. return err
  92. }
  93. basicReqBody := bytes.NewReader(basicPayload)
  94. reqBody := bytes.NewReader(payload)
  95. client := &http.Client{
  96. Timeout: time.Second * 5,
  97. }
  98. for _, slackInt := range s.slackInts {
  99. resp, err := client.Post(string(slackInt.Webhook), "application/json", reqBody)
  100. if err != nil || resp.StatusCode != 200 {
  101. client.Post(string(slackInt.Webhook), "application/json", basicReqBody)
  102. }
  103. }
  104. return nil
  105. }
  106. func getSlackBlocks(opts *NotifyOpts) ([]*SlackBlock, []*SlackBlock) {
  107. res := []*SlackBlock{}
  108. if opts.Status == StatusHelmDeployed || opts.Status == StatusHelmFailed {
  109. res = append(res, getHelmMessageBlock(opts))
  110. } else if opts.Status == StatusPodCrashed {
  111. res = append(res, getPodCrashedMessageBlock(opts))
  112. }
  113. res = append(
  114. res,
  115. getDividerBlock(),
  116. getMarkdownBlock(fmt.Sprintf("*Name:* %s", "`"+opts.Name+"`")),
  117. getMarkdownBlock(fmt.Sprintf("*Namespace:* %s", "`"+opts.Namespace+"`")),
  118. )
  119. if opts.Status == StatusHelmDeployed || opts.Status == StatusHelmFailed {
  120. res = append(res, getMarkdownBlock(fmt.Sprintf("*Version:* %d", opts.Version)))
  121. }
  122. basicRes := res
  123. infoBlock := getInfoBlock(opts)
  124. if infoBlock != nil {
  125. res = append(res, infoBlock)
  126. }
  127. return res, basicRes
  128. }
  129. func getDividerBlock() *SlackBlock {
  130. return &SlackBlock{
  131. Type: "divider",
  132. }
  133. }
  134. func getMarkdownBlock(md string) *SlackBlock {
  135. return &SlackBlock{
  136. Type: "section",
  137. Text: &SlackText{
  138. Type: "mrkdwn",
  139. Text: md,
  140. },
  141. }
  142. }
  143. func getHelmMessageBlock(opts *NotifyOpts) *SlackBlock {
  144. var md string
  145. switch opts.Status {
  146. case StatusHelmDeployed:
  147. md = getHelmSuccessMessage(opts)
  148. case StatusHelmFailed:
  149. md = getHelmFailedMessage(opts)
  150. }
  151. return getMarkdownBlock(md)
  152. }
  153. func getPodCrashedMessageBlock(opts *NotifyOpts) *SlackBlock {
  154. md := fmt.Sprintf(
  155. ":x: Your application %s crashed on Porter. <%s|View the application.>",
  156. "`"+opts.Name+"`",
  157. opts.URL,
  158. )
  159. return getMarkdownBlock(md)
  160. }
  161. func getInfoBlock(opts *NotifyOpts) *SlackBlock {
  162. var md string
  163. switch opts.Status {
  164. case StatusHelmFailed:
  165. md = getFailedInfoMessage(opts)
  166. case StatusPodCrashed:
  167. md = getFailedInfoMessage(opts)
  168. default:
  169. return nil
  170. }
  171. return getMarkdownBlock(md)
  172. }
  173. func getHelmSuccessMessage(opts *NotifyOpts) string {
  174. return fmt.Sprintf(
  175. ":rocket: Your application %s was successfully updated on Porter! <%s|View the new release.>",
  176. "`"+opts.Name+"`",
  177. opts.URL,
  178. )
  179. }
  180. func getHelmFailedMessage(opts *NotifyOpts) string {
  181. return fmt.Sprintf(
  182. ":x: Your application %s failed to deploy on Porter. <%s|View the status here.>",
  183. "`"+opts.Name+"`",
  184. opts.URL,
  185. )
  186. }
  187. func getFailedInfoMessage(opts *NotifyOpts) string {
  188. info := opts.Info
  189. // TODO: this casing is quite ugly and looks for particular types of API server
  190. // errors, otherwise it truncates the error message to 200 characters. This should
  191. // handle the errors more gracefully.
  192. if strings.Contains(info, "Invalid value:") {
  193. errArr := strings.Split(info, "Invalid value:")
  194. // look for "unmarshalerDecoder" error
  195. if strings.Contains(info, "unmarshalerDecoder") {
  196. udArr := strings.Split(info, "unmarshalerDecoder:")
  197. info = errArr[0] + udArr[1]
  198. } else {
  199. info = errArr[0] + "..."
  200. }
  201. } else if len(info) > 200 {
  202. info = info[0:200] + "..."
  203. }
  204. return fmt.Sprintf("```\n%s\n```", info)
  205. }