cfn-flow 0.2.1 → 0.5.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.
data/lib/cfn-flow/git.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  # Git helper module
2
+ # TODO: extract as plugin
2
3
  class CfnFlow::Git
3
4
  class << self
4
5
 
@@ -1,64 +1,78 @@
1
- class CfnFlow::Template
2
- attr_reader :from, :prefix, :bucket
3
- def initialize(opts={})
4
- unless [:from, :prefix, :bucket].all? {|arg| opts.key?(arg) }
5
- raise ArgumentError.new("Must pass :from, :prefix, and :bucket")
1
+ module CfnFlow
2
+ class Template
3
+
4
+ # Tag for JSON/YAML loading errors
5
+ module Error; end
6
+
7
+ attr_reader :local_path
8
+ def initialize(local_path)
9
+ @local_path = local_path
6
10
  end
7
- @from, @prefix, @bucket = opts[:from], opts[:prefix], opts[:bucket]
8
- end
9
11
 
10
- def yaml?
11
- from.end_with?('.yml')
12
- end
12
+ def yaml?
13
+ local_path.end_with?('.yml')
14
+ end
13
15
 
14
- def json?
15
- ! yaml?
16
- end
16
+ def json?
17
+ ! yaml?
18
+ end
17
19
 
18
- # Determine if this file is a CFN template
19
- def is_cfn_template?
20
- from_data.is_a?(Hash) && from_data.key?('Resources')
21
- end
20
+ # Determine if this file is a CFN template
21
+ def is_cfn_template?
22
+ local_data.is_a?(Hash) && local_data.key?('Resources')
23
+ end
22
24
 
23
- # Returns a response object if valid, or raises an
24
- # Aws::CloudFormation::Errors::ValidationError with an error message
25
- def validate!
26
- cfn.validate_template(template_body: to_json)
27
- end
25
+ # Returns a response object if valid, or raises an
26
+ # Aws::CloudFormation::Errors::ValidationError with an error message
27
+ def validate!
28
+ cfn.validate_template(template_body: to_json)
29
+ end
28
30
 
29
- def key
30
- # Replace leading './' in from
31
- File.join(prefix, from.sub(/\A\.\//, ''))
32
- end
31
+ ##
32
+ # S3 methods
33
+ def key(release)
34
+ # Replace leading './' in local_path
35
+ clean_path = local_path.sub(/\A\.\//, '')
36
+ File.join(*[s3_prefix, release, clean_path].compact)
37
+ end
33
38
 
34
- def upload!
35
- s3_object.put(body: to_json)
36
- end
39
+ def s3_object(release)
40
+ Aws::S3::Object.new(bucket, key(release))
41
+ end
37
42
 
38
- def url
39
- s3_object.public_url
40
- end
43
+ def url(release)
44
+ s3_object(release).public_url
45
+ end
41
46
 
42
- def from_data
43
- # We *could* load JSON as YAML, but that would generate confusing errors
44
- # in the case of a JSON syntax error.
45
- @from_data ||= yaml? ? YAML.load_file(from) : MultiJson.load(File.read(from))
46
- rescue
47
- puts "Error loading #{from}"
48
- raise $!
49
- end
47
+ def upload(release)
48
+ s3_object(release).put(body: to_json)
49
+ end
50
50
 
51
- def to_json
52
- @to_json ||= MultiJson.dump(from_data, pretty: true)
53
- end
51
+ def local_data
52
+ # We *could* load JSON as YAML, but that would generate confusing errors
53
+ # in the case of a JSON syntax error.
54
+ @local_data ||= yaml? ? YAML.load_file(local_path) : MultiJson.load(File.read(local_path))
55
+ rescue Exception => error
56
+ # Tag & re-raise any error
57
+ error.extend(CfnFlow::Template::Error)
58
+ raise error
59
+ end
54
60
 
55
- private
56
- def cfn
57
- Aws::CloudFormation::Client.new
58
- end
61
+ def to_json
62
+ @to_json ||= MultiJson.dump(local_data, pretty: true)
63
+ end
59
64
 
60
- def s3_object
61
- Aws::S3::Object.new(bucket, key)
62
- end
65
+ def bucket
66
+ CfnFlow.template_s3_bucket
67
+ end
63
68
 
69
+ def s3_prefix
70
+ CfnFlow.template_s3_prefix
71
+ end
72
+
73
+ private
74
+ def cfn
75
+ CfnFlow.cfn_client
76
+ end
77
+ end
64
78
  end
@@ -0,0 +1,3 @@
1
+ module CfnFlow
2
+ VERSION = '0.5.0'
3
+ end
@@ -0,0 +1,372 @@
1
+ require_relative '../helper'
2
+
3
+ describe 'CfnFlow::CLI' do
4
+ let(:cli) { CfnFlow::CLI }
5
+ let(:template) { 'spec/data/sqs.yml' }
6
+
7
+ before do
8
+ ENV.update({
9
+ 'CFN_FLOW_BUCKET' => 'test-bucket',
10
+ 'CFN_FLOW_FROM' => 'spec/data',
11
+ 'CFN_FLOW_TO' => 'test'
12
+ })
13
+ end
14
+
15
+ describe '#validate' do
16
+ it 'succeeds' do
17
+ out, err = capture_io { cli.start [:validate, template] }
18
+ err.must_be :empty?
19
+ out.must_match "Validating #{template}... valid."
20
+ end
21
+
22
+ it 'can have multiple templates' do
23
+ out, _ = capture_io { cli.start [:validate, template, 'spec/data/sqs.template'] }
24
+ out.split("\n").size.must_equal 2
25
+ end
26
+
27
+ it 'can fail with malformed templates' do
28
+ _, err = capture_io { cli.start [:validate, 'no/such/template'] }
29
+ err.must_match 'Error loading template'
30
+ err.must_match 'Errno::ENOENT'
31
+ end
32
+
33
+ it 'can fail with validation error' do
34
+ Aws.config[:cloudformation] = {stub_responses: {validate_template: 'ValidationError'}}
35
+ _, err = capture_io { cli.start [:validate, template] }
36
+ err.must_match "Invalid template"
37
+ end
38
+
39
+ it 'fails when no templates are passed' do
40
+ out, err = capture_io { cli.start [:validate] }
41
+ out.must_equal ''
42
+ err.must_match 'You must specify a template to validate'
43
+ end
44
+ end
45
+
46
+ describe '#publish' do
47
+ it 'succeeds' do
48
+ out, err = capture_io { cli.start [:publish, template] }
49
+ err.must_equal ''
50
+ out.must_match "Validating #{template}... valid."
51
+ out.must_match "Publishing #{template}"
52
+ end
53
+
54
+ it 'can have multiple templates' do
55
+ out, _ = capture_io { cli.start [:publish, template, 'spec/data/sqs.template'] }
56
+ # 2 lines for validating, 2 for publishing
57
+ out.split("\n").size.must_equal 4
58
+ end
59
+
60
+ it 'uses the dev-name' do
61
+ out, _ = capture_io { cli.start [:publish, template] }
62
+ out.must_match("dev/#{ENV['CFN_FLOW_DEV_NAME']}")
63
+ end
64
+
65
+ it 'can take a dev-name argument' do
66
+ name = 'a-new-dev-name'
67
+ out, _ = capture_io { cli.start [:publish, template, '--dev-name', name] }
68
+ out.must_match("dev/#{name}")
69
+ end
70
+
71
+ it 'can take a release argument' do
72
+ release = 'v2.0'
73
+ out, _ = capture_io { cli.start [:publish, template, '--release', release] }
74
+ out.must_match CfnFlow::Template.new(template).url("release/#{release}")
75
+ end
76
+
77
+ it 'can fail with malformed templates' do
78
+ _, err = capture_io { cli.start [:publish, 'no/such/template'] }
79
+ err.must_match 'Error loading template'
80
+ err.must_match 'Errno::ENOENT'
81
+ end
82
+
83
+ it 'can fail with validation error' do
84
+ Aws.config[:cloudformation] = {stub_responses: {validate_template: 'ValidationError'}}
85
+ _, err = capture_io { cli.start [:publish, template] }
86
+ err.must_match "Invalid template"
87
+ end
88
+
89
+ it 'fails when no templates are passed' do
90
+ out, err = capture_io { cli.start [:publish] }
91
+ out.must_equal ''
92
+ err.must_match 'You must specify a template to publish'
93
+ end
94
+
95
+ it 'fails with no release' do
96
+ ENV.delete('CFN_FLOW_DEV_NAME')
97
+ _, err = capture_io { cli.start [:publish, template] }
98
+ err.must_match 'Must specify --release or --dev-name'
99
+ end
100
+ end
101
+
102
+ describe '#deploy' do
103
+
104
+ it 'succeeds' do
105
+ Aws.config[:cloudformation]= {
106
+ stub_responses: {
107
+ describe_stacks: { stacks: [ stub_stack_data(stack_name: 'cfn-flow-spec-stack') ] },
108
+ describe_stack_events: { stack_events: [ stub_event_data ] },
109
+ }
110
+ }
111
+ out, err = capture_io { cli.start [:deploy, 'test-env'] }
112
+
113
+ out.must_match "Launching stack #{CfnFlow.config['stack']['stack_name']}"
114
+ out.must_match "Polling for events..."
115
+ out.must_match "CREATE_COMPLETE"
116
+ out.wont_match 'Finding stacks to cleanup'
117
+ err.must_equal ''
118
+ end
119
+
120
+ it 'can fail with a validation error' do
121
+ Aws.config[:cloudformation]= {
122
+ stub_responses: { create_stack: 'ValidationError' }
123
+ }
124
+
125
+ out, err = capture_io { cli.start [:deploy, 'test-env'] }
126
+ out.must_equal ''
127
+ err.must_match 'error'
128
+
129
+ end
130
+
131
+ it 'can cleanup' do
132
+
133
+ # Stubbing hacks alert!
134
+ # The first time we call :describe_stacks, return the stack we launch.
135
+ # The second time, we're loading 'another-stack' to clean it up
136
+ stack_stubs = [
137
+ { stacks: [ stub_stack_data(stack_name: 'cfn-flow-spec-stack') ] },
138
+ { stacks: [ stub_stack_data(stack_name: 'another-stack') ] }
139
+ ]
140
+ Aws.config[:cloudformation]= {
141
+ stub_responses: {
142
+ describe_stacks: stack_stubs,
143
+ describe_stack_events: { stack_events: [ stub_event_data ] },
144
+ }
145
+ }
146
+
147
+ Thor::LineEditor.stub :readline, "yes" do
148
+ out, err = capture_io { cli.start [:deploy, 'production', '--cleanup'] }
149
+ out.must_match 'Finding stacks to clean up'
150
+ out.must_match 'Deleted stack another-stack'
151
+ err.must_equal ''
152
+ end
153
+ end
154
+
155
+ end
156
+
157
+ describe '#list' do
158
+ it 'has no output with no stacks' do
159
+ out, err = capture_io { cli.start [:list] }
160
+ out.must_equal ''
161
+ err.must_equal ''
162
+ end
163
+
164
+ describe 'with one stack' do
165
+ before do
166
+ Aws.config[:cloudformation]= {
167
+ stub_responses: {
168
+ describe_stacks: { stacks: [ stub_stack_data ] }
169
+ }
170
+ }
171
+ end
172
+ it 'should print the stack' do
173
+ out, err = capture_io { cli.start [:list] }
174
+ out.must_match(/mystack\s+production\s+CREATE_COMPLETE/)
175
+ err.must_equal ''
176
+ end
177
+
178
+ it 'should print the header' do
179
+ out, _ = capture_io { cli.start [:list] }
180
+ out.must_match(/NAME\s+ENVIRONMENT\s+STATUS/)
181
+ end
182
+
183
+ it 'should print stacks when passed an environment' do
184
+ out, _ = capture_io { cli.start [:list, 'production'] }
185
+ out.must_match 'mystack'
186
+
187
+ out, _ = capture_io { cli.start [:list, 'none-such-env'] }
188
+ out.must_equal ''
189
+ end
190
+
191
+ it 'should not print the header with option[no-header]' do
192
+ out, _ = capture_io { cli.start [:list, '--no-header'] }
193
+ out.wont_match(/NAME\s+ENVIRONMENT\s+STATUS/)
194
+ end
195
+ end
196
+
197
+ describe 'with stacks in a different service' do
198
+ before do
199
+ Aws.config[:cloudformation]= {
200
+ stub_responses: {
201
+ describe_stacks: {
202
+ stacks: [
203
+ { stack_name: "mystack",
204
+ stack_status: 'CREATE_COMPLETE',
205
+ creation_time: Time.now,
206
+ tags: [
207
+ {key: 'CfnFlowService', value: 'none-such-service'},
208
+ {key: 'CfnFlowEnvironment', value: 'production'}
209
+ ]
210
+ }
211
+ ]
212
+ }
213
+ }
214
+ }
215
+ end
216
+
217
+ it 'has no output' do
218
+ out, _ = capture_io { cli.start [:list] }
219
+ out.must_equal ''
220
+ end
221
+ end
222
+ end
223
+
224
+ describe '#show' do
225
+ describe 'with a stack' do
226
+ before do
227
+ Aws.config[:cloudformation]= {
228
+ stub_responses: {
229
+ describe_stacks: { stacks: [ stub_stack_data ] }
230
+ }
231
+ }
232
+ end
233
+
234
+ it 'should print in yaml' do
235
+ out, err = capture_io { cli.start [:show, 'mystack'] }
236
+ expected = CfnFlow.cfn_resource.stack('mystack').data.to_hash.to_yaml
237
+ out.must_equal expected
238
+ err.must_equal ''
239
+ end
240
+
241
+ it 'handles --json option' do
242
+ out, _ = capture_io { cli.start [:show, 'mystack', '--json'] }
243
+ expected = MultiJson.dump(CfnFlow.cfn_resource.stack('mystack').data.to_hash, pretty: true) + "\n"
244
+ out.must_equal expected
245
+ end
246
+ end
247
+
248
+ it 'returns an error with missing stacks' do
249
+ Aws.config[:cloudformation]= {
250
+ stub_responses: { describe_stacks: 'ValidationError' }
251
+ }
252
+ out, err = capture_io { cli.start [:show, 'none-such-stack'] }
253
+ out.must_equal ''
254
+ err.must_match 'error'
255
+ end
256
+
257
+ it 'returns an error when stack is not in service' do
258
+ stack_data = stub_stack_data
259
+ stack_data[:tags][0][:value] = 'none-such-service'
260
+ Aws.config[:cloudformation]= {
261
+ stub_responses: {
262
+ describe_stacks: { stacks: [ stack_data ] }
263
+ }
264
+ }
265
+ out, err = capture_io { cli.start [:show, 'none-such-stack'] }
266
+ out.must_equal ''
267
+ err.must_match "not tagged for service #{CfnFlow.service}"
268
+ end
269
+ end
270
+
271
+ describe '#events' do
272
+ before do
273
+ Aws.config[:cloudformation] = {
274
+ stub_responses: {
275
+ describe_stack_events: { stack_events: [ stub_event_data ] },
276
+ describe_stacks: { stacks: [ stub_stack_data ] }
277
+ }
278
+ }
279
+ end
280
+
281
+ it 'should show the header by default' do
282
+ out, _ = capture_io { cli.start [:events, 'mystack'] }
283
+ out.must_match CfnFlow::EventPresenter.header
284
+ end
285
+
286
+ it 'can omit header' do
287
+ out, _ = capture_io { cli.start [:events, '--no-headers', 'mystack'] }
288
+ out.wont_match CfnFlow::EventPresenter.header
289
+ end
290
+
291
+ it 'should show an event' do
292
+ out, err = capture_io { cli.start [:events, 'mystack'] }
293
+
294
+ out.must_match CfnFlow::EventPresenter.new(stub_event).to_s
295
+ err.must_equal ''
296
+ end
297
+
298
+ describe 'with polling' do
299
+ before do
300
+ Aws.config[:cloudformation] = {
301
+ stub_responses: {
302
+ describe_stack_events: [
303
+ { stack_events: [ stub_event_data(resource_status: 'CREATE_IN_PROGRESS') ] },
304
+ { stack_events: [ stub_event_data(resource_status: 'CREATE_COMPLETE') ] }
305
+ ],
306
+ describe_stacks: [
307
+ { stacks: [ stub_stack_data(stack_status: 'CREATE_IN_PROGRESS') ] },
308
+ { stacks: [ stub_stack_data(stack_status: 'CREATE_COMPLETE') ] },
309
+ ]
310
+ }
311
+ }
312
+ end
313
+
314
+ it 'should not poll by default' do
315
+ out, _ = capture_io { cli.start [:events, '--no-header', 'mystack'] }
316
+ out.must_match 'CREATE_IN_PROGRESS'
317
+ out.wont_match 'CREATE_COMPLETE'
318
+ end
319
+
320
+ it 'will poll until complete' do
321
+ out, _ = capture_io {
322
+ cli.start [:events, '--no-header', '--poll', 'mystack']
323
+ }
324
+ out.must_match 'CREATE_IN_PROGRESS'
325
+ out.must_match 'CREATE_COMPLETE'
326
+ end
327
+ end
328
+
329
+ end
330
+
331
+ describe '#delete' do
332
+ describe 'with a stack' do
333
+ before do
334
+ Aws.config[:cloudformation] = {
335
+ stub_responses: { describe_stacks: { stacks: [ stub_stack_data ] } }
336
+ }
337
+ end
338
+
339
+ it 'deletes the stack' do
340
+ Thor::LineEditor.stub :readline, "yes" do
341
+ out, err = capture_io { cli.start [:delete, 'mystack'] }
342
+ out.must_equal "Deleted stack mystack\n"
343
+ err.must_equal ''
344
+ end
345
+ end
346
+
347
+ it 'does not delete the stack if you say no' do
348
+ Thor::LineEditor.stub :readline, "no" do
349
+ out, err = capture_io { cli.start [:delete, 'mystack'] }
350
+ out.must_equal ''
351
+ err.must_equal ''
352
+ end
353
+ end
354
+
355
+ it 'does not ask when --force is set' do
356
+ out, err = capture_io { cli.start [:delete, '--force', 'mystack'] }
357
+ out.must_equal "Deleted stack mystack\n"
358
+ err.must_equal ''
359
+ end
360
+ end
361
+
362
+ it 'returns an error for a stack in another service' do
363
+ Aws.config[:cloudformation] = {
364
+ stub_responses: { describe_stacks: { stacks: [ stub_stack_data(tags: []) ] } }
365
+ }
366
+ out, err = capture_io { cli.start [:delete, 'wrong-stack'] }
367
+ out.must_equal ''
368
+ err.must_match 'Stack wrong-stack is not tagged for service'
369
+ end
370
+ end
371
+
372
+ end