lib/terraform_landscape/terraform_plan.rb



require 'colorize'
require 'diffy'
require 'json'
require 'treetop'

########################################################################
# Represents the parsed output of `terraform plan`.
#
# This allows us to easily inspect the plan and present a more readable
# explanation of the plan to the user.
########################################################################
class TerraformLandscape::TerraformPlan # rubocop:disable Metrics/ClassLength
  GRAMMAR_FILE = File.expand_path(File.join(File.dirname(__FILE__),
                                            '..', '..', 'grammar',
                                            'terraform_plan.treetop'))

  CHANGE_SYMBOL_TO_COLOR = {
    :~ => :yellow,
    :- => :red,
    :+ => :green,
    :'-/+' => :yellow,
    :'<=' => :cyan
  }.freeze

  DEFAULT_DIFF_CONTEXT_LINES = 5

  UNICODE_ESCAPER = proc { |s|
    format('\u%04X', s.codepoints[0])
  }

  class ParseError < StandardError; end

  class << self
    def from_output(string)
      # Our grammar assumes output with Unix line endings
      string = string.gsub("\r\n", "\n")

      return new([]) if string.strip.empty?
      tree = parser.parse(string)
      raise ParseError, parser.failure_reason unless tree
      new(tree.to_ast)
    end

    private

    def parser
      @parser ||=
        begin
          Treetop.load(GRAMMAR_FILE)
          TerraformPlanParser.new
        end
    end
  end

  # Create a plan from an abstract syntax tree (AST).
  def initialize(plan_ast, options = {})
    @ast = plan_ast
    @diff_context_lines = options.fetch(:diff_context_lines, DEFAULT_DIFF_CONTEXT_LINES)
  end

  def display(output)
    @out = output
    @ast.each do |resource|
      display_resource(resource)
      @out.newline
    end
  end

  private

  def display_resource(resource) # rubocop:disable Metrics/MethodLength
    change_color = CHANGE_SYMBOL_TO_COLOR[resource[:change]]

    resource_header = "#{resource[:change]} #{resource[:resource_type]}." \
                      "#{resource[:resource_name]}".colorize(change_color)

    if resource[:reason]
      resource_header += " (#{resource[:reason]})".colorize(:magenta)
    end

    if resource[:additional_reason]
      resource_header += " (#{resource[:additional_reason]})".colorize(:magenta)
    end

    @out.puts resource_header

    # Determine longest attribute name so we align all values at same indentation
    attribute_value_indent_amount = attribute_indent_amount_for_resource(resource)

    resource[:attributes].each do |attribute_name, attribute_value_and_reason|
      attribute_value = attribute_value_and_reason[:value]
      attribute_change_reason = attribute_value_and_reason[:reason]
      display_attribute(resource,
                        change_color,
                        attribute_name,
                        attribute_value,
                        attribute_change_reason,
                        attribute_value_indent_amount)
    end
  end

  def attribute_indent_amount_for_resource(resource)
    longest_name_length = resource[:attributes].keys.reduce(0) do |longest, name|
      name.length > longest ? name.length : longest
    end
    longest_name_length + 8
  end

  def undump(value)
    value.encode('ASCII', fallback: UNICODE_ESCAPER).undump
  end

  def json?(value)
    ['{', '['].include?(value.to_s[0]) &&
      (JSON.parse(value) rescue nil) # rubocop:disable Style/RescueModifier
  end

  def to_pretty_json(value)
    # Can't JSON.parse an empty string, so handle it separately
    return '' if value.strip.empty?

    sorted = recursive_sort(JSON.parse(value))
    JSON.pretty_generate(sorted,
                         {
                           indent: '  ',
                           space: ' ',
                           object_nl: "\n",
                           array_nl: "\n"
                         })
  end

  def recursive_sort(obj)
    case obj
    when Array
      obj.map { |item| recursive_sort(item) }
    when Hash
      obj.keys.sort.each_with_object({}) do |key, hash|
        hash[key] = recursive_sort(obj[key])
      end
    else
      obj
    end
  end

  def display_diff(old, new, indent)
    @out.print Diffy::Diff.new(old, new, { context: @diff_context_lines })
      .to_s(String.disable_colorization ? :text : :color)
                          .gsub("\n", "\n" + indent)
                          .strip
  end

  def display_attribute( # rubocop:disable Metrics/ParameterLists
    resource,
    change_color,
    attribute_name,
    attribute_value,
    attribute_change_reason,
    attribute_value_indent_amount
  )
    attribute_value_indent = ' ' * attribute_value_indent_amount

    if %i[~ -/+].include?(resource[:change])
      display_modified_attribute(change_color,
                                 attribute_name,
                                 attribute_value,
                                 attribute_change_reason,
                                 attribute_value_indent,
                                 attribute_value_indent_amount)
    else
      display_added_or_removed_attribute(change_color,
                                         attribute_name,
                                         attribute_value,
                                         attribute_value_indent,
                                         attribute_value_indent_amount)
    end
  end

  # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength
  def display_modified_attribute(
    change_color,
    attribute_name,
    attribute_value,
    attribute_change_reason,
    attribute_value_indent,
    attribute_value_indent_amount
  )
    # Since the attribute line is always of the form "old value" => "new value"
    attribute_value =~ /^ *(".*") *=> *(".*") *$/
    old = undump(Regexp.last_match[1])
    new = undump(Regexp.last_match[2])

    return if old == new && new != '<sensitive>' # Don't show unchanged attributes

    @out.print "    #{attribute_name}:".ljust(attribute_value_indent_amount, ' ')
                                       .colorize(change_color)

    if json?(new)
      # Value looks like JSON, so prettify it to make it more readable
      fancy_old = "#{to_pretty_json(old)}\n"
      fancy_new = "#{to_pretty_json(new)}\n"
      display_diff(fancy_old, fancy_new, attribute_value_indent)
    elsif old.include?("\n") || new.include?("\n")
      # Multiline content, so display nicer diff
      display_diff("#{old}\n", "#{new}\n", attribute_value_indent)
    else
      # Typical values, so just show before/after
      @out.print '"' + old.colorize(:red) + '"'
      @out.print ' => '.colorize(:light_black)
      @out.print '"' + new.colorize(:green) + '"'
    end

    if attribute_change_reason
      @out.print " (#{attribute_change_reason})".colorize(:magenta)
    end

    @out.newline
  end

  def display_added_or_removed_attribute(
    change_color,
    attribute_name,
    attribute_value,
    attribute_value_indent,
    attribute_value_indent_amount
  )
    @out.print "    #{attribute_name}:".ljust(attribute_value_indent_amount, ' ')
                                       .colorize(change_color)

    evaluated_string = undump(attribute_value)
    if json?(evaluated_string)
      @out.print to_pretty_json(evaluated_string).gsub("\n",
                                                       "\n#{attribute_value_indent}")
                                                 .colorize(change_color)
    else
      @out.print "\"#{evaluated_string.colorize(change_color)}\""
    end

    @out.newline
  end
end