switchtower 0.10.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -3,6 +3,7 @@ require 'switchtower/command'
3
3
  require 'switchtower/transfer'
4
4
  require 'switchtower/gateway'
5
5
  require 'switchtower/ssh'
6
+ require 'switchtower/utils'
6
7
 
7
8
  module SwitchTower
8
9
 
@@ -29,12 +30,18 @@ module SwitchTower
29
30
  attr_accessor :connection_factory
30
31
  attr_accessor :command_factory
31
32
  attr_accessor :transfer_factory
33
+ attr_accessor :default_io_proc
32
34
  end
33
35
 
34
36
  self.connection_factory = DefaultConnectionFactory
35
37
  self.command_factory = Command
36
38
  self.transfer_factory = Transfer
37
39
 
40
+ self.default_io_proc = Proc.new do |ch, stream, out|
41
+ level = out == :error ? :important : :info
42
+ ch[:actor].logger.send(level, out, "#{stream} :: #{ch[:host]}")
43
+ end
44
+
38
45
  # The configuration instance associated with this actor.
39
46
  attr_reader :configuration
40
47
 
@@ -62,18 +69,22 @@ module SwitchTower
62
69
 
63
70
  # Represents the definition of a single task.
64
71
  class Task #:nodoc:
65
- attr_reader :name, :options
72
+ attr_reader :name, :actor, :options
66
73
 
67
- def initialize(name, options)
68
- @name, @options = name, options
74
+ def initialize(name, actor, options)
75
+ @name, @actor, @options = name, actor, options
69
76
  @servers = nil
70
77
  end
71
78
 
72
79
  # Returns the list of servers (_not_ connections to servers) that are
73
80
  # the target of this task.
74
- def servers(configuration)
81
+ def servers
75
82
  unless @servers
76
- 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
83
+ roles = [*(@options[:roles] || actor.configuration.roles.keys)].
84
+ map { |name|
85
+ actor.configuration.roles[name] or
86
+ raise ArgumentError, "task #{self.name.inspect} references non-existant role #{name.inspect}"
87
+ }.flatten
77
88
  only = @options[:only] || {}
78
89
 
79
90
  unless only.empty?
@@ -105,10 +116,10 @@ module SwitchTower
105
116
  # Define a new task for this actor. The block will be invoked when this
106
117
  # task is called.
107
118
  def define_task(name, options={}, &block)
108
- @tasks[name] = Task.new(name, options)
119
+ @tasks[name] = (options[:task_class] || Task).new(name, self, options)
109
120
  define_method(name) do
110
121
  send "before_#{name}" if respond_to? "before_#{name}"
111
- logger.trace "executing task #{name}"
122
+ logger.debug "executing task #{name}"
112
123
  begin
113
124
  push_task_call_frame name
114
125
  result = instance_eval(&block)
@@ -129,10 +140,7 @@ module SwitchTower
129
140
  #
130
141
  # If +pretend+ mode is active, this does nothing.
131
142
  def run(cmd, options={}, &block)
132
- block ||= Proc.new do |ch, stream, out|
133
- logger.debug(out, "#{stream} :: #{ch[:host]}")
134
- end
135
-
143
+ block ||= default_io_proc
136
144
  logger.debug "executing #{cmd.strip.inspect}"
137
145
 
138
146
  execute_on_servers(options) do |servers|
@@ -176,9 +184,7 @@ module SwitchTower
176
184
  # the sudo password (if required) is the same as the password for logging
177
185
  # in to the server.
178
186
  def sudo(command, options={}, &block)
179
- block ||= Proc.new do |ch, stream, out|
180
- logger.debug(out, "#{stream} :: #{ch[:host]}")
181
- end
187
+ block ||= default_io_proc
182
188
 
183
189
  # in order to prevent _each host_ from prompting when the password was
184
190
  # wrong, let's track which host prompted first and only allow subsequent
@@ -320,11 +326,29 @@ module SwitchTower
320
326
  task_call_frames.last.rollback = block
321
327
  end
322
328
 
323
- private
329
+ # An instance-level reader for the class' #default_io_proc attribute.
330
+ def default_io_proc
331
+ self.class.default_io_proc
332
+ end
324
333
 
325
- def metaclass
326
- class << self; self; end
327
- end
334
+ # Used to force connections to be made to the current task's servers.
335
+ # Connections are normally made lazily in SwitchTower--you can use this
336
+ # to force them open before performing some operation that might be
337
+ # time-sensitive.
338
+ def connect!(options={})
339
+ execute_on_servers(options) { }
340
+ end
341
+
342
+ def current_task
343
+ return nil if task_call_frames.empty?
344
+ tasks[task_call_frames.last.name]
345
+ end
346
+
347
+ def metaclass
348
+ class << self; self; end
349
+ end
350
+
351
+ private
328
352
 
329
353
  def define_method(name, &block)
330
354
  metaclass.send(:define_method, name, &block)
@@ -358,8 +382,14 @@ module SwitchTower
358
382
  end
359
383
 
360
384
  def execute_on_servers(options)
361
- servers = tasks[task_call_frames.last.name].servers(configuration)
362
- servers = servers.first if options[:once]
385
+ task = current_task
386
+ servers = task.servers
387
+
388
+ if servers.empty?
389
+ raise "The #{task.name} task is only run for servers matching #{task.options.inspect}, but no servers matched"
390
+ end
391
+
392
+ servers = [servers.first] if options[:once]
363
393
  logger.trace "servers: #{servers.inspect}"
364
394
 
365
395
  if !pretend
@@ -376,5 +406,6 @@ module SwitchTower
376
406
  super
377
407
  end
378
408
  end
409
+
379
410
  end
380
411
  end
@@ -86,7 +86,7 @@ module SwitchTower
86
86
  # require 'switchtower/cli'
87
87
  # config = SwitchTower::Configuration.new
88
88
  # config.logger_level = SwitchTower::Logger::TRACE
89
- # config.set :password, Proc.new { SwitchTower::CLI.password_prompt }
89
+ # config.set(:password) { SwitchTower::CLI.password_prompt }
90
90
  # config.load "standard", "config/deploy"
91
91
  # config.actor.update_code
92
92
  #
@@ -22,8 +22,6 @@ module SwitchTower
22
22
  # fails (non-zero return code) on any of the hosts, this will raise a
23
23
  # RuntimeError.
24
24
  def process!
25
- logger.debug "processing command"
26
-
27
25
  since = Time.now
28
26
  loop do
29
27
  active = 0
@@ -56,6 +54,7 @@ module SwitchTower
56
54
  @servers.map do |server|
57
55
  @actor.sessions[server].open_channel do |channel|
58
56
  channel[:host] = server
57
+ channel[:actor] = @actor # so callbacks can access the actor instance
59
58
  channel.request_pty :want_reply => true
60
59
 
61
60
  channel.on_success do |ch|
@@ -1,6 +1,7 @@
1
1
  require 'switchtower/actor'
2
2
  require 'switchtower/logger'
3
3
  require 'switchtower/scm/subversion'
4
+ require 'switchtower/extensions'
4
5
 
5
6
  module SwitchTower
6
7
  # Represents a specific SwitchTower configuration. A Configuration instance
@@ -29,6 +30,9 @@ module SwitchTower
29
30
  # determining the release path.
30
31
  attr_reader :now
31
32
 
33
+ # The has of variables currently known by the configuration
34
+ attr_reader :variables
35
+
32
36
  def initialize(actor_class=Actor) #:nodoc:
33
37
  @roles = Hash.new { |h,k| h[k] = [] }
34
38
  @actor = actor_class.new(self)
@@ -48,18 +52,28 @@ module SwitchTower
48
52
 
49
53
  set :ssh_options, Hash.new
50
54
 
51
- set :deploy_to, Proc.new { "/u/apps/#{application}" }
55
+ set(:deploy_to) { "/u/apps/#{application}" }
52
56
 
53
57
  set :version_dir, DEFAULT_VERSION_DIR_NAME
54
58
  set :current_dir, DEFAULT_CURRENT_DIR_NAME
55
59
  set :shared_dir, DEFAULT_SHARED_DIR_NAME
56
60
  set :scm, :subversion
57
61
 
58
- set :revision, Proc.new { source.latest_revision }
62
+ set(:revision) { source.latest_revision }
59
63
  end
60
64
 
61
65
  # Set a variable to the given value.
62
- def set(variable, value)
66
+ def set(variable, value=nil, &block)
67
+ # if the variable is uppercase, then we add it as a constant to the
68
+ # actor. This is to allow uppercase "variables" to be set and referenced
69
+ # in recipes.
70
+ if variable.to_s[0].between?(?A, ?Z)
71
+ klass = @actor.metaclass
72
+ klass.send(:remove_const, variable) if klass.const_defined?(variable)
73
+ klass.const_set(variable, value)
74
+ end
75
+
76
+ value = block if value.nil? && block_given?
63
77
  @variables[variable] = value
64
78
  end
65
79
 
@@ -103,11 +117,19 @@ module SwitchTower
103
117
  #
104
118
  # load(:string => "set :scm, :subversion"):
105
119
  # Load the given string as a configuration specification.
106
- def load(*args)
120
+ #
121
+ # load { ... }
122
+ # Load the block in the context of the configuration.
123
+ def load(*args, &block)
107
124
  options = args.last.is_a?(Hash) ? args.pop : {}
108
125
  args.each { |arg| load options.merge(:file => arg) }
126
+ return unless args.empty?
127
+
128
+ if block
129
+ raise "loading a block requires 0 parameters" unless args.empty?
130
+ load(options.merge(:proc => block))
109
131
 
110
- if options[:file]
132
+ elsif options[:file]
111
133
  file = options[:file]
112
134
  unless file[0] == ?/
113
135
  load_paths.each do |path|
@@ -122,9 +144,17 @@ module SwitchTower
122
144
  end
123
145
 
124
146
  load :string => File.read(file), :name => options[:name] || file
147
+
125
148
  elsif options[:string]
126
- logger.debug "loading configuration #{options[:name] || "<eval>"}"
127
- instance_eval options[:string], options[:name] || "<eval>"
149
+ logger.trace "loading configuration #{options[:name] || "<eval>"}"
150
+ instance_eval(options[:string], options[:name] || "<eval>")
151
+
152
+ elsif options[:proc]
153
+ logger.trace "loading configuration #{options[:proc].inspect}"
154
+ instance_eval(&options[:proc])
155
+
156
+ else
157
+ raise ArgumentError, "don't know how to load #{options.inspect}"
128
158
  end
129
159
  end
130
160
 
@@ -164,6 +194,18 @@ module SwitchTower
164
194
  actor.define_task(name, options, &block)
165
195
  end
166
196
 
197
+ # Require another file. This is identical to the standard require method,
198
+ # with the exception that it sets the reciever as the "current" configuration
199
+ # so that third-party task bundles can include themselves relative to
200
+ # that configuration.
201
+ def require(*args) #:nodoc:
202
+ original, SwitchTower.configuration = SwitchTower.configuration, self
203
+ super
204
+ ensure
205
+ # restore the original, so that require's can be nested
206
+ SwitchTower.configuration = original
207
+ end
208
+
167
209
  # Return the path into which releases should be deployed.
168
210
  def releases_path
169
211
  File.join(deploy_to, version_dir)
@@ -0,0 +1,38 @@
1
+ require 'switchtower/actor'
2
+
3
+ module SwitchTower
4
+ class ExtensionProxy
5
+ def initialize(actor, mod)
6
+ @actor = actor
7
+ extend(mod)
8
+ end
9
+
10
+ def method_missing(sym, *args, &block)
11
+ @actor.send(sym, *args, &block)
12
+ end
13
+ end
14
+
15
+ EXTENSIONS = {}
16
+
17
+ def self.plugin(name, mod)
18
+ return false if EXTENSIONS.has_key?(name)
19
+
20
+ SwitchTower::Actor.class_eval <<-STR, __FILE__, __LINE__+1
21
+ def #{name}
22
+ @__#{name}_proxy ||= SwitchTower::ExtensionProxy.new(self, SwitchTower::EXTENSIONS[#{name.inspect}])
23
+ end
24
+ STR
25
+
26
+ EXTENSIONS[name] = mod
27
+ return true
28
+ end
29
+
30
+ def self.remove_plugin(name)
31
+ if EXTENSIONS.delete(name)
32
+ SwitchTower::Actor.send(:remove_method, name)
33
+ return true
34
+ end
35
+
36
+ return false
37
+ end
38
+ end
@@ -25,11 +25,14 @@ module SwitchTower
25
25
  # The Net::SSH session representing the gateway connection.
26
26
  attr_reader :session
27
27
 
28
+ MAX_PORT = 65535
29
+ MIN_PORT = 1024
30
+
28
31
  def initialize(server, config) #:nodoc:
29
32
  @config = config
30
33
  @pending_forward_requests = {}
31
34
  @mutex = Mutex.new
32
- @next_port = 31310
35
+ @next_port = MAX_PORT
33
36
  @terminate_thread = false
34
37
 
35
38
  waiter = ConditionVariable.new
@@ -79,6 +82,13 @@ module SwitchTower
79
82
 
80
83
  private
81
84
 
85
+ def next_port
86
+ port = @next_port
87
+ @next_port -= 1
88
+ @next_port = MAX_PORT if @next_port < MIN_PORT
89
+ port
90
+ end
91
+
82
92
  def process_next_pending_connection_request
83
93
  @mutex.synchronize do
84
94
  key = @pending_forward_requests.keys.detect { |k| ConditionVariable === @pending_forward_requests[k] } or return
@@ -86,14 +96,16 @@ module SwitchTower
86
96
 
87
97
  @config.logger.trace "establishing connection to #{key} via gateway"
88
98
 
89
- port = @next_port
90
- @next_port += 1
99
+ port = next_port
91
100
 
92
101
  begin
93
102
  @session.forward.local(port, key, 22)
94
103
  @pending_forward_requests[key] = SSH.connect('127.0.0.1', @config,
95
104
  port)
96
105
  @config.logger.trace "connection to #{key} via gateway established"
106
+ rescue Errno::EADDRINUSE
107
+ port = next_port
108
+ retry
97
109
  rescue Object
98
110
  @pending_forward_requests[key] = nil
99
111
  raise
@@ -10,7 +10,11 @@ def switchtower_invoke(*actions)
10
10
  # no rubygems to load, so we fail silently
11
11
  end
12
12
 
13
- require 'switchtower/cli'
13
+ options = actions.last.is_a?(Hash) ? actions.pop : {}
14
+
15
+ args = %w[-r config/deploy]
16
+ verbose = options[:verbose] || "-vvvvv"
17
+ args << verbose
14
18
 
15
19
  args = %w[-vvvvv -r config/<%= recipe_file %>]
16
20
  args.concat(actions.map { |act| ["-a", act.to_s] }.flatten)
@@ -34,7 +38,7 @@ end
34
38
 
35
39
  desc "Enumerate all available deployment tasks"
36
40
  task :show_deploy_tasks do
37
- switchtower_invoke :show_tasks
41
+ switchtower_invoke :show_tasks, :verbose => ""
38
42
  end
39
43
 
40
44
  desc "Execute a specific action using switchtower"
@@ -6,6 +6,8 @@ module SwitchTower
6
6
  INFO = 1
7
7
  DEBUG = 2
8
8
  TRACE = 3
9
+
10
+ MAX_LEVEL = 3
9
11
 
10
12
  def initialize(options={})
11
13
  output = options[:output] || STDERR
@@ -27,12 +29,13 @@ module SwitchTower
27
29
 
28
30
  def log(level, message, line_prefix=nil)
29
31
  if level <= self.level
30
- if line_prefix
31
- message.split(/\r?\n/).each do |line|
32
- @device.print "[#{line_prefix}] #{line.strip}\n"
32
+ indent = "%*s" % [MAX_LEVEL, "*" * (MAX_LEVEL - level)]
33
+ message.split(/\r?\n/).each do |line|
34
+ if line_prefix
35
+ @device.print "#{indent} [#{line_prefix}] #{line.strip}\n"
36
+ else
37
+ @device.puts "#{indent} #{line.strip}\n"
33
38
  end
34
- else
35
- @device.puts message.strip
36
39
  end
37
40
  end
38
41
  end
@@ -11,10 +11,15 @@
11
11
 
12
12
  set :rake, "rake"
13
13
 
14
+ set :rails_env, :production
15
+
14
16
  set :migrate_target, :current
15
17
  set :migrate_env, ""
16
18
 
17
- set :restart_via, :sudo
19
+ set :use_sudo, true
20
+ set(:run_method) { use_sudo ? :sudo : :run }
21
+
22
+ set :spinner_user, :app
18
23
 
19
24
  desc "Enumerate and describe every available task."
20
25
  task :show_tasks do
@@ -98,12 +103,12 @@ task :symlink, :roles => [:app, :db, :web] do
98
103
  end
99
104
 
100
105
  desc <<-DESC
101
- Restart the FCGI processes on the app server. This uses the :restart_via
102
- variable to determine whether to use sudo or not. By default, :restart_via is
103
- set to :sudo, but you can set it to :run if you are in a shared environment.
106
+ Restart the FCGI processes on the app server. This uses the :use_sudo
107
+ variable to determine whether to use sudo or not. By default, :use_sudo is
108
+ set to true, but you can set it to false if you are in a shared environment.
104
109
  DESC
105
110
  task :restart, :roles => :app do
106
- send(restart_via, "#{current_path}/script/process/reaper")
111
+ send(run_method, "#{current_path}/script/process/reaper")
107
112
  end
108
113
 
109
114
  desc <<-DESC
@@ -126,7 +131,7 @@ task :migrate, :roles => :db, :only => { :primary => true } do
126
131
  end
127
132
 
128
133
  run "cd #{directory} && " +
129
- "#{rake} RAILS_ENV=production #{migrate_env} migrate"
134
+ "#{rake} RAILS_ENV=#{rails_env} #{migrate_env} migrate"
130
135
  end
131
136
 
132
137
  desc <<-DESC
@@ -189,7 +194,8 @@ end
189
194
  desc <<-DESC
190
195
  Removes unused releases from the releases directory. By default, the last 5
191
196
  releases are retained, but this can be configured with the 'keep_releases'
192
- variable.
197
+ variable. This will use sudo to do the delete by default, but you can specify
198
+ that run should be used by setting the :use_sudo variable to false.
193
199
  DESC
194
200
  task :cleanup do
195
201
  count = (self[:keep_releases] || 5).to_i
@@ -197,9 +203,42 @@ task :cleanup do
197
203
  logger.important "no old releases to clean up"
198
204
  else
199
205
  logger.info "keeping #{count} of #{releases.length} deployed releases"
200
- keepers = (releases - releases.last(count)).map { |release|
206
+ directories = (releases - releases.last(count)).map { |release|
201
207
  File.join(releases_path, release) }.join(" ")
202
208
 
203
- sudo "rm -rf #{keepers}"
209
+ send(run_method, "rm -rf #{directories}")
204
210
  end
205
- end
211
+ end
212
+
213
+ desc <<-DESC
214
+ Start the spinner daemon for the application (requires script/spin). This will
215
+ use sudo to start the spinner by default, unless :use_sudo is false. If using
216
+ sudo, you can specify the user that the spinner ought to run as by setting the
217
+ :spinner_user variable (defaults to :app).
218
+ DESC
219
+ task :spinner, :roles => :app do
220
+ user = (use_sudo && spinner_user) ? "-u #{spinner_user} " : ""
221
+ send(run_method, "#{user}#{current_path}/script/spin")
222
+ end
223
+
224
+ desc <<-DESC
225
+ Used only for deploying when the spinner isn't running. It invokes deploy,
226
+ and when it finishes it then invokes the spinner task (to start the spinner).
227
+ DESC
228
+ task :cold_deploy do
229
+ deploy
230
+ spinner
231
+ end
232
+
233
+ desc <<-DESC
234
+ A simple task for performing one-off commands that may not require a full task
235
+ to be written for them. Simply specify the command to execute via the COMMAND
236
+ environment variable. To execute the command only on certain roles, specify
237
+ the ROLES environment variable as a comma-delimited list of role names. Lastly,
238
+ if you want to execute the command via sudo, specify a non-empty value for the
239
+ SUDO environment variable.
240
+ DESC
241
+ task :invoke, :roles => SwitchTower.str2roles(ENV["ROLES"] || "") do
242
+ method = ENV["SUDO"] ? :sudo : :run
243
+ send(method, ENV["COMMAND"])
244
+ end