mux_tf 0.15.0 → 0.17.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.
@@ -11,10 +11,64 @@ module MuxTf
11
11
  extend PiotrbCliUtils::CmdLoop
12
12
  include Coloring
13
13
 
14
- class << self # rubocop:disable Metrics/ClassLength
14
+ class << self
15
+ attr_accessor :plan_command
16
+
17
+ def detect_tool_type(tool_cmd: nil)
18
+ tool_cmd ||= ENV.fetch("MUX_TF_BASE_CMD", "terraform")
19
+ output = `#{tool_cmd} --version`
20
+ case output
21
+ when /^Terraform v(.+)$/
22
+ {
23
+ tool_name: "terraform",
24
+ tool_version: ::Regexp.last_match(1)
25
+ }
26
+ when /^OpenTofu v(.+)$/
27
+ {
28
+ tool_name: "opentofu",
29
+ tool_version: ::Regexp.last_match(1)
30
+ }
31
+ when /^terragrunt version v(.+)$/
32
+ result = {
33
+ tool_wrapper: "terragrunt",
34
+ tool_wrapper_version: ::Regexp.last_match(1)
35
+ }
36
+ tg_tool = ENV.fetch("TG_TF_PATH", "tofu")
37
+ result.merge! detect_tool_type(tool_cmd: tg_tool)
38
+ result
39
+ else
40
+ raise "can't parse tool version from: #{output.inspect}"
41
+ end
42
+ end
43
+
44
+ def init_tool_env
45
+ info = detect_tool_type
46
+
47
+ if %w[terraform opentofu].include?(info[:tool_name])
48
+ ENV["TF_IN_AUTOMATION"] = "1"
49
+ ENV["TF_INPUT"] = "0"
50
+ end
51
+
52
+ return unless info[:tool_wrapper] == "terragrunt"
53
+
54
+ if Gem::Version.new(info[:tool_wrapper_version]) >= Gem::Version.new("0.70.0")
55
+ # new syntax
56
+ ENV["TG_LOG_FORMAT"] = "json"
57
+ ENV["TG_TF_FORWARD_STDOUT"] = "true"
58
+ else
59
+ # old syntax
60
+ ENV["TERRAGRUNT_JSON_LOG"] = "1"
61
+ ENV["TERRAGRUNT_FORWARD_TF_STDOUT"] = "1"
62
+ end
63
+ end
64
+
15
65
  def run(args)
16
66
  version_check
17
67
 
68
+ self.plan_command = PlanCommand.new
69
+
70
+ init_tool_env
71
+
18
72
  if args[0] == "cli"
19
73
  cmd_loop
20
74
  return
@@ -27,6 +81,7 @@ module MuxTf
27
81
  if args[0] && valid_commands.include?(args[0])
28
82
  stop_reason = catch(:stop) {
29
83
  root_cmd.run(args, {}, hard_exit: true)
84
+ nil
30
85
  }
31
86
  log pastel.red("Stopped: #{stop_reason}") if stop_reason
32
87
  return
@@ -36,17 +91,14 @@ module MuxTf
36
91
  folder_name = File.basename(Dir.getwd)
37
92
  log "Processing #{pastel.cyan(folder_name)} ..."
38
93
 
39
- ENV["TF_IN_AUTOMATION"] = "1"
40
- ENV["TF_INPUT"] = "0"
41
-
42
- return launch_cmd_loop(:error) unless run_validate
94
+ return launch_cmd_loop(:error) unless @plan_command.run_validate
43
95
 
44
96
  if ENV["TF_UPGRADE"]
45
97
  upgrade_status, _upgrade_meta = run_upgrade
46
98
  return launch_cmd_loop(:error) unless upgrade_status == :ok
47
99
  end
48
100
 
49
- plan_status = run_plan
101
+ plan_status = @plan_command.run_plan
50
102
 
51
103
  case plan_status
52
104
  when :ok
@@ -60,23 +112,6 @@ module MuxTf
60
112
  end
61
113
  end
62
114
 
63
- def plan_filename
64
- PlanFilenameGenerator.for_path
65
- end
66
-
67
- private
68
-
69
- def version_check
70
- return unless VersionCheck.has_updates?
71
-
72
- log pastel.yellow("=" * 80)
73
- log "New version of #{pastel.cyan('mux_tf')} is available!"
74
- log "You are currently on version: #{pastel.yellow(VersionCheck.current_gem_version)}"
75
- log "Latest version found is: #{pastel.green(VersionCheck.latest_gem_version)}"
76
- log "Run `#{pastel.green('gem install mux_tf')}` to update!"
77
- log pastel.yellow("=" * 80)
78
- end
79
-
80
115
  # block is expected to return a touple, the first element is a list of remedies
81
116
  # the rest are any additional results
82
117
  def remedy_retry_helper(from:, level: 1, attempt: 0, &block)
@@ -86,24 +121,65 @@ module MuxTf
86
121
  remedies, *results = block.call
87
122
  return results if remedies.empty?
88
123
 
89
- remedy_status, _remedy_results = process_remedies(remedies, from: from, level: level)
124
+ remedy_status, remedy_results = process_remedies(remedies, from: from, level: level)
125
+ throw :abort, false if remedy_results[:user_error]
90
126
  return remedy_status if remedy_status
91
127
  end
92
128
  log "!! giving up because attempt: #{attempt}"
93
129
  end
94
130
  end
95
131
 
96
- # returns boolean true if succeeded
97
- def run_validate(level: 1)
98
- remedy_retry_helper(from: :validate, level: level) do
99
- validation_info = validate
100
- PlanFormatter.print_validation_errors(validation_info)
101
- remedies = PlanFormatter.process_validation(validation_info)
102
- [remedies, validation_info]
132
+ def print_errors_and_warnings(meta)
133
+ message = []
134
+ message << pastel.yellow("#{meta[:warnings].length} Warnings") if meta[:warnings]
135
+ message << pastel.red("#{meta[:errors].length} Errors") if meta[:errors]
136
+ if message.length.positive?
137
+ log ""
138
+ log "Encountered: #{message.join(' and ')}"
139
+ log ""
140
+ end
141
+
142
+ meta[:warnings]&.each do |warning|
143
+ next if warning[:printed]
144
+
145
+ log "-" * 20
146
+ log pastel.yellow("Warning: #{warning[:message]}")
147
+ warning[:body]&.each do |line|
148
+ log pastel.yellow(line), depth: 1
149
+ end
150
+ log ""
103
151
  end
152
+
153
+ meta[:errors]&.each do |error|
154
+ next if error[:printed]
155
+
156
+ log "-" * 20
157
+ log pastel.red("Error: #{error[:message]}")
158
+ error[:body]&.each do |line|
159
+ log pastel.red(line), depth: 1
160
+ end
161
+ log ""
162
+ end
163
+
164
+ return unless message.length.positive?
165
+
166
+ log ""
167
+ end
168
+
169
+ private
170
+
171
+ def version_check
172
+ return unless VersionCheck.has_updates?
173
+
174
+ log pastel.yellow("=" * 80)
175
+ log "New version of #{pastel.cyan('mux_tf')} is available!"
176
+ log "You are currently on version: #{pastel.yellow(VersionCheck.current_gem_version)}"
177
+ log "Latest version found is: #{pastel.green(VersionCheck.latest_gem_version)}"
178
+ log "Run `#{pastel.green('gem install mux_tf')}` to update!"
179
+ log pastel.yellow("=" * 80)
104
180
  end
105
181
 
106
- def process_remedies(remedies, from: nil, level: 1, retry_count: 0) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
182
+ def process_remedies(remedies, from: nil, level: 1, retry_count: 0)
107
183
  remedies = remedies.dup
108
184
  remedy = nil
109
185
  wrap_log = lambda do |msg, color: nil|
@@ -123,12 +199,21 @@ module MuxTf
123
199
  log wrap_log["unprocessed remedies: #{remedies.to_a}", color: :red], depth: 1
124
200
  return [false, results]
125
201
  end
202
+ if remedies.delete? :user_error
203
+ remedy = :user_error
204
+ log wrap_log["user error encountered!", color: :red]
205
+ log wrap_log["-" * 40, color: :red]
206
+ log wrap_log["!! User Error, Please fix the issue and try again", color: :red]
207
+ log wrap_log["-" * 40, color: :red]
208
+ results[:user_error] = true
209
+ return [false, results]
210
+ end
126
211
  if remedies.delete? :init
127
212
  remedy = :init
128
213
  log wrap_log["Running terraform init ..."], depth: 2
129
- exit_code, meta = PlanFormatter.run_tf_init
214
+ exit_code, meta = InitFormatter.run_tf_init
130
215
  print_errors_and_warnings(meta)
131
- remedies = PlanFormatter.init_status_to_remedies(exit_code, meta)
216
+ remedies = InitFormatter.init_status_to_remedies(exit_code, meta)
132
217
  status, r_results = process_remedies(remedies, from: from, level: level + 1)
133
218
  results.merge!(r_results)
134
219
  return [true, r_results] if status
@@ -136,7 +221,7 @@ module MuxTf
136
221
  if remedies.delete?(:plan)
137
222
  remedy = :plan
138
223
  log wrap_log["Running terraform plan ..."], depth: 2
139
- plan_status = run_plan(retry_count: retry_count)
224
+ plan_status = @plan_command.run_plan(retry_count: retry_count)
140
225
  results[:plan_status] = plan_status
141
226
  return [false, results] unless [:ok, :changes].include?(plan_status)
142
227
  end
@@ -144,9 +229,9 @@ module MuxTf
144
229
  remedy = :reconfigure
145
230
  log wrap_log["Running terraform init ..."], depth: 2
146
231
  result = remedy_retry_helper(from: :reconfigure, level: level + 1, attempt: retry_count) {
147
- exit_code, meta = PlanFormatter.run_tf_init(reconfigure: true)
232
+ exit_code, meta = InitFormatter.run_tf_init(reconfigure: true)
148
233
  print_errors_and_warnings(meta)
149
- remedies = PlanFormatter.init_status_to_remedies(exit_code, meta)
234
+ remedies = InitFormatter.init_status_to_remedies(exit_code, meta)
150
235
  [remedies, exit_code, meta]
151
236
  }
152
237
  unless result
@@ -154,14 +239,6 @@ module MuxTf
154
239
  return [false, result]
155
240
  end
156
241
  end
157
- if remedies.delete? :user_error
158
- remedy = :user_error
159
- log wrap_log["user error encountered!", color: :red]
160
- log wrap_log["-" * 40, color: :red]
161
- log wrap_log["!! User Error, Please fix the issue and try again", color: :red]
162
- log wrap_log["-" * 40, color: :red]
163
- return [false, results]
164
- end
165
242
  if remedies.delete? :auth
166
243
  remedy = :auth
167
244
  log wrap_log["auth error encountered!", color: :red]
@@ -182,27 +259,6 @@ module MuxTf
182
259
  [true, results]
183
260
  end
184
261
 
185
- def validate
186
- log "Validating module ...", depth: 1
187
- tf_validate.parsed_output
188
- end
189
-
190
- def create_plan(filename, targets: [])
191
- log "Preparing Plan ...", depth: 1
192
- exit_code, meta = PlanFormatter.pretty_plan(filename, targets: targets)
193
- case exit_code
194
- when 0
195
- [:ok, meta]
196
- when 1
197
- [:error, meta]
198
- when 2
199
- [:changes, meta]
200
- else
201
- log pastel.yellow("terraform plan exited with an unknown exit code: #{exit_code}")
202
- [:unknown, meta]
203
- end
204
- end
205
-
206
262
  def launch_cmd_loop(status)
207
263
  return if ENV["NO_CMD"]
208
264
 
@@ -215,13 +271,26 @@ module MuxTf
215
271
  cmd_loop(status)
216
272
  end
217
273
 
218
- def cmd_loop(status = nil)
274
+ def cmd_loop(initial_status = nil)
219
275
  root_cmd = build_root_cmd
220
276
 
221
277
  folder_name = File.basename(Dir.getwd)
222
278
 
223
279
  puts root_cmd.help
224
280
 
281
+ status = initial_status
282
+
283
+ prompt = proc { format_prompt(folder_name, status) }
284
+
285
+ run_cmd_loop(prompt) do |cmd|
286
+ status = nil
287
+ throw(:stop, :no_input) if cmd == ""
288
+ args = Shellwords.split(cmd)
289
+ root_cmd.run(args, {}, hard_exit: false)
290
+ end
291
+ end
292
+
293
+ def format_prompt(folder_name, status)
225
294
  prompt = "#{folder_name} => "
226
295
  case status
227
296
  when :error, :unknown
@@ -229,18 +298,13 @@ module MuxTf
229
298
  when :changes
230
299
  prompt = "[#{pastel.yellow(status.to_s)}] #{prompt}"
231
300
  end
232
-
233
- run_cmd_loop(prompt) do |cmd|
234
- throw(:stop, :no_input) if cmd == ""
235
- args = Shellwords.split(cmd)
236
- root_cmd.run(args, {}, hard_exit: false)
237
- end
301
+ prompt
238
302
  end
239
303
 
240
304
  def build_root_cmd
241
305
  root_cmd = define_cmd(nil)
242
306
 
243
- root_cmd.add_command(plan_cmd)
307
+ root_cmd.add_command(@plan_command.plan_cmd)
244
308
  root_cmd.add_command(apply_cmd)
245
309
  root_cmd.add_command(shell_cmd)
246
310
  root_cmd.add_command(force_unlock_cmd)
@@ -248,41 +312,35 @@ module MuxTf
248
312
  root_cmd.add_command(reconfigure_cmd)
249
313
  root_cmd.add_command(interactive_cmd)
250
314
  root_cmd.add_command(plan_details_cmd)
315
+ root_cmd.add_command(init_cmd)
251
316
 
252
317
  root_cmd.add_command(exit_cmd)
253
318
  root_cmd.add_command(define_cmd("help", summary: "Show help for commands") { |_opts, _args, cmd| puts cmd.supercommand.help })
254
319
  root_cmd
255
320
  end
256
321
 
257
- def plan_summary_text
258
- plan_filename = PlanFilenameGenerator.for_path
259
- if File.exist?("#{plan_filename}.txt") && File.mtime("#{plan_filename}.txt").to_f >= File.mtime(plan_filename).to_f
260
- File.read("#{plan_filename}.txt")
261
- else
262
- puts "Inspecting Changes ... #{plan_filename}"
263
- data = PlanUtils.text_version_of_plan_show(plan_filename)
264
- File.write("#{plan_filename}.txt", data)
265
- data
266
- end
267
- end
268
-
269
322
  def plan_details_cmd
270
323
  define_cmd("details", summary: "Show Plan Details") do |_opts, _args, _cmd|
271
- puts plan_summary_text
272
- end
273
- end
324
+ plan_filename = PlanFilenameGenerator.for_path
325
+ plan = PlanSummaryHandler.from_file(plan_filename)
274
326
 
275
- def plan_cmd
276
- define_cmd("plan", summary: "Re-run plan") do |_opts, _args, _cmd|
277
- run_validate && run_plan
327
+ log plan.plan_text_output
328
+
329
+ log ""
330
+
331
+ log "Resource Summary:"
332
+ plan.simple_summary do |line|
333
+ log line
334
+ end
278
335
  end
279
336
  end
280
337
 
281
338
  def apply_cmd
282
339
  define_cmd("apply", summary: "Apply the current plan") do |_opts, _args, _cmd|
340
+ plan_filename = PlanFilenameGenerator.for_path
283
341
  status = tf_apply(filename: plan_filename)
284
342
  if status.success?
285
- plan_status = run_plan
343
+ plan_status = @plan_command.run_plan
286
344
  throw :stop, :done if plan_status == :ok
287
345
  else
288
346
  log "Apply Failed!"
@@ -302,7 +360,7 @@ module MuxTf
302
360
  define_cmd("force-unlock", summary: "Force unlock state after encountering a lock error!") do # rubocop:disable Metrics/BlockLength
303
361
  prompt = TTY::Prompt.new(interrupt: :noop)
304
362
 
305
- lock_info = @last_lock_info
363
+ lock_info = @plan_command.last_lock_info
306
364
 
307
365
  if lock_info
308
366
  table = TTY::Table.new(header: %w[Field Value])
@@ -343,6 +401,17 @@ module MuxTf
343
401
  end
344
402
  end
345
403
 
404
+ def init_cmd
405
+ define_cmd("init", summary: "Re-run init") do |_opts, _args, _cmd|
406
+ exit_code, meta = InitFormatter.run_tf_init
407
+ print_errors_and_warnings(meta)
408
+ if exit_code != 0
409
+ log meta.inspect unless meta.empty?
410
+ log "Init Failed!"
411
+ end
412
+ end
413
+ end
414
+
346
415
  def upgrade_cmd
347
416
  define_cmd("upgrade", summary: "Upgrade modules/plguins") do |_opts, _args, _cmd|
348
417
  status, meta = run_upgrade
@@ -355,7 +424,7 @@ module MuxTf
355
424
 
356
425
  def reconfigure_cmd
357
426
  define_cmd("reconfigure", summary: "Reconfigure modules/plguins") do |_opts, _args, _cmd|
358
- exit_code, meta = PlanFormatter.run_tf_init(reconfigure: true)
427
+ exit_code, meta = InitFormatter.run_tf_init(reconfigure: true)
359
428
  print_errors_and_warnings(meta)
360
429
  if exit_code != 0
361
430
  log meta.inspect unless meta.empty?
@@ -366,13 +435,18 @@ module MuxTf
366
435
 
367
436
  def interactive_cmd
368
437
  define_cmd("interactive", summary: "Apply interactively") do |_opts, _args, _cmd|
438
+ plan_filename = PlanFilenameGenerator.for_path
369
439
  plan = PlanSummaryHandler.from_file(plan_filename)
370
440
  begin
371
- abort_message = catch(:abort) { plan.run_interactive }
441
+ abort_message = catch(:abort) {
442
+ result = plan.run_interactive
443
+ log "Re-running apply with the selected resources ..."
444
+ @plan_command.run_plan(targets: result)
445
+ }
372
446
  if abort_message
373
447
  log pastel.red("Aborted: #{abort_message}")
374
448
  else
375
- run_plan
449
+ @plan_command.run_plan
376
450
  end
377
451
  rescue Exception => e # rubocop:disable Lint/RescueException
378
452
  log e.full_message
@@ -381,104 +455,8 @@ module MuxTf
381
455
  end
382
456
  end
383
457
 
384
- def print_errors_and_warnings(meta) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
385
- message = []
386
- message << pastel.yellow("#{meta[:warnings].length} Warnings") if meta[:warnings]
387
- message << pastel.red("#{meta[:errors].length} Errors") if meta[:errors]
388
- if message.length.positive?
389
- log ""
390
- log "Encountered: #{message.join(' and ')}"
391
- log ""
392
- end
393
-
394
- meta[:warnings]&.each do |warning|
395
- log "-" * 20
396
- log pastel.yellow("Warning: #{warning[:message]}")
397
- warning[:body]&.each do |line|
398
- log pastel.yellow(line), depth: 1
399
- end
400
- log ""
401
- end
402
-
403
- meta[:errors]&.each do |error|
404
- log "-" * 20
405
- log pastel.red("Error: #{error[:message]}")
406
- error[:body]&.each do |line|
407
- log pastel.red(line), depth: 1
408
- end
409
- log ""
410
- end
411
-
412
- return unless message.length.positive?
413
-
414
- log ""
415
- end
416
-
417
- def detect_remedies_from_plan(meta)
418
- remedies = Set.new
419
- meta[:errors]&.each do |error|
420
- remedies << :plan if error[:message].include?("timeout while waiting for plugin to start")
421
- end
422
- remedies << :unlock if lock_error?(meta)
423
- remedies << :auth if meta[:need_auth]
424
- remedies
425
- end
426
-
427
- def lock_error?(meta)
428
- meta && meta["error"] == "lock"
429
- end
430
-
431
- def extract_lock_info(meta)
432
- {
433
- lock_id: meta["ID"],
434
- operation: meta["Operation"],
435
- who: meta["Who"],
436
- created: meta["Created"]
437
- }
438
- end
439
-
440
- def run_plan(targets: [], level: 1, retry_count: 0)
441
- plan_status, = remedy_retry_helper(from: :plan, level: level, attempt: retry_count) {
442
- @last_lock_info = nil
443
-
444
- plan_status, meta = create_plan(plan_filename, targets: targets)
445
-
446
- print_errors_and_warnings(meta)
447
-
448
- remedies = detect_remedies_from_plan(meta)
449
-
450
- if remedies.include?(:unlock)
451
- @last_lock_info = extract_lock_info(meta)
452
- throw :abort, [plan_status, meta]
453
- end
454
-
455
- throw :abort, [plan_status, meta] if remedies.include?(:auth)
456
-
457
- [remedies, plan_status, meta]
458
- }
459
-
460
- case plan_status
461
- when :ok
462
- log "no changes", depth: 1
463
- when :error
464
- log "something went wrong", depth: 1
465
- when :changes
466
- unless ENV["JSON_PLAN"]
467
- log "Printing Plan Summary ...", depth: 1
468
- pretty_plan_summary(plan_filename)
469
- end
470
- puts plan_summary_text if ENV["JSON_PLAN"]
471
- when :unknown
472
- # nothing
473
- end
474
-
475
- plan_status
476
- end
477
-
478
- public :run_plan
479
-
480
458
  def run_upgrade
481
- exit_code, meta = PlanFormatter.run_tf_init(upgrade: true)
459
+ exit_code, meta = InitFormatter.run_tf_init(upgrade: true)
482
460
  print_errors_and_warnings(meta)
483
461
  case exit_code
484
462
  when 0
@@ -490,18 +468,6 @@ module MuxTf
490
468
  [:unknown, meta]
491
469
  end
492
470
  end
493
-
494
- def pretty_plan_summary(filename)
495
- plan = PlanSummaryHandler.from_file(filename)
496
- plan.flat_summary.each do |line|
497
- log line, depth: 2
498
- end
499
- plan.output_summary.each do |line|
500
- log line, depth: 2
501
- end
502
- log "", depth: 2
503
- log plan.summary, depth: 2
504
- end
505
471
  end
506
472
  end
507
473
  end