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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: e16917c5b5720382b9419f1dd7e4be47c0f0cbf2
4
- data.tar.gz: 1448b8d55c7679a1a20c2f622b58ba9397ee9f69
3
+ metadata.gz: a8a7c12f98182c07a7961ddca970637d6454275a
4
+ data.tar.gz: 4af32ae281b4a00f55eb9a7263d56eb40fefb678
5
5
  SHA512:
6
- metadata.gz: 20c957cf75facb44eb17cd36795858e891cc7b23ddbc900e4ee752fcd6561f0be31cc0f2df7f9d11b432ee00191607dbdef8e11b42fde07be6d4f2bbae6ddac4
7
- data.tar.gz: 5e2876dbca28e35787d383d6d1f0858608c167bb2e4b389c362d000c1f6930af7b10efc94b7db755cbf0dc2ae85d71c82096c9c34f5b49444c66b35d467a17b4
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. Track template changes in git and upload versioned releases to AWS S3.
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
@@ -4,6 +4,6 @@ require "bundler/gem_tasks"
4
4
  require 'rake/testtask'
5
5
 
6
6
  Rake::TestTask.new do |t|
7
- t.pattern = "spec/*_spec.rb"
7
+ t.pattern = "spec/**/*_spec.rb"
8
8
  end
9
9
  task :default => :test
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
- class CfnFlow::CLI < Thor
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
- # Exit with status code = 1 when raising a Thor::Error
13
- # Override thor default
14
- def self.exit_on_failure?
15
- true
16
- end
4
+ def self.exit_on_failure?
5
+ CfnFlow.exit_on_failure?
6
+ end
17
7
 
18
- no_commands do
19
- def load_config
20
- defaults = { 'from' => '.' }
21
- file_config = begin
22
- YAML.load_file(ENV['CFN_FLOW_CONFIG'] || './cfn-flow.yml')
23
- rescue Errno::ENOENT
24
- {}
25
- end
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
- unless options['dev-name'] || options['release']
48
- raise Thor::RequiredArgumentMissingError.new("Missing either 'dev-name' or 'release' argument")
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
- shared_options
53
- def load_templates
54
- load_config
55
- glob = File.join(options['from'], '**/*.{yml,json,template}')
56
-
57
- puts Dir.glob(glob.inspect)
58
- @templates = Dir.glob(glob).map { |path|
59
- CfnFlow::Template.new(from: path, bucket: options['bucket'], prefix: prefix)
60
- }.select {|t|
61
- verbose "Checking file #{t.from}... "
62
- if t.is_cfn_template?
63
- verbose "loaded"
64
- true
65
- else
66
- verbose "skipped."
67
- false
68
- end
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
- desc :validate, 'Validates templates'
74
- shared_options
75
- def validate
76
- load_templates
77
- @templates.each do |t|
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
- say "Validating #{t.from}... "
80
- t.validate!
81
- say "valid."
82
- rescue Aws::CloudFormation::Errors::ValidationError
83
- raise Thor::Error.new("Error validating #{t.from}. Message: #{$!.message}")
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
- desc :upload, 'Validate & upload templates to the CFN_FLOW_DEV_NAME prefix'
89
- shared_options
90
- method_option :release, type: :string, lazy_default: CfnFlow::Git.sha, desc: 'Upload release'
91
- def upload
92
- CfnFlow::Git.check_status if options['release']
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
- validate
95
- @templates.each do |t|
96
- say "Uploading #{t.from} to #{t.url}"
97
- t.upload!
101
+ [ s.name, env, s.stack_status ]
102
+ end
103
+
104
+ print_table(table_header + table_data)
98
105
  end
99
106
 
100
- end
101
- default_task :upload
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
- private
104
- def verbose(msg)
105
- say msg if options['verbose']
106
- end
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
- def prefix
109
- # Add the release or dev name to the prefix
110
- parts = []
111
- parts << options['to']
112
- if options['release']
113
- parts += [ 'release', options['release'] ]
114
- else
115
- parts += [ 'dev', options['dev-name'] ]
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