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.
- 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 +148 -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
|