kennel 1.74.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 +7 -0
- data/Readme.md +244 -0
- data/lib/kennel.rb +90 -0
- data/lib/kennel/api.rb +83 -0
- data/lib/kennel/file_cache.rb +53 -0
- data/lib/kennel/github_reporter.rb +49 -0
- data/lib/kennel/importer.rb +135 -0
- data/lib/kennel/models/base.rb +29 -0
- data/lib/kennel/models/dashboard.rb +209 -0
- data/lib/kennel/models/monitor.rb +213 -0
- data/lib/kennel/models/project.rb +31 -0
- data/lib/kennel/models/record.rb +94 -0
- data/lib/kennel/models/slo.rb +92 -0
- data/lib/kennel/models/team.rb +12 -0
- data/lib/kennel/optional_validations.rb +21 -0
- data/lib/kennel/progress.rb +34 -0
- data/lib/kennel/settings_as_methods.rb +86 -0
- data/lib/kennel/subclass_tracking.rb +19 -0
- data/lib/kennel/syncer.rb +260 -0
- data/lib/kennel/tasks.rb +147 -0
- data/lib/kennel/template_variables.rb +38 -0
- data/lib/kennel/unmuted_alerts.rb +89 -0
- data/lib/kennel/utils.rb +159 -0
- data/lib/kennel/version.rb +4 -0
- data/template/Readme.md +205 -0
- metadata +109 -0
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Kennel
|
3
|
+
module Models
|
4
|
+
class Project < Base
|
5
|
+
settings :team, :parts, :tags, :mention
|
6
|
+
defaults(
|
7
|
+
tags: -> { ["service:#{kennel_id}"] + team.tags },
|
8
|
+
mention: -> { team.mention }
|
9
|
+
)
|
10
|
+
|
11
|
+
def self.file_location
|
12
|
+
@file_location ||= begin
|
13
|
+
method_in_file = instance_methods(false).first
|
14
|
+
instance_method(method_in_file).source_location.first.sub("#{Bundler.root}/", "")
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def validated_parts
|
19
|
+
all = parts
|
20
|
+
validate_parts(all)
|
21
|
+
all
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
# hook for users to add custom validations via `prepend`
|
27
|
+
def validate_parts(parts)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,94 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Kennel
|
3
|
+
module Models
|
4
|
+
class Record < Base
|
5
|
+
LOCK = "\u{1F512}"
|
6
|
+
READONLY_ATTRIBUTES = [
|
7
|
+
:deleted, :id, :created, :created_at, :creator, :org_id, :modified, :modified_at, :api_resource
|
8
|
+
].freeze
|
9
|
+
API_LIST_INCOMPLETE = false
|
10
|
+
|
11
|
+
settings :id, :kennel_id
|
12
|
+
|
13
|
+
class << self
|
14
|
+
def parse_any_url(url)
|
15
|
+
subclasses.detect do |s|
|
16
|
+
if id = s.parse_url(url)
|
17
|
+
break s.api_resource, id
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def normalize(_expected, actual)
|
25
|
+
self::READONLY_ATTRIBUTES.each { |k| actual.delete k }
|
26
|
+
end
|
27
|
+
|
28
|
+
def ignore_default(expected, actual, defaults)
|
29
|
+
definitions = [actual, expected]
|
30
|
+
defaults.each do |key, default|
|
31
|
+
if definitions.all? { |r| !r.key?(key) || r[key] == default }
|
32
|
+
actual.delete(key)
|
33
|
+
expected.delete(key)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
attr_reader :project
|
40
|
+
|
41
|
+
def initialize(project, *args)
|
42
|
+
raise ArgumentError, "First argument must be a project, not #{project.class}" unless project.is_a?(Project)
|
43
|
+
@project = project
|
44
|
+
super(*args)
|
45
|
+
end
|
46
|
+
|
47
|
+
def diff(actual)
|
48
|
+
expected = as_json
|
49
|
+
expected.delete(:id)
|
50
|
+
|
51
|
+
self.class.send(:normalize, expected, actual)
|
52
|
+
|
53
|
+
# strict: ignore Integer vs Float
|
54
|
+
# similarity: show diff when not 100% similar
|
55
|
+
# use_lcs: saner output
|
56
|
+
Hashdiff.diff(actual, expected, use_lcs: false, strict: false, similarity: 1)
|
57
|
+
end
|
58
|
+
|
59
|
+
def tracking_id
|
60
|
+
"#{project.kennel_id}:#{kennel_id}"
|
61
|
+
end
|
62
|
+
|
63
|
+
def resolve_linked_tracking_ids!(*)
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
def resolve_link(id, type, id_map, force:)
|
69
|
+
value = id_map[id]
|
70
|
+
if value == :new
|
71
|
+
if force
|
72
|
+
# TODO: remove the need for this by sorting monitors by missing resolutions
|
73
|
+
invalid! "#{id} needs to already exist, try again"
|
74
|
+
else
|
75
|
+
id # will be re-resolved by syncer after the linked object was created
|
76
|
+
end
|
77
|
+
elsif value
|
78
|
+
value
|
79
|
+
else
|
80
|
+
invalid! "Unable to find #{type} #{id} (does not exist and is not being created by the current run)"
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
# let users know which project/resource failed when something happens during diffing where the backtrace is hidden
|
85
|
+
def invalid!(message)
|
86
|
+
raise ValidationError, "#{tracking_id} #{message}"
|
87
|
+
end
|
88
|
+
|
89
|
+
def raise_with_location(error, message)
|
90
|
+
super error, "#{message} for project #{project.kennel_id}"
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Kennel
|
3
|
+
module Models
|
4
|
+
class Slo < Record
|
5
|
+
READONLY_ATTRIBUTES = superclass::READONLY_ATTRIBUTES + [:type_id, :monitor_tags]
|
6
|
+
DEFAULTS = {
|
7
|
+
description: nil,
|
8
|
+
query: nil,
|
9
|
+
groups: nil,
|
10
|
+
monitor_ids: [],
|
11
|
+
thresholds: []
|
12
|
+
}.freeze
|
13
|
+
|
14
|
+
settings :type, :description, :thresholds, :query, :tags, :monitor_ids, :monitor_tags, :name, :groups
|
15
|
+
|
16
|
+
defaults(
|
17
|
+
id: -> { nil },
|
18
|
+
tags: -> { @project.tags },
|
19
|
+
query: -> { DEFAULTS.fetch(:query) },
|
20
|
+
description: -> { DEFAULTS.fetch(:description) },
|
21
|
+
monitor_ids: -> { DEFAULTS.fetch(:monitor_ids) },
|
22
|
+
thresholds: -> { DEFAULTS.fetch(:thresholds) },
|
23
|
+
groups: -> { DEFAULTS.fetch(:groups) }
|
24
|
+
)
|
25
|
+
|
26
|
+
def initialize(*)
|
27
|
+
super
|
28
|
+
if thresholds.any? { |t| t[:warning] && t[:warning].to_f <= t[:critical].to_f }
|
29
|
+
raise ValidationError, "Threshold warning must be greater-than critical value"
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def as_json
|
34
|
+
return @as_json if @as_json
|
35
|
+
data = {
|
36
|
+
name: "#{name}#{LOCK}",
|
37
|
+
description: description,
|
38
|
+
thresholds: thresholds,
|
39
|
+
monitor_ids: monitor_ids,
|
40
|
+
tags: tags.uniq,
|
41
|
+
type: type
|
42
|
+
}
|
43
|
+
|
44
|
+
if v = query
|
45
|
+
data[:query] = v
|
46
|
+
end
|
47
|
+
if v = id
|
48
|
+
data[:id] = v
|
49
|
+
end
|
50
|
+
if v = groups
|
51
|
+
data[:groups] = v
|
52
|
+
end
|
53
|
+
|
54
|
+
@as_json = data
|
55
|
+
end
|
56
|
+
|
57
|
+
def self.api_resource
|
58
|
+
"slo"
|
59
|
+
end
|
60
|
+
|
61
|
+
def url(id)
|
62
|
+
Utils.path_to_url "/slo?slo_id=#{id}"
|
63
|
+
end
|
64
|
+
|
65
|
+
def self.parse_url(url)
|
66
|
+
url[/\/slo\?slo_id=([a-z\d]+)/, 1]
|
67
|
+
end
|
68
|
+
|
69
|
+
def resolve_linked_tracking_ids!(id_map, **args)
|
70
|
+
as_json[:monitor_ids] = as_json[:monitor_ids].map do |id|
|
71
|
+
id.is_a?(String) ? resolve_link(id, :monitor, id_map, **args) : id
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def self.normalize(expected, actual)
|
76
|
+
super
|
77
|
+
|
78
|
+
# remove readonly values
|
79
|
+
actual[:thresholds]&.each do |threshold|
|
80
|
+
threshold.delete(:warning_display)
|
81
|
+
threshold.delete(:target_display)
|
82
|
+
end
|
83
|
+
|
84
|
+
# tags come in a semi-random order and order is never updated
|
85
|
+
expected[:tags]&.sort!
|
86
|
+
actual[:tags].sort!
|
87
|
+
|
88
|
+
ignore_default(expected, actual, DEFAULTS)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Kennel
|
3
|
+
module Models
|
4
|
+
class Team < Base
|
5
|
+
settings :mention, :tags, :renotify_interval, :kennel_id
|
6
|
+
defaults(
|
7
|
+
tags: -> { ["team:#{kennel_id.sub(/^teams_/, "")}"] },
|
8
|
+
renotify_interval: -> { 0 }
|
9
|
+
)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Kennel
|
3
|
+
module OptionalValidations
|
4
|
+
def self.included(base)
|
5
|
+
base.settings :validate
|
6
|
+
base.defaults(validate: -> { true })
|
7
|
+
end
|
8
|
+
|
9
|
+
private
|
10
|
+
|
11
|
+
def validate_json(data)
|
12
|
+
bad = Kennel::Utils.all_keys(data).grep_v(Symbol)
|
13
|
+
return if bad.empty?
|
14
|
+
invalid!(
|
15
|
+
"Only use Symbols as hash keys to avoid permanent diffs when updating.\n" \
|
16
|
+
"Change these keys to be symbols (usually 'foo' => 1 --> 'foo': 1)\n" \
|
17
|
+
"#{bad.map(&:inspect).join("\n")}"
|
18
|
+
)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require "benchmark"
|
3
|
+
|
4
|
+
module Kennel
|
5
|
+
class Progress
|
6
|
+
# print what we are doing and a spinner until it is done ... then show how long it took
|
7
|
+
def self.progress(name)
|
8
|
+
Kennel.err.print "#{name} ... "
|
9
|
+
|
10
|
+
animation = "-\\|/"
|
11
|
+
count = 0
|
12
|
+
stop = false
|
13
|
+
result = nil
|
14
|
+
|
15
|
+
spinner = Thread.new do
|
16
|
+
loop do
|
17
|
+
break if stop
|
18
|
+
Kennel.err.print animation[count % animation.size]
|
19
|
+
sleep 0.2
|
20
|
+
Kennel.err.print "\b"
|
21
|
+
count += 1
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
time = Benchmark.realtime { result = yield }
|
26
|
+
|
27
|
+
stop = true
|
28
|
+
spinner.join
|
29
|
+
Kennel.err.print "#{time.round(2)}s\n"
|
30
|
+
|
31
|
+
result
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Kennel
|
3
|
+
module SettingsAsMethods
|
4
|
+
SETTING_OVERRIDABLE_METHODS = [].freeze
|
5
|
+
|
6
|
+
def self.included(base)
|
7
|
+
base.extend ClassMethods
|
8
|
+
base.instance_variable_set(:@settings, [])
|
9
|
+
end
|
10
|
+
|
11
|
+
module ClassMethods
|
12
|
+
def settings(*names)
|
13
|
+
duplicates = (@settings & names)
|
14
|
+
if duplicates.any?
|
15
|
+
raise ArgumentError, "Settings #{duplicates.map(&:inspect).join(", ")} are already defined"
|
16
|
+
end
|
17
|
+
|
18
|
+
overrides = ((instance_methods - self::SETTING_OVERRIDABLE_METHODS) & names)
|
19
|
+
if overrides.any?
|
20
|
+
raise ArgumentError, "Settings #{overrides.map(&:inspect).join(", ")} are already used as methods"
|
21
|
+
end
|
22
|
+
|
23
|
+
@settings.concat names
|
24
|
+
|
25
|
+
names.each do |name|
|
26
|
+
next if method_defined?(name)
|
27
|
+
define_method name do
|
28
|
+
raise_with_location ArgumentError, "'#{name}' on #{self.class} was not set or passed as option"
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def defaults(options)
|
34
|
+
options.each do |name, block|
|
35
|
+
validate_setting_exist name
|
36
|
+
define_method name, &block
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def validate_setting_exist(name)
|
43
|
+
return if !@settings || @settings.include?(name)
|
44
|
+
supported = @settings.map(&:inspect)
|
45
|
+
raise ArgumentError, "Unsupported setting #{name.inspect}, supported settings are #{supported.join(", ")}"
|
46
|
+
end
|
47
|
+
|
48
|
+
def inherited(child)
|
49
|
+
super
|
50
|
+
child.instance_variable_set(:@settings, (@settings || []).dup)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def initialize(options = {})
|
55
|
+
super()
|
56
|
+
|
57
|
+
unless options.is_a?(Hash)
|
58
|
+
raise ArgumentError, "Expected #{self.class.name}.new options to be a Hash, got a #{options.class}"
|
59
|
+
end
|
60
|
+
|
61
|
+
options.each do |k, v|
|
62
|
+
next if v.class == Proc
|
63
|
+
raise ArgumentError, "Expected #{self.class.name}.new option :#{k} to be Proc, for example `#{k}: -> { 12 }`"
|
64
|
+
end
|
65
|
+
|
66
|
+
options.each do |name, block|
|
67
|
+
self.class.send :validate_setting_exist, name
|
68
|
+
define_singleton_method name, &block
|
69
|
+
end
|
70
|
+
|
71
|
+
# need expand_path so it works wih rake and when run individually
|
72
|
+
pwd = /^#{Regexp.escape(Dir.pwd)}\//
|
73
|
+
@invocation_location = caller.detect do |l|
|
74
|
+
if found = File.expand_path(l).sub!(pwd, "")
|
75
|
+
break found
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def raise_with_location(error, message)
|
81
|
+
message = message.dup
|
82
|
+
message << " on #{@invocation_location}" if @invocation_location
|
83
|
+
raise error, message
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Kennel
|
3
|
+
module SubclassTracking
|
4
|
+
def recursive_subclasses
|
5
|
+
subclasses + subclasses.flat_map(&:recursive_subclasses)
|
6
|
+
end
|
7
|
+
|
8
|
+
def subclasses
|
9
|
+
@subclasses ||= []
|
10
|
+
end
|
11
|
+
|
12
|
+
private
|
13
|
+
|
14
|
+
def inherited(child)
|
15
|
+
super
|
16
|
+
subclasses << child
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,260 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Kennel
|
3
|
+
class Syncer
|
4
|
+
CACHE_FILE = "tmp/cache/details" # keep in sync with .travis.yml caching
|
5
|
+
TRACKING_FIELDS = [:message, :description].freeze
|
6
|
+
DELETE_ORDER = ["dashboard", "slo", "monitor"].freeze # dashboards references monitors + slos, slos reference monitors
|
7
|
+
|
8
|
+
def initialize(api, expected, project: nil)
|
9
|
+
@api = api
|
10
|
+
@project_filter = project
|
11
|
+
@expected = expected
|
12
|
+
if @project_filter
|
13
|
+
original = @expected
|
14
|
+
@expected = @expected.select { |e| e.project.kennel_id == @project_filter }
|
15
|
+
if @expected.empty?
|
16
|
+
possible = original.map { |e| e.project.kennel_id }.uniq.sort
|
17
|
+
raise "#{@project_filter} does not match any projects, try any of these:\n#{possible.join("\n")}"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
@expected.each { |e| add_tracking_id e }
|
21
|
+
calculate_diff
|
22
|
+
prevent_irreversible_partial_updates
|
23
|
+
end
|
24
|
+
|
25
|
+
def plan
|
26
|
+
Kennel.out.puts "Plan:"
|
27
|
+
if noop?
|
28
|
+
Kennel.out.puts Utils.color(:green, "Nothing to do")
|
29
|
+
else
|
30
|
+
print_plan "Create", @create, :green
|
31
|
+
print_plan "Update", @update, :yellow
|
32
|
+
print_plan "Delete", @delete, :red
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def confirm
|
37
|
+
ENV["CI"] || !STDIN.tty? || Utils.ask("Execute Plan ?") unless noop?
|
38
|
+
end
|
39
|
+
|
40
|
+
def update
|
41
|
+
changed = (@create + @update).map { |_, e| e } unless @create.empty?
|
42
|
+
|
43
|
+
@create.each do |_, e|
|
44
|
+
e.resolve_linked_tracking_ids!({}, force: true)
|
45
|
+
|
46
|
+
reply = @api.create e.class.api_resource, e.as_json
|
47
|
+
id = reply.fetch(:id)
|
48
|
+
|
49
|
+
# resolve ids we could previously no resolve
|
50
|
+
changed.delete e
|
51
|
+
resolve_linked_tracking_ids! from: [reply], to: changed
|
52
|
+
|
53
|
+
Kennel.out.puts "Created #{e.class.api_resource} #{tracking_id(e.as_json)} #{e.url(id)}"
|
54
|
+
end
|
55
|
+
|
56
|
+
@update.each do |id, e|
|
57
|
+
e.resolve_linked_tracking_ids!({}, force: true)
|
58
|
+
@api.update e.class.api_resource, id, e.as_json
|
59
|
+
Kennel.out.puts "Updated #{e.class.api_resource} #{tracking_id(e.as_json)} #{e.url(id)}"
|
60
|
+
end
|
61
|
+
|
62
|
+
@delete.each do |id, _, a|
|
63
|
+
@api.delete a.fetch(:api_resource), id
|
64
|
+
Kennel.out.puts "Deleted #{a.fetch(:api_resource)} #{tracking_id(a)} #{id}"
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
def noop?
|
71
|
+
@create.empty? && @update.empty? && @delete.empty?
|
72
|
+
end
|
73
|
+
|
74
|
+
def calculate_diff
|
75
|
+
@update = []
|
76
|
+
@delete = []
|
77
|
+
|
78
|
+
actual = Progress.progress("Downloading definitions") { download_definitions }
|
79
|
+
resolve_linked_tracking_ids! from: actual, to: @expected
|
80
|
+
filter_by_project! actual
|
81
|
+
|
82
|
+
Progress.progress "Diffing" do
|
83
|
+
items = actual.map do |a|
|
84
|
+
e = matching_expected(a)
|
85
|
+
if e && @expected.delete(e)
|
86
|
+
[e, a]
|
87
|
+
else
|
88
|
+
[nil, a]
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
details_cache do |cache|
|
93
|
+
# fill details of things we need to compare (only do this part in parallel for safety & balancing)
|
94
|
+
Utils.parallel(items.select { |e, _| e && e.class::API_LIST_INCOMPLETE }) { |_, a| fill_details(a, cache) }
|
95
|
+
end
|
96
|
+
|
97
|
+
# pick out things to update or delete
|
98
|
+
items.each do |e, a|
|
99
|
+
id = a.fetch(:id)
|
100
|
+
if e
|
101
|
+
diff = e.diff(a)
|
102
|
+
@update << [id, e, a, diff] if diff.any?
|
103
|
+
elsif tracking_id(a) # was previously managed
|
104
|
+
@delete << [id, nil, a]
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
ensure_all_ids_found
|
109
|
+
@create = @expected.map { |e| [nil, e] }
|
110
|
+
@create.sort_by! { |_, e| -DELETE_ORDER.index(e.class.api_resource) }
|
111
|
+
end
|
112
|
+
|
113
|
+
@delete.sort_by! { |_, _, a| DELETE_ORDER.index a.fetch(:api_resource) }
|
114
|
+
end
|
115
|
+
|
116
|
+
# Make diff work even though we cannot mass-fetch definitions
|
117
|
+
def fill_details(a, cache)
|
118
|
+
resource = a.fetch(:api_resource)
|
119
|
+
args = [resource, a.fetch(:id)]
|
120
|
+
full = cache.fetch(args, a[:modified] || a.fetch(:modified_at)) do
|
121
|
+
@api.show(*args)
|
122
|
+
end
|
123
|
+
a.merge!(full)
|
124
|
+
end
|
125
|
+
|
126
|
+
def details_cache(&block)
|
127
|
+
cache = FileCache.new CACHE_FILE, Kennel::VERSION
|
128
|
+
cache.open(&block)
|
129
|
+
end
|
130
|
+
|
131
|
+
def download_definitions
|
132
|
+
Utils.parallel(Models::Record.subclasses.map(&:api_resource)) do |api_resource|
|
133
|
+
results = @api.list(api_resource, with_downtimes: false) # lookup monitors without adding unnecessary downtime information
|
134
|
+
results = results[results.keys.first] if results.is_a?(Hash) # dashboards are nested in {dashboards: []}
|
135
|
+
results.each { |c| c[:api_resource] = api_resource } # store api resource for later diffing
|
136
|
+
end.flatten(1)
|
137
|
+
end
|
138
|
+
|
139
|
+
def ensure_all_ids_found
|
140
|
+
@expected.each do |e|
|
141
|
+
next unless id = e.id
|
142
|
+
raise "Unable to find existing #{e.class.api_resource} with id #{id}"
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
def matching_expected(a)
|
147
|
+
# index list by all the thing we look up by: tracking id and actual id
|
148
|
+
@lookup_map ||= @expected.each_with_object({}) do |e, all|
|
149
|
+
keys = [tracking_id(e.as_json)]
|
150
|
+
keys << "#{e.class.api_resource}:#{e.id}" if e.id
|
151
|
+
keys.compact.each do |key|
|
152
|
+
raise "Lookup #{key} is duplicated" if all[key]
|
153
|
+
all[key] = e
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
@lookup_map["#{a.fetch(:api_resource)}:#{a.fetch(:id)}"] || @lookup_map[tracking_id(a)]
|
158
|
+
end
|
159
|
+
|
160
|
+
def print_plan(step, list, color)
|
161
|
+
return if list.empty?
|
162
|
+
list.each do |_, e, a, diff|
|
163
|
+
api_resource = (e ? e.class.api_resource : a.fetch(:api_resource))
|
164
|
+
Kennel.out.puts Utils.color(color, "#{step} #{api_resource} #{e&.tracking_id || tracking_id(a)}")
|
165
|
+
print_diff(diff) if diff # only for update
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
def print_diff(diff)
|
170
|
+
diff.each do |type, field, old, new|
|
171
|
+
if type == "+"
|
172
|
+
temp = Utils.pretty_inspect(new)
|
173
|
+
new = Utils.pretty_inspect(old)
|
174
|
+
old = temp
|
175
|
+
else # ~ and -
|
176
|
+
old = Utils.pretty_inspect(old)
|
177
|
+
new = Utils.pretty_inspect(new)
|
178
|
+
end
|
179
|
+
|
180
|
+
if (old + new).size > 100
|
181
|
+
Kennel.out.puts " #{type}#{field}"
|
182
|
+
Kennel.out.puts " #{old} ->"
|
183
|
+
Kennel.out.puts " #{new}"
|
184
|
+
else
|
185
|
+
Kennel.out.puts " #{type}#{field} #{old} -> #{new}"
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
# Do not add tracking-id when working with existing ids on a branch,
|
191
|
+
# so resource do not get deleted fr:om merges to master.
|
192
|
+
# Also make sure the diff still makes sense, by kicking out the now noop-update.
|
193
|
+
#
|
194
|
+
# Note: ideally we'd never add tracking in the first place, but at that point we do not know the diff yet
|
195
|
+
def prevent_irreversible_partial_updates
|
196
|
+
return unless @project_filter
|
197
|
+
@update.select! do |_, e, _, diff|
|
198
|
+
next true unless e.id # short circuit for performance
|
199
|
+
|
200
|
+
diff.select! do |field_diff|
|
201
|
+
(_, field, old, new) = field_diff
|
202
|
+
next true unless tracking_field?(field)
|
203
|
+
|
204
|
+
if (old_tracking = tracking_value(old))
|
205
|
+
old_tracking == tracking_value(new) || raise("do not update! (atm unreachable)")
|
206
|
+
else
|
207
|
+
field_diff[3] = remove_tracking_id(e) # make plan output match update
|
208
|
+
old != field_diff[3]
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
!diff.empty?
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
def resolve_linked_tracking_ids!(from:, to:)
|
217
|
+
map = from.each_with_object({}) { |a, lookup| lookup[tracking_id(a)] = a.fetch(:id) }
|
218
|
+
to.each { |e| map[e.tracking_id] ||= :new }
|
219
|
+
to.each { |e| e.resolve_linked_tracking_ids!(map, force: false) }
|
220
|
+
end
|
221
|
+
|
222
|
+
def filter_by_project!(definitions)
|
223
|
+
return unless @project_filter
|
224
|
+
definitions.select! do |a|
|
225
|
+
id = tracking_id(a)
|
226
|
+
!id || id.start_with?("#{@project_filter}:")
|
227
|
+
end
|
228
|
+
end
|
229
|
+
|
230
|
+
def add_tracking_id(e)
|
231
|
+
json = e.as_json
|
232
|
+
field = tracking_field(json)
|
233
|
+
raise "remove \"-- Managed by kennel\" line it from #{field} to copy a resource" if tracking_value(json[field])
|
234
|
+
json[field] = "#{json[field]}\n-- Managed by kennel #{e.tracking_id} in #{e.project.class.file_location}, do not modify manually".lstrip
|
235
|
+
end
|
236
|
+
|
237
|
+
def remove_tracking_id(e)
|
238
|
+
json = e.as_json
|
239
|
+
field = tracking_field(json)
|
240
|
+
value = json[field]
|
241
|
+
json[field] = value.dup.sub!(/\n?-- Managed by kennel .*/, "") || raise("did not find tracking id in #{value}")
|
242
|
+
end
|
243
|
+
|
244
|
+
def tracking_id(a)
|
245
|
+
tracking_value a[tracking_field(a)]
|
246
|
+
end
|
247
|
+
|
248
|
+
def tracking_value(content)
|
249
|
+
content.to_s[/-- Managed by kennel (\S+:\S+)/, 1]
|
250
|
+
end
|
251
|
+
|
252
|
+
def tracking_field(a)
|
253
|
+
TRACKING_FIELDS.detect { |f| a.key?(f) }
|
254
|
+
end
|
255
|
+
|
256
|
+
def tracking_field?(field)
|
257
|
+
TRACKING_FIELDS.include?(field.to_sym)
|
258
|
+
end
|
259
|
+
end
|
260
|
+
end
|