-
Notifications
You must be signed in to change notification settings - Fork 49
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
23 changed files
with
933 additions
and
27 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,93 @@ | ||
# Auto-Scaling Group LaunchConfig Rollout Tool | ||
|
||
## Overview | ||
|
||
This plugin adds support for rolling out changes to Auto Scaling | ||
Groups that have happened after a stack update. It supports various | ||
pre- and post-actions on the instances (using Moonshot's native SSH | ||
support). | ||
|
||
## Example | ||
|
||
The ASGRollout class is intended to be used within a method in your | ||
project's derived class of the Moonshot::CLI class. | ||
|
||
```ruby | ||
#!/usr/bin/env ruby | ||
|
||
require 'moonshot' | ||
|
||
class MyService < Moonshot::CLI | ||
# .. normal configuration .. | ||
|
||
desc :asg_rollout, 'Update instances in the Auto Scaling Group' | ||
def asg_rollout | ||
ar = Moonshot::Tools::ASGRollout.new do |config| | ||
config.controller = controller | ||
config.logical_id = 'APIAutoScalingGroup' | ||
|
||
# Trigger the worker process to initiate a clean shutdown. | ||
config.predetach = ->(h) { 0 == h.exec('systemctl stop my-worker-1').exitstatus } | ||
|
||
# Wait for that clean shutdown to happen. | ||
config.terminate_when = ->(h) { 0 == h.exec('pgrep -f my-worker-1').exitstatus } | ||
end | ||
ar.run! | ||
end | ||
end | ||
|
||
MyService.start | ||
``` | ||
|
||
## Configuration | ||
|
||
The ASGRollout object accepts two required keyword arguments: | ||
- **controller**: A Moonshot::Controller. If you are using | ||
Moonshot::CLI the `controller` method is most often when you want | ||
to use. | ||
- **logical_id**: The Logical resource ID from the CloudFormation | ||
stack to operate on. | ||
|
||
By default, the ASGRollout class will build a list of instances with | ||
non-conforming LaunchConfiguration, then one at a time perform the | ||
following actions: | ||
1. Increase the *Max* and *Desired* sizes of the Auto Scaling Group by one. | ||
2. Wait for the new instance to be *InService*. | ||
3. Detach a non-conforming instance. | ||
4. Wait for the instance to be *OutOfService* or removed from the | ||
Auto Scaling Group. | ||
5. If the Auto Scaling Group has an associated Elastic Load | ||
Balancer, wait for the instance to be *Todo?* there as well. | ||
6. Wait for a new instance to replace it. | ||
7. Wait for that new instance to be *InService* in the Auto Scaling Group. | ||
8. If the Auto Scaling Group has an associated Elastic Load | ||
Balancer, wait for the instance to be *InService* there as well. | ||
9. Terminate the detached non-conforming instance. | ||
10. If there are other non-conforming instances, go to | ||
step 3. | ||
11. Restore the *Max* and *Desired* sizes of the Auto Scaling Group | ||
to their original values. | ||
|
||
A config object is yielded by the constructor as illustrated in the | ||
example above, which accepts the following options: | ||
- **pre_detach** *(Callable)*: This will be run before step 3 | ||
above. If it returns `false`, the process will be aborted. | ||
- **terminate** *(Callable)*: This will be run to execute step 9. If | ||
not specified, the default is to use EC2's Terminate API. | ||
- **terminate_when** *(Callable)*: This will be run every 5 seconds | ||
prior to step 9. Step 9 will continue as soon as this returns | ||
`true`. | ||
- **terminate_when_timeout** *(Integer)*: (Default 300s) Number of seconds to | ||
wait for the terminate_when lambda to return true. After this | ||
timeout, the process will be aborted. | ||
|
||
For the callables above, an instance of `HookExecEnvironment` is | ||
passed in, providing the following methods: | ||
- **exec** *(Moonshot::SSHForkExecutor::Result)*: Run a command on | ||
the instance and return the output and exit code. | ||
- **ec2** *(Aws::EC2::Client)*: A configured Aws::EC2::Client for the region we're | ||
operating in. | ||
- **instance_id** *(String)*: The EC2 instance ID. | ||
- **debug(msg)**: Log a debug message, shown when Moonshot is in | ||
verbose mode. | ||
- **info(msg)**: Log a message, shown always. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
require 'open3' | ||
|
||
module Moonshot | ||
# Run an SSH command via fork/exec. | ||
class SSHForkExecutor | ||
Result = Struct.new(:output, :exitstatus) | ||
|
||
def run(cmd) | ||
output = StringIO.new | ||
|
||
exit_status = nil | ||
Open3.popen3(cmd) do |_, stdout, _, wt| | ||
output << stdout.read until stdout.eof? | ||
exit_status = wt.value.exitstatus | ||
end | ||
|
||
Result.new(output.string.chomp, exit_status) | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,170 @@ | ||
require_relative 'asg_rollout_config' | ||
require_relative 'asg_rollout/asg' | ||
require_relative 'asg_rollout/hook_exec_environment' | ||
|
||
module Moonshot | ||
module Tools | ||
class ASGRollout # rubocop:disable Documentation | ||
attr_accessor :config | ||
|
||
def initialize(controller:, logical_id:) | ||
@config = ASGRolloutConfig.new | ||
@controller = controller | ||
@logical_id = logical_id | ||
yield @config if block_given? | ||
end | ||
|
||
def run! | ||
increase_max_and_desired | ||
new_instance = wait_for_new_instance | ||
wait_for_in_service(new_instance) | ||
|
||
targets = asg.non_conforming_instances | ||
last_instance = targets.last | ||
|
||
targets.each do |instance| | ||
run_pre_detach(instance) if @config.pre_detach | ||
detach(instance, decrement: instance == last_instance) | ||
wait_for_out_of_service(instance) | ||
|
||
unless instance == last_instance | ||
new_instance = wait_for_new_instance | ||
wait_for_in_service(new_instance) | ||
end | ||
|
||
wait_for_terminate_when_hook(instance) if @config.terminate_when | ||
terminate(instance) | ||
end | ||
ensure | ||
log.start_threaded 'Restoring MaxSize/DesiredCapacity values to normal...' do |s| | ||
asg.set_max_and_desired(@max, @desired) | ||
|
||
s.success 'Restored MaxSize/DesiredCapacity values to normal!' | ||
end | ||
end | ||
|
||
private | ||
|
||
def increase_max_and_desired | ||
log.start_threaded 'Increasing MaxSize/DesiredCapacity by 1.' do |s| | ||
@max, @desired = asg.current_max_and_desired | ||
|
||
asg.set_max_and_desired(@max + 1, @desired + 1) | ||
s.success 'Increased MaxSize/DesiredCapacity by 1.' | ||
end | ||
end | ||
|
||
def wait_for_new_instance | ||
new_instance = nil | ||
log.start_threaded 'Waiting for a new instance to join Auto Scaling Group...' do |s| | ||
new_instance = asg.wait_for_new_instance | ||
s.success "A wild #{new_instance.blue} appears!" | ||
end | ||
new_instance | ||
end | ||
|
||
def wait_for_in_service(new_instance) | ||
log.start_threaded "Waiting for #{new_instance.blue} to be InService..." do |s| | ||
instance_health = nil | ||
|
||
loop do | ||
instance_health = asg.instance_health(new_instance) | ||
break if instance_health.in_service? | ||
|
||
s.continue "Instance #{new_instance.blue} is #{instance_health}..." | ||
|
||
sleep @config.instance_health_delay | ||
end | ||
|
||
s.success "Instance #{new_instance.blue} is #{instance_health}!" | ||
end | ||
end | ||
|
||
def run_pre_detach(instance) | ||
if @config.pre_detach | ||
log.start_threaded "Running PreDetach hook on #{instance.blue}..." do |s| | ||
he = HookExecEnvironment.new(@controller.config, instance) | ||
if false == @config.pre_detach.call(he) | ||
s.failure "PreDetach hook failed for #{instance.blue}!" | ||
raise "PreDetach hook failed for #{instance.blue}!" | ||
end | ||
|
||
s.success "PreDetach hook complete for #{instance.blue}!" | ||
end | ||
end | ||
end | ||
|
||
def detach(instance, decrement:) | ||
log.start_threaded "Detaching instance #{instance.blue}..." do |s| | ||
asg.detach_instance(instance, decrement: decrement) | ||
|
||
if decrement | ||
s.success "Detached instance #{instance.blue}, and decremented DesiredCapacity." | ||
else | ||
s.success "Detached instance #{instance.blue}." | ||
end | ||
end | ||
end | ||
|
||
def wait_for_out_of_service(instance) | ||
log.start_threaded "Waiting for #{instance.blue} to be OutOfService..." do |s| | ||
instance_health = nil | ||
|
||
loop do | ||
instance_health = asg.instance_health(instance) | ||
break if instance_health.out_of_service? | ||
|
||
s.continue "Instance #{instance.blue} is #{instance_health}..." | ||
|
||
sleep @config.instance_health_delay | ||
end | ||
|
||
s.success "Instance #{instance.blue} is #{instance_health}!" | ||
end | ||
end | ||
|
||
def wait_for_terminate_when_hook(instance) | ||
log.start_threaded "Waiting for TerminateWhen hook for #{instance.blue}..." do |s| | ||
start = Time.now.to_f | ||
he = HookExecEnvironment.new(@controller.config, instance) | ||
timeout = @config.terminate_when_timeout | ||
|
||
loop do | ||
break if @config.terminate_when.call(he) | ||
sleep @config.terminate_when_delay | ||
|
||
if Time.now.to_f - start > timeout | ||
s.failure "TerminateWhen for #{instance.blue} did not complete in #{timeout} seconds!" | ||
raise "TerminateWhen for #{instance.blue} did not complete in #{timeout} seconds!" | ||
end | ||
end | ||
|
||
s.success "Completed TerminateWhen check for #{instance.blue}!" | ||
end | ||
end | ||
|
||
def terminate(instance) | ||
log.start_threaded "Terminating #{instance.blue}..." do |s| | ||
he = HookExecEnvironment.new(@controller.config, instance) | ||
@config.terminate.call(he) | ||
s.success "Terminated #{instance.blue}!" | ||
end | ||
end | ||
|
||
def asg | ||
return @asg if @asg | ||
|
||
asg_name = @controller.stack.physical_id_for(@logical_id) | ||
unless asg_name | ||
raise "Could not find Auto Scaling Group #{@logical_id}!" | ||
end | ||
|
||
@asg ||= ASGRollout::ASG.new(asg_name) | ||
end | ||
|
||
def log | ||
@controller.config.interactive_logger | ||
end | ||
end | ||
end | ||
end |
Oops, something went wrong.