stacker-yaml 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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