Commit 3986aa16 authored by Dmytro Zaporozhets's avatar Dmytro Zaporozhets

Merge branch...

Merge branch '218560-allow-generic-endpoint-to-receive-alerts-from-external-prometheus' into 'master'

Allow generic endpoint to receive alerts from external Prometheus

See merge request gitlab-org/gitlab!32676
parents e657bc52 ac284638
...@@ -29,12 +29,22 @@ module Projects ...@@ -29,12 +29,22 @@ module Projects
end end
def notify_service def notify_service
Projects::Alerting::NotifyService notify_service_class.new(project, current_user, notification_payload)
.new(project, current_user, notification_payload) end
def notify_service_class
# We are tracking the consolidation of these services in
# https://gitlab.com/groups/gitlab-org/-/epics/3360
# to get rid of this workaround.
if Projects::Prometheus::Alerts::NotifyService.processable?(notification_payload)
Projects::Prometheus::Alerts::NotifyService
else
Projects::Alerting::NotifyService
end
end end
def notification_payload def notification_payload
params.permit![:notification] @notification_payload ||= params.permit![:notification]
end end
end end
end end
......
...@@ -7,9 +7,19 @@ module Projects ...@@ -7,9 +7,19 @@ module Projects
include Gitlab::Utils::StrongMemoize include Gitlab::Utils::StrongMemoize
include IncidentManagement::Settings include IncidentManagement::Settings
# This set of keys identifies a payload as a valid Prometheus
# payload and thus processable by this service. See also
# https://prometheus.io/docs/alerting/configuration/#webhook_config
REQUIRED_PAYLOAD_KEYS = %w[
version groupKey status receiver groupLabels commonLabels
commonAnnotations externalURL alerts
].to_set.freeze
SUPPORTED_VERSION = '4'
def execute(token) def execute(token)
return bad_request unless valid_payload_size? return bad_request unless valid_payload_size?
return unprocessable_entity unless valid_version? return unprocessable_entity unless self.class.processable?(params)
return unauthorized unless valid_alert_manager_token?(token) return unauthorized unless valid_alert_manager_token?(token)
process_prometheus_alerts process_prometheus_alerts
...@@ -20,6 +30,14 @@ module Projects ...@@ -20,6 +30,14 @@ module Projects
ServiceResponse.success ServiceResponse.success
end end
def self.processable?(params)
# Workaround for https://gitlab.com/gitlab-org/gitlab/-/issues/220496
return false unless params
REQUIRED_PAYLOAD_KEYS.subset?(params.keys.to_set) &&
params['version'] == SUPPORTED_VERSION
end
private private
def valid_payload_size? def valid_payload_size?
...@@ -42,12 +60,10 @@ module Projects ...@@ -42,12 +60,10 @@ module Projects
params['alerts'] params['alerts']
end end
def valid_version?
params['version'] == '4'
end
def valid_alert_manager_token?(token) def valid_alert_manager_token?(token)
valid_for_manual?(token) || valid_for_managed?(token) valid_for_manual?(token) ||
valid_for_alerts_endpoint?(token) ||
valid_for_managed?(token)
end end
def valid_for_manual?(token) def valid_for_manual?(token)
...@@ -61,6 +77,13 @@ module Projects ...@@ -61,6 +77,13 @@ module Projects
end end
end end
def valid_for_alerts_endpoint?(token)
return false unless project.alerts_service_activated?
# Here we are enforcing the existence of the token
compare_token(token, project.alerts_service.token)
end
def valid_for_managed?(token) def valid_for_managed?(token)
prometheus_application = available_prometheus_application(project) prometheus_application = available_prometheus_application(project)
return false unless prometheus_application return false unless prometheus_application
......
---
title: Allow generic endpoint to receive alerts from external Prometheus
merge_request: 32676
author:
type: added
...@@ -18,6 +18,9 @@ create an issue with the payload in the body of the issue. You can always ...@@ -18,6 +18,9 @@ create an issue with the payload in the body of the issue. You can always
The entire payload will be posted in the issue discussion as a comment The entire payload will be posted in the issue discussion as a comment
authored by the GitLab Alert Bot. authored by the GitLab Alert Bot.
NOTE: **Note**
In GitLab versions 13.1 and greater, you can configure [External Prometheus instances](prometheus.md#external-prometheus-instances) to use this endpoint.
## Setting up generic alerts ## Setting up generic alerts
To set up the generic alerts integration: To set up the generic alerts integration:
......
...@@ -981,6 +981,9 @@ receivers: ...@@ -981,6 +981,9 @@ receivers:
In order for GitLab to associate your alerts with an [environment](../../../ci/environments/index.md), you need to configure a `gitlab_environment_name` label on the alerts you set up in Prometheus. The value of this should match the name of your Environment in GitLab. In order for GitLab to associate your alerts with an [environment](../../../ci/environments/index.md), you need to configure a `gitlab_environment_name` label on the alerts you set up in Prometheus. The value of this should match the name of your Environment in GitLab.
NOTE: **Note**
In GitLab versions 13.1 and greater, you can configure your manually configured Prometheus server to use the [Generic alerts integration](generic_alerts.md).
### Taking action on incidents **(ULTIMATE)** ### Taking action on incidents **(ULTIMATE)**
>- [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/4925) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 11.11. >- [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/4925) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 11.11.
......
...@@ -3,6 +3,8 @@ ...@@ -3,6 +3,8 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Projects::Prometheus::Alerts::NotifyService do RSpec.describe Projects::Prometheus::Alerts::NotifyService do
include PrometheusHelpers
let_it_be(:project, reload: true) { create(:project) } let_it_be(:project, reload: true) { create(:project) }
let(:service) { described_class.new(project, nil, payload) } let(:service) { described_class.new(project, nil, payload) }
...@@ -94,7 +96,7 @@ RSpec.describe Projects::Prometheus::Alerts::NotifyService do ...@@ -94,7 +96,7 @@ RSpec.describe Projects::Prometheus::Alerts::NotifyService do
context 'with valid payload' do context 'with valid payload' do
let(:alert_firing) { create(:prometheus_alert, project: project) } let(:alert_firing) { create(:prometheus_alert, project: project) }
let(:alert_resolved) { create(:prometheus_alert, project: project) } let(:alert_resolved) { create(:prometheus_alert, project: project) }
let(:payload_raw) { payload_for(firing: [alert_firing], resolved: [alert_resolved]) } let(:payload_raw) { prometheus_alert_payload(firing: [alert_firing], resolved: [alert_resolved]) }
let(:payload) { ActionController::Parameters.new(payload_raw).permit! } let(:payload) { ActionController::Parameters.new(payload_raw).permit! }
let(:payload_alert_firing) { payload_raw['alerts'].first } let(:payload_alert_firing) { payload_raw['alerts'].first }
let(:token) { 'token' } let(:token) { 'token' }
...@@ -135,51 +137,4 @@ RSpec.describe Projects::Prometheus::Alerts::NotifyService do ...@@ -135,51 +137,4 @@ RSpec.describe Projects::Prometheus::Alerts::NotifyService do
end end
end end
end end
private
def payload_for(firing: [], resolved: [])
status = firing.any? ? 'firing' : 'resolved'
alerts = firing + resolved
alert_name = alerts.first.title
prometheus_metric_id = alerts.first.prometheus_metric_id.to_s
alerts_map = \
firing.map { |alert| map_alert_payload('firing', alert) } +
resolved.map { |alert| map_alert_payload('resolved', alert) }
# See https://prometheus.io/docs/alerting/configuration/#%3Cwebhook_config%3E
{
'version' => '4',
'receiver' => 'gitlab',
'status' => status,
'alerts' => alerts_map,
'groupLabels' => {
'alertname' => alert_name
},
'commonLabels' => {
'alertname' => alert_name,
'gitlab' => 'hook',
'gitlab_alert_id' => prometheus_metric_id
},
'commonAnnotations' => {},
'externalURL' => '',
'groupKey' => "{}:{alertname=\'#{alert_name}\'}"
}
end
def map_alert_payload(status, alert)
{
'status' => status,
'labels' => {
'alertname' => alert.title,
'gitlab' => 'hook',
'gitlab_alert_id' => alert.prometheus_metric_id.to_s
},
'annotations' => {},
'startsAt' => '2018-09-24T08:57:31.095725221Z',
'endsAt' => '0001-01-01T00:00:00Z',
'generatorURL' => 'http://prometheus-prometheus-server-URL'
}
end
end end
...@@ -7,85 +7,96 @@ RSpec.describe Projects::Alerting::NotificationsController do ...@@ -7,85 +7,96 @@ RSpec.describe Projects::Alerting::NotificationsController do
let_it_be(:environment) { create(:environment, project: project) } let_it_be(:environment) { create(:environment, project: project) }
describe 'POST #create' do describe 'POST #create' do
let(:service_response) { ServiceResponse.success }
let(:notify_service) { instance_double(Projects::Alerting::NotifyService, execute: service_response) }
around do |example| around do |example|
ForgeryProtection.with_forgery_protection { example.run } ForgeryProtection.with_forgery_protection { example.run }
end end
before do shared_examples 'process alert payload' do |notify_service_class|
allow(Projects::Alerting::NotifyService).to receive(:new).and_return(notify_service) let(:service_response) { ServiceResponse.success }
end let(:notify_service) { instance_double(notify_service_class, execute: service_response) }
def make_request(body = {}) before do
post :create, params: project_params, body: body.to_json, as: :json allow(notify_service_class).to receive(:new).and_return(notify_service)
end end
context 'when notification service succeeds' do def make_request
let(:payload) do post :create, params: project_params, body: payload.to_json, as: :json
{
title: 'Alert title',
hosts: 'https://gitlab.com'
}
end end
let(:permitted_params) { ActionController::Parameters.new(payload).permit! } context 'when notification service succeeds' do
let(:permitted_params) { ActionController::Parameters.new(payload).permit! }
it 'responds with ok' do it 'responds with ok' do
make_request make_request
expect(response).to have_gitlab_http_status(:ok) expect(response).to have_gitlab_http_status(:ok)
end end
it 'does not pass excluded parameters to the notify service' do it 'does not pass excluded parameters to the notify service' do
make_request(payload) make_request
expect(Projects::Alerting::NotifyService) expect(notify_service_class)
.to have_received(:new) .to have_received(:new)
.with(project, nil, permitted_params) .with(project, nil, permitted_params)
end
end end
end
context 'when notification service fails' do context 'when notification service fails' do
let(:service_response) { ServiceResponse.error(message: 'Unauthorized', http_status: :unauthorized) } let(:service_response) { ServiceResponse.error(message: 'Unauthorized', http_status: :unauthorized) }
it 'responds with the service response' do it 'responds with the service response' do
make_request make_request
expect(response).to have_gitlab_http_status(:unauthorized) expect(response).to have_gitlab_http_status(:unauthorized)
end
end end
end
context 'bearer token' do context 'bearer token' do
context 'when set' do context 'when set' do
it 'extracts bearer token' do it 'extracts bearer token' do
request.headers['HTTP_AUTHORIZATION'] = 'Bearer some token' request.headers['HTTP_AUTHORIZATION'] = 'Bearer some token'
expect(notify_service).to receive(:execute).with('some token') expect(notify_service).to receive(:execute).with('some token')
make_request make_request
end end
it 'pass nil if cannot extract a non-bearer token' do it 'pass nil if cannot extract a non-bearer token' do
request.headers['HTTP_AUTHORIZATION'] = 'some token' request.headers['HTTP_AUTHORIZATION'] = 'some token'
expect(notify_service).to receive(:execute).with(nil) expect(notify_service).to receive(:execute).with(nil)
make_request make_request
end
end end
end
context 'when missing' do context 'when missing' do
it 'passes nil' do it 'passes nil' do
expect(notify_service).to receive(:execute).with(nil) expect(notify_service).to receive(:execute).with(nil)
make_request make_request
end
end end
end end
end end
context 'generic alert payload' do
it_behaves_like 'process alert payload', Projects::Alerting::NotifyService do
let(:payload) { { title: 'Alert title' } }
end
end
context 'Prometheus alert payload' do
include PrometheusHelpers
it_behaves_like 'process alert payload', Projects::Prometheus::Alerts::NotifyService do
let(:payload) { prometheus_alert_payload }
end
end
end end
private
def project_params(opts = {}) def project_params(opts = {})
opts.reverse_merge(namespace_id: project.namespace, project_id: project) opts.reverse_merge(namespace_id: project.namespace, project_id: project)
end end
......
...@@ -45,9 +45,13 @@ FactoryBot.define do ...@@ -45,9 +45,13 @@ FactoryBot.define do
end end
factory :alerts_service do factory :alerts_service do
active
project project
type { 'AlertsService' } type { 'AlertsService' }
active { true }
trait :active do
active { true }
end
trait :inactive do trait :inactive do
active { false } active { false }
......
...@@ -3,6 +3,8 @@ ...@@ -3,6 +3,8 @@
require 'spec_helper' require 'spec_helper'
describe Projects::Prometheus::Alerts::NotifyService do describe Projects::Prometheus::Alerts::NotifyService do
include PrometheusHelpers
let_it_be(:project, reload: true) { create(:project) } let_it_be(:project, reload: true) { create(:project) }
let(:service) { described_class.new(project, nil, payload) } let(:service) { described_class.new(project, nil, payload) }
...@@ -92,9 +94,10 @@ describe Projects::Prometheus::Alerts::NotifyService do ...@@ -92,9 +94,10 @@ describe Projects::Prometheus::Alerts::NotifyService do
end end
context 'with valid payload' do context 'with valid payload' do
let(:alert_firing) { create(:prometheus_alert, project: project) } let_it_be(:alert_firing) { create(:prometheus_alert, project: project) }
let(:alert_resolved) { create(:prometheus_alert, project: project) } let_it_be(:alert_resolved) { create(:prometheus_alert, project: project) }
let(:payload_raw) { payload_for(firing: [alert_firing], resolved: [alert_resolved]) } let_it_be(:cluster) { create(:cluster, :provided_by_user, projects: [project]) }
let(:payload_raw) { prometheus_alert_payload(firing: [alert_firing], resolved: [alert_resolved]) }
let(:payload) { ActionController::Parameters.new(payload_raw).permit! } let(:payload) { ActionController::Parameters.new(payload_raw).permit! }
let(:payload_alert_firing) { payload_raw['alerts'].first } let(:payload_alert_firing) { payload_raw['alerts'].first }
let(:token) { 'token' } let(:token) { 'token' }
...@@ -116,9 +119,7 @@ describe Projects::Prometheus::Alerts::NotifyService do ...@@ -116,9 +119,7 @@ describe Projects::Prometheus::Alerts::NotifyService do
with_them do with_them do
before do before do
cluster = create(:cluster, :provided_by_user, cluster.update!(enabled: cluster_enabled)
projects: [project],
enabled: cluster_enabled)
if status if status
create(:clusters_applications_prometheus, status, create(:clusters_applications_prometheus, status,
...@@ -179,6 +180,39 @@ describe Projects::Prometheus::Alerts::NotifyService do ...@@ -179,6 +180,39 @@ describe Projects::Prometheus::Alerts::NotifyService do
end end
end end
context 'with generic alerts integration' do
using RSpec::Parameterized::TableSyntax
where(:alerts_service, :token, :result) do
:active | :valid | :success
:active | :invalid | :failure
:active | nil | :failure
:inactive | :valid | :failure
nil | nil | :failure
end
with_them do
let(:valid) { project.alerts_service.token }
let(:invalid) { 'invalid token' }
let(:token_input) { public_send(token) if token }
before do
if alerts_service
create(:alerts_service, alerts_service, project: project)
end
end
case result = params[:result]
when :success
it_behaves_like 'notifies alerts'
when :failure
it_behaves_like 'no notifications', http_status: :unauthorized
else
raise "invalid result: #{result.inspect}"
end
end
end
context 'alert emails' do context 'alert emails' do
before do before do
create(:prometheus_service, project: project) create(:prometheus_service, project: project)
...@@ -227,7 +261,7 @@ describe Projects::Prometheus::Alerts::NotifyService do ...@@ -227,7 +261,7 @@ describe Projects::Prometheus::Alerts::NotifyService do
context 'with multiple firing alerts and resolving alerts' do context 'with multiple firing alerts and resolving alerts' do
let(:payload_raw) do let(:payload_raw) do
payload_for(firing: [alert_firing, alert_firing], resolved: [alert_resolved]) prometheus_alert_payload(firing: [alert_firing, alert_firing], resolved: [alert_resolved])
end end
it 'processes Prometheus alerts' do it 'processes Prometheus alerts' do
...@@ -258,7 +292,7 @@ describe Projects::Prometheus::Alerts::NotifyService do ...@@ -258,7 +292,7 @@ describe Projects::Prometheus::Alerts::NotifyService do
context 'multiple firing alerts' do context 'multiple firing alerts' do
let(:payload_raw) do let(:payload_raw) do
payload_for(firing: [alert_firing, alert_firing], resolved: []) prometheus_alert_payload(firing: [alert_firing, alert_firing], resolved: [])
end end
it_behaves_like 'processes incident issues', 2 it_behaves_like 'processes incident issues', 2
...@@ -266,7 +300,7 @@ describe Projects::Prometheus::Alerts::NotifyService do ...@@ -266,7 +300,7 @@ describe Projects::Prometheus::Alerts::NotifyService do
context 'without firing alerts' do context 'without firing alerts' do
let(:payload_raw) do let(:payload_raw) do
payload_for(firing: [], resolved: [alert_resolved]) prometheus_alert_payload(firing: [], resolved: [alert_resolved])
end end
it_behaves_like 'processes incident issues', 1 it_behaves_like 'processes incident issues', 1
...@@ -284,24 +318,17 @@ describe Projects::Prometheus::Alerts::NotifyService do ...@@ -284,24 +318,17 @@ describe Projects::Prometheus::Alerts::NotifyService do
end end
context 'with invalid payload' do context 'with invalid payload' do
context 'without version' do context 'when payload is not processable' do
let(:payload) { {} } let(:payload) { {} }
it_behaves_like 'no notifications', http_status: :unprocessable_entity before do
end allow(described_class).to receive(:processable?).with(payload)
.and_return(false)
context 'when version is not "4"' do end
let(:payload) { { 'version' => '5' } }
it_behaves_like 'no notifications', http_status: :unprocessable_entity it_behaves_like 'no notifications', http_status: :unprocessable_entity
end end
context 'with missing alerts' do
let(:payload) { { 'version' => '4' } }
it_behaves_like 'no notifications', http_status: :unauthorized
end
context 'when the payload is too big' do context 'when the payload is too big' do
let(:payload) { { 'the-payload-is-too-big' => true } } let(:payload) { { 'the-payload-is-too-big' => true } }
let(:deep_size_object) { instance_double(Gitlab::Utils::DeepSize, valid?: false) } let(:deep_size_object) { instance_double(Gitlab::Utils::DeepSize, valid?: false) }
...@@ -328,50 +355,39 @@ describe Projects::Prometheus::Alerts::NotifyService do ...@@ -328,50 +355,39 @@ describe Projects::Prometheus::Alerts::NotifyService do
end end
end end
private describe '.processable?' do
let(:valid_payload) { prometheus_alert_payload }
def payload_for(firing: [], resolved: [])
status = firing.any? ? 'firing' : 'resolved' subject { described_class.processable?(payload) }
alerts = firing + resolved
alert_name = alerts.first.title context 'with valid payload' do
prometheus_metric_id = alerts.first.prometheus_metric_id.to_s let(:payload) { valid_payload }
alerts_map = \ it { is_expected.to eq(true) }
firing.map { |alert| map_alert_payload('firing', alert) } +
resolved.map { |alert| map_alert_payload('resolved', alert) } context 'containing unrelated keys' do
let(:payload) { valid_payload.merge('unrelated' => 'key') }
# See https://prometheus.io/docs/alerting/configuration/#%3Cwebhook_config%3E
{
'version' => '4',
'receiver' => 'gitlab',
'status' => status,
'alerts' => alerts_map,
'groupLabels' => {
'alertname' => alert_name
},
'commonLabels' => {
'alertname' => alert_name,
'gitlab' => 'hook',
'gitlab_alert_id' => prometheus_metric_id
},
'commonAnnotations' => {},
'externalURL' => '',
'groupKey' => "{}:{alertname=\'#{alert_name}\'}"
}
end
def map_alert_payload(status, alert) it { is_expected.to eq(true) }
{ end
'status' => status, end
'labels' => {
'alertname' => alert.title, context 'with invalid payload' do
'gitlab' => 'hook', where(:missing_key) do
'gitlab_alert_id' => alert.prometheus_metric_id.to_s described_class::REQUIRED_PAYLOAD_KEYS.to_a
}, end
'annotations' => {},
'startsAt' => '2018-09-24T08:57:31.095725221Z', with_them do
'endsAt' => '0001-01-01T00:00:00Z', let(:payload) { valid_payload.except(missing_key) }
'generatorURL' => 'http://prometheus-prometheus-server-URL'
} it { is_expected.to eq(false) }
end
end
context 'with unsupported version' do
let(:payload) { valid_payload.merge('version' => '5') }
it { is_expected.to eq(false) }
end
end end
end end
...@@ -236,4 +236,51 @@ module PrometheusHelpers ...@@ -236,4 +236,51 @@ module PrometheusHelpers
] ]
} }
end end
def prometheus_alert_payload(firing: [], resolved: [])
status = firing.any? ? 'firing' : 'resolved'
alerts = firing + resolved
alert_name = alerts.first&.title || ''
prometheus_metric_id = alerts.first&.prometheus_metric_id&.to_s
alerts_map = \
firing.map { |alert| prometheus_map_alert_payload('firing', alert) } +
resolved.map { |alert| prometheus_map_alert_payload('resolved', alert) }
# See https://prometheus.io/docs/alerting/configuration/#%3Cwebhook_config%3E
{
'version' => '4',
'receiver' => 'gitlab',
'status' => status,
'alerts' => alerts_map,
'groupLabels' => {
'alertname' => alert_name
},
'commonLabels' => {
'alertname' => alert_name,
'gitlab' => 'hook',
'gitlab_alert_id' => prometheus_metric_id
},
'commonAnnotations' => {},
'externalURL' => '',
'groupKey' => "{}:{alertname=\'#{alert_name}\'}"
}
end
private
def prometheus_map_alert_payload(status, alert)
{
'status' => status,
'labels' => {
'alertname' => alert.title,
'gitlab' => 'hook',
'gitlab_alert_id' => alert.prometheus_metric_id.to_s
},
'annotations' => {},
'startsAt' => '2018-09-24T08:57:31.095725221Z',
'endsAt' => '0001-01-01T00:00:00Z',
'generatorURL' => 'http://prometheus-prometheus-server-URL'
}
end
end end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment