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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f9945e9a42b446b95f53152f08d74765e4f7c228a4a4e9d3f5770eb6006338a0
4
- data.tar.gz: 3cc6fd5ff0cece1763b9a4e798ac9da97df80c6b27b613caf45abe7f9ef4a1eb
3
+ metadata.gz: b931b9c2d75d6e0d69967e3088795e72d8a5f3f558ff1dd1b6de03efde7aabf8
4
+ data.tar.gz: '081dde24122f351f92c7dc8f16e7877ec3dcd1f7226bd1e4fc126abd3044798d'
5
5
  SHA512:
6
- metadata.gz: c43fb32a753b1e59aca9728195612568ec2a11d7d83e7a74daad064bbc5ec2d9f29951d0e0ff66e290d25e357b5a3e2d310cbac0b335ebccb1da7cbb5752a225
7
- data.tar.gz: 61f079c7c9a0d8c746c529badc5e2d67fe4c3e8c19e2025ab95516be2667f4b1788d288d3e8420b929dc2fa85a98cd5c6f23b9395c3a066785194f44a02cb0a9
6
+ metadata.gz: ffd01589e499c34d72dd477f5e3e00369582c42cacbf7eae19493e791b84ecbac42c48c5bf34e3d8c8a6245f555d29f62e3cb879cc2e24892b555766f9b5ffcc
7
+ data.tar.gz: 7a95114123013fd90a64993f86f217b5f4379e0d3dc6b19d876d2ac42f404b4907ef35b2396c811c4291838ca8183073523090bb2d89882dbbffa6972b40bb38
data/Readme.md CHANGED
@@ -206,6 +206,7 @@ end
206
206
  class MyProject < Kennel::Models::Project
207
207
  defaults(
208
208
  team: -> { Teams::MyTeam.new }, # use existing team or create new one in teams/
209
+ # kennel_id: -> { "my_project" } # Custom kennel_id (default is snake_cased class name)
209
210
  parts: -> {
210
211
  [
211
212
  Kennel::Models::Monitor.new(
@@ -249,6 +250,7 @@ Remove the code that created the resource. The next update will delete it (see a
249
250
  - go to [datadog dashboard UI](https://app.datadoghq.com/dashboard/lists) and click on _New Dashboard_ to find a dashboard
250
251
  - run `URL='https://app.datadoghq.com/dashboard/bet-foo-bar' bundle exec rake kennel:import` and copy the output
251
252
  - find or create a project in `projects/`
253
+ - tags: only `team:` tags are submitted to datadog since nothing else is supported
252
254
  - add a dashboard to `parts: [` list, for example:
253
255
  ```Ruby
254
256
  class MyProject < Kennel::Models::Project
@@ -264,8 +266,9 @@ Remove the code that created the resource. The next update will delete it (see a
264
266
  template_variables: -> { ["environment"] }, # see https://docs.datadoghq.com/api/?lang=ruby#timeboards
265
267
  kennel_id: -> { "overview-dashboard" }, # make up a unique name
266
268
  layout_type: -> { "ordered" },
269
+ widgets: -> { "... raw widget definitions, most flexible ..." },
267
270
  definitions: -> {
268
- [ # An array or arrays, each one is a graph in the dashboard, alternatively a hash for finer control
271
+ [ # each element is a graph in the dashboard, alternatively a hash for complete control just like in `widgets`
269
272
  [
270
273
  # title, viz, type, query, edit an existing graph and see the json definition
271
274
  "Graph name", "timeseries", "area", "sum:mystats.foobar{$environment}"
@@ -274,7 +277,7 @@ Remove the code that created the resource. The next update will delete it (see a
274
277
  # queries can be an Array as well, this will generate multiple requests
275
278
  # for a single graph
276
279
  "Graph name", "timeseries", "area", ["sum:mystats.foobar{$environment}", "sum:mystats.success{$environment}"],
277
- # add events too ...
280
+ # add events too ... (also supports `:markers` and `:precision`)
278
281
  events: [{q: "tags:foobar,deploy", tags_execution: "and"}]
279
282
  ]
280
283
  ]
@@ -286,6 +289,55 @@ Remove the code that created the resource. The next update will delete it (see a
286
289
  end
287
290
  ```
288
291
 
292
+ ### Adding a new synthetic test
293
+ - go to [datadog synthetic tests UI](https://app.datadoghq.com/synthetics/tests) and click on _New_ to create a test
294
+ - see below
295
+
296
+ ### Updating an existing synthetic test
297
+ - go to [datadog synthetic tests UI](https://app.datadoghq.com/synthetics/tests) to find a test
298
+ - run `URL='https://app.datadoghq.com/synthetics/details/abc-def-ghi' bundle exec rake kennel:import` and copy the output
299
+ - find or create a project in `projects/`
300
+ - add a synthetic test to `parts: [` list, for example:
301
+ ```Ruby
302
+ class MyProject < Kennel::Models::Project
303
+ defaults(
304
+ team: -> { Teams::MyTeam.new },
305
+ parts: -> {
306
+ [
307
+ Kennel::Models::SyntheticTest.new(
308
+ self,
309
+ id: -> { "abc-def-ghi" }, # id from datadog url, not needed when creating a new test
310
+ kennel_id: -> { "my-api-test" },
311
+ name: -> { "My API Test" },
312
+ type: -> { "api" },
313
+ subtype: -> { "http" },
314
+ locations: -> { :all }, # use all locations, or specify: ["aws:us-east-1", "aws:eu-west-1"]
315
+ message: -> {
316
+ <<~TEXT
317
+ API check failed!
318
+ #{super()}
319
+ TEXT
320
+ },
321
+ options: -> {
322
+ {
323
+ tick_every: 60,
324
+ min_failure_duration: 0,
325
+ min_location_failed: 1
326
+ }
327
+ },
328
+ config: -> {
329
+ {
330
+ assertions: [{ type: "statusCode", operator: "is", target: 200 }],
331
+ request: { method: "GET", url: "https://example.com/health" }
332
+ }
333
+ }
334
+ )
335
+ ]
336
+ }
337
+ )
338
+ end
339
+ ```
340
+
289
341
  ### Updating existing resources with id
290
342
  Setting `id` makes kennel take over a manually created datadog resource.
291
343
  When manually creating to import, it is best to remove the `id` and delete the manually created resource.
@@ -327,10 +379,11 @@ module ProjectA
327
379
  - Use `TRACKING_ID=<project-kennel_id>:<resource-kennel_id>` for single resource:
328
380
 
329
381
  Use the project kennel_id and the resources kennel_id, for example `class ProjectA` and `FooAlert` would give `project_a:foo_alert`.
382
+ Alternatively use the path of the generated file `TRACKING_ID=generated/project_a/foo_alert.json`
330
383
 
331
384
  ### Skipping validations
332
385
  Some validations might be too strict for your usecase or just wrong, please [open an issue](https://github.com/grosser/kennel/issues) and
333
- to unblock use the `validate: -> { false }` option.
386
+ to unblock use `ignored_errors: [:name_of_the_error]`.
334
387
 
335
388
  ### Linking resources with kennel_id
336
389
  Link resources with their kennel_id in the format `project kennel_id` + `:` + `resource kennel_id`,
@@ -339,7 +392,7 @@ so they can be created in a single update and can be re-created if any of them i
339
392
 
340
393
  |Resource|Type|Syntax|
341
394
  |---|---|---|
342
- |Dashboard|uptime|`monitor: {id: "foo:bar"}`|
395
+ |Dashboard|uptime|`monitor_ids: ["foo:bar", "foo:baz"]`|
343
396
  |Dashboard|alert_graph|`alert_id: "foo:bar"`|
344
397
  |Dashboard|slo|`slo_id: "foo:bar"`|
345
398
  |Dashboard|timeseries|`queries: [{ data_source: "slo", slo_id: "foo:bar" }]`|
@@ -388,6 +441,12 @@ Run `rake kennel:alerts TAG=service:my-service` to see all un-muted alerts for a
388
441
  ### Validating mentions work
389
442
  `rake kennel:validate_mentions` should run as part of CI
390
443
 
444
+ Use `KNOWN=foo@bar.com,baz@bar.com` to exempt mentions that are not returned by the API.
445
+ Use `KNOWN_RANDOM=@sns-foo,@sns-bar` to ignore for example SNS handles that are randomly invalid in the API.
446
+
447
+ ### Validating planned changes
448
+ `rake kennel:validate_plan` validates planned monitor and dashboard changes against the Datadog API.
449
+
391
450
  ### Grepping through all of datadog
392
451
  ```Bash
393
452
  rake kennel:dump > tmp/dump
@@ -405,6 +464,9 @@ https://foo.datadog.com/monitor/123
405
464
  ### Find all monitors with No-Data
406
465
  `rake kennel:nodata TAG=team:foo`
407
466
 
467
+ - `FORMAT=json` to output as JSON with tracking IDs
468
+ - `THRESHOLD_DAYS=N` to filter to monitors with N+ days in no-data
469
+
408
470
  ### Finding the tracking id of a resource
409
471
 
410
472
  When trying to link resources together, this avoids having to go through datadog UI.
@@ -421,6 +483,15 @@ rake kennel:tracking_id ID=123 RESOURCE=monitor
421
483
  - Setting `FORCE_GET_CACHE=true` will cache all get requests, which makes benchmarking improvements more reliable.
422
484
  - Setting `STORE=false` will make `rake plan` not update the files on disk and save a bit of time
423
485
 
486
+ ### Other Environment Variables
487
+ | Variable | Description | Default |
488
+ |----------|-------------|---------|
489
+ | `KENNEL_MARKER_TEXT` | Custom marker text to namespace multiple Kennel instances in the same Datadog account. Each instance will only manage resources with its marker. | `Managed by kennel` |
490
+ | `KENNEL_API_CACHE_FILE` | Path to the API cache file for dashboard details. | `tmp/cache/details` |
491
+ | `KENNEL_NO_GENERATE` | When set, skip generating files during `plan` or `update_datadog`. Useful when generated files are already up to date. | - |
492
+ | `NO_IGNORED_ERRORS` | When set, show all validation errors including ones suppressed via `ignored_errors`. | - |
493
+ | `SHOW_UNCACHED_FILL_DETAILS` | When set, print the number of uncached dashboard `show` requests made to fill details during diffing. | - |
494
+
424
495
  ### Integration testing
425
496
  ```Bash
426
497
  rake play
data/lib/kennel/api.rb CHANGED
@@ -5,8 +5,6 @@ module Kennel
5
5
  class Api
6
6
  CACHE_FILE = ENV.fetch("KENNEL_API_CACHE_FILE", "tmp/cache/details")
7
7
 
8
- RateLimitParams = Data.define(:limit, :period, :remaining, :reset, :name)
9
-
10
8
  def self.with_tracking(api_resource, reply)
11
9
  klass = Models::Record.api_resource_map[api_resource]
12
10
  return reply unless klass # do not blow up on unknown models
@@ -21,11 +19,12 @@ module Kennel
21
19
  @app_key = app_key || ENV.fetch("DATADOG_APP_KEY")
22
20
  @api_key = api_key || ENV.fetch("DATADOG_API_KEY")
23
21
  url = Utils.path_to_url("")
24
- @client = Faraday.new(url: url) { |c| c.adapter :net_http_persistent }
22
+ @client = Faraday.new(url: url)
25
23
  end
26
24
 
27
- def show(api_resource, id, params = {})
28
- response = request :get, "/api/v1/#{api_resource}/#{id}", params: params
25
+ def show(api_resource, id, params = {}, ignore_404: false)
26
+ response = request :get, "/api/v1/#{api_resource}/#{id}", params: params, ignore_404: ignore_404
27
+ return if response.nil? # 404 that was ignored
29
28
  response = response.fetch(:data) if api_resource == "slo"
30
29
  response[:id] = response.delete(:public_id) if api_resource == "synthetics/tests"
31
30
  self.class.with_tracking(api_resource, response)
@@ -56,6 +55,8 @@ module Kennel
56
55
  end
57
56
 
58
57
  def update(api_resource, id, attributes)
58
+ restore_widget_ids(id, attributes) if api_resource == "dashboard"
59
+
59
60
  response = request :put, "/api/v1/#{api_resource}/#{id}", body: attributes
60
61
  response[:id] = response.delete(:public_id) if api_resource == "synthetics/tests"
61
62
  self.class.with_tracking(api_resource, response)
@@ -73,14 +74,47 @@ module Kennel
73
74
  end
74
75
  end
75
76
 
77
+ # fill the resource with the full response from the `show` if `list` does not return it
76
78
  def fill_details!(api_resource, list)
77
79
  details_cache do |cache|
78
- Utils.parallel(list) { |a| fill_detail!(api_resource, a, cache) }
80
+ results = Utils.parallel(list) { |a| fill_detail!(api_resource, a, cache) }
81
+
82
+ # drop resources that were not found (race condition between listing and showing)
83
+ list.select!.with_index { |_, i| results[i][1] }
84
+
85
+ results.count { |uncached, _| uncached }
79
86
  end
80
87
  end
81
88
 
82
89
  private
83
90
 
91
+ # keep widget ids stable so stored dashboard urls that point at a widget do not break on every update
92
+ # widget ids are dropped when diffing since they change on every update, but that also changes them in datadog
93
+ # and breaks stored urls that point at a specific widget, so restore the previous ids by matching widget titles
94
+ def restore_widget_ids(id, attributes)
95
+ return unless (widgets = attributes[:widgets])
96
+ return unless (current = show("dashboard", id, {}, ignore_404: true))
97
+
98
+ ids = {}
99
+ each_widget_with_key(current[:widgets]) { |key, widget| ids[key] = widget[:id] }
100
+ each_widget_with_key(widgets) do |key, widget|
101
+ next unless (id = ids[key])
102
+ widget[:id] ||= id # do not set nil, that breaks updates
103
+ end
104
+ end
105
+
106
+ # yield each widget with a key built from its (optional) group title and its own title
107
+ def each_widget_with_key(widgets, prefix: nil, &block)
108
+ widgets.each do |widget|
109
+ title = widget.dig(:definition, :title)
110
+ key = [prefix, title].compact.join("-")
111
+ yield key, widget
112
+ if (nested = widget.dig(:definition, :widgets))
113
+ each_widget_with_key(nested, prefix: title, &block)
114
+ end
115
+ end
116
+ end
117
+
84
118
  def with_pagination(enabled, params)
85
119
  return yield params unless enabled
86
120
  raise ArgumentError if params[:limit] || params[:offset]
@@ -97,10 +131,16 @@ module Kennel
97
131
  end
98
132
 
99
133
  # Make diff work even though we cannot mass-fetch definitions
134
+ # @return [uncached, found]
100
135
  def fill_detail!(api_resource, a, cache)
136
+ uncached = false
101
137
  args = [api_resource, a.fetch(:id)]
102
- full = cache.fetch(args, a.fetch(:modified_at)) { show(*args) }
103
- a.merge!(full)
138
+ full = cache.fetch(args, a.fetch(:modified_at)) do
139
+ uncached = true
140
+ show(*args, ignore_404: true)
141
+ end
142
+ a.merge!(full) if full
143
+ [uncached, !!full]
104
144
  end
105
145
 
106
146
  def details_cache(&block)
@@ -108,44 +148,23 @@ module Kennel
108
148
  cache.open(&block)
109
149
  end
110
150
 
111
- def request(method, path, body: nil, params: {}, ignore_404: false)
151
+ def request(method, path, body: nil, params: {}, ignore_404: false, tries: 5)
112
152
  path = "#{path}?#{Faraday::FlatParamsEncoder.encode(params)}" if params.any?
113
- with_cache ENV["FORCE_GET_CACHE"] && method == :get, path do
114
- response = nil
115
- tries = 2
116
-
117
- tries.times do |i|
118
- response = Utils.retry Faraday::ConnectionFailed, Faraday::TimeoutError, times: 2 do
119
- @client.send(method, path) do |request|
120
- request.body = JSON.generate(body) if body
121
- request.headers["Content-type"] = "application/json"
122
- request.headers["DD-API-KEY"] = @api_key
123
- request.headers["DD-APPLICATION-KEY"] = @app_key
124
- end
125
- end
126
-
127
- rate_limit = RateLimitParams.new(
128
- limit: response.headers["x-ratelimit-limit"],
129
- period: response.headers["x-ratelimit-period"],
130
- remaining: response.headers["x-ratelimit-remaining"],
131
- reset: response.headers["x-ratelimit-reset"],
132
- name: response.headers["x-ratelimit-name"]
133
- )
134
-
135
- if response.status == 429
136
- message = "Datadog rate limit #{rate_limit.name.inspect} hit"
137
- message += " (#{rate_limit.limit} requests per #{rate_limit.period} seconds)"
138
- message += "; sleeping #{rate_limit.reset} seconds before trying again"
139
- Kennel.err.puts message
140
- sleep rate_limit.reset.to_f
141
- redo
153
+ cached = (ENV["FORCE_GET_CACHE"] && method == :get)
154
+
155
+ with_cache cached, path do
156
+ response = request_with_retries(path, tries) do
157
+ @client.send(method, path) do |request|
158
+ request.body = JSON.generate(body) if body
159
+ request.headers["Content-type"] = "application/json"
160
+ request.headers["DD-API-KEY"] = @api_key
161
+ request.headers["DD-APPLICATION-KEY"] = @app_key
142
162
  end
143
-
144
- break if i == tries - 1 || method != :get || response.status < 500
145
- Kennel.err.puts "Retrying on server error #{response.status} for #{path}"
146
163
  end
147
164
 
148
- if !response.success? && (response.status != 404 || !ignore_404)
165
+ next if response.status == 404 && ignore_404
166
+
167
+ unless response.success?
149
168
  message = "Error #{response.status} during #{method.upcase} #{path}\n"
150
169
  message << "request:\n#{JSON.pretty_generate(body)}\nresponse:\n" if body
151
170
  message << response.body.encode(message.encoding, invalid: :replace, undef: :replace)
@@ -160,6 +179,48 @@ module Kennel
160
179
  end
161
180
  end
162
181
 
182
+ # retry on rate-limits and server errors, giving up after `tries` attempts
183
+ def request_with_retries(path, tries, &block)
184
+ raise ArgumentError, "tries must be > 0" if tries < 1
185
+ response = nil
186
+ tries.times do |i|
187
+ response = Utils.retry Faraday::ConnectionFailed, Faraday::TimeoutError, times: 2, &block
188
+
189
+ # we do not count 429s into the "tries", tries are only for real errors
190
+ # so this could loop forever if datadog consistently 429s
191
+ if response.status == 429
192
+ sleep_until_rate_limit_resets(response)
193
+ redo
194
+ end
195
+
196
+ last_try = (i == tries - 1)
197
+ break if last_try || response.status < 500
198
+ Kennel.err.puts "Retrying on server error #{response.status} for #{path}"
199
+ sleep retry_backoff_time(i)
200
+ end
201
+ response
202
+ end
203
+
204
+ def sleep_until_rate_limit_resets(response)
205
+ limit = response.headers["x-ratelimit-limit"]
206
+ period = response.headers["x-ratelimit-period"]
207
+ reset = response.headers["x-ratelimit-reset"]
208
+ name = response.headers["x-ratelimit-name"]
209
+
210
+ message = "Datadog rate limit #{name.inspect} hit"
211
+ message += " (#{limit} requests per #{period} seconds)"
212
+ message += "; sleeping #{reset} seconds before trying again"
213
+
214
+ Kennel.err.puts message
215
+ sleep reset.to_f
216
+ end
217
+
218
+ # 0:0.1s - 4:2s
219
+ def retry_backoff_time(attempt)
220
+ base = 2**attempt
221
+ 0.1 * base * (0.5 + (rand / 2.0))
222
+ end
223
+
163
224
  # allow caching all requests to speedup/benchmark logic that includes repeated requests
164
225
  def with_cache(enabled, key)
165
226
  return yield unless enabled
data/lib/kennel/filter.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  module Kennel
4
4
  class Filter
5
+ ID_SEPARATOR = ":"
5
6
  attr_reader :project_filter
6
7
 
7
8
  def initialize
@@ -22,23 +23,18 @@ module Kennel
22
23
  !project_filter.nil?
23
24
  end
24
25
 
25
- def matches_project_id?(project_id)
26
+ def filters_project_id?(project_id)
26
27
  !filtering? || project_filter.include?(project_id)
27
28
  end
28
29
 
29
- def matches_tracking_id?(tracking_id)
30
+ def filters_tracking_id?(tracking_id)
30
31
  return true unless filtering?
31
32
  return tracking_id_filter.include?(tracking_id) if tracking_id_filter
32
33
 
33
- project_id = tracking_id.split(":").first
34
+ project_id = tracking_id.split(ID_SEPARATOR, 2).first
34
35
  project_filter.include?(project_id)
35
36
  end
36
37
 
37
- def tracking_id_for_path(tracking_id)
38
- return tracking_id unless tracking_id.end_with?(".json")
39
- tracking_id.sub("generated/", "").sub(".json", "").sub("/", ":")
40
- end
41
-
42
38
  private
43
39
 
44
40
  attr_reader :tracking_id_filter
@@ -46,7 +42,7 @@ module Kennel
46
42
  # needs to be called after read_tracking_id_filter_from_env
47
43
  def read_project_filter_from_env
48
44
  project_names = ENV["PROJECT"]&.split(",")&.sort&.uniq
49
- tracking_project_names = tracking_id_filter&.map { |id| id.split(":", 2).first }&.sort&.uniq
45
+ tracking_project_names = tracking_id_filter&.map { |id| id.split(ID_SEPARATOR, 2).first }&.sort&.uniq
50
46
  if project_names && tracking_project_names && project_names != tracking_project_names
51
47
  # avoid everything being filtered out
52
48
  raise "do not set a different PROJECT= when using TRACKING_ID="
@@ -57,8 +53,8 @@ module Kennel
57
53
  def read_tracking_id_filter_from_env
58
54
  return unless (tracking_id = ENV["TRACKING_ID"])
59
55
  tracking_id.split(",").map do |id|
60
- # allow users to paste the generated/ path of an objects to update it without manually converting
61
- tracking_id_for_path(id)
56
+ # allow using the generated/ path from `git diff` to update objects without manually converting
57
+ id.include?(ID_SEPARATOR) ? id : PartsSerializer.tracking_id_for_path(id)
62
58
  end.sort.uniq
63
59
  end
64
60
 
@@ -21,6 +21,12 @@ module Kennel
21
21
  data = @api.show(model.api_resource, id)
22
22
 
23
23
  id = data.fetch(:id) # keep native value
24
+ if resource == "slo"
25
+ # only set primary if needed to reduce clutter
26
+ if data[:thresholds] && data[:thresholds].min_by { |t| t[:timeframe].to_i }[:timeframe] != data[:timeframe]
27
+ data[:primary] = data[:timeframe]
28
+ end
29
+ end
24
30
  model.normalize({}, data) # removes id
25
31
  data[:id] = id
26
32
 
@@ -80,7 +86,14 @@ module Kennel
80
86
  end
81
87
  when "synthetics/tests"
82
88
  data[:locations] = :all if data[:locations].sort == Kennel::Models::SyntheticTest::LOCATIONS.sort
83
- else
89
+ when "slo"
90
+ # sli_specification it is only used by datadog if the user switched to "Bad Events"
91
+ # otherwise user would be trying to set sli_specification but the slo does not change since query is used
92
+ if data.key?(:query) && data.key?(:sli_specification)
93
+ delete = (data.dig(:sli_specification, :count, :bad_events_formula) ? :query : :sli_specification)
94
+ data.delete delete
95
+ end
96
+ else # uncovered
84
97
  # noop
85
98
  end
86
99
 
@@ -208,9 +208,10 @@ module Kennel
208
208
  end
209
209
  end
210
210
 
211
- def validate_update!(diffs)
212
- _, path, from, to = diffs.find { |diff| diff[1] == "layout_type" }
213
- invalid_update!(path, from, to) if path
211
+ def allowed_update_error(actual)
212
+ actual_type = actual[:layout_type]
213
+ return if actual_type == layout_type
214
+ "cannot update layout_type from #{actual_type} to #{layout_type}"
214
215
  end
215
216
 
216
217
  private