mux_tf 0.13.0 → 0.14.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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