harrison 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
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
- # Run block will be invoked once for each host after new code is in place.
57
- h.run do |h|
58
- # You can interrogate h.host to see what host you are currently running on.
59
- if h.host =~ /util/
60
- # Do something on the util box.
61
- else
62
- puts "Reloading Unicorn on #{h.host}..."
63
- h.remote_exec("sudo -- /etc/init.d/unicorn_#{h.project} reload")
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
- @@packager ||= Harrison::Package.new(opts)
54
+ @@task_runners[:package] ||= Harrison::Package.new(opts)
50
55
 
51
56
  # Parse options if this is the target command.
52
- @@packager.parse(@@args.dup) if @@runner && @@runner.call == @@packager
57
+ @@task_runners[:package].parse(@@args.dup) if @@runner && @@runner.call == @@task_runners[:package]
53
58
 
54
- yield @@packager
59
+ yield @@task_runners[:package]
55
60
  end
56
61
 
57
62
  def self.deploy(opts={})
58
- @@deployer ||= Harrison::Deploy.new(opts)
63
+ @@task_runners[:deploy] ||= Harrison::Deploy.new(opts)
59
64
 
60
65
  # Parse options if this is the target command.
61
- @@deployer.parse(@@args.dup) if @@runner && @@runner.call == @@deployer
66
+ @@task_runners[:deploy].parse(@@args.dup) if @@runner && @@runner.call == @@task_runners[:deploy]
62
67
 
63
- yield @@deployer
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
- case command.downcase
87
- when 'package' then lambda { @@packager if self.class_variable_defined?(:@@packager) }
88
- when 'deploy' then lambda { @@deployer if self.class_variable_defined?(:@@deployer) }
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
- abort("ERROR: Unable to execute local command: \"#{cmd}\"") if !$?.success? || result.nil?
38
- result.strip
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
- abort("ERROR: Unable to execute remote command: \"#{cmd}\"") if result.nil?
44
- result.strip
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 = ssh if with_ssh.nil?
108
+ with_ssh ||= ssh
101
109
  host = with_ssh.host
102
110
 
103
111
  @_ensured_remote ||= {}
@@ -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
- # Make sure they passed an artifact.
33
- self.artifact = args[1] || abort("ERROR: You must specify the artifact to be deployed as an argument to this command.")
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 run(&block)
41
- return super if block_given?
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 to, either in your Harrisonfile or via --hosts.")
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
- puts "Deploying #{artifact} for \"#{project}\" onto #{hosts.size} hosts..."
91
+ if self.rollback
92
+ puts "Rolling back \"#{project}\" to previously deployed release on #{hosts.size} hosts...\n\n"
54
93
 
55
- self.release_dir = "#{remote_project_dir}/releases/" + File.basename(artifact, '.tar.gz')
56
- self.deploy_link = "#{remote_project_dir}/deploys/" + Time.new.utc.strftime('%Y-%m-%d_%H%M%S')
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
- hosts.each do |h|
59
- self.host = h
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
- ensure_remote_dir("#{remote_project_dir}/deploys", self.ssh)
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
- # Make folder for release or bail if it already exists.
65
- remote_exec("mkdir #{release_dir}")
112
+ progress_stack = []
66
113
 
67
- if match = remote_regex.match(artifact)
68
- # Copy artifact to host from remote source.
69
- src_user, src_host, src_path = match.captures
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
- remote_exec("scp #{src_user}@#{src_host}:#{src_path} #{remote_project_dir}/releases/")
73
- else
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
- # Unpack.
79
- remote_exec("cd #{release_dir} && tar -xzf ../#{File.basename(artifact)}")
121
+ phase._run(self)
80
122
 
81
- # Clean up artifact.
82
- remote_exec("rm -f #{remote_project_dir}/releases/#{File.basename(artifact)}")
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
- # Symlink a new deploy to this release.
85
- remote_exec("ln -s #{release_dir} #{deploy_link}")
128
+ # We want "failed" to be false if nothing was caught.
129
+ false
130
+ end
86
131
 
87
- # Symlink current to new deploy.
88
- remote_exec("ln -sfn #{deploy_link} #{remote_project_dir}/current")
132
+ if failed
133
+ print "\n"
89
134
 
90
- # Run user supplied deploy code to restart server or whatever.
91
- super
135
+ progress_stack.reverse.each do |progress|
136
+ self.host = progress[:host]
137
+ phase = @_phases[progress[:phase]]
92
138
 
93
- # Cleanup old releases if a keep value is set.
94
- if (self.keep)
95
- cleanup_deploys(self.keep)
96
- cleanup_releases
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
- close(self.host)
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 on #{self.host}, keeping #{limit}..."
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 sorted list of deploys, unsorted.
268
+ # Return a list of deploys, unsorted.
151
269
  def deploys
152
270
  remote_exec("cd deploys && ls -1").split("\n")
153
271
  end