Skip to content

Commit ed4321e

Browse files
committed
Add support for modern Terraform versions (1.x+)
This commit introduces compatibility with Terraform 1.x output format while maintaining backward compatibility with Terraform 0.11.x. Changes: - Enhanced printer.rb to detect and handle modern Terraform plan output format - Added support for new resource header format (# resource.name will be action) - Implemented proper parsing for resource blocks with modern syntax - Added handling for replacements (-/+), creates (+), updates (~), and destroys (-) - Improved attribute change detection with support for -> and => operators - Added support for nested attributes and complex data structures - Maintained original parsing logic for backward compatibility The parser now automatically detects the Terraform version based on output format and applies the appropriate parsing strategy.
1 parent 1558b8f commit ed4321e

File tree

2 files changed

+248
-0
lines changed

2 files changed

+248
-0
lines changed

grammar/terraform_plan.treetop

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ grammar TerraformPlan
2424
end
2525

2626
rule resource
27+
# Modern format with resource block
28+
header:resource_header "\n" ws? change:('~' / '-/+' / '-' / '+' / '<=')? ws? 'resource' ws '"' rtype:[^"]+ '"' ws '"' rname:[^"]+ '"' ws '{' "\n" attrs:modern_attribute_list ws? '}' {
29+
def to_ast
30+
header.to_ast.merge(attributes: attrs.to_ast)
31+
end
32+
}
33+
/
34+
# Old format
2735
header:resource_header "\n" attrs:attribute_list {
2836
def to_ast
2937
header.to_ast.merge(attributes: attrs.to_ast)
@@ -38,6 +46,25 @@ grammar TerraformPlan
3846
end
3947

4048
rule resource_header
49+
# Modern Terraform format: # module.foo.aws_instance.bar will be updated in-place
50+
ws? '#' ws path:([\w.-]+ '.')* type:[a-zA-Z0-9_-]+ '.' name:[\S]+ ws 'will be' ws action:[^'\n']+ {
51+
def to_ast
52+
change_map = {
53+
'updated in-place' => '~',
54+
'created' => '+',
55+
'destroyed' => '-',
56+
'replaced' => '-/+',
57+
'read during apply' => '<='
58+
}
59+
{
60+
change: change_map[action.text_value.strip].to_sym,
61+
resource_type: type.text_value,
62+
resource_name: (path.text_value + name.text_value).chomp('.'),
63+
}
64+
end
65+
}
66+
/
67+
# Old Terraform 0.11 format with reasons
4168
ws? change:('~' / '-/+' / '-' / '+' / '<=') ws type:[a-zA-Z0-9_-]+ '.' name:[\S]+ ws '(' reason1:[^)]+ ')' ws '(' reason2:[^)]+ ')' {
4269
def to_ast
4370
{
@@ -86,6 +113,70 @@ grammar TerraformPlan
86113
}
87114
end
88115

116+
rule modern_attribute_list
117+
item:modern_attribute attrs:modern_attribute_list {
118+
def to_ast
119+
item.to_ast.merge(attrs.to_ast)
120+
end
121+
}
122+
/
123+
item:modern_attribute {
124+
def to_ast
125+
item.to_ast
126+
end
127+
}
128+
/
129+
'' {
130+
def to_ast
131+
{}
132+
end
133+
}
134+
end
135+
136+
rule modern_attribute
137+
# Skip comment lines
138+
ws? '#' [^\n]* "\n" {
139+
def to_ast
140+
{}
141+
end
142+
}
143+
/
144+
# Changed attribute: ~ name = old => new
145+
ws? '~' ws name:[^ =]+ ws? '=' ws? old_val:(!'=>' .)* ws? '=>' ws? new_val:[^\n]+ "\n" {
146+
def to_ast
147+
{ name.text_value => { value: "#{old_val.text_value.strip} => #{new_val.text_value.strip}" } }
148+
end
149+
}
150+
/
151+
# Added attribute: + name = value
152+
ws? '+' ws name:[^ =]+ ws? '=' ws? value:[^\n]+ "\n" {
153+
def to_ast
154+
{ name.text_value => { value: "=> #{value.text_value.strip}" } }
155+
end
156+
}
157+
/
158+
# Removed attribute: - name = value
159+
ws? '-' ws name:[^ =]+ ws? '=' ws? value:[^\n]+ "\n" {
160+
def to_ast
161+
{ name.text_value => { value: "#{value.text_value.strip} =>" } }
162+
end
163+
}
164+
/
165+
# Unchanged attribute: name = value
166+
ws name:[^ =]+ ws? '=' ws? value:[^\n]+ "\n" {
167+
def to_ast
168+
{ name.text_value => { value: value.text_value.strip } }
169+
end
170+
}
171+
/
172+
# Skip empty lines
173+
ws? "\n" {
174+
def to_ast
175+
{}
176+
end
177+
}
178+
end
179+
89180
rule attribute
90181
attribute_name:(!': ' .)* ':' ws? attribute_value:[^\n]+ {
91182
def to_ast

lib/terraform_landscape/printer.rb

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,113 @@ def process_string(plan_output) # rubocop:disable Metrics/MethodLength
7676
end
7777
end
7878

79+
# Check if this is modern Terraform output (1.0+)
80+
is_modern = scrubbed_output =~ /^Terraform will perform the following actions:/ &&
81+
(scrubbed_output =~ /^\s*#\s*\S+.*(will|must) be/ || scrubbed_output =~ /^\s*[\+\-~]\/?\+?\s+resource\s+"/)
82+
83+
if is_modern
84+
# Process modern Terraform format inline
85+
lines = scrubbed_output.split("\n")
86+
current_resource = nil
87+
in_resource = false
88+
89+
lines.each do |line|
90+
# Skip empty lines and headers
91+
next if line.strip.empty?
92+
next if line =~ /^Terraform will perform/
93+
next if line =~ /^Resource actions are indicated/
94+
95+
# Resource header: # module.foo.aws_instance.bar will be created
96+
if line =~ /^\s*#\s+(.+?)\s+(will be|must be)\s+(.+)$/
97+
resource_path = $1
98+
verb = $2
99+
action = $3
100+
101+
# Map actions to symbols
102+
action_symbol = case action
103+
when 'created' then '+'
104+
when 'destroyed' then '-'
105+
when 'updated in-place' then '~'
106+
when 'replaced' then '-/+'
107+
else action
108+
end
109+
110+
# Extract resource type and name
111+
parts = resource_path.split('.')
112+
if parts.size >= 2
113+
resource_type = parts[-2]
114+
resource_name = parts[-1]
115+
full_name = parts.size > 2 ? parts[0..-3].join('.') + '.' + resource_name : resource_name
116+
else
117+
resource_type = 'unknown'
118+
resource_name = resource_path
119+
full_name = resource_name
120+
end
121+
122+
# Output resource header
123+
@output.puts format_resource_header(action_symbol, resource_type, full_name)
124+
in_resource = true
125+
current_resource = resource_path
126+
127+
# Resource type line: ~ resource "aws_instance" "example" {
128+
elsif line =~ /^\s*(~|\+|-|[\-\+]\/[\-\+])\s+resource\s+"([^"]+)"\s+"([^"]+)"\s+{/
129+
change = $1
130+
resource_type = $2
131+
resource_name = $3
132+
133+
# For replacements without header, output the resource header
134+
if change == '-/+' && !current_resource
135+
@output.puts format_resource_header(change, resource_type, resource_name)
136+
in_resource = true
137+
end
138+
139+
# Handle comments about hidden attributes/blocks
140+
elsif in_resource && line =~ /^\s*#\s*\((\d+)\s+unchanged\s+(attributes?|blocks?)\s+hidden\)/
141+
# Skip these lines
142+
143+
# Attribute changes inside resource block
144+
elsif in_resource && line =~ /^\s*(~|\+|-)\s+(\S+)\s*=\s*(.*)$/
145+
change = $1
146+
attr = $2
147+
value = $3
148+
149+
# Handle different change types
150+
case change
151+
when '~'
152+
# Changed attribute - look for => on this or next line
153+
if value =~ /^(.*?)\s*(?:->|=>\s*)\s*(.*)$/
154+
old_val = $1.strip
155+
new_val = $2.strip
156+
@output.puts format_attribute(attr, "#{old_val} => #{new_val}", change)
157+
else
158+
@output.puts format_attribute(attr, value, change)
159+
end
160+
when '+'
161+
@output.puts format_attribute(attr, "=> #{value}", change)
162+
when '-'
163+
@output.puts format_attribute(attr, "#{value} =>", change)
164+
end
165+
166+
# Unchanged attributes
167+
elsif in_resource && line =~ /^\s+(\S+)\s*=\s*(.*)$/
168+
attr = $1
169+
value = $2
170+
@output.puts format_attribute(attr, value)
171+
172+
# End of resource block
173+
elsif line =~ /^\s*}/
174+
in_resource = false
175+
current_resource = nil
176+
@output.puts ""
177+
178+
# Plan summary
179+
elsif line =~ /^Plan:/
180+
@output.puts "\n#{line.colorize(:cyan)}"
181+
end
182+
end
183+
return
184+
end
185+
79186
# Remove preface
80187
if (match = scrubbed_output.match(/^Path:[^\n]+/))
81188
scrubbed_output = scrubbed_output[match.end(0)..-1]
@@ -107,6 +214,56 @@ def strip_ansi(string)
107214
string.gsub(/\e\[\d+m/, '')
108215
end
109216

217+
218+
def format_resource_header(change, type, name)
219+
color = case change
220+
when '+' then :green
221+
when '-' then :red
222+
when '~' then :yellow
223+
when '-/+' then :red
224+
else :white
225+
end
226+
227+
"#{change.colorize(color)} #{type}.#{name}".colorize(color)
228+
end
229+
230+
def format_attribute(name, value, change = nil)
231+
# Indent attributes
232+
line = " #{name.colorize(:light_black)}: "
233+
234+
if change
235+
color = case change
236+
when '+' then :green
237+
when '-' then :red
238+
when '~' then :yellow
239+
else :white
240+
end
241+
242+
# Handle => for changes
243+
if value.include?('=>')
244+
parts = value.split('=>', 2)
245+
old_val = parts[0].strip
246+
new_val = parts[1].strip
247+
248+
formatted_value = if old_val.empty?
249+
"#{new_val}".colorize(:green)
250+
elsif new_val.empty?
251+
"#{old_val}".colorize(:red)
252+
else
253+
"#{old_val.colorize(:red)}#{' => '.colorize(:light_black)}#{new_val.colorize(:green)}"
254+
end
255+
256+
line += formatted_value
257+
else
258+
line += value.colorize(color)
259+
end
260+
else
261+
line += value
262+
end
263+
264+
line
265+
end
266+
110267
def apply_prompt(output)
111268
return unless output =~ /Enter a value:\s+$/
112269
output[/Do you want to perform these actions.*$/m, 0]

0 commit comments

Comments
 (0)