kennel 1.128.0 → 1.130.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: d27f0b47987630640f1d58181bc98360714dc4b39827ea7282d1445496e3d315
4
- data.tar.gz: a2dbe1c762943b8859134772f39e3f0af0f4e7e2c5c1a60c2de980574de84714
3
+ metadata.gz: d6ee3df09b69dd5a7af7285c219f4415c4e94ce2ab4f6533c5fff03be848e9c4
4
+ data.tar.gz: d7f923b69ad9b63774141ac0e9c6c04de7a26a67abd732c41c5f19d478704656
5
5
  SHA512:
6
- metadata.gz: 6c16b16c0914e6c436424c431eec9b5b0ff5dcb92195096973e1807c75375e34f16094c4935c28de6a0797ebb8d5cc5a8e86a9c38f4a778df65598ede58ad348
7
- data.tar.gz: d885678eb185f3fa404d8c42c64f3c7f22250da1537ecec556d5f9728fe9a6aa7d7fb889f9c8d637c4f29787864f05ec5a51c5abb90023fde1c71a0110a24dd5
6
+ metadata.gz: 7c967bf54743cc757a0c0c911e981701d4b5b393f6ebac66538432541c7c6dfecef2cd8c1c0e5389f90c7d0b91a54e79a29e0e1abf9d16fd67adad4bcd6209fe
7
+ data.tar.gz: 297239ea9305e4eadbe40a363160b0ca2aaf70dad237f95a448b00b30d3db9ed9f707b2736b2596ef73e8981d333b600af87da40978f5d1c9fc8e0dee3d7f9ea
data/lib/kennel/api.rb CHANGED
@@ -5,6 +5,14 @@ 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
+ reply.merge(
11
+ klass: klass,
12
+ tracking_id: klass.parse_tracking_id(reply)
13
+ )
14
+ end
15
+
8
16
  def initialize(app_key = nil, api_key = nil)
9
17
  @app_key = app_key || ENV.fetch("DATADOG_APP_KEY")
10
18
  @api_key = api_key || ENV.fetch("DATADOG_API_KEY")
@@ -16,7 +24,7 @@ module Kennel
16
24
  response = request :get, "/api/v1/#{api_resource}/#{id}", params: params
17
25
  response = response.fetch(:data) if api_resource == "slo"
18
26
  response[:id] = response.delete(:public_id) if api_resource == "synthetics/tests"
19
- response
27
+ self.class.tag(api_resource, response)
20
28
  end
21
29
 
22
30
  def list(api_resource, params = {})
@@ -32,7 +40,7 @@ module Kennel
32
40
  # ignore monitor synthetics create and that inherit the kennel_id, we do not directly manage them
33
41
  response.reject! { |m| m[:type] == "synthetics alert" } if api_resource == "monitor"
34
42
 
35
- response
43
+ response.map { |r| self.class.tag(api_resource, r) }
36
44
  end
37
45
  end
38
46
 
@@ -40,13 +48,13 @@ module Kennel
40
48
  response = request :post, "/api/v1/#{api_resource}", body: attributes
41
49
  response = response.fetch(:data).first if api_resource == "slo"
42
50
  response[:id] = response.delete(:public_id) if api_resource == "synthetics/tests"
43
- response
51
+ self.class.tag(api_resource, response)
44
52
  end
45
53
 
46
54
  def update(api_resource, id, attributes)
47
55
  response = request :put, "/api/v1/#{api_resource}/#{id}", body: attributes
48
56
  response[:id] = response.delete(:public_id) if api_resource == "synthetics/tests"
49
- response
57
+ self.class.tag(api_resource, response)
50
58
  end
51
59
 
52
60
  # - force=true to not dead-lock on dependent monitors+slos
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "diff/lcs"
4
+
5
+ module Kennel
6
+ class AttributeDiffer
7
+ def initialize
8
+ # min '2' because: -1 makes no sense, 0 does not work with * 2 math, 1 says '1 lines'
9
+ @max_diff_lines = [Integer(ENV.fetch("MAX_DIFF_LINES", "50")), 2].max
10
+ super
11
+ end
12
+
13
+ def format(type, field, old, new = nil)
14
+ multiline = false
15
+ if type == "+"
16
+ temp = pretty_inspect(new)
17
+ new = pretty_inspect(old)
18
+ old = temp
19
+ elsif old.is_a?(String) && new.is_a?(String) && (old.include?("\n") || new.include?("\n"))
20
+ multiline = true
21
+ else # ~ and -
22
+ old = pretty_inspect(old)
23
+ new = pretty_inspect(new)
24
+ end
25
+
26
+ message =
27
+ if multiline
28
+ " #{type}#{field}\n" +
29
+ multiline_diff(old, new).map { |l| " #{l}" }.join("\n")
30
+ elsif (old + new).size > 100
31
+ " #{type}#{field}\n" \
32
+ " #{old} ->\n" \
33
+ " #{new}"
34
+ else
35
+ " #{type}#{field} #{old} -> #{new}"
36
+ end
37
+
38
+ truncate(message)
39
+ end
40
+
41
+ private
42
+
43
+ # display diff for multi-line strings
44
+ # must stay readable when color is off too
45
+ def multiline_diff(old, new)
46
+ Diff::LCS.sdiff(old.split("\n", -1), new.split("\n", -1)).flat_map do |diff|
47
+ case diff.action
48
+ when "-"
49
+ Console.color(:red, "- #{diff.old_element}")
50
+ when "+"
51
+ Console.color(:green, "+ #{diff.new_element}")
52
+ when "!"
53
+ [
54
+ Console.color(:red, "- #{diff.old_element}"),
55
+ Console.color(:green, "+ #{diff.new_element}")
56
+ ]
57
+ else
58
+ " #{diff.old_element}"
59
+ end
60
+ end
61
+ end
62
+
63
+ def truncate(message)
64
+ warning = Console.color(
65
+ :magenta,
66
+ " (Diff for this item truncated after #{@max_diff_lines} lines. " \
67
+ "Rerun with MAX_DIFF_LINES=#{@max_diff_lines * 2} to see more)"
68
+ )
69
+ StringUtils.truncate_lines(message, to: @max_diff_lines, warning: warning)
70
+ end
71
+
72
+ # TODO: use awesome-print or similar, but it has too many monkey-patches
73
+ # https://github.com/amazing-print/amazing_print/issues/36
74
+ def pretty_inspect(object)
75
+ string = object.inspect.dup
76
+ string.gsub!(/:([a-z_]+)=>/, "\\1: ")
77
+ 10.times do
78
+ string.gsub!(/{(\S.*?\S)}/, "{ \\1 }") || break
79
+ end
80
+ string
81
+ end
82
+ end
83
+ end
@@ -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
@@ -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
@@ -189,7 +189,7 @@ module Kennel
189
189
 
190
190
  # important to the front and rest deterministic
191
191
  def sort_hash(hash)
192
- Hash[hash.sort_by { |k, _| [SORT_ORDER.index(k) || 999, k] }]
192
+ hash.sort_by { |k, _| [SORT_ORDER.index(k) || 999, k] }.to_h
193
193
  end
194
194
  end
195
195
  end
@@ -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
@@ -35,8 +35,8 @@ module Kennel
35
35
  :klass, :tracking_id # added by syncer.rb
36
36
  ].freeze
37
37
  ALLOWED_KENNEL_ID_CHARS = "a-zA-Z_\\d.-"
38
- ALLOWED_KENNEL_ID_FULL = "[#{ALLOWED_KENNEL_ID_CHARS}]+:[#{ALLOWED_KENNEL_ID_CHARS}]+"
39
- ALLOWED_KENNEL_ID_REGEX = /\A#{ALLOWED_KENNEL_ID_FULL}\z/.freeze
38
+ ALLOWED_KENNEL_ID_FULL = "[#{ALLOWED_KENNEL_ID_CHARS}]+:[#{ALLOWED_KENNEL_ID_CHARS}]+".freeze
39
+ ALLOWED_KENNEL_ID_REGEX = /\A#{ALLOWED_KENNEL_ID_FULL}\z/
40
40
 
41
41
  settings :id, :kennel_id
42
42
 
@@ -52,7 +52,7 @@ module Kennel
52
52
  end
53
53
 
54
54
  def api_resource_map
55
- subclasses.map { |s| [s.api_resource, s] }.to_h
55
+ subclasses.to_h { |s| [s.api_resource, s] }
56
56
  end
57
57
 
58
58
  def parse_tracking_id(a)
@@ -98,6 +98,8 @@ module Kennel
98
98
 
99
99
  self.class.send(:normalize, expected, actual)
100
100
 
101
+ return [] if actual == expected # Hashdiff is slow, this is fast
102
+
101
103
  # strict: ignore Integer vs Float
102
104
  # similarity: show diff when not 100% similar
103
105
  # use_lcs: saner output
@@ -3,8 +3,7 @@ module Kennel
3
3
  module Models
4
4
  class SyntheticTest < Record
5
5
  TRACKING_FIELD = :message
6
- DEFAULTS = {
7
- }.freeze
6
+ DEFAULTS = {}.freeze
8
7
  READONLY_ATTRIBUTES = superclass::READONLY_ATTRIBUTES + [:status, :monitor_id]
9
8
  LOCATIONS = ["aws:ca-central-1", "aws:eu-north-1", "aws:eu-west-1", "aws:eu-west-3", "aws:eu-west-2", "aws:ap-south-1", "aws:us-west-2", "aws:us-west-1", "aws:sa-east-1", "aws:us-east-2", "aws:ap-northeast-1", "aws:ap-northeast-2", "aws:eu-central-1", "aws:ap-southeast-2", "aws:ap-southeast-1"].freeze
10
9
 
@@ -8,13 +8,9 @@ module Kennel
8
8
 
9
9
  def write(parts)
10
10
  Progress.progress "Storing" do
11
- if filter.tracking_id_filter
12
- write_changed(parts)
13
- else
14
- old = old_paths
15
- used = write_changed(parts)
16
- (old - used).uniq.each { |p| FileUtils.rm_rf(p) }
17
- end
11
+ existing = existing_files_and_folders
12
+ used = write_changed(parts)
13
+ FileUtils.rm_rf(existing - used)
18
14
  end
19
15
  end
20
16
 
@@ -26,31 +22,36 @@ module Kennel
26
22
  used = []
27
23
 
28
24
  Utils.parallel(parts, max: 2) do |part|
29
- path = "generated/#{part.tracking_id.tr("/", ":").sub(":", "/")}.json"
25
+ path = path_for_tracking_id(part.tracking_id)
30
26
 
31
- used << File.dirname(path) # only 1 level of sub folders, so this is enough
27
+ used << File.dirname(path) # we have 1 level of sub folders, so this is enough
32
28
  used << path
33
29
 
34
- payload = part.as_json.merge(api_resource: part.class.api_resource)
35
- write_file_if_necessary(path, JSON.pretty_generate(payload) << "\n")
30
+ content = part.as_json.merge(api_resource: part.class.api_resource)
31
+ write_file_if_necessary(path, content)
36
32
  end
37
33
 
38
34
  used
39
35
  end
40
36
 
41
- def directories_to_clean_up
42
- if filter.project_filter
43
- filter.project_filter.map { |project| "generated/#{project}" }
37
+ def existing_files_and_folders
38
+ if filter.tracking_id_filter
39
+ filter.tracking_id_filter.map { |tracking_id| path_for_tracking_id(tracking_id) }
40
+ elsif filter.project_filter
41
+ filter.project_filter.flat_map { |project| Dir["generated/#{project}/*"] }
44
42
  else
45
- ["generated"]
43
+ Dir["generated/**/*"] # also includes folders so we clean up empty directories
46
44
  end
47
45
  end
48
46
 
49
- def old_paths
50
- Dir["{#{directories_to_clean_up.join(",")}}/**/*"]
47
+ def path_for_tracking_id(tracking_id)
48
+ "generated/#{tracking_id.tr("/", ":").sub(":", "/")}.json"
51
49
  end
52
50
 
53
51
  def write_file_if_necessary(path, content)
52
+ # NOTE: always generating is faster than JSON.load-ing and comparing
53
+ content = JSON.pretty_generate(content) << "\n"
54
+
54
55
  # 99% case
55
56
  begin
56
57
  return if File.read(path) == content
@@ -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
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "diff/lcs"
4
-
5
3
  module Kennel
6
4
  class Syncer
7
5
  DELETE_ORDER = ["dashboard", "slo", "monitor", "synthetics/tests"].freeze # dashboards references monitors + slos, slos reference monitors
@@ -10,41 +8,47 @@ module Kennel
10
8
  Plan = Struct.new(:changes, keyword_init: true)
11
9
  Change = Struct.new(:type, :api_resource, :tracking_id, :id)
12
10
 
13
- def initialize(api, expected, actual, kennel:, project_filter: nil, tracking_id_filter: nil)
11
+ def initialize(api, expected, actual, strict_imports: true, project_filter: nil, tracking_id_filter: nil)
14
12
  @api = api
15
- @kennel = kennel
13
+ @expected = Set.new expected # need Set to speed up deletion
14
+ @actual = actual
15
+ @strict_imports = strict_imports
16
16
  @project_filter = project_filter
17
17
  @tracking_id_filter = tracking_id_filter
18
- @expected = Set.new expected # need set to speed up deletion
19
- @actual = actual
20
- calculate_diff
21
- validate_plan
18
+
19
+ @attribute_differ = AttributeDiffer.new
20
+
21
+ calculate_changes
22
+ validate_changes
22
23
  prevent_irreversible_partial_updates
24
+
25
+ @warnings.each { |message| Kennel.out.puts Console.color(:yellow, "Warning: #{message}") }
23
26
  end
24
27
 
25
28
  def plan
26
- Kennel.out.puts "Plan:"
27
- if noop?
28
- Kennel.out.puts Utils.color(:green, "Nothing to do")
29
- else
30
- @warnings.each { |message| Kennel.out.puts Utils.color(:yellow, "Warning: #{message}") }
31
- print_plan "Create", @create, :green
32
- print_plan "Update", @update, :yellow
33
- print_plan "Delete", @delete, :red
34
- end
35
-
36
29
  Plan.new(
37
30
  changes:
38
31
  @create.map { |_id, e, _a| Change.new(:create, e.class.api_resource, e.tracking_id, nil) } +
39
- @update.map { |id, e, _a| Change.new(:update, e.class.api_resource, e.tracking_id, id) } +
40
- @delete.map { |id, _e, a| Change.new(:delete, a.fetch(:klass).api_resource, a.fetch(:tracking_id), id) }
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) }
41
34
  )
42
35
  end
43
36
 
37
+ def print_plan
38
+ Kennel.out.puts "Plan:"
39
+ if noop?
40
+ Kennel.out.puts Console.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
46
+ end
47
+
44
48
  def confirm
45
49
  return false if noop?
46
50
  return true if ENV["CI"] || !STDIN.tty? || !Kennel.err.tty?
47
- Utils.ask("Execute Plan ?")
51
+ Console.ask?("Execute Plan ?")
48
52
  end
49
53
 
50
54
  def update
@@ -54,7 +58,6 @@ module Kennel
54
58
  message = "#{e.class.api_resource} #{e.tracking_id}"
55
59
  Kennel.out.puts "Creating #{message}"
56
60
  reply = @api.create e.class.api_resource, e.as_json
57
- Utils.inline_resource_metadata reply, e.class
58
61
  id = reply.fetch(:id)
59
62
  changes << Change.new(:create, e.class.api_resource, e.tracking_id, id)
60
63
  populate_id_map [], [reply] # allow resolving ids we could previously no resolve
@@ -83,8 +86,6 @@ module Kennel
83
86
 
84
87
  private
85
88
 
86
- attr_reader :kennel
87
-
88
89
  # loop over items until everything is resolved or crash when we get stuck
89
90
  # this solves cases like composite monitors depending on each other or monitor->monitor slo->slo monitor chains
90
91
  def each_resolved(list)
@@ -120,58 +121,67 @@ module Kennel
120
121
  @create.empty? && @update.empty? && @delete.empty?
121
122
  end
122
123
 
123
- def calculate_diff
124
+ def calculate_changes
124
125
  @warnings = []
125
- @update = []
126
- @delete = []
127
126
  @id_map = IdMap.new
128
127
 
129
128
  Progress.progress "Diffing" do
130
129
  populate_id_map @expected, @actual
131
130
  filter_actual! @actual
132
131
  resolve_linked_tracking_ids! @expected # resolve dependencies to avoid diff
132
+ @expected.each(&:add_tracking_id) # avoid diff with actual, which has tracking_id
133
133
 
134
- @expected.each(&:add_tracking_id) # avoid diff with actual
134
+ # see which expected match the actual
135
+ matching, unmatched_expected, unmatched_actual = partition_matched_expected
136
+ validate_expected_id_not_missing unmatched_expected
137
+ fill_details! matching # need details to diff later
135
138
 
136
- lookup_map = matching_expected_lookup_map
137
- items = @actual.map do |a|
138
- e = matching_expected(a, lookup_map)
139
- if e && @expected.delete?(e)
140
- [e, a]
141
- else
142
- [nil, a]
143
- end
144
- end
139
+ # update matching if needed
140
+ @update = matching.map do |e, a|
141
+ id = a.fetch(:id)
142
+ diff = e.diff(a)
143
+ [id, e, a, diff] if diff.any?
144
+ end.compact
145
145
 
146
- # fill details of things we need to compare
147
- details = items.map { |e, a| a if e && e.class.api_resource == "dashboard" }.compact
148
- @api.fill_details! "dashboard", details
146
+ # delete previously managed
147
+ @delete = unmatched_actual.map { |a| [a.fetch(:id), nil, a] if a.fetch(:tracking_id) }.compact
149
148
 
150
- # pick out things to update or delete
151
- items.each do |e, a|
152
- id = a.fetch(:id)
153
- if e
154
- diff = e.diff(a) # slow ...
155
- if diff.any?
156
- @update << [id, e, a, diff]
157
- end
158
- elsif a.fetch(:tracking_id) # was previously managed
159
- @delete << [id, nil, a]
160
- end
161
- end
149
+ # unmatched expected need to be created
150
+ @create = unmatched_expected.map { |e| [nil, e] }
162
151
 
163
- ensure_all_ids_found
164
- @create = @expected.map { |e| [nil, e] }
152
+ # order to avoid deadlocks
165
153
  @delete.sort_by! { |_, _, a| DELETE_ORDER.index a.fetch(:klass).api_resource }
166
154
  @update.sort_by! { |_, e, _| DELETE_ORDER.index e.class.api_resource } # slo needs to come before slo alert
167
155
  end
168
156
  end
169
157
 
170
- def ensure_all_ids_found
171
- @expected.each do |e|
158
+ def partition_matched_expected
159
+ lookup_map = matching_expected_lookup_map
160
+ unmatched_expected = @expected.dup
161
+ unmatched_actual = []
162
+ matched = []
163
+ @actual.each do |a|
164
+ e = matching_expected(a, lookup_map)
165
+ if e && unmatched_expected.delete?(e)
166
+ matched << [e, a]
167
+ else
168
+ unmatched_actual << a
169
+ end
170
+ end.compact
171
+ [matched, unmatched_expected, unmatched_actual]
172
+ end
173
+
174
+ # fill details of things we need to compare
175
+ def fill_details!(details_needed)
176
+ details_needed = details_needed.map { |e, a| a if e && e.class.api_resource == "dashboard" }.compact
177
+ @api.fill_details! "dashboard", details_needed
178
+ end
179
+
180
+ def validate_expected_id_not_missing(expected)
181
+ expected.each do |e|
172
182
  next unless id = e.id
173
183
  resource = e.class.api_resource
174
- if kennel.strict_imports
184
+ if @strict_imports
175
185
  raise "Unable to find existing #{resource} with id #{id}\nIf the #{resource} was deleted, remove the `id: -> { #{id} }` line."
176
186
  else
177
187
  @warnings << "#{resource} #{e.tracking_id} specifies id #{id}, but no such #{resource} exists. 'id' will be ignored. Remove the `id: -> { #{id} }` line."
@@ -196,79 +206,18 @@ module Kennel
196
206
  map["#{klass.api_resource}:#{a.fetch(:id)}"] || map[a.fetch(:tracking_id)]
197
207
  end
198
208
 
199
- def print_plan(step, list, color)
209
+ def print_changes(step, list, color)
200
210
  return if list.empty?
201
211
  list.each do |_, e, a, diff|
202
212
  klass = (e ? e.class : a.fetch(:klass))
203
- Kennel.out.puts Utils.color(color, "#{step} #{klass.api_resource} #{e&.tracking_id || a.fetch(:tracking_id)}")
204
- print_diff(diff) if diff # only for update
205
- end
206
- end
207
-
208
- def print_diff(diff)
209
- diff.each do |type, field, old, new|
210
- use_diff = false
211
- if type == "+"
212
- temp = Utils.pretty_inspect(new)
213
- new = Utils.pretty_inspect(old)
214
- old = temp
215
- elsif old.is_a?(String) && new.is_a?(String) && (old.include?("\n") || new.include?("\n"))
216
- use_diff = true
217
- else # ~ and -
218
- old = Utils.pretty_inspect(old)
219
- new = Utils.pretty_inspect(new)
220
- end
221
-
222
- message =
223
- if use_diff
224
- " #{type}#{field}\n" +
225
- diff(old, new).map { |l| " #{l}" }.join("\n")
226
- elsif (old + new).size > 100
227
- " #{type}#{field}\n" \
228
- " #{old} ->\n" \
229
- " #{new}"
230
- else
231
- " #{type}#{field} #{old} -> #{new}"
232
- end
233
-
234
- Kennel.out.puts truncate_diff(message)
235
- end
236
- end
237
-
238
- # display diff for multi-line strings
239
- # must stay readable when color is off too
240
- def diff(old, new)
241
- Diff::LCS.sdiff(old.split("\n", -1), new.split("\n", -1)).flat_map do |diff|
242
- case diff.action
243
- when "-"
244
- Utils.color(:red, "- #{diff.old_element}")
245
- when "+"
246
- Utils.color(:green, "+ #{diff.new_element}")
247
- when "!"
248
- [
249
- Utils.color(:red, "- #{diff.old_element}"),
250
- Utils.color(:green, "+ #{diff.new_element}")
251
- ]
252
- else
253
- " #{diff.old_element}"
254
- end
213
+ Kennel.out.puts Console.color(color, "#{step} #{klass.api_resource} #{e&.tracking_id || a.fetch(:tracking_id)}")
214
+ diff&.each { |args| Kennel.out.puts @attribute_differ.format(*args) } # only for update
255
215
  end
256
216
  end
257
217
 
258
- def truncate_diff(message)
259
- # min '2' because: -1 makes no sense, 0 does not work with * 2 math, 1 says '1 lines'
260
- @max_diff_lines ||= [Integer(ENV.fetch("MAX_DIFF_LINES", "50")), 2].max
261
- warning = Utils.color(
262
- :magenta,
263
- " (Diff for this item truncated after #{@max_diff_lines} lines. " \
264
- "Rerun with MAX_DIFF_LINES=#{@max_diff_lines * 2} to see more)"
265
- )
266
- Utils.truncate_lines(message, to: @max_diff_lines, warning: warning)
267
- end
268
-
269
218
  # We've already validated the desired objects ('generated') in isolation.
270
219
  # Now that we have made the plan, we can perform some more validation.
271
- def validate_plan
220
+ def validate_changes
272
221
  @update.each do |_, expected, actuals, diffs|
273
222
  expected.validate_update!(actuals, diffs)
274
223
  end
@@ -276,11 +225,10 @@ module Kennel
276
225
 
277
226
  # - do not add tracking-id when working with existing ids on a branch,
278
227
  # so resource do not get deleted when running an update on master (for example merge->CI)
279
- # - make sure the diff is clean, by kicking out the now noop-update
280
228
  # - ideally we'd never add tracking in the first place, but when adding tracking we do not know the diff yet
281
229
  def prevent_irreversible_partial_updates
282
- return unless @project_filter
283
- @update.select! do |_, e, _, diff|
230
+ return unless @project_filter # full update, so we are not on a branch
231
+ @update.select! do |_, e, _, diff| # ensure clean diff, by removing noop-update
284
232
  next true unless e.id # safe to add tracking when not having id
285
233
 
286
234
  diff.select! do |field_diff|
@@ -293,7 +241,7 @@ module Kennel
293
241
  actual != field_diff[3] # discard diff if now nothing changes
294
242
  end
295
243
 
296
- !diff.empty?
244
+ diff.any?
297
245
  end
298
246
  end
299
247
 
@@ -321,7 +269,7 @@ module Kennel
321
269
  (!@tracking_id_filter || @tracking_id_filter.include?(tracking_id))
322
270
 
323
271
  @id_map.set(api_resource, tracking_id, a.fetch(:id))
324
- if a[:klass].api_resource == "synthetics/tests"
272
+ if a.fetch(:klass).api_resource == "synthetics/tests"
325
273
  @id_map.set(Kennel::Models::Monitor.api_resource, tracking_id, a.fetch(:monitor_id))
326
274
  end
327
275
  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,99 +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
- @ios = ios
9
- end
10
-
11
- def write(string)
12
- @ios.each { |io| io.write string }
13
- end
14
- end
15
-
16
4
  class << self
17
- def snake_case(string)
18
- string
19
- .gsub(/::/, "_") # Foo::Bar -> foo_bar
20
- .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') # FOOBar -> foo_bar
21
- .gsub(/([a-z\d])([A-Z])/, '\1_\2') # fooBar -> foo_bar
22
- .tr("-", "_") # foo-bar -> foo_bar
23
- .downcase
24
- end
25
-
26
- # for child projects, not used internally
27
- def title_case(string)
28
- string.split(/[\s_]/).map(&:capitalize) * " "
29
- end
30
-
31
- # simplified version of https://apidock.com/rails/ActiveSupport/Inflector/parameterize
32
- def parameterize(string)
33
- string
34
- .downcase
35
- .gsub(/[^a-z0-9\-_]+/, "-") # remove unsupported
36
- .gsub(/-{2,}/, "-") # remove duplicates
37
- .gsub(/^-|-$/, "") # remove leading/trailing
38
- end
39
-
40
5
  def presence(value)
41
6
  value.nil? || value.empty? ? nil : value
42
7
  end
43
8
 
44
- def ask(question)
45
- Kennel.err.printf color(:red, "#{question} - press 'y' to continue: ", force: true)
46
- begin
47
- STDIN.gets.chomp == "y"
48
- rescue Interrupt # do not show a backtrace if user decides to Ctrl+C here
49
- Kennel.err.print "\n"
50
- exit 1
51
- end
52
- end
53
-
54
- def color(color, text, force: false)
55
- return text unless force || Kennel.out.tty?
56
-
57
- "\e[#{COLORS.fetch(color)}m#{text}\e[0m"
58
- end
59
-
60
- def truncate_lines(text, to:, warning:)
61
- lines = text.split(/\n/, to + 1)
62
- lines[-1] = warning if lines.size > to
63
- lines.join("\n")
64
- end
65
-
66
- def capture_stdout
67
- old = Kennel.out
68
- Kennel.out = StringIO.new
69
- yield
70
- Kennel.out.string
71
- ensure
72
- Kennel.out = old
73
- end
74
-
75
- def capture_stderr
76
- old = Kennel.err
77
- Kennel.err = StringIO.new
78
- yield
79
- Kennel.err.string
80
- ensure
81
- Kennel.err = old
82
- end
83
-
84
- def tee_output
85
- old_stdout = Kennel.out
86
- old_stderr = Kennel.err
87
- capture = StringIO.new
88
- Kennel.out = TeeIO.new([capture, Kennel.out])
89
- Kennel.err = TeeIO.new([capture, Kennel.err])
90
- yield
91
- capture.string
92
- ensure
93
- Kennel.out = old_stdout
94
- Kennel.err = old_stderr
95
- end
96
-
97
9
  def capture_sh(command)
98
10
  result = `#{command} 2>&1`
99
11
  raise "Command failed:\n#{command}\n#{result}" unless $CHILD_STATUS.success?
@@ -149,22 +61,6 @@ module Kennel
149
61
  else []
150
62
  end
151
63
  end
152
-
153
- # TODO: use awesome-print or similar, but it has too many monkey-patches
154
- # https://github.com/amazing-print/amazing_print/issues/36
155
- def pretty_inspect(object)
156
- string = object.inspect.dup
157
- string.gsub!(/:([a-z_]+)=>/, "\\1: ")
158
- 10.times do
159
- string.gsub!(/{(\S.*?\S)}/, "{ \\1 }") || break
160
- end
161
- string
162
- end
163
-
164
- def inline_resource_metadata(resource, klass)
165
- resource[:klass] = klass
166
- resource[:tracking_id] = klass.parse_tracking_id(resource)
167
- end
168
64
  end
169
65
  end
170
66
  end
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module Kennel
3
- VERSION = "1.128.0"
3
+ VERSION = "1.130.0"
4
4
  end
data/lib/kennel.rb CHANGED
@@ -5,11 +5,14 @@ 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"
11
13
  require "kennel/parts_serializer"
12
14
  require "kennel/projects_provider"
15
+ require "kennel/attribute_differ"
13
16
  require "kennel/syncer"
14
17
  require "kennel/id_map"
15
18
  require "kennel/api"
@@ -42,7 +45,6 @@ module Kennel
42
45
  UnresolvableIdError = Class.new(StandardError)
43
46
  DisallowedUpdateError = Class.new(StandardError)
44
47
  GenerationAbortedError = Class.new(StandardError)
45
- UpdateResult = Struct.new(:plan, :update, keyword_init: true)
46
48
 
47
49
  class << self
48
50
  attr_accessor :out, :err
@@ -52,12 +54,12 @@ module Kennel
52
54
  self.err = $stderr
53
55
 
54
56
  class Engine
57
+ attr_accessor :strict_imports
58
+
55
59
  def initialize
56
60
  @strict_imports = true
57
61
  end
58
62
 
59
- attr_accessor :strict_imports
60
-
61
63
  # start generation and download in parallel to make planning faster
62
64
  def preload
63
65
  Utils.parallel([:generated, :definitions]) { |m| send m, plain: true }
@@ -65,16 +67,17 @@ module Kennel
65
67
 
66
68
  def generate
67
69
  parts = generated
68
- parts_serializer.write(parts) if ENV["STORE"] != "false" # quicker when debugging
70
+ PartsSerializer.new(filter: filter).write(parts) if ENV["STORE"] != "false" # quicker when debugging
69
71
  parts
70
72
  end
71
73
 
72
74
  def plan
75
+ syncer.print_plan
73
76
  syncer.plan
74
77
  end
75
78
 
76
79
  def update
77
- syncer.plan
80
+ syncer.print_plan
78
81
  syncer.update if syncer.confirm
79
82
  end
80
83
 
@@ -87,7 +90,12 @@ module Kennel
87
90
  def syncer
88
91
  @syncer ||= begin
89
92
  preload
90
- Syncer.new(api, generated, definitions, kennel: self, project_filter: filter.project_filter, tracking_id_filter: filter.tracking_id_filter)
93
+ Syncer.new(
94
+ api, generated, definitions,
95
+ strict_imports: strict_imports,
96
+ project_filter: filter.project_filter,
97
+ tracking_id_filter: filter.tracking_id_filter
98
+ )
91
99
  end
92
100
  end
93
101
 
@@ -95,18 +103,10 @@ module Kennel
95
103
  @api ||= Api.new
96
104
  end
97
105
 
98
- def projects_provider
99
- @projects_provider ||= ProjectsProvider.new
100
- end
101
-
102
- def parts_serializer
103
- @parts_serializer ||= PartsSerializer.new(filter: filter)
104
- end
105
-
106
106
  def generated(**kwargs)
107
107
  @generated ||= begin
108
108
  parts = Progress.progress "Finding parts", **kwargs do
109
- projects = projects_provider.projects
109
+ projects = ProjectsProvider.new.projects
110
110
  projects = filter.filter_projects projects
111
111
 
112
112
  parts = Utils.parallel(projects, &:validated_parts).flatten(1)
@@ -135,8 +135,7 @@ module Kennel
135
135
  def definitions(**kwargs)
136
136
  @definitions ||= Progress.progress("Downloading definitions", **kwargs) do
137
137
  Utils.parallel(Models::Record.subclasses) do |klass|
138
- results = api.list(klass.api_resource, with_downtimes: false) # lookup monitors without adding unnecessary downtime information
139
- 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
140
139
  end.flatten(1)
141
140
  end
142
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.128.0
4
+ version: 1.130.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Grosser
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-12-06 00:00:00.000000000 Z
11
+ date: 2022-12-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: diff-lcs
@@ -80,7 +80,7 @@ dependencies:
80
80
  - - "~>"
81
81
  - !ruby/object:Gem::Version
82
82
  version: '2.4'
83
- description:
83
+ description:
84
84
  email: michael@grosser.it
85
85
  executables: []
86
86
  extensions: []
@@ -89,6 +89,8 @@ files:
89
89
  - Readme.md
90
90
  - lib/kennel.rb
91
91
  - lib/kennel/api.rb
92
+ - lib/kennel/attribute_differ.rb
93
+ - lib/kennel/console.rb
92
94
  - lib/kennel/file_cache.rb
93
95
  - lib/kennel/filter.rb
94
96
  - lib/kennel/github_reporter.rb
@@ -107,6 +109,7 @@ files:
107
109
  - lib/kennel/progress.rb
108
110
  - lib/kennel/projects_provider.rb
109
111
  - lib/kennel/settings_as_methods.rb
112
+ - lib/kennel/string_utils.rb
110
113
  - lib/kennel/subclass_tracking.rb
111
114
  - lib/kennel/syncer.rb
112
115
  - lib/kennel/tasks.rb
@@ -119,7 +122,7 @@ homepage: https://github.com/grosser/kennel
119
122
  licenses:
120
123
  - MIT
121
124
  metadata: {}
122
- post_install_message:
125
+ post_install_message:
123
126
  rdoc_options: []
124
127
  require_paths:
125
128
  - lib
@@ -127,15 +130,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
127
130
  requirements:
128
131
  - - ">="
129
132
  - !ruby/object:Gem::Version
130
- version: 2.6.0
133
+ version: 3.1.0
131
134
  required_rubygems_version: !ruby/object:Gem::Requirement
132
135
  requirements:
133
136
  - - ">="
134
137
  - !ruby/object:Gem::Version
135
138
  version: '0'
136
139
  requirements: []
137
- rubygems_version: 3.0.3
138
- signing_key:
140
+ rubygems_version: 3.3.26
141
+ signing_key:
139
142
  specification_version: 4
140
143
  summary: Keep datadog monitors/dashboards/etc in version control, avoid chaotic management
141
144
  via UI