switchtower 0.9.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/bin/switchtower ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ begin
4
+ require 'rubygems'
5
+ rescue LoadError
6
+ # no rubygems to load, so we fail silently
7
+ end
8
+
9
+ require 'switchtower/cli'
10
+
11
+ SwitchTower::CLI.execute!
@@ -0,0 +1,113 @@
1
+ # You must always specify the application and repository for every recipe. The
2
+ # repository must be the URL of the repository you want this recipe to
3
+ # correspond to. The deploy_to path must be the path on each machine that will
4
+ # form the root of the application path.
5
+
6
+ set :application, "sample"
7
+ set :repository, "http://svn.example.com/#{application}/trunk"
8
+
9
+ # The deploy_to path is optional, defaulting to "/u/apps/#{application}".
10
+
11
+ set :deploy_to, "/path/to/app/root"
12
+
13
+ # The user value is optional, defaulting to user-name of the current user. This
14
+ # is the user name that will be used when logging into the deployment boxes.
15
+
16
+ set :user, "flippy"
17
+
18
+ # By default, the source control module (scm) is set to "subversion". You can
19
+ # set it to any supported scm:
20
+
21
+ set :scm, :subversion
22
+
23
+ # gateway is optional, but allows you to specify the address of a computer that
24
+ # will be used to tunnel other requests through, such as when your machines are
25
+ # all behind a VPN or something
26
+
27
+ set :gateway, "gateway.example.com"
28
+
29
+ # You can define any number of roles, each of which contains any number of
30
+ # machines. Roles might include such things as :web, or :app, or :db, defining
31
+ # what the purpose of each machine is. You can also specify options that can
32
+ # be used to single out a specific subset of boxes in a particular role, like
33
+ # :primary => true.
34
+
35
+ role :web, "www01.example.com", "www02.example.com"
36
+ role :app, "app01.example.com", "app02.example.com", "app03.example.com"
37
+ role :db, "db01.example.com", :primary => true
38
+ role :db, "db02.example.com", "db03.example.com"
39
+
40
+ # Define tasks that run on all (or only some) of the machines. You can specify
41
+ # a role (or set of roles) that each task should be executed on. You can also
42
+ # narrow the set of servers to a subset of a role by specifying options, which
43
+ # must match the options given for the servers to select (like :primary => true)
44
+
45
+ desc <<DESC
46
+ An imaginary backup task. (Execute the 'show_tasks' task to display all
47
+ available tasks.)
48
+ DESC
49
+
50
+ task :backup, :roles => :db, :only => { :primary => true } do
51
+ # the on_rollback handler is only executed if this task is executed within
52
+ # a transaction (see below), AND it or a subsequent task fails.
53
+ on_rollback { delete "/tmp/dump.sql" }
54
+
55
+ run "mysqldump -u theuser -p thedatabase > /tmp/dump.sql" do |ch, stream, out|
56
+ ch.send_data "thepassword\n" if out =~ /^Enter password:/
57
+ end
58
+ end
59
+
60
+ # Tasks may take advantage of several different helper methods to interact
61
+ # with the remote server(s). These are:
62
+ #
63
+ # * run(command, options={}, &block): execute the given command on all servers
64
+ # associated with the current task, in parallel. The block, if given, should
65
+ # accept three parameters: the communication channel, a symbol identifying the
66
+ # type of stream (:err or :out), and the data. The block is invoked for all
67
+ # output from the command, allowing you to inspect output and act
68
+ # accordingly.
69
+ # * sudo(command, options={}, &block): same as run, but it executes the command
70
+ # via sudo.
71
+ # * delete(path, options={}): deletes the given file or directory from all
72
+ # associated servers. If :recursive => true is given in the options, the
73
+ # delete uses "rm -rf" instead of "rm -f".
74
+ # * put(buffer, path, options={}): creates or overwrites a file at "path" on
75
+ # all associated servers, populating it with the contents of "buffer". You
76
+ # can specify :mode as an integer value, which will be used to set the mode
77
+ # on the file.
78
+ # * render(template, options={}) or render(options={}): renders the given
79
+ # template and returns a string. Alternatively, if the :template key is given,
80
+ # it will be treated as the contents of the template to render. Any other keys
81
+ # are treated as local variables, which are made available to the (ERb)
82
+ # template.
83
+
84
+ desc "Demonstrates the various helper methods available to recipes."
85
+ task :helper_demo do
86
+ # "setup" is a standard task which sets up the directory structure on the
87
+ # remote servers. It is a good idea to run the "setup" task at least once
88
+ # at the beginning of your app's lifetime (it is non-destructive).
89
+ setup
90
+
91
+ buffer = render("maintenance.rhtml", :deadline => ENV['UNTIL'])
92
+ put buffer, "#{shared_path}/system/maintenance.html", :mode => 0644
93
+ sudo "killall -USR1 dispatch.fcgi"
94
+ run "#{release_path}/script/spin"
95
+ delete "#{shared_path}/system/maintenance.html"
96
+ end
97
+
98
+ # You can use "transaction" to indicate that if any of the tasks within it fail,
99
+ # all should be rolled back (for each task that specifies an on_rollback
100
+ # handler).
101
+
102
+ desc "A task demonstrating the use of transactions."
103
+ task :long_deploy do
104
+ transaction do
105
+ update_code
106
+ disable_web
107
+ symlink
108
+ migrate
109
+ end
110
+
111
+ restart
112
+ enable_web
113
+ end
@@ -0,0 +1 @@
1
+ require 'switchtower/configuration'
@@ -0,0 +1,350 @@
1
+ require 'erb'
2
+ require 'switchtower/command'
3
+ require 'switchtower/gateway'
4
+ require 'switchtower/ssh'
5
+
6
+ module SwitchTower
7
+
8
+ # An Actor is the entity that actually does the work of determining which
9
+ # servers should be the target of a particular task, and of executing the
10
+ # task on each of them in parallel. An Actor is never instantiated
11
+ # directly--rather, you create a new Configuration instance, and access the
12
+ # new actor via Configuration#actor.
13
+ class Actor
14
+
15
+ # An adaptor for making the SSH interface look and act like that of the
16
+ # Gateway class.
17
+ class DefaultConnectionFactory #:nodoc:
18
+ def initialize(config)
19
+ @config= config
20
+ end
21
+
22
+ def connect_to(server)
23
+ SSH.connect(server, @config)
24
+ end
25
+ end
26
+
27
+ class <<self
28
+ attr_accessor :connection_factory
29
+ attr_accessor :command_factory
30
+ end
31
+
32
+ self.connection_factory = DefaultConnectionFactory
33
+ self.command_factory = Command
34
+
35
+ # The configuration instance associated with this actor.
36
+ attr_reader :configuration
37
+
38
+ # A hash of the tasks known to this actor, keyed by name. The values are
39
+ # instances of Actor::Task.
40
+ attr_reader :tasks
41
+
42
+ # A hash of the SSH sessions that are currently open and available.
43
+ # Because sessions are constructed lazily, this will only contain
44
+ # connections to those servers that have been the targets of one or more
45
+ # executed tasks.
46
+ attr_reader :sessions
47
+
48
+ # The call stack of the tasks. The currently executing task may inspect
49
+ # this to see who its caller was. The current task is always the last
50
+ # element of this stack.
51
+ attr_reader :task_call_frames
52
+
53
+ # The history of executed tasks. This will be an array of all tasks that
54
+ # have been executed, in the order in which they were called.
55
+ attr_reader :task_call_history
56
+
57
+ # A struct for representing a single instance of an invoked task.
58
+ TaskCallFrame = Struct.new(:name, :rollback)
59
+
60
+ # Represents the definition of a single task.
61
+ class Task #:nodoc:
62
+ attr_reader :name, :options
63
+
64
+ def initialize(name, options)
65
+ @name, @options = name, options
66
+ end
67
+
68
+ # Returns the list of servers (_not_ connections to servers) that are
69
+ # the target of this task.
70
+ def servers(configuration)
71
+ unless @servers
72
+ roles = [*(@options[:roles] || configuration.roles.keys)].map { |name| configuration.roles[name] or raise ArgumentError, "task #{self.name.inspect} references non-existant role #{name.inspect}" }.flatten
73
+ only = @options[:only] || {}
74
+
75
+ unless only.empty?
76
+ roles = roles.delete_if do |role|
77
+ catch(:done) do
78
+ only.keys.each do |key|
79
+ throw(:done, true) if role.options[key] != only[key]
80
+ end
81
+ false
82
+ end
83
+ end
84
+ end
85
+
86
+ @servers = roles.map { |role| role.host }.uniq
87
+ end
88
+
89
+ @servers
90
+ end
91
+ end
92
+
93
+ def initialize(config) #:nodoc:
94
+ @configuration = config
95
+ @tasks = {}
96
+ @task_call_frames = []
97
+ @sessions = {}
98
+ @factory = self.class.connection_factory.new(configuration)
99
+ end
100
+
101
+ # Define a new task for this actor. The block will be invoked when this
102
+ # task is called.
103
+ def define_task(name, options={}, &block)
104
+ @tasks[name] = Task.new(name, options)
105
+ define_method(name) do
106
+ send "before_#{name}" if respond_to? "before_#{name}"
107
+ logger.trace "executing task #{name}"
108
+ begin
109
+ push_task_call_frame name
110
+ result = instance_eval &block
111
+ ensure
112
+ pop_task_call_frame
113
+ end
114
+ send "after_#{name}" if respond_to? "after_#{name}"
115
+ result
116
+ end
117
+ end
118
+
119
+ # Execute the given command on all servers that are the target of the
120
+ # current task. If a block is given, it is invoked for all output
121
+ # generated by the command, and should accept three parameters: the SSH
122
+ # channel (which may be used to send data back to the remote process),
123
+ # the stream identifier (<tt>:err</tt> for stderr, and <tt>:out</tt> for
124
+ # stdout), and the data that was received.
125
+ #
126
+ # If +pretend+ mode is active, this does nothing.
127
+ def run(cmd, options={}, &block)
128
+ block ||= Proc.new do |ch, stream, out|
129
+ logger.debug(out, "#{stream} :: #{ch[:host]}")
130
+ end
131
+
132
+ logger.debug "executing #{cmd.strip.inspect}"
133
+
134
+ # get the currently executing task and determine which servers it uses
135
+ servers = tasks[task_call_frames.last.name].servers(configuration)
136
+ servers = servers.first if options[:once]
137
+ logger.trace "servers: #{servers.inspect}"
138
+
139
+ if !pretend
140
+ # establish connections to those servers, as necessary
141
+ establish_connections(servers)
142
+
143
+ # execute the command on each server in parallel
144
+ command = self.class.command_factory.new(servers, cmd, block, options, self)
145
+ command.process! # raises an exception if command fails on any server
146
+ end
147
+ end
148
+
149
+ # Deletes the given file from all servers targetted by the current task.
150
+ # If <tt>:recursive => true</tt> is specified, it may be used to remove
151
+ # directories.
152
+ def delete(path, options={})
153
+ cmd = "rm -%sf #{path}" % (options[:recursive] ? "r" : "")
154
+ run(cmd, options)
155
+ end
156
+
157
+ # Store the given data at the given location on all servers targetted by
158
+ # the current task. If <tt>:mode</tt> is specified it is used to set the
159
+ # mode on the file.
160
+ def put(data, path, options={})
161
+ # Poor-man's SFTP... just run a cat on the remote end, and send data
162
+ # to it.
163
+
164
+ cmd = "cat > #{path}"
165
+ cmd << " && chmod #{options[:mode].to_s(8)} #{path}" if options[:mode]
166
+ run(cmd, options.merge(:data => data + "\n\4")) do |ch, stream, out|
167
+ logger.important out, "#{stream} :: #{ch[:host]}" if out == :err
168
+ end
169
+ end
170
+
171
+ # Like #run, but executes the command via <tt>sudo</tt>. This assumes that
172
+ # the sudo password (if required) is the same as the password for logging
173
+ # in to the server.
174
+ def sudo(command, options={}, &block)
175
+ block ||= Proc.new do |ch, stream, out|
176
+ logger.debug(out, "#{stream} :: #{ch[:host]}")
177
+ end
178
+
179
+ run "sudo #{command}", options do |ch, stream, out|
180
+ if out =~ /^Password:/
181
+ ch.send_data "#{password}\n"
182
+ else
183
+ block.call(ch, stream, out)
184
+ end
185
+ end
186
+ end
187
+
188
+ # Renders an ERb template and returns the result. This is useful for
189
+ # dynamically building documents to store on the remote servers.
190
+ #
191
+ # Usage:
192
+ #
193
+ # render("something", :foo => "hello")
194
+ # look for "something.rhtml" in the current directory, or in the
195
+ # switchtower/recipes/templates directory, and render it with
196
+ # foo defined as a local variable with the value "hello".
197
+ #
198
+ # render(:file => "something", :foo => "hello")
199
+ # same as above
200
+ #
201
+ # render(:template => "<%= foo %> world", :foo => "hello")
202
+ # treat the given string as an ERb template and render it with
203
+ # the given hash of local variables active.
204
+ def render(*args)
205
+ options = args.last.is_a?(Hash) ? args.pop : {}
206
+ options[:file] = args.shift if args.first.is_a?(String)
207
+ raise ArgumentError, "too many parameters" unless args.empty?
208
+
209
+ case
210
+ when options[:file]
211
+ file = options.delete :file
212
+ unless file[0] == ?/
213
+ dirs = [".",
214
+ File.join(File.dirname(__FILE__), "recipes", "templates")]
215
+ dirs.each do |dir|
216
+ if File.file?(File.join(dir, file))
217
+ file = File.join(dir, file)
218
+ break
219
+ elsif File.file?(File.join(dir, file + ".rhtml"))
220
+ file = File.join(dir, file + ".rhtml")
221
+ break
222
+ end
223
+ end
224
+ end
225
+
226
+ render options.merge(:template => File.read(file))
227
+
228
+ when options[:template]
229
+ erb = ERB.new(options[:template])
230
+ b = Proc.new { binding }.call
231
+ options.each do |key, value|
232
+ next if key == :template
233
+ eval "#{key} = options[:#{key}]", b
234
+ end
235
+ erb.result(b)
236
+
237
+ else
238
+ raise ArgumentError, "no file or template given for rendering"
239
+ end
240
+ end
241
+
242
+ # Inspects the remote servers to determine the list of all released versions
243
+ # of the software. Releases are sorted with the most recent release last.
244
+ def releases
245
+ unless @releases
246
+ buffer = ""
247
+ run "ls -x1 #{releases_path}", :once => true do |ch, str, out|
248
+ buffer << out if str == :out
249
+ raise "could not determine releases #{out.inspect}" if str == :err
250
+ end
251
+ @releases = buffer.split.sort
252
+ end
253
+
254
+ @releases
255
+ end
256
+
257
+ # Returns the most recent deployed release
258
+ def current_release
259
+ release_path(releases.last)
260
+ end
261
+
262
+ # Returns the release immediately before the currently deployed one
263
+ def previous_release
264
+ release_path(releases[-2])
265
+ end
266
+
267
+ # Invoke a set of tasks in a transaction. If any task fails (raises an
268
+ # exception), all tasks executed within the transaction are inspected to
269
+ # see if they have an associated on_rollback hook, and if so, that hook
270
+ # is called.
271
+ def transaction
272
+ if task_call_history
273
+ yield
274
+ else
275
+ logger.info "transaction: start"
276
+ begin
277
+ @task_call_history = []
278
+ yield
279
+ logger.info "transaction: commit"
280
+ rescue Object => e
281
+ current = task_call_history.last
282
+ logger.important "transaction: rollback", current ? current.name : "transaction start"
283
+ task_call_history.reverse.each do |task|
284
+ begin
285
+ logger.debug "rolling back", task.name
286
+ task.rollback.call if task.rollback
287
+ rescue Object => e
288
+ logger.info "exception while rolling back: #{e.class}, #{e.message}", task.name
289
+ end
290
+ end
291
+ raise
292
+ ensure
293
+ @task_call_history = nil
294
+ end
295
+ end
296
+ end
297
+
298
+ # Specifies an on_rollback hook for the currently executing task. If this
299
+ # or any subsequent task then fails, and a transaction is active, this
300
+ # hook will be executed.
301
+ def on_rollback(&block)
302
+ task_call_frames.last.rollback = block
303
+ end
304
+
305
+ private
306
+
307
+ def metaclass
308
+ class << self; self; end
309
+ end
310
+
311
+ def define_method(name, &block)
312
+ metaclass.send(:define_method, name, &block)
313
+ end
314
+
315
+ def push_task_call_frame(name)
316
+ frame = TaskCallFrame.new(name)
317
+ task_call_frames.push frame
318
+ task_call_history.push frame if task_call_history
319
+ end
320
+
321
+ def pop_task_call_frame
322
+ task_call_frames.pop
323
+ end
324
+
325
+ def establish_connections(servers)
326
+ @factory = establish_gateway if needs_gateway?
327
+ servers.each do |server|
328
+ @sessions[server] ||= @factory.connect_to(server)
329
+ end
330
+ end
331
+
332
+ def establish_gateway
333
+ logger.debug "establishing connection to gateway #{gateway}"
334
+ @established_gateway = true
335
+ Gateway.new(gateway, configuration)
336
+ end
337
+
338
+ def needs_gateway?
339
+ gateway && !@established_gateway
340
+ end
341
+
342
+ def method_missing(sym, *args, &block)
343
+ if @configuration.respond_to?(sym)
344
+ @configuration.send(sym, *args, &block)
345
+ else
346
+ super
347
+ end
348
+ end
349
+ end
350
+ end