lib/moonshot/deployment_mechanism/code_deploy.rb
# frozen_string_literal: true
require 'colorize'
# This mechanism is used to deploy software to an auto-scaling group within
# a stack. It currently only works with the S3Bucket ArtifactRepository.
#
# Usage:
# class MyApp < Moonshot::CLI
# self.artifact_repository = S3Bucket.new('foobucket')
# self.deployment_mechanism = CodeDeploy.new(asg: 'AutoScalingGroup')
# end
class Moonshot::DeploymentMechanism::CodeDeploy # rubocop:disable Metrics/ClassLength
include Moonshot::ResourcesHelper
include Moonshot::CredsHelper
include Moonshot::DoctorHelper
DEFAULT_ROLE_NAME = 'CodeDeployRole'
# @param asg [Array, String]
# The logical name of the AutoScalingGroup to create and manage a Deployment
# Group for in CodeDeploy.
# @param optional_asg [Array, String]
# The logical name of the AutoScalingGroup to create and manage a Deployment
# Group for in CodeDeploy. This ASG doesn't have to exist. If it does, it
# will be added to the Deployment Group.
# @param role [String]
# IAM role with AWSCodeDeployRole policy. CodeDeployRole is considered as
# default role if its not specified.
# @param app_name [String, nil] (nil)
# The name of the CodeDeploy Application. By default, this is the same as
# the stack name, and probably what you want. If you have multiple
# deployments in a single Stack, they must have unique names.
# @param group_name [String, nil] (nil)
# The name of the CodeDeploy Deployment Group. By default, this is the same
# as app_name.
# @param config_name [String]
# Name of the Deployment Config to use for CodeDeploy, By default we use
# CodeDeployDefault.OneAtATime.
# rubocop:disable Metrics/ParameterLists
def initialize(
asg: [],
optional_asg: [],
role: DEFAULT_ROLE_NAME,
app_name: nil,
group_name: nil,
config_name: 'CodeDeployDefault.OneAtATime'
)
@asg_logical_ids = Array(asg)
@optional_asg_logical_ids = Array(optional_asg)
@app_name = app_name
@group_name = group_name
@codedeploy_role = role
@codedeploy_config = config_name
@ignore_app_stop_failures = false
end
# rubocop:enable Metrics/ParameterLists
def post_create_hook
create_application_if_needed
create_deployment_group_if_needed
wait_for_asg_capacity
end
def post_update_hook
post_create_hook
unless deployment_group_ok? # rubocop:disable Style/GuardClause
delete_deployment_group
create_deployment_group_if_needed
end
end
def status_hook
t = Moonshot::UnicodeTable.new('')
application = t.add_leaf("CodeDeploy Application: #{app_name}")
application.add_line(code_deploy_status_msg)
t.draw_children
end
def deploy_hook(artifact_repo, version_name)
success = true
deployment_id = nil
ilog.start_threaded 'Creating Deployment' do |s|
res = create_deployment(artifact_repo, version_name)
deployment_id = res.deployment_id
s.continue "Created Deployment #{deployment_id.blue}."
success = wait_for_deployment(deployment_id, s)
end
handle_deployment_failure(deployment_id) unless success
end
def post_delete_hook
ilog.start 'Cleaning up CodeDeploy Application' do |s|
if application_exists?
cd_client.delete_application(application_name: app_name)
s.success "Deleted CodeDeploy Application '#{app_name}'."
else
s.success "CodeDeploy Application '#{app_name}' does not exist."
end
end
end
def deploy_cli_hook(parser)
parser.on('--ignore-app-stop-failures', TrueClass, 'Continue deployment on ApplicationStop failures') do |v|
puts "ignore = #{v}"
@ignore_app_stop_failures = v
end
parser
end
alias push_cli_hook deploy_cli_hook
private
# By default, use the stack name as the application name, unless one has been
# provided.
def app_name
@app_name || stack.name
end
# By default, use the stack name as the deployment group name, unless one has
# been provided.
def group_name
@group_name || stack.name
end
def pretty_app_name
"CodeDeploy Application #{app_name.blue}"
end
def pretty_deploy_group
"CodeDeploy Deployment Group #{app_name.blue}"
end
def create_application_if_needed
ilog.start "Creating #{pretty_app_name}." do |s|
if application_exists?
s.success "#{pretty_app_name} already exists."
else
cd_client.create_application(application_name: app_name)
s.success "Created #{pretty_app_name}."
end
end
end
def create_deployment_group_if_needed
ilog.start "Creating #{pretty_deploy_group}." do |s|
if deployment_group_exists?
s.success "CodeDeploy #{pretty_deploy_group} already exists."
else
create_deployment_group
s.success "Created #{pretty_deploy_group}."
end
end
end
def code_deploy_status_msg
case [application_exists?, deployment_group_exists?, deployment_group_ok?]
when [true, true, true]
'Application and Deployment Group are configured correctly.'.green
when [true, true, false]
'Deployment Group exists, but not associated with the correct '\
"Auto-Scaling Group, try running #{'update'.yellow}."
when [true, false, false]
"Deployment Group does not exist, try running #{'create'.yellow}."
when [false, false, false]
'Application and Deployment Group do not exist, try running'\
" #{'create'.yellow}."
end
end
def auto_scaling_groups
@auto_scaling_groups ||= load_auto_scaling_groups
end
def load_auto_scaling_groups
autoscaling_groups = []
@asg_logical_ids.each do |asg_logical_id|
asg_name = stack.physical_id_for(asg_logical_id)
raise "Could not find #{asg_logical_id} resource in Stack." unless asg_name
groups = as_client.describe_auto_scaling_groups(
auto_scaling_group_names: [asg_name]
)
raise "Could not find ASG #{asg_name}." if groups.auto_scaling_groups.empty?
autoscaling_groups.push(groups.auto_scaling_groups.first)
end
@optional_asg_logical_ids.each do |asg_logical_id|
asg_name = stack.physical_id_for(asg_logical_id)
next unless asg_name
groups = as_client.describe_auto_scaling_groups(
auto_scaling_group_names: [asg_name]
)
autoscaling_groups.push(groups.auto_scaling_groups.first) unless groups.auto_scaling_groups.empty?
end
autoscaling_groups
end
def asg_names
names = []
auto_scaling_groups.each do |auto_scaling_group|
names.push(auto_scaling_group.auto_scaling_group_name)
end
names
end
def application_exists?
cd_client.get_application(application_name: app_name)
true
rescue Aws::CodeDeploy::Errors::ApplicationDoesNotExistException
false
end
def deployment_group
cd_client.get_deployment_group(
application_name: app_name,
deployment_group_name: group_name
).deployment_group_info
end
def deployment_group_exists?
cd_client.get_deployment_group(
application_name: app_name,
deployment_group_name: group_name
)
true
rescue Aws::CodeDeploy::Errors::ApplicationDoesNotExistException,
Aws::CodeDeploy::Errors::DeploymentGroupDoesNotExistException
false
end
def deployment_group_ok?
return false unless deployment_group_exists?
asgs = deployment_group.auto_scaling_groups
return false unless asgs
return false unless asgs.count == auto_scaling_groups.count
asgs.each do |asg|
return false if (auto_scaling_groups.find_index { |a| a.auto_scaling_group_name == asg.name }).nil?
end
true
end
def role
iam_client.get_role(role_name: @codedeploy_role).role
rescue Aws::IAM::Errors::NoSuchEntity
# Auto create the IAM Role if it does not exist in the current AWS account
ilog.start "Missing IAM Role: #{@codedeploy_role.blue}. Creating it now ..." do |s|
code_deploy_policy = {
'Version' => '2012-10-17',
'Statement' => [
{
'Sid' => '',
'Effect' => 'Allow',
'Principal' => {
'Service' => [
'codedeploy.amazonaws.com'
]
},
'Action' => 'sts:AssumeRole'
}
]
}
result = iam_client.create_role(
role_name: @codedeploy_role,
assume_role_policy_document: code_deploy_policy.to_json
)
iam_client.attach_role_policy(
role_name: @codedeploy_role,
policy_arn: 'arn:aws:iam::aws:policy/service-role/AWSCodeDeployRole'
)
s.success "Created IAM Role successfully: #{@codedeploy_role.blue}"
result.role
end
end
def delete_deployment_group
ilog.start "Deleting #{pretty_deploy_group}." do |s|
cd_client.delete_deployment_group(
application_name: app_name,
deployment_group_name: group_name
)
s.success
end
end
def create_deployment_group
cd_client.create_deployment_group(
application_name: app_name,
deployment_group_name: group_name,
service_role_arn: role.arn,
auto_scaling_groups: asg_names
)
end
def wait_for_asg_capacity
ilog.start_threaded 'Waiting for AutoScaling Group(s) to reach capacity...' do |s|
loop do
asgs_at_capacity = 0
asgs = load_auto_scaling_groups
asgs.each do |asg|
count = asg.instances.count { |i| i.lifecycle_state == 'InService' }
if asg.desired_capacity == count
asgs_at_capacity += 1
s.continue "#{asg.auto_scaling_group_name} DesiredCapacity is #{asg.desired_capacity}, currently #{count} instance(s) are InService." # rubocop:disable Layout/LineLength
end
end
break if asgs.count == asgs_at_capacity
sleep 5
end
s.success 'AutoScaling Group(s) up to capacity!'
end
end
def wait_for_deployment(id, step)
success = true
loop do
sleep 5
info = cd_client.get_deployment(deployment_id: id).deployment_info
status = info.status
case status
when 'Created', 'Queued', 'InProgress'
step.continue "Waiting for Deployment #{id.blue} to complete, current status is '#{status}'."
when 'Succeeded'
step.success "Deployment #{id.blue} completed successfully!"
break
when 'Failed', 'Stopped'
step.failure "Deployment #{id.blue} failed with status '#{status}'"
success = false
break
end
end
success
end
def handle_deployment_failure(deployment_id)
instances = cd_client.list_deployment_instances(deployment_id:)
.instances_list.map do |instance_id|
cd_client.get_deployment_instance(deployment_id:,
instance_id:)
end
instances.map(&:instance_summary).each do |inst_summary|
next unless inst_summary.status == 'Failed'
inst_summary.lifecycle_events.each do |event|
next unless event.status == 'Failed'
if event.diagnostics.nil?
ilog.error('Lifecycle event chain is not available.')
else
ilog.error(event.diagnostics.message)
event.diagnostics.log_tail.each_line do |line|
ilog.error(line)
end
end
end
end
raise 'Deployment was unsuccessful!'
end
def revision_for_artifact_repo(artifact_repo, version_name)
case artifact_repo
when Moonshot::ArtifactRepository::S3Bucket
s3_revision_for(artifact_repo, version_name)
when NilClass
raise 'Must specify an ArtifactRepository with CodeDeploy. Take a look at the S3Bucket example.'
else
raise "Cannot use #{artifact_repo.class} to deploy with CodeDeploy."
end
end
def s3_revision_for(artifact_repo, version_name)
{
revision_type: 'S3',
s3_location: {
bucket: artifact_repo.bucket_name,
key: artifact_repo.filename_for_version(version_name),
bundle_type: 'tgz'
}
}
end
def create_deployment(artifact_repo, version_name)
cd_client.create_deployment(
application_name: app_name,
deployment_group_name: group_name,
revision: revision_for_artifact_repo(artifact_repo, version_name),
deployment_config_name: @codedeploy_config,
description: "Deploying version #{version_name}",
ignore_application_stop_failures: @ignore_app_stop_failures
)
end
def doctor_check_code_deploy_role
role
success("#{@codedeploy_role} exists.")
rescue StandardError => e
help = <<-EOF
Error: #{e.message}
For information on provisioning an account for use with CodeDeploy, see:
http://docs.aws.amazon.com/codedeploy/latest/userguide/how-to-create-service-role.html
EOF
critical("Could not find #{@codedeploy_role}, ", help)
end
def doctor_check_auto_scaling_resource_defined
@asg_logical_ids.each do |asg_logical_id|
if stack.template.resource_names.include?(asg_logical_id)
success("Resource '#{asg_logical_id}' exists in the CloudFormation template.")
else
critical("Resource '#{asg_logical_id}' does not exist in the CloudFormation template!")
end
end
end
end