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 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