kennel 1.74.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/Readme.md +289 -0
- data/lib/kennel.rb +90 -0
- data/lib/kennel/api.rb +83 -0
- data/lib/kennel/file_cache.rb +53 -0
- data/lib/kennel/github_reporter.rb +49 -0
- data/lib/kennel/importer.rb +135 -0
- data/lib/kennel/models/base.rb +29 -0
- data/lib/kennel/models/dashboard.rb +209 -0
- data/lib/kennel/models/monitor.rb +219 -0
- data/lib/kennel/models/project.rb +31 -0
- data/lib/kennel/models/record.rb +94 -0
- data/lib/kennel/models/slo.rb +92 -0
- data/lib/kennel/models/team.rb +12 -0
- data/lib/kennel/optional_validations.rb +21 -0
- data/lib/kennel/progress.rb +34 -0
- data/lib/kennel/settings_as_methods.rb +86 -0
- data/lib/kennel/subclass_tracking.rb +19 -0
- data/lib/kennel/syncer.rb +260 -0
- data/lib/kennel/tasks.rb +147 -0
- data/lib/kennel/template_variables.rb +38 -0
- data/lib/kennel/unmuted_alerts.rb +89 -0
- data/lib/kennel/utils.rb +159 -0
- data/lib/kennel/version.rb +4 -0
- data/template/Readme.md +247 -0
- metadata +109 -0
@@ -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
|