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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e2531f5f8a3ef6c978e2aeeec1db0a16f56aebe690ef6eae8baefff34c3c53fe
4
- data.tar.gz: d650a926e7d730f34e3dc90833f04f693161ad6aa112fd8ffdbca88f37d37497
3
+ metadata.gz: ba1d58935b541b89cb78318964470bb209933a491d9b81e23785de0fb6ab2654
4
+ data.tar.gz: aed4b13dc2c35ced80073e592fd0d2ba2afda8dfb763d2c6baa4b1f30851cf53
5
5
  SHA512:
6
- metadata.gz: 12c01b65de8be29754e78b8caa6d8d8cc3f8c8089928008917a31121d3641161eb8b519aedfd7f5a643ff9f4f1123579e8bc6e07382e26613b5dd9937f6f6c16
7
- data.tar.gz: 421e6d81e4ea91f0eb152ba5230026334221e452f5dea8a4e040d5b8285db8f0913443357b128f5c00e917557ace751f264a55568e50ee86a8e304fb1f9f188e
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
- reply = request :get, "/api/v1/#{api_resource}/#{id}", params: params
15
- api_resource == "slo" ? reply[:data] : reply
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
- if api_resource == "slo"
20
- raise ArgumentError if params[:limit] || params[:offset]
21
- limit = 1000
22
- offset = 0
23
- all = []
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
- reply = request :post, "/api/v1/#{api_resource}", body: attributes
41
- api_resource == "slo" ? reply[:data].first : reply
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
- params = params.merge(application_key: @app_key, api_key: @api_key)
78
- query = Faraday::FlatParamsEncoder.encode(params)
79
- response = nil
80
- tries = 2
81
-
82
- tries.times do |i|
83
- response = Utils.retry Faraday::ConnectionFailed, Faraday::TimeoutError, times: 2 do
84
- @client.send(method, "#{path}?#{query}") do |request|
85
- request.body = JSON.generate(body) if body
86
- request.headers["Content-type"] = "application/json"
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
- break if i == tries - 1 || method != :get || response.status < 500
91
- Kennel.err.puts "Retrying on server error #{response.status} for #{path}"
92
- end
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
- if !response.success? && (response.status != 404 || !ignore_404)
95
- message = +"Error #{response.status} during #{method.upcase} #{path}\n"
96
- message << "request:\n#{JSON.pretty_generate(body)}\nresponse:\n" if body
97
- message << response.body
98
- raise message
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
- if response.body.empty?
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
- JSON.parse(response.body, symbolize_names: true)
125
+ result = yield
126
+ File.write(file, Marshal.dump(result))
127
+ result
105
128
  end
106
129
  end
107
130
  end
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # cache that reads everything from a single file
4
- # to avoid doing multiple disk reads while iterating all definitions
5
- # it also replaces updated keys and has an overall expiry to not keep deleted things forever
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
- return old_value if old_version == [key_version, @cache_version]
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, [key_version, @cache_version], @expires]
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! { |_, (_, _, ex)| ex < @now }
54
+ @data.reject! { |_, (_, (_, cv), expires)| expires < @now || cv != @cache_version }
51
55
  end
52
56
  end
53
57
  end
@@ -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, *Syncer::TRACKING_FIELDS, :template_variables].freeze
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 tracking_field && data[tracking_field].sub!(/\n?-- Managed by kennel (\S+:\S+).*/, "")
38
- $1.split(":").last
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 { |widget| dry_up_query!(widget) }
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 dry_up_query!(widget)
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
 
@@ -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" => { show_legend: false, legend_size: "0" },
16
- "note" => { background_color: "white", font_size: "14", show_tick: false, tick_edge: "left", tick_pos: "50%", text_align: "left" }
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(expected, actual, DEFAULTS)
44
- ignore_default(expected, actual, reflow_type: "auto") if expected[:layout_type] == "ordered"
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
- # 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
-
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
- 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)
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
- [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
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, nesting: nil)
126
+ def ignore_defaults(expected, actual, defaults)
86
127
  [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)
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
- return unless id = url[/\/monitors[\/#](\d+)/, 1]
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
@@ -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, :api_resource
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
- "#{project.kennel_id}:#{kennel_id}"
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! "#{type} #{tracking_id} was referenced but is also created by the current run.\nIt could not be created because of a circular dependency, try creating only some of the resources"
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
@@ -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)
@@ -29,6 +29,8 @@ module Kennel
29
29
  Kennel.err.print "#{time.round(2)}s\n"
30
30
 
31
31
  result
32
+ ensure
33
+ stop = true
32
34
  end
33
35
  end
34
36
  end
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 "Created #{e.class.api_resource} #{tracking_id(e.as_json)} #{e.class.url(id)}"
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 "Updated #{e.class.api_resource} #{tracking_id(e.as_json)} #{e.class.url(id)}"
45
+ Kennel.out.puts "#{LINE_UP}Updated #{message}"
50
46
  end
51
47
 
52
48
  @delete.each do |id, _, a|
53
- @api.delete a.fetch(:api_resource), id
54
- Kennel.out.puts "Deleted #{a.fetch(:api_resource)} #{tracking_id(a)} #{id}"
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
- # resolve dependencies to avoid diff
103
- populate_id_map actual
104
- @expected.each { |e| @id_map[e.tracking_id] ||= :new }
105
- resolve_linked_tracking_ids! @expected
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
- filter_by_project! actual
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
- detailed = Hash.new { |h, k| h[k] = [] }
121
- items.each { |e, a| detailed[a[:api_resource]] << a if e }
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(a) # was previously managed
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.map(&:api_resource)) do |api_resource|
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 { |c| c[:api_resource] = api_resource } # store api resource for later diffing
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 = [tracking_id(e.as_json)]
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
- @lookup_map["#{a.fetch(:api_resource)}:#{a.fetch(:id)}"] || @lookup_map[tracking_id(a)]
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
- api_resource = (e ? e.class.api_resource : a.fetch(:api_resource))
176
- Kennel.out.puts Utils.color(color, "#{step} #{api_resource} #{e&.tracking_id || tracking_id(a)}")
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
- # Do not add tracking-id when working with existing ids on a branch,
203
- # so resource do not get deleted from running an update on master (for example merge->CI).
204
- # Also make sure the diff still makes sense, by kicking out the now noop-update.
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 # short circuit for performance
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, old, new) = field_diff
214
- next true unless tracking_field?(field)
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
- if (old_tracking = tracking_value(old))
217
- old_tracking == tracking_value(new) || raise("do not update! (atm unreachable)")
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 { |a| @id_map[tracking_id(a)] = a.fetch(:id) }
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 filter_by_project!(definitions)
240
+ def filter_actual_by_project!(actual)
237
241
  return unless @project_filter
238
- definitions.select! do |a|
239
- id = tracking_id(a)
240
- !id || id.start_with?("#{@project_filter}:")
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 add_tracking_id(e)
245
- json = e.as_json
246
- field = tracking_field(json)
247
- raise "remove \"-- Managed by kennel\" line it from #{field} to copy a resource" if tracking_value(json[field])
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
- def tracking_field?(field)
271
- TRACKING_FIELDS.include?(field.to_sym)
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
- (requests.is_a?(Hash) ? requests.values : requests).map { |r| r[:q] } # hostmap widgets have hash requests
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
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module Kennel
3
- VERSION = "1.86.1"
3
+ VERSION = "1.89.1"
4
4
  end
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.86.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-04-21 00:00:00.000000000 Z
11
+ date: 2021-07-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday