kennel 1.86.1 → 1.89.1

Sign up to get free protection for your applications and to get access to all the features.
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