344 lines
9.8 KiB
Go
344 lines
9.8 KiB
Go
// Copyright 2023 Prometheus Team
|
|
// 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 jira
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
commoncfg "github.com/prometheus/common/config"
|
|
"github.com/prometheus/common/model"
|
|
"github.com/trivago/tgo/tcontainer"
|
|
|
|
"github.com/prometheus/alertmanager/config"
|
|
"github.com/prometheus/alertmanager/notify"
|
|
"github.com/prometheus/alertmanager/template"
|
|
"github.com/prometheus/alertmanager/types"
|
|
)
|
|
|
|
const (
|
|
maxSummaryLenRunes = 255
|
|
maxDescriptionLenRunes = 32767
|
|
)
|
|
|
|
// Notifier implements a Notifier for JIRA notifications.
|
|
type Notifier struct {
|
|
conf *config.JiraConfig
|
|
tmpl *template.Template
|
|
logger *slog.Logger
|
|
client *http.Client
|
|
retrier *notify.Retrier
|
|
}
|
|
|
|
func New(c *config.JiraConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
|
client, err := commoncfg.NewClientFromConfig(*c.HTTPConfig, "jira", httpOpts...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &Notifier{
|
|
conf: c,
|
|
tmpl: t,
|
|
logger: l,
|
|
client: client,
|
|
retrier: ¬ify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
|
|
}, nil
|
|
}
|
|
|
|
// Notify implements the Notifier interface.
|
|
func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
|
key, err := notify.ExtractGroupKey(ctx)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
logger := n.logger.With("group_key", key.String())
|
|
|
|
var (
|
|
alerts = types.Alerts(as...)
|
|
|
|
tmplTextErr error
|
|
data = notify.GetTemplateData(ctx, n.tmpl, as, logger)
|
|
tmplText = notify.TmplText(n.tmpl, data, &tmplTextErr)
|
|
tmplTextFunc = func(tmpl string) (string, error) {
|
|
return tmplText(tmpl), tmplTextErr
|
|
}
|
|
|
|
path = "issue"
|
|
method = http.MethodPost
|
|
)
|
|
|
|
existingIssue, shouldRetry, err := n.searchExistingIssue(ctx, logger, key.Hash(), alerts.HasFiring())
|
|
if err != nil {
|
|
return shouldRetry, fmt.Errorf("failed to look up existing issues: %w", err)
|
|
}
|
|
|
|
if existingIssue == nil {
|
|
// Do not create new issues for resolved alerts
|
|
if alerts.Status() == model.AlertResolved {
|
|
return false, nil
|
|
}
|
|
|
|
logger.Debug("create new issue")
|
|
} else {
|
|
path = "issue/" + existingIssue.Key
|
|
method = http.MethodPut
|
|
|
|
logger.Debug("updating existing issue", "issue_key", existingIssue.Key)
|
|
}
|
|
|
|
requestBody, err := n.prepareIssueRequestBody(ctx, logger, key.Hash(), tmplTextFunc)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
_, shouldRetry, err = n.doAPIRequest(ctx, method, path, requestBody)
|
|
if err != nil {
|
|
return shouldRetry, fmt.Errorf("failed to %s request to %q: %w", method, path, err)
|
|
}
|
|
|
|
return n.transitionIssue(ctx, logger, existingIssue, alerts.HasFiring())
|
|
}
|
|
|
|
func (n *Notifier) prepareIssueRequestBody(ctx context.Context, logger *slog.Logger, groupID string, tmplTextFunc templateFunc) (issue, error) {
|
|
summary, err := tmplTextFunc(n.conf.Summary)
|
|
if err != nil {
|
|
return issue{}, fmt.Errorf("summary template: %w", err)
|
|
}
|
|
|
|
// Recursively convert any maps to map[string]interface{}, filtering out all non-string keys, so the json encoder
|
|
// doesn't blow up when marshaling JIRA requests.
|
|
fieldsWithStringKeys, err := tcontainer.ConvertToMarshalMap(n.conf.Fields, func(v string) string { return v })
|
|
if err != nil {
|
|
return issue{}, fmt.Errorf("convertToMarshalMap: %w", err)
|
|
}
|
|
|
|
summary, truncated := notify.TruncateInRunes(summary, maxSummaryLenRunes)
|
|
if truncated {
|
|
logger.Warn("Truncated summary", "max_runes", maxSummaryLenRunes)
|
|
}
|
|
|
|
requestBody := issue{Fields: &issueFields{
|
|
Project: &issueProject{Key: n.conf.Project},
|
|
Issuetype: &idNameValue{Name: n.conf.IssueType},
|
|
Summary: summary,
|
|
Labels: make([]string, 0, len(n.conf.Labels)+1),
|
|
Fields: fieldsWithStringKeys,
|
|
}}
|
|
|
|
issueDescriptionString, err := tmplTextFunc(n.conf.Description)
|
|
if err != nil {
|
|
return issue{}, fmt.Errorf("description template: %w", err)
|
|
}
|
|
|
|
issueDescriptionString, truncated = notify.TruncateInRunes(issueDescriptionString, maxDescriptionLenRunes)
|
|
if truncated {
|
|
logger.Warn("Truncated description", "max_runes", maxDescriptionLenRunes)
|
|
}
|
|
|
|
requestBody.Fields.Description = issueDescriptionString
|
|
if strings.HasSuffix(n.conf.APIURL.Path, "/3") {
|
|
var issueDescription any
|
|
if err := json.Unmarshal([]byte(issueDescriptionString), &issueDescription); err != nil {
|
|
return issue{}, fmt.Errorf("description unmarshaling: %w", err)
|
|
}
|
|
requestBody.Fields.Description = issueDescription
|
|
}
|
|
|
|
for i, label := range n.conf.Labels {
|
|
label, err = tmplTextFunc(label)
|
|
if err != nil {
|
|
return issue{}, fmt.Errorf("labels[%d] template: %w", i, err)
|
|
}
|
|
requestBody.Fields.Labels = append(requestBody.Fields.Labels, label)
|
|
}
|
|
requestBody.Fields.Labels = append(requestBody.Fields.Labels, fmt.Sprintf("ALERT{%s}", groupID))
|
|
sort.Strings(requestBody.Fields.Labels)
|
|
|
|
priority, err := tmplTextFunc(n.conf.Priority)
|
|
if err != nil {
|
|
return issue{}, fmt.Errorf("priority template: %w", err)
|
|
}
|
|
|
|
if priority != "" {
|
|
requestBody.Fields.Priority = &idNameValue{Name: priority}
|
|
}
|
|
|
|
return requestBody, nil
|
|
}
|
|
|
|
func (n *Notifier) searchExistingIssue(ctx context.Context, logger *slog.Logger, groupID string, firing bool) (*issue, bool, error) {
|
|
jql := strings.Builder{}
|
|
|
|
if n.conf.WontFixResolution != "" {
|
|
jql.WriteString(fmt.Sprintf(`resolution != %q and `, n.conf.WontFixResolution))
|
|
}
|
|
|
|
// If the group is firing, do not search for closed issues unless a reopen transition is defined.
|
|
if firing {
|
|
if n.conf.ReopenTransition == "" {
|
|
jql.WriteString(`statusCategory != Done and `)
|
|
}
|
|
} else {
|
|
reopenDuration := int64(time.Duration(n.conf.ReopenDuration).Minutes())
|
|
if reopenDuration != 0 {
|
|
jql.WriteString(fmt.Sprintf(`(resolutiondate is EMPTY OR resolutiondate >= -%dm) and `, reopenDuration))
|
|
}
|
|
}
|
|
|
|
alertLabel := fmt.Sprintf("ALERT{%s}", groupID)
|
|
jql.WriteString(fmt.Sprintf(`project=%q and labels=%q order by status ASC,resolutiondate DESC`, n.conf.Project, alertLabel))
|
|
|
|
requestBody := issueSearch{
|
|
JQL: jql.String(),
|
|
MaxResults: 2,
|
|
Fields: []string{"status"},
|
|
Expand: []string{},
|
|
}
|
|
|
|
logger.Debug("search for recent issues", "jql", requestBody.JQL)
|
|
|
|
responseBody, shouldRetry, err := n.doAPIRequest(ctx, http.MethodPost, "search", requestBody)
|
|
if err != nil {
|
|
return nil, shouldRetry, fmt.Errorf("HTTP request to JIRA API: %w", err)
|
|
}
|
|
|
|
var issueSearchResult issueSearchResult
|
|
err = json.Unmarshal(responseBody, &issueSearchResult)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
if issueSearchResult.Total == 0 {
|
|
logger.Debug("found no existing issue")
|
|
return nil, false, nil
|
|
}
|
|
|
|
if issueSearchResult.Total > 1 {
|
|
logger.Warn("more than one issue matched, selecting the most recently resolved", "selected_issue", issueSearchResult.Issues[0].Key)
|
|
}
|
|
|
|
return &issueSearchResult.Issues[0], false, nil
|
|
}
|
|
|
|
func (n *Notifier) getIssueTransitionByName(ctx context.Context, issueKey, transitionName string) (string, bool, error) {
|
|
path := fmt.Sprintf("issue/%s/transitions", issueKey)
|
|
|
|
responseBody, shouldRetry, err := n.doAPIRequest(ctx, http.MethodGet, path, nil)
|
|
if err != nil {
|
|
return "", shouldRetry, err
|
|
}
|
|
|
|
var issueTransitions issueTransitions
|
|
err = json.Unmarshal(responseBody, &issueTransitions)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
|
|
for _, issueTransition := range issueTransitions.Transitions {
|
|
if issueTransition.Name == transitionName {
|
|
return issueTransition.ID, false, nil
|
|
}
|
|
}
|
|
|
|
return "", false, fmt.Errorf("can't find transition %s for issue %s", transitionName, issueKey)
|
|
}
|
|
|
|
func (n *Notifier) transitionIssue(ctx context.Context, logger *slog.Logger, i *issue, firing bool) (bool, error) {
|
|
if i == nil || i.Key == "" || i.Fields == nil || i.Fields.Status == nil {
|
|
return false, nil
|
|
}
|
|
|
|
var transition string
|
|
if firing {
|
|
if i.Fields.Status.StatusCategory.Key != "done" {
|
|
return false, nil
|
|
}
|
|
|
|
transition = n.conf.ReopenTransition
|
|
} else {
|
|
if i.Fields.Status.StatusCategory.Key == "done" {
|
|
return false, nil
|
|
}
|
|
|
|
transition = n.conf.ResolveTransition
|
|
}
|
|
|
|
transitionID, shouldRetry, err := n.getIssueTransitionByName(ctx, i.Key, transition)
|
|
if err != nil {
|
|
return shouldRetry, err
|
|
}
|
|
|
|
requestBody := issue{
|
|
Transition: &idNameValue{
|
|
ID: transitionID,
|
|
},
|
|
}
|
|
|
|
path := fmt.Sprintf("issue/%s/transitions", i.Key)
|
|
|
|
logger.Debug("transitions jira issue", "issue_key", i.Key, "transition", transition)
|
|
_, shouldRetry, err = n.doAPIRequest(ctx, http.MethodPost, path, requestBody)
|
|
|
|
return shouldRetry, err
|
|
}
|
|
|
|
func (n *Notifier) doAPIRequest(ctx context.Context, method, path string, requestBody any) ([]byte, bool, error) {
|
|
var body io.Reader
|
|
if requestBody != nil {
|
|
var buf bytes.Buffer
|
|
if err := json.NewEncoder(&buf).Encode(requestBody); err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
body = &buf
|
|
}
|
|
|
|
url := n.conf.APIURL.JoinPath(path)
|
|
req, err := http.NewRequestWithContext(ctx, method, url.String(), body)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept-Language", "en")
|
|
|
|
resp, err := n.client.Do(req)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
defer notify.Drain(resp)
|
|
|
|
responseBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
shouldRetry, err := n.retrier.Check(resp.StatusCode, bytes.NewReader(responseBody))
|
|
if err != nil {
|
|
return nil, shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err)
|
|
}
|
|
|
|
return responseBody, false, nil
|
|
}
|