ecs_deploy_cli 0.1.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.
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