moonshot 0.7.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.
@@ -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