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.
@@ -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
- [@services, resolved_tasks, resolved_crons]
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: service_name,
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
- dns_name = load_dns_name_from_instance_ids(instance_ids)
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 load_dns_name_from_instance_ids(instance_ids)
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: 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
- instances = ecs_client.list_container_instances(
26
- cluster: config[:cluster]
27
- ).to_h[:container_instance_arns]
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