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.
@@ -6,7 +6,8 @@ module Kennel
6
6
 
7
7
  OPTIONAL_SERVICE_CHECK_THRESHOLDS = [:ok, :warning].freeze
8
8
  READONLY_ATTRIBUTES = superclass::READONLY_ATTRIBUTES + [
9
- :multi, :matching_downtimes, :overall_state_modified, :overall_state, :restricted_roles, :draft_status
9
+ :multi, :matching_downtimes, :overall_state_modified, :overall_state, :restricted_roles, :draft_status, :assets,
10
+ :enable_logs_sample
10
11
  ]
11
12
  TRACKING_FIELD = :message
12
13
 
@@ -25,19 +26,27 @@ module Kennel
25
26
  group_retention_duration: nil,
26
27
  groupby_simple_monitor: false,
27
28
  variables: nil,
28
- on_missing_data: "default", # "default" is "evaluate as zero"
29
+ on_missing_data: nil,
29
30
  notification_preset_name: nil,
30
- notify_by: nil
31
+ notify_by: nil,
32
+ include_tags: true
31
33
  }.freeze
32
34
  DEFAULT_ESCALATION_MESSAGE = ["", nil].freeze
33
35
  ALLOWED_PRIORITY_CLASSES = [NilClass, Integer].freeze
34
36
  SKIP_NOTIFY_NO_DATA_TYPES = ["event alert", "event-v2 alert", "log alert"].freeze
37
+ ON_MISSING_DATA_UNSUPPORTED_TYPES = ["composite", "datadog-usage alert"].freeze
38
+ MINUTES_PER_UNIT = {
39
+ "m" => 1,
40
+ "h" => 60,
41
+ "d" => 60 * 24,
42
+ "w" => 60 * 24 * 7
43
+ }.freeze
35
44
 
36
45
  settings(
37
46
  :query, :name, :message, :escalation_message, :critical, :type, :renotify_interval, :warning, :timeout_h, :evaluation_delay,
38
47
  :ok, :no_data_timeframe, :notify_no_data, :notify_audit, :tags, :critical_recovery, :warning_recovery, :require_full_window,
39
48
  :threshold_windows, :scheduling_options, :new_host_delay, :new_group_delay, :group_retention_duration, :priority,
40
- :variables, :on_missing_data, :notification_preset_name, :notify_by
49
+ :variables, :on_missing_data, :notification_preset_name, :notify_by, :include_tags
41
50
  )
42
51
 
43
52
  defaults(
@@ -49,13 +58,12 @@ module Kennel
49
58
  # datadog UI sets this to false by default, but true is safer
50
59
  # except for log alerts which will always have "no error" gaps and should default to false
51
60
  notify_no_data: -> { !SKIP_NOTIFY_NO_DATA_TYPES.include?(type) },
52
- no_data_timeframe: -> { 60 },
61
+ no_data_timeframe: -> { MONITOR_OPTION_DEFAULTS.fetch(:no_data_timeframe) },
53
62
  notify_audit: -> { MONITOR_OPTION_DEFAULTS.fetch(:notify_audit) },
54
63
  new_host_delay: -> { MONITOR_OPTION_DEFAULTS.fetch(:new_host_delay) },
55
64
  new_group_delay: -> { nil },
56
65
  group_retention_duration: -> { MONITOR_OPTION_DEFAULTS.fetch(:group_retention_duration) },
57
66
  tags: -> { @project.tags },
58
- timeout_h: -> { MONITOR_OPTION_DEFAULTS.fetch(:timeout_h) },
59
67
  evaluation_delay: -> { MONITOR_OPTION_DEFAULTS.fetch(:evaluation_delay) },
60
68
  critical_recovery: -> { nil },
61
69
  warning_recovery: -> { nil },
@@ -66,10 +74,13 @@ module Kennel
66
74
  on_missing_data: -> { MONITOR_OPTION_DEFAULTS.fetch(:on_missing_data) },
67
75
  notification_preset_name: -> { MONITOR_OPTION_DEFAULTS.fetch(:notification_preset_name) },
68
76
  notify_by: -> { MONITOR_OPTION_DEFAULTS.fetch(:notify_by) },
69
- require_full_window: -> { false }
77
+ require_full_window: -> { false },
78
+ include_tags: -> { MONITOR_OPTION_DEFAULTS.fetch(:include_tags) }
70
79
  )
71
80
 
72
81
  def build_json
82
+ no_data_options = configure_no_data
83
+
73
84
  data = super.merge(
74
85
  name: "#{name}#{LOCK}",
75
86
  type: type,
@@ -79,42 +90,22 @@ module Kennel
79
90
  priority: priority,
80
91
  options: {
81
92
  timeout_h: timeout_h,
82
- notify_no_data: notify_no_data,
83
- no_data_timeframe: notify_no_data ? no_data_timeframe : nil,
93
+ **no_data_options.except(:on_missing_data),
84
94
  notify_audit: notify_audit,
85
95
  require_full_window: require_full_window,
86
96
  new_host_delay: new_host_delay,
87
97
  new_group_delay: new_group_delay,
88
- include_tags: true,
98
+ include_tags: include_tags,
89
99
  escalation_message: Utils.presence(escalation_message.strip),
90
100
  evaluation_delay: evaluation_delay,
91
- locked: false, # deprecated: setting this to true will likely fail
92
101
  renotify_interval: renotify_interval || 0,
93
- variables: variables
102
+ variables: variables,
103
+ **configure_thresholds,
104
+ **no_data_options.slice(:on_missing_data) # moved here to avoid generated diff
94
105
  }
95
106
  )
96
107
 
97
108
  options = data[:options]
98
- if data.fetch(:type) != "composite"
99
- thresholds = (options[:thresholds] = { critical: critical })
100
-
101
- # warning, ok, critical_recovery, and warning_recovery are optional
102
- [:warning, :ok, :critical_recovery, :warning_recovery].each do |key|
103
- if (value = send(key))
104
- thresholds[key] = value
105
- end
106
- end
107
-
108
- thresholds[:critical] = critical unless
109
- case data.fetch(:type)
110
- when "service check"
111
- # avoid diff for default values of 1
112
- OPTIONAL_SERVICE_CHECK_THRESHOLDS.each { |t| thresholds[t] ||= 1 }
113
- when "query alert"
114
- # metric and query values are stored as float by datadog
115
- thresholds.each { |k, v| thresholds[k] = Float(v) }
116
- end
117
- end
118
109
 
119
110
  # set without causing lots of nulls to be stored
120
111
  if (notify_by_value = notify_by)
@@ -145,30 +136,54 @@ module Kennel
145
136
  # Add in statuses where we would re notify on. Possible values: alert, no data, warn
146
137
  if options[:renotify_interval] != 0
147
138
  statuses = ["alert"]
148
- statuses << "no data" if options[:notify_no_data]
139
+ statuses << "no data" if options[:notify_no_data] || options[:on_missing_data] == "show_and_notify_no_data"
149
140
  statuses << "warn" if options.dig(:thresholds, :warning)
150
141
  options[:renotify_statuses] = statuses
151
142
  end
152
143
 
153
- # on_missing_data cannot be used with notify_no_data or no_data_timeframe
154
- # TODO migrate everything to only use on_missing_data
155
- if data.fetch(:type) == "event-v2 alert" || on_missing_data != "default"
156
- options[:on_missing_data] = on_missing_data
157
- options[:notify_no_data] = false # cannot set nil or it's an endless update loop
158
- options.delete :no_data_timeframe
159
- end
160
-
161
144
  # only set when needed to avoid big diff
162
145
  if (notification_preset_name = notification_preset_name())
163
146
  options[:notification_preset_name] = notification_preset_name
164
147
  end
165
148
 
166
- # locked is deprecated, will fail if used
167
- options.delete :locked
168
-
169
149
  data
170
150
  end
171
151
 
152
+ # TODO: migrate everything to only use on_missing_data by only sending notify_no_data when it was set by a user
153
+ # and enforce that it is not set at the same time as on_missing_data
154
+ def configure_no_data
155
+ notify = notify_no_data
156
+ timeframe = no_data_timeframe
157
+ action = on_missing_data
158
+ action ||= "default" if type == "event-v2 alert"
159
+
160
+ # TODO: mark setting action && !notify.nil? at all as invalid
161
+ if action
162
+ if ON_MISSING_DATA_UNSUPPORTED_TYPES.include?(type)
163
+ invalid! :invalid_no_data_config, "cannot use on_missing_data with #{type} monitor"
164
+ end
165
+ if timeframe
166
+ invalid! :invalid_no_data_config, "set either no_data_timeframe or on_missing_data"
167
+ end
168
+ if action != "default" && type == "query alert" && query.to_s.include?("default_zero(") # is allowed for log alert for example
169
+ invalid! :invalid_no_data_config, "set on_missing_data to `default` when using default_zero"
170
+ end
171
+ if action == "resolve" && timeout_h.to_i != 0
172
+ invalid! :invalid_no_data_config, "timeout_h cannot be set and non-zero when on_missing_data is `resolve`"
173
+ end
174
+ end
175
+
176
+ # on_missing_data cannot be used with notify_no_data + no_data_timeframe
177
+ if action
178
+ { on_missing_data: action || "default" }
179
+ else
180
+ {
181
+ notify_no_data: notify,
182
+ no_data_timeframe: notify ? no_data_timeframe || default_no_data_timeframe : nil
183
+ }
184
+ end
185
+ end
186
+
172
187
  def resolve_linked_tracking_ids!(id_map, **args)
173
188
  case as_json[:type]
174
189
  when "composite", "slo alert"
@@ -180,14 +195,32 @@ module Kennel
180
195
  end
181
196
  end
182
197
 
183
- def validate_update!(diffs)
184
- # ensure type does not change, but not if it's metric->query which is supported and used by importer.rb
185
- _, path, from, to = diffs.detect { |_, path, _, _| path == "type" }
186
- if path && !(from == "metric alert" && to == "query alert")
187
- invalid_update!(path, from, to)
198
+ # ensure type does not change, but not if it's metric->query which is supported and used by importer.rb
199
+ def allowed_update_error(actual)
200
+ actual_type = actual[:type]
201
+ return if actual_type == type || (actual_type == "metric alert" && type == "query alert")
202
+ "cannot update type from #{actual_type} to #{type}"
203
+ end
204
+
205
+ # deprecated this setting is no longer returned by dd for new monitors
206
+ # datadog UI warns when setting no data timeframe to less than 2x the query window
207
+ # limited to 24h because `no_data_timeframe must not exceed group retention` and max group retention is 24h
208
+ def default_no_data_timeframe
209
+ default = 60
210
+ if type == "query alert" && (minutes = query_window_minutes)
211
+ (minutes * 2).clamp(default, 24 * 60)
212
+ else
213
+ default
188
214
  end
189
215
  end
190
216
 
217
+ # validate that monitors that alert on no data resolve in external services by using timeout_h, so it sends a
218
+ # notification when the no data group is removed from the monitor, which datadog does automatically after 24h
219
+ def timeout_h
220
+ sending_no_data_notifications = (on_missing_data ? on_missing_data == "show_and_notify_no_data" : notify_no_data)
221
+ sending_no_data_notifications ? 24 : MONITOR_OPTION_DEFAULTS.fetch(:timeout_h)
222
+ end
223
+
191
224
  def self.api_resource
192
225
  "monitor"
193
226
  end
@@ -214,7 +247,9 @@ module Kennel
214
247
  ignore_default(expected, actual, MONITOR_DEFAULTS)
215
248
 
216
249
  options = actual.fetch(:options)
217
- options.delete(:silenced) # we do not manage silenced, so ignore it when diffing
250
+
251
+ # we do not manage silenced: ignore it when diffing
252
+ options.delete(:silenced)
218
253
 
219
254
  # fields are not returned when set to true
220
255
  if ["service check", "event alert"].include?(actual[:type])
@@ -244,13 +279,38 @@ module Kennel
244
279
  options.delete(:escalation_message)
245
280
  expected_options.delete(:escalation_message)
246
281
  end
282
+
247
283
  # locked is deprecated: ignored when diffing
248
284
  options.delete(:locked)
249
- expected_options.delete(:locked)
250
285
  end
251
286
 
252
287
  private
253
288
 
289
+ def configure_thresholds
290
+ return {} if type == "composite"
291
+
292
+ thresholds = { critical: critical }
293
+
294
+ # set optional variables
295
+ [:warning, :ok, :critical_recovery, :warning_recovery].each do |key|
296
+ if (value = send(key))
297
+ thresholds[key] = value
298
+ end
299
+ end
300
+
301
+ # custom logic for some types
302
+ case type
303
+ when "service check"
304
+ # avoid diff for default values of 1
305
+ OPTIONAL_SERVICE_CHECK_THRESHOLDS.each { |t| thresholds[t] ||= 1 }
306
+ when "query alert"
307
+ # metric and query values are stored as float by datadog
308
+ thresholds.each { |k, v| thresholds[k] = Float(v) }
309
+ end
310
+
311
+ { thresholds: thresholds }
312
+ end
313
+
254
314
  def validate_json(data)
255
315
  super
256
316
 
@@ -266,9 +326,11 @@ module Kennel
266
326
  end
267
327
 
268
328
  # verify query includes critical value
269
- if (query_value = data.fetch(:query)[/\s*[<>]=?\s*(\d+(\.\d+)?)\s*$/, 1])
270
- if Float(query_value) != Float(data.dig(:options, :thresholds, :critical))
271
- invalid! :critical_does_not_match_query, "critical and value used in query must match"
329
+ if (critical = data.dig(:options, :thresholds, :critical))
330
+ if (query_value = data.fetch(:query)[/\s*[<>]=?\s*(\d+(\.\d+)?)\s*$/, 1])
331
+ if Float(query_value) != Float(critical)
332
+ invalid! :critical_does_not_match_query, "critical and value used in query must match"
333
+ end
272
334
  end
273
335
  end
274
336
 
@@ -297,7 +359,7 @@ module Kennel
297
359
  message = data.fetch(:message)
298
360
 
299
361
  used =
300
- message.scan(/{{\s*(?:[#^]is(?:_exact)?_match)\s*"([^\s}]+)"/) + # {{#is_match "environment.name" "production"}}
362
+ message.scan(/{{\s*(?:[#^]is(?:_exact)?_match)\s*['"]([^\s}]+)['"]/) + # {{#is_match "environment.name" "production"}}
301
363
  message.scan(/{{\s*([^}]+\.name)\s*}}/) # Pod {{pod.name}} failed
302
364
  return if used.empty?
303
365
  used.flatten!(1)
@@ -373,6 +435,11 @@ module Kennel
373
435
  else # do nothing
374
436
  end
375
437
  end
438
+
439
+ def query_window_minutes
440
+ return unless (match = query.match(/^\s*\w+\(last_(?<count>\d+)(?<unit>[mhdw])\):/))
441
+ Integer(match["count"]) * MINUTES_PER_UNIT.fetch(match["unit"])
442
+ end
376
443
  end
377
444
  end
378
445
  end
@@ -10,15 +10,21 @@ module Kennel
10
10
 
11
11
  def self.file_location
12
12
  return @file_location if defined?(@file_location)
13
- if (location = instance_methods(false).first)
14
- @file_location = instance_method(location).source_location.first.sub("#{Bundler.root}/", "")
13
+ methods = instance_methods(false)
14
+ if methods.any?
15
+ @file_location = methods.detect do |method|
16
+ location = instance_method(method).source_location.first
17
+ if (path = find_relative_path(location))
18
+ break path
19
+ end
20
+ end || raise("Unable to find file_location for #{name}")
15
21
  else
16
- @file_location = nil
22
+ @file_location = nil # not sure if this is actually needed
17
23
  end
18
24
  end
19
25
 
20
26
  def validated_parts
21
- all = parts
27
+ all = filter_parts(parts)
22
28
  unless all.is_a?(Array) && all.all? { |part| part.is_a?(Record) }
23
29
  raise "Project #{kennel_id} #parts must return an array of Records"
24
30
  end
@@ -29,6 +35,16 @@ module Kennel
29
35
 
30
36
  private
31
37
 
38
+ private_class_method def self.find_relative_path(path)
39
+ return path unless File.absolute_path?(path)
40
+ path.dup.sub!("#{Bundler.root}/", "") || path.dup.sub!("#{Dir.pwd}/", "")
41
+ end
42
+
43
+ # hook for users to add custom filtering via `prepend`
44
+ def filter_parts(parts)
45
+ parts
46
+ end
47
+
32
48
  # hook for users to add custom validations via `prepend`
33
49
  def validate_parts(parts)
34
50
  end
@@ -142,12 +142,8 @@ module Kennel
142
142
  @as_json
143
143
  end
144
144
 
145
- # Can raise DisallowedUpdateError
146
- def validate_update!(_diffs)
147
- end
148
-
149
- def invalid_update!(field, old_value, new_value)
150
- raise DisallowedUpdateError, "#{safe_tracking_id} Datadog does not allow update of #{field} (#{old_value.inspect} -> #{new_value.inspect})"
145
+ def allowed_update_error(_a)
146
+ nil
151
147
  end
152
148
 
153
149
  # For use during error handling
@@ -6,7 +6,7 @@ module Kennel
6
6
 
7
7
  READONLY_ATTRIBUTES = [
8
8
  *superclass::READONLY_ATTRIBUTES,
9
- :type_id, :monitor_tags, :target_threshold, :timeframe, :warning_threshold
9
+ :type_id, :monitor_tags
10
10
  ].freeze
11
11
  TRACKING_FIELD = :description
12
12
  DEFAULTS = {
@@ -14,10 +14,12 @@ module Kennel
14
14
  query: nil,
15
15
  groups: nil,
16
16
  monitor_ids: [],
17
- thresholds: []
17
+ thresholds: [],
18
+ primary: nil,
19
+ sli_specification: nil
18
20
  }.freeze
19
21
 
20
- settings :type, :description, :thresholds, :query, :tags, :monitor_ids, :monitor_tags, :name, :groups, :sli_specification
22
+ settings :type, :description, :thresholds, :query, :tags, :monitor_ids, :monitor_tags, :name, :groups, :sli_specification, :primary
21
23
 
22
24
  defaults(
23
25
  tags: -> { @project.tags },
@@ -25,7 +27,9 @@ module Kennel
25
27
  description: -> { DEFAULTS.fetch(:description) },
26
28
  monitor_ids: -> { DEFAULTS.fetch(:monitor_ids) },
27
29
  thresholds: -> { DEFAULTS.fetch(:thresholds) },
28
- groups: -> { DEFAULTS.fetch(:groups) }
30
+ groups: -> { DEFAULTS.fetch(:groups) },
31
+ primary: -> { DEFAULTS.fetch(:primary) },
32
+ sli_specification: -> { DEFAULTS.fetch(:sli_specification) }
29
33
  )
30
34
 
31
35
  def build_json
@@ -38,8 +42,18 @@ module Kennel
38
42
  type: type
39
43
  )
40
44
 
41
- if type == "time_slice"
42
- data[:sli_specification] = sli_specification
45
+ # add top level timeframe and threshold settings based on `primary`
46
+ if (p = primary)
47
+ data[:timeframe] = p
48
+ threshold =
49
+ thresholds.detect { |t| t[:timeframe] == p } ||
50
+ raise(ArgumentError, "#{tracking_id} unable to find threshold with timeframe #{p}")
51
+ data[:warning_threshold] = threshold[:warning]
52
+ data[:target_threshold] = threshold[:target]
53
+ end
54
+
55
+ if (v = sli_specification)
56
+ data[:sli_specification] = v
43
57
  elsif (v = query)
44
58
  data[:query] = v
45
59
  end
@@ -60,7 +74,7 @@ module Kennel
60
74
  end
61
75
 
62
76
  def self.parse_url(url)
63
- url[/[?&]slo_id=([a-z\d]{10,})/, 1] || url[/\/slo\/([a-z\d]{10,})\/edit(\?|$)/, 1]
77
+ url[/[?&]slo_id=([a-z\d]{10,})/, 1] || url[/\/slo\/([a-z\d]{10,})(:?\/edit)?(\?|$)/, 1]
64
78
  end
65
79
 
66
80
  def resolve_linked_tracking_ids!(id_map, **args)
@@ -83,6 +97,21 @@ module Kennel
83
97
  expected[:tags]&.sort!
84
98
  actual[:tags].sort!
85
99
 
100
+ # do not show these in the diff if we automatically pick the primary timeframe,
101
+ # or we will have a permanent `something -> nil` diff
102
+ unless expected[:timeframe]
103
+ [:timeframe, :warning_threshold, :target_threshold].each { |k| actual.delete k }
104
+ end
105
+
106
+ # discard deprecated query which stays in datadog forever when we are trying to set sli_specification
107
+ # (downgrading to query by setting sli_specification=nil is not supported in the api)
108
+ actual.delete :query if expected[:sli_specification]
109
+
110
+ # user set query so let's not worry about sli_specification even if this might be hiding bugs
111
+ # ideally we'd validate and tell the user that this will have no effect (see importer logic)
112
+ # but I'm not confident this will always be right
113
+ actual.delete :sli_specification if expected[:query]
114
+
86
115
  ignore_default(expected, actual, DEFAULTS)
87
116
  end
88
117
 
@@ -97,6 +126,11 @@ module Kennel
97
126
  invalid! :tags_are_upper_case, "Tags must not be upper case (bad tags: #{bad_tags.sort.inspect})"
98
127
  end
99
128
 
129
+ # Check that thresholds are not empty
130
+ if !data[:thresholds] || data[:thresholds].empty?
131
+ invalid! :thresholds_empty, "SLO must have at least one threshold defined"
132
+ end
133
+
100
134
  # prevent "Invalid payload: The target is incorrect: target must be a positive number between (0.0, 100.0)"
101
135
  data[:thresholds]&.each do |threshold|
102
136
  target = threshold.fetch(:target)
@@ -54,6 +54,9 @@ module Kennel
54
54
  actual[:locations] = actual[:locations]&.sort
55
55
 
56
56
  ignore_default(expected, actual, DEFAULTS)
57
+
58
+ # only update downtime_ids if we are trying to manage them
59
+ actual[:options]&.delete :downtime_ids unless expected.dig(:options, :downtime_ids)
57
60
  end
58
61
  end
59
62
  end
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
  module Kennel
3
3
  module Models
4
- class Team < Base
5
- settings :mention, :tags, :renotify_interval, :kennel_id
4
+ class Team
5
+ include SettingsAsMethods
6
+
7
+ settings :mention, :tags, :renotify_interval
6
8
  defaults(
7
- tags: -> { ["team:#{kennel_id.sub(/^teams_/, "").tr("_", "-")}"] },
9
+ tags: -> { ["team:#{StringUtils.snake_case(self.class.name).sub(/^teams_/, "").tr("_", "-")}"] },
8
10
  renotify_interval: -> { 0 }
9
11
  )
10
12
  end
@@ -2,6 +2,9 @@
2
2
 
3
3
  module Kennel
4
4
  class PartsSerializer
5
+ FILE_EXTENSION = ".json"
6
+ FOLDER = "generated"
7
+
5
8
  def initialize(filter:)
6
9
  @filter = filter
7
10
  end
@@ -9,8 +12,15 @@ module Kennel
9
12
  def write(parts)
10
13
  Progress.progress "Storing" do
11
14
  existing = existing_files_and_folders
12
- used = write_changed(parts)
13
- FileUtils.rm_rf(existing - used)
15
+ used, changed = write_changed(parts)
16
+ FileUtils.rm_rf(existing - used) # cleanup abandoned
17
+ suggest_using_project_filter(changed)
18
+ end
19
+ end
20
+
21
+ class << self
22
+ def tracking_id_for_path(path)
23
+ path.sub("#{FOLDER}/", "").sub(FILE_EXTENSION, "").sub("/", ":")
14
24
  end
15
25
  end
16
26
 
@@ -20,28 +30,29 @@ module Kennel
20
30
 
21
31
  def write_changed(parts)
22
32
  used = []
33
+ changed = []
23
34
 
24
35
  Utils.parallel(parts, max: 2) do |part|
25
36
  path = path_for_tracking_id(part.tracking_id)
26
37
 
38
+ # match paths returned from existing_files_and_folders
27
39
  used << File.dirname(path) # we have 1 level of sub folders, so this is enough
28
40
  used << path
29
41
 
30
42
  content = part.as_json.merge(api_resource: part.class.api_resource)
31
- write_file_if_necessary(path, content)
43
+ changed << path if write_file_if_necessary(path, content)
32
44
  end
33
-
34
- used
45
+ [used, changed]
35
46
  end
36
47
 
37
48
  def existing_files_and_folders
38
- paths = Dir["generated/**/*"]
49
+ paths = Dir["#{FOLDER}/**/*"] # we rely on this returning folders and files, see write_changed
39
50
 
40
51
  # when filtering we only need the files we are going to write
41
52
  if filter.filtering?
42
53
  paths.select! do |path|
43
- tracking_id = filter.tracking_id_for_path(path)
44
- filter.matches_tracking_id?(tracking_id)
54
+ tracking_id = self.class.tracking_id_for_path(path)
55
+ filter.filters_tracking_id?(tracking_id)
45
56
  end
46
57
  end
47
58
 
@@ -49,7 +60,7 @@ module Kennel
49
60
  end
50
61
 
51
62
  def path_for_tracking_id(tracking_id)
52
- "generated/#{tracking_id.tr("/", ":").sub(":", "/")}.json"
63
+ "#{FOLDER}/#{tracking_id.tr("/", ":").sub(":", "/")}#{FILE_EXTENSION}"
53
64
  end
54
65
 
55
66
  def write_file_if_necessary(path, content)
@@ -58,13 +69,21 @@ module Kennel
58
69
 
59
70
  # 99% case
60
71
  begin
61
- return if File.read(path) == content
62
- rescue Errno::ENOENT
72
+ return false if File.read(path) == content
73
+ rescue Errno::ENOENT # file or even folder did not exist
63
74
  FileUtils.mkdir_p(File.dirname(path))
64
75
  end
65
76
 
66
77
  # slow 1% case
67
78
  File.write(path, content)
79
+ true
80
+ end
81
+
82
+ def suggest_using_project_filter(changed)
83
+ return if filter.filtering?
84
+ projects = changed.map { |path| path.split("/")[1] }.uniq
85
+ return if projects.size != 1
86
+ warn "Hint: Using PROJECT=#{projects[0]} is faster"
68
87
  end
69
88
  end
70
89
  end
@@ -12,13 +12,14 @@ module Kennel
12
12
  # All requested projects. This is a slow operation when loading all projects.
13
13
  def projects
14
14
  load_requested
15
- loaded_projects.map(&:new)
15
+ projects = loaded_projects.map(&:new)
16
+ @filter.filter_projects projects # in case we loaded more though dependencies
16
17
  end
17
18
 
18
19
  private
19
20
 
20
21
  def loaded_projects
21
- Models::Project.recursive_subclasses
22
+ Models::Project.recursive_subclasses.reject(&:abstract_class?)
22
23
  end
23
24
 
24
25
  # "require" requested .rb files under './projects',
@@ -9,8 +9,16 @@ module Kennel
9
9
  @subclasses ||= []
10
10
  end
11
11
 
12
+ def abstract_class?
13
+ !!@abstract_class
14
+ end
15
+
12
16
  private
13
17
 
18
+ def abstract_class!
19
+ @abstract_class = true # not inherited by children
20
+ end
21
+
14
22
  def inherited(child)
15
23
  super
16
24
  subclasses << child
@@ -36,7 +36,13 @@ module Kennel
36
36
 
37
37
  def matching_expected(a, map)
38
38
  klass = a.fetch(:klass)
39
- map["#{klass.api_resource}:#{a.fetch(:id)}"] || map[a.fetch(:tracking_id)]
39
+ full_id = "#{klass.api_resource}:#{a.fetch(:id)}"
40
+ if (e = map[full_id]) # we try to update and the user has set the id
41
+ return e unless (error = e.allowed_update_error(a))
42
+ raise DisallowedUpdateError, "#{full_id} Datadog does not allow update: #{error}"
43
+ elsif (e = map[a.fetch(:tracking_id)])
44
+ e.allowed_update_error(a) ? nil : e # force a re-create if we can't update
45
+ end
40
46
  end
41
47
  end
42
48
  end
@@ -12,9 +12,9 @@ module Kennel
12
12
  if plan.empty?
13
13
  Kennel.out.puts Console.color(:green, "Nothing to do")
14
14
  else
15
+ print_changes "Delete", plan.deletes, :red
15
16
  print_changes "Create", plan.creates, :green
16
17
  print_changes "Update", plan.updates, :yellow
17
- print_changes "Delete", plan.deletes, :red
18
18
  end
19
19
  end
20
20
 
@@ -27,7 +27,7 @@ module Kennel
27
27
  # ignore when deleted from the codebase
28
28
  # (when running with filters we cannot see the other resources in the codebase)
29
29
  api_resource = a.fetch(:klass).api_resource
30
- next if !id_map.get(api_resource, tracking_id) && filter.matches_tracking_id?(tracking_id)
30
+ next if !id_map.get(api_resource, tracking_id) && filter.filters_tracking_id?(tracking_id)
31
31
 
32
32
  id_map.set(api_resource, tracking_id, a.fetch(:id))
33
33
  if a.fetch(:klass).api_resource == "synthetics/tests"