stacker-yaml 0.1.0

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.
@@ -0,0 +1,20 @@
1
+ module Stacker
2
+ module Resolvers
3
+
4
+ class Resolver
5
+
6
+ attr_reader :ref, :region
7
+
8
+ def initialize ref, region
9
+ @ref = ref
10
+ @region = region
11
+ end
12
+
13
+ def resolve
14
+ raise NotImplementedError
15
+ end
16
+
17
+ end
18
+
19
+ end
20
+ end
@@ -0,0 +1,17 @@
1
+ require 'stacker/resolvers/resolver'
2
+
3
+ module Stacker
4
+ module Resolvers
5
+
6
+ class StackOutputResolver < Resolver
7
+
8
+ def resolve
9
+ prefix = region.options.fetch(:stack_prefix, '')
10
+ stack = region.stack "#{prefix}#{ref.fetch('Stack')}"
11
+ stack.outputs.fetch ref.fetch('Output')
12
+ end
13
+
14
+ end
15
+
16
+ end
17
+ end
@@ -0,0 +1,275 @@
1
+ require 'active_support/core_ext/hash/keys'
2
+ require 'active_support/core_ext/hash/slice'
3
+ require 'active_support/core_ext/module/delegation'
4
+ require 'aws-sdk'
5
+ require 'memoist'
6
+ require 'securerandom'
7
+
8
+ require 'stacker/stack/errors'
9
+ require 'stacker/stack/capabilities'
10
+ require 'stacker/stack/parameters'
11
+ require 'stacker/stack/template'
12
+
13
+ module Stacker
14
+ class Stack
15
+
16
+ extend Memoist
17
+
18
+ CLIENT_METHODS = %w[
19
+ creation_time
20
+ description
21
+ last_updated_time
22
+ status_reason
23
+ ]
24
+
25
+ SAFE_UPDATE_POLICY = <<-JSON
26
+ {
27
+ "Statement" : [
28
+ {
29
+ "Effect" : "Deny",
30
+ "Action" : ["Update:Replace", "Update:Delete"],
31
+ "Principal" : "*",
32
+ "Resource" : "*"
33
+ },
34
+ {
35
+ "Effect" : "Allow",
36
+ "Action" : "Update:*",
37
+ "Principal" : "*",
38
+ "Resource" : "*"
39
+ }
40
+ ]
41
+ }
42
+ JSON
43
+
44
+ STATUS_COMPLETE_REGEX = /(ROLLBACK|CREATE|UPDATE)_(COMPLETE|FAILED)/
45
+
46
+ attr_reader :region, :name, :options
47
+
48
+ def initialize region, name, options = {}
49
+ @region, @name, @options = region, name, options
50
+ end
51
+
52
+ def client
53
+ res = region.client.describe_stacks(stack_name: name)
54
+ res.stacks.first
55
+ rescue Aws::CloudFormation::Errors::ValidationError
56
+ nil
57
+ end
58
+
59
+ def exists?
60
+ !!client
61
+ end
62
+
63
+ def status
64
+ if client
65
+ client.stack_status
66
+ else
67
+ "#{name}:\nStack with id #{name} does not exist"
68
+ end
69
+ end
70
+
71
+ delegate *CLIENT_METHODS, to: :client
72
+ memoize *CLIENT_METHODS
73
+
74
+ %w[complete failed in_progress].each do |stage|
75
+ define_method(:"#{stage}?") { status =~ /#{stage.upcase}/ }
76
+ end
77
+
78
+ def template
79
+ @template ||= Template.new self
80
+ end
81
+
82
+ def parameters
83
+ @parameters ||= Parameters.new self
84
+ end
85
+
86
+ def capabilities
87
+ @capabilities ||= Capabilities.new self
88
+ end
89
+
90
+ def outputs
91
+ @outputs ||= begin
92
+ return {} unless complete?
93
+ Hash[client.outputs.map do |output|
94
+ [ output.output_key, output.output_value ]
95
+ end]
96
+ end
97
+ end
98
+
99
+ def create blocking = true
100
+ if exists?
101
+ Stacker.logger.warn 'Stack already exists'
102
+ return
103
+ end
104
+
105
+ if parameters.missing.any?
106
+ raise MissingParameters.new(
107
+ "Required parameters missing: #{parameters.missing.join ', '}"
108
+ )
109
+ end
110
+
111
+ Stacker.logger.info 'Creating stack'
112
+
113
+ params = parameters.resolved.map do |k, v|
114
+ {
115
+ parameter_key: k,
116
+ parameter_value: v
117
+ }
118
+ end
119
+
120
+ region.client.create_stack(
121
+ stack_name: name,
122
+ template_body: template.local_raw,
123
+ parameters: params,
124
+ capabilities: capabilities.local
125
+ )
126
+
127
+ wait_until_complete if blocking
128
+ rescue Aws::CloudFormation::Errors::ValidationError, ArgumentError => err
129
+ raise Error.new err.message
130
+ end
131
+
132
+ def update options = {}
133
+ options.assert_valid_keys(:blocking, :allow_destructive)
134
+
135
+ blocking = options.fetch(:blocking, true)
136
+ allow_destructive = options.fetch(:allow_destructive, false)
137
+
138
+ if parameters.missing.any?
139
+ raise MissingParameters.new(
140
+ "Required parameters missing: #{parameters.missing.join ', '}"
141
+ )
142
+ end
143
+
144
+ Stacker.logger.info 'Updating stack'
145
+
146
+ unless allow_destructive
147
+ raise StackPolicyError if describe_change_set.any? do |c|
148
+ c[:change][:replacement] == 'True' ||
149
+ c[:change][:action] =~ /remove/i
150
+ end
151
+ end
152
+
153
+ region.client.execute_change_set(
154
+ change_set_name: change_set,
155
+ stack_name: name
156
+ )
157
+
158
+ if blocking
159
+ sleep 4 # Wait a bit for the stack to begin updating
160
+ wait_until_complete
161
+ end
162
+ rescue Aws::CloudFormation::Errors::ValidationError => err
163
+ case err.message
164
+ when /does not exist/
165
+ raise DoesNotExistError.new err.message
166
+ when /No updates/
167
+ raise UpToDateError.new err.message
168
+ else
169
+ raise Error.new err.message
170
+ end
171
+ end
172
+
173
+ def describe_change_set
174
+ retries = 6
175
+ changes = []
176
+ while changes.empty?
177
+ resp = region.client.describe_change_set(
178
+ change_set_name: change_set,
179
+ stack_name: name
180
+ )
181
+ changes = resp.changes
182
+ if changes.empty?
183
+ raise CannotDescribeChangeSet.new 'Empty change set' if retries == 0
184
+ retries -= 1
185
+ sleep 1
186
+ end
187
+ end
188
+ changes.map do |c|
189
+ rc = c.resource_change
190
+ {
191
+ type: c.type,
192
+ change: {
193
+ logical_resource_id: rc.logical_resource_id,
194
+ action: rc.action,
195
+ replacement: rc.replacement,
196
+ }
197
+ }
198
+ end
199
+ end
200
+ memoize :describe_change_set
201
+
202
+ def pretty_change_set
203
+ riw = describe_change_set.map do |c|
204
+ c[:change][:logical_resource_id].length
205
+ end.max
206
+ fmt = "%-6s %-#{riw}s %-5s"
207
+
208
+ ([fmt % ['Action', 'Resource', 'Replacement?'], '='*(riw+20)] +
209
+ describe_change_set.map do |c|
210
+ change = c[:change]
211
+ fmt % [
212
+ change[:action],
213
+ change[:logical_resource_id],
214
+ change[:replacement]
215
+ ]
216
+ end).join("\n")
217
+ end
218
+
219
+ private
220
+
221
+ def change_set_name
222
+ "stacker-#{SecureRandom.hex}"
223
+ end
224
+ memoize :change_set_name
225
+
226
+ def change_set
227
+ change_set_name.tap do |csname|
228
+ region.client.create_change_set(
229
+ stack_name: name,
230
+ template_body: template.local_raw,
231
+ parameters: parameters.resolved.map do |k, v|
232
+ {
233
+ parameter_key: k,
234
+ parameter_value: v
235
+ }
236
+ end,
237
+ capabilities: capabilities.local,
238
+ change_set_name: csname
239
+ )
240
+ end
241
+ rescue Aws::CloudFormation::Errors::ValidationError => err
242
+ raise Error.new err.message
243
+ end
244
+ memoize :change_set
245
+
246
+ def report_status
247
+ case status
248
+ when /_COMPLETE$/
249
+ Stacker.logger.info "#{name} Status => #{status}"
250
+ when /_ROLLBACK_IN_PROGRESS$/
251
+ failure_event = client.events.enum(limit: 30).find do |event|
252
+ event.resource_status =~ /_FAILED$/
253
+ end
254
+ failure_reason = failure_event.resource_status_reason
255
+ if failure_reason =~ /stack policy/
256
+ raise StackPolicyError.new failure_reason
257
+ else
258
+ Stacker.logger.fatal "#{name} Status => #{status}"
259
+ raise Error.new "Failure Reason: #{failure_reason}"
260
+ end
261
+ else
262
+ Stacker.logger.debug "#{name} Status => #{status}"
263
+ end
264
+ end
265
+
266
+ def wait_until_complete
267
+ while !(status =~ STATUS_COMPLETE_REGEX)
268
+ report_status
269
+ sleep 5
270
+ end
271
+ report_status
272
+ end
273
+
274
+ end
275
+ end
@@ -0,0 +1,19 @@
1
+ require 'stacker/stack/component'
2
+
3
+ module Stacker
4
+ class Stack
5
+ class Capabilities < Component
6
+
7
+ def local
8
+ @local ||= Array(stack.options.fetch 'capabilities', [])
9
+ end
10
+
11
+ def remote
12
+ # `capabilities` actually returns a
13
+ # !ruby/array:Aws::Xml::DefaultList
14
+ @remote ||= client.capabilities.to_a
15
+ end
16
+
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,22 @@
1
+ require 'stacker/stack'
2
+
3
+ module Stacker
4
+ class Stack
5
+ # an abstract base class for stack components (template, parameters)
6
+ class Component
7
+
8
+ attr_reader :stack
9
+
10
+ def initialize stack
11
+ @stack = stack
12
+ end
13
+
14
+ private
15
+
16
+ def client
17
+ stack.client
18
+ end
19
+
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,87 @@
1
+ require 'jsonlint'
2
+ require 'yamllint'
3
+
4
+ module Stacker
5
+ class Stack
6
+
7
+ class Error < StandardError; end
8
+ class StackPolicyError < Error; end
9
+ class DoesNotExistError < Error; end
10
+ class MissingParameters < Error; end
11
+ class UpToDateError < Error; end
12
+ class CannotDescribeChangeSet < Error; end
13
+
14
+ class StackUndeclared < Error
15
+
16
+ def initialize(name)
17
+ @name = name
18
+ end
19
+
20
+ def message
21
+ "Stack with id #{@name} is not declared"
22
+ end
23
+
24
+ end
25
+
26
+ class ParameterResolutionError < Error
27
+
28
+ def initialize(value, error)
29
+ @value = value
30
+ @error = error
31
+ end
32
+
33
+ def message
34
+ "Failed to resolve reference parameter: #{@value}\nError: #{@error}"
35
+ end
36
+
37
+ end
38
+
39
+ class TemplateDoesNotExistError < Error
40
+
41
+ def initialize(name)
42
+ @name = name
43
+ end
44
+
45
+ def message
46
+ "No template found with name '#{@name}'"
47
+ end
48
+
49
+ end
50
+
51
+ class TemplateSyntaxError < Error
52
+
53
+ def initialize(path)
54
+ @path = path
55
+ end
56
+
57
+ def message
58
+ <<END_MSG
59
+ Syntax error(s) in template.
60
+ #{path}:
61
+ #{errors}
62
+ END_MSG
63
+ end
64
+
65
+ private
66
+
67
+ attr_reader :path
68
+
69
+ def errors
70
+ @errors ||= begin
71
+ linter.check path
72
+ linter.errors.values.join "\n"
73
+ end
74
+ end
75
+
76
+ def linter
77
+ linter ||= if path.end_with? '.json'
78
+ JsonLint::Linter.new
79
+ else
80
+ YamlLint::Linter.new
81
+ end
82
+ end
83
+
84
+ end
85
+
86
+ end
87
+ end
@@ -0,0 +1,102 @@
1
+ require 'stacker/resolvers/stack_output_resolver'
2
+ require 'stacker/resolvers/file_resolver'
3
+
4
+ module Stacker
5
+ class Stack
6
+
7
+ # A Parameter represents a stack parameter. A parameter can be
8
+ # either a literal value (e.g. a string, number, or array) or a
9
+ # reference, otherwise known as a dependency. References parameter
10
+ # values are represented as hashes with a single key indicating
11
+ # the reference type. There is one exception to this rule;
12
+ # references to stack outputs are expressed as hashes with two
13
+ # keys, "Stack" and "Output".
14
+ class Parameter
15
+
16
+ extend Memoist
17
+
18
+ attr_reader :value, :region
19
+
20
+ def initialize value, region
21
+ @region = region
22
+ @value = if value.is_a?(Array)
23
+ value.map { |v| Parameter.new v, region }
24
+ else
25
+ value
26
+ end
27
+ end
28
+
29
+ def dependency?
30
+ value.is_a?(Hash)
31
+ end
32
+
33
+ def dependencies
34
+ if dependency?
35
+ [ to_s ]
36
+ elsif value.is_a?(Array)
37
+ value.map(&:dependencies).flatten
38
+ else
39
+ [ ]
40
+ end
41
+ end
42
+
43
+ def resolved
44
+ if dependency?
45
+ begin
46
+ resolver.resolve
47
+ rescue => err
48
+ raise ParameterResolutionError.new value, err
49
+ end
50
+ elsif value.is_a?(Array)
51
+ value.map(&:resolved).join ','
52
+ else
53
+ value
54
+ end
55
+ end
56
+ memoize :resolved
57
+
58
+ def to_s
59
+ if dependency?
60
+ value.values.map(&:to_s).sort.join('.')
61
+ else
62
+ value.to_s
63
+ end
64
+ end
65
+
66
+ def ==(parameter)
67
+ parameter.value == value && parameter.region == region
68
+ end
69
+
70
+ private
71
+
72
+ def stack_output?
73
+ dependency? && value['Stack'] && value['Output']
74
+ end
75
+
76
+ def resolver_class_name
77
+ type = if stack_output?
78
+ 'StackOutput'
79
+ elsif value.keys.size == 1
80
+ value.keys.first
81
+ else
82
+ raise ReferenceError.new 'Too many top-level keys in reference value.'
83
+ end
84
+
85
+ "Stacker::Resolvers::#{type}Resolver"
86
+ end
87
+
88
+ def reference_value
89
+ if stack_output?
90
+ value
91
+ else
92
+ value.values.first
93
+ end
94
+ end
95
+
96
+ def resolver
97
+ resolver_class_name.constantize.new reference_value, region
98
+ end
99
+
100
+ end
101
+ end
102
+ end