kennel 1.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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