ecs_deploy_cli 0.2.2 → 0.5.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/ecs_deploy_cli.rb +1 -0
- data/lib/ecs_deploy_cli/cli.rb +11 -1
- data/lib/ecs_deploy_cli/cloudformation/default.yml +411 -0
- data/lib/ecs_deploy_cli/dsl/cluster.rb +70 -0
- data/lib/ecs_deploy_cli/dsl/parser.rb +6 -2
- data/lib/ecs_deploy_cli/dsl/service.rb +31 -2
- data/lib/ecs_deploy_cli/runner.rb +7 -2
- data/lib/ecs_deploy_cli/runners/base.rb +61 -0
- data/lib/ecs_deploy_cli/runners/setup.rb +162 -0
- data/lib/ecs_deploy_cli/runners/ssh.rb +44 -10
- data/lib/ecs_deploy_cli/runners/update_crons.rb +16 -7
- data/lib/ecs_deploy_cli/version.rb +1 -1
- data/spec/ecs_deploy_cli/cli_spec.rb +8 -0
- data/spec/ecs_deploy_cli/dsl/cluster_spec.rb +48 -0
- data/spec/ecs_deploy_cli/dsl/service_spec.rb +31 -0
- data/spec/ecs_deploy_cli/runner_spec.rb +164 -23
- data/spec/ecs_deploy_cli/runners/base_spec.rb +57 -0
- data/spec/support/ECSFile +13 -1
- metadata +70 -5
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module EcsDeployCli
|
4
|
+
module DSL
|
5
|
+
class Cluster
|
6
|
+
include AutoOptions
|
7
|
+
|
8
|
+
allowed_options :instances_count, :instance_type, :ebs_volume_size, :keypair_name
|
9
|
+
|
10
|
+
def initialize(name, config)
|
11
|
+
@config = config
|
12
|
+
_options[:name] = name.to_s
|
13
|
+
end
|
14
|
+
|
15
|
+
def vpc(id = nil, &block)
|
16
|
+
@vpc = VPC.new(id)
|
17
|
+
@vpc.instance_exec(&block)
|
18
|
+
end
|
19
|
+
|
20
|
+
def as_definition
|
21
|
+
{
|
22
|
+
instances_count: 1,
|
23
|
+
|
24
|
+
device_name: '/dev/xvda',
|
25
|
+
ebs_volume_size: 22,
|
26
|
+
ebs_volume_type: 'gp2',
|
27
|
+
|
28
|
+
root_device_name: '/dev/xvdcz',
|
29
|
+
root_ebs_volume_size: 30,
|
30
|
+
|
31
|
+
vpc: @vpc&.as_definition
|
32
|
+
}.merge(_options)
|
33
|
+
end
|
34
|
+
|
35
|
+
class VPC
|
36
|
+
include AutoOptions
|
37
|
+
allowed_options :cidr, :subnet1, :subnet2, :subnet3
|
38
|
+
|
39
|
+
def initialize(id)
|
40
|
+
_options[:id] = id
|
41
|
+
end
|
42
|
+
|
43
|
+
def availability_zones(*values)
|
44
|
+
_options[:availability_zones] = values.join(',')
|
45
|
+
end
|
46
|
+
|
47
|
+
def subnet_ids(*values)
|
48
|
+
_options[:subnet_ids] = values.join(',')
|
49
|
+
end
|
50
|
+
|
51
|
+
def as_definition
|
52
|
+
validate! if _options[:id]
|
53
|
+
|
54
|
+
{
|
55
|
+
cidr: '10.0.0.0/16',
|
56
|
+
subnet1: '10.0.0.0/24',
|
57
|
+
subnet2: '10.0.1.0/24',
|
58
|
+
subnet3: '10.0.2.0/24'
|
59
|
+
}.merge(_options)
|
60
|
+
end
|
61
|
+
|
62
|
+
def validate!
|
63
|
+
[
|
64
|
+
:subnet1, :subnet_ids, :availability_zones
|
65
|
+
].each { |key| raise "Missing required parameter #{key}" unless _options[key] }
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -40,8 +40,11 @@ module EcsDeployCli
|
|
40
40
|
@crons[name].instance_exec(&block)
|
41
41
|
end
|
42
42
|
|
43
|
-
def cluster(name)
|
43
|
+
def cluster(name, &block)
|
44
44
|
config[:cluster] = name
|
45
|
+
@cluster ||= {}.with_indifferent_access
|
46
|
+
@cluster = Cluster.new(name, config)
|
47
|
+
@cluster.instance_exec(&block) if block
|
45
48
|
end
|
46
49
|
|
47
50
|
def config
|
@@ -58,7 +61,8 @@ module EcsDeployCli
|
|
58
61
|
resolved_containers = (@containers || {}).transform_values(&:as_definition)
|
59
62
|
resolved_tasks = (@tasks || {}).transform_values { |t| t.as_definition(resolved_containers) }
|
60
63
|
resolved_crons = (@crons || {}).transform_values { |t| t.as_definition(resolved_tasks) }
|
61
|
-
|
64
|
+
resolved_cluster = @cluster.as_definition
|
65
|
+
[@services, resolved_tasks, resolved_crons, resolved_cluster]
|
62
66
|
end
|
63
67
|
|
64
68
|
def self.load(file)
|
@@ -18,13 +18,42 @@ module EcsDeployCli
|
|
18
18
|
_options
|
19
19
|
end
|
20
20
|
|
21
|
+
def load_balancer(name, &block)
|
22
|
+
@load_balancers ||= []
|
23
|
+
|
24
|
+
load_balancer = LoadBalancer.new(name, @config)
|
25
|
+
load_balancer.instance_exec(&block)
|
26
|
+
|
27
|
+
@load_balancers << load_balancer
|
28
|
+
end
|
29
|
+
|
21
30
|
def as_definition(task)
|
22
31
|
{
|
23
32
|
cluster: @config[:cluster],
|
24
|
-
service:
|
25
|
-
task_definition: task
|
33
|
+
service: _options[:service],
|
34
|
+
task_definition: task,
|
35
|
+
load_balancers: @load_balancers&.map(&:as_definition) || []
|
26
36
|
}
|
27
37
|
end
|
38
|
+
|
39
|
+
class LoadBalancer
|
40
|
+
include AutoOptions
|
41
|
+
allowed_options :container_name, :container_port
|
42
|
+
|
43
|
+
def initialize(name, config)
|
44
|
+
_options[:load_balancer_name] = name
|
45
|
+
@config = config
|
46
|
+
end
|
47
|
+
|
48
|
+
def target_group_arn(value)
|
49
|
+
_options[:target_group_arn] = "arn:aws:elasticloadbalancing:#{@config[:aws_region]}:#{@config[:aws_profile_id]}:targetgroup/#{value}"
|
50
|
+
_options.delete(:load_balancer_name)
|
51
|
+
end
|
52
|
+
|
53
|
+
def as_definition
|
54
|
+
_options
|
55
|
+
end
|
56
|
+
end
|
28
57
|
end
|
29
58
|
end
|
30
59
|
end
|
@@ -7,6 +7,7 @@ require 'ecs_deploy_cli/runners/diff'
|
|
7
7
|
require 'ecs_deploy_cli/runners/update_crons'
|
8
8
|
require 'ecs_deploy_cli/runners/update_services'
|
9
9
|
require 'ecs_deploy_cli/runners/run_task'
|
10
|
+
require 'ecs_deploy_cli/runners/setup'
|
10
11
|
|
11
12
|
module EcsDeployCli
|
12
13
|
class Runner
|
@@ -14,6 +15,10 @@ module EcsDeployCli
|
|
14
15
|
@parser = parser
|
15
16
|
end
|
16
17
|
|
18
|
+
def setup!
|
19
|
+
EcsDeployCli::Runners::Setup.new(@parser).run!
|
20
|
+
end
|
21
|
+
|
17
22
|
def validate!
|
18
23
|
EcsDeployCli::Runners::Validate.new(@parser).run!
|
19
24
|
end
|
@@ -26,8 +31,8 @@ module EcsDeployCli
|
|
26
31
|
EcsDeployCli::Runners::RunTask.new(@parser).run!(task_name, launch_type: launch_type, security_groups: security_groups, subnets: subnets)
|
27
32
|
end
|
28
33
|
|
29
|
-
def ssh
|
30
|
-
EcsDeployCli::Runners::SSH.new(@parser).run!
|
34
|
+
def ssh(**options)
|
35
|
+
EcsDeployCli::Runners::SSH.new(@parser).run!(options)
|
31
36
|
end
|
32
37
|
|
33
38
|
def diff
|
@@ -11,14 +11,35 @@ module EcsDeployCli
|
|
11
11
|
raise NotImplementedError, 'abstract method'
|
12
12
|
end
|
13
13
|
|
14
|
+
def update_task(definition)
|
15
|
+
_update_task(definition)
|
16
|
+
end
|
17
|
+
|
14
18
|
protected
|
15
19
|
|
16
20
|
def _update_task(definition)
|
21
|
+
definition[:container_definitions].each do |container|
|
22
|
+
next unless container.dig(:log_configuration, :log_driver) == 'awslogs'
|
23
|
+
|
24
|
+
_create_cloudwatch_logs_if_needed(container.dig(:log_configuration, :options, 'awslogs-group'))
|
25
|
+
end
|
26
|
+
|
17
27
|
ecs_client.register_task_definition(
|
18
28
|
definition
|
19
29
|
).to_h[:task_definition]
|
20
30
|
end
|
21
31
|
|
32
|
+
def _create_cloudwatch_logs_if_needed(prefix)
|
33
|
+
log_group = cwl_client.describe_log_groups(log_group_name_prefix: prefix, limit: 1).to_h[:log_groups]
|
34
|
+
return if log_group.any?
|
35
|
+
|
36
|
+
cwl_client.create_log_group(log_group_name: prefix)
|
37
|
+
cwl_client.put_retention_policy(
|
38
|
+
log_group_name: prefix,
|
39
|
+
retention_in_days: 14
|
40
|
+
)
|
41
|
+
end
|
42
|
+
|
22
43
|
def ec2_client
|
23
44
|
@ec2_client ||= begin
|
24
45
|
require 'aws-sdk-ec2'
|
@@ -46,6 +67,46 @@ module EcsDeployCli
|
|
46
67
|
end
|
47
68
|
end
|
48
69
|
|
70
|
+
def ssm_client
|
71
|
+
@cwl_client ||= begin
|
72
|
+
require 'aws-sdk-ssm'
|
73
|
+
Aws::SSM::Client.new(
|
74
|
+
profile: ENV.fetch('AWS_PROFILE', 'default'),
|
75
|
+
region: config[:aws_region]
|
76
|
+
)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def cwl_client
|
81
|
+
@cwl_client ||= begin
|
82
|
+
require 'aws-sdk-cloudwatchlogs'
|
83
|
+
Aws::CloudWatchLogs::Client.new(
|
84
|
+
profile: ENV.fetch('AWS_PROFILE', 'default'),
|
85
|
+
region: config[:aws_region]
|
86
|
+
)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def cf_client
|
91
|
+
@cl_client ||= begin
|
92
|
+
require 'aws-sdk-cloudformation'
|
93
|
+
Aws::CloudFormation::Client.new(
|
94
|
+
profile: ENV.fetch('AWS_PROFILE', 'default'),
|
95
|
+
region: config[:aws_region]
|
96
|
+
)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def iam_client
|
101
|
+
@iam_client ||= begin
|
102
|
+
require 'aws-sdk-iam'
|
103
|
+
Aws::IAM::Client.new(
|
104
|
+
profile: ENV.fetch('AWS_PROFILE', 'default'),
|
105
|
+
region: config[:aws_region]
|
106
|
+
)
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
49
110
|
def config
|
50
111
|
@parser.config
|
51
112
|
end
|
@@ -0,0 +1,162 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module EcsDeployCli
|
4
|
+
module Runners
|
5
|
+
class Setup < Base
|
6
|
+
REQUIRED_ECS_ROLES = {
|
7
|
+
'ecsInstanceRole' => 'https://docs.aws.amazon.com/batch/latest/userguide/instance_IAM_role.html',
|
8
|
+
'ecsTaskExecutionRole' => 'https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_execution_IAM_role.html'
|
9
|
+
}.freeze
|
10
|
+
class SetupError < StandardError; end
|
11
|
+
|
12
|
+
def run!
|
13
|
+
services, resolved_tasks, _, cluster_options = @parser.resolve
|
14
|
+
|
15
|
+
ensure_ecs_roles_exists!
|
16
|
+
|
17
|
+
setup_cluster! cluster_options
|
18
|
+
setup_services! services, resolved_tasks: resolved_tasks
|
19
|
+
rescue SetupError => e
|
20
|
+
EcsDeployCli.logger.info e.message
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def setup_cluster!(cluster_options)
|
26
|
+
if cluster_exists?
|
27
|
+
EcsDeployCli.logger.info 'Cluster already created, skipping.'
|
28
|
+
return
|
29
|
+
end
|
30
|
+
|
31
|
+
EcsDeployCli.logger.info "Creating cluster #{config[:cluster]}..."
|
32
|
+
|
33
|
+
create_keypair_if_required! cluster_options
|
34
|
+
params = create_params(cluster_options)
|
35
|
+
|
36
|
+
ecs_client.create_cluster(
|
37
|
+
cluster_name: config[:cluster]
|
38
|
+
)
|
39
|
+
EcsDeployCli.logger.info 'Cluster created, now running cloudformation...'
|
40
|
+
|
41
|
+
stack_name = "EC2ContainerService-#{config[:cluster]}"
|
42
|
+
|
43
|
+
cf_client.create_stack(
|
44
|
+
stack_name: stack_name,
|
45
|
+
template_body: File.read(File.join(__dir__, '..', 'cloudformation', 'default.yml')),
|
46
|
+
on_failure: 'ROLLBACK',
|
47
|
+
parameters: format_cloudformation_params(params)
|
48
|
+
)
|
49
|
+
|
50
|
+
cf_client.wait_until(:stack_create_complete, { stack_name: stack_name }, delay: 30, max_attempts: 120)
|
51
|
+
EcsDeployCli.logger.info "Cluster #{config[:cluster]} created! 🎉"
|
52
|
+
end
|
53
|
+
|
54
|
+
def setup_services!(services, resolved_tasks:)
|
55
|
+
services.each do |service_name, service_definition|
|
56
|
+
existing_services = ecs_client.describe_services(cluster: config[:cluster], services: [service_name]).to_h[:services].select { |s| s[:status] != 'INACTIVE' }
|
57
|
+
if existing_services.any?
|
58
|
+
EcsDeployCli.logger.info "Service #{service_name} already created, skipping."
|
59
|
+
next
|
60
|
+
end
|
61
|
+
|
62
|
+
EcsDeployCli.logger.info "Creating service #{service_name}..."
|
63
|
+
task_definition = _update_task resolved_tasks[service_definition.options[:task]]
|
64
|
+
task_name = "#{task_definition[:family]}:#{task_definition[:revision]}"
|
65
|
+
|
66
|
+
ecs_client.create_service(
|
67
|
+
cluster: config[:cluster],
|
68
|
+
desired_count: 1, # FIXME: this should be a parameter
|
69
|
+
load_balancers: service_definition.as_definition(task_definition)[:load_balancers],
|
70
|
+
service_name: service_name,
|
71
|
+
task_definition: task_name
|
72
|
+
)
|
73
|
+
EcsDeployCli.logger.info "Service #{service_name} created!"
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def create_params(cluster_options)
|
78
|
+
raise ArgumentError, 'Missing vpc configuration' unless cluster_options[:vpc]
|
79
|
+
|
80
|
+
{
|
81
|
+
'AsgMaxSize' => cluster_options[:instances_count],
|
82
|
+
'AutoAssignPublicIp' => 'INHERIT',
|
83
|
+
'ConfigureDataVolume' => false,
|
84
|
+
'ConfigureRootVolume' => true,
|
85
|
+
'DeviceName' => cluster_options[:device_name],
|
86
|
+
'EbsVolumeSize' => cluster_options[:ebs_volume_size],
|
87
|
+
'EbsVolumeType' => cluster_options[:ebs_volume_type],
|
88
|
+
'EcsAmiId' => load_ami_id,
|
89
|
+
'EcsClusterName' => config[:cluster],
|
90
|
+
'EcsEndpoint' => nil,
|
91
|
+
'EcsInstanceType' => cluster_options[:instance_type],
|
92
|
+
'IamRoleInstanceProfile' => "arn:aws:iam::#{config[:aws_profile_id]}:instance-profile/ecsInstanceRole",
|
93
|
+
'IamSpotFleetRoleArn' => nil,
|
94
|
+
'IsWindows' => false,
|
95
|
+
'KeyName' => cluster_options[:keypair_name],
|
96
|
+
'RootDeviceName' => cluster_options[:root_device_name],
|
97
|
+
'RootEbsVolumeSize' => cluster_options[:root_ebs_volume_size],
|
98
|
+
|
99
|
+
##### TODO: Implement this feature
|
100
|
+
'SecurityGroupId' => nil,
|
101
|
+
'SecurityIngressCidrIp' => '0.0.0.0/0',
|
102
|
+
'SecurityIngressFromPort' => 80,
|
103
|
+
'SecurityIngressToPort' => 80,
|
104
|
+
#####
|
105
|
+
|
106
|
+
##### TODO: Implement this feature
|
107
|
+
'SpotAllocationStrategy' => 'diversified',
|
108
|
+
'SpotPrice' => nil,
|
109
|
+
'UseSpot' => false,
|
110
|
+
#####
|
111
|
+
|
112
|
+
'UserData' => "#!/bin/bash\necho ECS_CLUSTER=#{config[:cluster]} >> /etc/ecs/ecs.config;echo ECS_BACKEND_HOST= >> /etc/ecs/ecs.config;",
|
113
|
+
'VpcAvailabilityZones' => cluster_options.dig(:vpc, :availability_zones),
|
114
|
+
'VpcCidr' => cluster_options.dig(:vpc, :cidr),
|
115
|
+
'SubnetCidr1' => cluster_options.dig(:vpc, :subnet1),
|
116
|
+
'SubnetCidr2' => cluster_options.dig(:vpc, :subnet2),
|
117
|
+
'SubnetCidr3' => cluster_options.dig(:vpc, :subnet3),
|
118
|
+
|
119
|
+
'VpcId' => cluster_options.dig(:vpc, :id),
|
120
|
+
'SubnetIds' => cluster_options.dig(:vpc, :subnet_ids)
|
121
|
+
}
|
122
|
+
end
|
123
|
+
|
124
|
+
def cluster_exists?
|
125
|
+
clusters = ecs_client.describe_clusters(clusters: [config[:cluster]]).to_h[:clusters]
|
126
|
+
|
127
|
+
clusters.count { |c| c[:status] != 'INACTIVE' } == 1
|
128
|
+
end
|
129
|
+
|
130
|
+
def ensure_ecs_roles_exists!
|
131
|
+
REQUIRED_ECS_ROLES.each do |role_name, link|
|
132
|
+
iam_client.get_role(role_name: role_name).to_h
|
133
|
+
rescue Aws::IAM::Errors::NoSuchEntity
|
134
|
+
raise SetupError, "IAM Role #{role_name} does not exist. Please create it: #{link}."
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
def create_keypair_if_required!(cluster_options)
|
139
|
+
ec2_client.describe_key_pairs(key_names: [cluster_options[:keypair_name]]).to_h[:key_pairs]
|
140
|
+
rescue Aws::EC2::Errors::InvalidKeyPairNotFound
|
141
|
+
EcsDeployCli.logger.info "Keypair \"#{cluster_options[:keypair_name]}\" not found, creating it..."
|
142
|
+
key_material = ec2_client.create_key_pair(key_name: cluster_options[:keypair_name]).to_h[:key_material]
|
143
|
+
File.write("#{cluster_options[:keypair_name]}.pem", key_material)
|
144
|
+
EcsDeployCli.logger.info "Created PEM file at #{Dir.pwd}/#{cluster_options[:keypair_name]}.pem"
|
145
|
+
end
|
146
|
+
|
147
|
+
def format_cloudformation_params(params)
|
148
|
+
params.map { |k, v| { parameter_key: k, parameter_value: v.to_s } }
|
149
|
+
end
|
150
|
+
|
151
|
+
def load_ami_id
|
152
|
+
ami_data = ssm_client.get_parameter(
|
153
|
+
name: '/aws/service/ecs/optimized-ami/amazon-linux-2/recommended'
|
154
|
+
).to_h[:parameter]
|
155
|
+
|
156
|
+
ami_details = JSON.parse(ami_data[:value]).with_indifferent_access
|
157
|
+
|
158
|
+
ami_details[:image_id]
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
@@ -3,29 +3,63 @@
|
|
3
3
|
module EcsDeployCli
|
4
4
|
module Runners
|
5
5
|
class SSH < Base
|
6
|
-
def run!
|
7
|
-
instance_ids = load_container_instances
|
8
|
-
EcsDeployCli.logger.info "Found instances: #{instance_ids.join(', ')}"
|
6
|
+
def run!(params = {})
|
7
|
+
instance_ids = load_container_instances(params)
|
9
8
|
|
10
|
-
|
9
|
+
instance_id = choose_instance_id(instance_ids)
|
10
|
+
dns_name = load_dns_name_from_instance_id(instance_id)
|
11
11
|
run_ssh(dns_name)
|
12
12
|
end
|
13
13
|
|
14
14
|
private
|
15
15
|
|
16
|
-
def
|
16
|
+
def choose_instance_id(instance_ids)
|
17
|
+
raise 'No instance found' if instance_ids.empty?
|
18
|
+
return instance_ids[0] if instance_ids.length == 1
|
19
|
+
|
20
|
+
instances_selection_text = instance_ids.map.with_index do |instance, index|
|
21
|
+
"#{index + 1}) #{instance}"
|
22
|
+
end.join("\n")
|
23
|
+
|
24
|
+
EcsDeployCli.logger.info(
|
25
|
+
"Found #{instance_ids.count} instances:\n#{instances_selection_text}\nSelect which one you want to access:"
|
26
|
+
)
|
27
|
+
|
28
|
+
index = select_index_from_array(instance_ids, retry_message: 'Invalid option. Select which one you want to access:')
|
29
|
+
|
30
|
+
instance_ids[index]
|
31
|
+
end
|
32
|
+
|
33
|
+
def select_index_from_array(array, retry_message:)
|
34
|
+
while (index = STDIN.gets.chomp)
|
35
|
+
if index =~ /\A[1-9][0-9]*\Z/ && (index.to_i - 1) < array.count
|
36
|
+
index = index.to_i - 1
|
37
|
+
break
|
38
|
+
end
|
39
|
+
|
40
|
+
EcsDeployCli.logger.info(retry_message)
|
41
|
+
end
|
42
|
+
index
|
43
|
+
end
|
44
|
+
|
45
|
+
def load_dns_name_from_instance_id(instance_id)
|
17
46
|
response = ec2_client.describe_instances(
|
18
|
-
instance_ids:
|
47
|
+
instance_ids: [instance_id]
|
19
48
|
)
|
20
49
|
|
21
50
|
response.reservations[0].instances[0].public_dns_name
|
22
51
|
end
|
23
52
|
|
24
|
-
def load_container_instances
|
25
|
-
|
26
|
-
cluster: config[:cluster]
|
27
|
-
).to_h[:
|
53
|
+
def load_container_instances(params = {})
|
54
|
+
task_arns = ecs_client.list_tasks(
|
55
|
+
**params.merge(cluster: config[:cluster])
|
56
|
+
).to_h[:task_arns]
|
57
|
+
|
58
|
+
tasks = ecs_client.describe_tasks(
|
59
|
+
tasks: task_arns, cluster: config[:cluster]
|
60
|
+
).to_h[:tasks]
|
28
61
|
|
62
|
+
instances = tasks.map { |task| task[:container_instance_arn] }.uniq
|
29
63
|
response = ecs_client.describe_container_instances(
|
30
64
|
cluster: config[:cluster],
|
31
65
|
container_instances: instances
|