mux_tf 0.13.0 → 0.14.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.
@@ -5,26 +5,93 @@ module MuxTf
5
5
  extend TerraformHelpers
6
6
  include TerraformHelpers
7
7
  include PiotrbCliUtils::Util
8
+ include Coloring
8
9
 
9
- def self.from_file(file)
10
- data = data_from_file(file)
11
- new data
12
- end
10
+ class << self
11
+ def from_file(file)
12
+ data = data_from_file(file)
13
+ new data
14
+ end
13
15
 
14
- def self.data_from_file(file)
15
- if File.exist?("#{file}.json") && File.mtime("#{file}.json").to_f >= File.mtime(file).to_f
16
- JSON.parse(File.read("#{file}.json"))
17
- else
18
- puts "Analyzing changes ..."
19
- result = tf_show(file, json: true)
20
- data = result.parsed_output
21
- File.write("#{file}.json", JSON.dump(data))
22
- data
16
+ def data_from_file(file)
17
+ if File.exist?("#{file}.json") && File.mtime("#{file}.json").to_f >= File.mtime(file).to_f
18
+ JSON.parse(File.read("#{file}.json"))
19
+ else
20
+ puts "Analyzing changes ..."
21
+ result = tf_show(file, json: true)
22
+ data = result.parsed_output
23
+ File.write("#{file}.json", JSON.dump(data))
24
+ data
25
+ end
26
+ end
27
+
28
+ def from_data(data)
29
+ new(data)
30
+ end
31
+
32
+ def color_for_action(action)
33
+ case action
34
+ when "create", "add"
35
+ :green
36
+ when "update", "change"
37
+ :yellow
38
+ when "delete", "remove"
39
+ :red
40
+ when "replace" # rubocop:disable Lint/DuplicateBranch
41
+ :red
42
+ when "replace (create before delete)" # rubocop:disable Lint/DuplicateBranch
43
+ :red
44
+ when "read"
45
+ :cyan
46
+ when "import" # rubocop:disable Lint/DuplicateBranch
47
+ :cyan
48
+ else
49
+ :reset
50
+ end
51
+ end
52
+
53
+ def symbol_for_action(action)
54
+ case action
55
+ when "create"
56
+ "+"
57
+ when "update"
58
+ "~"
59
+ when "delete"
60
+ "-"
61
+ when "replace"
62
+ "∓"
63
+ when "replace (create before delete)"
64
+ "±"
65
+ when "read"
66
+ ">"
67
+ else
68
+ action
69
+ end
70
+ end
71
+
72
+ def format_action(action)
73
+ color = color_for_action(action)
74
+ symbol = symbol_for_action(action)
75
+ pastel.decorate(symbol, color)
23
76
  end
24
- end
25
77
 
26
- def self.from_data(data)
27
- new(data)
78
+ def self.format_address(address)
79
+ result = []
80
+ parts = ResourceTokenizer.tokenize(address)
81
+ parts.each_with_index do |(part_type, part_value), index|
82
+ case part_type
83
+ when :rt
84
+ result << "." if index.positive?
85
+ result << pastel.cyan(part_value)
86
+ when :rn
87
+ result << "."
88
+ result << part_value
89
+ when :ri
90
+ result << pastel.green(part_value)
91
+ end
92
+ end
93
+ result.join
94
+ end
28
95
  end
29
96
 
30
97
  def initialize(data) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
@@ -139,8 +206,8 @@ module MuxTf
139
206
  resource_summary[part[:action]] += 1
140
207
  end
141
208
  resource_pieces = resource_summary.map { |k, v|
142
- color = color_for_action(k)
143
- "#{Paint[v, :yellow]} to #{Paint[k, color]}"
209
+ color = self.class.color_for_action(k)
210
+ "#{pastel.yellow(v)} to #{pastel.decorate(k, color)}"
144
211
  }
145
212
 
146
213
  # outputs
@@ -150,15 +217,15 @@ module MuxTf
150
217
  output_summary[part[:action]] += 1
151
218
  end
152
219
  output_pieces = output_summary.map { |k, v|
153
- color = color_for_action(k)
154
- "#{Paint[v, :yellow]} to #{Paint[k, color]}"
220
+ color = self.class.color_for_action(k)
221
+ "#{pastel.yellow(v)} to #{pastel.decorate(k, color)}"
155
222
  }
156
223
 
157
224
  if resource_pieces.any? || output_pieces.any?
158
225
  [
159
226
  "Plan Summary:",
160
- resource_pieces.any? ? resource_pieces.join(Paint[", ", :gray]) : nil,
161
- output_pieces.any? ? "Outputs: #{output_pieces.join(Paint[', ', :gray])}" : nil
227
+ resource_pieces.any? ? resource_pieces.join(pastel.gray(", ")) : nil,
228
+ output_pieces.any? ? "Outputs: #{output_pieces.join(pastel.gray(', '))}" : nil
162
229
  ].compact.join(" ")
163
230
  else
164
231
  "Plan Summary: no changes"
@@ -168,7 +235,7 @@ module MuxTf
168
235
  def flat_summary
169
236
  result = []
170
237
  resource_parts.each do |part|
171
- result << "[#{format_action(part[:action])}] #{format_address(part[:address])}"
238
+ result << "[#{self.class.format_action(part[:action])}] #{self.class.format_address(part[:address])}"
172
239
  end
173
240
  result
174
241
  end
@@ -176,11 +243,11 @@ module MuxTf
176
243
  def sensitive_summary(before_value, after_value)
177
244
  # before vs after
178
245
  if before_value && after_value
179
- "(#{Paint['sensitive', :yellow]})"
246
+ "(#{pastel.yellow('sensitive')})"
180
247
  elsif before_value
181
- "(#{Paint['-sensitive', :red]})"
248
+ "(#{pastel.red('-sensitive')})"
182
249
  elsif after_value
183
- "(#{Paint['+sensitive', :cyan]})"
250
+ "(#{pastel.cyan('+sensitive')})"
184
251
  end
185
252
  end
186
253
 
@@ -188,8 +255,8 @@ module MuxTf
188
255
  result = []
189
256
  output_parts.each do |part|
190
257
  pieces = [
191
- "[#{format_action(part[:action])}]",
192
- format_address("output.#{part[:address]}"),
258
+ "[#{self.class.format_action(part[:action])}]",
259
+ self.class.format_address("output.#{part[:address]}"),
193
260
  part[:after_unknown] ? "(unknown)" : nil,
194
261
  sensitive_summary(*part[:sensitive])
195
262
  ].compact
@@ -198,7 +265,7 @@ module MuxTf
198
265
  result
199
266
  end
200
267
 
201
- def nested_summary # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
268
+ def nested_summary # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
202
269
  result = []
203
270
  parts = resource_parts.deep_dup
204
271
  until parts.empty?
@@ -209,7 +276,7 @@ module MuxTf
209
276
  else
210
277
  ""
211
278
  end
212
- message = "[#{format_action(part[:action])}]#{indent} #{format_address(part[:address])}"
279
+ message = "[#{self.class.format_action(part[:action])}]#{indent} #{self.class.format_address(part[:address])}"
213
280
  message += " - (needs: #{part[:met_deps].join(', ')})" if part[:met_deps]
214
281
  result << message
215
282
  parts.each do |ipart|
@@ -230,7 +297,7 @@ module MuxTf
230
297
  prompt = TTY::Prompt.new
231
298
  result = prompt.multi_select("Update resources:", per_page: 99, echo: false) { |menu|
232
299
  resource_parts.each do |part|
233
- label = "[#{format_action(part[:action])}] #{format_address(part[:address])}"
300
+ label = "[#{self.class.format_action(part[:action])}] #{self.class.format_address(part[:address])}"
234
301
  menu.choice label, part[:address]
235
302
  end
236
303
  }
@@ -258,7 +325,7 @@ module MuxTf
258
325
  when 2
259
326
  [:changes, meta]
260
327
  else
261
- log Paint["terraform plan exited with an unknown exit code: #{exit_code}", :yellow]
328
+ log pastel.yellow("terraform plan exited with an unknown exit code: #{exit_code}")
262
329
  [:unknown, meta]
263
330
  end
264
331
  end
@@ -320,67 +387,5 @@ module MuxTf
320
387
  [resource, parent_address]
321
388
  end
322
389
  end
323
-
324
- def color_for_action(action)
325
- case action
326
- when "create"
327
- :green
328
- when "update"
329
- :yellow
330
- when "delete"
331
- :red
332
- when "replace" # rubocop:disable Lint/DuplicateBranch
333
- :red
334
- when "replace (create before delete)" # rubocop:disable Lint/DuplicateBranch
335
- :red
336
- when "read"
337
- :cyan
338
- else
339
- :reset
340
- end
341
- end
342
-
343
- def symbol_for_action(action)
344
- case action
345
- when "create"
346
- "+"
347
- when "update"
348
- "~"
349
- when "delete"
350
- "-"
351
- when "replace"
352
- "∓"
353
- when "replace (create before delete)"
354
- "±"
355
- when "read"
356
- ">"
357
- else
358
- action
359
- end
360
- end
361
-
362
- def format_action(action)
363
- color = color_for_action(action)
364
- symbol = symbol_for_action(action)
365
- Paint[symbol, color]
366
- end
367
-
368
- def format_address(address)
369
- result = []
370
- parts = ResourceTokenizer.tokenize(address)
371
- parts.each_with_index do |(part_type, part_value), index|
372
- case part_type
373
- when :rt
374
- result << "." if index.positive?
375
- result << Paint[part_value, :cyan]
376
- when :rn
377
- result << "."
378
- result << part_value
379
- when :ri
380
- result << Paint[part_value, :green]
381
- end
382
- end
383
- result.join
384
- end
385
390
  end
386
391
  end
@@ -0,0 +1,360 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MuxTf
4
+ class PlanUtils
5
+ extend TerraformHelpers
6
+ extend PiotrbCliUtils::Util
7
+ include Coloring
8
+
9
+ KNOWN_AFTER_APPLY = "(known after apply)"
10
+ SENSITIVE = "(sensitive value)"
11
+
12
+ class << self
13
+ def warning(message, binding_arg: binding)
14
+ stack = binding_arg.send(:caller)
15
+ stack_line = stack[0].match(/^(?<path>.+):(?<ln>\d+):in `(?<method>.+)'$/).named_captures
16
+ stack_line["path"].gsub!(MuxTf::ROOT, pastel.gray("{mux_tf}"))
17
+ msg = [
18
+ "#{pastel.orange('WARNING')}: #{message}",
19
+ "at #{pastel.cyan(stack_line['path'])}:#{pastel.white(stack_line['ln'])}:in `#{pastel.cyan(stack_line['method'])}'"
20
+ ]
21
+ puts msg.join(" - ")
22
+ end
23
+
24
+ def update_placeholders(dst, src, placeholder) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
25
+ return unless src
26
+
27
+ case src
28
+ when Array
29
+ src.each_with_index do |v, index|
30
+ case v
31
+ when TrueClass
32
+ dst[index] = placeholder
33
+ when FalseClass
34
+ # do nothing
35
+ when Hash
36
+ dst[index] ||= {}
37
+ update_placeholders(dst[index], v, placeholder)
38
+ else
39
+ warning "Unknown array value (index: #{index}) for sensitive: #{v.inspect}"
40
+ end
41
+ end
42
+ when Hash
43
+ src.each do |key, value|
44
+ case value
45
+ when TrueClass
46
+ dst[key] = placeholder
47
+ when FalseClass
48
+ # do nothing
49
+ when Array
50
+ dst[key] ||= []
51
+ update_placeholders(dst[key], value, placeholder)
52
+ when Hash
53
+ dst[key] ||= {}
54
+ update_placeholders(dst[key], value, placeholder)
55
+ else
56
+ warning "Unknown value (key: #{key}) for sensitive: #{value.inspect}"
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ def tf_show_json_resource_diff(resource)
63
+ before = resource["change"]["before"] || {}
64
+ after = resource["change"]["after"] || {}
65
+
66
+ update_placeholders(after, resource["change"]["after_unknown"], KNOWN_AFTER_APPLY)
67
+
68
+ before = before.sort.to_h
69
+ after = after.sort.to_h
70
+
71
+ update_placeholders(before, resource["change"]["before_sensitive"], SENSITIVE)
72
+ update_placeholders(after, resource["change"]["after_sensitive"], SENSITIVE)
73
+
74
+ # hash_diff = HashDiff::Comparison.new(before, after)
75
+ # similarity: 0.0, numeric_tolerance: 1, array_path: true,
76
+ Hashdiff.diff(before, after, use_lcs: false)
77
+ end
78
+
79
+ def string_diff(value1, value2)
80
+ value1 = value1.split("\n")
81
+ value2 = value2.split("\n")
82
+
83
+ output = []
84
+ diffs = Diff::LCS.diff value1, value2
85
+ diffs.each do |diff|
86
+ hunk = Diff::LCS::Hunk.new(value1, value2, diff, 5, 0)
87
+ diff_lines = hunk.diff(:unified).split("\n")
88
+ # diff_lines.shift # remove the first line
89
+ output += diff_lines.map { |line| " #{line}" }
90
+ end
91
+ output
92
+ end
93
+
94
+ def valid_json?(value)
95
+ value.is_a?(String) && !!JSON.parse(value)
96
+ rescue JSON::ParserError
97
+ false
98
+ end
99
+
100
+ def valid_yaml?(value)
101
+ if value.is_a?(String)
102
+ parsed = YAML.safe_load(value)
103
+ parsed.is_a?(Hash) || parsed.is_a?(Array)
104
+ else
105
+ false
106
+ end
107
+ rescue Psych::SyntaxError => e
108
+ ap e
109
+ false
110
+ end
111
+
112
+ def colorize_symbol(symbol)
113
+ case symbol
114
+ when "+"
115
+ pastel.green(symbol)
116
+ when "~"
117
+ pastel.yellow(symbol)
118
+ else
119
+ warning "Unknown symbol: #{symbol.inspect}"
120
+ symbol
121
+ end
122
+ end
123
+
124
+ def wrap(text, prefix: "(", suffix: ")", newline: false, color: nil, indent: 0)
125
+ result = String.new
126
+ result << (color ? pastel.decorate(prefix, color) : prefix)
127
+ result << "\n" if newline
128
+ result << text.split("\n").map { |line|
129
+ "#{' ' * indent}#{line}"
130
+ }.join("\n")
131
+ result << "\n" if newline
132
+ result << (color ? pastel.decorate(suffix, color) : suffix)
133
+ result
134
+ end
135
+
136
+ def indent(text, indent: 2, first_line_indent: 0)
137
+ text.split("\n").map.with_index { |line, index|
138
+ if index.zero?
139
+ "#{' ' * first_line_indent}#{line}"
140
+ else
141
+ "#{' ' * indent}#{line}"
142
+ end
143
+ }.join("\n")
144
+ end
145
+
146
+ def in_display_representation(value) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize, Metrics/MethodLength
147
+ if valid_json?(value)
148
+ json_body = JSON.pretty_generate(JSON.parse(value))
149
+ wrap(json_body, prefix: "json(", suffix: ")", color: :gray)
150
+ elsif valid_yaml?(value)
151
+ yaml_body = YAML.dump(YAML.safe_load(value))
152
+ yaml_body.gsub!(/^---\n/, "")
153
+ wrap(yaml_body, prefix: "yaml(", suffix: ")", newline: true, color: :gray, indent: 2)
154
+ elsif [KNOWN_AFTER_APPLY, SENSITIVE].include?(value)
155
+ pastel.gray(value)
156
+ elsif value.is_a?(String) && value.include?("\n")
157
+ wrap(value, prefix: "<<- EOT", suffix: "EOT", newline: true, color: :gray)
158
+ # elsif value.is_a?(Array)
159
+ # body = value.ai.rstrip
160
+ # wrap(body, prefix: "", suffix: "", newline: false, color: :gray, indent: 2)
161
+ elsif value.is_a?(Array)
162
+ max_key = value.length.to_s.length
163
+ body = "["
164
+ value.each_with_index do |v, _index|
165
+ # body += "\n #{in_display_representation(index).ljust(max_key)}: "
166
+ body += "\n"
167
+ body += indent(in_display_representation(v), indent: 2, first_line_indent: 2)
168
+ body += ","
169
+ end
170
+ body += "\n]"
171
+ body
172
+ elsif value.is_a?(Hash)
173
+ max_key = value.keys.map(&:length).max
174
+ body = "{"
175
+ value.each do |k, v|
176
+ body += "\n #{in_display_representation(k).ljust(max_key)}: "
177
+ body += indent(in_display_representation(v), indent: 2, first_line_indent: 0)
178
+ body += ","
179
+ end
180
+ body += "\n}"
181
+ body
182
+ else
183
+ value.inspect
184
+ end
185
+ end
186
+
187
+ def format_value_diff(mode, value_arg)
188
+ case mode
189
+ when :both
190
+ vleft = in_display_representation(value_arg[0])
191
+ vright = in_display_representation(value_arg[1])
192
+ if [vleft, vright].any? { |v| v.is_a?(String) && v.include?("\n") }
193
+ if pastel.strip(vright) == KNOWN_AFTER_APPLY
194
+ "#{vleft} -> #{vright}".split("\n")
195
+ else
196
+ string_diff(pastel.strip(vleft), pastel.strip(vright))
197
+ end
198
+ else
199
+ "#{vleft} -> #{vright}".split("\n")
200
+ end
201
+ when :right
202
+ vright = in_display_representation(value_arg[1])
203
+ vright.split("\n")
204
+ when :left, :first
205
+ vleft = in_display_representation(value_arg[0])
206
+ vleft.split("\n")
207
+ end
208
+ end
209
+
210
+ def format_value(change)
211
+ symbol, _key, *value_arg = change
212
+
213
+ mode = :both
214
+ case symbol
215
+ when "+", "-"
216
+ mode = :first
217
+ when "~"
218
+ mode = :both
219
+ else
220
+ warning "Unknown symbol: #{symbol.inspect}"
221
+ end
222
+
223
+ format_value_diff(mode, value_arg)
224
+ end
225
+
226
+ # def format_value(value_arg, symbol)
227
+ # case value_arg
228
+ # when Array
229
+ # mode = :both
230
+ # case symbol
231
+ # when "+"
232
+ # mode = :right
233
+ # when "~"
234
+ # mode = :both
235
+ # else
236
+ # warning "Unknown symbol: #{symbol.inspect}"
237
+ # end
238
+
239
+ # format_value_diff(mode, value_arg)
240
+ # when Hash
241
+ # if value_arg.keys.all? { |k| k.is_a?(Integer) }
242
+ # # assuming its a hash notation of array keys changes
243
+ # value_arg.keys.sort.map { |k| "[#{k}] #{format_value(value_arg[k], symbol)[0]}" }
244
+ # else
245
+ # [value_arg.inspect]
246
+ # end
247
+ # else
248
+ # [value_arg.inspect]
249
+ # end
250
+ # end
251
+
252
+ def get_pretty_action_and_symbol(actions)
253
+ case actions
254
+ when ["update"]
255
+ pretty_action = "updated in-place"
256
+ symbol = "~"
257
+ when ["create"]
258
+ pretty_action = "created"
259
+ symbol = "+"
260
+ else
261
+ warning "Unknown action: #{actions.inspect}"
262
+ pretty_action = actions.inspect
263
+ symbol = "?"
264
+ end
265
+
266
+ [pretty_action, symbol]
267
+ end
268
+
269
+ # Example
270
+ # # kubectl_manifest.crossplane-provider-controller-config["aws-ecr"] will be updated in-place
271
+ # ~ resource "kubectl_manifest" "crossplane-provider-controller-config" {
272
+ # id = "/apis/pkg.crossplane.io/v1alpha1/controllerconfigs/aws-ecr-config"
273
+ # name = "aws-ecr-config"
274
+ # ~ yaml_body = (sensitive value)
275
+ # ~ yaml_body_parsed = <<-EOT
276
+ # apiVersion: pkg.crossplane.io/v1alpha1
277
+ # kind: ControllerConfig
278
+ # metadata:
279
+ # annotations:
280
+ # - eks.amazonaws.com/role-arn: <AWS_PROVIDER_ARN> <<- irsa
281
+ # + eks.amazonaws.com/role-arn: arn:aws:iam::852088082597:role/admin-crossplane-provider-aws-ecr
282
+ # name: aws-ecr-config
283
+ # spec:
284
+ # podSecurityContext:
285
+ # fsGroup: 2000
286
+ # EOT
287
+ # # (12 unchanged attributes hidden)
288
+ # }
289
+ def tf_show_json_resource(resource) # rubocop:disable Metrics/AbcSize
290
+ pretty_action, symbol = get_pretty_action_and_symbol(resource["change"]["actions"])
291
+
292
+ output = []
293
+
294
+ global_indent = " " * 2
295
+
296
+ output << ""
297
+ output << "#{global_indent}#{pastel.bold("# #{resource['address']}")} will be #{pretty_action}"
298
+ output << "#{global_indent}#{colorize_symbol(symbol)} resource \"#{resource['type']}\" \"#{resource['name']}\" {"
299
+ diff = tf_show_json_resource_diff(resource)
300
+ max_diff_key_length = diff.map { |change| change[1].length }.max
301
+ diff.each do |change|
302
+ change_symbol, key, *_values = change
303
+ prefix = format("#{global_indent} #{colorize_symbol(change_symbol)} %s = ", key.ljust(max_diff_key_length))
304
+ blank_prefix = " " * pastel.strip(prefix).length
305
+ format_value(change).each_with_index do |line, index|
306
+ output << if index.zero?
307
+ "#{prefix}#{line}"
308
+ else
309
+ "#{blank_prefix}#{line}"
310
+ end
311
+ end
312
+ end
313
+ # max_diff_key_length = diff.keys.map(&:length).max
314
+ # diff.each do |key, value|
315
+ # prefix = format("#{global_indent} #{colorize_symbol(symbol)} %s = ", key.ljust(max_diff_key_length))
316
+ # blank_prefix = " " * pastel.strip(prefix).length
317
+ # format_value(value, symbol).each_with_index do |line, index|
318
+ # output << if index.zero?
319
+ # "#{prefix}#{line}"
320
+ # else
321
+ # "#{blank_prefix}#{line}"
322
+ # end
323
+ # end
324
+ # end
325
+ output << "#{global_indent}}"
326
+
327
+ output.join("\n")
328
+ end
329
+
330
+ def text_version_of_plan_show(plan_filename)
331
+ result = tf_show(plan_filename, capture: true, json: true)
332
+ data = result.parsed_output
333
+
334
+ # Plan: 0 to add, 1 to change, 0 to destroy.
335
+
336
+ output = []
337
+
338
+ output << "Terraform will perform the following actions:"
339
+
340
+ if data["resource_drift"]
341
+ output << ""
342
+ output << "Resource Drift:"
343
+ data["resource_drift"].each do |resource|
344
+ output << tf_show_json_resource(resource)
345
+ end
346
+ end
347
+
348
+ if data["resource_changes"]
349
+ output << ""
350
+ output << "Resource Changes:"
351
+ data["resource_changes"].each do |resource|
352
+ output << tf_show_json_resource(resource) if resource["change"]["actions"] != ["no-op"]
353
+ end
354
+ end
355
+
356
+ output.join("\n")
357
+ end
358
+ end
359
+ end
360
+ end