mux_tf 0.1.2

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.
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MuxTf
4
+ module Cli
5
+ module Mux
6
+ extend PiotrbCliUtils::Util
7
+ extend PiotrbCliUtils::ShellHelpers
8
+
9
+ class << self
10
+ def run(_args)
11
+ Dotenv.load('.env.mux')
12
+
13
+ log 'Enumerating folders ...'
14
+ dirs = enumerate_terraform_dirs
15
+
16
+ fail_with 'Error: - no subfolders detected! Aborting.' if dirs.empty?
17
+
18
+ tasks = dirs.map do |dir|
19
+ {
20
+ name: dir,
21
+ cwd: dir,
22
+ cmd: File.expand_path(File.join(__dir__, '..', '..', '..', 'exe', 'tf_current'))
23
+ }
24
+ end
25
+
26
+ project = File.basename(Dir.getwd)
27
+
28
+ if ENV['MUX_TF_AUTH_WRAPPER']
29
+ log 'Warming up AWS connection ...'
30
+ words = Shellwords.shellsplit(ENV['MUX_TF_AUTH_WRAPPER'])
31
+ result = capture_shell([*words, 'aws', 'sts', 'get-caller-identity'], raise_on_error: true)
32
+ p JSON.parse(result)
33
+ end
34
+
35
+ if Tmux.session_running?(project)
36
+ log 'Killing existing session ...'
37
+ Tmux.kill_session(project)
38
+ end
39
+
40
+ log 'Starting new session ...'
41
+ Tmux.new_session project
42
+ Tmux.select_pane 'initial'
43
+
44
+ Tmux.set_hook 'pane-exited', 'select-layout tiled'
45
+ Tmux.set_hook 'window-pane-changed', 'select-layout tiled'
46
+
47
+ Tmux.set 'mouse', 'on'
48
+
49
+ unless tasks.empty?
50
+ tasks.each do |task|
51
+ log "launching task: #{task[:name]} ...", depth: 2
52
+ Tmux.split_window :horizontal, "#{project}:1", cmd: task[:cmd], cwd: task[:cwd]
53
+ Tmux.select_pane task[:name]
54
+ Tmux.tile!
55
+ task[:commands]&.each do |cmd|
56
+ Tmux.send_keys cmd, enter: true
57
+ end
58
+ end
59
+ end
60
+
61
+ log 'Almost done ...'
62
+
63
+ initial_pane = Tmux.find_pane('initial')
64
+ Tmux.kill_pane initial_pane[:id]
65
+ Tmux.tile!
66
+
67
+ puts "\e]0;tmux: #{project}\007"
68
+
69
+ log 'Attaching ...'
70
+ Tmux.attach(project, cc: !!ENV['MUXP_CC_MODE'])
71
+ log 'Done!'
72
+ end
73
+
74
+ private
75
+
76
+ def enumerate_terraform_dirs
77
+ ignored = []
78
+
79
+ ignored += ENV['MUX_IGNORE'].split(',') if ENV['MUX_IGNORE']
80
+
81
+ dirs = Dir['**/*/.terraform'].map { |n| n.gsub(%r{/\.terraform}, '') }
82
+ dirs.reject! { |d| d.in?(ignored) }
83
+
84
+ dirs
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MuxTf
4
+ module Cli
5
+ module PlanSummary
6
+ extend PiotrbCliUtils::Util
7
+ extend PiotrbCliUtils::ShellHelpers
8
+ extend TerraformHelpers
9
+
10
+ class << self
11
+ def run(args)
12
+ options = {
13
+ interactive: false,
14
+ hierarchy: false
15
+ }
16
+
17
+ args = OptionParser.new do |opts|
18
+ opts.on('-i') do |v|
19
+ options[:interactive] = v
20
+ end
21
+ opts.on('-h') do |v|
22
+ options[:hierarchy] = v
23
+ end
24
+ end.parse!(args)
25
+
26
+ if options[:interactive]
27
+ raise 'must specify plan file in interactive mode' if args[0].blank?
28
+ end
29
+
30
+ data = if args[0]
31
+ load_summary_from_file(args[0])
32
+ else
33
+ JSON.parse(STDIN.read)
34
+ end
35
+
36
+ parts = []
37
+
38
+ data['resource_changes'].each do |v|
39
+ next unless v['change']
40
+
41
+ case v['change']['actions']
42
+ when ['no-op']
43
+ # do nothing
44
+ when ['create']
45
+ parts << {
46
+ action: 'create',
47
+ address: v['address'],
48
+ deps: find_deps(data, v['address'])
49
+ }
50
+ when ['update']
51
+ parts << {
52
+ action: 'update',
53
+ address: v['address'],
54
+ deps: find_deps(data, v['address'])
55
+ }
56
+ when ['delete']
57
+ parts << {
58
+ action: 'delete',
59
+ address: v['address'],
60
+ deps: find_deps(data, v['address'])
61
+ }
62
+ when %w[delete create]
63
+ parts << {
64
+ action: 'replace',
65
+ address: v['address'],
66
+ deps: find_deps(data, v['address'])
67
+ }
68
+ when ['read']
69
+ parts << {
70
+ action: 'read',
71
+ address: v['address'],
72
+ deps: find_deps(data, v['address'])
73
+ }
74
+ else
75
+ puts "[??] #{v['address']}"
76
+ puts "UNKNOWN ACTIONS: #{v['change']['actions'].inspect}"
77
+ puts 'TODO: update plan_summary to support this!'
78
+ end
79
+ end
80
+
81
+ prune_unchanged_deps(parts)
82
+
83
+ if options[:interactive]
84
+ run_interactive(parts, args[0])
85
+ else
86
+ if options[:hierarchy]
87
+ print_nested(parts)
88
+ else
89
+ print_flat(parts)
90
+ end
91
+ print_summary(parts)
92
+ end
93
+ end
94
+
95
+ def load_summary_from_file(file)
96
+ if File.exist?("#{file}.json") && File.mtime("#{file}.json").to_f >= File.mtime(file).to_f
97
+ JSON.parse(File.read("#{file}.json"))
98
+ else
99
+ puts 'Analyzing changes ...'
100
+ result = tf_show(file, json: true)
101
+ data = result.parsed_output
102
+ File.open("#{file}.json", 'w') { |fh| fh.write(JSON.dump(data)) }
103
+ data
104
+ end
105
+ end
106
+
107
+ def print_summary(parts)
108
+ summary = {}
109
+ parts.each do |part|
110
+ summary[part[:action]] ||= 0
111
+ summary[part[:action]] += 1
112
+ end
113
+ pieces = summary.map do |k, v|
114
+ color = color_for_action(k)
115
+ "#{Paint[v, :yellow]} to #{Paint[k, color]}"
116
+ end
117
+
118
+ puts
119
+ puts "Plan Summary: #{pieces.join(Paint[', ', :gray])}"
120
+ end
121
+
122
+ def print_flat(parts)
123
+ parts.each do |part|
124
+ puts "[#{format_action(part[:action])}] #{format_address(part[:address])}"
125
+ end
126
+ end
127
+
128
+ def run_interactive(parts, _plan_name)
129
+ prompt = TTY::Prompt.new
130
+ result = prompt.multi_select('Update resources:', per_page: 99, echo: false) do |menu|
131
+ parts.each do |part|
132
+ label = "[#{format_action(part[:action])}] #{format_address(part[:address])}"
133
+ menu.choice label, part[:address]
134
+ end
135
+ end
136
+
137
+ if !result.empty?
138
+ log 'Re-running apply with the selected resources ...'
139
+ status = tf_apply(targets: result)
140
+ unless status.success?
141
+ log Paint["Failed! (#{status.status})", :red]
142
+ exit status.status
143
+ end
144
+ else
145
+ raise 'nothing selected'
146
+ end
147
+ end
148
+
149
+ def print_nested(parts)
150
+ parts = parts.deep_dup
151
+ until parts.empty?
152
+ part = parts.shift
153
+ if part[:deps] == []
154
+ indent = if part[:met_deps] && !part[:met_deps].empty?
155
+ ' '
156
+ else
157
+ ''
158
+ end
159
+ message = "[#{format_action(part[:action])}]#{indent} #{format_address(part[:address])}"
160
+ if part[:met_deps]
161
+ message += " - (needs: #{part[:met_deps].join(', ')})"
162
+ end
163
+ puts message
164
+ parts.each do |ipart|
165
+ d = ipart[:deps].delete(part[:address])
166
+ if d
167
+ ipart[:met_deps] ||= []
168
+ ipart[:met_deps] << d
169
+ end
170
+ end
171
+ else
172
+ parts.unshift part
173
+ end
174
+ end
175
+ end
176
+
177
+ def prune_unchanged_deps(parts)
178
+ valid_addresses = parts.map { |part| part[:address] }
179
+
180
+ parts.each do |part|
181
+ part[:deps].select! { |dep| valid_addresses.include?(dep) }
182
+ end
183
+ end
184
+
185
+ def find_config(module_root, module_name, address, parent_address)
186
+ module_info = if parent_address.empty?
187
+ module_root[module_name]
188
+ elsif module_root && module_root[module_name]
189
+ module_root[module_name]['module']
190
+ else
191
+ {}
192
+ end
193
+
194
+ if m = address.match(/^module\.([^.]+)\./)
195
+ find_config(module_info['module_calls'], m[1], m.post_match, parent_address + ["module.#{m[1]}"])
196
+ else
197
+ if module_info['resources']
198
+ resource = module_info['resources'].find do |resource|
199
+ address == resource['address']
200
+ end
201
+ end
202
+ [resource, parent_address]
203
+ end
204
+ end
205
+
206
+ def find_deps(data, address)
207
+ result = []
208
+
209
+ full_address = address
210
+ m = address.match(/\[(.+)\]$/)
211
+ if m
212
+ address = m.pre_match
213
+ index = m[1][0] == '"' ? m[1].gsub(/^"(.+)"$/, '\1') : m[1].to_i
214
+ end
215
+
216
+ if data['prior_state']['values']['root_module']['resources']
217
+ resource = data['prior_state']['values']['root_module']['resources'].find do |resource|
218
+ address == resource['address'] && index == resource['index']
219
+ end
220
+ end
221
+
222
+ result += resource['depends_on'] if resource && resource['depends_on']
223
+
224
+ resource, parent_address = find_config(data['configuration'], 'root_module', address, [])
225
+ if resource
226
+ deps = []
227
+ resource['expressions'].each do |_k, v|
228
+ deps << v['references'] if v.is_a?(Hash) && v['references']
229
+ end
230
+ result += deps.map { |s| (parent_address + [s]).join('.') }
231
+ end
232
+
233
+ result
234
+ end
235
+
236
+ def color_for_action(action)
237
+ case action
238
+ when 'create'
239
+ :green
240
+ when 'update'
241
+ :yellow
242
+ when 'delete'
243
+ :red
244
+ when 'replace'
245
+ :red
246
+ when 'read'
247
+ :cyan
248
+ else
249
+ :reset
250
+ end
251
+ end
252
+
253
+ def symbol_for_action(action)
254
+ case action
255
+ when 'create'
256
+ '+'
257
+ when 'update'
258
+ '~'
259
+ when 'delete'
260
+ '-'
261
+ when 'replace'
262
+ '±'
263
+ when 'read'
264
+ '>'
265
+ else
266
+ action
267
+ end
268
+ end
269
+
270
+ def format_action(action)
271
+ color = color_for_action(action)
272
+ symbol = symbol_for_action(action)
273
+ Paint[symbol, color]
274
+ end
275
+
276
+ def format_address(address)
277
+ parts = address.split('.')
278
+ parts.each_with_index do |part, index|
279
+ parts[index] = Paint[part, :cyan] if index.odd?
280
+ end
281
+ parts.join('.')
282
+ end
283
+ end
284
+ end
285
+ end
286
+ end
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MuxTf
4
+ class PlanFormatter
5
+ extend TerraformHelpers
6
+ extend PiotrbCliUtils::Util
7
+
8
+ class << self
9
+ # include CommandHelpers
10
+
11
+ def pretty_plan(filename)
12
+ pastel = Pastel.new
13
+
14
+ plan_output = String.new
15
+
16
+ phase = :init
17
+
18
+ meta = {}
19
+
20
+ parser = StatefulParser.new(normalizer: pastel.method(:strip))
21
+ parser.state(:info, /^Acquiring state lock/)
22
+ parser.state(:error, /Error locking state/, %i[none blank info])
23
+ parser.state(:refreshing, /Refreshing Terraform state in-memory prior to plan.../, %i[none blank info])
24
+ parser.state(:refresh_done, /^----------+$/, [:refreshing])
25
+ parser.state(:plan_info, /Terraform will perform the following actions:/, [:refresh_done])
26
+ parser.state(:plan_summary, /^Plan:/, [:plan_info])
27
+
28
+ parser.state(:error_lock_info, /Lock Info/, [:error])
29
+ parser.state(:error, /^$/, [:error_lock_info])
30
+
31
+ parser.state(:plan_error, /^Error: /, [:refreshing])
32
+
33
+ status = tf_plan(out: filename, detailed_exitcode: true, compact_warnings: true) do |raw_line|
34
+ plan_output << raw_line
35
+ parser.parse(raw_line.rstrip) do |state, line|
36
+ case state
37
+ when :none
38
+ if line.blank?
39
+ # nothing
40
+ else
41
+ p [state, line]
42
+ end
43
+ when :info
44
+ if /Acquiring state lock. This may take a few moments.../.match?(line)
45
+ log 'Acquiring state lock ...', depth: 2
46
+ else
47
+ p [state, line]
48
+ end
49
+ when :error
50
+ meta['error'] = 'lock'
51
+ log Paint[line, :red], depth: 2
52
+ when :plan_error
53
+ if phase != :plan_error
54
+ puts
55
+ phase = :plan_error
56
+ end
57
+ meta['error'] = 'refresh'
58
+ log Paint[line, :red], depth: 2
59
+ when :error_lock_info
60
+ if line =~ /^ ([^ ]+):\s+([^ ].+)$/
61
+ meta[$LAST_MATCH_INFO[1]] = $LAST_MATCH_INFO[2]
62
+ end
63
+ log Paint[line, :red], depth: 2
64
+ when :refreshing
65
+ if phase != :refreshing
66
+ phase = :refreshing
67
+ log 'Refreshing state ', depth: 2, newline: false
68
+ else
69
+ print '.'
70
+ end
71
+ when :refresh_done
72
+ if phase != :refresh_done
73
+ phase = :refresh_done
74
+ puts
75
+ else
76
+ # nothing
77
+ end
78
+ when :plan_info
79
+ log line, depth: 2
80
+ when :plan_summary
81
+ log line, depth: 2
82
+ else
83
+ p [state, line]
84
+ end
85
+ end
86
+ end
87
+ [status.status, meta]
88
+ end
89
+
90
+ def process_upgrade
91
+ pastel = Pastel.new
92
+
93
+ plan_output = String.new
94
+
95
+ phase = :init
96
+
97
+ meta = {}
98
+
99
+ parser = StatefulParser.new(normalizer: pastel.method(:strip))
100
+
101
+ parser.state(:modules, /^Upgrading modules\.\.\./)
102
+ parser.state(:backend, /^Initializing the backend\.\.\./, [:modules])
103
+ parser.state(:plugins, /^Initializing provider plugins\.\.\./, [:backend])
104
+
105
+ parser.state(:plugin_warnings, /^$/, [:plugins])
106
+
107
+ status = tf_init(upgrade: true, color: false) do |raw_line|
108
+ plan_output << raw_line
109
+ parser.parse(raw_line.rstrip) do |state, line|
110
+ case state
111
+ when :modules
112
+ if phase != state
113
+ # first line
114
+ phase = state
115
+ log 'Upgrding modules ', depth: 1, newline: false
116
+ next
117
+ end
118
+ case line
119
+ when /^- (?<module>[^ ]+) in (?<path>.+)$/
120
+ # info = $~.named_captures
121
+ # log "- #{info["module"]}", depth: 2
122
+ print '.'
123
+ when /^Downloading (?<repo>[^ ]+) (?<version>[^ ]+) for (?<module>[^ ]+)\.\.\./
124
+ # info = $~.named_captures
125
+ # log "Downloading #{info["module"]} from #{info["repo"]} @ #{info["version"]}"
126
+ print 'D'
127
+ when ''
128
+ puts
129
+ else
130
+ p [state, line]
131
+ end
132
+ when :backend
133
+ if phase != state
134
+ # first line
135
+ phase = state
136
+ log 'Initializing the backend ', depth: 1, newline: false
137
+ next
138
+ end
139
+ case line
140
+ when ''
141
+ puts
142
+ else
143
+ p [state, line]
144
+ end
145
+ when :plugins
146
+ if phase != state
147
+ # first line
148
+ phase = state
149
+ log 'Initializing provider plugins ...', depth: 1
150
+ next
151
+ end
152
+ case line
153
+ when /^- Downloading plugin for provider "(?<provider>[^\"]+)" \((?<provider_path>[^\)]+)\) (?<version>.+)\.\.\.$/
154
+ info = $LAST_MATCH_INFO.named_captures
155
+ log "- #{info['provider']} #{info['version']}", depth: 2
156
+ when '- Checking for available provider plugins...'
157
+ # noop
158
+ else
159
+ p [state, line]
160
+ end
161
+ when :plugin_warnings
162
+ if phase != state
163
+ # first line
164
+ phase = state
165
+ next
166
+ end
167
+
168
+ log Paint[line, :yellow], depth: 1
169
+ else
170
+ p [state, line]
171
+ end
172
+ end
173
+ end
174
+
175
+ [status.status, meta]
176
+ end
177
+
178
+ def process_validation(info)
179
+ remedies = Set.new
180
+
181
+ if info['error_count'] > 0 || info['warning_count'] > 0
182
+ log "Encountered #{Paint[info['error_count'], :red]} Errors and #{Paint[info['warning_count'], :yellow]} Warnings!", depth: 2
183
+ info['diagnostics'].each do |dinfo|
184
+ color = dinfo['severity'] == 'error' ? :red : :yellow
185
+ log "#{Paint[dinfo['severity'].capitalize, color]}: #{dinfo['summary']}", depth: 3
186
+ if dinfo['detail']&.include?('terraform init')
187
+ remedies << :init
188
+ else
189
+ log dinfo['detail'], depth: 4 if dinfo['detail']
190
+ if dinfo['range']
191
+ log format_validation_range(dinfo['range'], color), depth: 4
192
+ end
193
+
194
+ remedies << :unknown if dinfo['severity'] == 'error'
195
+ end
196
+ end
197
+ end
198
+
199
+ remedies
200
+ end
201
+
202
+ private
203
+
204
+ def format_validation_range(range, color)
205
+ # filename: "../../../modules/pods/jane_pod/main.tf"
206
+ # start:
207
+ # line: 151
208
+ # column: 27
209
+ # byte: 6632
210
+ # end:
211
+ # line: 151
212
+ # column: 53
213
+ # byte: 6658
214
+
215
+ context_lines = 3
216
+
217
+ lines = range['start']['line']..range['end']['line']
218
+ columns = range['start']['column']..range['end']['column']
219
+
220
+ # on ../../../modules/pods/jane_pod/main.tf line 151, in module "jane":
221
+ # 151: jane_resources_preset = var.jane_resources_presetx
222
+ output = []
223
+ lines_info = lines.size == 1 ? "#{lines.first}:#{columns.first}" : "#{lines.first}:#{columns.first} to #{lines.last}:#{columns.last}"
224
+ output << "on: #{range['filename']} line#{lines.size > 1 ? 's' : ''}: #{lines_info}"
225
+
226
+ if File.exist?(range['filename'])
227
+ file_lines = File.read(range['filename']).split("\n")
228
+ extract_range = ([lines.first - context_lines, 0].max)..([lines.last + context_lines, file_lines.length - 1].min)
229
+ file_lines.each_with_index do |line, index|
230
+ if extract_range.cover?(index + 1)
231
+ if lines.cover?(index + 1)
232
+ start_col = 1
233
+ end_col = :max
234
+ if index + 1 == lines.first
235
+ start_col = columns.first
236
+ elsif index + 1 == lines.last
237
+ start_col = columns.last
238
+ end
239
+ painted_line = paint_line(line, color, start_col: start_col, end_col: end_col)
240
+ output << "#{Paint['>', color]} #{index + 1}: #{painted_line}"
241
+ else
242
+ output << " #{index + 1}: #{line}"
243
+ end
244
+ end
245
+ end
246
+ end
247
+
248
+ output
249
+ end
250
+
251
+ def paint_line(line, *paint_options, start_col: 1, end_col: :max)
252
+ end_col = line.length if end_col == :max
253
+ prefix = line[0, start_col - 1]
254
+ suffix = line[end_col..-1]
255
+ middle = line[start_col - 1..end_col - 1]
256
+ "#{prefix}#{Paint[middle, *paint_options]}#{suffix}"
257
+ end
258
+ end
259
+ end
260
+ end