ecs_deploy_cli 0.2.1 → 0.5.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,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
@@ -56,6 +56,10 @@ module EcsDeployCli
56
56
 
57
57
  def as_definition
58
58
  {
59
+ cpu: 0,
60
+ mount_points: [],
61
+ port_mappings: [],
62
+ volumes_from: [],
59
63
  memory_reservation: nil,
60
64
  essential: true
61
65
  }.merge(_options)
@@ -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
@@ -15,27 +15,57 @@ module EcsDeployCli
15
15
 
16
16
  result = ecs_client.describe_task_definition(task_definition: task_name).to_h
17
17
 
18
- current = result[:task_definition].except(:revision, :status, :registered_at, :registered_by, :requires_attributes, :task_definition_arn)
18
+ current = cleanup_source_task(result[:task_definition])
19
+ definition = cleanup_source_task(definition)
19
20
 
20
21
  print_diff Hashdiff.diff(current.except(:container_definitions), definition.except(:container_definitions))
21
22
 
22
- current[:container_definitions].zip(definition[:container_definitions]).each do |a, b|
23
- EcsDeployCli.logger.info "Container #{a&.dig(:name) || 'NONE'} <=> #{b&.dig(:name) || 'NONE'}"
23
+ diff_container_definitions(
24
+ current[:container_definitions],
25
+ definition[:container_definitions]
26
+ )
24
27
 
25
- print_diff Hashdiff.diff(a, b) if a && b
26
- end
27
28
  EcsDeployCli.logger.info '---'
28
29
  end
29
30
  end
30
31
 
32
+ private
33
+
34
+ def diff_container_definitions(first, second)
35
+ first.zip(second).each do |a, b|
36
+ EcsDeployCli.logger.info "Container #{a&.dig(:name) || 'NONE'} <=> #{b&.dig(:name) || 'NONE'}"
37
+
38
+ next if !a || !b
39
+
40
+ sort_envs! a
41
+ sort_envs! b
42
+
43
+ print_diff Hashdiff.diff(a.delete_if { |_, v| v.nil? }, b.delete_if { |_, v| v.nil? })
44
+ end
45
+ end
46
+
47
+ def sort_envs!(definition)
48
+ return unless definition[:environment]
49
+
50
+ definition[:environment].sort_by! { |e| e[:name] }
51
+ end
52
+
53
+ def cleanup_source_task(task)
54
+ task.except(
55
+ :revision, :compatibilities, :status, :registered_at, :registered_by,
56
+ :requires_attributes, :task_definition_arn
57
+ ).delete_if { |_, v| v.nil? }
58
+ end
59
+
31
60
  def print_diff(diff)
32
61
  diff.each do |(op, path, *values)|
33
- if op == '-'
62
+ case op
63
+ when '-'
34
64
  EcsDeployCli.logger.info "#{op} #{path} => #{values.join(' ')}".colorize(:red)
35
- elsif op == '+'
65
+ when '+'
36
66
  EcsDeployCli.logger.info "#{op} #{path} => #{values.join(' ')}".colorize(:green)
37
67
  else
38
- EcsDeployCli.logger.info "#{op} #{path} => #{values.join(' ')}".colorize(:light_blue)
68
+ EcsDeployCli.logger.info "#{op} #{path} => #{values.join(' ')}".colorize(:yellow)
39
69
  end
40
70
  end
41
71
  end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsDeployCli
4
+ module Runners
5
+ class Logs < Base
6
+ def run!
7
+ # _, tasks, = @parser.resolve
8
+
9
+ # tasks.
10
+ # cwl_client.
11
+ end
12
+ end
13
+ end
14
+ 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].filter { |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.filter { |c| c[:status] != 'INACTIVE' }.length == 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