mux_tf 0.1.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +10 -0
- data/Gemfile +8 -0
- data/LICENSE.txt +21 -0
- data/README.md +42 -0
- data/Rakefile +4 -0
- data/exe/tf_current +8 -0
- data/exe/tf_mux +8 -0
- data/exe/tf_plan_summary +8 -0
- data/lib/mux_tf.rb +28 -0
- data/lib/mux_tf/cli.rb +21 -0
- data/lib/mux_tf/cli/current.rb +280 -0
- data/lib/mux_tf/cli/mux.rb +89 -0
- data/lib/mux_tf/cli/plan_summary.rb +286 -0
- data/lib/mux_tf/plan_formatter.rb +260 -0
- data/lib/mux_tf/terraform_helpers.rb +114 -0
- data/lib/mux_tf/tmux.rb +102 -0
- data/lib/mux_tf/version.rb +5 -0
- data/mux_tf.gemspec +40 -0
- metadata +178 -0
@@ -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
|