kennel 1.86.1 → 1.89.1
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 +6 -1
- data/lib/kennel/api.rb +67 -44
- data/lib/kennel/file_cache.rb +9 -5
- data/lib/kennel/importer.rb +26 -7
- data/lib/kennel/models/base.rb +1 -1
- data/lib/kennel/models/dashboard.rb +73 -34
- data/lib/kennel/models/monitor.rb +14 -2
- data/lib/kennel/models/record.rb +38 -3
- data/lib/kennel/models/slo.rb +2 -1
- data/lib/kennel/progress.rb +2 -0
- data/lib/kennel/syncer.rb +66 -81
- data/lib/kennel/tasks.rb +1 -1
- data/lib/kennel/template_variables.rb +2 -1
- data/lib/kennel/version.rb +1 -1
- data/template/Readme.md +1 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ba1d58935b541b89cb78318964470bb209933a491d9b81e23785de0fb6ab2654
|
|
4
|
+
data.tar.gz: aed4b13dc2c35ced80073e592fd0d2ba2afda8dfb763d2c6baa4b1f30851cf53
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d3595608df6e40ec4241bf2b758f1ed1b3fde0e63dff7f5a4fa393a358cf9cb9fa33bb0c5f72899b7df54817cb9482366d9c387e3c21a33249e35e3bcc4fc8ea
|
|
7
|
+
data.tar.gz: 745ec8c762acc885a9d0f2ae23fc9f43ca05f56b8c580501075e2b9237e7199a09499053cebb45e5bfa477aa0a187cabf5899e05f7bf6c361f952347ab6cac34
|
data/Readme.md
CHANGED
|
@@ -52,6 +52,7 @@ end
|
|
|
52
52
|
```
|
|
53
53
|
|
|
54
54
|
<!-- NOT IN template/Readme.md -->
|
|
55
|
+
|
|
55
56
|
## Installation
|
|
56
57
|
|
|
57
58
|
- create a new private `kennel` repo for your organization (do not fork this repo)
|
|
@@ -114,6 +115,7 @@ end
|
|
|
114
115
|
- use [datadog monitor UI](https://app.datadoghq.com/monitors/manage) to find a monitor
|
|
115
116
|
- get the `id` from the url
|
|
116
117
|
- run `URL='https://app.datadoghq.com/monitors/123' bundle exec rake kennel:import` and copy the output
|
|
118
|
+
- import task also works with SLO alerts, e.g. `URL='https://app.datadoghq.com/slo/edit/123abc456def123/alerts/789' bundle exec rake kennel:import`
|
|
117
119
|
- find or create a project in `projects/`
|
|
118
120
|
- add the monitor to `parts: [` list, for example:
|
|
119
121
|
```Ruby
|
|
@@ -292,9 +294,12 @@ https://foo.datadog.com/monitor/123
|
|
|
292
294
|
|
|
293
295
|
<!-- NOT IN template/Readme.md -->
|
|
294
296
|
|
|
295
|
-
|
|
296
297
|
## Development
|
|
297
298
|
|
|
299
|
+
### Benchmarking
|
|
300
|
+
|
|
301
|
+
Setting `FORCE_GET_CACHE=true` will cache all get requests, which makes benchmarking improvements more reliable.
|
|
302
|
+
|
|
298
303
|
### Integration testing
|
|
299
304
|
|
|
300
305
|
```Bash
|
data/lib/kennel/api.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# encapsulates knowledge around how the api works
|
|
3
|
+
# especially 1-off weirdness that should not lak into other parts of the code
|
|
2
4
|
module Kennel
|
|
3
|
-
# encapsulates knowledge around how the api works
|
|
4
5
|
class Api
|
|
5
6
|
CACHE_FILE = "tmp/cache/details"
|
|
6
7
|
|
|
@@ -11,34 +12,24 @@ module Kennel
|
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def show(api_resource, id, params = {})
|
|
14
|
-
|
|
15
|
-
api_resource == "slo"
|
|
15
|
+
response = request :get, "/api/v1/#{api_resource}/#{id}", params: params
|
|
16
|
+
response = response.fetch(:data) if api_resource == "slo"
|
|
17
|
+
response
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
def list(api_resource, params = {})
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
loop do
|
|
26
|
-
result = request :get, "/api/v1/#{api_resource}", params: params.merge(limit: limit, offset: offset)
|
|
27
|
-
data = result.fetch(:data)
|
|
28
|
-
all.concat data
|
|
29
|
-
break all if data.size < limit
|
|
30
|
-
offset += limit
|
|
31
|
-
end
|
|
32
|
-
else
|
|
33
|
-
result = request :get, "/api/v1/#{api_resource}", params: params
|
|
34
|
-
result = result.fetch(:dashboards) if api_resource == "dashboard"
|
|
35
|
-
result
|
|
21
|
+
with_pagination api_resource == "slo", params do |paginated_params|
|
|
22
|
+
response = request :get, "/api/v1/#{api_resource}", params: paginated_params
|
|
23
|
+
response = response.fetch(:dashboards) if api_resource == "dashboard"
|
|
24
|
+
response = response.fetch(:data) if api_resource == "slo"
|
|
25
|
+
response
|
|
36
26
|
end
|
|
37
27
|
end
|
|
38
28
|
|
|
39
29
|
def create(api_resource, attributes)
|
|
40
|
-
|
|
41
|
-
|
|
30
|
+
response = request :post, "/api/v1/#{api_resource}", body: attributes
|
|
31
|
+
response = response.fetch(:data).first if api_resource == "slo"
|
|
32
|
+
response
|
|
42
33
|
end
|
|
43
34
|
|
|
44
35
|
def update(api_resource, id, attributes)
|
|
@@ -53,7 +44,6 @@ module Kennel
|
|
|
53
44
|
end
|
|
54
45
|
|
|
55
46
|
def fill_details!(api_resource, list)
|
|
56
|
-
return unless api_resource == "dashboard"
|
|
57
47
|
details_cache do |cache|
|
|
58
48
|
Utils.parallel(list) { |a| fill_detail!(api_resource, a, cache) }
|
|
59
49
|
end
|
|
@@ -61,6 +51,21 @@ module Kennel
|
|
|
61
51
|
|
|
62
52
|
private
|
|
63
53
|
|
|
54
|
+
def with_pagination(enabled, params)
|
|
55
|
+
return yield params unless enabled
|
|
56
|
+
raise ArgumentError if params[:limit] || params[:offset]
|
|
57
|
+
limit = 1000
|
|
58
|
+
offset = 0
|
|
59
|
+
all = []
|
|
60
|
+
|
|
61
|
+
loop do
|
|
62
|
+
response = yield params.merge(limit: limit, offset: offset)
|
|
63
|
+
all.concat response
|
|
64
|
+
return all if response.size < limit
|
|
65
|
+
offset += limit
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
64
69
|
# Make diff work even though we cannot mass-fetch definitions
|
|
65
70
|
def fill_detail!(api_resource, a, cache)
|
|
66
71
|
args = [api_resource, a.fetch(:id)]
|
|
@@ -74,34 +79,52 @@ module Kennel
|
|
|
74
79
|
end
|
|
75
80
|
|
|
76
81
|
def request(method, path, body: nil, params: {}, ignore_404: false)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
82
|
+
path = "#{path}?#{Faraday::FlatParamsEncoder.encode(params)}" if params.any?
|
|
83
|
+
with_cache ENV["FORCE_GET_CACHE"] && method == :get, path do
|
|
84
|
+
response = nil
|
|
85
|
+
tries = 2
|
|
86
|
+
|
|
87
|
+
tries.times do |i|
|
|
88
|
+
response = Utils.retry Faraday::ConnectionFailed, Faraday::TimeoutError, times: 2 do
|
|
89
|
+
@client.send(method, path) do |request|
|
|
90
|
+
request.body = JSON.generate(body) if body
|
|
91
|
+
request.headers["Content-type"] = "application/json"
|
|
92
|
+
request.headers["DD-API-KEY"] = @api_key
|
|
93
|
+
request.headers["DD-APPLICATION-KEY"] = @app_key
|
|
94
|
+
end
|
|
87
95
|
end
|
|
96
|
+
|
|
97
|
+
break if i == tries - 1 || method != :get || response.status < 500
|
|
98
|
+
Kennel.err.puts "Retrying on server error #{response.status} for #{path}"
|
|
88
99
|
end
|
|
89
100
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
101
|
+
if !response.success? && (response.status != 404 || !ignore_404)
|
|
102
|
+
message = +"Error #{response.status} during #{method.upcase} #{path}\n"
|
|
103
|
+
message << "request:\n#{JSON.pretty_generate(body)}\nresponse:\n" if body
|
|
104
|
+
message << response.body
|
|
105
|
+
raise message
|
|
106
|
+
end
|
|
93
107
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
108
|
+
if response.body.empty?
|
|
109
|
+
{}
|
|
110
|
+
else
|
|
111
|
+
JSON.parse(response.body, symbolize_names: true)
|
|
112
|
+
end
|
|
99
113
|
end
|
|
114
|
+
end
|
|
100
115
|
|
|
101
|
-
|
|
102
|
-
|
|
116
|
+
# allow caching all requests to speedup/benchmark logic that includes repeated requests
|
|
117
|
+
def with_cache(enabled, key)
|
|
118
|
+
return yield unless enabled
|
|
119
|
+
dir = "tmp/cache"
|
|
120
|
+
FileUtils.mkdir_p(dir) unless File.directory?(dir)
|
|
121
|
+
file = "#{dir}/#{key.delete("/?=")}" # TODO: encode nicely
|
|
122
|
+
if File.exist?(file)
|
|
123
|
+
Marshal.load(File.read(file)) # rubocop:disable Security/MarshalLoad
|
|
103
124
|
else
|
|
104
|
-
|
|
125
|
+
result = yield
|
|
126
|
+
File.write(file, Marshal.dump(result))
|
|
127
|
+
result
|
|
105
128
|
end
|
|
106
129
|
end
|
|
107
130
|
end
|
data/lib/kennel/file_cache.rb
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# cache that reads everything from a single file
|
|
4
|
-
#
|
|
5
|
-
#
|
|
4
|
+
# - avoids doing multiple disk reads while iterating all definitions
|
|
5
|
+
# - has a global expiry to not keep deleted resources forever
|
|
6
6
|
module Kennel
|
|
7
7
|
class FileCache
|
|
8
8
|
def initialize(file, cache_version)
|
|
@@ -22,10 +22,11 @@ module Kennel
|
|
|
22
22
|
|
|
23
23
|
def fetch(key, key_version)
|
|
24
24
|
old_value, old_version = @data[key]
|
|
25
|
-
|
|
25
|
+
expected_version = [key_version, @cache_version]
|
|
26
|
+
return old_value if old_version == expected_version
|
|
26
27
|
|
|
27
28
|
new_value = yield
|
|
28
|
-
@data[key] = [new_value,
|
|
29
|
+
@data[key] = [new_value, expected_version, @expires]
|
|
29
30
|
new_value
|
|
30
31
|
end
|
|
31
32
|
|
|
@@ -46,8 +47,11 @@ module Kennel
|
|
|
46
47
|
File.write(@file, Marshal.dump(@data))
|
|
47
48
|
end
|
|
48
49
|
|
|
50
|
+
# keep the cache small to make loading it fast (5MB ~= 100ms)
|
|
51
|
+
# - delete expired keys
|
|
52
|
+
# - delete what would be deleted anyway when updating
|
|
49
53
|
def expire_old_data
|
|
50
|
-
@data.reject! { |_, (_, _,
|
|
54
|
+
@data.reject! { |_, (_, (_, cv), expires)| expires < @now || cv != @cache_version }
|
|
51
55
|
end
|
|
52
56
|
end
|
|
53
57
|
end
|
data/lib/kennel/importer.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Kennel
|
|
4
4
|
class Importer
|
|
5
5
|
TITLES = [:name, :title].freeze
|
|
6
|
-
SORT_ORDER = [*TITLES, :id, :kennel_id, :type, :tags, :query, *
|
|
6
|
+
SORT_ORDER = [*TITLES, :id, :kennel_id, :type, :tags, :query, *Models::Record.subclasses.map { |k| k::TRACKING_FIELDS }, :template_variables].freeze
|
|
7
7
|
|
|
8
8
|
def initialize(api)
|
|
9
9
|
@api = api
|
|
@@ -31,11 +31,10 @@ module Kennel
|
|
|
31
31
|
title.tr!(Kennel::Models::Record::LOCK, "") # avoid double lock icon
|
|
32
32
|
|
|
33
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
34
|
data[:kennel_id] =
|
|
37
|
-
if
|
|
38
|
-
|
|
35
|
+
if tracking_id = model.parse_tracking_id(data)
|
|
36
|
+
model.remove_tracking_id(data)
|
|
37
|
+
tracking_id.split(":").last
|
|
39
38
|
else
|
|
40
39
|
Kennel::Utils.parameterize(title)
|
|
41
40
|
end
|
|
@@ -66,7 +65,13 @@ module Kennel
|
|
|
66
65
|
data[:type] = "query alert" if data[:type] == "metric alert"
|
|
67
66
|
when "dashboard"
|
|
68
67
|
widgets = data[:widgets]&.flat_map { |widget| widget.dig(:definition, :widgets) || [widget] }
|
|
69
|
-
widgets&.each
|
|
68
|
+
widgets&.each do |widget|
|
|
69
|
+
convert_widget_to_compact_format!(widget)
|
|
70
|
+
dry_up_widget_metadata!(widget)
|
|
71
|
+
(widget.dig(:definition, :markers) || []).each { |m| m[:label]&.delete! " " }
|
|
72
|
+
end
|
|
73
|
+
else
|
|
74
|
+
# noop
|
|
70
75
|
end
|
|
71
76
|
|
|
72
77
|
data.delete(:tags) if data[:tags] == [] # do not create super + [] call
|
|
@@ -88,7 +93,7 @@ module Kennel
|
|
|
88
93
|
private
|
|
89
94
|
|
|
90
95
|
# reduce duplication in imports by using dry `q: :metadata` when possible
|
|
91
|
-
def
|
|
96
|
+
def dry_up_widget_metadata!(widget)
|
|
92
97
|
(widget.dig(:definition, :requests) || []).each do |request|
|
|
93
98
|
next unless request.is_a?(Hash)
|
|
94
99
|
next unless metadata = request[:metadata]
|
|
@@ -101,6 +106,20 @@ module Kennel
|
|
|
101
106
|
end
|
|
102
107
|
end
|
|
103
108
|
|
|
109
|
+
# new api format is very verbose, so use old dry format when possible
|
|
110
|
+
def convert_widget_to_compact_format!(widget)
|
|
111
|
+
(widget.dig(:definition, :requests) || []).each do |request|
|
|
112
|
+
next unless request.is_a?(Hash)
|
|
113
|
+
next if request[:formulas] && request[:formulas] != [{ formula: "query1" }]
|
|
114
|
+
next if request[:queries]&.size != 1
|
|
115
|
+
next if request[:queries].any? { |q| q[:data_source] != "metrics" }
|
|
116
|
+
next if widget.dig(:definition, :type) != request[:response_format]
|
|
117
|
+
request.delete(:formulas)
|
|
118
|
+
request.delete(:response_format)
|
|
119
|
+
request[:q] = request.delete(:queries).first.fetch(:query)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
104
123
|
def pretty_print(hash)
|
|
105
124
|
sort_widgets hash
|
|
106
125
|
|
data/lib/kennel/models/base.rb
CHANGED
|
@@ -11,7 +11,7 @@ module Kennel
|
|
|
11
11
|
|
|
12
12
|
def kennel_id
|
|
13
13
|
name = self.class.name
|
|
14
|
-
if name.start_with?("Kennel::")
|
|
14
|
+
if name.start_with?("Kennel::") # core objects would always generate the same id
|
|
15
15
|
raise_with_location ArgumentError, "Set :kennel_id"
|
|
16
16
|
end
|
|
17
17
|
@kennel_id ||= Utils.snake_case name
|
|
@@ -8,12 +8,61 @@ module Kennel
|
|
|
8
8
|
READONLY_ATTRIBUTES = superclass::READONLY_ATTRIBUTES + [
|
|
9
9
|
:author_handle, :author_name, :modified_at, :url, :is_read_only, :notify_list
|
|
10
10
|
]
|
|
11
|
+
TRACKING_FIELD = :description
|
|
11
12
|
REQUEST_DEFAULTS = {
|
|
12
13
|
style: { line_width: "normal", palette: "dog_classic", line_type: "solid" }
|
|
13
14
|
}.freeze
|
|
14
15
|
WIDGET_DEFAULTS = {
|
|
15
|
-
"timeseries" => {
|
|
16
|
-
|
|
16
|
+
"timeseries" => {
|
|
17
|
+
legend_size: "0",
|
|
18
|
+
markers: [],
|
|
19
|
+
legend_columns: [
|
|
20
|
+
"avg",
|
|
21
|
+
"min",
|
|
22
|
+
"max",
|
|
23
|
+
"value",
|
|
24
|
+
"sum"
|
|
25
|
+
],
|
|
26
|
+
legend_layout: "auto",
|
|
27
|
+
yaxis: {
|
|
28
|
+
include_zero: true,
|
|
29
|
+
label: "",
|
|
30
|
+
scale: "linear",
|
|
31
|
+
min: "auto",
|
|
32
|
+
max: "auto"
|
|
33
|
+
},
|
|
34
|
+
show_legend: true,
|
|
35
|
+
time: {},
|
|
36
|
+
title_align: "left",
|
|
37
|
+
title_size: "16"
|
|
38
|
+
},
|
|
39
|
+
"note" => {
|
|
40
|
+
show_tick: false,
|
|
41
|
+
tick_edge: "left",
|
|
42
|
+
tick_pos: "50%",
|
|
43
|
+
text_align: "left",
|
|
44
|
+
has_padding: true,
|
|
45
|
+
background_color: "white",
|
|
46
|
+
font_size: "14"
|
|
47
|
+
},
|
|
48
|
+
"query_value" => {
|
|
49
|
+
autoscale: true,
|
|
50
|
+
time: {},
|
|
51
|
+
title_align: "left",
|
|
52
|
+
title_size: "16"
|
|
53
|
+
},
|
|
54
|
+
"free_text" => {
|
|
55
|
+
font_size: "auto"
|
|
56
|
+
},
|
|
57
|
+
"check_status" => {
|
|
58
|
+
title_align: "left",
|
|
59
|
+
title_size: "16"
|
|
60
|
+
},
|
|
61
|
+
"slo" => {
|
|
62
|
+
global_time_target: "0",
|
|
63
|
+
title_align: "left",
|
|
64
|
+
title_size: "16"
|
|
65
|
+
}
|
|
17
66
|
}.freeze
|
|
18
67
|
SUPPORTED_DEFINITION_OPTIONS = [:events, :markers, :precision].freeze
|
|
19
68
|
|
|
@@ -40,53 +89,43 @@ module Kennel
|
|
|
40
89
|
def normalize(expected, actual)
|
|
41
90
|
super
|
|
42
91
|
|
|
43
|
-
ignore_default
|
|
44
|
-
ignore_default
|
|
92
|
+
ignore_default expected, actual, DEFAULTS
|
|
93
|
+
ignore_default expected, actual, reflow_type: "auto" if expected[:layout_type] == "ordered"
|
|
45
94
|
|
|
46
95
|
widgets_pairs(expected, actual).each do |pair|
|
|
47
|
-
|
|
48
|
-
pair
|
|
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
|
-
|
|
96
|
+
pair.each { |w| sort_conditional_formats w }
|
|
97
|
+
ignore_widget_defaults(*pair)
|
|
58
98
|
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) } }
|
|
99
|
+
pair.each { |widget| widget&.delete(:id) } # ids are kinda random so we always discard them
|
|
62
100
|
end
|
|
63
101
|
end
|
|
64
102
|
|
|
65
103
|
private
|
|
66
104
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
next unless defaults = WIDGET_DEFAULTS[types.first]
|
|
72
|
-
ignore_defaults(pair[0], pair[1], defaults, nesting: :definition)
|
|
105
|
+
# conditional_formats ordering is randomly changed by datadog, compare a stable ordering
|
|
106
|
+
def sort_conditional_formats(widget)
|
|
107
|
+
if formats = widget&.dig(:definition, :conditional_formats)
|
|
108
|
+
widget[:definition][:conditional_formats] = formats.sort_by(&:hash)
|
|
73
109
|
end
|
|
74
110
|
end
|
|
75
111
|
|
|
112
|
+
def ignore_widget_defaults(expected, actual)
|
|
113
|
+
types = [expected&.dig(:definition, :type), actual&.dig(:definition, :type)].uniq.compact
|
|
114
|
+
return unless types.size == 1
|
|
115
|
+
return unless defaults = WIDGET_DEFAULTS[types.first]
|
|
116
|
+
ignore_default expected&.[](:definition) || {}, actual&.[](:definition) || {}, defaults
|
|
117
|
+
end
|
|
118
|
+
|
|
76
119
|
# discard styles/conditional_formats/aggregator if nothing would change when we applied (both are default or nil)
|
|
77
120
|
def ignore_request_defaults(expected, actual)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
ignore_defaults e_r, a_r, REQUEST_DEFAULTS
|
|
82
|
-
end
|
|
121
|
+
a_r = actual&.dig(:definition, :requests) || []
|
|
122
|
+
e_r = expected&.dig(:definition, :requests) || []
|
|
123
|
+
ignore_defaults e_r, a_r, REQUEST_DEFAULTS
|
|
83
124
|
end
|
|
84
125
|
|
|
85
|
-
def ignore_defaults(expected, actual, defaults
|
|
126
|
+
def ignore_defaults(expected, actual, defaults)
|
|
86
127
|
[expected.size, actual.size].max.times do |i|
|
|
87
|
-
|
|
88
|
-
a = actual.dig(i, *nesting) || {}
|
|
89
|
-
ignore_default(e, a, defaults)
|
|
128
|
+
ignore_default expected[i] || {}, actual[i] || {}, defaults
|
|
90
129
|
end
|
|
91
130
|
end
|
|
92
131
|
|
|
@@ -99,7 +138,7 @@ module Kennel
|
|
|
99
138
|
nested = pair.map { |d| d.dig(:widgets, i, :definition, :widgets) || [] }
|
|
100
139
|
result << nested if nested.any?(&:any?)
|
|
101
140
|
end
|
|
102
|
-
result
|
|
141
|
+
result.flat_map { |a, e| [a.size, e.size].max.times.map { |i| [a[i], e[i]] } }
|
|
103
142
|
end
|
|
104
143
|
end
|
|
105
144
|
|
|
@@ -9,6 +9,7 @@ module Kennel
|
|
|
9
9
|
READONLY_ATTRIBUTES = superclass::READONLY_ATTRIBUTES + [
|
|
10
10
|
:multi, :matching_downtimes, :overall_state_modified, :overall_state, :restricted_roles
|
|
11
11
|
]
|
|
12
|
+
TRACKING_FIELD = :message
|
|
12
13
|
|
|
13
14
|
MONITOR_DEFAULTS = {
|
|
14
15
|
priority: nil
|
|
@@ -25,6 +26,7 @@ module Kennel
|
|
|
25
26
|
groupby_simple_monitor: false
|
|
26
27
|
}.freeze
|
|
27
28
|
DEFAULT_ESCALATION_MESSAGE = ["", nil].freeze
|
|
29
|
+
ALLOWED_PRIORITY_CLASSES = [NilClass, Integer].freeze
|
|
28
30
|
|
|
29
31
|
settings(
|
|
30
32
|
:query, :name, :message, :escalation_message, :critical, :type, :renotify_interval, :warning, :timeout_h, :evaluation_delay,
|
|
@@ -127,9 +129,15 @@ module Kennel
|
|
|
127
129
|
Utils.path_to_url "/monitors##{id}/edit"
|
|
128
130
|
end
|
|
129
131
|
|
|
130
|
-
# datadog uses / for show and # for edit as separator in it's links
|
|
131
132
|
def self.parse_url(url)
|
|
132
|
-
|
|
133
|
+
# datadog uses / for show and # for edit as separator in it's links
|
|
134
|
+
id = url[/\/monitors[\/#](\d+)/, 1]
|
|
135
|
+
|
|
136
|
+
# slo alert url
|
|
137
|
+
id ||= url[/\/slo\/edit\/[a-z\d]{10,}\/alerts\/(\d+)/, 1]
|
|
138
|
+
|
|
139
|
+
return unless id
|
|
140
|
+
|
|
133
141
|
Integer(id)
|
|
134
142
|
end
|
|
135
143
|
|
|
@@ -217,6 +225,10 @@ module Kennel
|
|
|
217
225
|
end
|
|
218
226
|
end
|
|
219
227
|
end
|
|
228
|
+
|
|
229
|
+
unless ALLOWED_PRIORITY_CLASSES.include?(priority.class)
|
|
230
|
+
invalid! "priority needs to be an Integer"
|
|
231
|
+
end
|
|
220
232
|
end
|
|
221
233
|
end
|
|
222
234
|
end
|
data/lib/kennel/models/record.rb
CHANGED
|
@@ -3,8 +3,10 @@ module Kennel
|
|
|
3
3
|
module Models
|
|
4
4
|
class Record < Base
|
|
5
5
|
LOCK = "\u{1F512}"
|
|
6
|
+
TRACKING_FIELDS = [:message, :description].freeze
|
|
6
7
|
READONLY_ATTRIBUTES = [
|
|
7
|
-
:deleted, :id, :created, :created_at, :creator, :org_id, :modified, :modified_at,
|
|
8
|
+
:deleted, :id, :created, :created_at, :creator, :org_id, :modified, :modified_at,
|
|
9
|
+
:klass, :tracking_id # added by syncer.rb
|
|
8
10
|
].freeze
|
|
9
11
|
|
|
10
12
|
settings :id, :kennel_id
|
|
@@ -22,6 +24,18 @@ module Kennel
|
|
|
22
24
|
subclasses.map { |s| [s.api_resource, s] }.to_h
|
|
23
25
|
end
|
|
24
26
|
|
|
27
|
+
def parse_tracking_id(a)
|
|
28
|
+
a[self::TRACKING_FIELD].to_s[/-- Managed by kennel (\S+:\S+)/, 1]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# TODO: combine with parse into a single method or a single regex
|
|
32
|
+
def remove_tracking_id(a)
|
|
33
|
+
value = a[self::TRACKING_FIELD]
|
|
34
|
+
a[self::TRACKING_FIELD] =
|
|
35
|
+
value.dup.sub!(/\n?-- Managed by kennel .*/, "") ||
|
|
36
|
+
raise("did not find tracking id in #{value}")
|
|
37
|
+
end
|
|
38
|
+
|
|
25
39
|
private
|
|
26
40
|
|
|
27
41
|
def normalize(_expected, actual)
|
|
@@ -60,19 +74,40 @@ module Kennel
|
|
|
60
74
|
end
|
|
61
75
|
|
|
62
76
|
def tracking_id
|
|
63
|
-
|
|
77
|
+
@tracking_id ||= begin
|
|
78
|
+
id = "#{project.kennel_id}:#{kennel_id}"
|
|
79
|
+
raise ValidationError, "#{id} kennel_id cannot include whitespace" if id.match?(/\s/) # <-> parse_tracking_id
|
|
80
|
+
id
|
|
81
|
+
end
|
|
64
82
|
end
|
|
65
83
|
|
|
66
84
|
def resolve_linked_tracking_ids!(*)
|
|
67
85
|
end
|
|
68
86
|
|
|
87
|
+
def add_tracking_id
|
|
88
|
+
json = as_json
|
|
89
|
+
if self.class.parse_tracking_id(json)
|
|
90
|
+
invalid! "remove \"-- Managed by kennel\" line it from #{self.class::TRACKING_FIELD} to copy a resource"
|
|
91
|
+
end
|
|
92
|
+
json[self.class::TRACKING_FIELD] =
|
|
93
|
+
"#{json[self.class::TRACKING_FIELD]}\n" \
|
|
94
|
+
"-- Managed by kennel #{tracking_id} in #{project.class.file_location}, do not modify manually".lstrip
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def remove_tracking_id
|
|
98
|
+
self.class.remove_tracking_id(as_json)
|
|
99
|
+
end
|
|
100
|
+
|
|
69
101
|
private
|
|
70
102
|
|
|
71
103
|
def resolve_link(tracking_id, type, id_map, force:)
|
|
72
104
|
id = id_map[tracking_id]
|
|
73
105
|
if id == :new
|
|
74
106
|
if force
|
|
75
|
-
invalid!
|
|
107
|
+
invalid!(
|
|
108
|
+
"#{type} #{tracking_id} was referenced but is also created by the current run.\n" \
|
|
109
|
+
"It could not be created because of a circular dependency, try creating only some of the resources"
|
|
110
|
+
)
|
|
76
111
|
else
|
|
77
112
|
nil # will be re-resolved after the linked object was created
|
|
78
113
|
end
|
data/lib/kennel/models/slo.rb
CHANGED
|
@@ -3,6 +3,7 @@ module Kennel
|
|
|
3
3
|
module Models
|
|
4
4
|
class Slo < Record
|
|
5
5
|
READONLY_ATTRIBUTES = superclass::READONLY_ATTRIBUTES + [:type_id, :monitor_tags]
|
|
6
|
+
TRACKING_FIELD = :description
|
|
6
7
|
DEFAULTS = {
|
|
7
8
|
description: nil,
|
|
8
9
|
query: nil,
|
|
@@ -63,7 +64,7 @@ module Kennel
|
|
|
63
64
|
end
|
|
64
65
|
|
|
65
66
|
def self.parse_url(url)
|
|
66
|
-
url[/\/slo(\?.*slo_id=|\/edit\/)([a-z\d]{10,})/, 2]
|
|
67
|
+
url[/\/slo(\?.*slo_id=|\/edit\/)([a-z\d]{10,})(&|$)/, 2]
|
|
67
68
|
end
|
|
68
69
|
|
|
69
70
|
def resolve_linked_tracking_ids!(id_map, **args)
|
data/lib/kennel/progress.rb
CHANGED
data/lib/kennel/syncer.rb
CHANGED
|
@@ -1,22 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
module Kennel
|
|
3
3
|
class Syncer
|
|
4
|
-
TRACKING_FIELDS = [:message, :description].freeze
|
|
5
4
|
DELETE_ORDER = ["dashboard", "slo", "monitor"].freeze # dashboards references monitors + slos, slos reference monitors
|
|
5
|
+
LINE_UP = "\e[1A\033[K" # go up and clear
|
|
6
6
|
|
|
7
7
|
def initialize(api, expected, project: nil)
|
|
8
8
|
@api = api
|
|
9
9
|
@project_filter = project
|
|
10
10
|
@expected = expected
|
|
11
|
-
if @project_filter
|
|
12
|
-
original = @expected
|
|
13
|
-
@expected = @expected.select { |e| e.project.kennel_id == @project_filter }
|
|
14
|
-
if @expected.empty?
|
|
15
|
-
possible = original.map { |e| e.project.kennel_id }.uniq.sort
|
|
16
|
-
raise "#{@project_filter} does not match any projects, try any of these:\n#{possible.join("\n")}"
|
|
17
|
-
end
|
|
18
|
-
end
|
|
19
|
-
@expected.each { |e| add_tracking_id e }
|
|
20
11
|
calculate_diff
|
|
21
12
|
prevent_irreversible_partial_updates
|
|
22
13
|
end
|
|
@@ -38,20 +29,28 @@ module Kennel
|
|
|
38
29
|
|
|
39
30
|
def update
|
|
40
31
|
each_resolved @create do |_, e|
|
|
32
|
+
message = "#{e.class.api_resource} #{e.tracking_id}"
|
|
33
|
+
Kennel.out.puts "Creating #{message}"
|
|
41
34
|
reply = @api.create e.class.api_resource, e.as_json
|
|
35
|
+
cache_metadata reply, e.class
|
|
42
36
|
id = reply.fetch(:id)
|
|
43
|
-
populate_id_map [reply] # allow resolving ids we could previously no resolve
|
|
44
|
-
Kennel.out.puts "
|
|
37
|
+
populate_id_map [], [reply] # allow resolving ids we could previously no resolve
|
|
38
|
+
Kennel.out.puts "#{LINE_UP}Created #{message} #{e.class.url(id)}"
|
|
45
39
|
end
|
|
46
40
|
|
|
47
41
|
each_resolved @update do |id, e|
|
|
42
|
+
message = "#{e.class.api_resource} #{e.tracking_id} #{e.class.url(id)}"
|
|
43
|
+
Kennel.out.puts "Updating #{message}"
|
|
48
44
|
@api.update e.class.api_resource, id, e.as_json
|
|
49
|
-
Kennel.out.puts "
|
|
45
|
+
Kennel.out.puts "#{LINE_UP}Updated #{message}"
|
|
50
46
|
end
|
|
51
47
|
|
|
52
48
|
@delete.each do |id, _, a|
|
|
53
|
-
|
|
54
|
-
|
|
49
|
+
klass = a.fetch(:klass)
|
|
50
|
+
message = "#{klass.api_resource} #{a.fetch(:tracking_id)} #{id}"
|
|
51
|
+
Kennel.out.puts "Deleting #{message}"
|
|
52
|
+
@api.delete klass.api_resource, id
|
|
53
|
+
Kennel.out.puts "#{LINE_UP}Deleted #{message}"
|
|
55
54
|
end
|
|
56
55
|
end
|
|
57
56
|
|
|
@@ -99,14 +98,14 @@ module Kennel
|
|
|
99
98
|
|
|
100
99
|
actual = Progress.progress("Downloading definitions") { download_definitions }
|
|
101
100
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
101
|
+
Progress.progress "Diffing" do
|
|
102
|
+
filter_expected_by_project! @expected
|
|
103
|
+
populate_id_map @expected, actual
|
|
104
|
+
filter_actual_by_project! actual
|
|
105
|
+
resolve_linked_tracking_ids! @expected # resolve dependencies to avoid diff
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
@expected.each(&:add_tracking_id) # avoid diff with actual
|
|
108
108
|
|
|
109
|
-
Progress.progress "Diffing" do
|
|
110
109
|
items = actual.map do |a|
|
|
111
110
|
e = matching_expected(a)
|
|
112
111
|
if e && @expected.delete(e)
|
|
@@ -117,9 +116,8 @@ module Kennel
|
|
|
117
116
|
end
|
|
118
117
|
|
|
119
118
|
# fill details of things we need to compare
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
detailed.each { |api_resource, actuals| @api.fill_details! api_resource, actuals }
|
|
119
|
+
details = items.map { |e, a| a if e && e.class.api_resource == "dashboard" }.compact
|
|
120
|
+
@api.fill_details! "dashboard", details
|
|
123
121
|
|
|
124
122
|
# pick out things to update or delete
|
|
125
123
|
items.each do |e, a|
|
|
@@ -127,26 +125,30 @@ module Kennel
|
|
|
127
125
|
if e
|
|
128
126
|
diff = e.diff(a)
|
|
129
127
|
@update << [id, e, a, diff] if diff.any?
|
|
130
|
-
elsif tracking_id
|
|
128
|
+
elsif a.fetch(:tracking_id) # was previously managed
|
|
131
129
|
@delete << [id, nil, a]
|
|
132
130
|
end
|
|
133
131
|
end
|
|
134
132
|
|
|
135
133
|
ensure_all_ids_found
|
|
136
134
|
@create = @expected.map { |e| [nil, e] }
|
|
135
|
+
@delete.sort_by! { |_, _, a| DELETE_ORDER.index a.fetch(:klass).api_resource }
|
|
137
136
|
end
|
|
138
|
-
|
|
139
|
-
@delete.sort_by! { |_, _, a| DELETE_ORDER.index a.fetch(:api_resource) }
|
|
140
137
|
end
|
|
141
138
|
|
|
142
139
|
def download_definitions
|
|
143
|
-
Utils.parallel(Models::Record.subclasses
|
|
144
|
-
results = @api.list(api_resource, with_downtimes: false) # lookup monitors without adding unnecessary downtime information
|
|
140
|
+
Utils.parallel(Models::Record.subclasses) do |klass|
|
|
141
|
+
results = @api.list(klass.api_resource, with_downtimes: false) # lookup monitors without adding unnecessary downtime information
|
|
145
142
|
results = results[results.keys.first] if results.is_a?(Hash) # dashboards are nested in {dashboards: []}
|
|
146
|
-
results.each { |
|
|
143
|
+
results.each { |a| cache_metadata(a, klass) }
|
|
147
144
|
end.flatten(1)
|
|
148
145
|
end
|
|
149
146
|
|
|
147
|
+
def cache_metadata(a, klass)
|
|
148
|
+
a[:klass] = klass
|
|
149
|
+
a[:tracking_id] = a.fetch(:klass).parse_tracking_id(a)
|
|
150
|
+
end
|
|
151
|
+
|
|
150
152
|
def ensure_all_ids_found
|
|
151
153
|
@expected.each do |e|
|
|
152
154
|
next unless id = e.id
|
|
@@ -158,7 +160,7 @@ module Kennel
|
|
|
158
160
|
def matching_expected(a)
|
|
159
161
|
# index list by all the thing we look up by: tracking id and actual id
|
|
160
162
|
@lookup_map ||= @expected.each_with_object({}) do |e, all|
|
|
161
|
-
keys = [
|
|
163
|
+
keys = [e.tracking_id]
|
|
162
164
|
keys << "#{e.class.api_resource}:#{e.id}" if e.id
|
|
163
165
|
keys.compact.each do |key|
|
|
164
166
|
raise "Lookup #{key} is duplicated" if all[key]
|
|
@@ -166,14 +168,15 @@ module Kennel
|
|
|
166
168
|
end
|
|
167
169
|
end
|
|
168
170
|
|
|
169
|
-
|
|
171
|
+
klass = a.fetch(:klass)
|
|
172
|
+
@lookup_map["#{klass.api_resource}:#{a.fetch(:id)}"] || @lookup_map[a.fetch(:tracking_id)]
|
|
170
173
|
end
|
|
171
174
|
|
|
172
175
|
def print_plan(step, list, color)
|
|
173
176
|
return if list.empty?
|
|
174
177
|
list.each do |_, e, a, diff|
|
|
175
|
-
|
|
176
|
-
Kennel.out.puts Utils.color(color, "#{step} #{api_resource} #{e&.tracking_id || tracking_id
|
|
178
|
+
klass = (e ? e.class : a.fetch(:klass))
|
|
179
|
+
Kennel.out.puts Utils.color(color, "#{step} #{klass.api_resource} #{e&.tracking_id || a.fetch(:tracking_id)}")
|
|
177
180
|
print_diff(diff) if diff # only for update
|
|
178
181
|
end
|
|
179
182
|
end
|
|
@@ -199,76 +202,58 @@ module Kennel
|
|
|
199
202
|
end
|
|
200
203
|
end
|
|
201
204
|
|
|
202
|
-
#
|
|
203
|
-
#
|
|
204
|
-
#
|
|
205
|
-
#
|
|
206
|
-
# Note: ideally we'd never add tracking in the first place, but at that point we do not know the diff yet
|
|
205
|
+
# - do not add tracking-id when working with existing ids on a branch,
|
|
206
|
+
# so resource do not get deleted when running an update on master (for example merge->CI)
|
|
207
|
+
# - make sure the diff is clean, by kicking out the now noop-update
|
|
208
|
+
# - ideally we'd never add tracking in the first place, but when adding tracking we do not know the diff yet
|
|
207
209
|
def prevent_irreversible_partial_updates
|
|
208
210
|
return unless @project_filter
|
|
209
211
|
@update.select! do |_, e, _, diff|
|
|
210
|
-
next true unless e.id #
|
|
212
|
+
next true unless e.id # safe to add tracking when not having id
|
|
211
213
|
|
|
212
214
|
diff.select! do |field_diff|
|
|
213
|
-
(_, field,
|
|
214
|
-
|
|
215
|
+
(_, field, actual) = field_diff
|
|
216
|
+
# TODO: refactor this so TRACKING_FIELD stays record-private
|
|
217
|
+
next true if e.class::TRACKING_FIELD != field.to_sym # need to sym here because Hashdiff produces strings
|
|
218
|
+
next true if e.class.parse_tracking_id(field.to_sym => actual) # already has tracking id
|
|
215
219
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
else
|
|
219
|
-
field_diff[3] = remove_tracking_id(e) # make plan output match update
|
|
220
|
-
old != field_diff[3]
|
|
221
|
-
end
|
|
220
|
+
field_diff[3] = e.remove_tracking_id # make `rake plan` output match what we are sending
|
|
221
|
+
actual != field_diff[3] # discard diff if now nothing changes
|
|
222
222
|
end
|
|
223
223
|
|
|
224
224
|
!diff.empty?
|
|
225
225
|
end
|
|
226
226
|
end
|
|
227
227
|
|
|
228
|
-
def populate_id_map(actual)
|
|
229
|
-
actual.each
|
|
228
|
+
def populate_id_map(expected, actual)
|
|
229
|
+
actual.each do |a|
|
|
230
|
+
next unless tracking_id = a.fetch(:tracking_id)
|
|
231
|
+
@id_map[tracking_id] = a.fetch(:id)
|
|
232
|
+
end
|
|
233
|
+
expected.each { |e| @id_map[e.tracking_id] ||= :new }
|
|
230
234
|
end
|
|
231
235
|
|
|
232
236
|
def resolve_linked_tracking_ids!(list, force: false)
|
|
233
237
|
list.each { |e| e.resolve_linked_tracking_ids!(@id_map, force: force) }
|
|
234
238
|
end
|
|
235
239
|
|
|
236
|
-
def
|
|
240
|
+
def filter_actual_by_project!(actual)
|
|
237
241
|
return unless @project_filter
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
!
|
|
242
|
+
actual.select! do |a|
|
|
243
|
+
tracking_id = a.fetch(:tracking_id)
|
|
244
|
+
!tracking_id || tracking_id.start_with?("#{@project_filter}:")
|
|
241
245
|
end
|
|
242
246
|
end
|
|
243
247
|
|
|
244
|
-
def
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
json[field] = "#{json[field]}\n-- Managed by kennel #{e.tracking_id} in #{e.project.class.file_location}, do not modify manually".lstrip
|
|
249
|
-
end
|
|
250
|
-
|
|
251
|
-
def remove_tracking_id(e)
|
|
252
|
-
json = e.as_json
|
|
253
|
-
field = tracking_field(json)
|
|
254
|
-
value = json[field]
|
|
255
|
-
json[field] = value.dup.sub!(/\n?-- Managed by kennel .*/, "") || raise("did not find tracking id in #{value}")
|
|
256
|
-
end
|
|
257
|
-
|
|
258
|
-
def tracking_id(a)
|
|
259
|
-
tracking_value a[tracking_field(a)]
|
|
260
|
-
end
|
|
261
|
-
|
|
262
|
-
def tracking_value(content)
|
|
263
|
-
content.to_s[/-- Managed by kennel (\S+:\S+)/, 1]
|
|
264
|
-
end
|
|
265
|
-
|
|
266
|
-
def tracking_field(a)
|
|
267
|
-
TRACKING_FIELDS.detect { |f| a.key?(f) }
|
|
268
|
-
end
|
|
248
|
+
def filter_expected_by_project!(expected)
|
|
249
|
+
return unless @project_filter
|
|
250
|
+
original = expected.dup
|
|
251
|
+
expected.select! { |e| e.project.kennel_id == @project_filter }
|
|
269
252
|
|
|
270
|
-
|
|
271
|
-
|
|
253
|
+
if expected.empty?
|
|
254
|
+
possible = original.map { |e| e.project.kennel_id }.uniq.sort
|
|
255
|
+
raise "#{@project_filter} does not match any projects, try any of these:\n#{possible.join("\n")}"
|
|
256
|
+
end
|
|
272
257
|
end
|
|
273
258
|
end
|
|
274
259
|
end
|
data/lib/kennel/tasks.rb
CHANGED
|
@@ -138,7 +138,7 @@ namespace :kennel do
|
|
|
138
138
|
resources.each do |resource|
|
|
139
139
|
Kennel::Progress.progress("Downloading #{resource}") do
|
|
140
140
|
list = api.list(resource)
|
|
141
|
-
api.fill_details!(resource, list)
|
|
141
|
+
api.fill_details!(resource, list) if resource == "dashboard"
|
|
142
142
|
end
|
|
143
143
|
list.each do |r|
|
|
144
144
|
r[:api_resource] = resource
|
|
@@ -36,7 +36,8 @@ module Kennel
|
|
|
36
36
|
|
|
37
37
|
def widget_queries(widget)
|
|
38
38
|
requests = widget.dig(:definition, :requests) || []
|
|
39
|
-
|
|
39
|
+
return requests.values.map { |r| r[:q] } if requests.is_a?(Hash) # hostmap widgets have hash requests
|
|
40
|
+
requests.flat_map { |r| r[:q] || r[:queries]&.map { |q| q[:query] } } # old format with q: or queries: [{query:}]
|
|
40
41
|
end
|
|
41
42
|
end
|
|
42
43
|
end
|
data/lib/kennel/version.rb
CHANGED
data/template/Readme.md
CHANGED
|
@@ -96,6 +96,7 @@ end
|
|
|
96
96
|
- use [datadog monitor UI](https://app.datadoghq.com/monitors/manage) to find a monitor
|
|
97
97
|
- get the `id` from the url
|
|
98
98
|
- run `URL='https://app.datadoghq.com/monitors/123' bundle exec rake kennel:import` and copy the output
|
|
99
|
+
- import task also works with SLO alerts, e.g. `URL='https://app.datadoghq.com/slo/edit/123abc456def123/alerts/789' bundle exec rake kennel:import`
|
|
99
100
|
- find or create a project in `projects/`
|
|
100
101
|
- add the monitor to `parts: [` list, for example:
|
|
101
102
|
```Ruby
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: kennel
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.89.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michael Grosser
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2021-
|
|
11
|
+
date: 2021-07-01 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: faraday
|