Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## main / (unreleased)

* [FEATURE] Add `jsmops` (Jira Service Management Operations) receiver as the migration target for the deprecated `opsgenie` receiver.
* [CHANGE] notify: The `reason` label on `alertmanager_notifications_failed_total` now distinguishes `authError` (HTTP 401/403) and `rateLimited` (HTTP 429) from the generic `clientError`. Dashboards/alerts matching `reason="clientError"` for these codes must be updated.
* [ENHANCEMENT] notify: The discord and webex integrations now report a failure `reason` on `alertmanager_notifications_failed_total`.
* [BUGFIX] webhook: Keep custom `payload` string values verbatim instead of reinterpreting JSON leaves that look like YAML (e.g. values ending with a colon). #5302
Expand Down
20 changes: 20 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import (
"github.com/prometheus/alertmanager/notify/discord"
"github.com/prometheus/alertmanager/notify/incidentio"
"github.com/prometheus/alertmanager/notify/jira"
"github.com/prometheus/alertmanager/notify/jsmops"
"github.com/prometheus/alertmanager/notify/mattermost"
"github.com/prometheus/alertmanager/notify/msteams"
"github.com/prometheus/alertmanager/notify/msteamsv2"
Expand Down Expand Up @@ -180,6 +181,9 @@ func resolveFilepaths(baseDir string, cfg *Config) {
for _, cfg := range receiver.OpsGenieConfigs {
cfg.HTTPConfig.SetDirectory(baseDir)
}
for _, cfg := range receiver.JSMOpsConfigs {
cfg.HTTPConfig.SetDirectory(baseDir)
}
for _, cfg := range receiver.PagerdutyConfigs {
cfg.HTTPConfig.SetDirectory(baseDir)
}
Expand Down Expand Up @@ -494,6 +498,19 @@ func (c *Config) UnmarshalYAML(unmarshal func(any) error) error {
return errors.New("no global OpsGenie API Key set either inline or in a file")
}
}
for _, jc := range rcv.JSMOpsConfigs {
if jc == nil {
return errors.New("missing cloud_id in jsmops_config")
}
jc.HTTPConfig = cmp.Or(jc.HTTPConfig, c.Global.HTTPConfig)
jc.APIURL = cmp.Or(jc.APIURL, c.Global.JSMOpsAPIURL)
if jc.APIURL == nil {
return errors.New("no global JSM Ops URL set")
}
if !strings.HasSuffix(jc.APIURL.Path, "/") {
jc.APIURL.Path += "/"
}
}
for _, wcc := range rcv.WechatConfigs {
if wcc == nil {
wcc = &WechatConfig{}
Expand Down Expand Up @@ -738,6 +755,7 @@ func DefaultGlobalConfig() GlobalConfig {
SMTPTLSConfig: &defaultSMTPTLSConfig,
PagerdutyURL: amcommoncfg.MustParseURL("https://events.pagerduty.com/v2/enqueue"),
OpsGenieAPIURL: amcommoncfg.MustParseURL("https://api.opsgenie.com/"),
JSMOpsAPIURL: amcommoncfg.MustParseURL("https://api.atlassian.com/jsm/ops/api/"),
WeChatAPIURL: amcommoncfg.MustParseURL("https://qyapi.weixin.qq.com/cgi-bin/"),
VictorOpsAPIURL: amcommoncfg.MustParseURL("https://alert.victorops.com/integrations/generic/20131114/alert/"),
TelegramAPIUrl: amcommoncfg.MustParseURL("https://api.telegram.org"),
Expand Down Expand Up @@ -845,6 +863,7 @@ type GlobalConfig struct {
OpsGenieAPIURL *amcommoncfg.URL `yaml:"opsgenie_api_url,omitempty" json:"opsgenie_api_url,omitempty"`
OpsGenieAPIKey commoncfg.Secret `yaml:"opsgenie_api_key,omitempty" json:"opsgenie_api_key,omitempty"`
OpsGenieAPIKeyFile string `yaml:"opsgenie_api_key_file,omitempty" json:"opsgenie_api_key_file,omitempty"`
JSMOpsAPIURL *amcommoncfg.URL `yaml:"jsmops_api_url,omitempty" json:"jsmops_api_url,omitempty"`
WeChatAPIURL *amcommoncfg.URL `yaml:"wechat_api_url,omitempty" json:"wechat_api_url,omitempty"`
WeChatAPISecret commoncfg.Secret `yaml:"wechat_api_secret,omitempty" json:"wechat_api_secret,omitempty"`
WeChatAPISecretFile string `yaml:"wechat_api_secret_file,omitempty" json:"wechat_api_secret_file,omitempty"`
Expand Down Expand Up @@ -972,6 +991,7 @@ type Receiver struct {
SlackConfigs []*SlackConfig `yaml:"slack_configs,omitempty" json:"slack_configs,omitempty"`
WebhookConfigs []*webhook.WebhookConfig `yaml:"webhook_configs,omitempty" json:"webhook_configs,omitempty"`
OpsGenieConfigs []*opsgenie.OpsGenieConfig `yaml:"opsgenie_configs,omitempty" json:"opsgenie_configs,omitempty"`
JSMOpsConfigs []*jsmops.JSMOpsConfig `yaml:"jsmops_configs,omitempty" json:"jsmops_configs,omitempty"`
WechatConfigs []*WechatConfig `yaml:"wechat_configs,omitempty" json:"wechat_configs,omitempty"`
PushoverConfigs []*PushoverConfig `yaml:"pushover_configs,omitempty" json:"pushover_configs,omitempty"`
VictorOpsConfigs []*VictorOpsConfig `yaml:"victorops_configs,omitempty" json:"victorops_configs,omitempty"`
Expand Down
17 changes: 17 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -766,6 +766,7 @@ func TestEmptyFieldsAndRegex(t *testing.T) {
SMTPRequireTLS: true,
PagerdutyURL: amcommoncfg.MustParseURL("https://events.pagerduty.com/v2/enqueue"),
OpsGenieAPIURL: amcommoncfg.MustParseURL("https://api.opsgenie.com/"),
JSMOpsAPIURL: amcommoncfg.MustParseURL("https://api.atlassian.com/jsm/ops/api/"),
WeChatAPIURL: amcommoncfg.MustParseURL("https://qyapi.weixin.qq.com/cgi-bin/"),
VictorOpsAPIURL: amcommoncfg.MustParseURL("https://alert.victorops.com/integrations/generic/20131114/alert/"),
TelegramAPIUrl: amcommoncfg.MustParseURL("https://api.telegram.org"),
Expand Down Expand Up @@ -920,6 +921,10 @@ receivers:
integration: "slack_configs",
expectedErr: "no Slack API URL nor App token set either inline or in a file",
},
{
integration: "jsmops_configs",
expectedErr: "missing cloud_id in jsmops_config",
},
{
integration: "opsgenie_configs",
expectedErr: "no global OpsGenie API Key set either inline or in a file",
Expand Down Expand Up @@ -1197,6 +1202,18 @@ func TestOpsGenieDeprecatedTeamSpecified(t *testing.T) {
}
}

func TestJSMOpsDefaultAPIURL(t *testing.T) {
conf, err := LoadFile("testdata/conf.jsmops-default-apiurl.yml")
require.NoError(t, err)

globalURL := conf.Global.JSMOpsAPIURL
// Receiver 0 omits api_url so it should inherit the global URL.
require.Equal(t, globalURL.String(), conf.Receivers[0].JSMOpsConfigs[0].APIURL.String())
// Receiver 1 overrides api_url so it should NOT equal the global URL.
require.NotEqual(t, globalURL.String(), conf.Receivers[1].JSMOpsConfigs[0].APIURL.String())
require.Equal(t, "https://override.jsmops.example.com/", conf.Receivers[1].JSMOpsConfigs[0].APIURL.String())
}

func TestSlackBothAPIURLAndFile(t *testing.T) {
_, err := LoadFile("testdata/conf.slack-both-file-and-url.yml")
if err == nil {
Expand Down
4 changes: 4 additions & 0 deletions config/receiver/receiver.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"github.com/prometheus/alertmanager/notify/email"
"github.com/prometheus/alertmanager/notify/incidentio"
"github.com/prometheus/alertmanager/notify/jira"
"github.com/prometheus/alertmanager/notify/jsmops"
"github.com/prometheus/alertmanager/notify/mattermost"
"github.com/prometheus/alertmanager/notify/msteams"
"github.com/prometheus/alertmanager/notify/msteamsv2"
Expand Down Expand Up @@ -75,6 +76,9 @@ func BuildReceiverIntegrations(nc config.Receiver, tmpl *template.Template, logg
for i, c := range nc.OpsGenieConfigs {
add("opsgenie", i, c, func(l *slog.Logger) (notify.Notifier, error) { return opsgenie.New(c, tmpl, l, httpOpts...) })
}
for i, c := range nc.JSMOpsConfigs {
add("jsmops", i, c, func(l *slog.Logger) (notify.Notifier, error) { return jsmops.New(c, tmpl, l, httpOpts...) })
}
for i, c := range nc.WechatConfigs {
add("wechat", i, c, func(l *slog.Logger) (notify.Notifier, error) { return wechat.New(c, tmpl, l, httpOpts...) })
}
Expand Down
28 changes: 28 additions & 0 deletions config/testdata/conf.jsmops-default-apiurl.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
global:
jsmops_api_url: https://custom.jsmops.example.com/
route:
group_by: ['alertname', 'cluster', 'service']
group_wait: 30s
group_interval: 5m
repeat_interval: 3h
receiver: team-Y-jsmops
routes:
- match:
service: foo
receiver: team-X-jsmops
receivers:
- name: 'team-X-jsmops'
jsmops_configs:
- cloud_id: 'cloud-id-1'
http_config:
basic_auth:
username: user1
password: pass1
- name: 'team-Y-jsmops'
jsmops_configs:
- cloud_id: 'cloud-id-2'
api_url: https://override.jsmops.example.com/
http_config:
basic_auth:
username: user2
password: pass2
83 changes: 83 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ global:
[ opsgenie_api_key: <secret> ]
[ opsgenie_api_key_file: <filepath> ]
[ opsgenie_api_url: <string> | default = "https://api.opsgenie.com/" ]
[ jsmops_api_url: <string> | default = "https://api.atlassian.com/jsm/ops/api/" ]
[ rocketchat_api_url: <string> | default = "https://open.rocket.chat/" ]
[ rocketchat_token: <secret> ]
[ rocketchat_token_file: <filepath> ]
Expand Down Expand Up @@ -842,6 +843,8 @@ jira_configs:
[ - <jira_config>, ... ]
opsgenie_configs:
[ - <opsgenie_config>, ... ]
jsmops_configs:
[ - <jsmops_config>, ... ]
pagerduty_configs:
[ - <pagerduty_config>, ... ]
incidentio_configs:
Expand Down Expand Up @@ -1460,6 +1463,86 @@ responders:
type: <tmpl_string>
```

### `<jsmops_config>`

JSM Ops (Jira Service Management Operations) notifications are sent via the
[JSM Ops REST API](https://developer.atlassian.com/cloud/jira/service-desk-ops/rest/v2/intro/).
This receiver is the migration target for the deprecated `opsgenie` receiver.

Authentication is configured entirely via the `http_config` block (using `basic_auth`
with an Atlassian account email and API token, or `oauth2`). There is no `api_key` field.

```yaml
# Whether to notify about resolved alerts.
[ send_resolved: <boolean> | default = true ]

# The Atlassian Cloud ID used as a path variable in the JSM Ops API URL.
# This is a required field.
cloud_id: <string>

# The base URL for JSM Ops API requests.
[ api_url: <string> | default = global.jsmops_api_url ]

# Alert text limited to 130 characters.
[ message: <tmpl_string> | default = '{{ template "jsmops.default.message" . }}' ]

# A description of the alert.
[ description: <tmpl_string> | default = '{{ template "jsmops.default.description" . }}' ]

# A backlink to the sender of the notification.
[ source: <tmpl_string> | default = '{{ template "jsmops.default.source" . }}' ]

# A set of arbitrary key/value pairs that provide further detail
# about the alert.
# All common labels are included as details by default.
[ details: { <string>: <tmpl_string>, ... } ]

# List of responders responsible for notifications.
responders:
[ - <responder> ... ]

# Comma separated list of tags attached to the notifications.
[ tags: <tmpl_string> ]

# Additional alert note.
[ note: <tmpl_string> ]

# Priority level of alert. Possible values are P1, P2, P3, P4, and P5.
[ priority: <tmpl_string> ]

# Whether to update message and description of the alert in JSM Ops if it already exists.
# By default, the alert is never updated in JSM Ops, the new message only appears in activity log.
[ update_alerts: <boolean> | default = false ]

# Optional field that can be used to specify which domain alert is related to.
[ entity: <tmpl_string> ]

# Comma separated list of actions that will be available for the alert.
[ actions: <tmpl_string> ]

# The HTTP client's configuration. You must use this configuration to supply
# authentication credentials. For Atlassian Cloud, use basic_auth with the
# Atlassian account email as the username and an API token as the password.
# OAuth 2.0 (3LO) is also supported via the oauth2 field.
[ http_config: <http_config> | default = global.http_config ]
```

The `responders` field uses the same format as `<opsgenie_config>`:

```yaml
# Exactly one of these fields should be defined.
[ id: <tmpl_string> ]
[ name: <tmpl_string> ]
[ username: <tmpl_string> ]

# One of `team`, `teams`, `user`, `escalation` or `schedule`.
#
# The `teams` responder is configured using the `name` field above.
# This field can contain a comma-separated list of team names.
# If the list is empty, no responders are configured.
type: <tmpl_string>
```

### `<pagerduty_config>`

PagerDuty notifications are sent via the [PagerDuty API](https://developer.pagerduty.com/documentation/integration/events).
Expand Down
109 changes: 109 additions & 0 deletions notify/jsmops/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright The Prometheus Authors
// 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 jsmops

import (
"errors"
"fmt"
"regexp"
"strings"

commoncfg "github.com/prometheus/common/config"

amcommoncfg "github.com/prometheus/alertmanager/config/common"
)

// DefaultJSMOpsConfig defines default values for JSM Ops configurations.
var DefaultJSMOpsConfig = JSMOpsConfig{
NotifierConfig: amcommoncfg.NotifierConfig{
VSendResolved: true,
},
Message: `{{ template "jsmops.default.message" . }}`,
Description: `{{ template "jsmops.default.description" . }}`,
Source: `{{ template "jsmops.default.source" . }}`,
}

// jsmopsValidTypesRe defines the accepted responder types for JSM Ops.
// Per swagger.v3.json the API enum is: team, user, escalation, schedule.
// The synthetic "teams" value is a config convenience that splits a
// comma-separated name into multiple "team" responders at send time.
const jsmopsValidTypesRe = `^(team|teams|user|escalation|schedule)$`

var jsmopsTypeMatcher = regexp.MustCompile(jsmopsValidTypesRe)

// JSMOpsConfig configures notifications via Jira Service Management Operations.
type JSMOpsConfig struct {
amcommoncfg.NotifierConfig `yaml:",inline" json:",inline"`

HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"`

// CloudID is the required Atlassian cloud ID used as a path variable in the
// JSM Ops API URL (https://api.atlassian.com/jsm/ops/api/{cloudId}/...).
CloudID string `yaml:"cloud_id,omitempty" json:"cloud_id,omitempty"`
APIURL *amcommoncfg.URL `yaml:"api_url,omitempty" json:"api_url,omitempty"`
Message string `yaml:"message,omitempty" json:"message,omitempty"`
Description string `yaml:"description,omitempty" json:"description,omitempty"`
Source string `yaml:"source,omitempty" json:"source,omitempty"`
Details map[string]string `yaml:"details,omitempty" json:"details,omitempty"`
Entity string `yaml:"entity,omitempty" json:"entity,omitempty"`
Responders []JSMOpsConfigResponder `yaml:"responders,omitempty" json:"responders,omitempty"`
Actions string `yaml:"actions,omitempty" json:"actions,omitempty"`
Tags string `yaml:"tags,omitempty" json:"tags,omitempty"`
Note string `yaml:"note,omitempty" json:"note,omitempty"`
Priority string `yaml:"priority,omitempty" json:"priority,omitempty"`
UpdateAlerts bool `yaml:"update_alerts,omitempty" json:"update_alerts,omitempty"`
}

// UnmarshalYAML implements the yaml.Unmarshaler interface.
func (c *JSMOpsConfig) UnmarshalYAML(unmarshal func(any) error) error {
*c = DefaultJSMOpsConfig
type plain JSMOpsConfig
if err := unmarshal((*plain)(c)); err != nil {
return err
}

if c.CloudID == "" {
return errors.New("missing cloud_id in jsmops_config")
}

for i, r := range c.Responders {
if r.ID == "" && r.Username == "" && r.Name == "" {
return fmt.Errorf("jsmOpsConfig responder %v has to have at least one of id, username or name specified", r)
}

isTemplated, err := amcommoncfg.ContainsTemplating(r.Type)
if err != nil {
return fmt.Errorf("jsmOpsConfig responder %v type contains invalid template syntax: %w", r, err)
}
if !isTemplated {
c.Responders[i].Type = strings.ToLower(r.Type)
if !jsmopsTypeMatcher.MatchString(c.Responders[i].Type) {
return fmt.Errorf("jsmOpsConfig responder %v type does not match valid options %s", r, jsmopsValidTypesRe)
}
}
}

return nil
}

// JSMOpsConfigResponder describes a single JSM Ops responder.
type JSMOpsConfigResponder struct {
// One of those 3 should be filled.
ID string `yaml:"id,omitempty" json:"id,omitempty"`
Name string `yaml:"name,omitempty" json:"name,omitempty"`
Username string `yaml:"username,omitempty" json:"username,omitempty"`

// Type is the responder type (team, user, escalation, schedule, etc.).
Type string `yaml:"type,omitempty" json:"type,omitempty"`
}
Loading