snap_deploy 0.1.3
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 +7 -0
- data/.gitignore +21 -0
- data/.init.sh +8 -0
- data/.rbenv-gemsets +1 -0
- data/.rspec +2 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/Gemfile +13 -0
- data/Gemfile.lock +100 -0
- data/LICENSE.txt +22 -0
- data/README.md +34 -0
- data/Rakefile +25 -0
- data/bin/snap-deploy +10 -0
- data/install.sh +18 -0
- data/lib/snap_deploy.rb +4 -0
- data/lib/snap_deploy/cli.rb +24 -0
- data/lib/snap_deploy/helpers.rb +74 -0
- data/lib/snap_deploy/provider.rb +8 -0
- data/lib/snap_deploy/provider/aws.rb +17 -0
- data/lib/snap_deploy/provider/aws/elastic_beanstalk.rb +156 -0
- data/lib/snap_deploy/provider/aws/ops_works.rb +98 -0
- data/lib/snap_deploy/provider/aws/s3.rb +111 -0
- data/lib/snap_deploy/provider/heroku.rb +242 -0
- data/lib/snap_deploy/provider/heroku/api.rb +1532 -0
- data/lib/snap_deploy/provider/update.rb +25 -0
- data/lib/snap_deploy/version.rb +3 -0
- data/snap_deploy.gemspec +28 -0
- data/spec/spec_helper.rb +41 -0
- data/spec/unit/snap_deploy/provider/aws/ops_works_spec.rb +128 -0
- data/spec/unit/snap_deploy/provider/aws/s3_spec.rb +60 -0
- data/spec/unit/snap_deploy/provider/heroku_spec.rb +189 -0
- metadata +177 -0
@@ -0,0 +1,98 @@
|
|
1
|
+
require 'timeout'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
class SnapDeploy::Provider::AWS::OpsWorks < Clamp::Command
|
5
|
+
|
6
|
+
option '--app-id', "APP_ID", "The application ID", :required => true
|
7
|
+
option '--instance-ids', "INSTANCE_IDS", "The instance IDs to deploy to", :multivalued => true
|
8
|
+
option '--[no-]wait', :flag, 'Wait until (or not) deployed and return the deployment status.', :default => true
|
9
|
+
option '--[no-]migrate', :flag, 'If the db should be automatically migrated.', :default => true
|
10
|
+
|
11
|
+
include SnapDeploy::CLI::DefaultOptions
|
12
|
+
include SnapDeploy::Helpers
|
13
|
+
|
14
|
+
def execute
|
15
|
+
require 'aws-sdk'
|
16
|
+
Timeout::timeout(600) do
|
17
|
+
create_deployment
|
18
|
+
end
|
19
|
+
rescue ::Timeout::Error
|
20
|
+
error 'Timeout: Could not finish deployment in 10 minutes.'
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def create_deployment
|
26
|
+
data = client.create_deployment(
|
27
|
+
{
|
28
|
+
stack_id: ops_works_app[:stack_id],
|
29
|
+
command: {name: 'deploy'},
|
30
|
+
comment: deploy_comment,
|
31
|
+
custom_json: custom_json.to_json
|
32
|
+
}.merge(deploy_target)
|
33
|
+
)
|
34
|
+
info "Deployment created: #{data[:deployment_id]}"
|
35
|
+
return unless wait?
|
36
|
+
print "Deploying "
|
37
|
+
deployment = wait_until_deployed(data[:deployment_id])
|
38
|
+
print "\n"
|
39
|
+
if deployment[:status] == 'successful'
|
40
|
+
info "Deployment successful."
|
41
|
+
else
|
42
|
+
error "Deployment failed."
|
43
|
+
raise "Deployment failed."
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def deploy_target
|
48
|
+
target = {app_id: app_id}
|
49
|
+
target[:instance_ids] = instance_ids_list if instance_ids_list && !instance_ids_list.empty?
|
50
|
+
return target
|
51
|
+
end
|
52
|
+
|
53
|
+
def custom_json
|
54
|
+
{
|
55
|
+
deploy: {
|
56
|
+
ops_works_app[:shortname] => {
|
57
|
+
migrate: !!migrate?,
|
58
|
+
scm: {
|
59
|
+
revision: snap_commit
|
60
|
+
}
|
61
|
+
}
|
62
|
+
}
|
63
|
+
}
|
64
|
+
end
|
65
|
+
|
66
|
+
def wait_until_deployed(deployment_id)
|
67
|
+
deployment = nil
|
68
|
+
loop do
|
69
|
+
result = client.describe_deployments(deployment_ids: [deployment_id])
|
70
|
+
deployment = result[:deployments].first
|
71
|
+
break unless deployment[:status] == "running"
|
72
|
+
print "."
|
73
|
+
sleep 5
|
74
|
+
end
|
75
|
+
deployment
|
76
|
+
end
|
77
|
+
|
78
|
+
def ops_works_app
|
79
|
+
@ops_works_app ||= fetch_ops_works_app
|
80
|
+
end
|
81
|
+
|
82
|
+
def fetch_ops_works_app
|
83
|
+
data = client.describe_apps(app_ids: [app_id])
|
84
|
+
unless data[:apps] && data[:apps].count == 1
|
85
|
+
raise "App #{app_id} not found."
|
86
|
+
end
|
87
|
+
data[:apps].first
|
88
|
+
end
|
89
|
+
|
90
|
+
def client
|
91
|
+
@client ||= begin
|
92
|
+
AWS.config(access_key_id: access_key_id, secret_access_key: secret_access_key, logger: logger, log_formatter: AWS::Core::LogFormatter.colored)
|
93
|
+
info "Logging in using Access Key ending with : #{access_key_id[-4..-1]}"
|
94
|
+
AWS::OpsWorks.new.client
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
require 'timeout'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
class SnapDeploy::Provider::AWS::S3 < Clamp::Command
|
5
|
+
|
6
|
+
option '--bucket',
|
7
|
+
'BUCKET_NAME',
|
8
|
+
'S3 Bucket.',
|
9
|
+
:required => true
|
10
|
+
|
11
|
+
option '--region',
|
12
|
+
"REGION",
|
13
|
+
'EC2 Region.',
|
14
|
+
:default => 'us-east-1'
|
15
|
+
|
16
|
+
option '--endpoint',
|
17
|
+
'ENDPOINT',
|
18
|
+
'S3 Endpoint.',
|
19
|
+
:default => 's3.amazonaws.com'
|
20
|
+
|
21
|
+
option '--local-dir',
|
22
|
+
"LOCAL_DIR",
|
23
|
+
'The local directory. e.g. `~/build/s3` (absolute) or `_site` (relative)',
|
24
|
+
:default => Dir.pwd
|
25
|
+
|
26
|
+
option '--remote-dir',
|
27
|
+
'REMOTE_DIR',
|
28
|
+
'The remote s3 directory to upload to.',
|
29
|
+
:default => '/'
|
30
|
+
|
31
|
+
option '--detect-encoding',
|
32
|
+
:flag,
|
33
|
+
'Set HTTP header `Content-Encoding` for files compressed with `gzip` and `compress` utilities.',
|
34
|
+
:default => nil
|
35
|
+
|
36
|
+
option '--cache-control',
|
37
|
+
'CACHE_OPTION',
|
38
|
+
'Set HTTP header `Cache-Control` to suggest that the browser cache the file. Valid options are `no-cache`, `no-store`, `max-age=<seconds>`, `s-maxage=<seconds>`, `no-transform`, `public`, `private`.',
|
39
|
+
:default => 'no-cache'
|
40
|
+
|
41
|
+
option '--expires',
|
42
|
+
'WHEN',
|
43
|
+
'This sets the date and time that the cached object is no longer cacheable. The date must be in the format `YYYY-MM-DD HH:MM:SS -ZONE`',
|
44
|
+
:default => nil
|
45
|
+
|
46
|
+
option '--acl',
|
47
|
+
'ACL',
|
48
|
+
'Sets the access control for the uploaded objects. Valid options are `private`, `public_read`, `public_read_write`, `authenticated_read`, `bucket_owner_read`, `bucket_owner_full_control`.',
|
49
|
+
:default => 'private'
|
50
|
+
|
51
|
+
option '--include-dot-files',
|
52
|
+
:flag,
|
53
|
+
'When set, upload files starting a `.`.'
|
54
|
+
|
55
|
+
option '--index-document',
|
56
|
+
'DOCUMENT_NAME',
|
57
|
+
'Set the index document of a S3 website.'
|
58
|
+
|
59
|
+
include SnapDeploy::CLI::DefaultOptions
|
60
|
+
include SnapDeploy::Helpers
|
61
|
+
|
62
|
+
def execute
|
63
|
+
require 'aws-sdk'
|
64
|
+
require 'mime-types'
|
65
|
+
|
66
|
+
glob_args = ["**/*"]
|
67
|
+
glob_args << File::FNM_DOTMATCH if include_dot_files?
|
68
|
+
|
69
|
+
Dir.chdir(local_dir) do
|
70
|
+
Dir.glob(*glob_args) do |filename|
|
71
|
+
content_type = MIME::Types.type_for(filename).first.to_s
|
72
|
+
opts = { :content_type => content_type }.merge(encoding_option_for(filename))
|
73
|
+
opts[:cache_control] = cache_control if cache_control
|
74
|
+
opts[:acl] = acl if acl
|
75
|
+
opts[:expires] = expires if expires
|
76
|
+
unless File.directory?(filename)
|
77
|
+
client.buckets[bucket].objects.create(upload_path(filename), File.read(filename), opts)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
if index_document
|
83
|
+
client.buckets[bucket].configure_website do |cfg|
|
84
|
+
cfg.index_document_suffix = index_document
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
private
|
90
|
+
|
91
|
+
def upload_path(filename)
|
92
|
+
[remote_dir, filename].compact.join("/")
|
93
|
+
end
|
94
|
+
|
95
|
+
def client
|
96
|
+
@client ||= begin
|
97
|
+
AWS.config(access_key_id: access_key_id, secret_access_key: secret_access_key, region: region, logger: logger, log_formatter: AWS::Core::LogFormatter.colored)
|
98
|
+
info "Logging in using Access Key ending with : #{access_key_id[-4..-1]}"
|
99
|
+
AWS::S3.new(endpoint: endpoint)
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def encoding_option_for(path)
|
104
|
+
if detect_encoding? && encoding_for(path)
|
105
|
+
{:content_encoding => encoding_for(path)}
|
106
|
+
else
|
107
|
+
{}
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
end
|
@@ -0,0 +1,242 @@
|
|
1
|
+
require 'rake'
|
2
|
+
require 'rake/file_utils_ext'
|
3
|
+
|
4
|
+
class SnapDeploy::Provider::Heroku < Clamp::Command
|
5
|
+
|
6
|
+
SnapDeploy::CLI.subcommand 'heroku', 'deploy to heroku', self
|
7
|
+
|
8
|
+
include SnapDeploy::CLI::DefaultOptions
|
9
|
+
include SnapDeploy::Helpers
|
10
|
+
include Rake::FileUtilsExt
|
11
|
+
|
12
|
+
option '--app-name',
|
13
|
+
'APP_NAME',
|
14
|
+
'The name of the heroku app to deploy',
|
15
|
+
:required => true
|
16
|
+
|
17
|
+
option '--region',
|
18
|
+
'REGION',
|
19
|
+
'The name of the region',
|
20
|
+
:default => 'us'
|
21
|
+
|
22
|
+
option '--config-var',
|
23
|
+
'KEY=VALUE',
|
24
|
+
'The name of the config variables',
|
25
|
+
:multivalued => true
|
26
|
+
|
27
|
+
option '--buildpack-url',
|
28
|
+
'BUILDPACK_URL',
|
29
|
+
'The url of the heroku buildpack' do |url|
|
30
|
+
require 'uri'
|
31
|
+
if url =~ URI::regexp(%w(http https git))
|
32
|
+
url
|
33
|
+
else
|
34
|
+
raise 'The buildpack url does not appear to be a url.'
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
option '--stack-name',
|
39
|
+
'STACK_NAME',
|
40
|
+
'The name of the heroku stack',
|
41
|
+
:default => 'cedar'
|
42
|
+
|
43
|
+
option '--[no-]db-migrate',
|
44
|
+
:flag,
|
45
|
+
'If the db should be automatically migrated',
|
46
|
+
:default => false
|
47
|
+
|
48
|
+
def initialize(*args)
|
49
|
+
super
|
50
|
+
require 'snap_deploy/provider/heroku/api'
|
51
|
+
require 'netrc'
|
52
|
+
require 'ansi'
|
53
|
+
require 'rendezvous'
|
54
|
+
require 'tempfile'
|
55
|
+
end
|
56
|
+
|
57
|
+
def execute
|
58
|
+
check_auth
|
59
|
+
maybe_create_app
|
60
|
+
setup_configuration
|
61
|
+
git_push
|
62
|
+
maybe_db_migrate
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
SLEEP_INTERVAL = 0.1
|
67
|
+
|
68
|
+
def maybe_db_migrate
|
69
|
+
return unless db_migrate?
|
70
|
+
|
71
|
+
log ANSI::Code.ansi("Attempting to run `#{migrate_command}`", :cyan)
|
72
|
+
dyno = client.dyno.create(app_name, :command => %Q{#{migrate_command}; echo "Command exited with $?"}, :attach => true)
|
73
|
+
|
74
|
+
reader, writer = IO.pipe
|
75
|
+
|
76
|
+
thread = Thread.new do
|
77
|
+
Rendezvous.start(:input => StringIO.new, :output => writer, :url => dyno['attach_url'])
|
78
|
+
end
|
79
|
+
|
80
|
+
begin
|
81
|
+
exit_code = Timeout.timeout(300) do
|
82
|
+
copy_to_stdout(thread, reader, writer)
|
83
|
+
end
|
84
|
+
|
85
|
+
unless exit_code
|
86
|
+
error ANSI::Code.ansi('The remote command execution may have failed to return an exit code.', :red)
|
87
|
+
exit(-1)
|
88
|
+
end
|
89
|
+
|
90
|
+
if exit_code != 0
|
91
|
+
error ANSI::Code.ansi("The remote command exited with status #{exit_code}", :red)
|
92
|
+
exit(exit_code)
|
93
|
+
end
|
94
|
+
rescue Timeout::Error
|
95
|
+
raise Timeout::Error('There was no output generated in 300 seconds.')
|
96
|
+
end
|
97
|
+
|
98
|
+
thread.join(SLEEP_INTERVAL)
|
99
|
+
end
|
100
|
+
|
101
|
+
def copy_to_stdout(thread, reader, writer)
|
102
|
+
writer.sync = true
|
103
|
+
exit_code = nil
|
104
|
+
tempfile = Tempfile.new('heroku-console-log')
|
105
|
+
|
106
|
+
loop do
|
107
|
+
nothing_to_read = if IO.select([reader], nil, nil, SLEEP_INTERVAL)
|
108
|
+
contents = (reader.readpartial(4096) rescue nil) unless writer.closed?
|
109
|
+
copy_output(contents, tempfile)
|
110
|
+
!!contents
|
111
|
+
else
|
112
|
+
true
|
113
|
+
end
|
114
|
+
|
115
|
+
thread_dead = if thread.join(SLEEP_INTERVAL)
|
116
|
+
writer.close unless writer.closed?
|
117
|
+
true
|
118
|
+
end
|
119
|
+
|
120
|
+
break if thread_dead && nothing_to_read
|
121
|
+
end
|
122
|
+
|
123
|
+
# read everything one last time
|
124
|
+
copy_output(reader.read, tempfile)
|
125
|
+
|
126
|
+
# go back a bit in the console output to check on the exit status, aka poor man's tail(1)
|
127
|
+
tempfile.seek([-tempfile.size, -32].max, IO::SEEK_END)
|
128
|
+
|
129
|
+
if last_line = tempfile.readlines.last
|
130
|
+
if match_data = last_line.match(/Command exited with (\d+)/)
|
131
|
+
exit_code = match_data[1].to_i
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
exit_code
|
136
|
+
end
|
137
|
+
|
138
|
+
def copy_output(contents, tempfile)
|
139
|
+
return unless contents
|
140
|
+
tempfile.print contents
|
141
|
+
print contents
|
142
|
+
end
|
143
|
+
|
144
|
+
def migrate_command
|
145
|
+
'rake db:migrate --trace'
|
146
|
+
end
|
147
|
+
|
148
|
+
def maybe_create_app
|
149
|
+
print ANSI::Code.ansi('Checking to see if app already exists... ', :cyan)
|
150
|
+
if app_exists?
|
151
|
+
print ANSI::Code.ansi("OK\n", :green)
|
152
|
+
else
|
153
|
+
print ANSI::Code.ansi("No\n", :yellow)
|
154
|
+
create_app
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
def setup_configuration
|
159
|
+
print ANSI::Code.ansi('Setting up config vars... ', :cyan)
|
160
|
+
|
161
|
+
# config_var_list returns a dup
|
162
|
+
configs = config_var_list
|
163
|
+
|
164
|
+
configs << "BUILDPACK_URL=#{buildpack_url}" if buildpack_url
|
165
|
+
|
166
|
+
if configs.empty?
|
167
|
+
print ANSI::Code.ansi("No config vars specified\n", :green)
|
168
|
+
return
|
169
|
+
end
|
170
|
+
|
171
|
+
existing_vars = client.config_var.info(app_name)
|
172
|
+
|
173
|
+
vars = configs.inject({}) do |memo, var|
|
174
|
+
key, value = var.split('=', 2)
|
175
|
+
if existing_vars[key] != value
|
176
|
+
memo[key] = value
|
177
|
+
end
|
178
|
+
memo
|
179
|
+
end
|
180
|
+
|
181
|
+
if vars.empty?
|
182
|
+
print ANSI::Code.ansi("No change required\n", :green)
|
183
|
+
else
|
184
|
+
print ANSI::Code.ansi("\nUpdating config vars #{vars.keys.join(', ')}... ", :cyan)
|
185
|
+
client.config_var.update(app_name, vars)
|
186
|
+
print ANSI::Code.ansi("OK\n", :green)
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
def git_push
|
191
|
+
if pull_request_number
|
192
|
+
print ANSI::Code.ansi("Pushing upstream branch #{snap_upstream_branch} from pull request #{pull_request_number} to heroku.\n", :cyan)
|
193
|
+
else
|
194
|
+
print ANSI::Code.ansi("Pushing branch #{snap_branch} to heroku.\n", :cyan)
|
195
|
+
end
|
196
|
+
cmd = "git push https://git.heroku.com/#{app_name}.git HEAD:refs/heads/master -f"
|
197
|
+
puts "$ #{ANSI::Code.ansi(cmd, :green)}"
|
198
|
+
sh(cmd) do |ok, res|
|
199
|
+
raise "Could not push to heroku remote. The exit code was #{res.exitstatus}." unless ok
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
def create_app
|
204
|
+
print ANSI::Code.ansi('Creating app on heroku since it does not exist... ', :cyan)
|
205
|
+
client.app.create({ name: app_name, region: region, stack: stack_name })
|
206
|
+
print ANSI::Code.ansi("Done\n", :green)
|
207
|
+
end
|
208
|
+
|
209
|
+
def app_exists?
|
210
|
+
!!client.app.info(app_name)
|
211
|
+
rescue Excon::Errors::Unauthorized, Excon::Errors::Forbidden => e
|
212
|
+
raise "You are not authorized to check if the app exists, perhaps you don't own that app?. The server returned status code #{e.response[:status]}."
|
213
|
+
rescue Excon::Errors::NotFound => ignore
|
214
|
+
false
|
215
|
+
end
|
216
|
+
|
217
|
+
def check_auth
|
218
|
+
print ANSI::Code.ansi('Checking heroku credentials... ', :cyan)
|
219
|
+
client.account.info
|
220
|
+
print ANSI::Code.ansi("OK\n", :green)
|
221
|
+
rescue Excon::Errors::HTTPStatusError => e
|
222
|
+
raise "Could not connect to heroku to check your credentials. The server returned status code #{e.response[:status]}."
|
223
|
+
end
|
224
|
+
|
225
|
+
def client
|
226
|
+
SnapDeploy::Provider::Heroku::API.connect_oauth(token)
|
227
|
+
end
|
228
|
+
|
229
|
+
def netrc
|
230
|
+
@netrc ||= Netrc.read
|
231
|
+
end
|
232
|
+
|
233
|
+
def token
|
234
|
+
@token ||= netrc['api.heroku.com'].password
|
235
|
+
end
|
236
|
+
|
237
|
+
def default_options
|
238
|
+
{
|
239
|
+
}
|
240
|
+
end
|
241
|
+
|
242
|
+
end
|