mux_tf 0.1.2

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