kennel 1.74.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 +244 -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 +213 -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 +205 -0
- metadata +109 -0
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
# Not used in here, but in our templated repo ... so keeping it around for now.
|
3
|
+
module Kennel
|
4
|
+
class GithubReporter
|
5
|
+
MAX_COMMENT_SIZE = 65536
|
6
|
+
TRUNCATED_MSG = "\n```\n... (truncated)" # finish the code block so it look nice
|
7
|
+
|
8
|
+
class << self
|
9
|
+
def report(token, &block)
|
10
|
+
return yield unless token
|
11
|
+
new(token, Utils.capture_sh("git rev-parse HEAD").strip).report(&block)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def initialize(token, git_sha)
|
16
|
+
@token = token
|
17
|
+
@git_sha = git_sha
|
18
|
+
origin = ENV["PROJECT_REPOSITORY"] || Utils.capture_sh("git remote -v").split("\n").first
|
19
|
+
@repo_part = origin[%r{github\.com[:/](.+?)(\.git|$)}, 1] || raise("no origin found")
|
20
|
+
end
|
21
|
+
|
22
|
+
def report(&block)
|
23
|
+
output = Utils.strip_shell_control(Utils.tee_output(&block).strip)
|
24
|
+
rescue StandardError
|
25
|
+
output = "Error:\n#{$ERROR_INFO.message}"
|
26
|
+
raise
|
27
|
+
ensure
|
28
|
+
comment "```\n#{output || "Error"}\n```"
|
29
|
+
end
|
30
|
+
|
31
|
+
# https://developer.github.com/v3/repos/comments/#create-a-commit-comment
|
32
|
+
def comment(body)
|
33
|
+
# truncate to maximum allowed comment size for github to avoid 422
|
34
|
+
if body.bytesize > MAX_COMMENT_SIZE
|
35
|
+
body = body.byteslice(0, MAX_COMMENT_SIZE - TRUNCATED_MSG.bytesize) + TRUNCATED_MSG
|
36
|
+
end
|
37
|
+
|
38
|
+
post "commits/#{@git_sha}/comments", body: body
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def post(path, data)
|
44
|
+
url = "https://api.github.com/repos/#{@repo_part}/#{path}"
|
45
|
+
response = Faraday.post(url, data.to_json, authorization: "token #{@token}")
|
46
|
+
raise "failed to POST to github:\n#{url} -> #{response.status}\n#{response.body}" unless response.status == 201
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,135 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Kennel
|
4
|
+
class Importer
|
5
|
+
TITLES = [:name, :title].freeze
|
6
|
+
SORT_ORDER = [*TITLES, :id, :kennel_id, :type, :tags, :query, *Syncer::TRACKING_FIELDS, :template_variables].freeze
|
7
|
+
|
8
|
+
def initialize(api)
|
9
|
+
@api = api
|
10
|
+
end
|
11
|
+
|
12
|
+
def import(resource, id)
|
13
|
+
if ["screen", "dash"].include?(resource)
|
14
|
+
raise ArgumentError, "resource 'screen' and 'dash' are deprecated, use 'dashboard'"
|
15
|
+
end
|
16
|
+
|
17
|
+
model =
|
18
|
+
begin
|
19
|
+
Kennel::Models.const_get(resource.capitalize)
|
20
|
+
rescue NameError
|
21
|
+
raise ArgumentError, "#{resource} is not supported"
|
22
|
+
end
|
23
|
+
|
24
|
+
data = @api.show(model.api_resource, id)
|
25
|
+
id = data.fetch(:id) # keep native value
|
26
|
+
model.normalize({}, data) # removes id
|
27
|
+
data[:id] = id
|
28
|
+
|
29
|
+
title_field = TITLES.detect { |f| data[f] }
|
30
|
+
title = data.fetch(title_field)
|
31
|
+
title.tr!(Kennel::Models::Record::LOCK, "") # avoid double lock icon
|
32
|
+
|
33
|
+
# calculate or reuse kennel_id
|
34
|
+
# TODO: this is copy-pasted from syncer, need to find a nice way to reuse it
|
35
|
+
tracking_field = Syncer::TRACKING_FIELDS.detect { |f| data[f] }
|
36
|
+
data[:kennel_id] =
|
37
|
+
if tracking_field && data[tracking_field].sub!(/\n?-- Managed by kennel (\S+:\S+).*/, "")
|
38
|
+
$1.split(":").last
|
39
|
+
else
|
40
|
+
Kennel::Utils.parameterize(title)
|
41
|
+
end
|
42
|
+
|
43
|
+
case resource
|
44
|
+
when "monitor"
|
45
|
+
# flatten monitor options so they are all on the base
|
46
|
+
data.merge!(data.delete(:options))
|
47
|
+
data.merge!(data.delete(:thresholds) || {})
|
48
|
+
[:notify_no_data, :notify_audit].each { |k| data.delete(k) if data[k] } # monitor uses true by default
|
49
|
+
data = data.slice(*model.instance_methods)
|
50
|
+
|
51
|
+
# make query use critical method if it matches
|
52
|
+
critical = data[:critical]
|
53
|
+
query = data[:query]
|
54
|
+
if query && critical
|
55
|
+
query.sub!(/([><=]) (#{Regexp.escape(critical.to_f.to_s)}|#{Regexp.escape(critical.to_i.to_s)})$/, "\\1 \#{critical}")
|
56
|
+
end
|
57
|
+
|
58
|
+
data[:type] = "query alert" if data[:type] == "metric alert"
|
59
|
+
when "dashboard"
|
60
|
+
widgets = data[:widgets]&.flat_map { |widget| widget.dig(:definition, :widgets) || [widget] }
|
61
|
+
widgets&.each { |widget| dry_up_query!(widget) }
|
62
|
+
end
|
63
|
+
|
64
|
+
# simplify template_variables to array of string when possible
|
65
|
+
if vars = data[:template_variables]
|
66
|
+
vars.map! { |v| v[:default] == "*" && v[:prefix] == v[:name] ? v[:name] : v }
|
67
|
+
end
|
68
|
+
|
69
|
+
pretty = pretty_print(data).lstrip.gsub("\\#", "#")
|
70
|
+
<<~RUBY
|
71
|
+
#{model.name}.new(
|
72
|
+
self,
|
73
|
+
#{pretty}
|
74
|
+
)
|
75
|
+
RUBY
|
76
|
+
end
|
77
|
+
|
78
|
+
private
|
79
|
+
|
80
|
+
# reduce duplication in imports by using dry `q: :metadata` when possible
|
81
|
+
def dry_up_query!(widget)
|
82
|
+
(widget.dig(:definition, :requests) || []).each do |request|
|
83
|
+
next unless request.is_a?(Hash)
|
84
|
+
next unless metadata = request[:metadata]
|
85
|
+
next unless query = request[:q]&.dup
|
86
|
+
metadata.each do |m|
|
87
|
+
next unless exp = m[:expression]
|
88
|
+
query.sub!(exp, "")
|
89
|
+
end
|
90
|
+
request[:q] = :metadata if query.delete(", ") == ""
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def pretty_print(hash)
|
95
|
+
sort_widgets hash
|
96
|
+
|
97
|
+
sort_hash(hash).map do |k, v|
|
98
|
+
pretty_value =
|
99
|
+
if v.is_a?(Hash) || (v.is_a?(Array) && !v.all? { |e| e.is_a?(String) })
|
100
|
+
# update answer here when changing https://stackoverflow.com/questions/8842546/best-way-to-pretty-print-a-hash
|
101
|
+
# (exclude last indent gsub)
|
102
|
+
pretty = JSON.pretty_generate(v)
|
103
|
+
.gsub(": null", ": nil")
|
104
|
+
.gsub(/(^\s*)"([a-zA-Z][a-zA-Z\d_]*)":/, "\\1\\2:") # "foo": 1 -> foo: 1
|
105
|
+
.gsub(/: \[\n\s+\]/, ": []") # empty arrays on a single line
|
106
|
+
.gsub(/^/, " ") # indent
|
107
|
+
.gsub('q: "metadata"', "q: :metadata") # bring symbols back
|
108
|
+
|
109
|
+
"\n#{pretty}\n "
|
110
|
+
elsif k == :message
|
111
|
+
"\n <<~TEXT\n#{v.each_line.map { |l| l.strip.empty? ? "\n" : " #{l}" }.join}\n \#{super()}\n TEXT\n "
|
112
|
+
elsif k == :tags
|
113
|
+
" super() + #{v.inspect} "
|
114
|
+
else
|
115
|
+
" #{v.inspect} "
|
116
|
+
end
|
117
|
+
" #{k}: -> {#{pretty_value}}"
|
118
|
+
end.join(",\n")
|
119
|
+
end
|
120
|
+
|
121
|
+
# sort dashboard widgets + nesting
|
122
|
+
def sort_widgets(outer)
|
123
|
+
outer[:widgets]&.each do |widgets|
|
124
|
+
definition = widgets[:definition]
|
125
|
+
definition.replace sort_hash(definition)
|
126
|
+
sort_widgets definition
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
# important to the front and rest deterministic
|
131
|
+
def sort_hash(hash)
|
132
|
+
Hash[hash.sort_by { |k, _| [SORT_ORDER.index(k) || 999, k] }]
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require "hashdiff"
|
3
|
+
|
4
|
+
module Kennel
|
5
|
+
module Models
|
6
|
+
class Base
|
7
|
+
extend SubclassTracking
|
8
|
+
include SettingsAsMethods
|
9
|
+
|
10
|
+
SETTING_OVERRIDABLE_METHODS = [:name, :kennel_id].freeze
|
11
|
+
|
12
|
+
def kennel_id
|
13
|
+
name = self.class.name
|
14
|
+
if name.start_with?("Kennel::")
|
15
|
+
raise_with_location ArgumentError, "Set :kennel_id"
|
16
|
+
end
|
17
|
+
@kennel_id ||= Utils.snake_case name
|
18
|
+
end
|
19
|
+
|
20
|
+
def name
|
21
|
+
self.class.name
|
22
|
+
end
|
23
|
+
|
24
|
+
def to_json # rubocop:disable Lint/ToJSON
|
25
|
+
raise NotImplementedError, "Use as_json"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,209 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Kennel
|
3
|
+
module Models
|
4
|
+
class Dashboard < Record
|
5
|
+
include TemplateVariables
|
6
|
+
include OptionalValidations
|
7
|
+
|
8
|
+
API_LIST_INCOMPLETE = true
|
9
|
+
DASHBOARD_DEFAULTS = { template_variables: [] }.freeze
|
10
|
+
READONLY_ATTRIBUTES = superclass::READONLY_ATTRIBUTES + [
|
11
|
+
:author_handle, :author_name, :modified_at, :url, :is_read_only, :notify_list
|
12
|
+
]
|
13
|
+
REQUEST_DEFAULTS = {
|
14
|
+
style: { line_width: "normal", palette: "dog_classic", line_type: "solid" }
|
15
|
+
}.freeze
|
16
|
+
WIDGET_DEFAULTS = {
|
17
|
+
"timeseries" => { show_legend: false, legend_size: "0" },
|
18
|
+
"note" => { background_color: "white", font_size: "14", show_tick: false, tick_edge: "left", tick_pos: "50%", text_align: "left" }
|
19
|
+
}.freeze
|
20
|
+
SUPPORTED_DEFINITION_OPTIONS = [:events, :markers, :precision].freeze
|
21
|
+
|
22
|
+
DEFAULTS = {
|
23
|
+
template_variable_presets: nil
|
24
|
+
}.freeze
|
25
|
+
|
26
|
+
settings :title, :description, :definitions, :widgets, :layout_type, :template_variable_presets
|
27
|
+
|
28
|
+
defaults(
|
29
|
+
description: -> { "" },
|
30
|
+
definitions: -> { [] },
|
31
|
+
widgets: -> { [] },
|
32
|
+
template_variable_presets: -> { DEFAULTS.fetch(:template_variable_presets) },
|
33
|
+
id: -> { nil }
|
34
|
+
)
|
35
|
+
|
36
|
+
class << self
|
37
|
+
def api_resource
|
38
|
+
"dashboard"
|
39
|
+
end
|
40
|
+
|
41
|
+
def normalize(expected, actual)
|
42
|
+
super
|
43
|
+
|
44
|
+
ignore_default(expected, actual, DEFAULTS)
|
45
|
+
|
46
|
+
widgets_pairs(expected, actual).each do |pair|
|
47
|
+
# conditional_formats ordering is randomly changed by datadog, compare a stable ordering
|
48
|
+
pair.each do |widgets|
|
49
|
+
widgets.each do |widget|
|
50
|
+
if formats = widget.dig(:definition, :conditional_formats)
|
51
|
+
widget[:definition][:conditional_formats] = formats.sort_by(&:hash)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
ignore_widget_defaults pair
|
57
|
+
|
58
|
+
ignore_request_defaults(*pair)
|
59
|
+
|
60
|
+
# ids are kinda random so we always discard them
|
61
|
+
pair.each { |widgets| widgets.each { |w| w.delete(:id) } }
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
def ignore_widget_defaults(pair)
|
68
|
+
pair.map(&:size).max.times do |i|
|
69
|
+
types = pair.map { |w| w.dig(i, :definition, :type) }.uniq
|
70
|
+
next unless types.size == 1
|
71
|
+
next unless defaults = WIDGET_DEFAULTS[types.first]
|
72
|
+
ignore_defaults(pair[0], pair[1], defaults, nesting: :definition)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
# discard styles/conditional_formats/aggregator if nothing would change when we applied (both are default or nil)
|
77
|
+
def ignore_request_defaults(expected, actual)
|
78
|
+
[expected.size, actual.size].max.times do |i|
|
79
|
+
a_r = actual.dig(i, :definition, :requests) || []
|
80
|
+
e_r = expected.dig(i, :definition, :requests) || []
|
81
|
+
ignore_defaults e_r, a_r, REQUEST_DEFAULTS
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def ignore_defaults(expected, actual, defaults, nesting: nil)
|
86
|
+
[expected.size, actual.size].max.times do |i|
|
87
|
+
e = expected.dig(i, *nesting) || {}
|
88
|
+
a = actual.dig(i, *nesting) || {}
|
89
|
+
ignore_default(e, a, defaults)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
# expand nested widgets into expected/actual pairs for default resolution
|
94
|
+
# [a, e] -> [[a-w, e-w], [a-w1-w1, e-w1-w1], ...]
|
95
|
+
def widgets_pairs(*pair)
|
96
|
+
result = [pair.map { |d| d[:widgets] || [] }]
|
97
|
+
slots = result[0].map(&:size).max
|
98
|
+
slots.times do |i|
|
99
|
+
nested = pair.map { |d| d.dig(:widgets, i, :definition, :widgets) || [] }
|
100
|
+
result << nested if nested.any?(&:any?)
|
101
|
+
end
|
102
|
+
result
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def as_json
|
107
|
+
return @json if @json
|
108
|
+
all_widgets = render_definitions + widgets
|
109
|
+
expand_q all_widgets
|
110
|
+
|
111
|
+
@json = {
|
112
|
+
layout_type: layout_type,
|
113
|
+
title: "#{title}#{LOCK}",
|
114
|
+
description: description,
|
115
|
+
template_variables: render_template_variables,
|
116
|
+
template_variable_presets: template_variable_presets,
|
117
|
+
widgets: all_widgets
|
118
|
+
}
|
119
|
+
|
120
|
+
@json[:id] = id if id
|
121
|
+
|
122
|
+
validate_json(@json) if validate
|
123
|
+
|
124
|
+
@json
|
125
|
+
end
|
126
|
+
|
127
|
+
def url(id)
|
128
|
+
Utils.path_to_url "/dashboard/#{id}"
|
129
|
+
end
|
130
|
+
|
131
|
+
def self.parse_url(url)
|
132
|
+
url[/\/dashboard\/([a-z\d-]+)/, 1]
|
133
|
+
end
|
134
|
+
|
135
|
+
def resolve_linked_tracking_ids!(id_map, **args)
|
136
|
+
widgets = as_json[:widgets].flat_map { |w| [w, *w.dig(:definition, :widgets) || []] }
|
137
|
+
widgets.each do |widget|
|
138
|
+
next unless definition = widget[:definition]
|
139
|
+
case definition[:type]
|
140
|
+
when "uptime"
|
141
|
+
if ids = definition[:monitor_ids]
|
142
|
+
definition[:monitor_ids] = ids.map do |id|
|
143
|
+
tracking_id?(id) ? resolve_link(id, :monitor, id_map, **args) : id
|
144
|
+
end
|
145
|
+
end
|
146
|
+
when "alert_graph"
|
147
|
+
if (id = definition[:alert_id]) && tracking_id?(id)
|
148
|
+
definition[:alert_id] = resolve_link(id, :monitor, id_map, **args).to_s
|
149
|
+
end
|
150
|
+
when "slo"
|
151
|
+
if (id = definition[:slo_id]) && tracking_id?(id)
|
152
|
+
definition[:slo_id] = resolve_link(id, :slo, id_map, **args).to_s
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
private
|
159
|
+
|
160
|
+
def tracking_id?(id)
|
161
|
+
id.is_a?(String) && id.include?(":")
|
162
|
+
end
|
163
|
+
|
164
|
+
# creates queries from metadata to avoid having to keep q and expression in sync
|
165
|
+
#
|
166
|
+
# {q: :metadata, metadata: [{expression: "sum:bar", alias_name: "foo"}, ...], }
|
167
|
+
# -> {q: "sum:bar, ...", metadata: ..., }
|
168
|
+
def expand_q(widgets)
|
169
|
+
widgets = widgets.flat_map { |w| w.dig(:definition, :widgets) || w } # expand groups
|
170
|
+
widgets.each do |w|
|
171
|
+
w.dig(:definition, :requests)&.each do |request|
|
172
|
+
next unless request.is_a?(Hash) && request[:q] == :metadata
|
173
|
+
request[:q] = request.fetch(:metadata).map { |m| m.fetch(:expression) }.join(", ")
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
def validate_json(data)
|
179
|
+
super
|
180
|
+
|
181
|
+
validate_template_variables data, :widgets
|
182
|
+
|
183
|
+
# Avoid diff from datadog presets sorting.
|
184
|
+
presets = data[:template_variable_presets]
|
185
|
+
invalid! "template_variable_presets must be sorted by name" if presets && presets != presets.sort_by { |p| p[:name] }
|
186
|
+
end
|
187
|
+
|
188
|
+
def render_definitions
|
189
|
+
definitions.map do |title, type, display_type, queries, options = {}, ignored = nil|
|
190
|
+
# validate inputs
|
191
|
+
if ignored || (!title || !type || !queries || !options.is_a?(Hash))
|
192
|
+
raise ArgumentError, "Expected exactly 5 arguments for each definition (title, type, display_type, queries, options)"
|
193
|
+
end
|
194
|
+
if (SUPPORTED_DEFINITION_OPTIONS | options.keys) != SUPPORTED_DEFINITION_OPTIONS
|
195
|
+
raise ArgumentError, "Supported options are: #{SUPPORTED_DEFINITION_OPTIONS.map(&:inspect).join(", ")}"
|
196
|
+
end
|
197
|
+
|
198
|
+
# build definition
|
199
|
+
requests = Array(queries).map do |q|
|
200
|
+
request = { q: q }
|
201
|
+
request[:display_type] = display_type if display_type
|
202
|
+
request
|
203
|
+
end
|
204
|
+
{ definition: { title: title, type: type, requests: requests, **options } }
|
205
|
+
end
|
206
|
+
end
|
207
|
+
end
|
208
|
+
end
|
209
|
+
end
|
@@ -0,0 +1,213 @@
|
|
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
|
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
|
+
)
|
49
|
+
|
50
|
+
def as_json
|
51
|
+
return @as_json if @as_json
|
52
|
+
data = {
|
53
|
+
name: "#{name}#{LOCK}",
|
54
|
+
type: type,
|
55
|
+
query: query.strip,
|
56
|
+
message: message.strip,
|
57
|
+
tags: tags.uniq,
|
58
|
+
options: {
|
59
|
+
timeout_h: timeout_h,
|
60
|
+
notify_no_data: notify_no_data,
|
61
|
+
no_data_timeframe: notify_no_data ? no_data_timeframe : nil,
|
62
|
+
notify_audit: notify_audit,
|
63
|
+
require_full_window: require_full_window,
|
64
|
+
new_host_delay: new_host_delay,
|
65
|
+
include_tags: true,
|
66
|
+
escalation_message: Utils.presence(escalation_message.strip),
|
67
|
+
evaluation_delay: evaluation_delay,
|
68
|
+
locked: false, # setting this to true prevents any edit and breaks updates when using replace workflow
|
69
|
+
renotify_interval: renotify_interval || 0
|
70
|
+
}
|
71
|
+
}
|
72
|
+
|
73
|
+
data[:id] = id if id
|
74
|
+
|
75
|
+
options = data[:options]
|
76
|
+
if data.fetch(:type) != "composite"
|
77
|
+
thresholds = (options[:thresholds] = { critical: critical })
|
78
|
+
|
79
|
+
# warning, ok, critical_recovery, and warning_recovery are optional
|
80
|
+
[:warning, :ok, :critical_recovery, :warning_recovery].each do |key|
|
81
|
+
if value = send(key)
|
82
|
+
thresholds[key] = value
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
thresholds[:critical] = critical unless
|
87
|
+
case data.fetch(:type)
|
88
|
+
when "service check"
|
89
|
+
# avoid diff for default values of 1
|
90
|
+
OPTIONAL_SERVICE_CHECK_THRESHOLDS.each { |t| thresholds[t] ||= 1 }
|
91
|
+
when "query alert"
|
92
|
+
# metric and query values are stored as float by datadog
|
93
|
+
thresholds.each { |k, v| thresholds[k] = Float(v) }
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
if windows = threshold_windows
|
98
|
+
options[:threshold_windows] = windows
|
99
|
+
end
|
100
|
+
|
101
|
+
validate_json(data) if validate
|
102
|
+
|
103
|
+
@as_json = data
|
104
|
+
end
|
105
|
+
|
106
|
+
def resolve_linked_tracking_ids!(id_map, **args)
|
107
|
+
if as_json[:type] == "composite"
|
108
|
+
as_json[:query] = as_json[:query].gsub(/%\{(.*?)\}/) do
|
109
|
+
resolve_link($1, :monitor, id_map, **args)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
def self.api_resource
|
115
|
+
"monitor"
|
116
|
+
end
|
117
|
+
|
118
|
+
def url(id)
|
119
|
+
Utils.path_to_url "/monitors##{id}/edit"
|
120
|
+
end
|
121
|
+
|
122
|
+
# datadog uses both / and # as separator in it's links
|
123
|
+
def self.parse_url(url)
|
124
|
+
return unless id = url[/\/monitors[\/#](\d+)/, 1]
|
125
|
+
Integer(id)
|
126
|
+
end
|
127
|
+
|
128
|
+
def self.normalize(expected, actual)
|
129
|
+
super
|
130
|
+
options = actual.fetch(:options)
|
131
|
+
options.delete(:silenced) # we do not manage silenced, so ignore it when diffing
|
132
|
+
|
133
|
+
# fields are not returned when set to true
|
134
|
+
if ["service check", "event alert"].include?(actual[:type])
|
135
|
+
options[:include_tags] = true unless options.key?(:include_tags)
|
136
|
+
options[:require_full_window] = true unless options.key?(:require_full_window)
|
137
|
+
end
|
138
|
+
|
139
|
+
case actual[:type]
|
140
|
+
when "event alert"
|
141
|
+
# setting nothing results in thresholds not getting returned from the api
|
142
|
+
options[:thresholds] ||= { critical: 0 }
|
143
|
+
|
144
|
+
when "service check"
|
145
|
+
# fields are not returned when created with default values via UI
|
146
|
+
OPTIONAL_SERVICE_CHECK_THRESHOLDS.each do |t|
|
147
|
+
options[:thresholds][t] ||= 1
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
# nil / "" / 0 are not returned from the api when set via the UI
|
152
|
+
options[:evaluation_delay] ||= nil
|
153
|
+
|
154
|
+
expected_options = expected[:options] || {}
|
155
|
+
ignore_default(expected_options, options, MONITOR_OPTION_DEFAULTS)
|
156
|
+
if DEFAULT_ESCALATION_MESSAGE.include?(options[:escalation_message])
|
157
|
+
options.delete(:escalation_message)
|
158
|
+
expected_options.delete(:escalation_message)
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
private
|
163
|
+
|
164
|
+
def require_full_window
|
165
|
+
# default 'on_average', 'at_all_times', 'in_total' aggregations to true, otherwise false
|
166
|
+
# https://docs.datadoghq.com/ap/#create-a-monitor
|
167
|
+
type != "query alert" || query.start_with?("avg", "min", "sum")
|
168
|
+
end
|
169
|
+
|
170
|
+
def validate_json(data)
|
171
|
+
super
|
172
|
+
|
173
|
+
type = data.fetch(:type)
|
174
|
+
|
175
|
+
# do not allow deprecated type that will be coverted by datadog and then produce a diff
|
176
|
+
if type == "metric alert"
|
177
|
+
invalid! "type 'metric alert' is deprecated, please set to a different type (e.g. 'query alert')"
|
178
|
+
end
|
179
|
+
|
180
|
+
# verify query includes critical value
|
181
|
+
if query_value = data.fetch(:query)[/\s*[<>]=?\s*(\d+(\.\d+)?)\s*$/, 1]
|
182
|
+
if Float(query_value) != Float(data.dig(:options, :thresholds, :critical))
|
183
|
+
invalid! "critical and value used in query must match"
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
# verify renotify interval is valid
|
188
|
+
unless RENOTIFY_INTERVALS.include? data.dig(:options, :renotify_interval)
|
189
|
+
invalid! "renotify_interval must be one of #{RENOTIFY_INTERVALS.join(", ")}"
|
190
|
+
end
|
191
|
+
|
192
|
+
if type == "query alert"
|
193
|
+
# verify interval is valud
|
194
|
+
interval = data.fetch(:query)[/\(last_(\S+?)\)/, 1]
|
195
|
+
if interval && !QUERY_INTERVALS.include?(interval)
|
196
|
+
invalid! "query interval was #{interval}, but must be one of #{QUERY_INTERVALS.join(", ")}"
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
if ["query alert", "service check"].include?(type) # TODO: most likely more types need this
|
201
|
+
# verify is_match uses available variables
|
202
|
+
message = data.fetch(:message)
|
203
|
+
used = message.scan(/{{\s*#is_match\s*"([a-zA-Z\d_.-]+).name"/).flatten.uniq
|
204
|
+
allowed = data.fetch(:query)[/by\s*[({]([^})]+)[})]/, 1].to_s.gsub(/["']/, "").split(/\s*,\s*/)
|
205
|
+
unsupported = used - allowed
|
206
|
+
if unsupported.any?
|
207
|
+
invalid! "is_match used with #{unsupported}, but metric is only grouped by #{allowed}"
|
208
|
+
end
|
209
|
+
end
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|
213
|
+
end
|