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.
@@ -24,13 +24,174 @@ module MuxTf
24
24
  end
25
25
  end
26
26
 
27
- def run(_args)
27
+ def run(args)
28
+ if ENV["MUX_V2"]
29
+ run_v2(args)
30
+ else
31
+ run_v1(args)
32
+ end
33
+ end
34
+
35
+ def run_create_session
36
+ project = File.basename(Dir.getwd)
37
+
38
+ if Tmux.session_running?(project)
39
+ log "Killing existing session ..."
40
+ Tmux.kill_session(project)
41
+ end
42
+
43
+ log "Starting new session ..."
44
+ with_clean_env do
45
+ Tmux.new_session project
46
+ end
47
+ Tmux.select_pane "initial"
48
+
49
+ window_id = Tmux.list_windows.first[:id]
50
+
51
+ Tmux.set "remain-on-exit", "on"
52
+
53
+ Tmux.set_hook "pane-exited", "select-layout tiled"
54
+ Tmux.set_hook "window-pane-changed", "select-layout tiled"
55
+ Tmux.set_hook "pane-exited", "select-layout tiled"
56
+
57
+ Tmux.set "mouse", "on"
58
+
59
+ puts "\e]0;tmux: #{project}\007"
60
+
61
+ Tmux.split_window :horizontal, "#{project}:#{window_id}", cwd: Dir.getwd,
62
+ cmd: File.expand_path(File.join(__dir__, "..", "..", "..", "exe", "tf_mux spawner"))
63
+ Tmux.select_pane "spawner"
64
+
65
+ initial_pane = Tmux.find_pane("initial")
66
+ Tmux.kill_pane initial_pane[:id]
67
+ Tmux.tile!
68
+
69
+ log "Attaching ..."
70
+ Tmux.attach(project)
71
+ log "Done!"
72
+ end
73
+
74
+ def parse_control_line(line)
75
+ keyword, remainder = line.split(" ", 2)
76
+ case keyword
77
+ when "%begin"
78
+ # n1, n2, n3, remainder = remainder.split(" ", 4)
79
+ p [:begin, n1, n2, n3, remainder]
80
+ when "%end"
81
+ # n1, n2, n3, remainder = remainder.split(" ", 4)
82
+ p [:end, n1, n2, n3, remainder]
83
+ when "%session-changed"
84
+ # n1, s1, remainder = remainder.split(" ", 3)
85
+ # p [:session_changed, n1, s1, remainder]
86
+ when "%window-pane-changed"
87
+ # n1, n2, remainder = remainder.split(" ", 3)
88
+ # p [:window_pane_changed, n1, n2, remainder]
89
+ when "%layout-change"
90
+ # ignore
91
+ # p [:layout_change, remainder]
92
+ when "%pane-mode-changed"
93
+ # ignore
94
+ p [:layout_change, remainder]
95
+ when "%subscription-changed"
96
+ sub_name, n1, n2, n3, n4, _, remainder = remainder.split(" ", 7)
97
+ if sub_name == "pane-info"
98
+ pane_id, pane_index, pane_title, pane_dead_status = remainder.strip.split(",", 4)
99
+ if pane_dead_status != ""
100
+ p [:pane_exited, pane_id, pane_index, pane_title, pane_dead_status]
101
+ Tmux.kill_pane(pane_id)
102
+ panes = Tmux.list_panes
103
+ if panes.length == 1 && panes.first[:name] == "spawner"
104
+ Tmux.kill_pane(panes.first[:id])
105
+ # its the last pane, so the whole thing should exit
106
+ end
107
+ end
108
+ else
109
+ p [:subscription_changed, sub_name, n1, n2, n3, n4, remainder]
110
+ end
111
+ when "%output"
112
+ pane, = remainder.split(" ", 2)
113
+ if pane == "%1"
114
+ # skip own output
115
+ # else
116
+ # p [:output, pane, remainder]
117
+ end
118
+ else
119
+ p [keyword, remainder]
120
+ end
121
+ end
122
+
123
+ def run_spawner
124
+ project = File.basename(Dir.getwd)
125
+
126
+ control_thread = Thread.new do
127
+ puts "Control Thread Started"
128
+ Tmux.attach_control(project, on_spawn: lambda { |stdin|
129
+ stdin.write("refresh-client -B \"pane-info:%*:\#{pane_id},\#{pane_index},\#{pane_title},\#{pane_dead_status}\"\n")
130
+ stdin.flush
131
+ }, on_line: lambda { |stream, line|
132
+ if stream == :stdout
133
+ parse_control_line(line)
134
+ # p info
135
+ else
136
+ p [stream, line]
137
+ end
138
+ })
139
+ puts "Control Thread Exited"
140
+ end
141
+
142
+ begin
143
+ log "Enumerating folders ..."
144
+ dirs = enumerate_terraform_dirs
145
+
146
+ fail_with "Error: - no subfolders detected! Aborting." if dirs.empty?
147
+
148
+ tasks = dirs.map { |dir|
149
+ {
150
+ name: dir,
151
+ cwd: dir,
152
+ cmd: File.expand_path(File.join(__dir__, "..", "..", "..", "exe", "tf_current"))
153
+ }
154
+ }
155
+
156
+ if ENV["MUX_TF_AUTH_WRAPPER"]
157
+ log "Warming up AWS connection ..."
158
+ words = Shellwords.shellsplit(ENV["MUX_TF_AUTH_WRAPPER"])
159
+ result = capture_shell([*words, "aws", "sts", "get-caller-identity"], raise_on_error: true)
160
+ p JSON.parse(result)
161
+ end
162
+
163
+ window_id = Tmux.list_windows.first[:id]
164
+
165
+ return if tasks.empty?
166
+
167
+ tasks.each do |task|
168
+ log "launching task: #{task[:name]} ...", depth: 2
169
+ Tmux.split_window :horizontal, "#{project}:#{window_id}", cmd: task[:cmd], cwd: task[:cwd]
170
+ Tmux.select_pane task[:name]
171
+ Tmux.tile!
172
+ task[:commands]&.each do |cmd|
173
+ Tmux.send_keys cmd, enter: true
174
+ end
175
+ end
176
+ ensure
177
+ control_thread.join
178
+ end
179
+ end
180
+
181
+ def run_v2(args)
28
182
  Dotenv.load(".env.mux")
29
183
 
30
- log "Enumerating folders ..."
31
- dirs = enumerate_terraform_dirs
184
+ if args[0] == "spawner"
185
+ run_spawner
186
+ else
187
+ run_create_session
188
+ end
189
+ end
32
190
 
33
- fail_with "Error: - no subfolders detected! Aborting." if dirs.empty?
191
+ def run_v1(_args)
192
+ Dotenv.load(".env.mux")
193
+
194
+ dirs = enumerate_terraform_dirs
34
195
 
35
196
  tasks = dirs.map { |dir|
36
197
  {
@@ -104,7 +265,9 @@ module MuxTf
104
265
  ignored += ENV["MUX_IGNORE"].split(",") if ENV["MUX_IGNORE"]
105
266
 
106
267
  dirs = Dir["**/.terraform.lock.hcl"].map { |f| File.dirname(f) }
107
- dirs.reject! do |d| d.in?(ignored) end
268
+ dirs.reject! do |d|
269
+ d.in?(ignored)
270
+ end
108
271
 
109
272
  dirs
110
273
  end
@@ -6,19 +6,15 @@ module MuxTf
6
6
  extend PiotrbCliUtils::Util
7
7
  extend PiotrbCliUtils::ShellHelpers
8
8
  extend TerraformHelpers
9
- import Coloring
9
+ include Coloring
10
10
 
11
11
  class << self
12
12
  def run(args)
13
13
  options = {
14
- interactive: false,
15
14
  hierarchy: false
16
15
  }
17
16
 
18
17
  args = OptionParser.new { |opts|
19
- opts.on("-i") do |v|
20
- options[:interactive] = v
21
- end
22
18
  opts.on("-h") do |v|
23
19
  options[:hierarchy] = v
24
20
  end
@@ -32,25 +28,20 @@ module MuxTf
32
28
  PlanSummaryHandler.from_data(JSON.parse($stdin.read))
33
29
  end
34
30
 
35
- if options[:interactive]
36
- abort_message = catch(:abort) { plan.run_interactive }
37
- log pastel.red("Aborted: #{abort_message}") if abort_message
31
+ if options[:hierarchy]
32
+ plan.nested_summary.each do |line|
33
+ puts line
34
+ end
38
35
  else
39
- if options[:hierarchy]
40
- plan.nested_summary.each do |line|
41
- puts line
42
- end
43
- else
44
- plan.flat_summary.each do |line|
45
- puts line
46
- end
47
- plan.output_summary.each do |line|
48
- puts line
49
- end
36
+ plan.flat_summary.each do |line|
37
+ puts line
38
+ end
39
+ plan.output_summary.each do |line|
40
+ puts line
50
41
  end
51
- puts
52
- puts plan.summary
53
42
  end
43
+ puts
44
+ puts plan.summary
54
45
  end
55
46
  end
56
47
  end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MuxTf
4
+ module ErrorHandlingMethods
5
+ def setup_error_handling(parser, from_states:)
6
+ parser.state(:init_error, /^Terraform encountered problems during initialisation/, [:none])
7
+ parser.state(:error_block, /^╷/, from_states | [:after_error, :init_error])
8
+ parser.state(:error_block_error, /^│ Error: /, [:error_block, :init_error])
9
+ parser.state(:error_block_warning, /^│ Warning: /, [:error_block, :init_error])
10
+ parser.state(:after_error, /^╵/, [:error_block, :error_block_error, :error_block_warning, :init_error])
11
+ end
12
+
13
+ def handle_error_states(meta, state, line)
14
+ case state
15
+ when :error_block
16
+ if meta[:init_error]
17
+ # turn this into an error first
18
+ meta[:errors] ||= []
19
+ meta[:errors] << {
20
+ type: :error,
21
+ message: meta[:init_error].join("\n")
22
+ # body: meta[:init_error]
23
+ }
24
+ meta.delete(:init_error)
25
+ end
26
+ meta[:current_error] = {
27
+ type: :unknown,
28
+ body: []
29
+ }
30
+ when :error_block_error, :error_block_warning
31
+ clean_line = pastel.strip(line).gsub(/^│ /, "")
32
+ if clean_line =~ /^(Warning|Error): (.+)$/
33
+ meta[:current_error][:type] = $LAST_MATCH_INFO[1].downcase.to_sym
34
+ meta[:current_error][:message] = $LAST_MATCH_INFO[2]
35
+ elsif clean_line == ""
36
+ # skip double empty lines
37
+ meta[:current_error][:body] << clean_line if meta[:current_error][:body].last != ""
38
+ else
39
+ meta[:current_error][:body] ||= []
40
+ meta[:current_error][:body] << clean_line
41
+ end
42
+ when :after_error
43
+ case pastel.strip(line)
44
+ when "╵" # closing of an error block
45
+ if meta[:current_error][:type] == :error
46
+ meta[:errors] ||= []
47
+ meta[:errors] << meta[:current_error]
48
+ end
49
+ if meta[:current_error][:type] == :warning
50
+ meta[:warnings] ||= []
51
+ meta[:warnings] << meta[:current_error]
52
+ end
53
+ meta.delete(:current_error)
54
+ end
55
+ when :init_error
56
+ meta[:init_error] ||= []
57
+ meta[:init_error] << line
58
+ else
59
+ return false
60
+ end
61
+ true
62
+ end
63
+
64
+ def log_unhandled_line(state, line, reason: nil)
65
+ p [state, pastel.strip(line), reason]
66
+ end
67
+
68
+ def print_errors(meta)
69
+ meta[:errors]&.each do |error|
70
+ log "-" * 20
71
+ log pastel.red("Error: #{error[:message]}")
72
+ error[:body]&.each do |line|
73
+ log pastel.red(line), depth: 1
74
+ end
75
+ log ""
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MuxTf
4
+ module FormatterCommon
5
+ def tf_cmd_json(cmd_call_proc, &block)
6
+ last_stderr_line = nil
7
+ handler = proc { |(stream, raw_line)| # rubocop:disable Metrics/BlockLength
8
+ case stream
9
+ when :command
10
+ log "Running command: #{raw_line.strip} ...", depth: 2
11
+ when :stdout
12
+ begin
13
+ parsed_line = JSON.parse(raw_line)
14
+ parsed_line.keys.each do |key|
15
+ if key[0] == "@"
16
+ parsed_line[key[1..]] = parsed_line[key]
17
+ parsed_line.delete(key)
18
+ end
19
+ end
20
+ parsed_line.symbolize_keys!
21
+ parsed_line[:stream] = stream
22
+ if last_stderr_line
23
+ emit_line_helper(last_stderr_line, &block)
24
+ last_stderr_line = nil
25
+ end
26
+ emit_line_helper(parsed_line, &block)
27
+ rescue JSON::ParserError
28
+ # eg: "[WARN] Provider spacelift-io/spacelift (registry.opentofu.org) gpg key expired, this will fail in future versions of OpenTofu\n"
29
+ # treat the line as a normal log line
30
+ parsed_line = {
31
+ message: raw_line.strip,
32
+ module: "non-json-log"
33
+ }
34
+ parsed_line[:level] = raw_line[0..-2].strip if raw_line.match?(/^[A-Z]+/)
35
+ parsed_line[:stream] = stream
36
+ emit_line_helper(parsed_line, &block)
37
+ end
38
+ when :stderr
39
+ parsed_line = parse_non_json_plan_line(raw_line)
40
+ parsed_line[:stream] = stream
41
+
42
+ if parsed_line[:blank]
43
+ if last_stderr_line
44
+ emit_line_helper(last_stderr_line, &block)
45
+ last_stderr_line = nil
46
+ end
47
+ elsif parsed_line[:merge_up]
48
+ if last_stderr_line
49
+ last_stderr_line[:message] += "\n#{parsed_line[:message]}"
50
+ else
51
+ # this is just a standalone message then
52
+ parsed_line.delete(:merge_up)
53
+ last_stderr_line = parsed_line
54
+ end
55
+ elsif last_stderr_line
56
+ emit_line_helper(last_stderr_line, &block)
57
+ last_stderr_line = parsed_line
58
+ else
59
+ last_stderr_line = parsed_line
60
+ end
61
+ end
62
+ }
63
+
64
+ status = cmd_call_proc.call(handler)
65
+
66
+ emit_line_helper(last_stderr_line, &block) if last_stderr_line
67
+ status
68
+ end
69
+
70
+ def parse_non_json_plan_line(raw_line)
71
+ result = {}
72
+
73
+ if raw_line.match(/^time=(?<timestamp>[^ ]+) level=(?<level>[^ ]+) msg=(?<message>.+?)(?: prefix=\[(?<prefix>.+?)\])?\s*$/)
74
+ result.merge!($LAST_MATCH_INFO.named_captures.symbolize_keys)
75
+ result[:module] = "terragrunt"
76
+ result.delete(:prefix) unless result[:prefix]
77
+ result[:prefix] = Pathname.new(result[:prefix]).relative_path_from(Dir.getwd).to_s if result[:prefix]
78
+
79
+ result[:merge_up] = true if result[:message].match(/^\d+ errors? occurred:$/)
80
+ elsif raw_line.strip == ""
81
+ result[:blank] = true
82
+ else
83
+ result[:message] = raw_line
84
+ result[:merge_up] = true
85
+ end
86
+
87
+ # time=2023-08-25T11:44:41-07:00 level=error msg=Terraform invocation failed in /Users/piotr/Work/janepods/.terragrunt-cache/BM86IAj5tW4bZga2lXeYT8tdOKI/V0IEypKSfyl-kHfCnRNAqyX02V8/modules/event-bus prefix=[/Users/piotr/Work/janepods/accounts/eks-dev/admin/apps/kube-system-event-bus]
88
+ # time=2023-08-25T11:44:41-07:00 level=error msg=1 error occurred:
89
+ # * [/Users/piotr/Work/janepods/.terragrunt-cache/BM86IAj5tW4bZga2lXeYT8tdOKI/V0IEypKSfyl-kHfCnRNAqyX02V8/modules/event-bus] exit status 2
90
+ #
91
+ #
92
+ result
93
+ end
94
+
95
+ def emit_line_helper(result, &block)
96
+ result[:level] ||= result[:stream] == :stderr ? "error" : "info"
97
+ result[:module] ||= result[:stream]
98
+ result[:type] ||= "unknown"
99
+
100
+ result[:message].lstrip! if result[:message] =~ /^\n/
101
+
102
+ if result[:message].match(/^Terraform invocation failed in (.+)/)
103
+ result[:type] = "tf_failed"
104
+
105
+ lines = result[:message].split("\n")
106
+ result[:diagnostic] = {
107
+ "summary" => "Terraform invocation failed",
108
+ "detail" => result[:message],
109
+ roots: [],
110
+ extra: []
111
+ }
112
+
113
+ lines.each do |line|
114
+ if line.match(/^\s+\* \[(.+)\] exit status (\d+)$/)
115
+ result[:diagnostic][:roots] << {
116
+ path: $LAST_MATCH_INFO[1],
117
+ status: $LAST_MATCH_INFO[2].to_i
118
+ }
119
+ elsif line.match(/^\d+ errors? occurred$/)
120
+ # noop
121
+ else
122
+ result[:diagnostic][:extra] << line
123
+ end
124
+ end
125
+
126
+ result[:message] = "Terraform invocation failed"
127
+ end
128
+
129
+ block.call(result)
130
+ end
131
+
132
+ def print_unhandled_error_line(parsed_line)
133
+ if parsed_line[:diagnostic]
134
+ color = :red
135
+ dinfo = parsed_line[:diagnostic]
136
+
137
+ log "#{pastel.decorate(dinfo['severity'].capitalize, color)}: #{dinfo['summary']}", depth: 3
138
+ log dinfo["detail"].split("\n"), depth: 4 if dinfo["detail"]
139
+ log format_validation_range(dinfo, color), depth: 4 if dinfo["range"]
140
+ elsif parsed_line[:message] =~ /^\[reset\]/
141
+ log pastel.red(parsed_line[:message].gsub(/^\[reset\]/, "")), depth: 3
142
+ else
143
+ p parsed_line
144
+ end
145
+ end
146
+
147
+ def format_validation_range(dinfo, color)
148
+ range = dinfo["range"]
149
+
150
+ # filename: "../../../modules/pods/jane_pod/main.tf"
151
+ # start:
152
+ # line: 151
153
+ # column: 27
154
+ # byte: 6632
155
+ # end:
156
+ # line: 151
157
+ # column: 53
158
+ # byte: 6658
159
+
160
+ context_lines = 3
161
+
162
+ lines = range["start"]["line"]..range["end"]["line"]
163
+ columns = range["start"]["column"]..range["end"]["column"]
164
+
165
+ # on ../../../modules/pods/jane_pod/main.tf line 151, in module "jane":
166
+ # 151: jane_resources_preset = var.jane_resources_presetx
167
+ output = []
168
+ lines_info = if lines.size == 1
169
+ "#{lines.first}:#{columns.first}"
170
+ else
171
+ "#{lines.first}:#{columns.first} to #{lines.last}:#{columns.last}"
172
+ end
173
+ output << "on: #{range['filename']} line#{lines.size > 1 ? 's' : ''}: #{lines_info}"
174
+
175
+ # TODO: in terragrunt mode, we need to somehow figure out the path to the cache root, all the paths will end up being relative to that
176
+ if File.exist?(range["filename"])
177
+ file_lines = File.read(range["filename"]).split("\n")
178
+ extract_range = (([lines.first - context_lines,
179
+ 0].max)..([lines.last + context_lines, file_lines.length - 1].min))
180
+ file_lines.each_with_index do |line, index|
181
+ if extract_range.cover?(index + 1)
182
+ if lines.cover?(index + 1)
183
+ start_col = 1
184
+ end_col = :max
185
+ if index + 1 == lines.first
186
+ start_col = columns.first
187
+ elsif index + 1 == lines.last
188
+ start_col = columns.last
189
+ end
190
+ painted_line = paint_line(line, color, start_col: start_col, end_col: end_col)
191
+ output << "#{pastel.decorate('>', color)} #{index + 1}: #{painted_line}"
192
+ else
193
+ output << " #{index + 1}: #{line}"
194
+ end
195
+ end
196
+ end
197
+ elsif dinfo["snippet"]
198
+ snippet = dinfo["snippet"]
199
+ # {
200
+ # "context"=>"locals",
201
+ # "code"=>" aws_iam_policy.crossplane_aws_ecr.arn",
202
+ # "start_line"=>72,
203
+ # "highlight_start_offset"=>8,
204
+ # "highlight_end_offset"=>41,
205
+ # "values"=>[]
206
+ # }
207
+ output << "Code:"
208
+ output << "in #{snippet['context']}" if snippet["context"]
209
+ line = snippet["start_line"]
210
+ snippet["code"].split("\n").each do |l|
211
+ output << "#{line}: #{l}"
212
+ line += 1
213
+ end
214
+ end
215
+
216
+ if dinfo["snippet"] && dinfo["snippet"]["values"]&.any?
217
+ snippet = dinfo["snippet"]
218
+ output << ""
219
+ output << "Values:"
220
+ snippet["values"].each do |value|
221
+ output << " #{pastel.bold(value['traversal'])} #{value['statement']}"
222
+ end
223
+ end
224
+
225
+ output
226
+ end
227
+
228
+ def paint_line(line, *paint_options, start_col: 1, end_col: :max)
229
+ end_col = line.length if end_col == :max
230
+ prefix = line[0, start_col - 1]
231
+ suffix = line[end_col..]
232
+ middle = line[start_col - 1..end_col - 1]
233
+ "#{prefix}#{pastel.decorate(middle, *paint_options)}#{suffix}"
234
+ end
235
+
236
+ def print_tg_error_line(parsed_line)
237
+ parsed_line[:message].split("\n").each do |line|
238
+ next if line.strip == ""
239
+
240
+ parsed_message = JSON.parse(line)
241
+ raw_prefix = "[TG ERROR] "
242
+ prefix = pastel.red(raw_prefix)
243
+ index = 0
244
+ lines = parsed_message["msg"].split("\n").map { |l|
245
+ l = if index.zero?
246
+ prefix + l
247
+ else
248
+ (" " * raw_prefix.length) + l
249
+ end
250
+ index += 1
251
+ l
252
+ }
253
+ log lines, depth: 1
254
+ end
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MuxTf
4
+ module Handlers
5
+ class PlanHandler
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MuxTf
4
+ module Handlers
5
+ end
6
+ end