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
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
|