harrison 0.2.0 → 0.3.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.
- data/CHANGELOG +23 -0
- data/README.md +62 -9
- data/harrison.gemspec +1 -0
- data/lib/harrison.rb +18 -14
- data/lib/harrison/base.rb +13 -5
- data/lib/harrison/deploy.rb +160 -42
- data/lib/harrison/deploy/phase.rb +51 -0
- data/lib/harrison/package.rb +18 -10
- data/lib/harrison/ssh.rb +12 -6
- data/lib/harrison/version.rb +1 -1
- data/spec/spec_helper.rb +3 -0
- data/spec/unit/harrison/base_spec.rb +4 -13
- data/spec/unit/harrison/deploy/phase_spec.rb +152 -0
- data/spec/unit/harrison/deploy_spec.rb +352 -63
- data/spec/unit/harrison_spec.rb +13 -11
- metadata +22 -3
data/CHANGELOG
CHANGED
@@ -1,3 +1,26 @@
|
|
1
|
+
0.3.0
|
2
|
+
-------------------
|
3
|
+
- BREAKING: Definition of "deploy" task has significantly changed. Deploys
|
4
|
+
are now comprised of 1 or more phases and each phase must complete
|
5
|
+
successfully on all hosts before proceeding to the next phase. If an
|
6
|
+
error is encountered, Harrison will attempt to unwind any changes made
|
7
|
+
by previously completed phases by invoking the code block supplied to
|
8
|
+
each phase's "on_fail" method. There are several built-in phases to
|
9
|
+
perform common deployment related tasks. See the README for more
|
10
|
+
information.
|
11
|
+
|
12
|
+
- BREAKING: Commands executed with remote_exec() inside the "run" block of
|
13
|
+
the "package" task will now execute in your commit working directory, so
|
14
|
+
you no longer need to prefix commands with "cd #{h.commit}". You will
|
15
|
+
need to update existing Harrisonfile "package" run blocks to reflect the
|
16
|
+
change of execution context.
|
17
|
+
|
18
|
+
- Allow multiple users to execute the "package" task for the same project
|
19
|
+
simultaneously.
|
20
|
+
|
21
|
+
- Added "rollback" command which will create a new deploy pointing to the
|
22
|
+
previously active release.
|
23
|
+
|
1
24
|
0.2.0
|
2
25
|
-------------------
|
3
26
|
- Implemented the ability to purge old releases after a successful deploy.
|
data/README.md
CHANGED
@@ -49,20 +49,73 @@ Harrison.package do |h|
|
|
49
49
|
end
|
50
50
|
|
51
51
|
Harrison.deploy do |h|
|
52
|
-
h.hosts = [ 'util-server-01.example.com', 'app-server-01.example.com', 'app-server-02.example.com' ]
|
53
52
|
h.user = 'jesse'
|
54
53
|
h.base_dir = '/opt'
|
55
54
|
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
55
|
+
h.hosts = [ 'util-server-01.example.com', 'app-server-01.example.com', 'app-server-02.example.com' ]
|
56
|
+
|
57
|
+
# How many deploys to keep around after a successful new deploy.
|
58
|
+
h.keep = 5
|
59
|
+
|
60
|
+
# Built in phases:
|
61
|
+
# - :upload Uploads your artifact to the host.
|
62
|
+
# - :extract Extracts your artifact into a release folder.
|
63
|
+
# - :link Creates a new deploy symlink pointed to the new release.
|
64
|
+
# - :cleanup Removes deploys older than the --keep option, if set.
|
65
|
+
#
|
66
|
+
# You can override these phases by adding a phase with the same name below.
|
67
|
+
#
|
68
|
+
# You will probably want to add one or more phases to actually do restart
|
69
|
+
# your application in an appropriate way.
|
70
|
+
#
|
71
|
+
# The built in "rollback" action will run your configured phases except
|
72
|
+
# that it will not run any phases named "upload", "extract", or "cleanup".
|
73
|
+
# Also, h.rollback can be inspected to distinguish a "rollback" action from
|
74
|
+
# a normal "deploy" action.
|
75
|
+
|
76
|
+
h.add_phase :migrate do |phase|
|
77
|
+
# Only run this phase on util boxes.
|
78
|
+
phase.add_condition { |h| h.host =~ /util/ }
|
79
|
+
|
80
|
+
phase.on_run do |h|
|
81
|
+
# Make the "current" symlink point to the new deploy.
|
82
|
+
h.update_current_symlink
|
83
|
+
|
84
|
+
h.remote_exec(%Q(bash -l -c "bundle exec rake db:migrate"))
|
85
|
+
end
|
86
|
+
|
87
|
+
phase.on_fail do |h|
|
88
|
+
# Make the "current" symlink point back to the previously active deploy.
|
89
|
+
h.revert_current_symlink
|
90
|
+
|
91
|
+
h.remote_exec(%Q(bash -l -c "bundle exec rake db:migrate"))
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
h.add_phase :restart do |phase|
|
96
|
+
# Only run this phase on non-util boxes.
|
97
|
+
phase.add_condition { |h| h.host !~ /util/ }
|
98
|
+
|
99
|
+
phase.on_run do |h|
|
100
|
+
# Make the "current" symlink point to the new deploy.
|
101
|
+
h.update_current_symlink
|
102
|
+
|
103
|
+
h.remote_exec("touch #{h.current_symlink}/restart.txt")
|
104
|
+
end
|
105
|
+
|
106
|
+
phase.on_fail do |h|
|
107
|
+
# Make the "current" symlink point back to the previously active deploy.
|
108
|
+
h.revert_current_symlink
|
109
|
+
|
110
|
+
h.remote_exec("touch #{h.current_symlink}/restart.txt")
|
64
111
|
end
|
65
112
|
end
|
113
|
+
|
114
|
+
# Define what phases to run and in what order on each host. Each
|
115
|
+
# phase will need to complete on every host before moving on to the
|
116
|
+
# next phase. If a phase fails on a host, all completed phases/hosts
|
117
|
+
# will have the "on_fail" block executed in reverse order.
|
118
|
+
h.phases = [ :upload, :extract, :link, :migrate, :restart, :cleanup ]
|
66
119
|
end
|
67
120
|
```
|
68
121
|
|
data/harrison.gemspec
CHANGED
@@ -26,6 +26,7 @@ Gem::Specification.new do |spec|
|
|
26
26
|
spec.add_development_dependency "bundler", "~> 1.6"
|
27
27
|
spec.add_development_dependency "rake"
|
28
28
|
spec.add_development_dependency "rspec", "~> 3.0"
|
29
|
+
spec.add_development_dependency "simplecov"
|
29
30
|
spec.add_development_dependency "debugger" if RUBY_VERSION < "2.0.0"
|
30
31
|
spec.add_development_dependency "byebug" if RUBY_VERSION >= "2.0.0"
|
31
32
|
spec.add_development_dependency "sourcify"
|
data/lib/harrison.rb
CHANGED
@@ -5,11 +5,16 @@ require "harrison/config"
|
|
5
5
|
require "harrison/base"
|
6
6
|
require "harrison/package"
|
7
7
|
require "harrison/deploy"
|
8
|
+
require "harrison/deploy/phase"
|
8
9
|
|
9
10
|
module Harrison
|
10
11
|
|
11
12
|
def self.invoke(args)
|
12
13
|
@@args = args.freeze
|
14
|
+
@@task_runners = {
|
15
|
+
package: nil,
|
16
|
+
deploy: nil,
|
17
|
+
}
|
13
18
|
|
14
19
|
abort("No command given. Run with --help for valid commands and options.") if @@args.empty?
|
15
20
|
|
@@ -17,12 +22,10 @@ module Harrison
|
|
17
22
|
Harrison::Base.new.parse(@@args.dup) and exit(0) if @@args[0] == '--help'
|
18
23
|
|
19
24
|
# Find Harrisonfile.
|
20
|
-
hf = find_harrisonfile
|
21
|
-
abort("ERROR: Could not find a Harrisonfile in this directory or any ancestor.") if hf.nil?
|
25
|
+
hf = find_harrisonfile || abort("ERROR: Could not find a Harrisonfile in this directory or any ancestor.")
|
22
26
|
|
23
27
|
# Find the class to handle command.
|
24
|
-
@@runner = find_runner(@@args[0])
|
25
|
-
abort("ERROR: Unrecognized command \"#{@@args[0]}\".") unless @@runner
|
28
|
+
@@runner = find_runner(@@args[0]) || abort("ERROR: Unrecognized command \"#{@@args[0]}\".")
|
26
29
|
|
27
30
|
# Eval the Harrisonfile.
|
28
31
|
eval_script(hf)
|
@@ -30,6 +33,8 @@ module Harrison
|
|
30
33
|
# Invoke command and cleanup afterwards.
|
31
34
|
begin
|
32
35
|
@@runner.call.run
|
36
|
+
rescue => e
|
37
|
+
raise e
|
33
38
|
ensure
|
34
39
|
@@runner.call.close
|
35
40
|
end
|
@@ -46,21 +51,21 @@ module Harrison
|
|
46
51
|
end
|
47
52
|
|
48
53
|
def self.package(opts={})
|
49
|
-
@@
|
54
|
+
@@task_runners[:package] ||= Harrison::Package.new(opts)
|
50
55
|
|
51
56
|
# Parse options if this is the target command.
|
52
|
-
@@
|
57
|
+
@@task_runners[:package].parse(@@args.dup) if @@runner && @@runner.call == @@task_runners[:package]
|
53
58
|
|
54
|
-
yield @@
|
59
|
+
yield @@task_runners[:package]
|
55
60
|
end
|
56
61
|
|
57
62
|
def self.deploy(opts={})
|
58
|
-
@@
|
63
|
+
@@task_runners[:deploy] ||= Harrison::Deploy.new(opts)
|
59
64
|
|
60
65
|
# Parse options if this is the target command.
|
61
|
-
@@
|
66
|
+
@@task_runners[:deploy].parse(@@args.dup) if @@runner && @@runner.call == @@task_runners[:deploy]
|
62
67
|
|
63
|
-
yield @@
|
68
|
+
yield @@task_runners[:deploy]
|
64
69
|
end
|
65
70
|
|
66
71
|
|
@@ -83,9 +88,8 @@ module Harrison
|
|
83
88
|
end
|
84
89
|
|
85
90
|
def self.find_runner(command)
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
end
|
91
|
+
command = 'deploy' if command == 'rollback'
|
92
|
+
|
93
|
+
lambda { @@task_runners[command.to_sym] } if @@task_runners.has_key?(command.to_sym)
|
90
94
|
end
|
91
95
|
end
|
data/lib/harrison/base.rb
CHANGED
@@ -34,14 +34,22 @@ module Harrison
|
|
34
34
|
|
35
35
|
def exec(cmd)
|
36
36
|
result = `#{cmd}`
|
37
|
-
|
38
|
-
result
|
37
|
+
|
38
|
+
if ($?.success? && result)
|
39
|
+
result.strip
|
40
|
+
else
|
41
|
+
throw :failure, true
|
42
|
+
end
|
39
43
|
end
|
40
44
|
|
41
45
|
def remote_exec(cmd)
|
42
46
|
result = ssh.exec(cmd)
|
43
|
-
|
44
|
-
result
|
47
|
+
|
48
|
+
if result
|
49
|
+
result.strip
|
50
|
+
else
|
51
|
+
throw :failure, true
|
52
|
+
end
|
45
53
|
end
|
46
54
|
|
47
55
|
def parse(args)
|
@@ -97,7 +105,7 @@ module Harrison
|
|
97
105
|
end
|
98
106
|
|
99
107
|
def ensure_remote_dir(dir, with_ssh = nil)
|
100
|
-
with_ssh
|
108
|
+
with_ssh ||= ssh
|
101
109
|
host = with_ssh.host
|
102
110
|
|
103
111
|
@_ensured_remote ||= {}
|
data/lib/harrison/deploy.rb
CHANGED
@@ -5,6 +5,11 @@ module Harrison
|
|
5
5
|
attr_accessor :release_dir
|
6
6
|
attr_accessor :deploy_link
|
7
7
|
|
8
|
+
attr_accessor :rollback
|
9
|
+
attr_accessor :phases
|
10
|
+
|
11
|
+
alias :invoke_user_block :run
|
12
|
+
|
8
13
|
def initialize(opts={})
|
9
14
|
# Config helpers for Harrisonfile.
|
10
15
|
self.class.option_helper(:hosts)
|
@@ -21,6 +26,8 @@ module Harrison
|
|
21
26
|
]
|
22
27
|
|
23
28
|
super(arg_opts, opts)
|
29
|
+
|
30
|
+
self.add_default_phases
|
24
31
|
end
|
25
32
|
|
26
33
|
def parse(args)
|
@@ -29,77 +36,120 @@ module Harrison
|
|
29
36
|
# Preserve argv hosts if it's been passed.
|
30
37
|
@_argv_hosts = self.hosts.dup if self.hosts
|
31
38
|
|
32
|
-
|
33
|
-
|
39
|
+
self.rollback = args[0] == 'rollback'
|
40
|
+
|
41
|
+
unless self.rollback
|
42
|
+
# Make sure they passed an artifact.
|
43
|
+
self.artifact = args[1] || abort("ERROR: You must specify the artifact to be deployed as an argument to this command.")
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def add_phase(name, &block)
|
48
|
+
@_phases ||= Hash.new
|
49
|
+
|
50
|
+
@_phases[name] = Harrison::Deploy::Phase.new(name, &block)
|
34
51
|
end
|
35
52
|
|
36
53
|
def remote_exec(cmd)
|
37
54
|
super("cd #{remote_project_dir} && #{cmd}")
|
38
55
|
end
|
39
56
|
|
40
|
-
def
|
41
|
-
|
57
|
+
def current_symlink
|
58
|
+
"#{self.remote_project_dir}/current"
|
59
|
+
end
|
60
|
+
|
61
|
+
def update_current_symlink
|
62
|
+
@_old_current = self.remote_exec("if [ -L #{current_symlink} ]; then readlink -vn #{current_symlink}; fi")
|
63
|
+
@_old_current = nil if @_old_current.empty?
|
64
|
+
|
65
|
+
# Symlink current to new deploy.
|
66
|
+
self.remote_exec("ln -sfn #{self.deploy_link} #{self.current_symlink}")
|
67
|
+
end
|
68
|
+
|
69
|
+
def revert_current_symlink
|
70
|
+
# Restore current symlink to previous if set.
|
71
|
+
if @_old_current
|
72
|
+
self.remote_exec("ln -sfn #{@_old_current} #{self.current_symlink}")
|
73
|
+
end
|
74
|
+
end
|
42
75
|
|
76
|
+
def run
|
43
77
|
# Override Harrisonfile hosts if it was passed on argv.
|
44
78
|
self.hosts = @_argv_hosts if @_argv_hosts
|
45
79
|
|
80
|
+
# Require at least one host.
|
46
81
|
if !self.hosts || self.hosts.empty?
|
47
|
-
abort("ERROR: You must specify one or more hosts to deploy
|
82
|
+
abort("ERROR: You must specify one or more hosts to deploy/rollback on, either in your Harrisonfile or via --hosts.")
|
48
83
|
end
|
49
84
|
|
85
|
+
# Default to just built in deployment phases.
|
86
|
+
self.phases ||= [ :upload, :extract, :link, :cleanup ]
|
87
|
+
|
50
88
|
# Default base_dir.
|
51
89
|
self.base_dir ||= '/opt'
|
52
90
|
|
53
|
-
|
91
|
+
if self.rollback
|
92
|
+
puts "Rolling back \"#{project}\" to previously deployed release on #{hosts.size} hosts...\n\n"
|
54
93
|
|
55
|
-
|
56
|
-
|
94
|
+
# Find the prior deploy on the first host.
|
95
|
+
self.host = hosts[0]
|
96
|
+
last_deploy = self.deploys.sort.reverse[1] || abort("ERROR: No previous deploy to rollback to.")
|
97
|
+
self.release_dir = remote_exec("cd deploys && readlink -vn #{last_deploy}")
|
98
|
+
|
99
|
+
# No need to upload or extract for rollback.
|
100
|
+
self.phases.delete(:upload)
|
101
|
+
self.phases.delete(:extract)
|
57
102
|
|
58
|
-
|
59
|
-
self.
|
103
|
+
# Don't cleanup old deploys either.
|
104
|
+
self.phases.delete(:cleanup)
|
105
|
+
else
|
106
|
+
puts "Deploying #{artifact} for \"#{project}\" onto #{hosts.size} hosts...\n\n"
|
107
|
+
self.release_dir = "#{remote_project_dir}/releases/" + File.basename(artifact, '.tar.gz')
|
108
|
+
end
|
60
109
|
|
61
|
-
|
62
|
-
ensure_remote_dir("#{remote_project_dir}/releases", self.ssh)
|
110
|
+
self.deploy_link = "#{remote_project_dir}/deploys/" + Time.new.utc.strftime('%Y-%m-%d_%H%M%S')
|
63
111
|
|
64
|
-
|
65
|
-
remote_exec("mkdir #{release_dir}")
|
112
|
+
progress_stack = []
|
66
113
|
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
src_user ||= self.user
|
114
|
+
failed = catch(:failure) do
|
115
|
+
self.phases.each do |phase_name|
|
116
|
+
phase = @_phases[phase_name] || abort("ERROR: Could not resolve \"#{phase_name}\" as a deployment phase.")
|
71
117
|
|
72
|
-
|
73
|
-
|
74
|
-
# Upload artifact to host.
|
75
|
-
upload(artifact, "#{remote_project_dir}/releases/")
|
76
|
-
end
|
118
|
+
self.hosts.each do |host|
|
119
|
+
self.host = host
|
77
120
|
|
78
|
-
|
79
|
-
remote_exec("cd #{release_dir} && tar -xzf ../#{File.basename(artifact)}")
|
121
|
+
phase._run(self)
|
80
122
|
|
81
|
-
|
82
|
-
|
123
|
+
# Track what phases we have completed on which hosts, in a stack.
|
124
|
+
progress_stack << { host: host, phase: phase_name }
|
125
|
+
end
|
126
|
+
end
|
83
127
|
|
84
|
-
#
|
85
|
-
|
128
|
+
# We want "failed" to be false if nothing was caught.
|
129
|
+
false
|
130
|
+
end
|
86
131
|
|
87
|
-
|
88
|
-
|
132
|
+
if failed
|
133
|
+
print "\n"
|
89
134
|
|
90
|
-
|
91
|
-
|
135
|
+
progress_stack.reverse.each do |progress|
|
136
|
+
self.host = progress[:host]
|
137
|
+
phase = @_phases[progress[:phase]]
|
92
138
|
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
139
|
+
# Don't let failures interrupt the rest of the process.
|
140
|
+
catch(:failure) do
|
141
|
+
phase._fail(self)
|
142
|
+
end
|
97
143
|
end
|
98
144
|
|
99
|
-
|
145
|
+
abort "\nDeployment failed, previously completed deployment actions have been reverted."
|
146
|
+
else
|
147
|
+
if self.rollback
|
148
|
+
puts "\nSucessfully rolled back #{project} on #{hosts.join(', ')}."
|
149
|
+
else
|
150
|
+
puts "\nSucessfully deployed #{artifact} to #{hosts.join(', ')}."
|
151
|
+
end
|
100
152
|
end
|
101
|
-
|
102
|
-
puts "Sucessfully deployed #{artifact} to #{hosts.join(', ')}."
|
103
153
|
end
|
104
154
|
|
105
155
|
def cleanup_deploys(limit)
|
@@ -107,7 +157,7 @@ module Harrison
|
|
107
157
|
purge_deploys = self.deploys.sort.reverse.slice(limit..-1) || []
|
108
158
|
|
109
159
|
if purge_deploys.size > 0
|
110
|
-
puts "Purging #{purge_deploys.size} old deploys
|
160
|
+
puts "[#{self.host}] Purging #{purge_deploys.size} old deploys. (Keeping #{limit}...)"
|
111
161
|
|
112
162
|
purge_deploys.each do |stale_deploy|
|
113
163
|
remote_exec("cd deploys && rm -f #{stale_deploy}")
|
@@ -138,6 +188,74 @@ module Harrison
|
|
138
188
|
|
139
189
|
protected
|
140
190
|
|
191
|
+
def add_default_phases
|
192
|
+
self.add_phase :upload do |phase|
|
193
|
+
phase.on_run do |h|
|
194
|
+
h.ensure_remote_dir("#{h.remote_project_dir}/deploys")
|
195
|
+
h.ensure_remote_dir("#{h.remote_project_dir}/releases")
|
196
|
+
|
197
|
+
# Remove if it already exists.
|
198
|
+
# TODO: if --force only?
|
199
|
+
h.remote_exec("rm -f #{h.remote_project_dir}/releases/#{File.basename(h.artifact)}")
|
200
|
+
|
201
|
+
if match = h.remote_regex.match(h.artifact)
|
202
|
+
# Copy artifact to host from remote source.
|
203
|
+
src_user, src_host, src_path = match.captures
|
204
|
+
src_user ||= h.user
|
205
|
+
|
206
|
+
h.remote_exec("scp #{src_user}@#{src_host}:#{src_path} #{h.remote_project_dir}/releases/")
|
207
|
+
else
|
208
|
+
# Upload artifact to host.
|
209
|
+
h.upload(h.artifact, "#{h.remote_project_dir}/releases/")
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
213
|
+
phase.on_fail do |h|
|
214
|
+
# Remove staged artifact.
|
215
|
+
h.remote_exec("rm -f #{h.remote_project_dir}/releases/#{File.basename(h.artifact)}")
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
self.add_phase :extract do |phase|
|
220
|
+
phase.on_run do |h|
|
221
|
+
# Make folder for release or bail if it already exists.
|
222
|
+
h.remote_exec("mkdir #{h.release_dir}")
|
223
|
+
|
224
|
+
# Unpack.
|
225
|
+
h.remote_exec("cd #{h.release_dir} && tar -xzf ../#{File.basename(h.artifact)}")
|
226
|
+
|
227
|
+
# Clean up artifact.
|
228
|
+
h.remote_exec("rm -f #{h.remote_project_dir}/releases/#{File.basename(h.artifact)}")
|
229
|
+
end
|
230
|
+
|
231
|
+
phase.on_fail do |h|
|
232
|
+
# Remove release.
|
233
|
+
h.remote_exec("rm -rf #{h.release_dir}")
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
self.add_phase :link do |phase|
|
238
|
+
phase.on_run do |h|
|
239
|
+
# Symlink new deploy to this release.
|
240
|
+
h.remote_exec("ln -s #{h.release_dir} #{h.deploy_link}")
|
241
|
+
end
|
242
|
+
|
243
|
+
phase.on_fail do |h|
|
244
|
+
# Remove broken deploy.
|
245
|
+
h.remote_exec("rm -f #{h.deploy_link}")
|
246
|
+
end
|
247
|
+
end
|
248
|
+
|
249
|
+
self.add_phase :cleanup do |phase|
|
250
|
+
phase.on_run do |h|
|
251
|
+
if (h.keep)
|
252
|
+
h.cleanup_deploys(h.keep)
|
253
|
+
h.cleanup_releases
|
254
|
+
end
|
255
|
+
end
|
256
|
+
end
|
257
|
+
end
|
258
|
+
|
141
259
|
def ssh
|
142
260
|
@_conns ||= {}
|
143
261
|
@_conns[self.host] ||= Harrison::SSH.new(host: self.host, user: @options[:user], proxy: self.deploy_via)
|
@@ -147,7 +265,7 @@ module Harrison
|
|
147
265
|
"#{base_dir}/#{project}"
|
148
266
|
end
|
149
267
|
|
150
|
-
# Return a
|
268
|
+
# Return a list of deploys, unsorted.
|
151
269
|
def deploys
|
152
270
|
remote_exec("cd deploys && ls -1").split("\n")
|
153
271
|
end
|