Skip to content

Instantly share code, notes, and snippets.

@greglook
Created July 27, 2018 23:11
Show Gist options
  • Save greglook/747ae5671074e2905225a5a3e1e710e8 to your computer and use it in GitHub Desktop.
Save greglook/747ae5671074e2905225a5a3e1e710e8 to your computer and use it in GitHub Desktop.
Render Terraform AWS security group diffs for humans
#!/usr/bin/env ruby
# Better Terraform security group diffing. Feed the terraform resource diff
# output into this script's standard input.
SGR_ANSI = "\e[%sm"
SGR_CODES = {
none: 0,
bold: 1,
black: 30,
red: 31,
green: 32,
yellow: 33,
blue: 34,
magenta: 35,
cyan: 36,
white: 37,
}
# Constructs an ANSI Select Graphic Rendition sequence.
def sgr(codes)
SGR_ANSI % codes.map{|c| SGR_CODES[c] || raise("Unknown SGR code key: #{c.inspect}") }.join(';')
end
# Apply color codes to a section of text.
def color(text, *codes)
[sgr(codes), text, sgr([:none])].join('')
end
# Parse an input line into an attribute structure.
def parse_line(line)
if /^ *(\S+): +"([^"]*)" => "([^"]*)"$/ === line.chomp
{name: $1.split('.'), before: $2, after: $3}
else
STDERR.puts "Ignoring unknown line format: #{line.inspect}"
nil
end
end
rule_map = {}
# Process input.
while STDIN.gets do
line = parse_line($_)
name = line && line[:name]
if name && name[0] == 'ingress'
rule_id = name[1]
next if rule_id == '#'
rule_map[rule_id] ||= {}
rule_attr = name[2]
next if rule_attr == 'self'
if name.count > 3
next if name[3] == '#'
rule_map[rule_id][rule_attr] ||= {before: [], after: []}
rule_map[rule_id][rule_attr][:before] << line[:before] unless line[:before] == ''
rule_map[rule_id][rule_attr][:after] << line[:after] unless line[:after] == ''
else
before = line[:before] != '' && line[:before] || nil
after = line[:after] != '' && line[:after] || nil
rule_map[rule_id][rule_attr] = {before: before, after: after} if before || after
end
end
end
rules = []
BLANKS = [nil, '', '0', []]
def rule_unchanged?(rule)
rule.values.all? {|v| v[:before] == v[:after] }
end
def rule_added?(rule)
rule.all? {|k, v| k.is_a?(Symbol) || (BLANKS.include?(v[:before]) && v[:after]) }
end
def rule_removed?(rule)
rule.all? {|k, v| k.is_a?(Symbol) || (v[:before] && BLANKS.include?(v[:after])) }
end
def find_match(rule, rside, candidates, cside, *fields)
candidates.each do |candidate|
matched = true
fields.each do |field|
rval = rule[field] && rule[field][rside]
cval = candidate[field] && candidate[field][cside]
matched = false unless rval && cval && rval == cval
end
return candidate if matched
end
nil
end
# If all the attributes are the same, no change. Remove these from dedupe
# consideration.
rule_map.keys.each do |rule_id|
rule = rule_map[rule_id]
next unless rule_unchanged?(rule)
rule[:id] = {before: rule_id, after: rule_id}
rule[:state] = :unchanged
rules << rule
rule_map.delete(rule_id)
end
# Set ids so we can access later.
rule_map.each do |rule_id, rule|
rule[:id] = rule_id
end
# Try to pair up additions and removals.
rule_map.keys.each do |rule_id|
rule = rule_map[rule_id]
next unless rule
if rule_added? rule
candidates = rule_map.values.select {|r| rule_removed? r }
match = find_match(rule, :after, candidates, :before, 'description') \
|| find_match(rule, :after, candidates, :before, 'protocol', 'from_port', 'to_port') \
|| find_match(rule, :after, candidates, :before, 'cidr_blocks') \
|| find_match(rule, :after, candidates, :before, 'ipv6_cidr_blocks') \
|| find_match(rule, :after, candidates, :before, 'security_groups')
rule_map.delete(rule_id)
if match
rule_map.delete(match[:id])
merged = {id: {before: match[:id], after: rule_id}}
(rule.keys + match.keys).uniq.each do |k|
next if k == :id
merged[k] = {before: match[k][:before], after: rule[k][:after]}
end
merged[:state] = :diff
rules << merged
else
rule[:id] = {after: rule_id}
rule[:state] = :added
rules << rule
end
elsif rule_removed? rule
candidates = rule_map.values.select {|r| rule_added? r }
match = find_match(rule, :before, candidates, :after, 'description') \
|| find_match(rule, :before, candidates, :after, 'protocol', 'from_port', 'to_port') \
|| find_match(rule, :before, candidates, :after, 'cidr_blocks') \
|| find_match(rule, :before, candidates, :after, 'ipv6_cidr_blocks') \
|| find_match(rule, :before, candidates, :after, 'security_groups')
rule_map.delete(rule_id)
if match
rule_map.delete(match[:id])
merged = {id: {before: rule_id, after: match[:id]}}
(rule.keys + match.keys).uniq.each do |k|
next if k == :id
merged[k] = {before: rule[k][:before], after: match[k][:after]}
end
merged[:state] = :diff
rules << merged
else
rule[:id] = {before: rule_id}
rule[:state] = :removed
rules << rule
end
else
raise "Unknown rule changes: #{rule_id} => #{rule.inspect}"
end
end
# Roughly sort rules by port order.
rules.sort_by! do |rule|
from_port = rule['from_port']
to_port = rule['to_port']
(from_port && (from_port[:before] || from_port[:after])) \
|| (to_port && (to_port[:before] || to_port[:after]))
end
# Return a human-readable string for the port range in a rule.
def port_range(rule, side)
protocol = rule['protocol'][side] || '???'
from_port = rule['from_port'][side] || '?'
to_port = rule['to_port'][side] || '?'
if protocol == 'icmp' && from_port == '-1' && to_port == '-1'
range = '(all)'
elsif from_port == '0' && to_port == '65535'
range = '(all)'
elsif from_port == to_port
range = from_port
else
range = "#{from_port}-#{to_port}"
end
"#{protocol.upcase} #{range}"
end
# Diff two lists and return colorized and indented text.
def diff_lists(left, right)
(left + right).uniq.sort.each do |val|
if left.include?(val) && right.include?(val)
puts " #{color(val, :cyan)}"
elsif left.include?(val)
puts " #{color(val, :red)}"
else
puts " #{color(val, :green)}"
end
end
end
special_attrs = ['description', 'protocol', 'from_port', 'to_port']
list_attrs = ['cidr_blocks', 'ipv6_cidr_blocks', 'security_groups']
# Pretty print each rule change.
rules.each do |rule|
case rule[:state]
when :unchanged
puts color(rule[:id][:before], :bold, :cyan)
puts " #{rule['description'][:before]}" if rule['description']
puts " #{port_range(rule, :before)}"
when :removed
puts "#{color(rule[:id][:before], :bold, :red)}"
puts " #{rule['description'][:before]}" if rule['description']
puts " #{port_range(rule, :before)}"
when :added
puts "#{color(rule[:id][:after], :bold, :green)}"
puts " #{rule['description'][:after]}" if rule['description']
puts " #{port_range(rule, :after)}"
else
puts "#{color(rule[:id][:before], :bold, :yellow)} => #{color(rule[:id][:after], :bold, :yellow)}"
description = rule['description']
if description && description[:before] == description[:after]
puts description[:before]
elsif description
puts " #{description[:before].inspect} => #{description[:after].inspect}"
end
before_range = port_range(rule, :before)
after_range = port_range(rule, :after)
if before_range == after_range
puts " #{before_range}"
else
puts " #{port_range(rule, :before)} => #{port_range(rule, :after)}"
end
end
list_attrs.each do |k|
next unless rule[k]
before = rule[k][:before]
after = rule[k][:after]
puts " #{k}"
diff_lists before, after
end
rule.each do |k, v|
next if special_attrs.include?(k) || list_attrs.include?(k) || k.is_a?(Symbol)
before = v[:before]
after = v[:after]
if before == after
puts " #{k}: #{before}"
else
puts " #{k}: #{before} => #{after}"
end
end
end
@shubhamsre
Copy link

Can you update it with egress block as well, and it only supports terraform 11!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment