ecs_deploy_cli 0.1.0 → 0.4.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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/lib/ecs_deploy_cli.rb +5 -1
  3. data/lib/ecs_deploy_cli/cli.rb +40 -2
  4. data/lib/ecs_deploy_cli/cloudformation/default.yml +411 -0
  5. data/lib/ecs_deploy_cli/dsl/auto_options.rb +17 -1
  6. data/lib/ecs_deploy_cli/dsl/cluster.rb +70 -0
  7. data/lib/ecs_deploy_cli/dsl/container.rb +8 -10
  8. data/lib/ecs_deploy_cli/dsl/parser.rb +9 -5
  9. data/lib/ecs_deploy_cli/dsl/service.rb +31 -2
  10. data/lib/ecs_deploy_cli/dsl/task.rb +2 -0
  11. data/lib/ecs_deploy_cli/runner.rb +26 -125
  12. data/lib/ecs_deploy_cli/runners/base.rb +105 -0
  13. data/lib/ecs_deploy_cli/runners/diff.rb +74 -0
  14. data/lib/ecs_deploy_cli/runners/logs.rb +14 -0
  15. data/lib/ecs_deploy_cli/runners/run_task.rb +27 -0
  16. data/lib/ecs_deploy_cli/runners/setup.rb +128 -0
  17. data/lib/ecs_deploy_cli/runners/ssh.rb +79 -0
  18. data/lib/ecs_deploy_cli/runners/status.rb +23 -0
  19. data/lib/ecs_deploy_cli/runners/update_crons.rb +41 -0
  20. data/lib/ecs_deploy_cli/runners/update_services.rb +55 -0
  21. data/lib/ecs_deploy_cli/runners/validate.rb +47 -0
  22. data/lib/ecs_deploy_cli/version.rb +3 -1
  23. data/spec/ecs_deploy_cli/cli_spec.rb +44 -4
  24. data/spec/ecs_deploy_cli/dsl/cluster_spec.rb +48 -0
  25. data/spec/ecs_deploy_cli/dsl/container_spec.rb +6 -4
  26. data/spec/ecs_deploy_cli/dsl/cron_spec.rb +2 -0
  27. data/spec/ecs_deploy_cli/dsl/parser_spec.rb +2 -0
  28. data/spec/ecs_deploy_cli/dsl/service_spec.rb +31 -0
  29. data/spec/ecs_deploy_cli/dsl/task_spec.rb +2 -0
  30. data/spec/ecs_deploy_cli/runner_spec.rb +177 -26
  31. data/spec/ecs_deploy_cli/runners/base_spec.rb +57 -0
  32. data/spec/spec_helper.rb +2 -3
  33. data/spec/support/ECSFile +13 -1
  34. data/spec/support/ECSFile.minimal +12 -0
  35. metadata +104 -13
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsDeployCli
4
+ module Runners
5
+ class Diff < Base
6
+ def run!
7
+ require 'hashdiff'
8
+ require 'colorize'
9
+
10
+ _, tasks, = @parser.resolve
11
+
12
+ tasks.each do |task_name, definition|
13
+ EcsDeployCli.logger.info '---'
14
+ EcsDeployCli.logger.info "Task: #{task_name}"
15
+
16
+ result = ecs_client.describe_task_definition(task_definition: task_name).to_h
17
+
18
+ current = cleanup_source_task(result[:task_definition])
19
+ definition = cleanup_source_task(definition)
20
+
21
+ print_diff Hashdiff.diff(current.except(:container_definitions), definition.except(:container_definitions))
22
+
23
+ diff_container_definitions(
24
+ current[:container_definitions],
25
+ definition[:container_definitions]
26
+ )
27
+
28
+ EcsDeployCli.logger.info '---'
29
+ end
30
+ end
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
+
60
+ def print_diff(diff)
61
+ diff.each do |(op, path, *values)|
62
+ case op
63
+ when '-'
64
+ EcsDeployCli.logger.info "#{op} #{path} => #{values.join(' ')}".colorize(:red)
65
+ when '+'
66
+ EcsDeployCli.logger.info "#{op} #{path} => #{values.join(' ')}".colorize(:green)
67
+ else
68
+ EcsDeployCli.logger.info "#{op} #{path} => #{values.join(' ')}".colorize(:yellow)
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ 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,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsDeployCli
4
+ module Runners
5
+ class RunTask < Base
6
+ def run!(task, launch_type:, security_groups:, subnets:)
7
+ _, tasks, = @parser.resolve
8
+
9
+ task_definition = _update_task tasks[task]
10
+ task_name = "#{task_definition[:family]}:#{task_definition[:revision]}"
11
+
12
+ ecs_client.run_task(
13
+ cluster: config[:cluster],
14
+ task_definition: task_name,
15
+ network_configuration: {
16
+ awsvpc_configuration: {
17
+ subnets: subnets,
18
+ security_groups: security_groups,
19
+ assign_public_ip: 'ENABLED'
20
+ }
21
+ },
22
+ launch_type: launch_type
23
+ )
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsDeployCli
4
+ module Runners
5
+ class Setup < Base
6
+ def run!
7
+ services, resolved_tasks, _, cluster_options = @parser.resolve
8
+
9
+ setup_cluster! cluster_options
10
+ setup_services! services, resolved_tasks: resolved_tasks
11
+ end
12
+
13
+ private
14
+
15
+ def setup_cluster!(cluster_options)
16
+ clusters = ecs_client.describe_clusters(clusters: [config[:cluster]]).to_h[:clusters]
17
+ if clusters.length == 1
18
+ EcsDeployCli.logger.info 'Cluster already created, skipping.'
19
+ return
20
+ end
21
+
22
+ EcsDeployCli.logger.info "Creating cluster #{config[:cluster]}..."
23
+
24
+ params = create_params(cluster_options)
25
+
26
+ ecs_client.create_cluster(
27
+ cluster_name: config[:cluster]
28
+ )
29
+
30
+ stack_name = "EC2ContainerService-#{config[:cluster]}"
31
+
32
+
33
+ cf_client.create_stack(
34
+ stack_name: stack_name,
35
+ template_body: File.read(File.join(__dir__, '..', 'cloudformation', 'default.yml')),
36
+ on_failure: 'ROLLBACK',
37
+ parameters: format_cloudformation_params(params)
38
+ )
39
+
40
+ cf_client.wait_until(:stack_create_complete, { stack_name: stack_name }, delay: 30, max_attempts: 120)
41
+ EcsDeployCli.logger.info "Cluster #{config[:cluster]} created!"
42
+ end
43
+
44
+ def setup_services!(services, resolved_tasks:)
45
+ services.each do |service_name, service_definition|
46
+ if ecs_client.describe_services(cluster: config[:cluster], services: [service_name]).to_h[:services].any?
47
+ EcsDeployCli.logger.info "Service #{service_name} already created, skipping."
48
+ next
49
+ end
50
+
51
+ EcsDeployCli.logger.info "Creating service #{service_name}..."
52
+ task_definition = _update_task resolved_tasks[service_definition.options[:task]]
53
+ task_name = "#{task_definition[:family]}:#{task_definition[:revision]}"
54
+
55
+ ecs_client.create_service(
56
+ cluster: config[:cluster],
57
+ desired_count: 1,
58
+ load_balancers: service_definition.as_definition(task_definition)[:load_balancers],
59
+ service_name: service_name,
60
+ task_definition: task_name
61
+ )
62
+ EcsDeployCli.logger.info "Service #{service_name} created!"
63
+ end
64
+ end
65
+
66
+ def create_params(cluster_options)
67
+ raise ArgumentError, 'Missing vpc configuration' unless cluster_options[:vpc]
68
+
69
+ {
70
+ 'AsgMaxSize' => cluster_options[:instances_count],
71
+ 'AutoAssignPublicIp' => 'INHERIT',
72
+ 'ConfigureDataVolume' => false,
73
+ 'ConfigureRootVolume' => true,
74
+ 'DeviceName' => cluster_options[:device_name],
75
+ 'EbsVolumeSize' => cluster_options[:ebs_volume_size],
76
+ 'EbsVolumeType' => cluster_options[:ebs_volume_type],
77
+ 'EcsAmiId' => load_ami_id,
78
+ 'EcsClusterName' => config[:cluster],
79
+ 'EcsEndpoint' => nil,
80
+ 'EcsInstanceType' => cluster_options[:instance_type],
81
+ 'IamRoleInstanceProfile' => "arn:aws:iam::#{config[:aws_profile_id]}:instance-profile/ecsInstanceRole",
82
+ 'IamSpotFleetRoleArn' => nil,
83
+ 'IsWindows' => false,
84
+ 'KeyName' => cluster_options[:keypair_name],
85
+ 'RootDeviceName' => cluster_options[:root_device_name],
86
+ 'RootEbsVolumeSize' => cluster_options[:root_ebs_volume_size],
87
+
88
+ ##### TODO: Implement this feature
89
+ 'SecurityGroupId' => nil,
90
+ 'SecurityIngressCidrIp' => '0.0.0.0/0',
91
+ 'SecurityIngressFromPort' => 80,
92
+ 'SecurityIngressToPort' => 80,
93
+ #####
94
+
95
+ ##### TODO: Implement this feature
96
+ 'SpotAllocationStrategy' => 'diversified',
97
+ 'SpotPrice' => nil,
98
+ 'UseSpot' => false,
99
+ #####
100
+
101
+ 'UserData' => "#!/bin/bash\necho ECS_CLUSTER=#{config[:cluster]} >> /etc/ecs/ecs.config;echo ECS_BACKEND_HOST= >> /etc/ecs/ecs.config;",
102
+ 'VpcAvailabilityZones' => cluster_options.dig(:vpc, :availability_zones),
103
+ 'VpcCidr' => cluster_options.dig(:vpc, :cidr),
104
+ 'SubnetCidr1' => cluster_options.dig(:vpc, :subnet1),
105
+ 'SubnetCidr2' => cluster_options.dig(:vpc, :subnet2),
106
+ 'SubnetCidr3' => cluster_options.dig(:vpc, :subnet3),
107
+
108
+ 'VpcId' => cluster_options.dig(:vpc, :id),
109
+ 'SubnetIds' => cluster_options.dig(:vpc, :subnet_ids)
110
+ }
111
+ end
112
+
113
+ def format_cloudformation_params(params)
114
+ params.map { |k, v| { parameter_key: k, parameter_value: v.to_s } }
115
+ end
116
+
117
+ def load_ami_id
118
+ ami_data = ssm_client.get_parameter(
119
+ name: '/aws/service/ecs/optimized-ami/amazon-linux-2/recommended'
120
+ ).to_h[:parameter]
121
+
122
+ ami_details = JSON.parse(ami_data[:value]).with_indifferent_access
123
+
124
+ ami_details[:image_id]
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsDeployCli
4
+ module Runners
5
+ class SSH < Base
6
+ def run!(params = {})
7
+ instance_ids = load_container_instances(params)
8
+
9
+ instance_id = choose_instance_id(instance_ids)
10
+ dns_name = load_dns_name_from_instance_id(instance_id)
11
+ run_ssh(dns_name)
12
+ end
13
+
14
+ private
15
+
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)
46
+ response = ec2_client.describe_instances(
47
+ instance_ids: [instance_id]
48
+ )
49
+
50
+ response.reservations[0].instances[0].public_dns_name
51
+ end
52
+
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]
61
+
62
+ instances = tasks.map { |task| task[:container_instance_arn] }.uniq
63
+ response = ecs_client.describe_container_instances(
64
+ cluster: config[:cluster],
65
+ container_instances: instances
66
+ )
67
+
68
+ response.container_instances.map(&:ec2_instance_id)
69
+ end
70
+
71
+ def run_ssh(dns_name)
72
+ EcsDeployCli.logger.info "Connecting to ec2-user@#{dns_name}..."
73
+
74
+ Process.fork { exec("ssh ec2-user@#{dns_name}") }
75
+ Process.wait
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsDeployCli
4
+ module Runners
5
+ class Status < Base
6
+ def run!(service)
7
+ services, = @parser.resolve
8
+
9
+ services.each do |service_name, service_definition|
10
+ next if !service.nil? && service != service_name
11
+
12
+ # task_definition = _update_task resolved_tasks[service_definition.options[:task]]
13
+ # task_name = "#{task_definition[:family]}:#{task_definition[:revision]}"
14
+
15
+ puts ecs_client.describe_service(
16
+ cluster: config[:cluster],
17
+ service: service_name
18
+ )
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsDeployCli
4
+ module Runners
5
+ class UpdateCrons < Base
6
+ def run!
7
+ _, tasks, crons = @parser.resolve
8
+
9
+ crons.each do |cron_name, cron_definition|
10
+ task_definition = tasks[cron_definition[:task_name]]
11
+ raise "Undefined task #{cron_definition[:task_name].inspect} in (#{tasks.keys.inspect})" unless task_definition
12
+
13
+ updated_task = _update_task(task_definition)
14
+
15
+ current_target = cwe_client.list_targets_by_rule(
16
+ {
17
+ rule: cron_name,
18
+ limit: 1
19
+ }
20
+ ).to_h[:targets].first
21
+
22
+ cwe_client.put_rule(
23
+ cron_definition[:rule]
24
+ )
25
+
26
+ cwe_client.put_targets(
27
+ rule: cron_name,
28
+ targets: [
29
+ id: current_target[:id],
30
+ arn: current_target[:arn],
31
+ role_arn: current_target[:role_arn],
32
+ input: cron_definition[:input].to_json,
33
+ ecs_parameters: cron_definition[:ecs_parameters].merge(task_definition_arn: updated_task[:task_definition_arn])
34
+ ]
35
+ )
36
+ EcsDeployCli.logger.info "Deployed scheduled task \"#{cron_name}\"!"
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsDeployCli
4
+ module Runners
5
+ class UpdateServices < Base
6
+ def run!(service: nil, timeout: 500)
7
+ services, resolved_tasks = @parser.resolve
8
+
9
+ services.each do |service_name, service_definition|
10
+ next if !service.nil? && service != service_name
11
+
12
+ task_definition = _update_task resolved_tasks[service_definition.options[:task]]
13
+ task_name = "#{task_definition[:family]}:#{task_definition[:revision]}"
14
+
15
+ ecs_client.update_service(
16
+ cluster: config[:cluster],
17
+ service: service_name,
18
+ task_definition: task_name
19
+ )
20
+ wait_for_deploy(service_name, task_name, timeout: timeout)
21
+ EcsDeployCli.logger.info "Deployed service \"#{service_name}\"!"
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def wait_for_deploy(service_name, task_name, timeout:)
28
+ wait_data = { cluster: config[:cluster], services: [service_name] }
29
+
30
+ started_at = Time.now
31
+ ecs_client.wait_until(
32
+ :services_stable, wait_data,
33
+ max_attempts: nil,
34
+ before_wait: lambda { |_, response|
35
+ deployments = response.services.first.deployments
36
+ log_deployments task_name, deployments
37
+
38
+ throw :success if deployments.count == 1 && deployments[0].task_definition.end_with?(task_name)
39
+ throw :failure if Time.now - started_at > timeout
40
+ }
41
+ )
42
+ end
43
+
44
+ def log_deployments(task_name, deployments)
45
+ EcsDeployCli.logger.info "Waiting for task: #{task_name} to become ok."
46
+ EcsDeployCli.logger.info 'Deployment status:'
47
+ deployments.each do |deploy|
48
+ EcsDeployCli.logger.info "[#{deploy.status}] task=#{deploy.task_definition.split('/').last}, "\
49
+ "desired_count=#{deploy.desired_count}, pending_count=#{deploy.pending_count}, running_count=#{deploy.running_count}, failed_tasks=#{deploy.failed_tasks}"
50
+ end
51
+ EcsDeployCli.logger.info ''
52
+ end
53
+ end
54
+ end
55
+ end