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
@greglook
Copy link
Author

Turns this:

  ~ aws_security_group.internal
      ingress.#:                                    "3" => "2"
      ingress.1456554718.cidr_blocks.#:             "0" => "4"
      ingress.1456554718.cidr_blocks.0:             "" => "172.16.0.0/16"
      ingress.1456554718.cidr_blocks.1:             "" => "172.17.0.0/16"
      ingress.1456554718.cidr_blocks.2:             "" => "172.18.0.0/16"
      ingress.1456554718.cidr_blocks.3:             "" => "192.168.0.0/16"
      ingress.1456554718.description:               "" => ""
      ingress.1456554718.from_port:                 "" => "8200"
      ingress.1456554718.ipv6_cidr_blocks.#:        "0" => "0"
      ingress.1456554718.protocol:                  "" => "tcp"
      ingress.1456554718.security_groups.#:         "0" => "0"
      ingress.1456554718.self:                      "" => "false"
      ingress.1456554718.to_port:                   "" => "8200"
      ingress.3913365297.cidr_blocks.#:             "3" => "0"
      ingress.3913365297.cidr_blocks.0:             "172.16.0.0/16" => ""
      ingress.3913365297.cidr_blocks.1:             "172.17.0.0/16" => ""
      ingress.3913365297.cidr_blocks.2:             "192.168.0.0/16" => ""
      ingress.3913365297.description:               "" => ""
      ingress.3913365297.from_port:                 "8200" => "0"
      ingress.3913365297.ipv6_cidr_blocks.#:        "0" => "0"
      ingress.3913365297.protocol:                  "tcp" => ""
      ingress.3913365297.security_groups.#:         "0" => "0"
      ingress.3913365297.self:                      "false" => "false"
      ingress.3913365297.to_port:                   "8200" => "0"
      ingress.4086720532.cidr_blocks.#:             "0" => "2"
      ingress.4086720532.cidr_blocks.0:             "" => "172.40.0.0/16"
      ingress.4086720532.cidr_blocks.1:             "" => "172.50.0.0/16"
      ingress.4086720532.description:               "" => ""
      ingress.4086720532.from_port:                 "" => "8200"
      ingress.4086720532.ipv6_cidr_blocks.#:        "0" => "0"
      ingress.4086720532.protocol:                  "" => "tcp"
      ingress.4086720532.security_groups.#:         "0" => "0"
      ingress.4086720532.self:                      "" => "false"
      ingress.4086720532.to_port:                   "" => "8200"
      ingress.4281125460.cidr_blocks.#:             "4" => "0"
      ingress.4281125460.cidr_blocks.0:             "172.40.0.0/16" => ""
      ingress.4281125460.cidr_blocks.1:             "172.50.0.0/16" => ""
      ingress.4281125460.cidr_blocks.2:             "172.16.0.32/32" => ""
      ingress.4281125460.cidr_blocks.3:             "172.18.0.0/16" => ""
      ingress.4281125460.description:               "" => ""
      ingress.4281125460.from_port:                 "8200" => "0"
      ingress.4281125460.ipv6_cidr_blocks.#:        "0" => "0"
      ingress.4281125460.protocol:                  "tcp" => ""
      ingress.4281125460.security_groups.#:         "0" => "0"
      ingress.4281125460.self:                      "false" => "false"
      ingress.4281125460.to_port:                   "8200" => "0"
      ingress.711154733.cidr_blocks.#:              "1" => "0"
      ingress.711154733.cidr_blocks.0:              "172.40.0.0/16" => ""
      ingress.711154733.description:                "" => ""
      ingress.711154733.from_port:                  "-1" => "0"
      ingress.711154733.ipv6_cidr_blocks.#:         "0" => "0"
      ingress.711154733.protocol:                   "icmp" => ""
      ingress.711154733.security_groups.#:          "0" => "0"
      ingress.711154733.self:                       "false" => "false"
      ingress.711154733.to_port:                    "-1" => "0"

into this:
screen shot 2018-07-30 at 11 17 48

@shubhamsre
Copy link

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

@leelee85527
Copy link

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