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.
- checksums.yaml +4 -4
- data/README.md +6 -1
- data/Rakefile +1 -1
- data/lib/cfn-flow.rb +131 -1
- data/lib/cfn-flow/cli.rb +154 -98
- data/lib/cfn-flow/event_presenter.rb +39 -0
- data/lib/cfn-flow/git.rb +1 -0
- data/lib/cfn-flow/template.rb +64 -50
- data/lib/cfn-flow/version.rb +3 -0
- data/spec/cfn-flow/cli_spec.rb +372 -0
- data/spec/cfn-flow/event_presenter_spec.rb +58 -0
- data/spec/cfn-flow/template_spec.rb +140 -0
- data/spec/cfn-flow_spec.rb +179 -0
- data/spec/data/cfn-flow.yml +12 -4
- data/spec/data/invalid.json +1 -0
- data/spec/data/invalid.yml +3 -0
- data/spec/helper.rb +48 -15
- metadata +19 -23
- data/spec/cfn-flow_cli_spec.rb +0 -30
- data/spec/cfn-flow_template_spec.rb +0 -56
data/lib/cfn-flow/git.rb
CHANGED
data/lib/cfn-flow/template.rb
CHANGED
@@ -1,64 +1,78 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
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
|
-
|
11
|
-
|
12
|
-
|
12
|
+
def yaml?
|
13
|
+
local_path.end_with?('.yml')
|
14
|
+
end
|
13
15
|
|
14
|
-
|
15
|
-
|
16
|
-
|
16
|
+
def json?
|
17
|
+
! yaml?
|
18
|
+
end
|
17
19
|
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
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
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
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
|
-
|
30
|
-
#
|
31
|
-
|
32
|
-
|
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
|
-
|
35
|
-
|
36
|
-
|
39
|
+
def s3_object(release)
|
40
|
+
Aws::S3::Object.new(bucket, key(release))
|
41
|
+
end
|
37
42
|
|
38
|
-
|
39
|
-
|
40
|
-
|
43
|
+
def url(release)
|
44
|
+
s3_object(release).public_url
|
45
|
+
end
|
41
46
|
|
42
|
-
|
43
|
-
|
44
|
-
|
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
|
-
|
52
|
-
|
53
|
-
|
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
|
-
|
56
|
-
|
57
|
-
|
58
|
-
end
|
61
|
+
def to_json
|
62
|
+
@to_json ||= MultiJson.dump(local_data, pretty: true)
|
63
|
+
end
|
59
64
|
|
60
|
-
|
61
|
-
|
62
|
-
|
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,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
|