1# frozen_string_literal: true
2
3# Representation of a payload of an alert. Defines a constant
4# API so that payloads from various sources can be treated
5# identically. Subclasses should define how to parse payload
6# based on source of alert.
7module Gitlab
8  module AlertManagement
9    module Payload
10      class Base
11        include ActiveModel::Model
12        include Gitlab::Utils::StrongMemoize
13        include Gitlab::Routing
14
15        attr_accessor :project, :payload, :integration
16
17        # Any attribute expected to be specifically read from
18        # or derived from an alert payload should be defined.
19        EXPECTED_PAYLOAD_ATTRIBUTES = [
20          :alert_markdown,
21          :alert_title,
22          :annotations,
23          :description,
24          :ends_at,
25          :environment,
26          :environment_name,
27          :full_query,
28          :generator_url,
29          :gitlab_alert,
30          :gitlab_fingerprint,
31          :gitlab_prometheus_alert_id,
32          :gitlab_y_label,
33          :has_required_attributes?,
34          :hosts,
35          :metric_id,
36          :metrics_dashboard_url,
37          :monitoring_tool,
38          :resolved?,
39          :runbook,
40          :service,
41          :severity,
42          :starts_at,
43          :status,
44          :title
45        ].freeze
46
47        private_constant :EXPECTED_PAYLOAD_ATTRIBUTES
48
49        # Define expected API for a payload
50        EXPECTED_PAYLOAD_ATTRIBUTES.each do |key|
51          define_method(key) {}
52        end
53
54        SEVERITY_MAPPING = {
55          'critical' => :critical,
56          'high' => :high,
57          'medium' => :medium,
58          'low' => :low,
59          'info' => :info
60        }.freeze
61
62        # Handle an unmapped severity value the same way we treat missing values
63        # so we can fallback to alert's default severity `critical`.
64        UNMAPPED_SEVERITY = nil
65
66        # Defines a method which allows access to a given
67        # value within an alert payload
68        #
69        # @param key [Symbol] Name expected to be used to reference value
70        # @param paths [String, Array<String>, Array<Array<String>>,]
71        #              List of (nested) keys at value can be found, the
72        #              first to yield a result will be used
73        # @param type [Symbol] If value should be converted to another type,
74        #              that should be specified here
75        # @param fallback [Proc] Block to be executed to yield a value if
76        #                 a value cannot be idenitied at any provided paths
77        # Example)
78        #    attribute :title
79        #              paths: [['title'],
80        #                     ['details', 'title']]
81        #              fallback: Proc.new { 'New Alert' }
82        #
83        # The above sample definition will define a method
84        # called #title which will return the value from the
85        # payload under the key `title` if available, otherwise
86        # looking under `details.title`. If neither returns a
87        # value, the return value will be `'New Alert'`
88        def self.attribute(key, paths:, type: nil, fallback: -> { nil })
89          define_method(key) do
90            strong_memoize(key) do
91              paths = Array(paths).first.is_a?(String) ? [Array(paths)] : paths
92              value = value_for_paths(paths)
93              value = parse_value(value, type) if value
94
95              value.presence || fallback.call
96            end
97          end
98        end
99
100        # Attributes of an AlertManagement::Alert as read
101        # directly from a payload. Prefer accessing
102        # AlertManagement::Alert directly for read operations.
103        def alert_params
104          {
105            description: description&.truncate(::AlertManagement::Alert::DESCRIPTION_MAX_LENGTH),
106            ended_at: ends_at,
107            environment: environment,
108            fingerprint: gitlab_fingerprint,
109            hosts: truncate_hosts(Array(hosts).flatten),
110            monitoring_tool: monitoring_tool&.truncate(::AlertManagement::Alert::TOOL_MAX_LENGTH),
111            payload: payload,
112            project_id: project.id,
113            prometheus_alert: gitlab_alert,
114            service: service&.truncate(::AlertManagement::Alert::SERVICE_MAX_LENGTH),
115            severity: severity,
116            started_at: starts_at,
117            title: title&.truncate(::AlertManagement::Alert::TITLE_MAX_LENGTH)
118          }.transform_values(&:presence).compact
119        end
120
121        def gitlab_fingerprint
122          strong_memoize(:gitlab_fingerprint) do
123            next unless plain_gitlab_fingerprint
124
125            Gitlab::AlertManagement::Fingerprint.generate(plain_gitlab_fingerprint)
126          end
127        end
128
129        def environment
130          strong_memoize(:environment) do
131            next unless environment_name
132
133            ::Environments::EnvironmentsFinder
134              .new(project, nil, { name: environment_name })
135              .execute
136              .first
137          end
138        end
139
140        def resolved?
141          status == 'resolved'
142        end
143
144        def has_required_attributes?
145          true
146        end
147
148        def severity
149          severity_mapping.fetch(severity_raw.to_s.downcase, UNMAPPED_SEVERITY)
150        end
151
152        private
153
154        def plain_gitlab_fingerprint
155        end
156
157        def severity_raw
158        end
159
160        def severity_mapping
161          SEVERITY_MAPPING
162        end
163
164        def truncate_hosts(hosts)
165          return hosts if hosts.join.length <= ::AlertManagement::Alert::HOSTS_MAX_LENGTH
166
167          hosts.inject([]) do |new_hosts, host|
168            remaining_length = ::AlertManagement::Alert::HOSTS_MAX_LENGTH - new_hosts.join.length
169
170            break new_hosts unless remaining_length > 0
171
172            new_hosts << host.to_s.truncate(remaining_length, omission: '')
173          end
174        end
175
176        # Overriden in EE::Gitlab::AlertManagement::Payload::Generic
177        def value_for_paths(paths)
178          target_path = paths.find { |path| payload&.dig(*path) }
179
180          payload&.dig(*target_path) if target_path
181        end
182
183        def parse_value(value, type)
184          case type
185          when :time
186            parse_time(value)
187          when :integer
188            parse_integer(value)
189          else
190            value
191          end
192        end
193
194        def parse_time(value)
195          Time.parse(value).utc
196        rescue ArgumentError, TypeError
197        end
198
199        def parse_integer(value)
200          Integer(value)
201        rescue ArgumentError, TypeError
202        end
203      end
204    end
205  end
206end
207