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.
- 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
|