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.
- 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
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a8a7c12f98182c07a7961ddca970637d6454275a
|
4
|
+
data.tar.gz: 4af32ae281b4a00f55eb9a7263d56eb40fefb678
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 012e7d1fd2c3d517ef83725ef4a29ed4165669cf235a744be0481ac09cb18479b5a5e77b350b76e810b6e2eb52daa2264c0999f784bd57dcb0a2d2b59d1fb0a6
|
7
|
+
data.tar.gz: 1f6bfd0cb3466afa28c054dcd7c03a3dae04bc729578fb475af346f18ecb699dbf258ec306caf8c657088b09c48a3db1dcc02edd400f910d896c8a19a1749106
|
data/README.md
CHANGED
@@ -1,5 +1,10 @@
|
|
1
1
|
# cfn-flow
|
2
|
-
An opinionated command-line workflow for developing AWS CloudFormation templates
|
2
|
+
An opinionated command-line workflow for developing [AWS CloudFormation](https://aws.amazon.com/cloudformation/) templates and deploying stacks.
|
3
|
+
|
4
|
+
Track template changes in git and upload versioned releases to AWS S3.
|
5
|
+
|
6
|
+
Deploy stacks using a standard, reliable process with extensible
|
7
|
+
configuration in git.
|
3
8
|
|
4
9
|
#### Opinions
|
5
10
|
|
data/Rakefile
CHANGED
data/lib/cfn-flow.rb
CHANGED
@@ -2,8 +2,138 @@ require 'thor'
|
|
2
2
|
require 'aws-sdk'
|
3
3
|
require 'multi_json'
|
4
4
|
require 'yaml'
|
5
|
+
require 'erb'
|
6
|
+
|
7
|
+
module CfnFlow
|
8
|
+
class << self
|
9
|
+
|
10
|
+
##
|
11
|
+
# Configuration
|
12
|
+
def config_path
|
13
|
+
ENV['CFN_FLOW_CONFIG_PATH'] || 'cfn-flow.yml'
|
14
|
+
end
|
15
|
+
|
16
|
+
def load_config
|
17
|
+
@config = YAML.load(
|
18
|
+
ERB.new( File.read(config_path) ).result(binding)
|
19
|
+
)
|
20
|
+
# TODO: Validate config?
|
21
|
+
end
|
22
|
+
|
23
|
+
def config_loaded?
|
24
|
+
@config.is_a? Hash
|
25
|
+
end
|
26
|
+
|
27
|
+
def config
|
28
|
+
load_config unless config_loaded?
|
29
|
+
@config
|
30
|
+
end
|
31
|
+
|
32
|
+
def service
|
33
|
+
unless config.key?('service')
|
34
|
+
raise Thor::Error.new("No service name in #{config_path}. Add 'service: my_app_name'.")
|
35
|
+
end
|
36
|
+
config['service']
|
37
|
+
end
|
38
|
+
|
39
|
+
def stack_params(environment)
|
40
|
+
unless config['stack'].is_a? Hash
|
41
|
+
raise Thor::Error.new("No stack defined in #{config_path}. Add 'stack: ...'.")
|
42
|
+
end
|
43
|
+
|
44
|
+
# Dup & symbolize keys
|
45
|
+
params = config['stack'].map{|k,v| [k.to_sym, v]}.to_h
|
46
|
+
|
47
|
+
# Expand params
|
48
|
+
if params[:parameters].is_a? Hash
|
49
|
+
expanded_params = params[:parameters].map do |key,value|
|
50
|
+
{ parameter_key: key, parameter_value: value }
|
51
|
+
end
|
52
|
+
params[:parameters] = expanded_params
|
53
|
+
end
|
54
|
+
|
55
|
+
# Expand tags
|
56
|
+
if params[:tags].is_a? Hash
|
57
|
+
tags = params[:tags].map do |key, value|
|
58
|
+
{key: key, value: value}
|
59
|
+
end
|
60
|
+
|
61
|
+
params[:tags] = tags
|
62
|
+
end
|
63
|
+
|
64
|
+
# Append CfnFlow tags
|
65
|
+
params[:tags] ||= []
|
66
|
+
params[:tags] << { key: 'CfnFlowService', value: service }
|
67
|
+
params[:tags] << { key: 'CfnFlowEnvironment', value: environment }
|
68
|
+
|
69
|
+
# Expand template body
|
70
|
+
if params[:template_body].is_a? String
|
71
|
+
begin
|
72
|
+
body = CfnFlow::Template.new(params[:template_body]).to_json
|
73
|
+
params[:template_body] = body
|
74
|
+
rescue CfnFlow::Template::Error
|
75
|
+
# Do nothing
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
params
|
80
|
+
end
|
81
|
+
|
82
|
+
def template_s3_bucket
|
83
|
+
unless config['templates'].is_a?(Hash) && config['templates']['s3_bucket']
|
84
|
+
raise Thor::Error.new("No s3_bucket defined for templates in #{config_path}. Add 'templates: { s3_bucket: ... }'.")
|
85
|
+
end
|
86
|
+
|
87
|
+
config['templates']['s3_bucket']
|
88
|
+
end
|
89
|
+
|
90
|
+
def template_s3_prefix
|
91
|
+
unless config['templates'].is_a?(Hash)
|
92
|
+
raise Thor::Error.new("No templates defined in #{config_path}. Add 'templates: ... '.")
|
93
|
+
end
|
94
|
+
|
95
|
+
# Ok for this to be ''
|
96
|
+
config['templates']['s3_prefix']
|
97
|
+
end
|
98
|
+
|
99
|
+
##
|
100
|
+
# Aws Clients
|
101
|
+
def cfn_client
|
102
|
+
@cfn_client ||= Aws::CloudFormation::Client.new(region: config[:region] || ENV['AWS_REGION'])
|
103
|
+
end
|
104
|
+
|
105
|
+
def cfn_resource
|
106
|
+
# NB: increase default retry limit to avoid throttling errors iterating over stacks.
|
107
|
+
# See https://github.com/aws/aws-sdk-ruby/issues/705
|
108
|
+
@cfn_resource ||= Aws::CloudFormation::Resource.new(
|
109
|
+
region: config[:region] || ENV['AWS_REGION'],
|
110
|
+
retry_limit: 10
|
111
|
+
)
|
112
|
+
end
|
113
|
+
|
114
|
+
# Clear aws sdk clients & config (for tests)
|
115
|
+
def clear!
|
116
|
+
@config = @cfn_client = @cfn_resource = nil
|
117
|
+
end
|
118
|
+
|
119
|
+
# Exit with status code = 1 when raising a Thor::Error
|
120
|
+
# Override thor default
|
121
|
+
def exit_on_failure?
|
122
|
+
if instance_variable_defined?(:@exit_on_failure)
|
123
|
+
@exit_on_failure
|
124
|
+
else
|
125
|
+
true
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
def exit_on_failure=(value)
|
130
|
+
@exit_on_failure = value
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
5
134
|
|
6
|
-
module CfnFlow; end
|
7
135
|
require 'cfn-flow/template'
|
8
136
|
require 'cfn-flow/git'
|
137
|
+
require 'cfn-flow/event_presenter'
|
9
138
|
require 'cfn-flow/cli'
|
139
|
+
require 'cfn-flow/version'
|
data/lib/cfn-flow/cli.rb
CHANGED
@@ -1,119 +1,175 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
def self.shared_options
|
4
|
-
method_option :bucket, type: :string, desc: 'S3 bucket for templates'
|
5
|
-
method_option :to, type: :string, desc: 'S3 path prefix for templates'
|
6
|
-
method_option :from, type: :string, desc: 'Local source directory for templates'
|
7
|
-
method_option 'dev-name', type: :string, desc: 'Personal development prefix'
|
8
|
-
method_option :region, type: :string, desc: 'AWS Region'
|
9
|
-
method_option :verbose, type: :boolean, desc: 'Verbose output', default: false
|
10
|
-
end
|
1
|
+
module CfnFlow
|
2
|
+
class CLI < Thor
|
11
3
|
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
true
|
16
|
-
end
|
4
|
+
def self.exit_on_failure?
|
5
|
+
CfnFlow.exit_on_failure?
|
6
|
+
end
|
17
7
|
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
env_config = {
|
27
|
-
'bucket' => ENV['CFN_FLOW_BUCKET'],
|
28
|
-
'to' => ENV['CFN_FLOW_TO'],
|
29
|
-
'from' => ENV['CFN_FLOW_FROM'],
|
30
|
-
'dev-name' => ENV['CFN_FLOW_DEV_NAME'],
|
31
|
-
'region' => ENV['AWS_REGION']
|
32
|
-
}.delete_if {|_,v| v.nil?}
|
33
|
-
|
34
|
-
# Env vars override config file. Command args override env vars.
|
35
|
-
self.options = defaults.merge(file_config).merge(env_config).merge(options)
|
36
|
-
|
37
|
-
# Ensure region env var is set for AWS client
|
38
|
-
ENV['AWS_REGION'] = options['region']
|
39
|
-
|
40
|
-
# validate required options are present
|
41
|
-
%w(region bucket to from).each do |arg|
|
42
|
-
unless options[arg]
|
43
|
-
raise Thor::RequiredArgumentMissingError.new("Missing required argument '#{arg}'")
|
44
|
-
end
|
8
|
+
##
|
9
|
+
# Template methods
|
10
|
+
|
11
|
+
desc 'validate TEMPLATE [...]', 'Validates templates'
|
12
|
+
def validate(*templates)
|
13
|
+
|
14
|
+
if templates.empty?
|
15
|
+
raise Thor::RequiredArgumentMissingError.new('You must specify a template to validate')
|
45
16
|
end
|
46
17
|
|
47
|
-
|
48
|
-
|
18
|
+
templates.map{|path| Template.new(path) }.each do |template|
|
19
|
+
say "Validating #{template.local_path}... "
|
20
|
+
template.validate!
|
21
|
+
say 'valid.', :green
|
49
22
|
end
|
23
|
+
rescue Aws::CloudFormation::Errors::ValidationError => e
|
24
|
+
raise Thor::Error.new("Invalid template. Message: #{e.message}")
|
25
|
+
rescue CfnFlow::Template::Error => e
|
26
|
+
raise Thor::Error.new("Error loading template. (#{e.class}) Message: #{e.message}")
|
50
27
|
end
|
51
28
|
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
29
|
+
desc 'publish TEMPLATE [...]', 'Validate & upload templates'
|
30
|
+
method_option 'dev-name', type: :string, desc: 'Personal development prefix'
|
31
|
+
method_option :release, type: :string, desc: 'Upload release', lazy_default: CfnFlow::Git.sha
|
32
|
+
method_option :verbose, type: :boolean, desc: 'Verbose output', default: false
|
33
|
+
def publish(*templates)
|
34
|
+
if templates.empty?
|
35
|
+
raise Thor::RequiredArgumentMissingError.new('You must specify a template to publish')
|
36
|
+
end
|
37
|
+
|
38
|
+
validate(*templates)
|
39
|
+
# TODO: check git is clean before releasing
|
40
|
+
#CfnFlow::Git.check_status if options['release']
|
41
|
+
|
42
|
+
release = publish_release
|
43
|
+
templates.each do |path|
|
44
|
+
t = Template.new(path)
|
45
|
+
|
46
|
+
say "Publishing #{t.local_path} to #{t.url(release)}"
|
47
|
+
t.upload(release)
|
48
|
+
end
|
70
49
|
end
|
71
|
-
end
|
72
50
|
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
51
|
+
##
|
52
|
+
# Stack methods
|
53
|
+
|
54
|
+
desc 'deploy ENVIRONMENT', 'Launch a stack'
|
55
|
+
method_option :cleanup, type: :boolean, desc: 'Prompt to shutdown other stacks in ENVIRONMENT after launching'
|
56
|
+
def deploy(environment)
|
57
|
+
|
78
58
|
begin
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
59
|
+
params = CfnFlow.stack_params(environment)
|
60
|
+
stack = CfnFlow.cfn_resource.create_stack(params)
|
61
|
+
rescue Aws::CloudFormation::Errors::ValidationError => e
|
62
|
+
raise Thor::Error.new(e.message)
|
63
|
+
end
|
64
|
+
|
65
|
+
say "Launching stack #{stack.name}"
|
66
|
+
|
67
|
+
# Invoke events
|
68
|
+
say "Polling for events..."
|
69
|
+
invoke :events, [stack.name], ['--poll']
|
70
|
+
|
71
|
+
|
72
|
+
# Optionally cleanup other stacks in this environment
|
73
|
+
if options[:cleanup]
|
74
|
+
puts "Finding stacks to clean up"
|
75
|
+
list_stacks_in_service.select {|s|
|
76
|
+
s.name != stack.name && \
|
77
|
+
s.tags.any? {|tag| tag.key == 'CfnFlowEnvironment' && tag.value == environment }
|
78
|
+
}.map(&:name).each do |name|
|
79
|
+
delete(name)
|
80
|
+
end
|
84
81
|
end
|
85
82
|
end
|
86
|
-
end
|
87
83
|
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
84
|
+
desc 'list [ENVIRONMENT]', 'List running stacks in all environments, or ENVIRONMENT'
|
85
|
+
method_option 'no-header', type: :boolean, desc: 'Do not print column headers'
|
86
|
+
def list(environment=nil)
|
87
|
+
stacks = list_stacks_in_service
|
88
|
+
if environment
|
89
|
+
stacks.select! do |stack|
|
90
|
+
stack.tags.any? {|tag| tag.key == 'CfnFlowEnvironment' && tag.value == environment }
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
return if stacks.empty?
|
95
|
+
|
96
|
+
table_header = options['no-header'] ? [] : [['NAME', 'ENVIRONMENT', 'STATUS']]
|
97
|
+
table_data = stacks.map do |s|
|
98
|
+
env_tag = s.tags.detect {|tag| tag.key == 'CfnFlowEnvironment'}
|
99
|
+
env = env_tag ? env_tag.value : 'NONE'
|
93
100
|
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
101
|
+
[ s.name, env, s.stack_status ]
|
102
|
+
end
|
103
|
+
|
104
|
+
print_table(table_header + table_data)
|
98
105
|
end
|
99
106
|
|
100
|
-
|
101
|
-
|
107
|
+
desc 'show STACK', 'Show details about STACK'
|
108
|
+
method_option :json, type: :boolean, desc: 'Show stack as JSON (default is YAML)'
|
109
|
+
def show(name)
|
110
|
+
data = find_stack_in_service(name).data.to_hash
|
111
|
+
say options[:json] ? MultiJson.dump(data, pretty: true) : data.to_yaml
|
112
|
+
end
|
102
113
|
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
114
|
+
desc 'events STACK', 'List events for STACK'
|
115
|
+
method_option :poll, type: :boolean, desc: 'Poll for new events until the stack is complete'
|
116
|
+
method_option 'no-header', type: :boolean, desc: 'Do not print column headers'
|
117
|
+
def events(name)
|
118
|
+
stack = find_stack_in_service(name)
|
119
|
+
|
120
|
+
say EventPresenter.header unless options['no-header']
|
121
|
+
EventPresenter.present(stack.events) {|p| say p }
|
107
122
|
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
123
|
+
if options[:poll]
|
124
|
+
# Display events until we're COMPLETE/FAILED
|
125
|
+
delay = (ENV['CFN_FLOW_EVENT_POLL_INTERVAL'] || 2).to_i
|
126
|
+
stack.wait_until(max_attempts: -1, delay: delay) do |s|
|
127
|
+
EventPresenter.present(s.events) {|p| say p }
|
128
|
+
# Wait until the stack status ends with _FAILED or _COMPLETE
|
129
|
+
s.stack_status.match(/_(FAILED|COMPLETE)$/)
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
desc 'delete STACK', 'Shut down STACK'
|
135
|
+
method_option :force, type: :boolean, default: false, desc: 'Shut down without confirmation'
|
136
|
+
def delete(name)
|
137
|
+
stack = find_stack_in_service(name)
|
138
|
+
if options[:force] || yes?("Are you sure you want to shut down #{name}?", :red)
|
139
|
+
stack.delete
|
140
|
+
say "Deleted stack #{name}"
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
private
|
145
|
+
def find_stack_in_service(name)
|
146
|
+
stack = CfnFlow.cfn_resource.stack(name).load
|
147
|
+
unless stack.tags.any? {|tag| tag.key == 'CfnFlowService' && tag.value == CfnFlow.service }
|
148
|
+
raise Thor::Error.new "Stack #{name} is not tagged for service #{CfnFlow.service}"
|
149
|
+
end
|
150
|
+
stack
|
151
|
+
rescue Aws::CloudFormation::Errors::ValidationError => e
|
152
|
+
# Handle missing stacks: 'Stack with id blah does not exist'
|
153
|
+
raise Thor::Error.new(e.message)
|
154
|
+
end
|
155
|
+
|
156
|
+
def list_stacks_in_service
|
157
|
+
CfnFlow.cfn_resource.stacks.select do |stack|
|
158
|
+
stack.tags.any? {|tag| tag.key == 'CfnFlowService' && tag.value == CfnFlow.service }
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
def publish_release
|
163
|
+
# Add the release or dev name to the prefix
|
164
|
+
if options[:release]
|
165
|
+
'release/' + options[:release]
|
166
|
+
elsif options['dev-name']
|
167
|
+
'dev/' + options['dev-name']
|
168
|
+
elsif ENV['CFN_FLOW_DEV_NAME']
|
169
|
+
'dev/' + ENV['CFN_FLOW_DEV_NAME']
|
170
|
+
else
|
171
|
+
raise Thor::Error.new("Must specify --release or --dev-name; or set CFN_FLOW_DEV_NAME env var")
|
172
|
+
end
|
116
173
|
end
|
117
|
-
File.join(*parts)
|
118
174
|
end
|
119
175
|
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
require 'set'
|
2
|
+
module CfnFlow
|
3
|
+
class EventPresenter
|
4
|
+
|
5
|
+
##
|
6
|
+
# Class methods
|
7
|
+
def self.seen_event_ids
|
8
|
+
@seen_event_ids ||= Set.new
|
9
|
+
end
|
10
|
+
|
11
|
+
# Yields each new event present to +block+
|
12
|
+
def self.present(raw_events, &block)
|
13
|
+
raw_events.to_a.reverse.sort_by(&:timestamp).
|
14
|
+
reject {|e| seen_event_ids.include?(e.id) }.
|
15
|
+
map {|e| yield new(e) }
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.header
|
19
|
+
%w(status logical_resource_id resource_type reason) * "\t"
|
20
|
+
end
|
21
|
+
|
22
|
+
##
|
23
|
+
# Instance methods
|
24
|
+
attr_accessor :event
|
25
|
+
def initialize(event)
|
26
|
+
@event = event
|
27
|
+
self.class.seen_event_ids << event.id
|
28
|
+
end
|
29
|
+
|
30
|
+
def to_s
|
31
|
+
[
|
32
|
+
event.resource_status,
|
33
|
+
event.logical_resource_id,
|
34
|
+
event.resource_type,
|
35
|
+
event.resource_status_reason
|
36
|
+
].compact * "\t"
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|