ecs_deploy 0.3.2 → 1.0.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.
@@ -6,12 +6,13 @@ module EcsDeploy
6
6
  :secret_access_key,
7
7
  :default_region,
8
8
  :deploy_wait_timeout,
9
- :ecs_service_role
9
+ :ecs_service_role,
10
+ :ecs_wait_until_services_stable_max_attempts,
11
+ :ecs_wait_until_services_stable_delay
10
12
 
11
13
  def initialize
12
14
  @log_level = :info
13
15
  @deploy_wait_timeout = 300
14
- @ecs_service_role = "ecsServiceRole"
15
16
  end
16
17
  end
17
18
  end
@@ -0,0 +1,173 @@
1
+ require "aws-sdk-autoscaling"
2
+ require "aws-sdk-ec2"
3
+ require "aws-sdk-ecs"
4
+
5
+ module EcsDeploy
6
+ class InstanceFluctuationManager
7
+ attr_reader :logger
8
+
9
+ MAX_UPDATABLE_ECS_CONTAINER_COUNT = 10
10
+ MAX_DETACHEABLE_EC2_INSTACE_COUNT = 20
11
+
12
+ def initialize(region:, cluster:, auto_scaling_group_name:, desired_capacity:, logger:)
13
+ @region = region
14
+ @cluster = cluster
15
+ @auto_scaling_group_name = auto_scaling_group_name
16
+ @desired_capacity = desired_capacity
17
+ @logger = logger
18
+ end
19
+
20
+ def increase
21
+ asg = as_client.describe_auto_scaling_groups(auto_scaling_group_names: [@auto_scaling_group_name]).auto_scaling_groups.first
22
+
23
+ @logger.info("Increase desired capacity of #{@auto_scaling_group_name}: #{asg.desired_capacity} => #{asg.max_size}")
24
+ as_client.update_auto_scaling_group(auto_scaling_group_name: @auto_scaling_group_name, desired_capacity: asg.max_size)
25
+
26
+ # Run in background because increasing instances may take time
27
+ Thread.new do
28
+ loop do
29
+ cluster = ecs_client.describe_clusters(clusters: [@cluster]).clusters.first
30
+ instance_count = cluster.registered_container_instances_count
31
+ if instance_count == asg.max_size
32
+ @logger.info("Succeeded in increasing instances!")
33
+ break
34
+ end
35
+ @logger.info("Current registered instance count: #{instance_count}")
36
+ sleep 5
37
+ end
38
+ end
39
+ end
40
+
41
+ def decrease
42
+ asg = as_client.describe_auto_scaling_groups(auto_scaling_group_names: [@auto_scaling_group_name]).auto_scaling_groups.first
43
+
44
+ decrease_count = asg.desired_capacity - @desired_capacity
45
+ if decrease_count <= 0
46
+ @logger.info("The capacity is already #{asg.desired_capacity}")
47
+ return
48
+ end
49
+ @logger.info("Decrease desired capacity of #{@auto_scaling_group_name}: #{asg.desired_capacity} => #{@desired_capacity}")
50
+
51
+ container_instance_arns = ecs_client.list_container_instances(
52
+ cluster: @cluster
53
+ ).container_instance_arns
54
+ container_instances = ecs_client.describe_container_instances(
55
+ cluster: @cluster,
56
+ container_instances: container_instance_arns
57
+ ).container_instances
58
+
59
+ az_to_container_instances = container_instances.sort_by {|ci| - ci.running_tasks_count }.group_by do |ci|
60
+ ci.attributes.find {|attribute| attribute.name == "ecs.availability-zone" }.value
61
+ end
62
+ if az_to_container_instances.empty?
63
+ @logger.info("There are no instances to terminate.")
64
+ return
65
+ end
66
+
67
+ target_container_instances = extract_target_container_instances(decrease_count, az_to_container_instances)
68
+
69
+ threads = []
70
+ all_running_task_arns = []
71
+ target_container_instances.map(&:container_instance_arn).each_slice(MAX_UPDATABLE_ECS_CONTAINER_COUNT) do |arns|
72
+ ecs_client.update_container_instances_state(
73
+ cluster: @cluster,
74
+ container_instances: arns,
75
+ status: "DRAINING"
76
+ )
77
+ arns.each do |arn|
78
+ threads << Thread.new(arn) do |a|
79
+ all_running_task_arns.concat(stop_tasks(a))
80
+ end
81
+ end
82
+ end
83
+
84
+ threads.each(&:join)
85
+ ecs_client.wait_until(:tasks_stopped, cluster: @cluster, tasks: all_running_task_arns) unless all_running_task_arns.empty?
86
+ @logger.info("All running tasks are stopped")
87
+
88
+ instance_ids = target_container_instances.map(&:ec2_instance_id)
89
+ terminate_instances(instance_ids)
90
+ @logger.info("Succeeded in decreasing instances!")
91
+ end
92
+
93
+ private
94
+
95
+ def aws_params
96
+ {
97
+ access_key_id: EcsDeploy.config.access_key_id,
98
+ secret_access_key: EcsDeploy.config.secret_access_key,
99
+ region: @region,
100
+ logger: @logger
101
+ }.reject do |_key, value|
102
+ value.nil?
103
+ end
104
+ end
105
+
106
+ def as_client
107
+ @as_client ||= Aws::AutoScaling::Client.new(aws_params)
108
+ end
109
+
110
+ def ec2_client
111
+ @ec2_client ||= Aws::EC2::Client.new(aws_params)
112
+ end
113
+
114
+ def ecs_client
115
+ @ecs_client ||= Aws::ECS::Client.new(aws_params)
116
+ end
117
+
118
+ # Extract container instances to terminate considering AZ balance
119
+ def extract_target_container_instances(decrease_count, az_to_container_instances)
120
+ target_container_instances = []
121
+ decrease_count.times do
122
+ @logger.debug do
123
+ "AZ balance: #{az_to_container_instances.sort_by {|az, _| az }.map {|az, instances| [az, instances.size] }.to_h}"
124
+ end
125
+ az = az_to_container_instances.max_by {|_az, instances| instances.size }.first
126
+ target_container_instances << az_to_container_instances[az].pop
127
+ end
128
+ @logger.info do
129
+ "AZ balance: #{az_to_container_instances.sort_by {|az, _| az }.map {|az, instances| [az, instances.size] }.to_h}"
130
+ end
131
+
132
+ target_container_instances
133
+ end
134
+
135
+ def stop_tasks(arn)
136
+ running_task_arns = ecs_client.list_tasks(cluster: @cluster, container_instance: arn).task_arns
137
+ unless running_task_arns.empty?
138
+ running_tasks = ecs_client.describe_tasks(cluster: @cluster, tasks: running_task_arns).tasks
139
+ running_tasks.each do |task|
140
+ ecs_client.stop_task(cluster: @cluster, task: task.task_arn) if task.group.start_with?("family:")
141
+ end
142
+ end
143
+ @logger.info("Tasks running on #{arn.split('/').last} will be stopped")
144
+ running_task_arns
145
+ end
146
+
147
+ def terminate_instances(instance_ids)
148
+ if instance_ids.empty?
149
+ @logger.info("There are no instances to terminate.")
150
+ return
151
+ end
152
+ instance_ids.each_slice(MAX_DETACHEABLE_EC2_INSTACE_COUNT) do |ids|
153
+ as_client.detach_instances(
154
+ auto_scaling_group_name: @auto_scaling_group_name,
155
+ instance_ids: ids,
156
+ should_decrement_desired_capacity: true
157
+ )
158
+ end
159
+
160
+ ec2_client.terminate_instances(instance_ids: instance_ids)
161
+
162
+ ec2_client.wait_until(:instance_terminated, instance_ids: instance_ids) do |w|
163
+ w.before_wait do |attempts, response|
164
+ @logger.info("Waiting for stopping all instances...#{attempts}")
165
+ instances = response.reservations.flat_map(&:instances)
166
+ instances.sort_by(&:instance_id).each do |instance|
167
+ @logger.info("#{instance.instance_id}\t#{instance.state.name}")
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
@@ -1,3 +1,4 @@
1
+ require 'aws-sdk-cloudwatchevents'
1
2
  require 'timeout'
2
3
 
3
4
  module EcsDeploy
@@ -8,7 +9,7 @@ module EcsDeploy
8
9
 
9
10
  def initialize(
10
11
  cluster:, rule_name:, schedule_expression:, enabled: true, description: nil, target_id: nil,
11
- task_definition_name:, revision: nil, task_count: nil, role_arn:,
12
+ task_definition_name:, revision: nil, task_count: nil, role_arn:, network_configuration: nil, launch_type: nil, platform_version: nil, group: nil,
12
13
  region: nil, container_overrides: nil
13
14
  )
14
15
  @cluster = cluster
@@ -21,10 +22,15 @@ module EcsDeploy
21
22
  @task_count = task_count || 1
22
23
  @revision = revision
23
24
  @role_arn = role_arn
24
- @region = region || EcsDeploy.config.default_region || ENV["AWS_DEFAULT_REGION"]
25
+ @network_configuration = network_configuration
26
+ @launch_type = launch_type || "EC2"
27
+ @platform_version = platform_version
28
+ @group = group
29
+ region ||= EcsDeploy.config.default_region
25
30
  @container_overrides = container_overrides
26
31
 
27
- @client = Aws::ECS::Client.new(region: @region)
32
+ @client = region ? Aws::ECS::Client.new(region: region) : Aws::ECS::Client.new
33
+ @region = @client.config.region
28
34
  @cloud_watch_events = Aws::CloudWatchEvents::Client.new(region: @region)
29
35
  end
30
36
 
@@ -66,8 +72,14 @@ module EcsDeploy
66
72
  ecs_parameters: {
67
73
  task_definition_arn: task_definition_arn,
68
74
  task_count: @task_count,
75
+ network_configuration: @network_configuration,
76
+ launch_type: @launch_type,
77
+ platform_version: @platform_version,
78
+ group: @group,
69
79
  },
70
80
  }
81
+ target[:ecs_parameters].compact!
82
+
71
83
  if @container_overrides
72
84
  target.merge!(input: { containerOverrides: @container_overrides }.to_json)
73
85
  end
@@ -5,13 +5,23 @@ module EcsDeploy
5
5
  CHECK_INTERVAL = 5
6
6
  MAX_DESCRIBE_SERVICES = 10
7
7
 
8
- attr_reader :cluster, :region, :service_name
8
+ attr_reader :cluster, :region, :service_name, :delete
9
9
 
10
10
  def initialize(
11
11
  cluster:, service_name:, task_definition_name: nil, revision: nil,
12
12
  load_balancers: nil,
13
13
  desired_count: nil, deployment_configuration: {maximum_percent: 200, minimum_healthy_percent: 100},
14
- region: nil
14
+ launch_type: nil,
15
+ placement_constraints: [],
16
+ placement_strategy: [],
17
+ network_configuration: nil,
18
+ health_check_grace_period_seconds: nil,
19
+ scheduling_strategy: 'REPLICA',
20
+ enable_ecs_managed_tags: nil,
21
+ tags: nil,
22
+ propagate_tags: nil,
23
+ region: nil,
24
+ delete: false
15
25
  )
16
26
  @cluster = cluster
17
27
  @service_name = service_name
@@ -19,11 +29,24 @@ module EcsDeploy
19
29
  @load_balancers = load_balancers
20
30
  @desired_count = desired_count
21
31
  @deployment_configuration = deployment_configuration
32
+ @launch_type = launch_type
33
+ @placement_constraints = placement_constraints
34
+ @placement_strategy = placement_strategy
35
+ @network_configuration = network_configuration
36
+ @health_check_grace_period_seconds = health_check_grace_period_seconds
37
+ @scheduling_strategy = scheduling_strategy
22
38
  @revision = revision
23
- @region = region || EcsDeploy.config.default_region || ENV["AWS_DEFAULT_REGION"]
39
+ @enable_ecs_managed_tags = enable_ecs_managed_tags
40
+ @tags = tags
41
+ @propagate_tags = propagate_tags
42
+
24
43
  @response = nil
25
44
 
26
- @client = Aws::ECS::Client.new(region: @region)
45
+ region ||= EcsDeploy.config.default_region
46
+ @client = region ? Aws::ECS::Client.new(region: region) : Aws::ECS::Client.new
47
+ @region = @client.config.region
48
+
49
+ @delete = delete
27
50
  end
28
51
 
29
52
  def current_task_definition_arn
@@ -37,35 +60,91 @@ module EcsDeploy
37
60
  cluster: @cluster,
38
61
  task_definition: task_definition_name_with_revision,
39
62
  deployment_configuration: @deployment_configuration,
63
+ network_configuration: @network_configuration,
64
+ health_check_grace_period_seconds: @health_check_grace_period_seconds,
40
65
  }
41
66
  if res.services.select{ |s| s.status == 'ACTIVE' }.empty?
67
+ return if @delete
68
+
42
69
  service_options.merge!({
43
70
  service_name: @service_name,
44
71
  desired_count: @desired_count.to_i,
72
+ launch_type: @launch_type,
73
+ placement_constraints: @placement_constraints,
74
+ placement_strategy: @placement_strategy,
75
+ enable_ecs_managed_tags: @enable_ecs_managed_tags,
76
+ tags: @tags,
77
+ propagate_tags: @propagate_tags,
45
78
  })
46
- if @load_balancers
79
+
80
+ if @load_balancers && EcsDeploy.config.ecs_service_role
47
81
  service_options.merge!({
48
82
  role: EcsDeploy.config.ecs_service_role,
83
+ })
84
+ end
85
+
86
+ if @load_balancers
87
+ service_options.merge!({
49
88
  load_balancers: @load_balancers,
50
89
  })
51
90
  end
91
+
92
+ if @scheduling_strategy == 'DAEMON'
93
+ service_options[:scheduling_strategy] = @scheduling_strategy
94
+ service_options.delete(:desired_count)
95
+ end
52
96
  @response = @client.create_service(service_options)
53
97
  EcsDeploy.logger.info "create service [#{@service_name}] [#{@region}] [#{Paint['OK', :green]}]"
54
98
  else
99
+ return delete_service if @delete
100
+
55
101
  service_options.merge!({service: @service_name})
56
102
  service_options.merge!({desired_count: @desired_count}) if @desired_count
103
+ update_tags(@service_name, @tags)
57
104
  @response = @client.update_service(service_options)
58
105
  EcsDeploy.logger.info "update service [#{@service_name}] [#{@region}] [#{Paint['OK', :green]}]"
59
106
  end
60
107
  end
61
108
 
109
+ def delete_service
110
+ if @scheduling_strategy != 'DAEMON'
111
+ @client.update_service(cluster: @cluster, service: @service_name, desired_count: 0)
112
+ sleep 1
113
+ end
114
+ @client.delete_service(cluster: @cluster, service: @service_name)
115
+ EcsDeploy.logger.info "delete service [#{@service_name}] [#{@region}] [#{Paint['OK', :green]}]"
116
+ end
117
+
118
+ def update_tags(service_name, tags)
119
+ service_arn = @client.describe_services(cluster: @cluster, services: [service_name]).services.first.service_arn
120
+ if service_arn.split('/').size == 2
121
+ if tags
122
+ EcsDeploy.logger.warn "#{service_name} doesn't support tagging operations, so tags are ignored. Long arn format must be used for tagging operations."
123
+ end
124
+ return
125
+ end
126
+
127
+ tags ||= []
128
+ current_tag_keys = @client.list_tags_for_resource(resource_arn: service_arn).tags.map(&:key)
129
+ deleted_tag_keys = current_tag_keys - tags.map { |t| t[:key] }
130
+
131
+ unless deleted_tag_keys.empty?
132
+ @client.untag_resource(resource_arn: service_arn, tag_keys: deleted_tag_keys)
133
+ end
134
+
135
+ unless tags.empty?
136
+ @client.tag_resource(resource_arn: service_arn, tags: tags)
137
+ end
138
+ end
139
+
62
140
  def wait_running
63
141
  return if @response.nil?
64
142
 
65
143
  service = @response.service
66
144
 
67
145
  @client.wait_until(:services_stable, cluster: @cluster, services: [service.service_name]) do |w|
68
- w.delay = 10
146
+ w.delay = EcsDeploy.config.ecs_wait_until_services_stable_delay if EcsDeploy.config.ecs_wait_until_services_stable_delay
147
+ w.max_attempts = EcsDeploy.config.ecs_wait_until_services_stable_max_attempts if EcsDeploy.config.ecs_wait_until_services_stable_max_attempts
69
148
 
70
149
  w.before_attempt do
71
150
  EcsDeploy.logger.info "wait service stable [#{service.service_name}]"
@@ -76,8 +155,11 @@ module EcsDeploy
76
155
  def self.wait_all_running(services)
77
156
  services.group_by { |s| [s.cluster, s.region] }.each do |(cl, region), ss|
78
157
  client = Aws::ECS::Client.new(region: region)
79
- ss.map(&:service_name).each_slice(MAX_DESCRIBE_SERVICES) do |chunked_service_names|
158
+ ss.reject(&:delete).map(&:service_name).each_slice(MAX_DESCRIBE_SERVICES) do |chunked_service_names|
80
159
  client.wait_until(:services_stable, cluster: cl, services: chunked_service_names) do |w|
160
+ w.delay = EcsDeploy.config.ecs_wait_until_services_stable_delay if EcsDeploy.config.ecs_wait_until_services_stable_delay
161
+ w.max_attempts = EcsDeploy.config.ecs_wait_until_services_stable_max_attempts if EcsDeploy.config.ecs_wait_until_services_stable_max_attempts
162
+
81
163
  w.before_attempt do
82
164
  EcsDeploy.logger.info "wait service stable [#{chunked_service_names.join(", ")}]"
83
165
  end
@@ -1,37 +1,47 @@
1
1
  module EcsDeploy
2
2
  class TaskDefinition
3
3
  def self.deregister(arn, region: nil)
4
- region = region || EcsDeploy.config.default_region || ENV["AWS_DEFAULT_REGION"]
5
- client = Aws::ECS::Client.new(region: region)
4
+ region ||= EcsDeploy.config.default_region
5
+ client = region ? Aws::ECS::Client.new(region: region) : Aws::ECS::Client.new
6
6
  client.deregister_task_definition({
7
7
  task_definition: arn,
8
8
  })
9
- EcsDeploy.logger.info "deregister task definition [#{arn}] [#{region}] [#{Paint['OK', :green]}]"
9
+ EcsDeploy.logger.info "deregister task definition [#{arn}] [#{client.config.region}] [#{Paint['OK', :green]}]"
10
10
  end
11
11
 
12
12
  def initialize(
13
13
  task_definition_name:, region: nil,
14
14
  network_mode: "bridge", volumes: [], container_definitions: [], placement_constraints: [],
15
- task_role_arn: nil
15
+ task_role_arn: nil,
16
+ execution_role_arn: nil,
17
+ requires_compatibilities: nil,
18
+ cpu: nil, memory: nil,
19
+ tags: nil
16
20
  )
17
21
  @task_definition_name = task_definition_name
18
22
  @task_role_arn = task_role_arn
19
- @region = region || EcsDeploy.config.default_region || ENV["AWS_DEFAULT_REGION"]
23
+ @execution_role_arn = execution_role_arn
24
+ region ||= EcsDeploy.config.default_region
20
25
 
21
26
  @container_definitions = container_definitions.map do |cd|
22
27
  if cd[:docker_labels]
23
28
  cd[:docker_labels] = cd[:docker_labels].map { |k, v| [k.to_s, v] }.to_h
24
29
  end
25
- if cd[:log_configuration] && cd[:log_configuration][:options]
26
- cd[:log_configuration][:options] = cd[:log_configuration][:options].map { |k, v| [k.to_s, v] }.to_h
30
+ if cd.dig(:log_configuration, :options)
31
+ cd[:log_configuration][:options] = cd.dig(:log_configuration, :options).map { |k, v| [k.to_s, v] }.to_h
27
32
  end
28
33
  cd
29
34
  end
30
35
  @volumes = volumes
31
36
  @network_mode = network_mode
32
37
  @placement_constraints = placement_constraints
38
+ @requires_compatibilities = requires_compatibilities
39
+ @cpu = cpu&.to_s
40
+ @memory = memory&.to_s
41
+ @tags = tags
33
42
 
34
- @client = Aws::ECS::Client.new(region: @region)
43
+ @client = region ? Aws::ECS::Client.new(region: region) : Aws::ECS::Client.new
44
+ @region = @client.config.region
35
45
  end
36
46
 
37
47
  def recent_task_definition_arns
@@ -52,6 +62,10 @@ module EcsDeploy
52
62
  volumes: @volumes,
53
63
  placement_constraints: @placement_constraints,
54
64
  task_role_arn: @task_role_arn,
65
+ execution_role_arn: @execution_role_arn,
66
+ requires_compatibilities: @requires_compatibilities,
67
+ cpu: @cpu, memory: @memory,
68
+ tags: @tags
55
69
  })
56
70
  EcsDeploy.logger.info "register task definition [#{@task_definition_name}] [#{@region}] [#{Paint['OK', :green]}]"
57
71
  res.task_definition