kennel 2.2.1 → 2.22.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 +4 -4
- data/Readme.md +75 -4
- data/lib/kennel/api.rb +103 -42
- data/lib/kennel/filter.rb +7 -11
- data/lib/kennel/importer.rb +14 -1
- data/lib/kennel/models/dashboard.rb +4 -3
- data/lib/kennel/models/monitor.rb +122 -55
- data/lib/kennel/models/project.rb +20 -4
- data/lib/kennel/models/record.rb +2 -6
- data/lib/kennel/models/slo.rb +41 -7
- data/lib/kennel/models/synthetic_test.rb +3 -0
- data/lib/kennel/models/team.rb +5 -3
- data/lib/kennel/parts_serializer.rb +30 -11
- data/lib/kennel/projects_provider.rb +3 -2
- data/lib/kennel/subclass_tracking.rb +8 -0
- data/lib/kennel/syncer/matched_expected.rb +7 -1
- data/lib/kennel/syncer/plan_printer.rb +1 -1
- data/lib/kennel/syncer/resolver.rb +1 -1
- data/lib/kennel/syncer.rb +25 -27
- data/lib/kennel/tasks/dump.rb +54 -0
- data/lib/kennel/tasks/import.rb +17 -0
- data/lib/kennel/tasks/nodata.rb +53 -0
- data/lib/kennel/tasks/tracking_id.rb +14 -0
- data/lib/kennel/tasks/validate_mentions.rb +41 -0
- data/lib/kennel/tasks/validate_plan.rb +114 -0
- data/lib/kennel/tasks.rb +9 -159
- data/lib/kennel/utils.rb +3 -2
- data/lib/kennel/version.rb +1 -1
- data/lib/kennel.rb +13 -7
- data/template/Readme.md +66 -4
- metadata +22 -20
data/lib/kennel/syncer.rb
CHANGED
|
@@ -13,14 +13,13 @@ module Kennel
|
|
|
13
13
|
|
|
14
14
|
attr_reader :plan
|
|
15
15
|
|
|
16
|
-
def initialize(api, expected
|
|
16
|
+
def initialize(api, expected:, actual:, filter:, strict_imports: true)
|
|
17
17
|
@api = api
|
|
18
18
|
@strict_imports = strict_imports
|
|
19
19
|
@filter = filter
|
|
20
20
|
|
|
21
21
|
@resolver = Resolver.new(expected: expected, filter: filter)
|
|
22
22
|
@plan = Plan.new(*calculate_changes(expected: expected, actual: actual))
|
|
23
|
-
validate_changes
|
|
24
23
|
end
|
|
25
24
|
|
|
26
25
|
def print_plan
|
|
@@ -94,7 +93,8 @@ module Kennel
|
|
|
94
93
|
convert_replace_into_update!(matching, unmatched_actual, unmatched_expected)
|
|
95
94
|
|
|
96
95
|
validate_expected_id_not_missing unmatched_expected
|
|
97
|
-
fill_details! matching # need details to diff later
|
|
96
|
+
uncached = fill_details! matching # need details to diff later
|
|
97
|
+
Kennel.out.puts "Uncached dashboard gets to fill details: #{uncached}" if ENV["SHOW_UNCACHED_FILL_DETAILS"]
|
|
98
98
|
|
|
99
99
|
# update matching if needed
|
|
100
100
|
updates = matching.map do |e, a|
|
|
@@ -126,21 +126,19 @@ module Kennel
|
|
|
126
126
|
# if there is a new item that has the same name or title as an "to be deleted" item,
|
|
127
127
|
# update it instead to avoid old urls from becoming invalid
|
|
128
128
|
# - careful with unmatched_actual being huge since it has all api resources
|
|
129
|
-
# - don't do it when
|
|
130
|
-
# - when using a filter and updating the kennel_id of an existing item, old and new must be in the filter
|
|
129
|
+
# - don't do it when update is not allowed
|
|
130
|
+
# - when using a filter and updating the kennel_id of an existing item, old and new must be in the filter (PROJECT= works, but not TRACKING_ID)
|
|
131
131
|
def convert_replace_into_update!(matching, unmatched_actual, unmatched_expected)
|
|
132
132
|
unmatched_expected.reject! do |e|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
break [field, value]
|
|
136
|
-
end
|
|
137
|
-
raise unless e_field # uncovered: should never happen ...
|
|
138
|
-
e_monitor_type = e.as_json[:type]
|
|
139
|
-
|
|
133
|
+
# find actual by title
|
|
134
|
+
e_field, e_value = title_field_and_value(e)
|
|
140
135
|
actual = unmatched_actual.detect do |a|
|
|
141
|
-
a[:klass].api_resource == e.class.api_resource &&
|
|
136
|
+
a[:klass].api_resource == e.class.api_resource &&
|
|
137
|
+
a[e_field] == e_value
|
|
142
138
|
end
|
|
143
|
-
|
|
139
|
+
|
|
140
|
+
# keep unmatched if we could not find or can't update
|
|
141
|
+
next false if !actual || e.allowed_update_error(actual)
|
|
144
142
|
|
|
145
143
|
# add as update and remove from unmatched
|
|
146
144
|
unmatched_actual.delete(actual)
|
|
@@ -150,10 +148,18 @@ module Kennel
|
|
|
150
148
|
end
|
|
151
149
|
end
|
|
152
150
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
151
|
+
def title_field_and_value(e)
|
|
152
|
+
Kennel::Models::Record::TITLE_FIELDS.detect do |field|
|
|
153
|
+
next unless (value = e.as_json[field])
|
|
154
|
+
return [field, value]
|
|
155
|
+
end
|
|
156
|
+
raise # uncovered: should never happen ...
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# fill details of things we need to compare, so diff works even though we cannot mass-fetch definitions
|
|
160
|
+
def fill_details!(matching)
|
|
161
|
+
dashboards = matching.filter_map { |e, a| a if e && e.class.api_resource == "dashboard" }
|
|
162
|
+
@api.fill_details! "dashboard", dashboards
|
|
157
163
|
end
|
|
158
164
|
|
|
159
165
|
def validate_expected_id_not_missing(expected)
|
|
@@ -169,20 +175,12 @@ module Kennel
|
|
|
169
175
|
end
|
|
170
176
|
end
|
|
171
177
|
|
|
172
|
-
# We've already validated the desired objects ('generated') in isolation.
|
|
173
|
-
# Now that we have made the plan, we can perform some more validation.
|
|
174
|
-
def validate_changes
|
|
175
|
-
@plan.updates.each do |item|
|
|
176
|
-
item.expected.validate_update!(item.diff)
|
|
177
|
-
end
|
|
178
|
-
end
|
|
179
|
-
|
|
180
178
|
def filter_actual!(actual)
|
|
181
179
|
return unless filter.filtering? # minor optimization
|
|
182
180
|
|
|
183
181
|
actual.select! do |a|
|
|
184
182
|
tracking_id = a.fetch(:tracking_id)
|
|
185
|
-
tracking_id.nil? || filter.
|
|
183
|
+
tracking_id.nil? || filter.filters_tracking_id?(tracking_id)
|
|
186
184
|
end
|
|
187
185
|
end
|
|
188
186
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :kennel do
|
|
4
|
+
desc "Dump ALL of datadog config as raw json ... useful for grep/search [TYPE=slo|monitor|dashboard]"
|
|
5
|
+
task dump: :environment do
|
|
6
|
+
resources =
|
|
7
|
+
if (type = ENV["TYPE"])
|
|
8
|
+
[type]
|
|
9
|
+
else
|
|
10
|
+
Kennel::Models::Record.api_resource_map.keys
|
|
11
|
+
end
|
|
12
|
+
api = Kennel::Api.new
|
|
13
|
+
list = nil
|
|
14
|
+
first = true
|
|
15
|
+
|
|
16
|
+
Kennel.out.puts "["
|
|
17
|
+
resources.each do |resource|
|
|
18
|
+
Kennel::Progress.progress("Downloading #{resource}") do
|
|
19
|
+
list = api.list(resource)
|
|
20
|
+
api.fill_details!(resource, list) if resource == "dashboard"
|
|
21
|
+
end
|
|
22
|
+
list.each do |r|
|
|
23
|
+
r[:api_resource] = resource
|
|
24
|
+
if first
|
|
25
|
+
first = false
|
|
26
|
+
else
|
|
27
|
+
Kennel.out.puts ","
|
|
28
|
+
end
|
|
29
|
+
Kennel.out.print JSON.pretty_generate(r)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
Kennel.out.puts "\n]"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
desc "Find items from dump by pattern DUMP= PATTERN= [URLS=true]"
|
|
36
|
+
task dump_grep: :environment do
|
|
37
|
+
file = ENV.fetch("DUMP")
|
|
38
|
+
pattern = Regexp.new ENV.fetch("PATTERN")
|
|
39
|
+
items = File.read(file)[2..-2].gsub("},\n{", "}--SPLIT--{").split("--SPLIT--")
|
|
40
|
+
models = Kennel::Models::Record.api_resource_map
|
|
41
|
+
found = items.grep(pattern)
|
|
42
|
+
exit 1 if found.empty?
|
|
43
|
+
found.each do |resource|
|
|
44
|
+
if ENV["URLS"]
|
|
45
|
+
parsed = JSON.parse(resource)
|
|
46
|
+
url = models[parsed.fetch("api_resource")].url(parsed.fetch("id"))
|
|
47
|
+
title = parsed["title"] || parsed["name"]
|
|
48
|
+
Kennel.out.puts "#{url} # #{title}"
|
|
49
|
+
else
|
|
50
|
+
Kennel.out.puts resource
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :kennel do
|
|
4
|
+
desc "Convert existing resources to copy-pasteable definitions to import existing resources (call with URL= or call with RESOURCE= and ID=)"
|
|
5
|
+
task import: :environment do
|
|
6
|
+
if (id = ENV["ID"]) && (resource = ENV["RESOURCE"])
|
|
7
|
+
id = Integer(id) if id =~ /^\d+$/
|
|
8
|
+
elsif (url = ENV["URL"])
|
|
9
|
+
resource, id = Kennel::Models::Record.parse_any_url(url) || Kennel::Tasks.abort("Unable to parse url")
|
|
10
|
+
else
|
|
11
|
+
possible_resources = Kennel::Models::Record.subclasses.map(&:api_resource)
|
|
12
|
+
Kennel::Tasks.abort("Call with URL= or call with RESOURCE=#{possible_resources.join(" or ")} and ID=")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
Kennel.out.puts Kennel::Importer.new(Kennel::Api.new).import(resource, id)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :kennel do
|
|
4
|
+
desc "show monitors with no data by TAG, for example TAG=team:foo [THRESHOLD_DAYS=7] [FORMAT=json]"
|
|
5
|
+
task nodata: :environment do
|
|
6
|
+
tag = ENV["TAG"] || Kennel::Tasks.abort("Call with TAG=foo:bar")
|
|
7
|
+
monitors = Kennel::Api.new.list("monitor", monitor_tags: tag, group_states: "no data")
|
|
8
|
+
monitors.select! { |m| m[:overall_state] == "No Data" }
|
|
9
|
+
monitors.reject! { |m| m[:tags].include? "nodata:ignore" }
|
|
10
|
+
if monitors.any?
|
|
11
|
+
Kennel.err.puts <<~TEXT
|
|
12
|
+
To ignore monitors with expected nodata, tag it with "nodata:ignore"
|
|
13
|
+
|
|
14
|
+
TEXT
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
now = Time.now
|
|
18
|
+
monitors.each do |m|
|
|
19
|
+
m[:days_in_no_data] =
|
|
20
|
+
if m[:overall_state_modified]
|
|
21
|
+
since = Date.parse(m[:overall_state_modified]).to_time
|
|
22
|
+
((now - since) / (24 * 60 * 60)).to_i
|
|
23
|
+
else
|
|
24
|
+
999
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
if (threshold = ENV["THRESHOLD_DAYS"])
|
|
29
|
+
monitors.select! { |m| m[:days_in_no_data] > Integer(threshold) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
monitors.each { |m| m[:url] = Kennel::Utils.path_to_url("/monitors/#{m[:id]}") }
|
|
33
|
+
|
|
34
|
+
if ENV["FORMAT"] == "json"
|
|
35
|
+
report = monitors.map do |m|
|
|
36
|
+
match = m[:message].to_s.match(/-- #{Regexp.escape(Kennel::Models::Record::MARKER_TEXT)} (\S+:\S+) in (\S+), /) || []
|
|
37
|
+
m.slice(:url, :name, :tags, :days_in_no_data).merge(
|
|
38
|
+
kennel_tracking_id: match[1],
|
|
39
|
+
kennel_source: match[2]
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
Kennel.out.puts JSON.pretty_generate(report)
|
|
44
|
+
else
|
|
45
|
+
monitors.each do |m|
|
|
46
|
+
Kennel.out.puts m[:name]
|
|
47
|
+
Kennel.out.puts Kennel::Utils.path_to_url("/monitors/#{m[:id]}")
|
|
48
|
+
Kennel.out.puts "No data since #{m[:days_in_no_data]}d"
|
|
49
|
+
Kennel.out.puts
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :kennel do
|
|
4
|
+
desc "Resolve given id to kennel tracking-id RESOURCE= ID="
|
|
5
|
+
task tracking_id: "kennel:environment" do
|
|
6
|
+
resource = ENV.fetch("RESOURCE")
|
|
7
|
+
id = ENV.fetch("ID")
|
|
8
|
+
klass =
|
|
9
|
+
Kennel::Models::Record.subclasses.detect { |s| s.api_resource == resource } ||
|
|
10
|
+
raise("resource #{resource} not know")
|
|
11
|
+
object = Kennel::Api.new.show(resource, id)
|
|
12
|
+
Kennel.out.puts klass.parse_tracking_id(object)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :kennel do
|
|
4
|
+
desc "Verify that all used monitor mentions are valid"
|
|
5
|
+
task validate_mentions: :environment do
|
|
6
|
+
known = []
|
|
7
|
+
|
|
8
|
+
# @slack- @team- @webhook- @sns- user-emails
|
|
9
|
+
known += Kennel::Api.new.send(:request, :get, "/api/v2/notifications/handles?group_limit=99999")
|
|
10
|
+
.fetch(:data)
|
|
11
|
+
.flat_map { |d| d.dig(:attributes, :handles) }
|
|
12
|
+
.map { |v| v.fetch(:value) }
|
|
13
|
+
|
|
14
|
+
# group emails or other 1-off things we know are valid
|
|
15
|
+
manual = ENV["KNOWN"].to_s.split(",")
|
|
16
|
+
dupes = (manual & known)
|
|
17
|
+
Kennel::Tasks.abort "KNOWN=#{dupes.join(",")} values are already known and should be removed" if dupes.any?
|
|
18
|
+
known += manual
|
|
19
|
+
|
|
20
|
+
# @sns- handles are randomly invalid so we need to ignore them without checking if the ignore is needed
|
|
21
|
+
# https://help.datadoghq.com/hc/en-us/requests/2310423
|
|
22
|
+
known += ENV["KNOWN_RANDOM"].to_s.split(",")
|
|
23
|
+
|
|
24
|
+
bad = []
|
|
25
|
+
Dir["generated/**/*.json"].each do |f|
|
|
26
|
+
next unless (message = JSON.parse(File.read(f))["message"])
|
|
27
|
+
used = message
|
|
28
|
+
.scan(/(?:^|\s)(@[^\s{,'"]+)/)
|
|
29
|
+
.flatten(1)
|
|
30
|
+
.grep(/^@.*@|^@.*-/) # ignore @here etc handles ... datadog uses @foo@bar.com for emails and @foo-bar for integrations
|
|
31
|
+
(used - known).each { |v| bad << [f, v] }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
if bad.any?
|
|
35
|
+
url = Kennel::Utils.path_to_url "/account/settings"
|
|
36
|
+
Kennel.err.puts "Invalid mentions found, either ignore them by adding to `KNOWN` env var or add them via #{url}"
|
|
37
|
+
bad.each { |f, v| Kennel.err.puts "Invalid mention #{v} in monitor message of #{f}" }
|
|
38
|
+
Kennel::Tasks.abort ENV["KNOWN_WARNING"]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module Kennel
|
|
3
|
+
module ValidatePlan
|
|
4
|
+
class MonitorValidator
|
|
5
|
+
COSMETIC_FIELDS = ["name", "message", "tags"].freeze
|
|
6
|
+
|
|
7
|
+
def initialize(item)
|
|
8
|
+
@item = item
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def validate(api)
|
|
12
|
+
data = @item.expected.as_json
|
|
13
|
+
|
|
14
|
+
# ignore unresolved ids from yet to be created monitors
|
|
15
|
+
return nil if ["composite", "slo alert"].include?(data[:type]) && data[:query].include?("%")
|
|
16
|
+
|
|
17
|
+
api.send(:request, :post, "/api/v1/monitor/validate", body: data)
|
|
18
|
+
nil
|
|
19
|
+
rescue StandardError => e
|
|
20
|
+
"#{Kennel::Console.color(:yellow, "#{@item.api_resource} #{@item.tracking_id}:")}\n#{e.message}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class DashboardValidator
|
|
25
|
+
COSMETIC_FIELDS = ["title", "description", "tags"].freeze
|
|
26
|
+
|
|
27
|
+
def initialize(item)
|
|
28
|
+
@item = item
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# datadog does not offer a validation api for dashboards,
|
|
32
|
+
# so we insert an invalid widget at the end and see if that is the invalid widget it complains about
|
|
33
|
+
# this will break if they ever start from the back or return errors for everything that is invalid
|
|
34
|
+
#
|
|
35
|
+
# we do not need to worry about unresolved ids because:
|
|
36
|
+
# - alert_graph widgets allows kennel style ids
|
|
37
|
+
# - slo widgets allow kennel style ids
|
|
38
|
+
# - uptime widgets allow kennel style ids
|
|
39
|
+
def validate(api)
|
|
40
|
+
json = @item.expected.as_json
|
|
41
|
+
json = Marshal.load(Marshal.dump(json))
|
|
42
|
+
|
|
43
|
+
# add a semi-valid (does not fail immediately on missing definition) widget still blocks the request
|
|
44
|
+
placeholder = "invalid_metric_do_not_update"
|
|
45
|
+
json.fetch(:widgets) << {
|
|
46
|
+
definition: {
|
|
47
|
+
type: "timeseries", requests: [{
|
|
48
|
+
response_format: "timeseries",
|
|
49
|
+
queries: [{ data_source: "metrics", name: "restarts", query: placeholder }]
|
|
50
|
+
}]
|
|
51
|
+
},
|
|
52
|
+
layout: { x: 0, y: 0, height: 0, width: 0 } # needed for `layout_type: free` and valid for all
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
begin
|
|
56
|
+
if @item.class::TYPE == :update
|
|
57
|
+
api.update("dashboard", @item.actual.fetch(:id), json)
|
|
58
|
+
else
|
|
59
|
+
api.create("dashboard", json)
|
|
60
|
+
end
|
|
61
|
+
raise "Dashboard validation should have failed, live dashboard was update/created by accident"
|
|
62
|
+
rescue StandardError => e
|
|
63
|
+
# parse the JSON in the error message and see if there is anything except our error
|
|
64
|
+
raise "Unreadable error format: #{e.message}" unless (json = e.message[/^\{"errors":.*}$/m])
|
|
65
|
+
data =
|
|
66
|
+
begin
|
|
67
|
+
JSON.parse(json)
|
|
68
|
+
rescue JSON::ParserError
|
|
69
|
+
raise "Unreadable error format: #{json}"
|
|
70
|
+
end
|
|
71
|
+
raise "Unreadable error format: #{data}" unless (errors = data["errors"]) # uncovered
|
|
72
|
+
return if errors.size == 1 && errors.all? { |m| m.include?("unable to parse #{placeholder}") }
|
|
73
|
+
"#{@item.tracking_id}: #{e.message}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
VALIDATORS = {
|
|
79
|
+
"monitor" => MonitorValidator,
|
|
80
|
+
"dashboard" => DashboardValidator
|
|
81
|
+
}.freeze
|
|
82
|
+
|
|
83
|
+
def self.validate(plan)
|
|
84
|
+
changes = (plan.creates + plan.updates)
|
|
85
|
+
|
|
86
|
+
validators = changes.filter_map do |item|
|
|
87
|
+
next unless (validator = VALIDATORS[item.api_resource]&.new(item))
|
|
88
|
+
|
|
89
|
+
if item.class::TYPE == :update
|
|
90
|
+
# ignore if nothing can break
|
|
91
|
+
modified_fields = item.diff.map { |_, f, *| f }
|
|
92
|
+
next nil if modified_fields.all? { |f| validator.class::COSMETIC_FIELDS.include?(f) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
validator
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
api = Kennel::Api.new
|
|
99
|
+
errors = validators.filter_map { |v| v.validate(api) }
|
|
100
|
+
return if errors.empty?
|
|
101
|
+
|
|
102
|
+
abort "#{Kennel::Console.color(:red, "#{errors.size} validation(s) failed:")}\n#{errors.join("\n")}"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
namespace :kennel do
|
|
108
|
+
desc "Validate planned changes against the Datadog API [PROJECT=]"
|
|
109
|
+
task "validate_plan" => "kennel:environment" do
|
|
110
|
+
kennel = Kennel::Tasks.kennel
|
|
111
|
+
kennel.preload
|
|
112
|
+
Kennel::ValidatePlan.validate(kennel.plan)
|
|
113
|
+
end
|
|
114
|
+
end
|
data/lib/kennel/tasks.rb
CHANGED
|
@@ -5,6 +5,8 @@ require "kennel/unmuted_alerts"
|
|
|
5
5
|
require "kennel/importer"
|
|
6
6
|
require "json"
|
|
7
7
|
|
|
8
|
+
Dir.children("#{__dir__}/tasks").each { |f| require_relative "tasks/#{File.basename(f, ".rb")}" }
|
|
9
|
+
|
|
8
10
|
module Kennel
|
|
9
11
|
module Tasks
|
|
10
12
|
class << self
|
|
@@ -25,6 +27,7 @@ module Kennel
|
|
|
25
27
|
source = ".env"
|
|
26
28
|
|
|
27
29
|
# warn when users have things like DATADOG_TOKEN already set and it will not be loaded from .env
|
|
30
|
+
# (KENNEL_SILENCE_UPDATED_ENV is intentionally not documented - users see it when needed)
|
|
28
31
|
unless ENV["KENNEL_SILENCE_UPDATED_ENV"]
|
|
29
32
|
updated = Dotenv.parse(source).select { |k, v| ENV[k] && ENV[k] != v }
|
|
30
33
|
warn "Environment variables #{updated.keys.join(", ")} need to be unset to be sourced from #{source}" if updated.any?
|
|
@@ -48,7 +51,11 @@ module Kennel
|
|
|
48
51
|
|
|
49
52
|
def on_default_branch?
|
|
50
53
|
branch = (ENV["TRAVIS_BRANCH"] || ENV["GITHUB_REF"]).to_s.sub(/^refs\/heads\//, "")
|
|
51
|
-
(
|
|
54
|
+
if (default = ENV["DEFAULT_BRANCH"])
|
|
55
|
+
branch == default
|
|
56
|
+
else
|
|
57
|
+
["main", "master"].include?(branch)
|
|
58
|
+
end
|
|
52
59
|
end
|
|
53
60
|
|
|
54
61
|
def git_push?
|
|
@@ -66,38 +73,7 @@ namespace :kennel do
|
|
|
66
73
|
Kennel::Tasks.abort "Error during diffing" unless $CHILD_STATUS.success?
|
|
67
74
|
end
|
|
68
75
|
|
|
69
|
-
|
|
70
|
-
# https://help.datadoghq.com/hc/en-us/requests/254114 for automatic validation
|
|
71
|
-
desc "Verify that all used monitor mentions are valid"
|
|
72
|
-
task validate_mentions: :environment do
|
|
73
|
-
known = Kennel::Api.new
|
|
74
|
-
.send(:request, :get, "/monitor/notifications")
|
|
75
|
-
.fetch(:handles)
|
|
76
|
-
.values
|
|
77
|
-
.flatten(1)
|
|
78
|
-
.map { |v| v.fetch(:value) }
|
|
79
|
-
|
|
80
|
-
known += ENV["KNOWN"].to_s.split(",")
|
|
81
|
-
|
|
82
|
-
bad = []
|
|
83
|
-
Dir["generated/**/*.json"].each do |f|
|
|
84
|
-
next unless (message = JSON.parse(File.read(f))["message"])
|
|
85
|
-
used = message
|
|
86
|
-
.scan(/(?:^|\s)(@[^\s{,'"]+)/)
|
|
87
|
-
.flatten(1)
|
|
88
|
-
.grep(/^@.*@|^@.*-/) # ignore @here etc handles ... datadog uses @foo@bar.com for emails and @foo-bar for integrations
|
|
89
|
-
(used - known).each { |v| bad << [f, v] }
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
if bad.any?
|
|
93
|
-
url = Kennel::Utils.path_to_url "/account/settings"
|
|
94
|
-
Kennel.err.puts "Invalid mentions found, either ignore them by adding to `KNOWN` env var or add them via #{url}"
|
|
95
|
-
bad.each { |f, v| Kennel.err.puts "Invalid mention #{v} in monitor message of #{f}" }
|
|
96
|
-
Kennel::Tasks.abort ENV["KNOWN_WARNING"]
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
desc "generate local definitions"
|
|
76
|
+
desc "store definitions in generated/"
|
|
101
77
|
task generate: :environment do
|
|
102
78
|
Kennel::Tasks.kennel.generate
|
|
103
79
|
end
|
|
@@ -129,132 +105,6 @@ namespace :kennel do
|
|
|
129
105
|
Kennel::UnmutedAlerts.print(Kennel::Api.new, tag)
|
|
130
106
|
end
|
|
131
107
|
|
|
132
|
-
desc "show monitors with no data by TAG, for example TAG=team:foo [THRESHOLD_DAYS=7] [FORMAT=json]"
|
|
133
|
-
task nodata: :environment do
|
|
134
|
-
tag = ENV["TAG"] || Kennel::Tasks.abort("Call with TAG=foo:bar")
|
|
135
|
-
monitors = Kennel::Api.new.list("monitor", monitor_tags: tag, group_states: "no data")
|
|
136
|
-
monitors.select! { |m| m[:overall_state] == "No Data" }
|
|
137
|
-
monitors.reject! { |m| m[:tags].include? "nodata:ignore" }
|
|
138
|
-
if monitors.any?
|
|
139
|
-
Kennel.err.puts <<~TEXT
|
|
140
|
-
To ignore monitors with expected nodata, tag it with "nodata:ignore"
|
|
141
|
-
|
|
142
|
-
TEXT
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
now = Time.now
|
|
146
|
-
monitors.each do |m|
|
|
147
|
-
m[:days_in_no_data] =
|
|
148
|
-
if m[:overall_state_modified]
|
|
149
|
-
since = Date.parse(m[:overall_state_modified]).to_time
|
|
150
|
-
((now - since) / (24 * 60 * 60)).to_i
|
|
151
|
-
else
|
|
152
|
-
999
|
|
153
|
-
end
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
if (threshold = ENV["THRESHOLD_DAYS"])
|
|
157
|
-
monitors.select! { |m| m[:days_in_no_data] > Integer(threshold) }
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
monitors.each { |m| m[:url] = Kennel::Utils.path_to_url("/monitors/#{m[:id]}") }
|
|
161
|
-
|
|
162
|
-
if ENV["FORMAT"] == "json"
|
|
163
|
-
report = monitors.map do |m|
|
|
164
|
-
match = m[:message].to_s.match(/-- #{Regexp.escape(Kennel::Models::Record::MARKER_TEXT)} (\S+:\S+) in (\S+), /) || []
|
|
165
|
-
m.slice(:url, :name, :tags, :days_in_no_data).merge(
|
|
166
|
-
kennel_tracking_id: match[1],
|
|
167
|
-
kennel_source: match[2]
|
|
168
|
-
)
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
Kennel.out.puts JSON.pretty_generate(report)
|
|
172
|
-
else
|
|
173
|
-
monitors.each do |m|
|
|
174
|
-
Kennel.out.puts m[:name]
|
|
175
|
-
Kennel.out.puts Kennel::Utils.path_to_url("/monitors/#{m[:id]}")
|
|
176
|
-
Kennel.out.puts "No data since #{m[:days_in_no_data]}d"
|
|
177
|
-
Kennel.out.puts
|
|
178
|
-
end
|
|
179
|
-
end
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
desc "Convert existing resources to copy-pasteable definitions to import existing resources (call with URL= or call with RESOURCE= and ID=)"
|
|
183
|
-
task import: :environment do
|
|
184
|
-
if (id = ENV["ID"]) && (resource = ENV["RESOURCE"])
|
|
185
|
-
id = Integer(id) if id =~ /^\d+$/ # dashboards can have alphanumeric ids
|
|
186
|
-
elsif (url = ENV["URL"])
|
|
187
|
-
resource, id = Kennel::Models::Record.parse_any_url(url) || Kennel::Tasks.abort("Unable to parse url")
|
|
188
|
-
else
|
|
189
|
-
possible_resources = Kennel::Models::Record.subclasses.map(&:api_resource)
|
|
190
|
-
Kennel::Tasks.abort("Call with URL= or call with RESOURCE=#{possible_resources.join(" or ")} and ID=")
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
Kennel.out.puts Kennel::Importer.new(Kennel::Api.new).import(resource, id)
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
desc "Dump ALL of datadog config as raw json ... useful for grep/search [TYPE=slo|monitor|dashboard]"
|
|
197
|
-
task dump: :environment do
|
|
198
|
-
resources =
|
|
199
|
-
if (type = ENV["TYPE"])
|
|
200
|
-
[type]
|
|
201
|
-
else
|
|
202
|
-
Kennel::Models::Record.api_resource_map.keys
|
|
203
|
-
end
|
|
204
|
-
api = Kennel::Api.new
|
|
205
|
-
list = nil
|
|
206
|
-
first = true
|
|
207
|
-
|
|
208
|
-
Kennel.out.puts "["
|
|
209
|
-
resources.each do |resource|
|
|
210
|
-
Kennel::Progress.progress("Downloading #{resource}") do
|
|
211
|
-
list = api.list(resource)
|
|
212
|
-
api.fill_details!(resource, list) if resource == "dashboard"
|
|
213
|
-
end
|
|
214
|
-
list.each do |r|
|
|
215
|
-
r[:api_resource] = resource
|
|
216
|
-
if first
|
|
217
|
-
first = false
|
|
218
|
-
else
|
|
219
|
-
Kennel.out.puts ","
|
|
220
|
-
end
|
|
221
|
-
Kennel.out.print JSON.pretty_generate(r)
|
|
222
|
-
end
|
|
223
|
-
end
|
|
224
|
-
Kennel.out.puts "\n]"
|
|
225
|
-
end
|
|
226
|
-
|
|
227
|
-
desc "Find items from dump by pattern DUMP= PATTERN= [URLS=true]"
|
|
228
|
-
task dump_grep: :environment do
|
|
229
|
-
file = ENV.fetch("DUMP")
|
|
230
|
-
pattern = Regexp.new ENV.fetch("PATTERN")
|
|
231
|
-
items = File.read(file)[2..-2].gsub("},\n{", "}--SPLIT--{").split("--SPLIT--")
|
|
232
|
-
models = Kennel::Models::Record.api_resource_map
|
|
233
|
-
found = items.grep(pattern)
|
|
234
|
-
exit 1 if found.empty?
|
|
235
|
-
found.each do |resource|
|
|
236
|
-
if ENV["URLS"]
|
|
237
|
-
parsed = JSON.parse(resource)
|
|
238
|
-
url = models[parsed.fetch("api_resource")].url(parsed.fetch("id"))
|
|
239
|
-
title = parsed["title"] || parsed["name"]
|
|
240
|
-
Kennel.out.puts "#{url} # #{title}"
|
|
241
|
-
else
|
|
242
|
-
Kennel.out.puts resource
|
|
243
|
-
end
|
|
244
|
-
end
|
|
245
|
-
end
|
|
246
|
-
|
|
247
|
-
desc "Resolve given id to kennel tracking-id RESOURCE= ID="
|
|
248
|
-
task tracking_id: "kennel:environment" do
|
|
249
|
-
resource = ENV.fetch("RESOURCE")
|
|
250
|
-
id = ENV.fetch("ID")
|
|
251
|
-
klass =
|
|
252
|
-
Kennel::Models::Record.subclasses.detect { |s| s.api_resource == resource } ||
|
|
253
|
-
raise("resource #{resource} not know")
|
|
254
|
-
object = Kennel::Api.new.show(resource, id)
|
|
255
|
-
Kennel.out.puts klass.parse_tracking_id(object)
|
|
256
|
-
end
|
|
257
|
-
|
|
258
108
|
task :environment do
|
|
259
109
|
Kennel::Tasks.load_environment
|
|
260
110
|
end
|
data/lib/kennel/utils.rb
CHANGED
|
@@ -24,13 +24,14 @@ module Kennel
|
|
|
24
24
|
workers = Array.new(threads).map do
|
|
25
25
|
Thread.new do
|
|
26
26
|
loop do
|
|
27
|
-
item, i = work.
|
|
27
|
+
item, i = work.shift
|
|
28
28
|
break unless i
|
|
29
29
|
done[i] =
|
|
30
30
|
begin
|
|
31
31
|
yield item
|
|
32
32
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
33
|
-
work.clear
|
|
33
|
+
work.clear # prevent new work
|
|
34
|
+
(workers - [Thread.current]).each(&:kill) # stop ongoing work
|
|
34
35
|
e
|
|
35
36
|
end
|
|
36
37
|
end
|
data/lib/kennel/version.rb
CHANGED
data/lib/kennel.rb
CHANGED
|
@@ -56,7 +56,7 @@ module Kennel
|
|
|
56
56
|
self.err = $stderr
|
|
57
57
|
|
|
58
58
|
class Engine
|
|
59
|
-
attr_accessor :strict_imports
|
|
59
|
+
attr_accessor :strict_imports # TODO: rename to :enforce_expected_ids_exist
|
|
60
60
|
|
|
61
61
|
def initialize
|
|
62
62
|
@strict_imports = true
|
|
@@ -69,7 +69,9 @@ module Kennel
|
|
|
69
69
|
|
|
70
70
|
def generate
|
|
71
71
|
parts = generated
|
|
72
|
-
|
|
72
|
+
if ENV["STORE"] != "false" # quicker when debugging
|
|
73
|
+
PartsSerializer.new(filter: filter).write(parts)
|
|
74
|
+
end
|
|
73
75
|
parts
|
|
74
76
|
end
|
|
75
77
|
|
|
@@ -93,7 +95,9 @@ module Kennel
|
|
|
93
95
|
@syncer ||= begin
|
|
94
96
|
preload
|
|
95
97
|
Syncer.new(
|
|
96
|
-
api,
|
|
98
|
+
api,
|
|
99
|
+
expected: generated,
|
|
100
|
+
actual: definitions,
|
|
97
101
|
filter: filter,
|
|
98
102
|
strict_imports: strict_imports
|
|
99
103
|
)
|
|
@@ -107,8 +111,7 @@ module Kennel
|
|
|
107
111
|
def generated(**kwargs)
|
|
108
112
|
@generated ||= begin
|
|
109
113
|
projects = Progress.progress "Loading projects", **kwargs do
|
|
110
|
-
|
|
111
|
-
filter.filter_projects projects
|
|
114
|
+
ProjectsProvider.new(filter: filter).projects
|
|
112
115
|
end
|
|
113
116
|
|
|
114
117
|
parts = Progress.progress "Finding parts", **kwargs do
|
|
@@ -129,7 +132,7 @@ module Kennel
|
|
|
129
132
|
end
|
|
130
133
|
end
|
|
131
134
|
|
|
132
|
-
# performance: this takes ~100ms on large codebases, tried rewriting with Set or Hash but it was slower
|
|
135
|
+
# performance: this takes ~100ms on large codebases, tried rewriting with Set or Hash, but it was slower
|
|
133
136
|
def validate_unique_tracking_ids(parts)
|
|
134
137
|
bad = parts.group_by(&:tracking_id).select { |_, same| same.size > 1 }
|
|
135
138
|
return if bad.empty?
|
|
@@ -143,7 +146,10 @@ module Kennel
|
|
|
143
146
|
def definitions(**kwargs)
|
|
144
147
|
@definitions ||= Progress.progress("Downloading definitions", **kwargs) do
|
|
145
148
|
Utils.parallel(Models::Record.subclasses) do |klass|
|
|
146
|
-
|
|
149
|
+
# lookup monitors without adding unnecessary downtime information
|
|
150
|
+
params = (klass.api_resource == "monitor" ? { with_downtimes: false } : {})
|
|
151
|
+
|
|
152
|
+
api.list(klass.api_resource, params)
|
|
147
153
|
end.flatten(1)
|
|
148
154
|
end
|
|
149
155
|
end
|