vlad 1.0.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/lib/vlad.rb ADDED
@@ -0,0 +1,76 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+ require 'thread'
4
+ require 'rake_remote_task'
5
+
6
+ # Declare a remote host and its roles.
7
+ # Equivalent to <tt>role</tt>, but shorter for multiple roles.
8
+ def host host_name, *roles
9
+ Rake::RemoteTask.host host_name, *roles
10
+ end
11
+
12
+ # Declare a Vlad task that will execute on all hosts by default.
13
+ # To limit that task to specific roles, use:
14
+ # remote_task :example, :roles => [:app, :web] do
15
+ def remote_task name, options = {}, &b
16
+ Rake::RemoteTask.remote_task name, options, &b
17
+ end
18
+
19
+ # Declare a role and assign a remote host to it.
20
+ # Equivalent to the <tt>host</tt> method; provided for capistrano compatibility.
21
+ def role role_name, host, args = {}
22
+ Rake::RemoteTask.role role_name, host, args
23
+ end
24
+
25
+ # Execute the given command on the <tt>target_host</tt> for the current task.
26
+ def run *args, &b
27
+ Thread.current[:task].run(*args, &b)
28
+ end
29
+
30
+ # rsync the given files to <tt>target_host</tt>.
31
+ def rsync local, remote
32
+ Thread.current[:task].rsync local, remote
33
+ end
34
+
35
+ # Declare a variable called +name+ and assign it a value.
36
+ # A globally-visible method with the name of the variable is defined.
37
+ # If a block is given, it will be called when the variable is first accessed.
38
+ # Subsequent references to the variable will always return the same value.
39
+ # Raises <tt>ArgumentError</tt> if the +name+ would conflict with an existing method.
40
+ def set name, val = nil, &b
41
+ Rake::RemoteTask.set name, val, &b
42
+ end
43
+
44
+ # Returns the name of the host that the current task is executing on.
45
+ # <tt>target_host</tt> can uniquely identify a particular task/host combination.
46
+ def target_host
47
+ Thread.current[:task].target_host
48
+ end
49
+
50
+ module Vlad
51
+
52
+ # This is the version of Vlad you are running.
53
+ VERSION = '1.0.0'
54
+
55
+ # Base error class for all Vlad errors.
56
+ class Error < RuntimeError; end
57
+
58
+ # Raised when you have incorrectly configured Vlad.
59
+ class ConfigurationError < Error; end
60
+
61
+ # Raised when a remote command fails.
62
+ class CommandFailedError < Error; end
63
+
64
+ # Raised when an environment variable hasn't been set.
65
+ class FetchError < Error; end
66
+
67
+ # Loads tasks file +tasks_file+ and the vlad_tasks file.
68
+ def self.load tasks_file = 'config/deploy.rb'
69
+ Kernel.load tasks_file
70
+ require 'vlad_tasks'
71
+ end
72
+
73
+ end
74
+
75
+ Rake::RemoteTask.reset
76
+
@@ -0,0 +1,51 @@
1
+ class Vlad::Perforce
2
+
3
+ def self.reset
4
+ set :p4cmd, "p4" unless respond_to? :p4cmd
5
+ set :p4config, ".p4config" unless respond_to? :p4config
6
+ end
7
+
8
+ reset
9
+
10
+ ##
11
+ # Returns the p4 command that will checkout +revision+ into the directory
12
+ # +destination+.
13
+
14
+ def checkout(revision, destination)
15
+ "#{p4cmd} sync ...#{rev_no(revision)}"
16
+ end
17
+
18
+ ##
19
+ # Returns the p4 command that will export +revision+ into the directory
20
+ # +directory+.
21
+
22
+ def export(revision_or_source, destination)
23
+ if revision_or_source =~ /^(\d+|head)$/i then
24
+ "(cd #{destination} && #{p4cmd} sync ...#{rev_no(revision_or_source)})"
25
+ else
26
+ "cp -r #{revision_or_source} #{destination}"
27
+ end
28
+ end
29
+
30
+ ##
31
+ # Returns a command that maps human-friendly revision identifier +revision+
32
+ # into a Perforce revision specification.
33
+
34
+ def revision(revision)
35
+ "`#{p4cmd} changes -s submitted -m 1 ...#{rev_no(revision)} | cut -f 2 -d\\ `"
36
+ end
37
+
38
+ ##
39
+ # Maps revision +revision+ into a Perforce revision.
40
+
41
+ def rev_no(revision)
42
+ case revision.to_s
43
+ when /head/i then
44
+ "#head"
45
+ when /^\d+$/ then
46
+ "@#{revision}"
47
+ else
48
+ revision
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,30 @@
1
+ class Vlad::Subversion
2
+
3
+ ##
4
+ # Returns the command that will check out +revision+ from the repository
5
+ # into directory +destination+
6
+
7
+ def checkout(revision, destination)
8
+ "svn co -r #{revision} #{repository} #{destination}"
9
+ end
10
+
11
+ ##
12
+ # Returns the command that will export +revision+ from the repository into
13
+ # the directory +destination+.
14
+
15
+ def export(revision_or_source, destination)
16
+ if revision_or_source =~ /^(\d+|head)$/i then
17
+ "svn export -r #{revision_or_source} #{repository} #{destination}"
18
+ else
19
+ "svn export #{revision_or_source} #{destination}"
20
+ end
21
+ end
22
+
23
+ ##
24
+ # Returns a command that maps human-friendly revision identifier +revision+
25
+ # into a subversion revision specification.
26
+
27
+ def revision(revision)
28
+ "`svn info #{repository} | grep 'Revision:' | cut -f2 -d\\ `"
29
+ end
30
+ end
data/lib/vlad_tasks.rb ADDED
@@ -0,0 +1,282 @@
1
+ require 'vlad'
2
+
3
+ class String #:nodoc:
4
+ def cleanup
5
+ if ENV['FULL'] then
6
+ gsub(/\s+/, ' ').strip
7
+ else
8
+ self[/\A.*?\./]
9
+ end
10
+ end
11
+ end
12
+
13
+ ##
14
+ # Ideal scenarios:
15
+ #
16
+ # Initial:
17
+ #
18
+ # 1) rake vlad:setup
19
+ # 2) rake vlad:update
20
+ # 3) rake vlad:migrate
21
+ # 4) rake vlad:start
22
+ #
23
+ # Subsequent:
24
+ #
25
+ # 1) rake vlad:update
26
+ # 2) rake vlad:migrate (optional)
27
+ # 3) rake vlad:start
28
+
29
+ namespace :vlad do
30
+ desc "Show the vlad setup. This is all the default variables for vlad
31
+ tasks.".cleanup
32
+
33
+ task :debug do
34
+ require 'yaml'
35
+ puts "# Environment:"
36
+ y Rake::RemoteTask.env
37
+ puts "# Roles:"
38
+ y Rake::RemoteTask.roles
39
+ end
40
+
41
+ # used by update, out here so we can ensure all threads have the same value
42
+ now = Time.now.utc.strftime("%Y%m%d%H%M.%S")
43
+
44
+ desc "Setup your servers. Before you can use any of the deployment
45
+ tasks with your project, you will need to make sure all of your
46
+ servers have been prepared with 'rake vlad:setup'. It is safe to
47
+ run this task on servers that have already been set up; it will
48
+ not destroy any deployed revisions or data.".cleanup
49
+
50
+ task :setup do
51
+ Rake::Task['vlad:setup_app'].invoke
52
+ end
53
+
54
+ desc "Updates your application server to the latest revision. Syncs a copy
55
+ of the repository, exports it as the latest release, fixes up your
56
+ symlinks, touches your assets, symlinks the latest revision to current and
57
+ logs the update.".cleanup
58
+
59
+ remote_task :update, :roles => :app do
60
+ symlink = false
61
+ begin
62
+ # TODO: head/version should be parameterized
63
+ run [ "cd #{scm_path}",
64
+ "#{source.checkout "head", '.'}",
65
+ "#{source.export ".", release_path}",
66
+ "chmod -R g+w #{latest_release}",
67
+ "rm -rf #{latest_release}/log #{latest_release}/public/system #{latest_release}/tmp/pids",
68
+ "mkdir -p #{latest_release}/db #{latest_release}/tmp",
69
+ "ln -s #{shared_path}/log #{latest_release}/log",
70
+ "ln -s #{shared_path}/system #{latest_release}/public/system",
71
+ "ln -s #{shared_path}/pids #{latest_release}/tmp/pids",
72
+ ].join(" && ")
73
+
74
+ asset_paths = %w(images stylesheets javascripts).map { |p| "#{latest_release}/public/#{p}" }.join(" ")
75
+ run "find #{asset_paths} -exec touch -t #{now} {} ';'; true"
76
+
77
+ symlink = true
78
+ run "rm -f #{current_path} && ln -s #{latest_release} #{current_path}"
79
+ # Rake::Task["vlad:migrate"].invoke
80
+
81
+ run "echo #{now} $USER #{'head'} #{File.basename release_path} >> #{deploy_to}/revisions.log" # FIX shouldn't be head
82
+ rescue => e
83
+ run "rm -f #{current_path} && ln -s #{previous_release} #{current_path}" if
84
+ symlink
85
+ run "rm -rf #{release_path}"
86
+ raise e
87
+ end
88
+ end
89
+
90
+ desc "Run the migrate rake task for the the app. By default this is run in
91
+ the latest app directory. You can run migrations for the current app
92
+ directory by setting :migrate_target to :current. Additional environment
93
+ variables can be passed to rake via the migrate_env variable.".cleanup
94
+
95
+ # HACK :only => { :primary => true }
96
+ # No application files are on the DB machine, also migrations should only be
97
+ # run once.
98
+ remote_task :migrate, :roles => :app do
99
+ break unless target_host == Rake::RemoteTask.hosts_for(:app).first
100
+
101
+ directory = case migrate_target.to_sym
102
+ when :current then current_path
103
+ when :latest then current_release
104
+ else raise ArgumentError, "unknown migration target #{migrate_target.inspect}"
105
+ end
106
+
107
+ run "cd #{directory}; #{rake} RAILS_ENV=#{rails_env} #{migrate_args} db:migrate"
108
+ end
109
+
110
+ desc "Invoke a single command on every remote server. This is useful for
111
+ performing one-off commands that may not require a full task to be written
112
+ for them. Simply specify the command to execute via the COMMAND
113
+ environment variable. To execute the command only on certain roles,
114
+ specify the ROLES environment variable as a comma-delimited list of role
115
+ names.
116
+
117
+ $ rake vlad:invoke COMMAND='uptime'".cleanup
118
+
119
+ remote_task :invoke do
120
+ command = ENV["COMMAND"]
121
+ abort "Please specify a command to execute on the remote servers (via the COMMAND environment variable)" unless command
122
+ puts run(command)
123
+ end
124
+
125
+ desc "Copy files to the currently deployed version. This is useful for
126
+ updating files piecemeal when you need to quickly deploy only a single
127
+ file.
128
+
129
+ To use this task, specify the files and directories you want to copy as a
130
+ comma-delimited list in the FILES environment variable. All directories
131
+ will be processed recursively, with all files being pushed to the
132
+ deployment servers. Any file or directory starting with a '.' character
133
+ will be ignored.
134
+
135
+ $ rake vlad:upload FILES=templates,controller.rb".cleanup
136
+
137
+ remote_task :upload do
138
+ file_list = (ENV["FILES"] || "").split(",")
139
+
140
+ files = file_list.map do |f|
141
+ f = f.strip
142
+ File.directory?(f) ? Dir["#{f}/**/*"] : f
143
+ end.flatten
144
+
145
+ files = files.reject { |f| File.directory?(f) || File.basename(f)[0] == ?. }
146
+
147
+ abort "Please specify at least one file to update (via the FILES environment variable)" if files.empty?
148
+
149
+ files.each do |file|
150
+ rsync file, File.join(current_path, file)
151
+ end
152
+ end
153
+
154
+ desc "Rolls back to a previous version and restarts. This is handy if you
155
+ ever discover that you've deployed a lemon; 'rake vlad:rollback' and
156
+ you're right back where you were, on the previously deployed
157
+ version.".cleanup
158
+
159
+ remote_task :rollback do
160
+ if releases.length < 2 then
161
+ abort "could not rollback the code because there is no prior release"
162
+ else
163
+ run "rm #{current_path}; ln -s #{previous_release} #{current_path} && rm -rf #{current_release}"
164
+ end
165
+
166
+ Rake::Task['vlad:start'].invoke
167
+ end
168
+
169
+ desc "Clean up old releases. By default, the last 5 releases are kept on
170
+ each server (though you can change this with the keep_releases variable).
171
+ All other deployed revisions are removed from the servers.".cleanup
172
+
173
+ remote_task :cleanup do
174
+ count = keep_releases
175
+ if count >= releases.length then
176
+ puts "no old releases to clean up"
177
+ else
178
+ puts "keeping #{count} of #{releases.length} deployed releases"
179
+
180
+ directories = (releases - releases.last(count)).map { |release|
181
+ File.join(releases_path, release)
182
+ }.join(" ")
183
+
184
+ run "rm -rf #{directories}"
185
+ end
186
+ end
187
+
188
+ ##
189
+ # Mongrel app server
190
+
191
+ set :mongrel_address, "127.0.0.1"
192
+ set :mongrel_clean, false
193
+ set :mongrel_command, 'mongrel_rails'
194
+ set :mongrel_conf, "#{shared_path}/mongrel_cluster.conf"
195
+ set :mongrel_config_script, nil
196
+ set :mongrel_environment, "production"
197
+ set :mongrel_group, nil
198
+ set :mongrel_log_file, nil
199
+ set :mongrel_pid_file, nil
200
+ set :mongrel_port, 8000
201
+ set :mongrel_prefix, nil
202
+ set :mongrel_servers, 2
203
+ set :mongrel_user, nil
204
+
205
+ desc "Prepares application servers for deployment. Mongrel configuration is set via the mongrel_*
206
+ variables.".cleanup
207
+
208
+ remote_task :setup_app, :roles => :app do
209
+ dirs = [deploy_to, releases_path, scm_path, shared_path]
210
+ dirs += %w(system log pids).map { |d| File.join(shared_path, d) }
211
+ run "umask 02 && mkdir -p #{dirs.join(' ')}"
212
+
213
+ cmd = [
214
+ "#{mongrel_command} cluster::configure",
215
+ "-N #{mongrel_servers}",
216
+ "-p #{mongrel_port}",
217
+ "-e #{mongrel_environment}",
218
+ "-a #{mongrel_address}",
219
+ "-c #{current_path}",
220
+ "-C #{mongrel_conf}",
221
+ ("-P #{mongrel_pid_file}" if mongrel_pid_file),
222
+ ("-l #{mongrel_log_file}" if mongrel_log_file),
223
+ ("--user #{mongrel_user}" if mongrel_user),
224
+ ("--group #{mongrel_group}" if mongrel_group),
225
+ ("--prefix #{mongrel_prefix}" if mongrel_prefix),
226
+ ("-S #{mongrel_config_script}" if mongrel_config_script),
227
+ ].compact.join ' '
228
+
229
+ run cmd
230
+ end
231
+
232
+ desc "Restart the app servers"
233
+
234
+ remote_task :start_app, :roles => :app do
235
+ cmd = "#{mongrel_command} cluster::restart -C #{mongrel_conf}"
236
+ cmd << ' --clean' if mongrel_clean
237
+ run cmd
238
+ end
239
+
240
+ desc "Stop the app servers"
241
+
242
+ remote_task :stop_app, :roles => :app do
243
+ cmd = "#{mongrel_command} cluster::stop -C #{mongrel_conf}"
244
+ cmd << ' --clean' if mongrel_clean
245
+ run cmd
246
+ end
247
+
248
+ ##
249
+ # Apache web server
250
+
251
+ set :web_command, "apachectl"
252
+
253
+ desc "Restart the web servers"
254
+
255
+ remote_task :start_web, :roles => :web do
256
+ run "#{web_command} restart"
257
+ end
258
+
259
+ desc "Stop the web servers"
260
+
261
+ remote_task :stop_web, :roles => :web do
262
+ run "#{web_command} stop"
263
+ end
264
+
265
+ ##
266
+ # Everything HTTP.
267
+
268
+ desc "Restart the web and app servers"
269
+
270
+ remote_task :start do
271
+ Rake::Task['vlad:start_app'].invoke
272
+ Rake::Task['vlad:start_web'].invoke
273
+ end
274
+
275
+ desc "Stop the web and app servers"
276
+
277
+ remote_task :stop do
278
+ Rake::Task['vlad:stop_app'].invoke
279
+ Rake::Task['vlad:stop_web'].invoke
280
+ end
281
+
282
+ end # namespace vlad
@@ -0,0 +1,181 @@
1
+ require 'test/vlad_test_case'
2
+ require 'vlad'
3
+
4
+ class TestRakeRemoteTask < VladTestCase
5
+ def test_enhance
6
+ util_set_hosts
7
+ body = Proc.new { 5 }
8
+ task = @vlad.remote_task(:some_task => :foo, &body)
9
+ action = Rake::RemoteTask::Action.new(task, body)
10
+ assert_equal [action], task.remote_actions
11
+ assert_equal task, action.task
12
+ assert_equal ["foo"], task.prerequisites
13
+ end
14
+
15
+ def test_enhance_with_no_task_body
16
+ util_set_hosts
17
+ util_setup_task
18
+ assert_equal [], @task.remote_actions
19
+ assert_equal [], @task.prerequisites
20
+ end
21
+
22
+ def test_execute
23
+ util_set_hosts
24
+ set :some_variable, 1
25
+ x = 5
26
+ task = @vlad.remote_task(:some_task) { x += some_variable }
27
+ task.execute
28
+ assert_equal 1, task.some_variable
29
+ assert_equal 2, task.remote_actions.first.workers.size
30
+ assert_equal 7, x
31
+ end
32
+
33
+ def test_execute_exposes_target_host
34
+ host "app.example.com", :app
35
+ task = remote_task(:target_task) { set(:test_target_host, target_host) }
36
+ task.execute
37
+ assert_equal "app.example.com", Rake::RemoteTask.fetch(:test_target_host)
38
+ end
39
+
40
+ def test_execute_with_no_hosts
41
+ @vlad.host "app.example.com", :app
42
+ t = @vlad.remote_task(:flunk, :roles => :db) { flunk "should not have run" }
43
+ e = assert_raise(Vlad::ConfigurationError) { t.execute }
44
+ assert_equal "No target hosts specified for task: flunk", e.message
45
+ end
46
+
47
+ def test_execute_with_no_roles
48
+ t = @vlad.remote_task(:flunk, :roles => :junk) { flunk "should not have run" }
49
+ e = assert_raise(Vlad::ConfigurationError) { t.execute }
50
+ assert_equal "No target hosts specified for task: flunk", e.message
51
+ end
52
+
53
+ def test_execute_with_roles
54
+ util_set_hosts
55
+ set :some_variable, 1
56
+ x = 5
57
+ task = @vlad.remote_task(:some_task, :roles => :db) { x += some_variable }
58
+ task.execute
59
+ assert_equal 1, task.some_variable
60
+ assert_equal 6, x
61
+ end
62
+
63
+ def test_rsync
64
+ util_setup_task
65
+ @task.target_host = "app.example.com"
66
+
67
+ @task.rsync 'localfile', 'remotefile'
68
+
69
+ commands = @task.commands
70
+
71
+ assert_equal 1, commands.size, 'not enough commands'
72
+ assert_equal %w[rsync -azP --delete localfile app.example.com:remotefile],
73
+ commands.first, 'rsync'
74
+ end
75
+
76
+ def test_rsync_fail
77
+ util_setup_task
78
+ @task.target_host = "app.example.com"
79
+ @task.action = lambda { false }
80
+
81
+ e = assert_raise(Vlad::CommandFailedError) { @task.rsync 'local', 'remote' }
82
+ assert_equal "execution failed: rsync -azP --delete local app.example.com:remote", e.message
83
+ end
84
+
85
+ def test_run
86
+ util_setup_task
87
+ @task.output << "file1\nfile2\n"
88
+ @task.target_host = "app.example.com"
89
+ result = nil
90
+
91
+ out, err = util_capture do
92
+ result = @task.run("ls")
93
+ end
94
+
95
+ commands = @task.commands
96
+
97
+ assert_equal 1, commands.size, 'not enough commands'
98
+ assert_equal ["ssh", "app.example.com", "ls"],
99
+ commands.first, 'app'
100
+ assert_equal "file1\nfile2\n", result
101
+
102
+ assert_equal "file1\nfile2\n", out.read
103
+ assert_equal '', err.read
104
+ end
105
+
106
+ def test_run_failing_command
107
+ util_set_hosts
108
+ util_setup_task
109
+ @task.input = StringIO.new "file1\nfile2\n"
110
+ @task.target_host = 'app.example.com'
111
+ @task.action = lambda { 1 }
112
+
113
+ e = assert_raise(Vlad::CommandFailedError) { @task.run("ls") }
114
+ assert_equal "execution failed with status 1: ssh app.example.com ls", e.message
115
+
116
+ assert_equal 1, @task.commands.size
117
+ end
118
+
119
+ def test_run_sudo
120
+ util_setup_task
121
+ @task.output << "file1\nfile2\n"
122
+ @task.error << 'Password:'
123
+ @task.target_host = "app.example.com"
124
+ def @task.sudo_password() "my password" end # gets defined by set
125
+ result = nil
126
+
127
+ out, err = util_capture do
128
+ result = @task.run("sudo ls")
129
+ end
130
+
131
+ commands = @task.commands
132
+
133
+ assert_equal 1, commands.size, 'not enough commands'
134
+ assert_equal ['ssh', 'app.example.com', 'sudo ls'],
135
+ commands.first
136
+
137
+ assert_equal "my password\n", @task.input.string
138
+ assert_equal "Password:\nfile1\nfile2\n", result
139
+
140
+ assert_equal "file1\nfile2\n", out.read
141
+ assert_equal "Password:\n", err.read
142
+ end
143
+
144
+ def test_sudo
145
+ util_setup_task
146
+ @task.target_host = "app.example.com"
147
+ @task.sudo "ls"
148
+
149
+ commands = @task.commands
150
+
151
+ assert_equal 1, commands.size, 'wrong number of commands'
152
+ assert_equal ["ssh", "app.example.com", "sudo ls"],
153
+ commands.first, 'app'
154
+ end
155
+
156
+ def util_capture
157
+ require 'stringio'
158
+ orig_stdout = $stdout.dup
159
+ orig_stderr = $stderr.dup
160
+ captured_stdout = StringIO.new
161
+ captured_stderr = StringIO.new
162
+ $stdout = captured_stdout
163
+ $stderr = captured_stderr
164
+ yield
165
+ captured_stdout.rewind
166
+ captured_stderr.rewind
167
+ return captured_stdout, captured_stderr
168
+ ensure
169
+ $stdout = orig_stdout
170
+ $stderr = orig_stderr
171
+ end
172
+
173
+ def util_setup_task(options = {})
174
+ @task = @vlad.remote_task :test_task, options
175
+ @task.commands = []
176
+ @task.output = []
177
+ @task.error = []
178
+ @task.action = nil
179
+ @task
180
+ end
181
+ end