terraform_landscape 0.0.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 3adeba30b54a83c182ee6956d97f42e16fe1bb14
4
+ data.tar.gz: ab302a4d55105e6a0a3bc70e31c497e64dc3b489
5
+ SHA512:
6
+ metadata.gz: 065d407040083b704ddef29a67080db8b618f557ed498c9623ff26ced98ddbee28538fad417253370155d904974e06abea1a5d1741acaee63e1d6b15f7ceed44
7
+ data.tar.gz: 7602f283d8a02775dc4e07c5b93fd6c5763cc4e1d5f939fae3d1c042f47d0e3c387f48a2d3e472c9cb1d7610595f46829953f60bb7a356a9fbbfaaa28f2d4e72
data/bin/landscape ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'terraform_landscape'
4
+
5
+ output = TerraformLandscape::Output.new(STDOUT)
6
+ cli = TerraformLandscape::CLI.new(output)
7
+ exit cli.run(ARGV)
@@ -0,0 +1,67 @@
1
+ # Defines grammar for parsing `terraform plan` output.
2
+
3
+ grammar TerraformPlan
4
+ rule plan
5
+ [\s]* list:resource_list "\n"* {
6
+ def to_ast
7
+ list.to_ast
8
+ end
9
+ }
10
+ end
11
+
12
+ rule resource_list
13
+ item:resource "\n\n" list:resource_list {
14
+ def to_ast
15
+ [item.to_ast] + list.to_ast
16
+ end
17
+ }
18
+ /
19
+ item:resource "\n" {
20
+ def to_ast
21
+ [item.to_ast]
22
+ end
23
+ }
24
+ end
25
+
26
+ rule resource
27
+ header:resource_header "\n" attrs:attribute_list {
28
+ def to_ast
29
+ header.to_ast.merge(attributes: attrs.to_ast)
30
+ end
31
+ }
32
+ end
33
+
34
+ rule resource_header
35
+ change:('~' / '-' / '+') _ type:[a-zA-Z0-9_-]+ '.' name:[a-zA-Z0-9_-]+ {
36
+ def to_ast
37
+ { change: change.text_value.to_sym, resource_type: type.text_value, resource_name: name.text_value }
38
+ end
39
+ }
40
+ end
41
+
42
+ rule attribute_list
43
+ _ item:attribute "\n" attrs:attribute_list {
44
+ def to_ast
45
+ item.to_ast.merge(attrs.to_ast)
46
+ end
47
+ }
48
+ /
49
+ _ item:attribute {
50
+ def to_ast
51
+ item.to_ast
52
+ end
53
+ }
54
+ end
55
+
56
+ rule attribute
57
+ attribute_name:[^\s:]* ':' _? attribute_value:[^\n]+ {
58
+ def to_ast
59
+ { attribute_name.text_value => attribute_value.text_value }
60
+ end
61
+ }
62
+ end
63
+
64
+ rule _
65
+ [ ]+
66
+ end
67
+ end
@@ -0,0 +1,7 @@
1
+ require 'terraform_landscape/constants'
2
+ require 'terraform_landscape/version'
3
+ require 'terraform_landscape/output'
4
+ require 'terraform_landscape/errors'
5
+ require 'terraform_landscape/printer'
6
+ require 'terraform_landscape/terraform_plan'
7
+ require 'terraform_landscape/cli'
@@ -0,0 +1,58 @@
1
+ require 'optparse'
2
+
3
+ module TerraformLandscape
4
+ # Handles option parsing for the command line application.
5
+ class ArgumentsParser
6
+ # Parses command line options into an options hash.
7
+ #
8
+ # @param args [Array<String>] arguments passed via the command line
9
+ #
10
+ # @return [Hash] parsed options
11
+ def parse(args)
12
+ @options = {}
13
+ @options[:command] = :pretty_print # Default command
14
+
15
+ OptionParser.new do |parser|
16
+ parser.banner = "Usage: landscape [options] [plan-output-file]"
17
+
18
+ add_info_options parser
19
+ end.parse!(args)
20
+
21
+ # Any remaining arguments are assumed to be the output file
22
+ @options[:plan_output_file] = args.first
23
+
24
+ @options
25
+ rescue OptionParser::InvalidOption => ex
26
+ raise InvalidCliOptionError,
27
+ "#{ex.message}\nRun `landscape --help` to " \
28
+ 'see a list of available options.'
29
+ end
30
+
31
+ private
32
+
33
+ # Register informational flags.
34
+ def add_info_options(parser)
35
+ parser.on('--[no-]color', 'Force output to be colorized') do |color|
36
+ @options[:color] = color
37
+ end
38
+
39
+ parser.on('-d', '--debug', 'Enable debug mode for more verbose output') do
40
+ @options[:debug] = true
41
+ end
42
+
43
+ parser.on_tail('-h', '--help', 'Display help documentation') do
44
+ @options[:command] = :display_help
45
+ @options[:help_message] = parser.help
46
+ end
47
+
48
+ parser.on_tail('-v', '--version', 'Display version') do
49
+ @options[:command] = :display_version
50
+ end
51
+
52
+ parser.on_tail('-V', '--verbose-version', 'Display verbose version information') do
53
+ @options[:command] = :display_version
54
+ @options[:verbose_version] = true
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,51 @@
1
+ require 'commander'
2
+
3
+ module TerraformLandscape
4
+ # Command line application interface.
5
+ class CLI
6
+ include Commander::Methods
7
+
8
+ def initialize(output)
9
+ @output = output
10
+ end
11
+
12
+ # Parses the given command line arguments and executes appropriate logic
13
+ # based on those arguments.
14
+ #
15
+ # @param args [Array<String>] command line arguments
16
+ #
17
+ # @return [Integer] exit status code
18
+ def run(args)
19
+ program :name, 'Terraform Landscape'
20
+ program :version, VERSION
21
+ program :description, 'Pretty-print your Terraform plan output'
22
+
23
+ define_commands
24
+
25
+ run!
26
+ 0 # OK
27
+ end
28
+
29
+ private
30
+
31
+ def define_commands
32
+ command :print do |c|
33
+ c.action do
34
+ print
35
+ end
36
+ end
37
+
38
+ global_option '--no-color', 'Do not output any color' do
39
+ String.disable_colorization = true
40
+ @output.color_enabled = false
41
+ end
42
+
43
+ default_command :print
44
+ end
45
+
46
+ def print
47
+ printer = Printer.new(@output)
48
+ printer.process_stream(STDIN)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Global application constants.
4
+ module TerraformLandscape
5
+ HOME = File.expand_path(File.join(File.dirname(__FILE__), '..', '..')).freeze
6
+
7
+ REPO_URL = 'https://github.com/coinbase/terraform_landscape'.freeze
8
+ BUG_REPORT_URL = "#{REPO_URL}/issues".freeze
9
+ end
@@ -0,0 +1,33 @@
1
+ # Collection of errors that can be raised by the framework.
2
+ module TerraformLandscape
3
+ # Abstract error. Separates LintTrappings errors from other kinds of
4
+ # errors in the exception hierarchy.
5
+ #
6
+ # @abstract
7
+ class Error < StandardError
8
+ # Returns the status code that should be output if this error goes
9
+ # unhandled.
10
+ #
11
+ # Ideally these should resemble exit codes from the sysexits documentation
12
+ # where it makes sense.
13
+ def self.exit_status(*args)
14
+ if args.any?
15
+ @exit_status = args.first
16
+ elsif @exit_status
17
+ @exit_status
18
+ else
19
+ ancestors.each do |ancestor|
20
+ return 70 if ancestor == TerraformLandscape::Error # No exit status defined
21
+ return ancestor.exit_status if ancestor.exit_status
22
+ end
23
+ end
24
+ end
25
+
26
+ def exit_status
27
+ self.class.exit_status
28
+ end
29
+ end
30
+
31
+ # Raised when there was a problem parsing a document.
32
+ class ParseError < Error; end
33
+ end
@@ -0,0 +1,118 @@
1
+ module TerraformLandscape
2
+ # Encapsulates all communication to an output source.
3
+ class Output
4
+ # Whether colored output via ANSI escape sequences is enabled.
5
+ # @return [true,false]
6
+ attr_accessor :color_enabled
7
+
8
+ # Creates a logger which outputs nothing.
9
+ # @return [TerraformLandscape::Output]
10
+ def self.silent
11
+ new(File.open(File::NULL, 'w'))
12
+ end
13
+
14
+ # Creates a new {SlimLint::Logger} instance.
15
+ #
16
+ # @param out [IO] the output destination.
17
+ def initialize(out)
18
+ @out = out
19
+ @color_enabled = tty?
20
+ end
21
+
22
+ # Print the specified output.
23
+ #
24
+ # @param output [String] the output to send
25
+ # @param newline [true,false] whether to append a newline
26
+ def puts(output, newline = true)
27
+ @out.print(output)
28
+ @out.print("\n") if newline
29
+ end
30
+
31
+ # Print the specified output without a newline.
32
+ #
33
+ # @param output [String] the output to send
34
+ def print(output)
35
+ puts(output, false)
36
+ end
37
+
38
+ # Print the specified output in bold face.
39
+ # If output destination is not a TTY, behaves the same as {#log}.
40
+ #
41
+ # @param args [Array<String>]
42
+ def bold(*args)
43
+ color('1', *args)
44
+ end
45
+
46
+ # Print the specified output in a color indicative of error.
47
+ # If output destination is not a TTY, behaves the same as {#log}.
48
+ #
49
+ # @param args [Array<String>]
50
+ def error(*args)
51
+ color(31, *args)
52
+ end
53
+
54
+ # Print the specified output in a bold face and color indicative of error.
55
+ # If output destination is not a TTY, behaves the same as {#log}.
56
+ #
57
+ # @param args [Array<String>]
58
+ def bold_error(*args)
59
+ color('1;31', *args)
60
+ end
61
+
62
+ # Print the specified output in a color indicative of success.
63
+ # If output destination is not a TTY, behaves the same as {#log}.
64
+ #
65
+ # @param args [Array<String>]
66
+ def success(*args)
67
+ color(32, *args)
68
+ end
69
+
70
+ # Print the specified output in a color indicative of a warning.
71
+ # If output destination is not a TTY, behaves the same as {#log}.
72
+ #
73
+ # @param args [Array<String>]
74
+ def warning(*args)
75
+ color(33, *args)
76
+ end
77
+
78
+ # Print the specified output in a color indicating something worthy of
79
+ # notice.
80
+ # If output destination is not a TTY, behaves the same as {#log}.
81
+ #
82
+ # @param args [Array<String>]
83
+ def notice(*args)
84
+ color(35, *args)
85
+ end
86
+
87
+ # Print the specified output in a color indicating information.
88
+ # If output destination is not a TTY, behaves the same as {#log}.
89
+ #
90
+ # @param args [Array<String>]
91
+ def info(*args)
92
+ color(36, *args)
93
+ end
94
+
95
+ # Print a blank line.
96
+ def newline
97
+ puts('')
98
+ end
99
+
100
+ # Whether this logger is outputting to a TTY.
101
+ #
102
+ # @return [true,false]
103
+ def tty?
104
+ @out.respond_to?(:tty?) && @out.tty?
105
+ end
106
+
107
+ private
108
+
109
+ # Print output in the specified color.
110
+ #
111
+ # @param code [Integer,String] ANSI color code
112
+ # @param output [String] output to print
113
+ # @param newline [Boolean] whether to append a newline
114
+ def color(code, output, newline = true)
115
+ puts(color_enabled ? "\033[#{code}m#{output}\033[0m" : output, newline)
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,56 @@
1
+ require 'stringio'
2
+
3
+ module TerraformLandscape
4
+ class Printer
5
+ def initialize(output)
6
+ @output = output
7
+ end
8
+
9
+ def process_stream(io)
10
+ buffer = StringIO.new
11
+ begin
12
+ block_size = 1024
13
+
14
+ done = false
15
+ until done
16
+ readable_fds, = IO.select([io])
17
+ next unless readable_fds
18
+
19
+ readable_fds.each do |f|
20
+ begin
21
+ buffer << f.read_nonblock(block_size)
22
+ rescue IO::WaitReadable # rubocop:disable Lint/HandleExceptions
23
+ # Ignore; we'll call IO.select again
24
+ rescue EOFError
25
+ done = true
26
+ end
27
+ end
28
+ end
29
+ ensure
30
+ io.close
31
+ end
32
+
33
+ plan_output = buffer.string
34
+ scrubbed_output = plan_output.gsub(/\e\[\d+m/, '')
35
+
36
+ # Remove preface
37
+ if (match = scrubbed_output.match(/^Path:[^\n]+/))
38
+ scrubbed_output = scrubbed_output[match.end(0)..-1]
39
+ else
40
+ raise ParseError, 'Output does not contain proper preface'
41
+ end
42
+
43
+ # Remove postface
44
+ if (match = scrubbed_output.match(/^Plan:[^\n]+/))
45
+ plan_summary = scrubbed_output[match.begin(0)..match.end(0)]
46
+ scrubbed_output = scrubbed_output[0...match.begin(0)]
47
+ else
48
+ raise ParseError, 'Output does not container proper postface'
49
+ end
50
+
51
+ plan = TerraformPlan.from_output(scrubbed_output)
52
+ plan.display(@output)
53
+ @output.puts plan_summary
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,188 @@
1
+ require 'colorize'
2
+ require 'diffy'
3
+ require 'json'
4
+ require 'treetop'
5
+
6
+ ########################################################################
7
+ # Represents the parsed output of `terraform plan`.
8
+ #
9
+ # This allows us to easily inspect the plan and present a more readable
10
+ # explanation of the plan to the user.
11
+ ########################################################################
12
+ class TerraformLandscape::TerraformPlan
13
+ GRAMMAR_FILE = File.expand_path(File.join(File.dirname(__FILE__),
14
+ '..', '..', 'grammar',
15
+ 'terraform_plan.treetop'))
16
+
17
+ CHANGE_SYMBOL_TO_COLOR = {
18
+ :~ => :yellow,
19
+ :- => :red,
20
+ :+ => :green
21
+ }.freeze
22
+
23
+ DEFAULT_DIFF_CONTEXT_LINES = 5
24
+
25
+ class ParseError < StandardError; end
26
+
27
+ class << self
28
+ def from_output(string)
29
+ return new([]) if string.strip.empty?
30
+ tree = parser.parse(string)
31
+ raise ParseError, parser.failure_reason unless tree
32
+ new(tree.to_ast)
33
+ end
34
+
35
+ private
36
+
37
+ def parser
38
+ @parser ||=
39
+ begin
40
+ Treetop.load(GRAMMAR_FILE)
41
+ TerraformPlanParser.new
42
+ end
43
+ end
44
+ end
45
+
46
+ # Create a plan from an abstract syntax tree (AST).
47
+ def initialize(plan_ast, options = {})
48
+ @ast = plan_ast
49
+ @diff_context_lines = options.fetch(:diff_context_lines, DEFAULT_DIFF_CONTEXT_LINES)
50
+ end
51
+
52
+ def display(output)
53
+ @out = output
54
+ @ast.each do |resource|
55
+ display_resource(resource)
56
+ @out.newline
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def display_resource(resource)
63
+ change_color = CHANGE_SYMBOL_TO_COLOR[resource[:change]]
64
+
65
+ @out.puts "#{resource[:change]} #{resource[:resource_type]}." \
66
+ "#{resource[:resource_name]}".colorize(change_color)
67
+
68
+ # Determine longest attribute name so we align all values at same indentation
69
+ attribute_value_indent_amount = attribute_indent_amount_for_resource(resource)
70
+
71
+ resource[:attributes].each do |attribute_name, attribute_value|
72
+ display_attribute(resource,
73
+ change_color,
74
+ attribute_name,
75
+ attribute_value,
76
+ attribute_value_indent_amount)
77
+ end
78
+ end
79
+
80
+ def attribute_indent_amount_for_resource(resource)
81
+ longest_name_length = resource[:attributes].keys.reduce(0) do |longest, name|
82
+ name.length > longest ? name.length : longest
83
+ end
84
+ longest_name_length + 8
85
+ end
86
+
87
+ def json?(value)
88
+ ['{', '['].include?(value.to_s[0]) &&
89
+ (JSON.parse(value) rescue nil) # rubocop:disable Style/RescueModifier
90
+ end
91
+
92
+ def to_pretty_json(value)
93
+ JSON.pretty_generate(JSON.parse(value),
94
+ {
95
+ indent: ' ',
96
+ space: ' ',
97
+ object_nl: "\n",
98
+ array_nl: "\n"
99
+ })
100
+ end
101
+
102
+ def display_diff(old, new, indent)
103
+ @out.print Diffy::Diff.new(old, new, { context: @diff_context_lines })
104
+ .to_s(String.disable_colorization ? :text : :color)
105
+ .gsub("\n", "\n" + indent)
106
+ .strip
107
+ end
108
+
109
+ def display_attribute(
110
+ resource,
111
+ change_color,
112
+ attribute_name,
113
+ attribute_value,
114
+ attribute_value_indent_amount
115
+ )
116
+ attribute_value_indent = ' ' * attribute_value_indent_amount
117
+
118
+ if resource[:change] == :~
119
+ display_modified_attribute(change_color,
120
+ attribute_name,
121
+ attribute_value,
122
+ attribute_value_indent,
123
+ attribute_value_indent_amount)
124
+ else
125
+ display_added_or_removed_attribute(change_color,
126
+ attribute_name,
127
+ attribute_value,
128
+ attribute_value_indent,
129
+ attribute_value_indent_amount)
130
+ end
131
+ end
132
+
133
+ def display_modified_attribute( # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
134
+ change_color,
135
+ attribute_name,
136
+ attribute_value,
137
+ attribute_value_indent,
138
+ attribute_value_indent_amount
139
+ )
140
+ # Since the attribute line is always of the form
141
+ # "old value" => "new value", we can add curly braces and parse with
142
+ # `eval` to obtain a hash with a single key/value.
143
+ old, new = eval("{#{attribute_value}}").to_a.first # rubocop:disable Lint/Eval
144
+
145
+ return if old == new # Don't show unchanged attributes
146
+
147
+ @out.print " #{attribute_name}:".ljust(attribute_value_indent_amount, ' ')
148
+ .colorize(change_color)
149
+
150
+ if json?(new)
151
+ # Value looks like JSON, so prettify it to make it more readable
152
+ fancy_old = "#{to_pretty_json(old)}\n"
153
+ fancy_new = "#{to_pretty_json(new)}\n"
154
+ display_diff(fancy_old, fancy_new, attribute_value_indent)
155
+ elsif old.include?("\n") || new.include?("\n")
156
+ # Multiline content, so display nicer diff
157
+ display_diff("#{old}\n", "#{new}\n", attribute_value_indent)
158
+ else
159
+ # Typical values, so just show before/after
160
+ @out.print '"' + old.colorize(:red) + '"'
161
+ @out.print ' => '.colorize(:light_black)
162
+ @out.print '"' + new.colorize(:green) + '"'
163
+ end
164
+
165
+ @out.newline
166
+ end
167
+
168
+ def display_added_or_removed_attribute(
169
+ change_color,
170
+ attribute_name,
171
+ attribute_value,
172
+ attribute_value_indent,
173
+ attribute_value_indent_amount
174
+ )
175
+ @out.print " #{attribute_name}:".ljust(attribute_value_indent_amount, ' ')
176
+ .colorize(change_color)
177
+
178
+ evaluated_string = eval(attribute_value) # rubocop:disable Lint/Eval
179
+ if json?(evaluated_string)
180
+ @out.print to_pretty_json(evaluated_string).gsub("\n", "\n" + attribute_value_indent)
181
+ .colorize(change_color)
182
+ else
183
+ @out.print "\"#{evaluated_string.colorize(change_color)}\""
184
+ end
185
+
186
+ @out.newline
187
+ end
188
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Defines the gem version.
4
+ module TerraformLandscape
5
+ VERSION = '0.0.1'.freeze
6
+ end
metadata ADDED
@@ -0,0 +1,113 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: terraform_landscape
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Coinbase
8
+ - Shane da Silva
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2017-02-28 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: colorize
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - "~>"
19
+ - !ruby/object:Gem::Version
20
+ version: '0.7'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - "~>"
26
+ - !ruby/object:Gem::Version
27
+ version: '0.7'
28
+ - !ruby/object:Gem::Dependency
29
+ name: commander
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - "~>"
33
+ - !ruby/object:Gem::Version
34
+ version: '4.4'
35
+ type: :runtime
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - "~>"
40
+ - !ruby/object:Gem::Version
41
+ version: '4.4'
42
+ - !ruby/object:Gem::Dependency
43
+ name: diffy
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - "~>"
47
+ - !ruby/object:Gem::Version
48
+ version: '3.0'
49
+ type: :runtime
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - "~>"
54
+ - !ruby/object:Gem::Version
55
+ version: '3.0'
56
+ - !ruby/object:Gem::Dependency
57
+ name: treetop
58
+ requirement: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - "~>"
61
+ - !ruby/object:Gem::Version
62
+ version: '1.6'
63
+ type: :runtime
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - "~>"
68
+ - !ruby/object:Gem::Version
69
+ version: '1.6'
70
+ description: Improve output of Terraform plans with color and indentation
71
+ email:
72
+ - shane@coinbase.com
73
+ executables:
74
+ - landscape
75
+ extensions: []
76
+ extra_rdoc_files: []
77
+ files:
78
+ - bin/landscape
79
+ - grammar/terraform_plan.treetop
80
+ - lib/terraform_landscape.rb
81
+ - lib/terraform_landscape/arguments_parser.rb
82
+ - lib/terraform_landscape/cli.rb
83
+ - lib/terraform_landscape/constants.rb
84
+ - lib/terraform_landscape/errors.rb
85
+ - lib/terraform_landscape/output.rb
86
+ - lib/terraform_landscape/printer.rb
87
+ - lib/terraform_landscape/terraform_plan.rb
88
+ - lib/terraform_landscape/version.rb
89
+ homepage: https://github.com/coinbase/terraform_landscape
90
+ licenses:
91
+ - Apache 2.0
92
+ metadata: {}
93
+ post_install_message:
94
+ rdoc_options: []
95
+ require_paths:
96
+ - lib
97
+ required_ruby_version: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: '2'
102
+ required_rubygems_version: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: '0'
107
+ requirements: []
108
+ rubyforge_project:
109
+ rubygems_version: 2.6.10
110
+ signing_key:
111
+ specification_version: 4
112
+ summary: Pretty-print Terraform plan output
113
+ test_files: []