shiprails 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.
@@ -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