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