cfn-flow 0.2.1 → 0.5.0

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