capistrano-asg-rolling 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aws-sdk-autoscaling'
4
+
5
+ module Capistrano
6
+ module ASG
7
+ module Rolling
8
+ # AWS EC2 Auto Scaling Group.
9
+ class AutoscaleGroup
10
+ include AWS
11
+
12
+ LIFECYCLE_STATE_IN_SERVICE = 'InService'
13
+ LIFECYCLE_STATE_STANDBY = 'Standby'
14
+
15
+ attr_reader :name, :properties
16
+
17
+ def initialize(name, properties = {})
18
+ @name = name
19
+ @properties = properties
20
+ end
21
+
22
+ def exists?
23
+ aws_autoscaling_group.exists?
24
+ end
25
+
26
+ def launch_template
27
+ @launch_template ||= begin
28
+ template = aws_autoscaling_group.launch_template
29
+ raise Capistrano::ASG::Rolling::NoLaunchTemplate if template.nil?
30
+
31
+ LaunchTemplate.new(template.launch_template_id, template.version)
32
+ end
33
+ end
34
+
35
+ def subnet_ids
36
+ aws_autoscaling_group.vpc_zone_identifier.split(',')
37
+ end
38
+
39
+ def instance_warmup_time
40
+ aws_autoscaling_group.health_check_grace_period
41
+ end
42
+
43
+ def start_instance_refresh
44
+ aws_autoscaling_client.start_instance_refresh(
45
+ auto_scaling_group_name: name,
46
+ strategy: 'Rolling',
47
+ preferences: {
48
+ instance_warmup: instance_warmup_time,
49
+ min_healthy_percentage: 100
50
+ }
51
+ )
52
+ end
53
+
54
+ # Returns instances with lifecycle state "InService" for this Auto Scaling Group.
55
+ def instances
56
+ instance_ids = aws_autoscaling_group.instances.select { |i| i.lifecycle_state == LIFECYCLE_STATE_IN_SERVICE }.map(&:instance_id)
57
+ return [] if instance_ids.empty?
58
+
59
+ response = aws_ec2_client.describe_instances(instance_ids: instance_ids)
60
+ response.reservations.flat_map(&:instances).map do |instance|
61
+ Instance.new(instance.instance_id, instance.private_ip_address, instance.public_ip_address, instance.image_id, self)
62
+ end
63
+ end
64
+
65
+ def enter_standby(instance)
66
+ instance = aws_autoscaling_group.instances.find { |i| i.id == instance.id }
67
+ return if instance.nil?
68
+
69
+ instance.enter_standby(should_decrement_desired_capacity: true)
70
+
71
+ loop do
72
+ instance.load
73
+ break if instance.lifecycle_state == LIFECYCLE_STATE_STANDBY
74
+
75
+ sleep 1
76
+ end
77
+ end
78
+
79
+ def exit_standby(instance)
80
+ instance = aws_autoscaling_group.instances.find { |i| i.id == instance.id }
81
+ return if instance.nil?
82
+
83
+ instance.exit_standby
84
+ end
85
+
86
+ def rolling?
87
+ properties.fetch(:rolling, true)
88
+ end
89
+
90
+ def name_tag
91
+ "Deployment for #{name}"
92
+ end
93
+
94
+ private
95
+
96
+ def aws_autoscaling_group
97
+ @aws_autoscaling_group ||= ::Aws::AutoScaling::AutoScalingGroup.new(name: name, client: aws_autoscaling_client)
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Capistrano
6
+ module ASG
7
+ module Rolling
8
+ # Collection of Auto Scaling Groups.
9
+ class AutoscaleGroups
10
+ include Enumerable
11
+
12
+ def initialize(groups = [])
13
+ @groups = groups
14
+ end
15
+
16
+ def <<(group)
17
+ @groups << group
18
+ end
19
+
20
+ def each(&block)
21
+ @groups.reject { |group| filtered?(group) }.each(&block)
22
+ end
23
+
24
+ def with_launch_template(launch_template)
25
+ self.class.new(select { |group| group.launch_template == launch_template })
26
+ end
27
+
28
+ def update_launch_templates(amis:, description: nil)
29
+ launch_templates = Set.new
30
+
31
+ amis.each do |ami|
32
+ old_image_id = ami.instance.image_id
33
+ new_image_id = ami.id
34
+
35
+ find_launch_templates_for(image_id: old_image_id).each do |launch_template|
36
+ launch_templates << launch_template.create_version(image_id: new_image_id, description: description)
37
+ end
38
+ end
39
+
40
+ launch_templates
41
+ end
42
+
43
+ def start_instance_refresh
44
+ each(&:start_instance_refresh)
45
+ end
46
+
47
+ private
48
+
49
+ def filtered?(group)
50
+ return false if Configuration.auto_scaling_group_name.nil?
51
+
52
+ Configuration.auto_scaling_group_name != group.name
53
+ end
54
+
55
+ def find_launch_templates_for(image_id:)
56
+ launch_templates = Set.new
57
+
58
+ each do |group|
59
+ launch_template = group.launch_template
60
+ next if launch_template.image_id != image_id
61
+
62
+ launch_templates << launch_template
63
+ end
64
+
65
+ launch_templates
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aws-sdk-autoscaling'
4
+ require 'aws-sdk-ec2'
5
+
6
+ module Capistrano
7
+ module ASG
8
+ module Rolling
9
+ # AWS SDK.
10
+ module AWS
11
+ def aws_autoscaling_client
12
+ @aws_autoscaling_client ||= ::Aws::AutoScaling::Client.new(aws_options)
13
+ end
14
+
15
+ def aws_ec2_client
16
+ @aws_ec2_client ||= ::Aws::EC2::Client.new(aws_options)
17
+ end
18
+
19
+ private
20
+
21
+ def aws_options
22
+ options = {}
23
+ options[:region] = aws_region if aws_region
24
+ options[:credentials] = aws_credentials if aws_credentials.set?
25
+ options[:http_wire_trace] = true if ENV['AWS_HTTP_WIRE_TRACE'] == '1'
26
+ options
27
+ end
28
+
29
+ def aws_credentials
30
+ ::Aws::Credentials.new(aws_access_key_id, aws_secret_access_key)
31
+ end
32
+
33
+ def aws_access_key_id
34
+ Configuration.aws_access_key_id
35
+ end
36
+
37
+ def aws_secret_access_key
38
+ Configuration.aws_secret_access_key
39
+ end
40
+
41
+ def aws_region
42
+ Configuration.aws_region
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Capistrano
6
+ module ASG
7
+ module Rolling
8
+ # Singleton that holds the configuration.
9
+ module Configuration
10
+ extend Capistrano::DSL
11
+
12
+ module_function
13
+
14
+ # Registered Auto Scaling Groups.
15
+ def autoscale_groups
16
+ @autoscale_groups ||= AutoscaleGroups.new
17
+ end
18
+
19
+ # Launched Instances.
20
+ def instances
21
+ @instances ||= Instances.new
22
+ end
23
+
24
+ # Updated Launch Templates.
25
+ def launch_templates
26
+ @launch_templates ||= Set.new
27
+ end
28
+
29
+ def aws_access_key_id
30
+ fetch(:aws_access_key_id)
31
+ end
32
+
33
+ def aws_secret_access_key
34
+ fetch(:aws_secret_access_key)
35
+ end
36
+
37
+ def aws_region
38
+ fetch(:aws_region)
39
+ end
40
+
41
+ def auto_scaling_group_name
42
+ fetch(:asg_rolling_group_name)
43
+ end
44
+
45
+ def ssh_options
46
+ fetch(:asg_rolling_ssh_options, fetch(:ssh_options))
47
+ end
48
+
49
+ def instance_overrides
50
+ fetch(:asg_rolling_instance_overrides)
51
+ end
52
+
53
+ def use_private_ip_address?
54
+ fetch(:asg_rolling_use_private_ip_address)
55
+ end
56
+
57
+ def keep_versions
58
+ fetch(:asg_rolling_keep_versions, fetch(:keep_releases))
59
+ end
60
+
61
+ def verbose?
62
+ fetch(:asg_rolling_verbose)
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capistrano
4
+ module ASG
5
+ module Rolling
6
+ # Adds the autoscale DSL to the Capistrano configuration:
7
+ #
8
+ # autoscale 'my-asg', user: 'deployer', roles: %w(app assets)
9
+ #
10
+ module DSL
11
+ def autoscale(name, properties = {})
12
+ group = Capistrano::ASG::Rolling::AutoscaleGroup.new(name, properties)
13
+ raise Capistrano::ASG::Rolling::NoAutoScalingGroup, "Auto Scaling Group #{name} could not be found." unless group.exists?
14
+
15
+ Capistrano::ASG::Rolling::Configuration.autoscale_groups << group
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+
22
+ extend Capistrano::ASG::Rolling::DSL # rubocop:disable Style/MixinUsage
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capistrano
4
+ module ASG
5
+ module Rolling
6
+ # Base class for exceptions.
7
+ class Exception < StandardError
8
+ end
9
+
10
+ class NoAutoScalingGroup < Capistrano::ASG::Rolling::Exception
11
+ end
12
+
13
+ class NoLaunchTemplate < Capistrano::ASG::Rolling::Exception
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module Capistrano
6
+ module ASG
7
+ module Rolling
8
+ # AWS EC2 instance.
9
+ class Instance
10
+ include AWS
11
+
12
+ attr_reader :id, :private_ip_address, :public_ip_address, :image_id, :autoscale_group
13
+ attr_accessor :auto_terminate, :terminated
14
+ alias auto_terminate? auto_terminate
15
+ alias terminated? terminated
16
+
17
+ def initialize(id, private_ip_address, public_ip_address, image_id, autoscale_group)
18
+ @id = id
19
+ @private_ip_address = private_ip_address
20
+ @public_ip_address = public_ip_address
21
+ @image_id = image_id
22
+ @autoscale_group = autoscale_group
23
+ @auto_terminate = true
24
+ @terminated = false
25
+ end
26
+
27
+ # Launch a new instance based on settings from Auto Scaling Group and associated Launch Template.
28
+ def self.run(autoscaling_group:, overrides: nil)
29
+ launch_template = autoscaling_group.launch_template
30
+
31
+ aws_ec2_client = autoscaling_group.aws_ec2_client
32
+ options = {
33
+ min_count: 1,
34
+ max_count: 1,
35
+ launch_template: {
36
+ launch_template_id: launch_template.id,
37
+ version: launch_template.version
38
+ }
39
+ }
40
+
41
+ # Add a subnet defined in the Auto Scaling Group, but only when the Launch Template
42
+ # does not define a network interface. Otherwise it will trigger the following error:
43
+ # => Network interfaces and an instance-level subnet ID may not be specified on the same request
44
+ options[:subnet_id] = autoscaling_group.subnet_ids.sample if launch_template.network_interfaces.empty?
45
+
46
+ # Optionally override settings in the Launch Template.
47
+ options.merge!(overrides) if overrides
48
+
49
+ options[:tag_specifications] = [
50
+ { resource_type: 'instance', tags: [{ key: 'Name', value: autoscaling_group.name_tag }] },
51
+ { resource_type: 'volume', tags: [{ key: 'Name', value: autoscaling_group.name_tag }] }
52
+ ]
53
+
54
+ response = aws_ec2_client.run_instances(options)
55
+
56
+ instance = response.instances.first
57
+
58
+ # Wait until the instance is running and has a public IP address.
59
+ aws_instance = ::Aws::EC2::Instance.new(instance.instance_id, client: aws_ec2_client)
60
+ instance = aws_instance.wait_until_running
61
+
62
+ new(instance.instance_id, instance.private_ip_address, instance.public_ip_address, instance.image_id, autoscaling_group)
63
+ end
64
+
65
+ def wait_for_ssh
66
+ started_at = Time.now
67
+
68
+ loop do
69
+ result = SSH.test?(ip_address, autoscale_group.properties[:user], Configuration.ssh_options)
70
+
71
+ break if result || Time.now - started_at > 300
72
+
73
+ sleep 1
74
+ end
75
+ end
76
+
77
+ def ip_address
78
+ Configuration.use_private_ip_address? ? private_ip_address : public_ip_address
79
+ end
80
+
81
+ def stop
82
+ aws_ec2_client.stop_instances(instance_ids: [id])
83
+ aws_ec2_client.wait_until(:instance_stopped, instance_ids: [id])
84
+ end
85
+
86
+ def terminate(wait: false)
87
+ aws_ec2_client.terminate_instances(instance_ids: [id])
88
+ aws_ec2_client.wait_until(:instance_terminated, instance_ids: [id]) if wait
89
+
90
+ @terminated = true
91
+ end
92
+
93
+ def create_ami(name: nil, description: nil)
94
+ now = Time.now
95
+ name ||= "#{autoscale_group.name_tag} on #{now.strftime('%Y-%m-%d')} at #{now.strftime('%H.%M.%S')}"
96
+
97
+ AMI.create(instance: self, name: name, description: description, tags: { 'Name' => autoscale_group.name_tag })
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capistrano
4
+ module ASG
5
+ module Rolling
6
+ # Collection of Instances. Runs commands on instances in parallel.
7
+ class Instances
8
+ include Enumerable
9
+
10
+ def initialize(instances = [])
11
+ @instances = instances
12
+ end
13
+
14
+ def <<(instance)
15
+ @instances << instance
16
+ end
17
+
18
+ def each(&block)
19
+ instances.each(&block)
20
+ end
21
+
22
+ def empty?
23
+ instances.empty?
24
+ end
25
+
26
+ def auto_terminate
27
+ self.class.new(select(&:auto_terminate?))
28
+ end
29
+
30
+ def with_image(image_id)
31
+ self.class.new(select { |instance| instance.image_id == image_id })
32
+ end
33
+
34
+ def wait_for_ssh
35
+ Parallel.run(instances, &:wait_for_ssh)
36
+ end
37
+
38
+ def stop
39
+ Parallel.run(instances, &:stop)
40
+ end
41
+
42
+ def terminate
43
+ Parallel.run(instances, &:terminate)
44
+ end
45
+
46
+ def create_ami(name: nil, description: nil)
47
+ Parallel.run(instances) do |instance|
48
+ instance.create_ami(name: name, description: description)
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def instances
55
+ @instances.reject(&:terminated?)
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capistrano
4
+ module ASG
5
+ module Rolling
6
+ # AWS EC2 Launch Template.
7
+ class LaunchTemplate
8
+ include AWS
9
+
10
+ attr_reader :id, :version, :default_version
11
+ alias default_version? default_version
12
+
13
+ def initialize(id, version, default_version: false)
14
+ @id = id
15
+ @version = version.to_s
16
+ @default_version = default_version
17
+ end
18
+
19
+ def create_version(image_id:, description: nil)
20
+ response = aws_ec2_client.create_launch_template_version(
21
+ launch_template_data: { image_id: image_id },
22
+ launch_template_id: id,
23
+ source_version: version,
24
+ version_description: description
25
+ )
26
+ version = response.launch_template_version
27
+
28
+ self.class.new(version.launch_template_id, version.version_number, default_version: version.default_version)
29
+ end
30
+
31
+ def delete
32
+ aws_ec2_client.delete_launch_template_versions(
33
+ launch_template_id: id,
34
+ versions: [version]
35
+ )
36
+ end
37
+
38
+ def ami
39
+ @ami ||= AMI.new(image_id)
40
+ end
41
+
42
+ def previous_versions
43
+ aws_ec2_client.describe_launch_template_versions(launch_template_id: id)
44
+ .launch_template_versions
45
+ .sort_by { |v| -v.version_number }
46
+ .select { |v| v.version_number < version_number }
47
+ .map { |v| self.class.new(v.launch_template_id, v.version_number, default_version: v.default_version) }
48
+ end
49
+
50
+ def version_number
51
+ aws_describe_launch_template_version.version_number
52
+ end
53
+
54
+ def image_id
55
+ aws_describe_launch_template_version.launch_template_data.image_id
56
+ end
57
+
58
+ def network_interfaces
59
+ aws_describe_launch_template_version.launch_template_data.network_interfaces
60
+ end
61
+
62
+ def security_group_ids
63
+ aws_describe_launch_template_version.launch_template_data.security_group_ids
64
+ end
65
+
66
+ # Object equality for Launch Templates is only by ID. Version number is deliberately not taken in account.
67
+ def ==(other)
68
+ id == other.id
69
+ end
70
+
71
+ alias eql? ==
72
+
73
+ def hash
74
+ id.hash
75
+ end
76
+
77
+ private
78
+
79
+ def aws_describe_launch_template_version
80
+ @aws_describe_launch_template_version ||=
81
+ aws_ec2_client.describe_launch_template_versions(launch_template_id: id, versions: [version])
82
+ .launch_template_versions.first
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capistrano
4
+ module ASG
5
+ module Rolling
6
+ # Logging support.
7
+ class Logger
8
+ def initialize(verbose: false)
9
+ @verbose = verbose
10
+ end
11
+
12
+ def info(text)
13
+ $stdout.puts format_text(text)
14
+ end
15
+
16
+ def error(text)
17
+ $stderr.puts color(format_text(text), :red) # rubocop:disable Style/StderrPuts
18
+ end
19
+
20
+ def verbose(text)
21
+ info(text) if @verbose
22
+ end
23
+
24
+ def bold(text, color = :light_white)
25
+ color(text, color, :bold)
26
+ end
27
+
28
+ def color(text, color, mode = nil)
29
+ _color.colorize(text, color, mode)
30
+ end
31
+
32
+ private
33
+
34
+ def format_text(text)
35
+ text.gsub(/\*\*(.+?)\*\*/, bold('\\1'))
36
+ end
37
+
38
+ def _color
39
+ @_color ||= SSHKit::Color.new($stdout)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent/array'
4
+
5
+ module Capistrano
6
+ module ASG
7
+ module Rolling
8
+ # Simple helper for running code in parallel.
9
+ module Parallel
10
+ module_function
11
+
12
+ def run(work)
13
+ result = Concurrent::Array.new
14
+ threads = []
15
+
16
+ work.each do |w|
17
+ threads << Thread.new do
18
+ result << yield(w)
19
+ end
20
+ end
21
+
22
+ threads.each(&:join)
23
+
24
+ result
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end