moonshot 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,151 @@
1
+ module Moonshot
2
+ # The Controller coordinates and performs all Moonshot actions.
3
+ class Controller # rubocop:disable ClassLength
4
+ def initialize
5
+ @config = ControllerConfig.new
6
+ yield @config if block_given?
7
+ end
8
+
9
+ def list
10
+ Moonshot::StackLister.new(
11
+ @config.app_name, log: @config.logger).list
12
+ end
13
+
14
+ def create
15
+ run_plugins(:pre_create)
16
+ run_hook(:deploy, :pre_create)
17
+ stack_ok = stack.create
18
+ if stack_ok # rubocop:disable GuardClause
19
+ run_hook(:deploy, :post_create)
20
+ run_plugins(:post_create)
21
+ end
22
+ end
23
+
24
+ def update
25
+ run_plugins(:pre_update)
26
+ run_hook(:deploy, :pre_update)
27
+ stack.update
28
+ run_hook(:deploy, :post_update)
29
+ run_plugins(:post_update)
30
+ end
31
+
32
+ def status
33
+ run_plugins(:status)
34
+ run_hook(:deploy, :status)
35
+ stack.status
36
+ end
37
+
38
+ def deploy_code
39
+ version = "#{stack_name}-#{Time.now.to_i}"
40
+ build_version(version)
41
+ deploy_version(version)
42
+ end
43
+
44
+ def build_version(version_name)
45
+ run_plugins(:pre_build)
46
+ run_hook(:build, :pre_build, version_name)
47
+ run_hook(:build, :build, version_name)
48
+ run_hook(:build, :post_build, version_name)
49
+ run_plugins(:post_build)
50
+ run_hook(:repo, :store, @config.build_mechanism, version_name)
51
+ end
52
+
53
+ def deploy_version(version_name)
54
+ run_plugins(:pre_deploy)
55
+ run_hook(:deploy, :deploy, @config.artifact_repository, version_name)
56
+ run_plugins(:post_deploy)
57
+ end
58
+
59
+ def delete
60
+ run_plugins(:pre_delete)
61
+ run_hook(:deploy, :pre_delete)
62
+ stack.delete
63
+ run_hook(:deploy, :post_delete)
64
+ run_plugins(:post_delete)
65
+ end
66
+
67
+ def doctor
68
+ # @todo use #run_hook when Stack becomes an InfrastructureProvider
69
+ success = true
70
+ success &&= stack.doctor_hook
71
+ success &&= run_hook(:build, :doctor)
72
+ success &&= run_hook(:repo, :doctor)
73
+ success &&= run_hook(:deploy, :doctor)
74
+ results = run_plugins(:doctor)
75
+
76
+ success = false if results.value?(false)
77
+ success
78
+ end
79
+
80
+ def stack
81
+ @stack ||= Stack.new(stack_name,
82
+ app_name: @config.app_name,
83
+ log: @config.logger,
84
+ ilog: @config.interactive_logger) do |config|
85
+ config.parent_stacks = @config.parent_stacks
86
+ config.show_all_events = @config.show_all_stack_events
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ def default_stack_name
93
+ user = ENV.fetch('USER').gsub(/\W/, '')
94
+ "#{@config.app_name}-dev-#{user}"
95
+ end
96
+
97
+ def ensure_prefix(name)
98
+ if name.start_with?(@config.app_name + '-')
99
+ name
100
+ else
101
+ @config.app_name + "-#{name}"
102
+ end
103
+ end
104
+
105
+ def stack_name
106
+ name = @config.environment_name || default_stack_name
107
+ if @config.auto_prefix_stack == false
108
+ name
109
+ else
110
+ ensure_prefix(name)
111
+ end
112
+ end
113
+
114
+ def resources
115
+ @resources ||=
116
+ Resources.new(stack: stack, log: @config.logger,
117
+ ilog: @config.interactive_logger)
118
+ end
119
+
120
+ def run_hook(type, name, *args)
121
+ mech = get_mechanism(type)
122
+ name = name.to_s << '_hook'
123
+
124
+ @config.logger.debug("Calling hook=#{name} on mech=#{mech.class}")
125
+ return unless mech && mech.respond_to?(name)
126
+
127
+ mech.resources = resources
128
+ mech.send(name, *args)
129
+ end
130
+
131
+ def run_plugins(type)
132
+ results = {}
133
+ @config.plugins.each do |plugin|
134
+ next unless plugin.respond_to?(type)
135
+ results[plugin] = plugin.send(type, resources)
136
+ end
137
+
138
+ results
139
+ end
140
+
141
+ def get_mechanism(type)
142
+ case type
143
+ when :build then @config.build_mechanism
144
+ when :repo then @config.artifact_repository
145
+ when :deploy then @config.deployment_mechanism
146
+ else
147
+ raise "Unknown hook type: #{type}"
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,25 @@
1
+ module Moonshot
2
+ # Holds configuration for Moonshot::Controller
3
+ class ControllerConfig
4
+ attr_accessor :app_name
5
+ attr_accessor :artifact_repository
6
+ attr_accessor :auto_prefix_stack
7
+ attr_accessor :build_mechanism
8
+ attr_accessor :deployment_mechanism
9
+ attr_accessor :environment_name
10
+ attr_accessor :interactive_logger
11
+ attr_accessor :logger
12
+ attr_accessor :parent_stacks
13
+ attr_accessor :plugins
14
+ attr_accessor :show_all_stack_events
15
+
16
+ def initialize
17
+ @auto_prefix_stack = true
18
+ @interactive_logger = InteractiveLogger.new
19
+ @logger = Logger.new(STDOUT)
20
+ @parent_stacks = []
21
+ @plugins = []
22
+ @show_all_stack_events = false
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,28 @@
1
+ module Moonshot
2
+ # Create convenience methods for various AWS client creation.
3
+ module CredsHelper
4
+ def cf_client
5
+ Aws::CloudFormation::Client.new
6
+ end
7
+
8
+ def cd_client
9
+ Aws::CodeDeploy::Client.new
10
+ end
11
+
12
+ def ec2_client
13
+ Aws::EC2::Client.new
14
+ end
15
+
16
+ def iam_client
17
+ Aws::IAM::Client.new
18
+ end
19
+
20
+ def as_client
21
+ Aws::AutoScaling::Client.new
22
+ end
23
+
24
+ def s3_client
25
+ Aws::S3::Client.new
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,303 @@
1
+ require 'colorize'
2
+
3
+ # This mechanism is used to deploy software to an auto-scaling group within
4
+ # a stack. It currently only works with the S3Bucket ArtifactRepository.
5
+ #
6
+ # Usage:
7
+ # class MyApp < Moonshot::CLI
8
+ # self.artifact_repository = S3Bucket.new('foobucket')
9
+ # self.deployment_mechanism = CodeDeploy.new(asg: 'AutoScalingGroup')
10
+ # end
11
+ class Moonshot::DeploymentMechanism::CodeDeploy # rubocop:disable ClassLength
12
+ include Moonshot::ResourcesHelper
13
+ include Moonshot::CredsHelper
14
+ include Moonshot::DoctorHelper
15
+
16
+ # @param asg [String]
17
+ # The logical name of the AutoScalingGroup to create and manage a Deployment
18
+ # Group for in CodeDeploy.
19
+ # @param app_name [String, nil] (nil)
20
+ # The name of the CodeDeploy Application and Deployment Group. By default,
21
+ # this is the same as the stack name, and probably what you want. If you
22
+ # have multiple deployments in a single Stack, they must have unique names.
23
+ def initialize(asg:, app_name: nil)
24
+ @asg_logical_id = asg
25
+ @app_name = app_name
26
+ end
27
+
28
+ def post_create_hook
29
+ create_application_if_needed
30
+ create_deployment_group_if_needed
31
+
32
+ wait_for_asg_capacity
33
+ end
34
+
35
+ def post_update_hook
36
+ post_create_hook
37
+
38
+ unless deployment_group_ok? # rubocop:disable GuardClause
39
+ delete_deployment_group
40
+ create_deployment_group_if_needed
41
+ end
42
+ end
43
+
44
+ def status_hook
45
+ t = Moonshot::UnicodeTable.new('')
46
+ application = t.add_leaf("CodeDeploy Application: #{app_name}")
47
+ application.add_line(code_deploy_status_msg)
48
+ t.draw_children
49
+ end
50
+
51
+ def deploy_hook(artifact_repo, version_name)
52
+ ilog.start_threaded 'Creating Deployment' do |s|
53
+ res = cd_client.create_deployment(
54
+ application_name: app_name,
55
+ deployment_group_name: app_name,
56
+ revision: revision_for_artifact_repo(artifact_repo, version_name),
57
+ deployment_config_name: 'CodeDeployDefault.OneAtATime',
58
+ description: "Deploying version #{version_name}"
59
+ )
60
+ deployment_id = res.deployment_id
61
+ s.continue "Created Deployment #{deployment_id.blue}."
62
+ wait_for_deployment(deployment_id, s)
63
+ end
64
+ end
65
+
66
+ def post_delete_hook
67
+ ilog.start 'Cleaning up CodeDeploy Application' do |s|
68
+ if application_exists?
69
+ cd_client.delete_application(application_name: app_name)
70
+ s.success "Deleted CodeDeploy Application '#{app_name}'."
71
+ else
72
+ s.success "CodeDeploy Application '#{app_name}' does not exist."
73
+ end
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ # By default, use the stack name as the application and deployment group
80
+ # names, unless one has been provided.
81
+ def app_name
82
+ @app_name || stack.name
83
+ end
84
+
85
+ def pretty_app_name
86
+ "CodeDeploy Application #{app_name.blue}"
87
+ end
88
+
89
+ def pretty_deploy_group
90
+ "CodeDeploy Deployment Group #{app_name.blue}"
91
+ end
92
+
93
+ def create_application_if_needed
94
+ ilog.start "Creating #{pretty_app_name}." do |s|
95
+ if application_exists?
96
+ s.success "#{pretty_app_name} already exists."
97
+ else
98
+ cd_client.create_application(application_name: app_name)
99
+ s.success "Created #{pretty_app_name}."
100
+ end
101
+ end
102
+ end
103
+
104
+ def create_deployment_group_if_needed
105
+ ilog.start "Creating #{pretty_deploy_group}." do |s|
106
+ if deployment_group_exists?
107
+ s.success "CodeDeploy #{pretty_deploy_group} already exists."
108
+ else
109
+ create_deployment_group
110
+ s.success "Created #{pretty_deploy_group}."
111
+ end
112
+ end
113
+ end
114
+
115
+ def code_deploy_status_msg
116
+ case [application_exists?, deployment_group_exists?, deployment_group_ok?]
117
+ when [true, true, true]
118
+ 'Application and Deployment Group are configured correctly.'.green
119
+ when [true, true, false]
120
+ 'Deployment Group exists, but not associated with the correct '\
121
+ "Auto-Scaling Group, try running #{'update'.yellow}."
122
+ when [true, false, false]
123
+ "Deployment Group does not exist, try running #{'create'.yellow}."
124
+ when [false, false, false]
125
+ 'Application and Deployment Group do not exist, try running'\
126
+ " #{'create'.yellow}."
127
+ end
128
+ end
129
+
130
+ def auto_scaling_group
131
+ @auto_scaling_group ||= load_auto_scaling_group
132
+ end
133
+
134
+ def load_auto_scaling_group
135
+ asg_name = stack.physical_id_for(@asg_logical_id)
136
+ unless asg_name
137
+ raise Thor::Error, "Could not find #{@asg_logical_id} resource in Stack."
138
+ end
139
+
140
+ groups = as_client.describe_auto_scaling_groups(
141
+ auto_scaling_group_names: [asg_name])
142
+ if groups.auto_scaling_groups.empty?
143
+ raise Thor::Error, "Could not find ASG #{asg_name}."
144
+ end
145
+
146
+ groups.auto_scaling_groups.first
147
+ end
148
+
149
+ def asg_name
150
+ auto_scaling_group.auto_scaling_group_name
151
+ end
152
+
153
+ def application_exists?
154
+ cd_client.get_application(application_name: app_name)
155
+ true
156
+ rescue Aws::CodeDeploy::Errors::ApplicationDoesNotExistException
157
+ false
158
+ end
159
+
160
+ def deployment_group
161
+ cd_client.get_deployment_group(
162
+ application_name: app_name, deployment_group_name: app_name)
163
+ .deployment_group_info
164
+ end
165
+
166
+ def deployment_group_exists?
167
+ cd_client.get_deployment_group(
168
+ application_name: app_name, deployment_group_name: app_name)
169
+ true
170
+ rescue Aws::CodeDeploy::Errors::ApplicationDoesNotExistException,
171
+ Aws::CodeDeploy::Errors::DeploymentGroupDoesNotExistException
172
+ false
173
+ end
174
+
175
+ def deployment_group_ok?
176
+ return false unless deployment_group_exists?
177
+ asg = deployment_group.auto_scaling_groups.first
178
+ return false unless asg
179
+ asg.name == auto_scaling_group.auto_scaling_group_name
180
+ end
181
+
182
+ def role
183
+ iam_client.get_role(role_name: 'CodeDeployRole').role
184
+ rescue Aws::IAM::Errors::NoSuchEntity
185
+ raise Thor::Error, 'Did not find an IAM Role: CodeDeployRole'
186
+ end
187
+
188
+ def delete_deployment_group
189
+ ilog.start "Deleting #{pretty_deploy_group}." do |s|
190
+ cd_client.delete_deployment_group(
191
+ application_name: app_name,
192
+ deployment_group_name: app_name)
193
+ s.success
194
+ end
195
+ end
196
+
197
+ def create_deployment_group
198
+ cd_client.create_deployment_group(
199
+ application_name: app_name,
200
+ deployment_group_name: app_name,
201
+ service_role_arn: role.arn,
202
+ auto_scaling_groups: [asg_name])
203
+ end
204
+
205
+ def wait_for_asg_capacity
206
+ ilog.start 'Waiting for AutoScaling Group to reach capacity...' do |s|
207
+ loop do
208
+ asg = load_auto_scaling_group
209
+ count = asg.instances.count { |i| i.lifecycle_state == 'InService' }
210
+ break if asg.desired_capacity == count
211
+ s.continue "DesiredCapacity is #{asg.desired_capacity}, currently #{count} instance(s) are InService." # rubocop:disable LineLength
212
+ sleep 5
213
+ end
214
+
215
+ s.success 'AutoScaling Group up to capacity!'
216
+ end
217
+ end
218
+
219
+ def wait_for_deployment(id, step)
220
+ loop do
221
+ sleep 5
222
+ info = cd_client.get_deployment(deployment_id: id).deployment_info
223
+ status = info.status
224
+
225
+ case status
226
+ when 'Created', 'Queued', 'InProgress'
227
+ step.continue "Waiting for Deployment #{id.blue} to complete, current status is '#{status}'." # rubocop:disable LineLength
228
+ when 'Succeeded'
229
+ step.success "Deployment #{id.blue} completed successfully!"
230
+ break
231
+ when 'Failed', 'Stopped'
232
+ step.failure "Deployment #{id.blue} failed with status '#{status}'"
233
+ handle_deployment_failure(id)
234
+ end
235
+ end
236
+ end
237
+
238
+ def handle_deployment_failure(deployment_id) # rubocop:disable AbcSize
239
+ instances = cd_client.list_deployment_instances(deployment_id: deployment_id)
240
+ .instances_list.map do |instance_id|
241
+ cd_client.get_deployment_instance(deployment_id: deployment_id,
242
+ instance_id: instance_id)
243
+ end
244
+
245
+ instances.map(&:instance_summary).each do |inst_summary|
246
+ next unless inst_summary.status == 'Failed'
247
+
248
+ inst_summary.lifecycle_events.each do |event|
249
+ next unless event.status == 'Failed'
250
+
251
+ ilog.error(event.diagnostics.message)
252
+ event.diagnostics.log_tail.each_line do |line|
253
+ ilog.error(line)
254
+ end
255
+ end
256
+ end
257
+
258
+ raise Thor::Error, 'Deployment was unsuccessful!'
259
+ end
260
+
261
+ def revision_for_artifact_repo(artifact_repo, version_name)
262
+ case artifact_repo
263
+ when Moonshot::ArtifactRepository::S3Bucket
264
+ s3_revision_for(artifact_repo, version_name)
265
+ when NilClass
266
+ raise 'Must specify an ArtifactRepository with CodeDeploy. Take a look at the S3Bucket example.' # rubocop:disable LineLength
267
+ else
268
+ raise "Cannot use #{artifact_repo.class} to deploy with CodeDeploy."
269
+ end
270
+ end
271
+
272
+ def s3_revision_for(artifact_repo, version_name)
273
+ {
274
+ revision_type: 'S3',
275
+ s3_location: {
276
+ bucket: artifact_repo.bucket_name,
277
+ key: artifact_repo.filename_for_version(version_name),
278
+ bundle_type: 'tgz'
279
+ }
280
+ }
281
+ end
282
+
283
+ def doctor_check_code_deploy_role
284
+ iam_client.get_role(role_name: 'CodeDeployRole').role
285
+ success('CodeDeployRole exists.')
286
+ rescue => e
287
+ help = <<-EOF
288
+ Error: #{e.message}
289
+
290
+ For information on provisioning an account for use with CodeDeploy, see:
291
+ http://docs.aws.amazon.com/codedeploy/latest/userguide/how-to-create-service-role.html
292
+ EOF
293
+ critical('Could not find CodeDeployRole, ', help)
294
+ end
295
+
296
+ def doctor_check_auto_scaling_resource_defined
297
+ if stack.template.resource_names.include?(@asg_logical_id)
298
+ success("Resource '#{@asg_logical_id}' exists in the CloudFormation template.") # rubocop:disable LineLength
299
+ else
300
+ critical("Resource '#{@asg_logical_id}' does not exist in the CloudFormation template!") # rubocop:disable LineLength
301
+ end
302
+ end
303
+ end
@@ -0,0 +1,57 @@
1
+ # -*- coding: utf-8 -*-
2
+ require 'colorize'
3
+
4
+ module Moonshot
5
+ DoctorCritical = Class.new(RuntimeError)
6
+
7
+ #
8
+ # A series of methods for adding "doctor" checks to a mechanism.
9
+ #
10
+ module DoctorHelper
11
+ def doctor_hook
12
+ run_all_checks
13
+ end
14
+
15
+ private
16
+
17
+ def run_all_checks
18
+ success = true
19
+ puts
20
+ puts self.class.name.split('::').last
21
+ private_methods.each do |meth|
22
+ begin
23
+ send(meth) if meth =~ /^doctor_check_/
24
+ rescue DoctorCritical
25
+ # Stop running checks in this Mechanism.
26
+ success = false
27
+ break
28
+ rescue => e
29
+ success = false
30
+ print ' ✗ '.red
31
+ puts "Exception while running check: #{e.class}: #{e.message.lines.first}"
32
+ break
33
+ end
34
+ end
35
+
36
+ success
37
+ end
38
+
39
+ def success(str)
40
+ print ' ✓ '.green
41
+ puts str
42
+ end
43
+
44
+ def warning(str, additional_info = nil)
45
+ print ' ? '.yellow
46
+ puts str
47
+ additional_info.lines.each { |l| puts " #{l}" } if additional_info
48
+ end
49
+
50
+ def critical(str, additional_info = nil)
51
+ print ' ✗ '.red
52
+ puts str
53
+ additional_info.lines.each { |l| puts " #{l}" } if additional_info
54
+ raise DoctorCritical
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,32 @@
1
+ require 'ostruct'
2
+
3
+ module Moonshot
4
+ # This module supports massaging of the incoming environment.
5
+ module EnvironmentParser
6
+ def self.parse(log)
7
+ log.debug('Starting to parse environment.')
8
+
9
+ # Ops Bastion servers export AWS_CREDENTIAL_FILE, instead of key and
10
+ # secret keys, so we support both here. We then set them as environment
11
+ # variables which will be respected by aws-sdk.
12
+ parse_credentials_file if ENV.key?('AWS_CREDENTIAL_FILE')
13
+
14
+ # Ensure the aws-sdk is able to find a set of credentials.
15
+ creds = Aws::CredentialProviderChain.new(OpenStruct.new).resolve
16
+
17
+ raise 'Unable to find AWS credentials!' unless creds
18
+
19
+ log.debug('Environment parsing complete.')
20
+ end
21
+
22
+ def self.parse_credentials_file
23
+ File.open(ENV.fetch('AWS_CREDENTIAL_FILE')).each_line do |line|
24
+ key, val = line.chomp.split('=')
25
+ case key
26
+ when 'AWSAccessKeyId' then ENV['AWS_ACCESS_KEY_ID'] = val
27
+ when 'AWSSecretKey' then ENV['AWS_SECRET_ACCESS_KEY'] = val
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,49 @@
1
+ require 'forwardable'
2
+
3
+ module Moonshot
4
+ # This class pretends to be an InteractiveLogger for systems that are
5
+ # non-interactive.
6
+ class InteractiveLoggerProxy
7
+ # Non-interactive version of InteractiveLogger::Step.
8
+ class Step
9
+ def initialize(logger)
10
+ @logger = logger
11
+ end
12
+
13
+ def blank
14
+ end
15
+
16
+ def continue(str = nil)
17
+ @logger.info(str) if str
18
+ end
19
+
20
+ def failure(str = 'Failure')
21
+ @logger.error(str)
22
+ end
23
+
24
+ def repaint
25
+ end
26
+
27
+ def success(str = 'Success')
28
+ @logger.info(str)
29
+ end
30
+ end
31
+
32
+ extend Forwardable
33
+
34
+ def_delegator :@debug, :itself, :debug?
35
+ def_delegators :@logger, :debug, :error, :info
36
+ alias msg info
37
+
38
+ def initialize(logger, debug: false)
39
+ @debug = debug
40
+ @logger = logger
41
+ end
42
+
43
+ def start(str)
44
+ @logger.info(str)
45
+ yield Step.new(@logger)
46
+ end
47
+ alias start_threaded start
48
+ end
49
+ end
@@ -0,0 +1,13 @@
1
+ module Moonshot
2
+ # Resources is a dependency container that holds references to instances
3
+ # provided to a Mechanism (build, deploy, etc.).
4
+ class Resources
5
+ attr_reader :log, :stack, :ilog
6
+
7
+ def initialize(log:, stack:, ilog:)
8
+ @log = log
9
+ @stack = stack
10
+ @ilog = ilog
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,24 @@
1
+ module Moonshot
2
+ # Provides shorthand methods for accessing resources provided by the Resources
3
+ # container.
4
+ module ResourcesHelper
5
+ attr_writer :resources
6
+
7
+ private
8
+
9
+ def log
10
+ raise 'Resources not provided to Mechanism!' unless @resources
11
+ @resources.log
12
+ end
13
+
14
+ def stack
15
+ raise 'Resources not provided to Mechanism!' unless @resources
16
+ @resources.stack
17
+ end
18
+
19
+ def ilog
20
+ raise 'Resources not provided to Mechanism!' unless @resources
21
+ @resources.ilog
22
+ end
23
+ end
24
+ end