kennel 1.75.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+ module Kennel
3
+ module Models
4
+ class Monitor < Record
5
+ include OptionalValidations
6
+
7
+ RENOTIFY_INTERVALS = [0, 10, 20, 30, 40, 50, 60, 90, 120, 180, 240, 300, 360, 720, 1440].freeze # minutes
8
+ # 2d and 1w are valid timeframes for anomaly monitors
9
+ QUERY_INTERVALS = ["1m", "5m", "10m", "15m", "30m", "1h", "2h", "4h", "1d", "2d", "1w"].freeze
10
+ OPTIONAL_SERVICE_CHECK_THRESHOLDS = [:ok, :warning].freeze
11
+ READONLY_ATTRIBUTES = superclass::READONLY_ATTRIBUTES + [
12
+ :multi, :matching_downtimes, :overall_state_modified, :overall_state, :restricted_roles
13
+ ]
14
+
15
+ # defaults that datadog uses when options are not sent, so safe to leave out if our values match their defaults
16
+ MONITOR_OPTION_DEFAULTS = {
17
+ evaluation_delay: nil,
18
+ new_host_delay: 300,
19
+ timeout_h: 0,
20
+ renotify_interval: 0,
21
+ no_data_timeframe: nil # this works out ok since if notify_no_data is on, it would never be nil
22
+ }.freeze
23
+ DEFAULT_ESCALATION_MESSAGE = ["", nil].freeze
24
+
25
+ settings(
26
+ :query, :name, :message, :escalation_message, :critical, :type, :renotify_interval, :warning, :timeout_h, :evaluation_delay,
27
+ :ok, :no_data_timeframe, :notify_no_data, :notify_audit, :tags, :critical_recovery, :warning_recovery, :require_full_window,
28
+ :threshold_windows, :new_host_delay, :groupby_simple_monitor
29
+ )
30
+
31
+ defaults(
32
+ message: -> { "\n\n#{project.mention}" },
33
+ escalation_message: -> { DEFAULT_ESCALATION_MESSAGE.first },
34
+ renotify_interval: -> { project.team.renotify_interval },
35
+ warning: -> { nil },
36
+ ok: -> { nil },
37
+ id: -> { nil },
38
+ notify_no_data: -> { true },
39
+ no_data_timeframe: -> { 60 },
40
+ notify_audit: -> { true },
41
+ new_host_delay: -> { MONITOR_OPTION_DEFAULTS.fetch(:new_host_delay) },
42
+ tags: -> { @project.tags },
43
+ timeout_h: -> { MONITOR_OPTION_DEFAULTS.fetch(:timeout_h) },
44
+ evaluation_delay: -> { MONITOR_OPTION_DEFAULTS.fetch(:evaluation_delay) },
45
+ critical_recovery: -> { nil },
46
+ warning_recovery: -> { nil },
47
+ threshold_windows: -> { nil },
48
+ groupby_simple_monitor: -> { nil }
49
+ )
50
+
51
+ def as_json
52
+ return @as_json if @as_json
53
+ data = {
54
+ name: "#{name}#{LOCK}",
55
+ type: type,
56
+ query: query.strip,
57
+ message: message.strip,
58
+ tags: tags.uniq,
59
+ options: {
60
+ timeout_h: timeout_h,
61
+ notify_no_data: notify_no_data,
62
+ no_data_timeframe: notify_no_data ? no_data_timeframe : nil,
63
+ notify_audit: notify_audit,
64
+ require_full_window: require_full_window,
65
+ new_host_delay: new_host_delay,
66
+ include_tags: true,
67
+ escalation_message: Utils.presence(escalation_message.strip),
68
+ evaluation_delay: evaluation_delay,
69
+ locked: false, # setting this to true prevents any edit and breaks updates when using replace workflow
70
+ renotify_interval: renotify_interval || 0
71
+ }
72
+ }
73
+
74
+ data[:id] = id if id
75
+
76
+ options = data[:options]
77
+ if data.fetch(:type) != "composite"
78
+ thresholds = (options[:thresholds] = { critical: critical })
79
+
80
+ # warning, ok, critical_recovery, and warning_recovery are optional
81
+ [:warning, :ok, :critical_recovery, :warning_recovery].each do |key|
82
+ if value = send(key)
83
+ thresholds[key] = value
84
+ end
85
+ end
86
+
87
+ thresholds[:critical] = critical unless
88
+ case data.fetch(:type)
89
+ when "service check"
90
+ # avoid diff for default values of 1
91
+ OPTIONAL_SERVICE_CHECK_THRESHOLDS.each { |t| thresholds[t] ||= 1 }
92
+ when "query alert"
93
+ # metric and query values are stored as float by datadog
94
+ thresholds.each { |k, v| thresholds[k] = Float(v) }
95
+ end
96
+ end
97
+
98
+ # option randomly pops up and cannot be removed
99
+ unless (group = groupby_simple_monitor).nil?
100
+ options[:groupby_simple_monitor] = group
101
+ end
102
+
103
+ if windows = threshold_windows
104
+ options[:threshold_windows] = windows
105
+ end
106
+
107
+ validate_json(data) if validate
108
+
109
+ @as_json = data
110
+ end
111
+
112
+ def resolve_linked_tracking_ids!(id_map, **args)
113
+ if as_json[:type] == "composite"
114
+ as_json[:query] = as_json[:query].gsub(/%\{(.*?)\}/) do
115
+ resolve_link($1, :monitor, id_map, **args)
116
+ end
117
+ end
118
+ end
119
+
120
+ def self.api_resource
121
+ "monitor"
122
+ end
123
+
124
+ def url(id)
125
+ Utils.path_to_url "/monitors##{id}/edit"
126
+ end
127
+
128
+ # datadog uses / for show and # for edit as separator in it's links
129
+ def self.parse_url(url)
130
+ return unless id = url[/\/monitors[\/#](\d+)/, 1]
131
+ Integer(id)
132
+ end
133
+
134
+ def self.normalize(expected, actual)
135
+ super
136
+ options = actual.fetch(:options)
137
+ options.delete(:silenced) # we do not manage silenced, so ignore it when diffing
138
+
139
+ # fields are not returned when set to true
140
+ if ["service check", "event alert"].include?(actual[:type])
141
+ options[:include_tags] = true unless options.key?(:include_tags)
142
+ options[:require_full_window] = true unless options.key?(:require_full_window)
143
+ end
144
+
145
+ case actual[:type]
146
+ when "event alert"
147
+ # setting nothing results in thresholds not getting returned from the api
148
+ options[:thresholds] ||= { critical: 0 }
149
+
150
+ when "service check"
151
+ # fields are not returned when created with default values via UI
152
+ OPTIONAL_SERVICE_CHECK_THRESHOLDS.each do |t|
153
+ options[:thresholds][t] ||= 1
154
+ end
155
+ end
156
+
157
+ # nil / "" / 0 are not returned from the api when set via the UI
158
+ options[:evaluation_delay] ||= nil
159
+
160
+ expected_options = expected[:options] || {}
161
+ ignore_default(expected_options, options, MONITOR_OPTION_DEFAULTS)
162
+ if DEFAULT_ESCALATION_MESSAGE.include?(options[:escalation_message])
163
+ options.delete(:escalation_message)
164
+ expected_options.delete(:escalation_message)
165
+ end
166
+ end
167
+
168
+ private
169
+
170
+ def require_full_window
171
+ # default 'on_average', 'at_all_times', 'in_total' aggregations to true, otherwise false
172
+ # https://docs.datadoghq.com/ap/#create-a-monitor
173
+ type != "query alert" || query.start_with?("avg", "min", "sum")
174
+ end
175
+
176
+ def validate_json(data)
177
+ super
178
+
179
+ type = data.fetch(:type)
180
+
181
+ # do not allow deprecated type that will be coverted by datadog and then produce a diff
182
+ if type == "metric alert"
183
+ invalid! "type 'metric alert' is deprecated, please set to a different type (e.g. 'query alert')"
184
+ end
185
+
186
+ # verify query includes critical value
187
+ if query_value = data.fetch(:query)[/\s*[<>]=?\s*(\d+(\.\d+)?)\s*$/, 1]
188
+ if Float(query_value) != Float(data.dig(:options, :thresholds, :critical))
189
+ invalid! "critical and value used in query must match"
190
+ end
191
+ end
192
+
193
+ # verify renotify interval is valid
194
+ unless RENOTIFY_INTERVALS.include? data.dig(:options, :renotify_interval)
195
+ invalid! "renotify_interval must be one of #{RENOTIFY_INTERVALS.join(", ")}"
196
+ end
197
+
198
+ if type == "query alert"
199
+ # verify interval is valud
200
+ interval = data.fetch(:query)[/\(last_(\S+?)\)/, 1]
201
+ if interval && !QUERY_INTERVALS.include?(interval)
202
+ invalid! "query interval was #{interval}, but must be one of #{QUERY_INTERVALS.join(", ")}"
203
+ end
204
+ end
205
+
206
+ if ["query alert", "service check"].include?(type) # TODO: most likely more types need this
207
+ # verify is_match uses available variables
208
+ message = data.fetch(:message)
209
+ used = message.scan(/{{\s*#is_match\s*"([a-zA-Z\d_.-]+).name"/).flatten.uniq
210
+ allowed = data.fetch(:query)[/by\s*[({]([^})]+)[})]/, 1].to_s.gsub(/["']/, "").split(/\s*,\s*/)
211
+ unsupported = used - allowed
212
+ if unsupported.any?
213
+ invalid! "is_match used with #{unsupported}, but metric is only grouped by #{allowed}"
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+ module Kennel
3
+ module Models
4
+ class Project < Base
5
+ settings :team, :parts, :tags, :mention
6
+ defaults(
7
+ tags: -> { ["service:#{kennel_id}"] + team.tags },
8
+ mention: -> { team.mention }
9
+ )
10
+
11
+ def self.file_location
12
+ @file_location ||= begin
13
+ method_in_file = instance_methods(false).first
14
+ instance_method(method_in_file).source_location.first.sub("#{Bundler.root}/", "")
15
+ end
16
+ end
17
+
18
+ def validated_parts
19
+ all = parts
20
+ validate_parts(all)
21
+ all
22
+ end
23
+
24
+ private
25
+
26
+ # hook for users to add custom validations via `prepend`
27
+ def validate_parts(parts)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+ module Kennel
3
+ module Models
4
+ class Record < Base
5
+ LOCK = "\u{1F512}"
6
+ READONLY_ATTRIBUTES = [
7
+ :deleted, :id, :created, :created_at, :creator, :org_id, :modified, :modified_at, :api_resource
8
+ ].freeze
9
+ API_LIST_INCOMPLETE = false
10
+
11
+ settings :id, :kennel_id
12
+
13
+ class << self
14
+ def parse_any_url(url)
15
+ subclasses.detect do |s|
16
+ if id = s.parse_url(url)
17
+ break s.api_resource, id
18
+ end
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def normalize(_expected, actual)
25
+ self::READONLY_ATTRIBUTES.each { |k| actual.delete k }
26
+ end
27
+
28
+ def ignore_default(expected, actual, defaults)
29
+ definitions = [actual, expected]
30
+ defaults.each do |key, default|
31
+ if definitions.all? { |r| !r.key?(key) || r[key] == default }
32
+ actual.delete(key)
33
+ expected.delete(key)
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ attr_reader :project
40
+
41
+ def initialize(project, *args)
42
+ raise ArgumentError, "First argument must be a project, not #{project.class}" unless project.is_a?(Project)
43
+ @project = project
44
+ super(*args)
45
+ end
46
+
47
+ def diff(actual)
48
+ expected = as_json
49
+ expected.delete(:id)
50
+
51
+ self.class.send(:normalize, expected, actual)
52
+
53
+ # strict: ignore Integer vs Float
54
+ # similarity: show diff when not 100% similar
55
+ # use_lcs: saner output
56
+ Hashdiff.diff(actual, expected, use_lcs: false, strict: false, similarity: 1)
57
+ end
58
+
59
+ def tracking_id
60
+ "#{project.kennel_id}:#{kennel_id}"
61
+ end
62
+
63
+ def resolve_linked_tracking_ids!(*)
64
+ end
65
+
66
+ private
67
+
68
+ def resolve_link(id, type, id_map, force:)
69
+ value = id_map[id]
70
+ if value == :new
71
+ if force
72
+ # TODO: remove the need for this by sorting monitors by missing resolutions
73
+ invalid! "#{id} needs to already exist, try again"
74
+ else
75
+ id # will be re-resolved by syncer after the linked object was created
76
+ end
77
+ elsif value
78
+ value
79
+ else
80
+ invalid! "Unable to find #{type} #{id} (does not exist and is not being created by the current run)"
81
+ end
82
+ end
83
+
84
+ # let users know which project/resource failed when something happens during diffing where the backtrace is hidden
85
+ def invalid!(message)
86
+ raise ValidationError, "#{tracking_id} #{message}"
87
+ end
88
+
89
+ def raise_with_location(error, message)
90
+ super error, "#{message} for project #{project.kennel_id}"
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+ module Kennel
3
+ module Models
4
+ class Slo < Record
5
+ READONLY_ATTRIBUTES = superclass::READONLY_ATTRIBUTES + [:type_id, :monitor_tags]
6
+ DEFAULTS = {
7
+ description: nil,
8
+ query: nil,
9
+ groups: nil,
10
+ monitor_ids: [],
11
+ thresholds: []
12
+ }.freeze
13
+
14
+ settings :type, :description, :thresholds, :query, :tags, :monitor_ids, :monitor_tags, :name, :groups
15
+
16
+ defaults(
17
+ id: -> { nil },
18
+ tags: -> { @project.tags },
19
+ query: -> { DEFAULTS.fetch(:query) },
20
+ description: -> { DEFAULTS.fetch(:description) },
21
+ monitor_ids: -> { DEFAULTS.fetch(:monitor_ids) },
22
+ thresholds: -> { DEFAULTS.fetch(:thresholds) },
23
+ groups: -> { DEFAULTS.fetch(:groups) }
24
+ )
25
+
26
+ def initialize(*)
27
+ super
28
+ if thresholds.any? { |t| t[:warning] && t[:warning].to_f <= t[:critical].to_f }
29
+ raise ValidationError, "Threshold warning must be greater-than critical value"
30
+ end
31
+ end
32
+
33
+ def as_json
34
+ return @as_json if @as_json
35
+ data = {
36
+ name: "#{name}#{LOCK}",
37
+ description: description,
38
+ thresholds: thresholds,
39
+ monitor_ids: monitor_ids,
40
+ tags: tags.uniq,
41
+ type: type
42
+ }
43
+
44
+ if v = query
45
+ data[:query] = v
46
+ end
47
+ if v = id
48
+ data[:id] = v
49
+ end
50
+ if v = groups
51
+ data[:groups] = v
52
+ end
53
+
54
+ @as_json = data
55
+ end
56
+
57
+ def self.api_resource
58
+ "slo"
59
+ end
60
+
61
+ def url(id)
62
+ Utils.path_to_url "/slo?slo_id=#{id}"
63
+ end
64
+
65
+ def self.parse_url(url)
66
+ url[/\/slo\?slo_id=([a-z\d]+)/, 1]
67
+ end
68
+
69
+ def resolve_linked_tracking_ids!(id_map, **args)
70
+ as_json[:monitor_ids] = as_json[:monitor_ids].map do |id|
71
+ id.is_a?(String) ? resolve_link(id, :monitor, id_map, **args) : id
72
+ end
73
+ end
74
+
75
+ def self.normalize(expected, actual)
76
+ super
77
+
78
+ # remove readonly values
79
+ actual[:thresholds]&.each do |threshold|
80
+ threshold.delete(:warning_display)
81
+ threshold.delete(:target_display)
82
+ end
83
+
84
+ # tags come in a semi-random order and order is never updated
85
+ expected[:tags]&.sort!
86
+ actual[:tags].sort!
87
+
88
+ ignore_default(expected, actual, DEFAULTS)
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+ module Kennel
3
+ module Models
4
+ class Team < Base
5
+ settings :mention, :tags, :renotify_interval, :kennel_id
6
+ defaults(
7
+ tags: -> { ["team:#{kennel_id.sub(/^teams_/, "")}"] },
8
+ renotify_interval: -> { 0 }
9
+ )
10
+ end
11
+ end
12
+ end