shiprails 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,140 @@
1
+ require "active_support/all"
2
+ require "aws-sdk"
3
+ require "thor/group"
4
+
5
+ module Shiprails
6
+ class Ship < Thor
7
+ class Exec < Thor::Group
8
+ include Thor::Actions
9
+ class_option "path",
10
+ aliases: ["-p"],
11
+ default: ".",
12
+ desc: "Specify a configuration path"
13
+ class_option "environment",
14
+ default: "production",
15
+ desc: "Specify the environment"
16
+ class_option "region",
17
+ default: "us-west-2",
18
+ desc: "Specify the region"
19
+ class_option "service",
20
+ default: "app",
21
+ desc: "Specify the service name"
22
+ class_option "private-key",
23
+ default: "~/.ssh/aws.pem",
24
+ desc: "Specify the AWS SSH private key path"
25
+
26
+ def run_command
27
+ region = options['region']
28
+ service = options['service']
29
+ cluster_name = "#{project_name}_#{options['environment']}"
30
+ command_string = args.join ' '
31
+ ssh_private_key_path = options['private-key']
32
+ ecs_exec(region, cluster_name, service, command_string, ssh_private_key_path)
33
+ end
34
+
35
+ private
36
+
37
+ def configuration
38
+ YAML.load(File.read("#{options[:path]}/.shiprails.yml")).deep_symbolize_keys
39
+ end
40
+
41
+ def aws_access_key_id
42
+ @aws_access_key_id ||= ask "AWS Access Key ID", default: ENV.fetch("AWS_ACCESS_KEY_ID")
43
+ end
44
+
45
+ def aws_access_key_secret
46
+ @aws_access_key_secret ||= ask "AWS Access Key Secret", default: ENV.fetch("AWS_SECRET_ACCESS_KEY")
47
+ end
48
+
49
+ def project_name
50
+ configuration[:project_name]
51
+ end
52
+
53
+ def ecs_exec(region, cluster, service, command, ssh_private_key_path, ssh_user: 'ec2-user')
54
+ # we'll need to use both the ecs and ec2 apis
55
+ ecs = Aws::ECS::Client.new(region: region)
56
+ ec2 = Aws::EC2::Client.new(region: region)
57
+
58
+ # first we get the ARN of the task managed by the service
59
+ tasks_list = ecs.list_tasks({cluster: cluster, desired_status: 'RUNNING', service_name: service})
60
+ task_arn = tasks_list.task_arns.first
61
+
62
+ # using the ARN of the task, we can get the ARN of the container instance where its being deployed
63
+ task_descriptions = ecs.describe_tasks({cluster: cluster, tasks: [task_arn]})
64
+ task_definition_arn = task_descriptions.tasks.first.task_definition_arn
65
+ task_definition_name = task_definition_arn.split('/').last
66
+ container_instance_arn = task_descriptions.tasks.first.container_instance_arn
67
+ task_definition_description = ecs.describe_task_definition({task_definition: task_definition_name})
68
+
69
+ say "Setting up EC2 instance for SSH..."
70
+ # with the instance ARN let's grab the intance id
71
+ ec2_instance_id = ecs.describe_container_instances({cluster: cluster, container_instances: [container_instance_arn]}).container_instances.first.ec2_instance_id
72
+ ec2_instance = ec2.describe_instances({instance_ids: [ec2_instance_id]}).reservations.first.instances.first
73
+
74
+ # get its current security groups to restory later
75
+ security_group_ids = ec2_instance.security_groups.map(&:group_id)
76
+
77
+ # create new public ip
78
+ elastic_ip = ec2.allocate_address({ domain: "vpc" })
79
+ # link ip to ec2 instance
80
+ associate_address_response = ec2.associate_address({
81
+ allocation_id: elastic_ip.allocation_id,
82
+ instance_id: ec2_instance_id
83
+ })
84
+ # create security group for us
85
+ security_group_response = ec2.create_security_group({
86
+ group_name: "shiprails-exec-#{cluster}-#{Time.now.to_i}",
87
+ description: "SSH access to run interactive command (created by #{`whoami`.rstrip} via shiprails)",
88
+ vpc_id: ec2_instance.vpc_id
89
+ })
90
+ # get our public ip
91
+ my_ip_address = open('http://whatismyip.akamai.com').read
92
+ # authorize SSH access from our public ip
93
+ ec2.authorize_security_group_ingress({
94
+ group_id: security_group_response.group_id,
95
+ ip_protocol: "tcp",
96
+ from_port: 22,
97
+ to_port: 22,
98
+ cidr_ip: "#{my_ip_address}/32"
99
+ })
100
+ # add ec2 instance to our new security group
101
+ ec2.modify_instance_attribute({
102
+ instance_id: ec2_instance_id,
103
+ groups: security_group_ids + [security_group_response.group_id]
104
+ })
105
+
106
+ # build the command we'll run on the instance
107
+ command_array = ["docker run -it --rm"]
108
+ task_definition_description.task_definition.container_definitions.first.environment.each do |env|
109
+ command_array << "-e #{env.name}='#{env.value}'"
110
+ end
111
+ command_array << task_definition_description.task_definition.container_definitions.first.image
112
+ command_array << command
113
+ command_string = command_array.join ' '
114
+
115
+ say "Waiting for AWS to setup networking..."
116
+ sleep 5 # AWS just needs a little bit to setup networking
117
+ say "Connecting #{ssh_user}@#{elastic_ip.public_ip}..."
118
+ say "Executing: $ #{command_string}"
119
+ system "ssh -o ConnectTimeout=15 -o 'StrictHostKeyChecking no' -t -i #{ssh_private_key_path} #{ssh_user}@#{elastic_ip.public_ip} '#{command_string}'"
120
+ rescue => e
121
+ say "Error: #{e.message}", :red
122
+ ensure
123
+ say "Cleaning up SSH access..."
124
+ # restore original security groups
125
+ ec2.modify_instance_attribute({
126
+ instance_id: ec2_instance_id,
127
+ groups: security_group_ids
128
+ }) rescue nil
129
+ # remove our access security group
130
+ ec2.delete_security_group({ group_id: security_group_response.group_id }) rescue nil
131
+ # unlink ec2 instance from public ip
132
+ ec2.disassociate_address({ association_id: associate_address_response.association_id }) rescue nil
133
+ # release public ip address
134
+ ec2.release_address({ allocation_id: elastic_ip.allocation_id }) rescue nil
135
+ say "Done.", :green
136
+ end
137
+
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,23 @@
1
+ # https://github.com/ddollar/forego
2
+
3
+ COMPOSE_PROJECT_NAME=<%= project_name %>
4
+ APPLICATION_HOST=<%= application_host %>
5
+ ASSET_HOST=<%= application_host %>
6
+ APPLICATION_HOST=<%= application_host %>
7
+
8
+ PORT=3000
9
+ RACK_ENV=development
10
+ SECRET_KEY_BASE=development_secret
11
+ EXECJS_RUNTIME=Node
12
+
13
+ SMTP_ADDRESS=smtp.example.com
14
+ SMTP_DOMAIN=example.com
15
+ SMTP_PASSWORD=password
16
+ SMTP_USERNAME=username
17
+
18
+ RACK_MINI_PROFILER=1
19
+ WEB_CONCURRENCY=1
20
+ MAX_THREADS=4
21
+
22
+ FACEBOOK_APP_ID=FILL_ME_IN
23
+ FACEBOOK_SECRET=FILL_ME_IN
@@ -0,0 +1,28 @@
1
+ FROM ruby:<%= ruby_version %>-slim
2
+
3
+ RUN apt-get update -qq && apt-get install -y build-essential git-core
4
+
5
+ # for postgres
6
+ RUN apt-get install -y libpq-dev
7
+
8
+ # for nokogiri
9
+ RUN apt-get install -y libxml2-dev libxslt1-dev
10
+
11
+ # for capybara-webkit
12
+ RUN apt-get install -y libqt4-webkit libqt4-dev xvfb
13
+
14
+ # for a JS runtime
15
+ RUN apt-get install -y nodejs
16
+
17
+ # lighten up the image size
18
+ RUN rm -rf /var/lib/apt/lists/*
19
+
20
+ RUN gem install bundler --no-document
21
+
22
+ ENV APP_HOME /app
23
+ RUN mkdir $APP_HOME
24
+ WORKDIR $APP_HOME
25
+
26
+ ENV BUNDLE_PATH /bundle
27
+
28
+ ADD . $APP_HOME
@@ -0,0 +1,29 @@
1
+ FROM ruby:<%= ruby_version %>-slim
2
+
3
+ RUN apt-get update -qq && apt-get install -y build-essential git-core
4
+
5
+ # for postgres
6
+ RUN apt-get install -y libpq-dev
7
+
8
+ # for nokogiri
9
+ RUN apt-get install -y libxml2-dev libxslt1-dev
10
+
11
+ # for capybara-webkit
12
+ RUN apt-get install -y libqt4-webkit libqt4-dev xvfb
13
+
14
+ # for a JS runtime
15
+ RUN apt-get install -y nodejs
16
+
17
+ # lighten up the image size
18
+ RUN rm -rf /var/lib/apt/lists/*
19
+
20
+ RUN gem install bundler --no-document
21
+
22
+ ENV APP_HOME /app
23
+ RUN mkdir $APP_HOME
24
+ WORKDIR $APP_HOME
25
+
26
+ ADD Gemfile* $APP_HOME/
27
+ RUN bundle install
28
+
29
+ ADD . $APP_HOME
@@ -0,0 +1,81 @@
1
+ version: '2'
2
+
3
+ services:
4
+ memcached:
5
+ image: memcached
6
+ postgres:
7
+ environment:
8
+ LC_ALL: C.UTF-8
9
+ image: postgres
10
+ volumes:
11
+ - postgres-data:/var/lib/postgresql/data
12
+ redis:
13
+ command: redis-server --appendonly yes
14
+ image: redis
15
+ volumes:
16
+ - redis-data:/var/lib/redis
17
+ app: &app_base
18
+ build: .
19
+ command: ./bin/start
20
+ depends_on:
21
+ - memcached
22
+ - postgres
23
+ - redis
24
+ environment: &app_environment
25
+ # PostgreSQL Development Database:
26
+ DATABASE_URL: postgres://postgres:@postgres:5432/development?pool=25&encoding=unicode&schema_search_path=public
27
+ # memcached Development Cache:
28
+ MEMCACHED_URL: memcached:11211
29
+ # Redis Database:
30
+ REDIS_URL: redis://redis:6379
31
+ DOCKERIZED: 1
32
+ POOL_SIZE: 5
33
+
34
+ # Sidekiq configuration:
35
+ SIDEKIQ_CONCURRENCY: 5
36
+ SIDEKIQ_TIMEOUT: 10
37
+
38
+ # Enable the byebug debugging server - this can be overriden
39
+ # from the command line:
40
+ ENABLE_DEBUG_SERVER: 0
41
+
42
+ # Run the app in the 'development' environment:
43
+ RACK_ENV: development
44
+ env_file: .env
45
+ ports:
46
+ - "3000:3000"
47
+ stdin_open: true
48
+ tmpfs: /app/tmp
49
+ volumes:
50
+ - ".:/app"
51
+ - gems-data:/bundle
52
+ worker:
53
+ <<: *app_base
54
+ command: bundle exec sidekiq -C config/sidekiq.yml
55
+ ports: []
56
+ cable:
57
+ <<: *app_base
58
+ command: bundle exec puma --bind tcp://0.0.0.0:28080 cable/config.ru
59
+ ports:
60
+ - 28080:28080
61
+ # App Guard: Keeps running tests on a separate process:
62
+ test:
63
+ <<: *app_base # We copy from &app_base, and override:
64
+ command: bundle exec guard start --no-bundler-warning --no-interactions
65
+ environment:
66
+ <<: *app_environment
67
+ # PostgreSQL Test Database:
68
+ DATABASE_URL: postgres://postgres:@postgres:5432/test?pool=25&encoding=unicode&schema_search_path=public
69
+
70
+ # Run the app in the 'test' environment, instead of the default 'developent'
71
+ RACK_ENV: test
72
+ RAILS_ENV: test
73
+ ports: []
74
+
75
+ volumes:
76
+ gems-data:
77
+ driver: local
78
+ postgres-data:
79
+ driver: local
80
+ redis-data:
81
+ driver: local
@@ -0,0 +1,29 @@
1
+ config_s3_bucket: '<%= config_s3_bucket %>'
2
+ project_name: '<%= project_name %>'
3
+ services:
4
+ <%- services.each do |service| -%>
5
+ <%= service[:name] %>:
6
+ command: '<%= service[:command] %>'
7
+ image: '<%= service[:image] %>'
8
+ <%- if service[:ports].any? -%>
9
+ ports:
10
+ <%- service[:ports].each do |port| -%>
11
+ - <%= port %>
12
+ <%- end -%>
13
+ <%- end -%>
14
+ regions:
15
+ <%- service[:regions].each do |region, data| -%>
16
+ <%= region %>:
17
+ environments:
18
+ <%- environments.each do |environment| -%>
19
+ - <%= environment[:name] %>
20
+ <%- end -%>
21
+ <%- data.each do |k,v| -%>
22
+ <%= k %>: '<%= v %>'
23
+ <%- end -%>
24
+ <%- end -%>
25
+ resources:
26
+ <%- service[:resources].each do |k,v| -%>
27
+ <%= k %>: <%= v %>
28
+ <%- end -%>
29
+ <%- end -%>
@@ -0,0 +1,159 @@
1
+ require "active_support/all"
2
+ require "aws-sdk"
3
+ require "thor/group"
4
+
5
+ module Shiprails
6
+ class Ship < Thor
7
+ class Install < Thor::Group
8
+ include Thor::Actions
9
+
10
+ class_option "path",
11
+ aliases: ["-p"],
12
+ default: ".",
13
+ desc: "Specify a configuration path"
14
+
15
+ def self.source_root
16
+ File.expand_path("../install", __FILE__)
17
+ end
18
+
19
+ def application_host
20
+ "#{project_name}.dev"
21
+ end
22
+
23
+ no_commands {
24
+ def aws_access_key_id
25
+ @aws_access_key_id ||= ask "AWS Access Key ID", default: ENV.fetch("AWS_ACCESS_KEY_ID")
26
+ end
27
+
28
+ def aws_access_key_secret
29
+ @aws_access_key_secret ||= ask "AWS Access Key Secret", default: ENV.fetch("AWS_SECRET_ACCESS_KEY")
30
+ end
31
+ }
32
+
33
+ def config_s3_bucket
34
+ return @bucket_name unless @bucket_name.nil?
35
+ @_s3_client ||= Aws::S3::Client.new(region: ENV.fetch("AWS_REGION", "us-west-2"), access_key_id: aws_access_key_id, secret_access_key: aws_access_key_secret)
36
+ begin
37
+ bucket_name = "#{project_name}-config"
38
+ bucket_name = ask "S3 bucket name for configuration store", default: bucket_name
39
+ resp = @_s3_client.create_bucket({
40
+ bucket: bucket_name
41
+ })
42
+ rescue Aws::S3::Errors::BucketAlreadyExists
43
+ error "'#{bucket_name}' already exists"
44
+ retry
45
+ rescue Aws::S3::Errors::BucketAlreadyOwnedByYou
46
+ end
47
+ @bucket_name = bucket_name
48
+ end
49
+
50
+ def environments
51
+ environments = Dir.entries("#{Dir.getwd}/config/environments").grep(/\.rb$/).map { |fname| fname.chomp!(".rb") }.select{ |e| !['development', 'test'].include? e } rescue ['production']
52
+ environments ||= ['production']
53
+ @regions ||= ask("Which regions?", default: 'us-west-2').split(',')
54
+ environments.map do |environment|
55
+ {
56
+ name: environment,
57
+ regions: @regions
58
+ }
59
+ end
60
+ end
61
+
62
+ no_commands {
63
+ def services
64
+ return @services unless @services.nil?
65
+ docker_compose = YAML.load(File.read("#{options[:path]}/docker-compose.yml")).deep_symbolize_keys
66
+ image_for_build = {}
67
+ regions_for_build = {}
68
+ @services = docker_compose[:services].map do |service_name, service|
69
+ next if [:test].include? service_name
70
+ if service[:image].nil?
71
+ build_name = service[:build]
72
+ build_name = "Dockerfile" if build_name == '.'
73
+ unless image = image_for_build[build_name]
74
+ image = service_name
75
+ image_for_build[build_name] = image
76
+ end
77
+ unless regions = regions_for_build[build_name]
78
+ regions = @regions.map do |region|
79
+ [region, {
80
+ repository_url: repository_url_for_region(region, service_name)
81
+ }]
82
+ end
83
+ regions_for_build[build_name] = regions
84
+ end
85
+ {
86
+ command: service[:command],
87
+ image: image,
88
+ name: service_name.to_s,
89
+ ports: (service[:ports] || []).map{ |port| port.split(":").last },
90
+ regions: regions,
91
+ resources: {
92
+ cpu_units: 256,
93
+ memory_units: 256
94
+ }
95
+ }
96
+ end
97
+ end.compact
98
+ end
99
+
100
+ def project_name
101
+ @project_name ||= ask "What's your project called?", default: File.basename(Dir.getwd)
102
+ end
103
+
104
+ def ruby_version
105
+ "#{RUBY_VERSION}"
106
+ end
107
+ }
108
+
109
+ def create_dockerfile
110
+ template("Dockerfile.erb", "#{options[:path]}/Dockerfile")
111
+ template("Dockerfile.production.erb", "#{options[:path]}/Dockerfile.production")
112
+ end
113
+
114
+ def create_dot_env
115
+ template(".env.erb", "#{options[:path]}/.env")
116
+ template(".env.erb", "#{options[:path]}/.env.example")
117
+ end
118
+
119
+ def ignore_dot_env
120
+ if File.exists?(".gitignore")
121
+ append_to_file(".gitignore", <<-EOF)
122
+
123
+ # Ignore Docker ENV
124
+ /.env
125
+ EOF
126
+ end
127
+ end
128
+
129
+ def create_docker_compose
130
+ template("docker-compose.yml.erb", "#{options[:path]}/docker-compose.yml")
131
+ end
132
+
133
+ def create_configuration
134
+ template("shiprails.yml.erb", "#{options[:path]}/.shiprails.yml")
135
+ end
136
+
137
+ private
138
+
139
+ def repository_url_for_region(region, service_name)
140
+ @_ecr_client ||= Aws::ECR::Client.new(region: region, access_key_id: aws_access_key_id, secret_access_key: aws_access_key_secret)
141
+ resp = @_ecr_client.describe_repositories({}).to_h
142
+ say "Amazon EC2 Container Registry (ECR) for #{project_name}_#{service_name}?"
143
+ choices = ["CREATE NEW REGISTRY"] + resp[:repositories].map{|r| "#{r[:repository_name]} (#{r[:repository_uri]})" }
144
+ choices = choices.map.with_index{ |a, i| [i+1, *a]}
145
+ print_table choices
146
+ selection = ask("Pick one:").to_i
147
+ if selection == 1
148
+ resp = @_ecr_client.create_repository({
149
+ repository_name: "#{project_name}/#{service_name}",
150
+ }).to_h
151
+ resp[:repository][:repository_uri]
152
+ else
153
+ resp[:repositories][selection - 2][:repository_uri]
154
+ end
155
+ end
156
+
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,81 @@
1
+ require "active_support/all"
2
+ require "aws-sdk"
3
+ require "git"
4
+ require "thor/group"
5
+
6
+ module Shiprails
7
+ class Ship < Thor
8
+ class Scale < Thor::Group
9
+ include Thor::Actions
10
+
11
+ argument :method_name, type: :string
12
+ argument :environment, type: :string
13
+ argument :service, type: :string
14
+ argument :scale, type: :string
15
+
16
+ class_option "region",
17
+ desc: "Specify region"
18
+ class_option "path",
19
+ aliases: ["-p"],
20
+ default: ".",
21
+ desc: "Specify a configuration path"
22
+
23
+ def update_ecs_services
24
+ say "Setting ECS service #{service} scale=#{scale} in #{environment}..."
25
+ configuration[:services].each do |service_name, service|
26
+ next unless service_name.to_s == self.service
27
+ image_name = "#{project_name}_#{service_name}"
28
+ service[:regions].each do |region_name, region|
29
+ next unless options["region"].nil? or options["region"] == region_name.to_s
30
+ ecs = Aws::ECS::Client.new(region: region_name.to_s)
31
+ region[:environments].each do |environment_name|
32
+ next unless environment_name == self.environment
33
+ cluster_name = "#{project_name}_#{environment_name}"
34
+ task_name = "#{image_name}_#{environment_name}"
35
+ begin
36
+ task_definition_description = ecs.describe_task_definition({task_definition: task_name})
37
+ task_definition = task_definition_description.task_definition.to_hash
38
+ rescue Aws::ECS::Errors::ClientException => e
39
+ say "Missing ECS task for #{task_name}!", :red
40
+ say "Run `ship setup`", :red
41
+ exit
42
+ end
43
+ begin
44
+ service_response = ecs.update_service({
45
+ cluster: cluster_name,
46
+ service: service_name,
47
+ desired_count: scale
48
+ })
49
+ say "Set ECS service #{service_name} scale=#{scale} in #{environment} (#{region_name})...", :green
50
+ rescue Aws::ECS::Errors::ServiceNotFoundException, Aws::ECS::Errors::ServiceNotActiveException => e
51
+ say "Missing ECS service for #{task_name}!", :red
52
+ say "Run `ship setup`", :red
53
+ exit
54
+ end
55
+ end
56
+ end
57
+ end
58
+ say "ECS service updated.", :green
59
+ end
60
+
61
+ private
62
+
63
+ def aws_access_key_id
64
+ @aws_access_key_id ||= ask "AWS Access Key ID", default: ENV.fetch("AWS_ACCESS_KEY_ID")
65
+ end
66
+
67
+ def aws_access_key_secret
68
+ @aws_access_key_secret ||= ask "AWS Access Key Secret", default: ENV.fetch("AWS_SECRET_ACCESS_KEY")
69
+ end
70
+
71
+ def configuration
72
+ YAML.load(File.read("#{options[:path]}/.shiprails.yml")).deep_symbolize_keys
73
+ end
74
+
75
+ def project_name
76
+ configuration[:project_name]
77
+ end
78
+
79
+ end
80
+ end
81
+ end