kennel 1.129.0 → 1.131.0

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: c7690d961a150ea0ba0649edcab513c22ec828988fec2ab631636497274305c7
4
- data.tar.gz: c5bc7244c4168466f5167e5c322f3a2cdb8e6b9b8ec23a2d12fb343a7648ae06
3
+ metadata.gz: ac3eeb3c1ec0e2b03c7acbf2b2d7c68335207a7e79408775e9b876c6c4acd400
4
+ data.tar.gz: db96fddb04bce543efcea1a505a1d360447161259e9e9fdd481acfe785bad194
5
5
  SHA512:
6
- metadata.gz: 51b47377a8cf688dafd17600b543cc1734eb8511a6ed1b627d8b1180bf70fc66c86f9d4a8e4b2a3ff5e7223edf366e5483d983d9465c5907aaa07f5b9c36ef46
7
- data.tar.gz: 698c23a4de558382467342b90e7d3eac143a0279653efe9f1b711b3e114a37d8d72f4a9c5d97e652a6646007298f5e93f296c1125caaa4c498195089b0cac730
6
+ metadata.gz: a710b2e2ae6932cf36bdd12fbb06c34f8b538944e693a484b294e7614b7d9941e7c2da498cf7cdcb56bda255a0b2c874383306437ee5b71456d900ae1e9a5c2f
7
+ data.tar.gz: 90e767722863bb640f3bd96436937c390d40d8c8b868d3ee51a444ff6e042e36f03048cc1b5c9cec49d96207d53d99df6f529318658c3881fd9078ed55c2b7d8
data/lib/kennel/api.rb CHANGED
@@ -5,6 +5,16 @@ module Kennel
5
5
  class Api
6
6
  CACHE_FILE = "tmp/cache/details"
7
7
 
8
+ def self.tag(api_resource, reply)
9
+ klass = Models::Record.api_resource_map[api_resource]
10
+ return reply unless klass # do not blow up on unknown models
11
+
12
+ reply.merge(
13
+ klass: klass,
14
+ tracking_id: klass.parse_tracking_id(reply)
15
+ )
16
+ end
17
+
8
18
  def initialize(app_key = nil, api_key = nil)
9
19
  @app_key = app_key || ENV.fetch("DATADOG_APP_KEY")
10
20
  @api_key = api_key || ENV.fetch("DATADOG_API_KEY")
@@ -16,7 +26,7 @@ module Kennel
16
26
  response = request :get, "/api/v1/#{api_resource}/#{id}", params: params
17
27
  response = response.fetch(:data) if api_resource == "slo"
18
28
  response[:id] = response.delete(:public_id) if api_resource == "synthetics/tests"
19
- response
29
+ self.class.tag(api_resource, response)
20
30
  end
21
31
 
22
32
  def list(api_resource, params = {})
@@ -32,7 +42,7 @@ module Kennel
32
42
  # ignore monitor synthetics create and that inherit the kennel_id, we do not directly manage them
33
43
  response.reject! { |m| m[:type] == "synthetics alert" } if api_resource == "monitor"
34
44
 
35
- response
45
+ response.map { |r| self.class.tag(api_resource, r) }
36
46
  end
37
47
  end
38
48
 
@@ -40,13 +50,13 @@ module Kennel
40
50
  response = request :post, "/api/v1/#{api_resource}", body: attributes
41
51
  response = response.fetch(:data).first if api_resource == "slo"
42
52
  response[:id] = response.delete(:public_id) if api_resource == "synthetics/tests"
43
- response
53
+ self.class.tag(api_resource, response)
44
54
  end
45
55
 
46
56
  def update(api_resource, id, attributes)
47
57
  response = request :put, "/api/v1/#{api_resource}/#{id}", body: attributes
48
58
  response[:id] = response.delete(:public_id) if api_resource == "synthetics/tests"
49
- response
59
+ self.class.tag(api_resource, response)
50
60
  end
51
61
 
52
62
  # - force=true to not dead-lock on dependent monitors+slos
@@ -46,13 +46,13 @@ module Kennel
46
46
  Diff::LCS.sdiff(old.split("\n", -1), new.split("\n", -1)).flat_map do |diff|
47
47
  case diff.action
48
48
  when "-"
49
- Utils.color(:red, "- #{diff.old_element}")
49
+ Console.color(:red, "- #{diff.old_element}")
50
50
  when "+"
51
- Utils.color(:green, "+ #{diff.new_element}")
51
+ Console.color(:green, "+ #{diff.new_element}")
52
52
  when "!"
53
53
  [
54
- Utils.color(:red, "- #{diff.old_element}"),
55
- Utils.color(:green, "+ #{diff.new_element}")
54
+ Console.color(:red, "- #{diff.old_element}"),
55
+ Console.color(:green, "+ #{diff.new_element}")
56
56
  ]
57
57
  else
58
58
  " #{diff.old_element}"
@@ -61,12 +61,12 @@ module Kennel
61
61
  end
62
62
 
63
63
  def truncate(message)
64
- warning = Utils.color(
64
+ warning = Console.color(
65
65
  :magenta,
66
66
  " (Diff for this item truncated after #{@max_diff_lines} lines. " \
67
67
  "Rerun with MAX_DIFF_LINES=#{@max_diff_lines * 2} to see more)"
68
68
  )
69
- Utils.truncate_lines(message, to: @max_diff_lines, warning: warning)
69
+ StringUtils.truncate_lines(message, to: @max_diff_lines, warning: warning)
70
70
  end
71
71
 
72
72
  # TODO: use awesome-print or similar, but it has too many monkey-patches
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+ module Kennel
3
+ module Console
4
+ COLORS = { red: 31, green: 32, yellow: 33, cyan: 36, magenta: 35, default: 0 }.freeze
5
+
6
+ class TeeIO < IO
7
+ def initialize(ios)
8
+ super(0) # called with fake file descriptor 0, so we can call super and get a proper class
9
+ @ios = ios
10
+ end
11
+
12
+ def write(string)
13
+ @ios.each { |io| io.write string }
14
+ end
15
+ end
16
+
17
+ class << self
18
+ def ask?(question)
19
+ Kennel.err.printf color(:red, "#{question} - press 'y' to continue: ", force: true)
20
+ begin
21
+ STDIN.gets.chomp == "y"
22
+ rescue Interrupt # do not show a backtrace if user decides to Ctrl+C here
23
+ Kennel.err.print "\n"
24
+ exit 1
25
+ end
26
+ end
27
+
28
+ def color(color, text, force: false)
29
+ return text unless force || Kennel.out.tty?
30
+
31
+ "\e[#{COLORS.fetch(color)}m#{text}\e[0m"
32
+ end
33
+
34
+ def capture_stdout
35
+ old = Kennel.out
36
+ Kennel.out = StringIO.new
37
+ yield
38
+ Kennel.out.string
39
+ ensure
40
+ Kennel.out = old
41
+ end
42
+
43
+ def capture_stderr
44
+ old = Kennel.err
45
+ Kennel.err = StringIO.new
46
+ yield
47
+ Kennel.err.string
48
+ ensure
49
+ Kennel.err = old
50
+ end
51
+
52
+ def tee_output
53
+ old_stdout = Kennel.out
54
+ old_stderr = Kennel.err
55
+ capture = StringIO.new
56
+ Kennel.out = TeeIO.new([capture, Kennel.out])
57
+ Kennel.err = TeeIO.new([capture, Kennel.err])
58
+ yield
59
+ capture.string
60
+ ensure
61
+ Kennel.out = old_stdout
62
+ Kennel.err = old_stderr
63
+ end
64
+ end
65
+ end
66
+ end
@@ -57,7 +57,9 @@ module Kennel
57
57
  # - delete expired keys
58
58
  # - delete what would be deleted anyway when updating
59
59
  def expire_old_data
60
- @data.reject! { |_, (_, (_, cv), expires)| expires < @now || cv != @cache_version }
60
+ @data.reject! do |(_api_resource, _id), (_value, (_key_version, cache_version), expires)|
61
+ expires < @now || cache_version != @cache_version
62
+ end
61
63
  end
62
64
  end
63
65
  end
@@ -24,7 +24,7 @@ module Kennel
24
24
  end
25
25
 
26
26
  def report(&block)
27
- output = Utils.tee_output(&block).strip
27
+ output = Console.tee_output(&block).strip
28
28
  rescue StandardError
29
29
  output = "Error:\n#{$ERROR_INFO.message}"
30
30
  raise
@@ -33,7 +33,7 @@ module Kennel
33
33
  model.remove_tracking_id(data)
34
34
  tracking_id.split(":").last
35
35
  else
36
- Kennel::Utils.parameterize(title)
36
+ Kennel::StringUtils.parameterize(title)
37
37
  end
38
38
 
39
39
  case resource
@@ -10,7 +10,7 @@ module Kennel
10
10
  SETTING_OVERRIDABLE_METHODS = [:name, :kennel_id].freeze
11
11
 
12
12
  def kennel_id
13
- @kennel_id ||= Utils.snake_case kennel_id_base
13
+ @kennel_id ||= StringUtils.snake_case kennel_id_base
14
14
  end
15
15
 
16
16
  def name
@@ -202,7 +202,7 @@ module Kennel
202
202
  end
203
203
  end
204
204
 
205
- def validate_update!(_actuals, diffs)
205
+ def validate_update!(diffs)
206
206
  _, path, from, to = diffs.find { |diff| diff[1] == "layout_type" }
207
207
  invalid_update!(path, from, to) if path
208
208
  end
@@ -137,7 +137,7 @@ module Kennel
137
137
  end
138
138
  end
139
139
 
140
- def validate_update!(_actuals, diffs)
140
+ def validate_update!(diffs)
141
141
  # ensure type does not change, but not if it's metric->query which is supported and used by importer.rb
142
142
  _, path, from, to = diffs.detect { |_, path, _, _| path == "type" }
143
143
  if path && !(from == "metric alert" && to == "query alert")
@@ -167,7 +167,7 @@ module Kennel
167
167
  end
168
168
 
169
169
  # Can raise DisallowedUpdateError
170
- def validate_update!(*)
170
+ def validate_update!(_diffs)
171
171
  end
172
172
 
173
173
  def invalid_update!(field, old_value, new_value)
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+ module Kennel
3
+ module StringUtils
4
+ class << self
5
+ def snake_case(string)
6
+ string
7
+ .gsub(/::/, "_") # Foo::Bar -> foo_bar
8
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') # FOOBar -> foo_bar
9
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2') # fooBar -> foo_bar
10
+ .tr("-", "_") # foo-bar -> foo_bar
11
+ .downcase
12
+ end
13
+
14
+ # for child projects, not used internally
15
+ def title_case(string)
16
+ string.split(/[\s_]/).map(&:capitalize) * " "
17
+ end
18
+
19
+ # simplified version of https://apidock.com/rails/ActiveSupport/Inflector/parameterize
20
+ def parameterize(string)
21
+ string
22
+ .downcase
23
+ .gsub(/[^a-z0-9\-_]+/, "-") # remove unsupported
24
+ .gsub(/-{2,}/, "-") # remove duplicates
25
+ .gsub(/^-|-$/, "") # remove leading/trailing
26
+ end
27
+
28
+ def truncate_lines(text, to:, warning:)
29
+ lines = text.split(/\n/, to + 1)
30
+ lines[-1] = warning if lines.size > to
31
+ lines.join("\n")
32
+ end
33
+ end
34
+ end
35
+ end
data/lib/kennel/syncer.rb CHANGED
@@ -6,170 +6,121 @@ module Kennel
6
6
  LINE_UP = "\e[1A\033[K" # go up and clear
7
7
 
8
8
  Plan = Struct.new(:changes, keyword_init: true)
9
+
10
+ InternalPlan = Struct.new(:creates, :updates, :deletes) do
11
+ def empty?
12
+ creates.empty? && updates.empty? && deletes.empty?
13
+ end
14
+ end
15
+
9
16
  Change = Struct.new(:type, :api_resource, :tracking_id, :id)
10
17
 
11
18
  def initialize(api, expected, actual, strict_imports: true, project_filter: nil, tracking_id_filter: nil)
12
19
  @api = api
13
- @expected = Set.new expected # need Set to speed up deletion
14
- @actual = actual
15
20
  @strict_imports = strict_imports
16
21
  @project_filter = project_filter
17
22
  @tracking_id_filter = tracking_id_filter
18
23
 
19
- @attribute_differ = AttributeDiffer.new
24
+ @resolver = Resolver.new(expected: expected, project_filter: @project_filter, tracking_id_filter: @tracking_id_filter)
20
25
 
21
- calculate_changes
22
- validate_changes
23
- prevent_irreversible_partial_updates
26
+ internal_plan = calculate_changes(expected: expected, actual: actual)
27
+ validate_changes(internal_plan)
28
+ @internal_plan = internal_plan
24
29
 
25
- @warnings.each { |message| Kennel.out.puts Utils.color(:yellow, "Warning: #{message}") }
30
+ @warnings.each { |message| Kennel.out.puts Console.color(:yellow, "Warning: #{message}") }
26
31
  end
27
32
 
28
33
  def plan
34
+ ip = @internal_plan
29
35
  Plan.new(
30
- changes:
31
- @create.map { |_id, e, _a| Change.new(:create, e.class.api_resource, e.tracking_id, nil) } +
32
- @update.map { |id, e, _a| Change.new(:update, e.class.api_resource, e.tracking_id, id) } +
33
- @delete.map { |id, _e, a| Change.new(:delete, a.fetch(:klass).api_resource, a.fetch(:tracking_id), id) }
36
+ changes: (ip.creates + ip.updates + ip.deletes).map(&:change)
34
37
  )
35
38
  end
36
39
 
37
40
  def print_plan
38
- Kennel.out.puts "Plan:"
39
- if noop?
40
- Kennel.out.puts Utils.color(:green, "Nothing to do")
41
- else
42
- print_changes "Create", @create, :green
43
- print_changes "Update", @update, :yellow
44
- print_changes "Delete", @delete, :red
45
- end
41
+ PlanDisplayer.new.display(internal_plan)
46
42
  end
47
43
 
48
44
  def confirm
49
- return false if noop?
45
+ return false if internal_plan.empty?
50
46
  return true if ENV["CI"] || !STDIN.tty? || !Kennel.err.tty?
51
- Utils.ask("Execute Plan ?")
47
+ Console.ask?("Execute Plan ?")
52
48
  end
53
49
 
54
50
  def update
55
51
  changes = []
56
52
 
57
- each_resolved @create do |_, e|
58
- message = "#{e.class.api_resource} #{e.tracking_id}"
53
+ internal_plan.deletes.each do |item|
54
+ message = "#{item.api_resource} #{item.tracking_id} #{item.id}"
55
+ Kennel.out.puts "Deleting #{message}"
56
+ @api.delete item.api_resource, item.id
57
+ changes << item.change
58
+ Kennel.out.puts "#{LINE_UP}Deleted #{message}"
59
+ end
60
+
61
+ resolver.each_resolved internal_plan.creates do |item|
62
+ message = "#{item.api_resource} #{item.tracking_id}"
59
63
  Kennel.out.puts "Creating #{message}"
60
- reply = @api.create e.class.api_resource, e.as_json
61
- Utils.inline_resource_metadata reply, e.class
64
+ reply = @api.create item.api_resource, item.expected.as_json
62
65
  id = reply.fetch(:id)
63
- changes << Change.new(:create, e.class.api_resource, e.tracking_id, id)
64
- populate_id_map [], [reply] # allow resolving ids we could previously no resolve
65
- Kennel.out.puts "#{LINE_UP}Created #{message} #{e.class.url(id)}"
66
+ changes << item.change(id)
67
+ resolver.add_actual [reply] # allow resolving ids we could previously not resolve
68
+ Kennel.out.puts "#{LINE_UP}Created #{message} #{item.url(id)}"
66
69
  end
67
70
 
68
- each_resolved @update do |id, e|
69
- message = "#{e.class.api_resource} #{e.tracking_id} #{e.class.url(id)}"
71
+ resolver.each_resolved internal_plan.updates do |item|
72
+ message = "#{item.api_resource} #{item.tracking_id} #{item.url}"
70
73
  Kennel.out.puts "Updating #{message}"
71
- @api.update e.class.api_resource, id, e.as_json
72
- changes << Change.new(:update, e.class.api_resource, e.tracking_id, id)
74
+ @api.update item.api_resource, item.id, item.expected.as_json
75
+ changes << item.change
73
76
  Kennel.out.puts "#{LINE_UP}Updated #{message}"
74
77
  end
75
78
 
76
- @delete.each do |id, _, a|
77
- klass = a.fetch(:klass)
78
- message = "#{klass.api_resource} #{a.fetch(:tracking_id)} #{id}"
79
- Kennel.out.puts "Deleting #{message}"
80
- @api.delete klass.api_resource, id
81
- changes << Change.new(:delete, klass.api_resource, a.fetch(:tracking_id), id)
82
- Kennel.out.puts "#{LINE_UP}Deleted #{message}"
83
- end
84
-
85
79
  Plan.new(changes: changes)
86
80
  end
87
81
 
88
82
  private
89
83
 
90
- # loop over items until everything is resolved or crash when we get stuck
91
- # this solves cases like composite monitors depending on each other or monitor->monitor slo->slo monitor chains
92
- def each_resolved(list)
93
- list = list.dup
94
- loop do
95
- return if list.empty?
96
- list.reject! do |id, e|
97
- if resolved?(e)
98
- yield id, e
99
- true
100
- else
101
- false
102
- end
103
- end ||
104
- assert_resolved(list[0][1]) # resolve something or show a circular dependency error
105
- end
106
- end
107
-
108
- # TODO: optimize by storing an instance variable if already resolved
109
- def resolved?(e)
110
- assert_resolved e
111
- true
112
- rescue UnresolvableIdError
113
- false
114
- end
115
-
116
- # raises UnresolvableIdError when not resolved
117
- def assert_resolved(e)
118
- resolve_linked_tracking_ids! [e], force: true
119
- end
120
-
121
- def noop?
122
- @create.empty? && @update.empty? && @delete.empty?
123
- end
84
+ attr_reader :resolver, :internal_plan
124
85
 
125
- def calculate_changes
86
+ def calculate_changes(expected:, actual:)
126
87
  @warnings = []
127
- @id_map = IdMap.new
128
88
 
129
89
  Progress.progress "Diffing" do
130
- populate_id_map @expected, @actual
131
- filter_actual! @actual
132
- resolve_linked_tracking_ids! @expected # resolve dependencies to avoid diff
133
- @expected.each(&:add_tracking_id) # avoid diff with actual, which has tracking_id
90
+ resolver.add_actual actual
91
+ filter_actual! actual
92
+ resolver.resolve_as_much_as_possible(expected) # resolve as many dependencies as possible to reduce the diff
134
93
 
135
94
  # see which expected match the actual
136
- matching, unmatched_expected, unmatched_actual = partition_matched_expected
95
+ matching, unmatched_expected, unmatched_actual = MatchedExpected.partition(expected, actual)
137
96
  validate_expected_id_not_missing unmatched_expected
138
97
  fill_details! matching # need details to diff later
139
98
 
140
99
  # update matching if needed
141
- @update = matching.map do |e, a|
100
+ updates = matching.map do |e, a|
101
+ # Refuse to "adopt" existing items into kennel while running with a filter (i.e. on a branch).
102
+ # Without this, we'd adopt an item, then the next CI run would delete it
103
+ # (instead of "unadopting" it).
104
+ e.add_tracking_id unless @project_filter && a.fetch(:tracking_id).nil?
142
105
  id = a.fetch(:id)
143
106
  diff = e.diff(a)
144
- [id, e, a, diff] if diff.any?
107
+ a[:id] = id
108
+ Types::PlannedUpdate.new(e, a, diff) if diff.any?
145
109
  end.compact
146
110
 
147
111
  # delete previously managed
148
- @delete = unmatched_actual.map { |a| [a.fetch(:id), nil, a] if a.fetch(:tracking_id) }.compact
112
+ deletes = unmatched_actual.map { |a| Types::PlannedDelete.new(a) if a.fetch(:tracking_id) }.compact
149
113
 
150
114
  # unmatched expected need to be created
151
- @create = unmatched_expected.map { |e| [nil, e] }
115
+ unmatched_expected.each(&:add_tracking_id)
116
+ creates = unmatched_expected.map { |e| Types::PlannedCreate.new(e) }
152
117
 
153
118
  # order to avoid deadlocks
154
- @delete.sort_by! { |_, _, a| DELETE_ORDER.index a.fetch(:klass).api_resource }
155
- @update.sort_by! { |_, e, _| DELETE_ORDER.index e.class.api_resource } # slo needs to come before slo alert
156
- end
157
- end
119
+ deletes.sort_by! { |item| DELETE_ORDER.index item.api_resource }
120
+ updates.sort_by! { |item| DELETE_ORDER.index item.api_resource } # slo needs to come before slo alert
158
121
 
159
- def partition_matched_expected
160
- lookup_map = matching_expected_lookup_map
161
- unmatched_expected = @expected.dup
162
- unmatched_actual = []
163
- matched = []
164
- @actual.each do |a|
165
- e = matching_expected(a, lookup_map)
166
- if e && unmatched_expected.delete?(e)
167
- matched << [e, a]
168
- else
169
- unmatched_actual << a
170
- end
171
- end.compact
172
- [matched, unmatched_expected, unmatched_actual]
122
+ InternalPlan.new(creates, updates, deletes)
123
+ end
173
124
  end
174
125
 
175
126
  # fill details of things we need to compare
@@ -190,108 +141,242 @@ module Kennel
190
141
  end
191
142
  end
192
143
 
193
- # index list by all the thing we look up by: tracking id and actual id
194
- def matching_expected_lookup_map
195
- @expected.each_with_object({}) do |e, all|
196
- keys = [e.tracking_id]
197
- keys << "#{e.class.api_resource}:#{e.id}" if e.id
198
- keys.compact.each do |key|
199
- raise "Lookup #{key} is duplicated" if all[key]
200
- all[key] = e
201
- end
144
+ # We've already validated the desired objects ('generated') in isolation.
145
+ # Now that we have made the plan, we can perform some more validation.
146
+ def validate_changes(internal_plan)
147
+ internal_plan.updates.each do |item|
148
+ item.expected.validate_update!(item.diff)
202
149
  end
203
150
  end
204
151
 
205
- def matching_expected(a, map)
206
- klass = a.fetch(:klass)
207
- map["#{klass.api_resource}:#{a.fetch(:id)}"] || map[a.fetch(:tracking_id)]
152
+ def filter_actual!(actual)
153
+ if @tracking_id_filter
154
+ actual.select! do |a|
155
+ tracking_id = a.fetch(:tracking_id)
156
+ !tracking_id || @tracking_id_filter.include?(tracking_id)
157
+ end
158
+ elsif @project_filter
159
+ project_prefixes = @project_filter.map { |p| "#{p}:" }
160
+ actual.select! do |a|
161
+ tracking_id = a.fetch(:tracking_id)
162
+ !tracking_id || tracking_id.start_with?(*project_prefixes)
163
+ end
164
+ end
208
165
  end
209
166
 
210
- def print_changes(step, list, color)
211
- return if list.empty?
212
- list.each do |_, e, a, diff|
213
- klass = (e ? e.class : a.fetch(:klass))
214
- Kennel.out.puts Utils.color(color, "#{step} #{klass.api_resource} #{e&.tracking_id || a.fetch(:tracking_id)}")
215
- diff&.each { |args| Kennel.out.puts @attribute_differ.format(*args) } # only for update
167
+ class PlanDisplayer
168
+ def initialize
169
+ @attribute_differ = AttributeDiffer.new
216
170
  end
217
- end
218
171
 
219
- # We've already validated the desired objects ('generated') in isolation.
220
- # Now that we have made the plan, we can perform some more validation.
221
- def validate_changes
222
- @update.each do |_, expected, actuals, diffs|
223
- expected.validate_update!(actuals, diffs)
172
+ def display(internal_plan)
173
+ Kennel.out.puts "Plan:"
174
+ if internal_plan.empty?
175
+ Kennel.out.puts Console.color(:green, "Nothing to do")
176
+ else
177
+ print_changes "Create", internal_plan.creates, :green
178
+ print_changes "Update", internal_plan.updates, :yellow
179
+ print_changes "Delete", internal_plan.deletes, :red
180
+ end
181
+ end
182
+
183
+ private
184
+
185
+ def print_changes(step, list, color)
186
+ return if list.empty?
187
+ list.each do |item|
188
+ Kennel.out.puts Console.color(color, "#{step} #{item.api_resource} #{item.tracking_id}")
189
+ if item.class::TYPE == :update
190
+ item.diff.each { |args| Kennel.out.puts @attribute_differ.format(*args) } # only for update
191
+ end
192
+ end
224
193
  end
225
194
  end
226
195
 
227
- # - do not add tracking-id when working with existing ids on a branch,
228
- # so resource do not get deleted when running an update on master (for example merge->CI)
229
- # - ideally we'd never add tracking in the first place, but when adding tracking we do not know the diff yet
230
- def prevent_irreversible_partial_updates
231
- return unless @project_filter # full update, so we are not on a branch
232
- @update.select! do |_, e, _, diff| # ensure clean diff, by removing noop-update
233
- next true unless e.id # safe to add tracking when not having id
234
-
235
- diff.select! do |field_diff|
236
- (_, field, actual) = field_diff
237
- # TODO: refactor this so TRACKING_FIELD stays record-private
238
- next true if e.class::TRACKING_FIELD != field.to_sym # need to sym here because Hashdiff produces strings
239
- next true if e.class.parse_tracking_id(field.to_sym => actual) # already has tracking id
240
-
241
- field_diff[3] = e.remove_tracking_id # make `rake plan` output match what we are sending
242
- actual != field_diff[3] # discard diff if now nothing changes
196
+ class Resolver
197
+ def initialize(expected:, project_filter:, tracking_id_filter:)
198
+ @id_map = IdMap.new
199
+ @project_filter = project_filter
200
+ @tracking_id_filter = tracking_id_filter
201
+
202
+ # mark everything as new
203
+ expected.each do |e|
204
+ id_map.set(e.class.api_resource, e.tracking_id, IdMap::NEW)
205
+ if e.class.api_resource == "synthetics/tests"
206
+ id_map.set(Kennel::Models::Monitor.api_resource, e.tracking_id, IdMap::NEW)
207
+ end
243
208
  end
209
+ end
244
210
 
245
- diff.any?
211
+ def add_actual(actual)
212
+ # override resources that exist with their id
213
+ project_prefixes = project_filter&.map { |p| "#{p}:" }
214
+
215
+ actual.each do |a|
216
+ # ignore when not managed by kennel
217
+ next unless tracking_id = a.fetch(:tracking_id)
218
+
219
+ # ignore when deleted from the codebase
220
+ # (when running with filters we cannot see the other resources in the codebase)
221
+ api_resource = a.fetch(:klass).api_resource
222
+ next if
223
+ !id_map.get(api_resource, tracking_id) &&
224
+ (!project_prefixes || tracking_id.start_with?(*project_prefixes)) &&
225
+ (!tracking_id_filter || tracking_id_filter.include?(tracking_id))
226
+
227
+ id_map.set(api_resource, tracking_id, a.fetch(:id))
228
+ if a.fetch(:klass).api_resource == "synthetics/tests"
229
+ id_map.set(Kennel::Models::Monitor.api_resource, tracking_id, a.fetch(:monitor_id))
230
+ end
231
+ end
246
232
  end
247
- end
248
233
 
249
- def populate_id_map(expected, actual)
250
- # mark everything as new
251
- expected.each do |e|
252
- @id_map.set(e.class.api_resource, e.tracking_id, IdMap::NEW)
253
- if e.class.api_resource == "synthetics/tests"
254
- @id_map.set(Kennel::Models::Monitor.api_resource, e.tracking_id, IdMap::NEW)
234
+ def resolve_as_much_as_possible(expected)
235
+ expected.each do |e|
236
+ e.resolve_linked_tracking_ids!(id_map, force: false)
255
237
  end
256
238
  end
257
239
 
258
- # override resources that exist with their id
259
- project_prefixes = @project_filter&.map { |p| "#{p}:" }
260
- actual.each do |a|
261
- # ignore when not managed by kennel
262
- next unless tracking_id = a.fetch(:tracking_id)
263
-
264
- # ignore when deleted from the codebase
265
- # (when running with filters we cannot see the other resources in the codebase)
266
- api_resource = a.fetch(:klass).api_resource
267
- next if
268
- !@id_map.get(api_resource, tracking_id) &&
269
- (!project_prefixes || tracking_id.start_with?(*project_prefixes)) &&
270
- (!@tracking_id_filter || @tracking_id_filter.include?(tracking_id))
271
-
272
- @id_map.set(api_resource, tracking_id, a.fetch(:id))
273
- if a[:klass].api_resource == "synthetics/tests"
274
- @id_map.set(Kennel::Models::Monitor.api_resource, tracking_id, a.fetch(:monitor_id))
240
+ # loop over items until everything is resolved or crash when we get stuck
241
+ # this solves cases like composite monitors depending on each other or monitor->monitor slo->slo monitor chains
242
+ def each_resolved(list)
243
+ list = list.dup
244
+ loop do
245
+ return if list.empty?
246
+ list.reject! do |item|
247
+ if resolved?(item.expected)
248
+ yield item
249
+ true
250
+ else
251
+ false
252
+ end
253
+ end ||
254
+ assert_resolved(list[0].expected) # resolve something or show a circular dependency error
275
255
  end
276
256
  end
257
+
258
+ private
259
+
260
+ attr_reader :id_map, :project_filter, :tracking_id_filter
261
+
262
+ # TODO: optimize by storing an instance variable if already resolved
263
+ def resolved?(e)
264
+ assert_resolved e
265
+ true
266
+ rescue UnresolvableIdError
267
+ false
268
+ end
269
+
270
+ # raises UnresolvableIdError when not resolved
271
+ def assert_resolved(e)
272
+ e.resolve_linked_tracking_ids!(id_map, force: true)
273
+ end
277
274
  end
278
275
 
279
- def resolve_linked_tracking_ids!(list, force: false)
280
- list.each { |e| e.resolve_linked_tracking_ids!(@id_map, force: force) }
276
+ module MatchedExpected
277
+ class << self
278
+ def partition(expected, actual)
279
+ lookup_map = matching_expected_lookup_map(expected)
280
+ unmatched_expected = Set.new(expected) # for efficient deletion
281
+ unmatched_actual = []
282
+ matched = []
283
+ actual.each do |a|
284
+ e = matching_expected(a, lookup_map)
285
+ if e && unmatched_expected.delete?(e)
286
+ matched << [e, a]
287
+ else
288
+ unmatched_actual << a
289
+ end
290
+ end.compact
291
+ [matched, unmatched_expected.to_a, unmatched_actual]
292
+ end
293
+
294
+ private
295
+
296
+ # index list by all the thing we look up by: tracking id and actual id
297
+ def matching_expected_lookup_map(expected)
298
+ expected.each_with_object({}) do |e, all|
299
+ keys = [e.tracking_id]
300
+ keys << "#{e.class.api_resource}:#{e.id}" if e.id
301
+ keys.compact.each do |key|
302
+ raise "Lookup #{key} is duplicated" if all[key]
303
+ all[key] = e
304
+ end
305
+ end
306
+ end
307
+
308
+ def matching_expected(a, map)
309
+ klass = a.fetch(:klass)
310
+ map["#{klass.api_resource}:#{a.fetch(:id)}"] || map[a.fetch(:tracking_id)]
311
+ end
312
+ end
281
313
  end
282
314
 
283
- def filter_actual!(actual)
284
- if @tracking_id_filter
285
- actual.select! do |a|
286
- tracking_id = a.fetch(:tracking_id)
287
- !tracking_id || @tracking_id_filter.include?(tracking_id)
315
+ module Types
316
+ class PlannedChange
317
+ def initialize(klass, tracking_id)
318
+ @klass = klass
319
+ @tracking_id = tracking_id
288
320
  end
289
- elsif @project_filter
290
- project_prefixes = @project_filter.map { |p| "#{p}:" }
291
- actual.select! do |a|
292
- tracking_id = a.fetch(:tracking_id)
293
- !tracking_id || tracking_id.start_with?(*project_prefixes)
321
+
322
+ def api_resource
323
+ klass.api_resource
324
+ end
325
+
326
+ def url(id = nil)
327
+ klass.url(id || self.id)
328
+ end
329
+
330
+ def change(id = nil)
331
+ Change.new(self.class::TYPE, api_resource, tracking_id, id)
294
332
  end
333
+
334
+ attr_reader :klass, :tracking_id
335
+ end
336
+
337
+ class PlannedCreate < PlannedChange
338
+ TYPE = :create
339
+
340
+ def initialize(expected)
341
+ super(expected.class, expected.tracking_id)
342
+ @expected = expected
343
+ end
344
+
345
+ attr_reader :expected
346
+ end
347
+
348
+ class PlannedUpdate < PlannedChange
349
+ TYPE = :update
350
+
351
+ def initialize(expected, actual, diff)
352
+ super(expected.class, expected.tracking_id)
353
+ @expected = expected
354
+ @actual = actual
355
+ @diff = diff
356
+ @id = actual.fetch(:id)
357
+ end
358
+
359
+ def change
360
+ super(id)
361
+ end
362
+
363
+ attr_reader :expected, :actual, :diff, :id
364
+ end
365
+
366
+ class PlannedDelete < PlannedChange
367
+ TYPE = :delete
368
+
369
+ def initialize(actual)
370
+ super(actual.fetch(:klass), actual.fetch(:tracking_id))
371
+ @actual = actual
372
+ @id = actual.fetch(:id)
373
+ end
374
+
375
+ def change
376
+ super(id)
377
+ end
378
+
379
+ attr_reader :actual, :id
295
380
  end
296
381
  end
297
382
  end
@@ -22,7 +22,7 @@ module Kennel
22
22
  m[:state][:groups].each do |g|
23
23
  color = COLORS[g[:status]] || :default
24
24
  since = "\t#{time_since(g[:last_triggered_ts])}"
25
- Kennel.out.puts "#{Kennel::Utils.color(color, g[:status])}\t#{g[:name]}#{since}"
25
+ Kennel.out.puts "#{Kennel::Console.color(color, g[:status])}\t#{g[:name]}#{since}"
26
26
  end
27
27
  Kennel.out.puts
28
28
  end
data/lib/kennel/utils.rb CHANGED
@@ -1,100 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
  module Kennel
3
3
  module Utils
4
- COLORS = { red: 31, green: 32, yellow: 33, cyan: 36, magenta: 35, default: 0 }.freeze
5
-
6
- class TeeIO < IO
7
- def initialize(ios)
8
- super(0) # called with fake file descriptor 0, so we can call super and get a proper class
9
- @ios = ios
10
- end
11
-
12
- def write(string)
13
- @ios.each { |io| io.write string }
14
- end
15
- end
16
-
17
4
  class << self
18
- def snake_case(string)
19
- string
20
- .gsub(/::/, "_") # Foo::Bar -> foo_bar
21
- .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') # FOOBar -> foo_bar
22
- .gsub(/([a-z\d])([A-Z])/, '\1_\2') # fooBar -> foo_bar
23
- .tr("-", "_") # foo-bar -> foo_bar
24
- .downcase
25
- end
26
-
27
- # for child projects, not used internally
28
- def title_case(string)
29
- string.split(/[\s_]/).map(&:capitalize) * " "
30
- end
31
-
32
- # simplified version of https://apidock.com/rails/ActiveSupport/Inflector/parameterize
33
- def parameterize(string)
34
- string
35
- .downcase
36
- .gsub(/[^a-z0-9\-_]+/, "-") # remove unsupported
37
- .gsub(/-{2,}/, "-") # remove duplicates
38
- .gsub(/^-|-$/, "") # remove leading/trailing
39
- end
40
-
41
5
  def presence(value)
42
6
  value.nil? || value.empty? ? nil : value
43
7
  end
44
8
 
45
- def ask(question)
46
- Kennel.err.printf color(:red, "#{question} - press 'y' to continue: ", force: true)
47
- begin
48
- STDIN.gets.chomp == "y"
49
- rescue Interrupt # do not show a backtrace if user decides to Ctrl+C here
50
- Kennel.err.print "\n"
51
- exit 1
52
- end
53
- end
54
-
55
- def color(color, text, force: false)
56
- return text unless force || Kennel.out.tty?
57
-
58
- "\e[#{COLORS.fetch(color)}m#{text}\e[0m"
59
- end
60
-
61
- def truncate_lines(text, to:, warning:)
62
- lines = text.split(/\n/, to + 1)
63
- lines[-1] = warning if lines.size > to
64
- lines.join("\n")
65
- end
66
-
67
- def capture_stdout
68
- old = Kennel.out
69
- Kennel.out = StringIO.new
70
- yield
71
- Kennel.out.string
72
- ensure
73
- Kennel.out = old
74
- end
75
-
76
- def capture_stderr
77
- old = Kennel.err
78
- Kennel.err = StringIO.new
79
- yield
80
- Kennel.err.string
81
- ensure
82
- Kennel.err = old
83
- end
84
-
85
- def tee_output
86
- old_stdout = Kennel.out
87
- old_stderr = Kennel.err
88
- capture = StringIO.new
89
- Kennel.out = TeeIO.new([capture, Kennel.out])
90
- Kennel.err = TeeIO.new([capture, Kennel.err])
91
- yield
92
- capture.string
93
- ensure
94
- Kennel.out = old_stdout
95
- Kennel.err = old_stderr
96
- end
97
-
98
9
  def capture_sh(command)
99
10
  result = `#{command} 2>&1`
100
11
  raise "Command failed:\n#{command}\n#{result}" unless $CHILD_STATUS.success?
@@ -150,11 +61,6 @@ module Kennel
150
61
  else []
151
62
  end
152
63
  end
153
-
154
- def inline_resource_metadata(resource, klass)
155
- resource[:klass] = klass
156
- resource[:tracking_id] = klass.parse_tracking_id(resource)
157
- end
158
64
  end
159
65
  end
160
66
  end
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module Kennel
3
- VERSION = "1.129.0"
3
+ VERSION = "1.131.0"
4
4
  end
data/lib/kennel.rb CHANGED
@@ -5,6 +5,8 @@ require "zeitwerk"
5
5
  require "English"
6
6
 
7
7
  require "kennel/version"
8
+ require "kennel/console"
9
+ require "kennel/string_utils"
8
10
  require "kennel/utils"
9
11
  require "kennel/progress"
10
12
  require "kennel/filter"
@@ -133,8 +135,7 @@ module Kennel
133
135
  def definitions(**kwargs)
134
136
  @definitions ||= Progress.progress("Downloading definitions", **kwargs) do
135
137
  Utils.parallel(Models::Record.subclasses) do |klass|
136
- results = api.list(klass.api_resource, with_downtimes: false) # lookup monitors without adding unnecessary downtime information
137
- results.each { |a| Utils.inline_resource_metadata(a, klass) }
138
+ api.list(klass.api_resource, with_downtimes: false) # lookup monitors without adding unnecessary downtime information
138
139
  end.flatten(1)
139
140
  end
140
141
  end
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.129.0
4
+ version: 1.131.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Grosser
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-12-16 00:00:00.000000000 Z
11
+ date: 2023-01-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: diff-lcs
@@ -90,6 +90,7 @@ files:
90
90
  - lib/kennel.rb
91
91
  - lib/kennel/api.rb
92
92
  - lib/kennel/attribute_differ.rb
93
+ - lib/kennel/console.rb
93
94
  - lib/kennel/file_cache.rb
94
95
  - lib/kennel/filter.rb
95
96
  - lib/kennel/github_reporter.rb
@@ -108,6 +109,7 @@ files:
108
109
  - lib/kennel/progress.rb
109
110
  - lib/kennel/projects_provider.rb
110
111
  - lib/kennel/settings_as_methods.rb
112
+ - lib/kennel/string_utils.rb
111
113
  - lib/kennel/subclass_tracking.rb
112
114
  - lib/kennel/syncer.rb
113
115
  - lib/kennel/tasks.rb