ecs_deploy_cli 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e5f34210100053703e0137631635b625ea37cc1cb140e4fa432c3375c6cdf87a
4
+ data.tar.gz: '08d8ead32a4b87cddb19b36539d661fc954e006a8ac3986a8dc60aaac93cd560'
5
+ SHA512:
6
+ metadata.gz: 90cb49d9d8de03d5b82946493d2df2466dec05cd01c190e9673ed6023adf9b5167465032bc47076ac12f7f8bfc451fd78c8568aec3aede6fd82467b7369914ad
7
+ data.tar.gz: f3dedd74c575d671fc65afb974086d8683c4eb3ee38074aec7d1dacb0b31632dd52813127bb39f1b3df95167353a8871308da8fbd885fd57cbb7f3ef34ca9941
data/bin/ecs-deploy ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'ecs_deploy_cli'
4
+
5
+ EcsDeployCli::CLI.start(ARGV)
@@ -0,0 +1 @@
1
+ require 'ecs_deploy_cli'
@@ -0,0 +1,32 @@
1
+ require 'yaml'
2
+ require 'logger'
3
+ require 'thor'
4
+ require 'aws-sdk-ecs'
5
+ require 'active_support/core_ext/hash/indifferent_access'
6
+
7
+ module EcsDeployCli
8
+ def self.logger
9
+ @logger ||= begin
10
+ logger = Logger.new(STDOUT)
11
+ logger.formatter = proc { |severity, datetime, progname, msg|
12
+ "#{msg}\n"
13
+ }
14
+ logger.level = Logger::INFO
15
+ logger
16
+ end
17
+ end
18
+
19
+ def self.logger=(value)
20
+ @logger = value
21
+ end
22
+ end
23
+
24
+ require 'ecs_deploy_cli/version'
25
+ require 'ecs_deploy_cli/dsl/auto_options'
26
+ require 'ecs_deploy_cli/dsl/container'
27
+ require 'ecs_deploy_cli/dsl/task'
28
+ require 'ecs_deploy_cli/dsl/cron'
29
+ require 'ecs_deploy_cli/dsl/service'
30
+ require 'ecs_deploy_cli/dsl/parser'
31
+ require 'ecs_deploy_cli/runner'
32
+ require 'ecs_deploy_cli/cli'
@@ -0,0 +1,58 @@
1
+ module EcsDeployCli
2
+ class CLI < Thor
3
+ desc 'validate', 'Validates your ECSFile'
4
+ option :file, default: 'ECSFile'
5
+ def validate
6
+ @parser = load(options[:file])
7
+ runner.validate!
8
+ puts 'Your ECSFile looks fine! 🎉'
9
+ end
10
+
11
+ desc 'version', 'Updates all services defined in your ECSFile'
12
+ def version
13
+ puts "ECS Deploy CLI Version #{EcsDeployCli::VERSION}."
14
+ end
15
+
16
+ desc 'deploy-scheduled-tasks', 'Updates all scheduled tasks defined in your ECSFile'
17
+ option :file, default: 'ECSFile'
18
+ def deploy_scheduled_tasks
19
+ @parser = load(options[:file])
20
+ runner.update_crons!
21
+ end
22
+
23
+ desc 'deploy-services', 'Updates all services defined in your ECSFile'
24
+ option :only
25
+ option :file, default: 'ECSFile'
26
+ option :timeout, type: :numeric, default: 500
27
+ def deploy_services
28
+ @parser = load(options[:file])
29
+ runner.update_services! timeout: options[:timeout], service: options[:only]
30
+ end
31
+
32
+ desc 'deploy', 'Updates a single service defined in your ECSFile'
33
+ option :file, default: 'ECSFile'
34
+ option :timeout, type: :numeric, default: 500
35
+ def deploy
36
+ @parser = load(options[:file])
37
+ runner.update_services! timeout: options[:timeout]
38
+ runner.update_crons!
39
+ end
40
+
41
+ desc 'ssh', 'Connects to ECS instance via SSH'
42
+ option :file, default: 'ECSFile'
43
+ def ssh
44
+ @parser = load(options[:file])
45
+ runner.ssh
46
+ end
47
+
48
+ private
49
+
50
+ def load(file)
51
+ EcsDeployCli::DSL::Parser.load(file)
52
+ end
53
+
54
+ def runner
55
+ @runner ||= EcsDeployCli::Runner.new(@parser)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,18 @@
1
+ module EcsDeployCli
2
+ module DSL
3
+ module AutoOptions
4
+ def method_missing(name, *args, &block)
5
+ if args.count == 1 && !block
6
+ EcsDeployCli.logger.info("Auto-added option security_group #{name.to_sym} = #{args.first}")
7
+ _options[name.to_sym] = args.first
8
+ else
9
+ super
10
+ end
11
+ end
12
+
13
+ def _options
14
+ @_options ||= {}
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsDeployCli
4
+ module DSL
5
+ class Container
6
+ include AutoOptions
7
+
8
+ def initialize(name, config)
9
+ @config = config
10
+ _options[:name] = name.to_s
11
+ end
12
+
13
+ def image(value)
14
+ _options[:image] = value
15
+ end
16
+
17
+ def command(*command)
18
+ _options[:command] = command
19
+ end
20
+
21
+ def load_envs(name)
22
+ _options[:environment] = (_options[:environment] || []) + YAML.safe_load(File.open(name))
23
+ end
24
+
25
+ def env(key:, value:)
26
+ (_options[:environment] ||= []) << { 'name' => key, 'value' => value }
27
+ end
28
+
29
+ def secret(key:, value:)
30
+ (_options[:secrets] ||= []) << { name: key, value_from: "arn:aws:ssm:#{@config[:aws_region]}:#{@config[:aws_profile_id]}:parameter/#{value}" }
31
+ end
32
+
33
+ def expose(**options)
34
+ (_options[:port_mappings] ||= []) << options
35
+ end
36
+
37
+ def cpu(value)
38
+ _options[:cpu] = value
39
+ end
40
+
41
+ def memory(limit:, reservation:)
42
+ _options[:memory] = limit
43
+ _options[:memory_reservation] = reservation
44
+ end
45
+
46
+ def merge(other)
47
+ other_options = other._options
48
+ other_options.delete(:name)
49
+ _options.merge!(other_options)
50
+ end
51
+
52
+ def cloudwatch_logs(value)
53
+ _options[:log_configuration] = {
54
+ log_driver: 'awslogs',
55
+ options: {
56
+ 'awslogs-group' => "/ecs/#{value}",
57
+ 'awslogs-stream-prefix' => 'ecs',
58
+ 'awslogs-region' => @config[:aws_region]
59
+ }
60
+ }
61
+ end
62
+
63
+ def as_definition
64
+ {
65
+ memory_reservation: nil,
66
+ essential: true
67
+ }.merge(_options)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsDeployCli
4
+ module DSL
5
+ class Cron
6
+ include AutoOptions
7
+
8
+ def initialize(name, config)
9
+ _options[:name] = name
10
+ @config = config
11
+ end
12
+
13
+ def task(name, &block)
14
+ _options[:task] = Task.new(name.to_s, @config)
15
+ _options[:task].instance_exec(&block)
16
+ end
17
+
18
+ def run_at(cron_expression)
19
+ @cron_expression = "cron(#{cron_expression})"
20
+ end
21
+
22
+ def run_every(interval)
23
+ @every = "rate(#{interval})"
24
+ end
25
+
26
+ def task_role(role)
27
+ _options[:task_role] = "arn:aws:iam::#{@config[:aws_profile_id]}:role/#{role}"
28
+ end
29
+
30
+ def subnets(*value)
31
+ _options[:subnets] = value
32
+ end
33
+
34
+ def security_groups(*value)
35
+ _options[:security_groups] = value
36
+ end
37
+
38
+ def launch_type(value)
39
+ _options[:launch_type] = value
40
+ end
41
+
42
+ def assign_public_ip(value)
43
+ _options[:assign_public_ip] = value
44
+ end
45
+
46
+ def as_definition(tasks)
47
+ raise 'Missing task definition' unless _options[:task]
48
+
49
+ input = { 'containerOverrides' => _options[:task].as_definition }
50
+ input['taskRoleArn'] = _options[:task_role] if _options[:task_role]
51
+
52
+ {
53
+ task_name: _options[:task].name,
54
+ rule: {
55
+ name: _options[:name],
56
+ schedule_expression: @cron_expression || @every || raise("Missing schedule expression.")
57
+ },
58
+ input: input,
59
+ ecs_parameters: {
60
+ # task_definition_arn: task_definition[:task_definition_arn],
61
+ task_count: _options[:task_count] || 1,
62
+ launch_type: _options[:launch_type] || raise('Missing parameter launch_type'),
63
+ network_configuration: {
64
+ awsvpc_configuration: {
65
+ subnets: _options[:subnets] || raise('Missing parameter subnets'),
66
+ security_groups: _options[:security_groups] || [],
67
+ assign_public_ip: _options[:assign_public_ip] ? 'ENABLED' : 'DISABLED'
68
+ }
69
+ },
70
+ platform_version: _options[:platform_version] || 'LATEST'
71
+ }
72
+ }
73
+ end
74
+
75
+ class Task
76
+ include AutoOptions
77
+
78
+ attr_reader :name
79
+
80
+ def initialize(name, config)
81
+ @name = name
82
+ @config = config
83
+ end
84
+
85
+ def container(name, &block)
86
+ container = Container.new(name, @config)
87
+ container.instance_exec(&block)
88
+ (_options[:containers] ||= []) << container
89
+ end
90
+
91
+ def as_definition
92
+ # [{"name"=>"cron", "command"=>["rails", "cron:adalytics"]}]
93
+ (_options[:containers] || []).map(&:as_definition)
94
+ end
95
+ end
96
+
97
+ class Container < EcsDeployCli::DSL::Container
98
+ def as_definition
99
+ _options.to_h
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsDeployCli
4
+ module DSL
5
+ class Parser
6
+ def aws_profile_id(value)
7
+ config[:aws_profile_id] = value
8
+ end
9
+
10
+ def aws_region(value)
11
+ config[:aws_region] = value
12
+ end
13
+
14
+ def stage(stage)
15
+ config[:stage] = stage
16
+ end
17
+
18
+ def container(container, extends: nil, &block)
19
+ @containers ||= {}
20
+ @containers[container] = Container.new(container, config)
21
+ @containers[container].merge(@containers[extends]) if extends
22
+ @containers[container].instance_exec(&block)
23
+ end
24
+
25
+ def task(task, &block)
26
+ @tasks ||= {}.with_indifferent_access
27
+ @tasks[task] = Task.new(task, config)
28
+ @tasks[task].instance_exec(&block)
29
+ end
30
+
31
+ def service(name, &block)
32
+ @services ||= {}.with_indifferent_access
33
+ @services[name.to_s] = Service.new(name, config)
34
+ @services[name.to_s].instance_exec(&block)
35
+ end
36
+
37
+ def cron(name, &block)
38
+ @crons ||= {}.with_indifferent_access
39
+ @crons[name] = Cron.new(name, config)
40
+ @crons[name].instance_exec(&block)
41
+ end
42
+
43
+ def cluster(name)
44
+ config[:cluster] = name
45
+ end
46
+
47
+ def config
48
+ @config ||= {}
49
+ end
50
+
51
+ def ensure_required_params!
52
+ [
53
+ :aws_profile_id, :aws_region, :cluster
54
+ ].each { |key| raise "Missing required parameter #{key}" unless config[key] }
55
+ end
56
+
57
+ def resolve
58
+ resolved_containers = @containers.transform_values(&:as_definition)
59
+ resolved_tasks = @tasks.transform_values { |t| t.as_definition(resolved_containers) }
60
+ resolved_crons = @crons.transform_values { |t| t.as_definition(resolved_tasks) }
61
+ [@services, resolved_tasks, resolved_crons]
62
+ end
63
+
64
+ def self.load(file)
65
+ result = new
66
+ result.instance_eval(File.read(file))
67
+ result.ensure_required_params!
68
+ result
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsDeployCli
4
+ module DSL
5
+ class Service
6
+ include AutoOptions
7
+
8
+ def initialize(name, config)
9
+ _options[:service] = name
10
+ @config = config
11
+ end
12
+
13
+ def task(name)
14
+ _options[:task] = name
15
+ end
16
+
17
+ def options
18
+ _options
19
+ end
20
+
21
+ def as_definition(task)
22
+ {
23
+ cluster: @config[:cluster],
24
+ service: service_name,
25
+ task_definition: task
26
+ }
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsDeployCli
4
+ module DSL
5
+ class Task
6
+ include AutoOptions
7
+
8
+ def initialize(name, config)
9
+ @config = config
10
+ _options[:family] = name.to_s
11
+ end
12
+
13
+ def containers(*containers)
14
+ @containers = containers
15
+ end
16
+
17
+ def cpu(value)
18
+ @cpu = value.to_s
19
+ end
20
+
21
+ def memory(value)
22
+ @memory = value.to_s
23
+ end
24
+
25
+ def tag(key, value)
26
+ (_options[:tags] ||= []) << { key: key, value: value }
27
+ end
28
+
29
+ def volume(value)
30
+ (_options[:volumes] ||= []) << value
31
+ end
32
+
33
+ def execution_role(name)
34
+ _options[:execution_role_arn] = "arn:aws:iam::#{@config[:aws_profile_id]}:role/#{name}"
35
+ end
36
+
37
+ def as_definition(containers)
38
+ {
39
+ container_definitions: containers.values_at(*@containers),
40
+ execution_role_arn: "arn:aws:iam::#{@config[:aws_profile_id]}:role/ecsTaskExecutionRole",
41
+ requires_compatibilities: ['EC2'],
42
+ placement_constraints: [],
43
+ cpu: @cpu,
44
+ memory: @memory,
45
+ volumes: [],
46
+ network_mode: nil
47
+ }.merge(_options)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,153 @@
1
+ module EcsDeployCli
2
+ class Runner
3
+ def initialize(parser)
4
+ @parser = parser
5
+ end
6
+
7
+ def validate!
8
+ @parser.resolve
9
+ end
10
+
11
+ def update_crons!
12
+ _, tasks, crons = @parser.resolve
13
+
14
+ crons.each do |cron_name, cron_definition|
15
+ task_definition = tasks[cron_definition[:task_name]]
16
+ raise "Undefined task #{cron_definition[:task_name].inspect} in (#{tasks.keys.inspect})" unless task_definition
17
+
18
+ updated_task = _update_task(task_definition)
19
+
20
+ current_target = cwe_client.list_targets_by_rule(
21
+ {
22
+ rule: cron_name,
23
+ limit: 1
24
+ }
25
+ ).to_h[:targets].first
26
+
27
+ cwe_client.put_rule(
28
+ cron_definition[:rule]
29
+ )
30
+
31
+ cwe_client.put_targets(
32
+ rule: cron_name,
33
+ targets: [
34
+ id: current_target[:id],
35
+ arn: current_target[:arn],
36
+ role_arn: current_target[:role_arn],
37
+ input: cron_definition[:input].to_json,
38
+ ecs_parameters: cron_definition[:ecs_parameters].merge(task_definition_arn: updated_task[:task_definition_arn])
39
+ ]
40
+ )
41
+ EcsDeployCli.logger.info "Deployed scheduled task \"#{cron_name}\"!"
42
+ end
43
+ end
44
+
45
+ def ssh
46
+ instances = ecs_client.list_container_instances(
47
+ cluster: config[:cluster]
48
+ ).to_h[:container_instance_arns]
49
+
50
+ response = ecs_client.describe_container_instances(
51
+ cluster: config[:cluster],
52
+ container_instances: instances
53
+ )
54
+
55
+ EcsDeployCli.logger.info "Found instances: #{response.container_instances.map(&:ec2_instance_id).join(', ')}"
56
+
57
+ response = ec2_client.describe_instances(
58
+ instance_ids: response.container_instances.map(&:ec2_instance_id)
59
+ )
60
+
61
+ dns_name = response.reservations[0].instances[0].public_dns_name
62
+ EcsDeployCli.logger.info "Connecting to ec2-user@#{dns_name}..."
63
+
64
+ Process.fork { exec("ssh ec2-user@#{dns_name}") }
65
+ Process.wait
66
+ end
67
+
68
+ def update_services!(service: nil, timeout: 500)
69
+ services, resolved_tasks = @parser.resolve
70
+
71
+ services.each do |service_name, service_definition|
72
+ next if !service.nil? && service != service_name
73
+
74
+ task_definition = _update_task resolved_tasks[service_definition.options[:task]]
75
+ task_name = "#{task_definition[:family]}:#{task_definition[:revision]}"
76
+
77
+ ecs_client.update_service(
78
+ cluster: config[:cluster],
79
+ service: service_name,
80
+ task_definition: "#{task_definition[:family]}:#{task_name}"
81
+ )
82
+ wait_for_deploy(service_name, task_name, timeout: timeout)
83
+ EcsDeployCli.logger.info "Deployed service \"#{service_name}\"!"
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def wait_for_deploy(service_name, task_name, timeout:)
90
+ wait_data = { cluster: config[:cluster], services: [service_name] }
91
+
92
+ started_at = Time.now
93
+ ecs_client.wait_until(
94
+ :services_stable, wait_data,
95
+ max_attempts: nil,
96
+ before_wait: lambda { |_, response|
97
+ deployments = response.services.first.deployments
98
+ log_deployments task_name, deployments
99
+
100
+ throw :success if deployments.count == 1 && deployments[0].task_definition.end_with?(task_name)
101
+ throw :failure if Time.now - started_at > timeout
102
+ }
103
+ )
104
+ end
105
+
106
+ def _update_task(definition)
107
+ ecs_client.register_task_definition(
108
+ definition
109
+ ).to_h[:task_definition]
110
+ end
111
+
112
+ def log_deployments(task_name, deployments)
113
+ EcsDeployCli.logger.info "Waiting for task: #{task_name} to become ok."
114
+ EcsDeployCli.logger.info 'Deployment status:'
115
+ deployments.each do |deploy|
116
+ EcsDeployCli.logger.info "[#{deploy.status}] task=#{deploy.task_definition.split('/').last}, "\
117
+ "desired_count=#{deploy.desired_count}, pending_count=#{deploy.pending_count}, running_count=#{deploy.running_count}, failed_tasks=#{deploy.failed_tasks}"
118
+ end
119
+ EcsDeployCli.logger.info ''
120
+ end
121
+
122
+ def ec2_client
123
+ @ec2_client ||= begin
124
+ require 'aws-sdk-ec2'
125
+ Aws::EC2::Client.new(
126
+ profile: ENV.fetch('AWS_PROFILE', 'default'),
127
+ region: config[:aws_region]
128
+ )
129
+ end
130
+ end
131
+
132
+ def ecs_client
133
+ @ecs_client ||= Aws::ECS::Client.new(
134
+ profile: ENV.fetch('AWS_PROFILE', 'default'),
135
+ region: config[:aws_region]
136
+ )
137
+ end
138
+
139
+ def cwe_client
140
+ @cwe_client ||= begin
141
+ require 'aws-sdk-cloudwatchevents'
142
+ Aws::CloudWatchEvents::Client.new(
143
+ profile: ENV.fetch('AWS_PROFILE', 'default'),
144
+ region: config[:aws_region]
145
+ )
146
+ end
147
+ end
148
+
149
+ def config
150
+ @parser.config
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,3 @@
1
+ module EcsDeployCli
2
+ VERSION = '0.1.0'
3
+ end
@@ -0,0 +1,54 @@
1
+ require 'spec_helper'
2
+
3
+ describe EcsDeployCli::CLI do
4
+ context 'defines task data' do
5
+ let(:runner) { double }
6
+
7
+ around(:each) do |example|
8
+ ENV['AWS_PROFILE_ID'] = '123123123'
9
+ ENV['AWS_REGION'] = 'us-east-1'
10
+ example.run
11
+ ENV['AWS_PROFILE_ID'] = nil
12
+ ENV['AWS_REGION'] = nil
13
+ end
14
+
15
+ it 'runs help' do
16
+ expect { described_class.start(['help']) }.to output(/rspec deploy-scheduled-tasks/).to_stdout
17
+ end
18
+
19
+ it 'runs version' do
20
+ expect { described_class.start(['version']) }.to output(/Version #{EcsDeployCli::VERSION}/).to_stdout
21
+ end
22
+
23
+ it 'runs validate' do
24
+ expect { described_class.start(['validate', '--file', 'spec/support/ECSFile']) }.to output(/Your ECSFile looks fine! 🎉/).to_stdout
25
+ end
26
+
27
+ it 'runs deploy' do
28
+ expect(runner).to receive(:update_crons!)
29
+ expect(runner).to receive(:update_services!).with(timeout: 500)
30
+ expect_any_instance_of(described_class).to receive(:runner).at_least(:once).and_return(runner)
31
+ expect { described_class.start(['deploy', '--file', 'spec/support/ECSFile']) }.to output(/[WARNING]/).to_stdout
32
+ end
33
+
34
+ it 'runs deploy-services' do
35
+ expect(runner).to receive(:update_services!)
36
+ expect_any_instance_of(described_class).to receive(:runner).and_return(runner)
37
+ expect { described_class.start(['deploy-services', '--file', 'spec/support/ECSFile']) }.to output(/[WARNING]/).to_stdout
38
+ end
39
+
40
+ it 'runs ssh' do
41
+ expect(runner).to receive(:ssh)
42
+ expect_any_instance_of(described_class).to receive(:runner).and_return(runner)
43
+
44
+ described_class.start(['ssh', '--file', 'spec/support/ECSFile'])
45
+ end
46
+
47
+ it 'runs deploy-scheduled-tasks' do
48
+ expect(runner).to receive(:update_crons!)
49
+ expect_any_instance_of(described_class).to receive(:runner).and_return(runner)
50
+
51
+ described_class.start(['deploy-scheduled-tasks', '--file', 'spec/support/ECSFile'])
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,106 @@
1
+ require 'spec_helper'
2
+
3
+ describe EcsDeployCli::DSL::Container do
4
+ context 'defines container data' do
5
+ subject { described_class.new('test', { aws_profile_id: '123123', aws_region: 'eu-central-1' }) }
6
+
7
+ it 'has the correct name' do
8
+ expect(subject.as_definition[:name]).to eq('test')
9
+ end
10
+
11
+ it '#memory configures memory' do
12
+ subject.memory limit: 1024, reservation: 900
13
+ expect(subject.as_definition[:memory]).to eq(1024)
14
+ expect(subject.as_definition[:memory_reservation]).to eq(900)
15
+ end
16
+
17
+ it '#env configures a single env' do
18
+ subject.env key: 'SOME', value: 'env'
19
+ subject.env key: 'SOME2', value: 'env2'
20
+ expect(subject.as_definition[:environment]).to eq(
21
+ [
22
+ {
23
+ 'name' => 'SOME', 'value' => 'env'
24
+ },
25
+ {
26
+ 'name' => 'SOME2', 'value' => 'env2'
27
+ }
28
+ ]
29
+ )
30
+ end
31
+
32
+ it '#cloudwatch_logs configures cloudwatch logs' do
33
+ subject.cloudwatch_logs 'yourproject'
34
+ expect(subject.as_definition[:log_configuration]).to eq(
35
+ {
36
+ log_driver: 'awslogs',
37
+ options: { 'awslogs-group' => '/ecs/yourproject', 'awslogs-region' => 'eu-central-1', 'awslogs-stream-prefix' => 'ecs' }
38
+ }
39
+ )
40
+ end
41
+
42
+ it '#secret configures secrets' do
43
+ subject.secret key: 'RAILS_MASTER_KEY', value: 'railsMasterKey'
44
+
45
+ expect(subject.as_definition[:secrets]).to eq(
46
+ [
47
+ {
48
+ name: 'RAILS_MASTER_KEY',
49
+ value_from: 'arn:aws:ssm:eu-central-1:123123:parameter/railsMasterKey'
50
+ }
51
+ ]
52
+ )
53
+ end
54
+
55
+ it '#merge: merges two containers' do
56
+ other = described_class.new('base', { aws_profile_id: '123123', aws_region: 'eu-central-1' })
57
+ other.expose host_port: 0, protocol: 'tcp', container_port: 3000
58
+
59
+ subject.secret key: 'RAILS_MASTER_KEY', value: 'railsMasterKey'
60
+ subject.merge(other)
61
+
62
+ expect(subject.as_definition[:secrets]).to eq(
63
+ [
64
+ {
65
+ name: 'RAILS_MASTER_KEY',
66
+ value_from: 'arn:aws:ssm:eu-central-1:123123:parameter/railsMasterKey'
67
+ }
68
+ ]
69
+ )
70
+
71
+ expect(subject.as_definition[:port_mappings]).to eq(
72
+ [
73
+ { host_port: 0, protocol: 'tcp', container_port: 3000 }
74
+ ]
75
+ )
76
+ end
77
+
78
+ it '#expose: configures port mapping' do
79
+ subject.expose host_port: 0, protocol: 'tcp', container_port: 3000
80
+ expect(subject.as_definition[:port_mappings]).to eq(
81
+ [
82
+ { host_port: 0, protocol: 'tcp', container_port: 3000 }
83
+ ]
84
+ )
85
+ end
86
+
87
+ it '#load_envs loads env files' do
88
+ subject.load_envs 'spec/support/env_file.yml'
89
+ expect(subject.as_definition[:environment]).to eq(
90
+ [
91
+ {
92
+ 'name' => 'RAILS_ENV', 'value' => 'production'
93
+ },
94
+ {
95
+ 'name' => 'API_KEY', 'value' => '123123123'
96
+ }
97
+ ]
98
+ )
99
+ end
100
+
101
+ it 'fallbacks not handled methods to an option in the container definition' do
102
+ subject.image 'some_image:version'
103
+ expect(subject.as_definition[:image]).to eq('some_image:version')
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,61 @@
1
+ require 'spec_helper'
2
+
3
+ describe EcsDeployCli::DSL::Cron do
4
+ context 'defines cron data' do
5
+ subject { described_class.new('test', { aws_profile_id: '123123', aws_region: 'eu-central-1' }) }
6
+
7
+ let(:container) do
8
+ EcsDeployCli::DSL::Container.new('web', { aws_profile_id: '123123', aws_region: 'eu-central-1' }).as_definition
9
+ end
10
+
11
+ let(:task) do
12
+ task = EcsDeployCli::DSL::Task.new('some', { aws_profile_id: '123123', aws_region: 'eu-central-1' })
13
+ task.containers :web
14
+
15
+ task.as_definition({ web: container })
16
+ end
17
+
18
+ it '#task' do
19
+ subject.task :some do
20
+ container :web do
21
+ command 'rails', 'run:task'
22
+ memory limit: 2048, reservation: 1024
23
+ end
24
+ end
25
+ subject.subnets 'subnet-1298ca5f'
26
+ subject.security_groups 'sg-1298ca5f'
27
+ subject.launch_type 'FARGATE'
28
+ subject.task_role 'ecsEventsRole'
29
+ subject.run_every '2 hours'
30
+ subject.assign_public_ip true
31
+
32
+ expect(subject.as_definition({ 'some' => task })).to eq(
33
+ {
34
+ task_name: 'some',
35
+ input: {
36
+ 'containerOverrides' => [
37
+ { command: ['rails', 'run:task'], memory: 2048, memory_reservation: 1024, name: 'web' }
38
+ ],
39
+ 'taskRoleArn' => 'arn:aws:iam::123123:role/ecsEventsRole'
40
+ },
41
+ rule: {
42
+ name: 'test',
43
+ schedule_expression: 'rate(2 hours)'
44
+ },
45
+ ecs_parameters: {
46
+ task_count: 1,
47
+ launch_type: 'FARGATE',
48
+ network_configuration: {
49
+ awsvpc_configuration: {
50
+ subnets: ['subnet-1298ca5f'],
51
+ assign_public_ip: 'ENABLED',
52
+ security_groups: ['sg-1298ca5f']
53
+ }
54
+ },
55
+ platform_version: 'LATEST'
56
+ }
57
+ }
58
+ )
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,28 @@
1
+ require 'spec_helper'
2
+
3
+ describe EcsDeployCli::DSL::Parser do
4
+ context 'defines task data' do
5
+ subject { described_class.load('spec/support/ECSFile') }
6
+
7
+ # TODO: More tests
8
+ it 'validates required data in a ECSFile' do
9
+ expect { subject.resolve }.to raise_error('Missing required parameter aws_profile_id')
10
+ end
11
+
12
+ context 'with all required data available' do
13
+ around(:each) do |example|
14
+ ENV['AWS_PROFILE_ID'] = '123123123'
15
+ ENV['AWS_REGION'] = 'us-east-1'
16
+ example.run
17
+ ENV['AWS_PROFILE_ID'] = nil
18
+ ENV['AWS_REGION'] = nil
19
+ end
20
+
21
+ it 'imports the ECSFile' do
22
+ services, tasks = subject.resolve
23
+ expect(services).to include('yourproject-service')
24
+ expect(tasks).to include(:yourproject)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,34 @@
1
+ require 'spec_helper'
2
+
3
+ describe EcsDeployCli::DSL::Task do
4
+ context 'defines task data' do
5
+ subject { described_class.new('test', { aws_profile_id: '123123', aws_region: 'eu-central-1' }) }
6
+
7
+ it 'has family name equals test' do
8
+ expect(subject.as_definition({})[:family]).to eq('test')
9
+ end
10
+
11
+ it '#tag adds a tag' do
12
+ subject.tag 'product', 'yourproject'
13
+ subject.tag 'product2', 'yourproject2'
14
+ expect(subject.as_definition({})[:tags]).to eq(
15
+ [
16
+ { key: 'product', value: 'yourproject' },
17
+ { key: 'product2', value: 'yourproject2' }
18
+ ]
19
+ )
20
+ end
21
+
22
+ it '#execution_role' do
23
+ subject.execution_role 'someRole'
24
+ expect(subject.as_definition({})[:execution_role_arn]).to eq(
25
+ 'arn:aws:iam::123123:role/someRole'
26
+ )
27
+ end
28
+
29
+ it 'fallbacks not handled methods to an option in the container definition' do
30
+ subject.cpu 256
31
+ expect(subject.as_definition({})[:cpu]).to eq('256')
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,75 @@
1
+ require 'spec_helper'
2
+ require 'aws-sdk-cloudwatchevents'
3
+ require 'aws-sdk-ec2'
4
+
5
+ describe EcsDeployCli::Runner do
6
+ context 'defines task data' do
7
+ let(:parser) { EcsDeployCli::DSL::Parser.load('spec/support/ECSFile') }
8
+ subject { described_class.new(parser) }
9
+ let(:mock_ecs_client) { Aws::ECS::Client.new(stub_responses: true) }
10
+ let(:mock_ec2_client) { Aws::EC2::Client.new(stub_responses: true) }
11
+ let(:mock_cwe_client) do
12
+ Aws::CloudWatchEvents::Client.new(stub_responses: true)
13
+ end
14
+
15
+ it '#validate!' do
16
+ expect { subject.validate! }.to raise_error('Missing required parameter aws_profile_id')
17
+ end
18
+
19
+ context 'with envs set' do
20
+ around(:each) do |example|
21
+ ENV['AWS_PROFILE_ID'] = '123123123'
22
+ ENV['AWS_REGION'] = 'us-east-1'
23
+ example.run
24
+ ENV['AWS_PROFILE_ID'] = nil
25
+ ENV['AWS_REGION'] = nil
26
+ end
27
+
28
+ it '#ssh' do
29
+ expect(mock_ecs_client).to receive(:list_container_instances).and_return({ container_instance_arns: ['arn:123123'] })
30
+ expect(mock_ecs_client).to receive(:describe_container_instances).and_return(double(container_instances: [double(ec2_instance_id: 'i-123123')]))
31
+
32
+ expect(mock_ec2_client).to receive(:describe_instances)
33
+ .with(instance_ids: ['i-123123'])
34
+ .and_return(
35
+ double(reservations: [
36
+ double(instances: [double(public_dns_name: 'test.com')])
37
+ ]
38
+ )
39
+ )
40
+
41
+ expect(Process).to receive(:fork) do |&block|
42
+ block.call
43
+ end
44
+ expect(Process).to receive(:wait)
45
+
46
+ expect(subject).to receive(:exec).with('ssh ec2-user@test.com')
47
+ expect(subject).to receive(:ecs_client).at_least(:once).and_return(mock_ecs_client)
48
+ expect(subject).to receive(:ec2_client).at_least(:once).and_return(mock_ec2_client)
49
+
50
+ subject.ssh
51
+ end
52
+
53
+ it '#update_crons!' do
54
+ mock_ecs_client.stub_responses(:register_task_definition, { task_definition: { family: 'some', revision: 1, task_definition_arn: 'arn:task:eu-central-1:xxxx' } })
55
+
56
+ mock_cwe_client.stub_responses(:list_targets_by_rule, { targets: [{ id: '123', arn: 'arn:123' }] })
57
+
58
+ expect(subject).to receive(:ecs_client).at_least(:once).and_return(mock_ecs_client)
59
+ expect(subject).to receive(:cwe_client).at_least(:once).and_return(mock_cwe_client)
60
+
61
+ subject.update_crons!
62
+ end
63
+
64
+ it '#update_services!' do
65
+ expect(mock_ecs_client).to receive(:register_task_definition).at_least(:once).and_return({ task_definition: { family: 'some', revision: '1' } })
66
+ expect(mock_ecs_client).to receive(:update_service)
67
+ expect(mock_ecs_client).to receive(:wait_until)
68
+
69
+ expect(subject).to receive(:ecs_client).at_least(:once).and_return(mock_ecs_client)
70
+
71
+ subject.update_services!
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,15 @@
1
+ require 'active_support'
2
+ require 'rspec'
3
+ require 'ecs_deploy_cli'
4
+ # require 'kaminari-activerecord'
5
+
6
+ I18n.enforce_available_locales = false
7
+ RSpec::Expectations.configuration.warn_about_potential_false_positives = false
8
+
9
+ Dir[File.expand_path('../support/*.rb', __FILE__)].each { |f| require f }
10
+
11
+ RSpec.configure do |config|
12
+
13
+ end
14
+
15
+
@@ -0,0 +1,80 @@
1
+ aws_region ENV.fetch('AWS_REGION', 'eu-central-1')
2
+
3
+ # Used to create ARNs
4
+ aws_profile_id ENV['AWS_PROFILE_ID']
5
+
6
+ # Defining the cluster name
7
+ cluster 'yourproject-cluster'
8
+
9
+ # This is used as a template for the next two containers, it will not be used inside a task
10
+ container :base_container do
11
+ image "#{ENV['REPO_URL']}:#{ENV['CURRENT_VERSION']}"
12
+ load_envs 'spec/support/env_file.yml'
13
+ load_envs 'spec/support/env_file.ext.yml'
14
+ secret key: 'RAILS_MASTER_KEY', value: 'railsMasterKey' # Taking the secret from AWS System Manager with name "arn:aws:ssm:__AWS_REGION__:__AWS_PROFILE_ID__:parameter/railsMasterKey"
15
+ working_directory '/app'
16
+ cloudwatch_logs 'yourproject' # Configuring cloudwatch logs
17
+ end
18
+
19
+ # The rails web application
20
+ container :web, extends: :base_container do
21
+ cpu 512
22
+ memory limit: 3584, reservation: 3584
23
+ command 'bundle', 'exec', 'puma', '-C', 'config/puma.rb'
24
+
25
+ expose host_port: 0, protocol: 'tcp', container_port: 3000
26
+ end
27
+
28
+ # The rails job worker
29
+ container :worker, extends: :base_container do
30
+ cpu 1536
31
+ memory limit: 3584, reservation: 3584
32
+ command 'bundle', 'exec', 'shoryuken', '-C', 'config/shoryuken.yml', '-R'
33
+ end
34
+
35
+ # A container to exec cron jobs
36
+ container :cron, extends: :base_container do
37
+ command 'rails', 'runner'
38
+ end
39
+
40
+ # The main task, having two containers
41
+ task :yourproject do
42
+ containers :web, :worker
43
+ cpu 2048
44
+ memory 3584
45
+
46
+ tag 'product', 'yourproject'
47
+ end
48
+
49
+ # The main service
50
+ service :'yourproject-service' do
51
+ task :yourproject
52
+ end
53
+
54
+ # A task for cron jobs
55
+ task :'yourproject-cron' do
56
+ containers :cron
57
+ cpu 256
58
+ memory 1024
59
+ execution_role 'ecsTaskExecutionRole'
60
+ network_mode 'awsvpc'
61
+ requires_compatibilities ['FARGATE']
62
+
63
+ tag 'product', 'yourproject'
64
+ end
65
+
66
+ # Scheduled tasks using Cloudwatch Events / Eventbridge
67
+ cron :scheduled_emails do
68
+ task :'yourproject-cron' do
69
+ # Overrides
70
+ container :cron do
71
+ command 'rails', 'cron:exec'
72
+ end
73
+ end
74
+ subnets 'subnet-123123'
75
+ launch_type 'FARGATE'
76
+ task_role 'ecsEventsRole'
77
+ # Examples:
78
+ # run_every '2 hours'
79
+ run_at '0 * * * ? *'
80
+ end
@@ -0,0 +1,2 @@
1
+ - name: 'OTHER_API_KEY'
2
+ value: '321321321'
@@ -0,0 +1,5 @@
1
+ - name: 'RAILS_ENV'
2
+ value: 'production'
3
+
4
+ - name: 'API_KEY'
5
+ value: '123123123'
metadata ADDED
@@ -0,0 +1,181 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ecs_deploy_cli
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Mònade
8
+ - ProGM
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2021-03-31 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: activesupport
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: '5'
21
+ - - "<"
22
+ - !ruby/object:Gem::Version
23
+ version: '7'
24
+ type: :runtime
25
+ prerelease: false
26
+ version_requirements: !ruby/object:Gem::Requirement
27
+ requirements:
28
+ - - ">="
29
+ - !ruby/object:Gem::Version
30
+ version: '5'
31
+ - - "<"
32
+ - !ruby/object:Gem::Version
33
+ version: '7'
34
+ - !ruby/object:Gem::Dependency
35
+ name: aws-sdk-cloudwatchevents
36
+ requirement: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1'
41
+ type: :runtime
42
+ prerelease: false
43
+ version_requirements: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1'
48
+ - !ruby/object:Gem::Dependency
49
+ name: aws-sdk-ec2
50
+ requirement: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1'
55
+ type: :runtime
56
+ prerelease: false
57
+ version_requirements: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1'
62
+ - !ruby/object:Gem::Dependency
63
+ name: aws-sdk-ecs
64
+ requirement: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1'
69
+ type: :runtime
70
+ prerelease: false
71
+ version_requirements: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1'
76
+ - !ruby/object:Gem::Dependency
77
+ name: thor
78
+ requirement: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.1'
83
+ type: :runtime
84
+ prerelease: false
85
+ version_requirements: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.1'
90
+ - !ruby/object:Gem::Dependency
91
+ name: rspec
92
+ requirement: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '3'
97
+ type: :development
98
+ prerelease: false
99
+ version_requirements: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '3'
104
+ - !ruby/object:Gem::Dependency
105
+ name: rubocop
106
+ requirement: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '0.93'
111
+ type: :development
112
+ prerelease: false
113
+ version_requirements: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '0.93'
118
+ description: Declare your cluster structure in a ECSFile and use the CLI to run deploys
119
+ and monitor its status.
120
+ email: team@monade.io
121
+ executables:
122
+ - ecs-deploy
123
+ extensions: []
124
+ extra_rdoc_files: []
125
+ files:
126
+ - bin/ecs-deploy
127
+ - lib/ecs-deploy-cli.rb
128
+ - lib/ecs_deploy_cli.rb
129
+ - lib/ecs_deploy_cli/cli.rb
130
+ - lib/ecs_deploy_cli/dsl/auto_options.rb
131
+ - lib/ecs_deploy_cli/dsl/container.rb
132
+ - lib/ecs_deploy_cli/dsl/cron.rb
133
+ - lib/ecs_deploy_cli/dsl/parser.rb
134
+ - lib/ecs_deploy_cli/dsl/service.rb
135
+ - lib/ecs_deploy_cli/dsl/task.rb
136
+ - lib/ecs_deploy_cli/runner.rb
137
+ - lib/ecs_deploy_cli/version.rb
138
+ - spec/ecs_deploy_cli/cli_spec.rb
139
+ - spec/ecs_deploy_cli/dsl/container_spec.rb
140
+ - spec/ecs_deploy_cli/dsl/cron_spec.rb
141
+ - spec/ecs_deploy_cli/dsl/parser_spec.rb
142
+ - spec/ecs_deploy_cli/dsl/task_spec.rb
143
+ - spec/ecs_deploy_cli/runner_spec.rb
144
+ - spec/spec_helper.rb
145
+ - spec/support/ECSFile
146
+ - spec/support/env_file.ext.yml
147
+ - spec/support/env_file.yml
148
+ homepage: https://rubygems.org/gems/ecs_deploy_cli
149
+ licenses:
150
+ - MIT
151
+ metadata: {}
152
+ post_install_message:
153
+ rdoc_options: []
154
+ require_paths:
155
+ - lib
156
+ required_ruby_version: !ruby/object:Gem::Requirement
157
+ requirements:
158
+ - - ">="
159
+ - !ruby/object:Gem::Version
160
+ version: 2.5.0
161
+ required_rubygems_version: !ruby/object:Gem::Requirement
162
+ requirements:
163
+ - - ">="
164
+ - !ruby/object:Gem::Version
165
+ version: '0'
166
+ requirements: []
167
+ rubygems_version: 3.2.7
168
+ signing_key:
169
+ specification_version: 4
170
+ summary: A command line interface to make ECS deployments easier
171
+ test_files:
172
+ - spec/spec_helper.rb
173
+ - spec/support/ECSFile
174
+ - spec/support/env_file.yml
175
+ - spec/support/env_file.ext.yml
176
+ - spec/ecs_deploy_cli/runner_spec.rb
177
+ - spec/ecs_deploy_cli/cli_spec.rb
178
+ - spec/ecs_deploy_cli/dsl/task_spec.rb
179
+ - spec/ecs_deploy_cli/dsl/parser_spec.rb
180
+ - spec/ecs_deploy_cli/dsl/cron_spec.rb
181
+ - spec/ecs_deploy_cli/dsl/container_spec.rb