Commit 4de7c8fd authored by Adam Hegyi's avatar Adam Hegyi

Merge branch 'extend-graphql-api-for-alerts-in-environments' into 'master'

Expose alert information for environments

See merge request gitlab-org/gitlab!38881
parents 98966a68 06589d92
......@@ -107,6 +107,16 @@ module Types
description: 'Todos of the current user for the alert',
resolver: Resolvers::TodoResolver
field :details_url,
GraphQL::STRING_TYPE,
null: false,
description: 'The URL of the alert detail page'
field :prometheus_alert,
Types::PrometheusAlertType,
null: true,
description: 'The alert condition for Prometheus'
def notes
object.ordered_notes
end
......
......@@ -19,5 +19,10 @@ module Types
field :metrics_dashboard, Types::Metrics::DashboardType, null: true,
description: 'Metrics dashboard schema for the environment',
resolver: Resolvers::Metrics::DashboardResolver
field :latest_opened_most_severe_alert,
Types::AlertManagement::AlertType,
null: true,
description: 'The most severe open alert for the environment. If multiple alerts have equal severity, the most recent is returned.'
end
end
......@@ -169,6 +169,12 @@ module Types
description: 'Environments of the project',
resolver: Resolvers::EnvironmentsResolver
field :environment,
Types::EnvironmentType,
null: true,
description: 'A single environment of the project',
resolver: Resolvers::EnvironmentsResolver.single
field :sast_ci_configuration, ::Types::CiConfiguration::Sast::Type, null: true,
description: 'SAST CI configuration for the project',
resolver: ::Resolvers::CiConfiguration::SastResolver
......
# frozen_string_literal: true
module Types
class PrometheusAlertType < BaseObject
graphql_name 'PrometheusAlert'
description 'The alert condition for Prometheus'
authorize :read_prometheus_alerts
present_using PrometheusAlertPresenter
field :id, GraphQL::ID_TYPE, null: false,
description: 'ID of the alert condition'
field :humanized_text,
GraphQL::STRING_TYPE,
null: false,
description: 'The human-readable text of the alert condition'
end
end
......@@ -118,7 +118,7 @@ module AlertManagement
end
delegate :iid, to: :issue, prefix: true, allow_nil: true
delegate :metrics_dashboard_url, :runbook, to: :present
delegate :metrics_dashboard_url, :runbook, :details_url, to: :present
scope :for_iid, -> (iid) { where(iid: iid) }
scope :for_status, -> (status) { where(status: status) }
......@@ -137,6 +137,7 @@ module AlertManagement
# Descending sort order sorts severity from more critical to less critical.
# https://gitlab.com/gitlab-org/gitlab/-/issues/221242#what-is-the-expected-correct-behavior
scope :order_severity, -> (sort_order) { order(severity: sort_order == :asc ? :desc : :asc) }
scope :order_severity_with_open_prometheus_alert, -> { open.with_prometheus_alert.order(severity: :asc, started_at: :desc) }
# Ascending sort order sorts statuses: Ignored > Resolved > Acknowledged > Triggered
# Descending sort order sorts statuses: Triggered > Acknowledged > Resolved > Ignored
......
......@@ -29,6 +29,7 @@ class Environment < ApplicationRecord
has_one :last_visible_deployment, -> { visible.distinct_on_environment }, inverse_of: :environment, class_name: 'Deployment'
has_one :last_visible_deployable, through: :last_visible_deployment, source: 'deployable', source_type: 'CommitStatus'
has_one :last_visible_pipeline, through: :last_visible_deployable, source: 'pipeline'
has_one :latest_opened_most_severe_alert, -> { order_severity_with_open_prometheus_alert }, class_name: 'AlertManagement::Alert', inverse_of: :environment
before_validation :nullify_external_url
before_validation :generate_slug, if: ->(env) { env.slug.blank? }
......@@ -291,6 +292,10 @@ class Environment < ApplicationRecord
!!ENV['USE_SAMPLE_METRICS']
end
def has_opened_alert?
latest_opened_most_severe_alert.present?
end
def metrics
prometheus_adapter.query(:environment, self) if has_metrics_and_can_query?
end
......
......@@ -3,6 +3,7 @@
class PrometheusAlert < ApplicationRecord
include Sortable
include UsageStatistics
include Presentable
OPERATORS_MAP = {
lt: "<",
......
# frozen_string_literal: true
class PrometheusAlertPolicy < ::BasePolicy
delegate { @subject.project }
end
......@@ -4,6 +4,7 @@ module AlertManagement
class AlertPresenter < Gitlab::View::Presenter::Delegated
include Gitlab::Utils::StrongMemoize
include IncidentManagement::Settings
include ActionView::Helpers::UrlHelper
MARKDOWN_LINE_BREAK = " \n".freeze
......@@ -45,15 +46,12 @@ module AlertManagement
def metrics_dashboard_url; end
private
def details_url
::Gitlab::Routing.url_helpers.details_project_alert_management_url(
project,
alert.iid
)
details_project_alert_management_url(project, alert.iid)
end
private
attr_reader :alert, :project
def alerting_alert
......
# frozen_string_literal: true
class PrometheusAlertPresenter < Gitlab::View::Presenter::Delegated
include ActionView::Helpers::UrlHelper
presents :prometheus_alert
def humanized_text
operator_text =
case prometheus_alert.operator
when 'lt' then s_('PrometheusAlerts|is less than')
when 'eq' then s_('PrometheusAlerts|is equal to')
when 'gt' then s_('PrometheusAlerts|exceeded')
end
"#{operator_text} #{prometheus_alert.threshold}#{prometheus_alert.prometheus_metric.unit}"
end
end
......@@ -71,6 +71,8 @@ class EnvironmentEntity < Grape::Entity
can?(current_user, :destroy_environment, environment)
end
expose :has_opened_alert?, if: -> (*) { can_read_alert_management_alert? }, expose_nil: false, as: :has_opened_alert
private
alias_method :environment, :object
......@@ -91,6 +93,10 @@ class EnvironmentEntity < Grape::Entity
can?(current_user, :read_pod_logs, environment.project)
end
def can_read_alert_management_alert?
can?(current_user, :read_alert_management_alert, environment.project)
end
def cluster_platform_kubernetes?
deployment_platform && deployment_platform.is_a?(Clusters::Platforms::Kubernetes)
end
......
---
title: Expose alert information for environments
merge_request: 38881
author:
type: added
......@@ -209,6 +209,11 @@ type AlertManagementAlert implements Noteable {
"""
details: JSON
"""
The URL of the alert detail page
"""
detailsUrl: String!
"""
All discussions on this noteable
"""
......@@ -294,6 +299,11 @@ type AlertManagementAlert implements Noteable {
last: Int
): NoteConnection!
"""
The alert condition for Prometheus
"""
prometheusAlert: PrometheusAlert
"""
Runbook for the alert as defined in alert details
"""
......@@ -4418,6 +4428,11 @@ type Environment {
"""
id: ID!
"""
The most severe open alert for the environment. If multiple alerts have equal severity, the most recent is returned.
"""
latestOpenedMostSevereAlert: AlertManagementAlert
"""
Metrics dashboard schema for the environment
"""
......@@ -10553,6 +10568,26 @@ type Project {
"""
descriptionHtml: String
"""
A single environment of the project
"""
environment(
"""
Name of the environment
"""
name: String
"""
Search query for environment name
"""
search: String
"""
States of environments that should be included in result
"""
states: [String!]
): Environment
"""
Environments of the project
"""
......@@ -12116,6 +12151,21 @@ type ProjectStatistics {
wikiSize: Float
}
"""
The alert condition for Prometheus
"""
type PrometheusAlert {
"""
The human-readable text of the alert condition
"""
humanizedText: String!
"""
ID of the alert condition
"""
id: ID!
}
type Query {
"""
Get information about current user
......
......@@ -577,6 +577,24 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "detailsUrl",
"description": "The URL of the alert detail page",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "discussions",
"description": "All discussions on this noteable",
......@@ -801,6 +819,20 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "prometheusAlert",
"description": "The alert condition for Prometheus",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "PrometheusAlert",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "runbook",
"description": "Runbook for the alert as defined in alert details",
......@@ -12338,6 +12370,20 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "latestOpenedMostSevereAlert",
"description": "The most severe open alert for the environment. If multiple alerts have equal severity, the most recent is returned.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "AlertManagementAlert",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "metricsDashboard",
"description": "Metrics dashboard schema for the environment",
......@@ -31457,6 +31503,57 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "environment",
"description": "A single environment of the project",
"args": [
{
"name": "name",
"description": "Name of the environment",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "search",
"description": "Search query for environment name",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "states",
"description": "States of environments that should be included in result",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "Environment",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "environments",
"description": "Environments of the project",
......@@ -35655,6 +35752,55 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "PrometheusAlert",
"description": "The alert condition for Prometheus",
"fields": [
{
"name": "humanizedText",
"description": "The human-readable text of the alert condition",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "id",
"description": "ID of the alert condition",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "Query",
......@@ -64,6 +64,7 @@ Describes an alert from the project's Alert Management
| `createdAt` | Time | Timestamp the alert was created |
| `description` | String | Description of the alert |
| `details` | JSON | Alert details |
| `detailsUrl` | String! | The URL of the alert detail page |
| `endedAt` | Time | Timestamp the alert ended |
| `eventCount` | Int | Number of events of this alert |
| `hosts` | String! => Array | List of hosts the alert came from |
......@@ -71,6 +72,7 @@ Describes an alert from the project's Alert Management
| `issueIid` | ID | Internal ID of the GitLab issue attached to the alert |
| `metricsDashboardUrl` | String | URL for metrics embed for the alert |
| `monitoringTool` | String | Monitoring tool the alert came from |
| `prometheusAlert` | PrometheusAlert | The alert condition for Prometheus |
| `runbook` | String | Runbook for the alert as defined in alert details |
| `service` | String | Service the alert came from |
| `severity` | AlertManagementSeverity | Severity of the alert |
......@@ -739,6 +741,7 @@ Describes where code is deployed for a project
| Name | Type | Description |
| --- | ---- | ---------- |
| `id` | ID! | ID of the environment |
| `latestOpenedMostSevereAlert` | AlertManagementAlert | The most severe open alert for the environment. If multiple alerts have equal severity, the most recent is returned. |
| `metricsDashboard` | MetricsDashboard | Metrics dashboard schema for the environment |
| `name` | String! | Human-readable name of the environment |
| `state` | String! | State of the environment, for example: available/stopped |
......@@ -1602,6 +1605,7 @@ Information about pagination in a connection.
| `createdAt` | Time | Timestamp of the project creation |
| `description` | String | Short description of the project |
| `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` |
| `environment` | Environment | A single environment of the project |
| `forksCount` | Int! | Number of times the project has been forked |
| `fullPath` | ID! | Full path of the project |
| `grafanaIntegration` | GrafanaIntegration | Grafana integration details for the project |
......@@ -1732,6 +1736,15 @@ Represents a Project Member
| `storageSize` | Float! | Storage size of the project |
| `wikiSize` | Float | Wiki size of the project |
## PrometheusAlert
The alert condition for Prometheus
| Name | Type | Description |
| --- | ---- | ---------- |
| `humanizedText` | String! | The human-readable text of the alert condition |
| `id` | ID! | ID of the alert condition |
## Release
Represents a release
......
......@@ -75,6 +75,7 @@
"can_stop": {
"type": "boolean"
},
"has_opened_alert": { "type": "boolean" },
"cancel_auto_stop_path": { "type": "string" },
"auto_stop_at": { "type": "string", "format": "date-time" },
"can_delete": {
......
......@@ -19432,9 +19432,18 @@ msgstr ""
msgid "PrometheusAlerts|Threshold"
msgstr ""
msgid "PrometheusAlerts|exceeded"
msgstr ""
msgid "PrometheusAlerts|https://gitlab.com/gitlab-com/runbooks"
msgstr ""
msgid "PrometheusAlerts|is equal to"
msgstr ""
msgid "PrometheusAlerts|is less than"
msgstr ""
msgid "PrometheusService|%{exporters} with %{metrics} were found"
msgstr ""
......
......@@ -33,6 +33,7 @@
"updated_at": { "type": "string", "format": "date-time" },
"auto_stop_at": { "type": "string", "format": "date-time" },
"can_stop": { "type": "boolean" },
"has_opened_alert": { "type": "boolean" },
"cluster_type": { "type": "types/nullable_string.json" },
"terminal_path": { "type": "types/nullable_string.json" },
"last_deployment": {
......
......@@ -30,6 +30,8 @@ RSpec.describe GitlabSchema.types['AlertManagementAlert'] do
metrics_dashboard_url
runbook
todos
details_url
prometheus_alert
]
expect(described_class).to have_graphql_fields(*expected_fields)
......
......@@ -7,11 +7,76 @@ RSpec.describe GitlabSchema.types['Environment'] do
it 'has the expected fields' do
expected_fields = %w[
name id state metrics_dashboard
name id state metrics_dashboard latest_opened_most_severe_alert
]
expect(described_class).to have_graphql_fields(*expected_fields)
end
specify { expect(described_class).to require_graphql_authorizations(:read_environment) }
context 'when there is an environment' do
let_it_be(:project) { create(:project) }
let_it_be(:environment) { create(:environment, project: project) }
let_it_be(:user) { create(:user) }
subject { GitlabSchema.execute(query, context: { current_user: user }).as_json }
let(:query) do
%(
query {
project(fullPath: "#{project.full_path}") {
environment(name: "#{environment.name}") {
name
state
}
}
}
)
end
before do
project.add_developer(user)
end
it 'returns an environment' do
expect(subject['data']['project']['environment']['name']).to eq(environment.name)
end
context 'when query alert data for the environment' do
let_it_be(:query) do
%(
query {
project(fullPath: "#{project.full_path}") {
environment(name: "#{environment.name}") {
name
state
latestOpenedMostSevereAlert {
severity
title
detailsUrl
prometheusAlert {
humanizedText
}
}
}
}
}
)
end
it 'does not return alert information' do
expect(subject['data']['project']['environment']['latestOpenedMostSevereAlert']).to be_nil
end
context 'when alert is raised on the environment' do
let!(:prometheus_alert) { create(:prometheus_alert, project: project, environment: environment) }
let!(:alert) { create(:alert_management_alert, :triggered, :prometheus, project: project, environment: environment, prometheus_alert: prometheus_alert) }
it 'returns alert information' do
expect(subject['data']['project']['environment']['latestOpenedMostSevereAlert']['severity']).to eq(alert.severity.upcase)
end
end
end
end
end
......@@ -24,7 +24,7 @@ RSpec.describe GitlabSchema.types['Project'] do
namespace group statistics repository merge_requests merge_request issues
issue milestones pipelines removeSourceBranchAfterMerge sentryDetailedError snippets
grafanaIntegration autocloseReferencedIssues suggestion_commit_message environments
boards jira_import_status jira_imports services releases release
environment boards jira_import_status jira_imports services releases release
alert_management_alerts alert_management_alert alert_management_alert_status_counts
container_expiration_policy sast_ci_configuration service_desk_enabled service_desk_address
issue_status_counts
......@@ -98,6 +98,13 @@ RSpec.describe GitlabSchema.types['Project'] do
it { is_expected.to have_graphql_resolver(Resolvers::EnvironmentsResolver) }
end
describe 'environment field' do
subject { described_class.fields['environment'] }
it { is_expected.to have_graphql_type(Types::EnvironmentType) }
it { is_expected.to have_graphql_resolver(Resolvers::EnvironmentsResolver.single) }
end
describe 'members field' do
subject { described_class.fields['projectMembers'] }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['PrometheusAlert'] do
specify { expect(described_class.graphql_name).to eq('PrometheusAlert') }
it 'has the expected fields' do
expected_fields = %w[
id humanized_text
]
expect(described_class).to have_graphql_fields(*expected_fields)
end
specify { expect(described_class).to require_graphql_authorizations(:read_prometheus_alerts) }
end
......@@ -230,6 +230,17 @@ RSpec.describe AlertManagement::Alert do
it { is_expected.to match_array(env_alert) }
end
describe '.order_severity_with_open_prometheus_alert' do
subject { described_class.where(project: alert_project).order_severity_with_open_prometheus_alert }
let_it_be(:alert_project) { create(:project) }
let_it_be(:resolved_critical_alert) { create(:alert_management_alert, :resolved, :critical, project: alert_project) }
let_it_be(:triggered_critical_alert) { create(:alert_management_alert, :triggered, :critical, project: alert_project) }
let_it_be(:triggered_high_alert) { create(:alert_management_alert, :triggered, :high, project: alert_project) }
it { is_expected.to eq([triggered_critical_alert, triggered_high_alert]) }
end
describe '.counts_by_status' do
subject { described_class.counts_by_status }
......
......@@ -19,6 +19,7 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
it { is_expected.to have_many(:deployments) }
it { is_expected.to have_many(:metrics_dashboard_annotations) }
it { is_expected.to have_many(:alert_management_alerts) }
it { is_expected.to have_one(:latest_opened_most_severe_alert) }
it { is_expected.to delegate_method(:stop_action).to(:last_deployment) }
it { is_expected.to delegate_method(:manual_actions).to(:last_deployment) }
......@@ -1347,4 +1348,27 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
expect(project.environments.count_by_state).to eq({ stopped: 0, available: 0 })
end
end
describe '#has_opened_alert?' do
subject { environment.has_opened_alert? }
let_it_be(:project) { create(:project) }
let_it_be(:environment, reload: true) { create(:environment, project: project) }
context 'when environment has an triggered alert' do
let!(:alert) { create(:alert_management_alert, :triggered, project: project, environment: environment) }
it { is_expected.to be(true) }
end
context 'when environment has an resolved alert' do
let!(:alert) { create(:alert_management_alert, :resolved, project: project, environment: environment) }
it { is_expected.to be(false) }
end
context 'when environment does not have an alert' do
it { is_expected.to be(false) }
end
end
end
......@@ -58,4 +58,10 @@ RSpec.describe AlertManagement::AlertPresenter do
expect(presenter.runbook).to eq('https://runbook.com')
end
end
describe '#details_url' do
it 'returns the details URL' do
expect(presenter.details_url).to match(%r{#{project.web_url}/-/alert_management/#{alert.iid}/details})
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe PrometheusAlertPresenter do
let_it_be(:project) { create(:project) }
let_it_be(:environment) { create(:environment, project: project) }
let(:presenter) { described_class.new(prometheus_alert) }
describe '#humanized_text' do
subject { presenter.humanized_text }
let_it_be(:prometheus_metric) { create(:prometheus_metric, project: project) }
let(:prometheus_alert) { create(:prometheus_alert, operator: operator, project: project, environment: environment, prometheus_metric: prometheus_metric) }
let(:operator) { :gt }
it { is_expected.to eq('exceeded 1.0m/s') }
context 'when operator is eq' do
let(:operator) { :eq }
it { is_expected.to eq('is equal to 1.0m/s') }
end
context 'when operator is lt' do
let(:operator) { :lt }
it { is_expected.to eq('is less than 1.0m/s') }
end
end
end
......@@ -7,9 +7,9 @@ RSpec.describe 'getting Alert Management Alerts' do
let_it_be(:payload) { { 'custom' => { 'alert' => 'payload' }, 'runbook' => 'runbook' } }
let_it_be(:project) { create(:project, :repository) }
let_it_be(:current_user) { create(:user) }
let_it_be(:resolved_alert) { create(:alert_management_alert, :all_fields, :resolved, project: project, issue: nil, severity: :low) }
let_it_be(:triggered_alert) { create(:alert_management_alert, :all_fields, project: project, severity: :critical, payload: payload) }
let_it_be(:other_project_alert) { create(:alert_management_alert, :all_fields) }
let_it_be(:resolved_alert) { create(:alert_management_alert, :all_fields, :resolved, project: project, issue: nil, severity: :low).present }
let_it_be(:triggered_alert) { create(:alert_management_alert, :all_fields, project: project, severity: :critical, payload: payload).present }
let_it_be(:other_project_alert) { create(:alert_management_alert, :all_fields).present }
let(:params) { {} }
......@@ -75,6 +75,8 @@ RSpec.describe 'getting Alert Management Alerts' do
'createdAt' => triggered_alert.created_at.strftime('%Y-%m-%dT%H:%M:%SZ'),
'updatedAt' => triggered_alert.updated_at.strftime('%Y-%m-%dT%H:%M:%SZ'),
'metricsDashboardUrl' => nil,
'detailsUrl' => triggered_alert.details_url,
'prometheusAlert' => nil,
'runbook' => 'runbook'
)
......
......@@ -82,6 +82,26 @@ RSpec.describe EnvironmentEntity do
end
end
context 'with alert' do
let!(:environment) { create(:environment, project: project) }
let!(:prometheus_alert) { create(:prometheus_alert, project: project, environment: environment) }
let!(:alert) { create(:alert_management_alert, :triggered, :prometheus, project: project, environment: environment, prometheus_alert: prometheus_alert) }
it 'exposes active alert flag' do
project.add_maintainer(user)
expect(subject[:has_opened_alert]).to eq(true)
end
context 'when user does not have permission to read alert' do
it 'does not expose active alert flag' do
project.add_reporter(user)
expect(subject[:has_opened_alert]).to be_nil
end
end
end
context 'pod_logs' do
context 'with reporter access' do
before do
......
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