capistrano-asg-rolling 0.1.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,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