kennel 1.128.0 → 1.130.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.
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