switchtower 0.9.0
Sign up to get free protection for your applications and to get access to all the features.
- data/bin/switchtower +11 -0
- data/examples/sample.rb +113 -0
- data/lib/switchtower.rb +1 -0
- data/lib/switchtower/actor.rb +350 -0
- data/lib/switchtower/cli.rb +220 -0
- data/lib/switchtower/command.rb +85 -0
- data/lib/switchtower/configuration.rb +193 -0
- data/lib/switchtower/gateway.rb +106 -0
- data/lib/switchtower/generators/rails/deployment/deployment_generator.rb +25 -0
- data/lib/switchtower/generators/rails/deployment/templates/deploy.rb +116 -0
- data/lib/switchtower/generators/rails/deployment/templates/switchtower.rake +33 -0
- data/lib/switchtower/generators/rails/loader.rb +20 -0
- data/lib/switchtower/logger.rb +56 -0
- data/lib/switchtower/recipes/standard.rb +175 -0
- data/lib/switchtower/recipes/templates/maintenance.rhtml +53 -0
- data/lib/switchtower/scm/base.rb +43 -0
- data/lib/switchtower/scm/cvs.rb +73 -0
- data/lib/switchtower/scm/darcs.rb +27 -0
- data/lib/switchtower/scm/subversion.rb +104 -0
- data/lib/switchtower/ssh.rb +30 -0
- data/lib/switchtower/version.rb +9 -0
- data/test/actor_test.rb +261 -0
- data/test/command_test.rb +43 -0
- data/test/configuration_test.rb +210 -0
- data/test/fixtures/config.rb +5 -0
- data/test/scm/cvs_test.rb +164 -0
- data/test/scm/subversion_test.rb +100 -0
- data/test/ssh_test.rb +104 -0
- data/test/utils.rb +41 -0
- metadata +88 -0
@@ -0,0 +1,220 @@
|
|
1
|
+
require 'optparse'
|
2
|
+
require 'switchtower'
|
3
|
+
|
4
|
+
module SwitchTower
|
5
|
+
class CLI
|
6
|
+
def self.execute!
|
7
|
+
new.execute!
|
8
|
+
end
|
9
|
+
|
10
|
+
begin
|
11
|
+
if !defined?(USE_TERMIOS) || USE_TERMIOS
|
12
|
+
require 'termios'
|
13
|
+
else
|
14
|
+
raise LoadError
|
15
|
+
end
|
16
|
+
|
17
|
+
# Enable or disable stdin echoing to the terminal.
|
18
|
+
def echo(enable)
|
19
|
+
term = Termios::getattr(STDIN)
|
20
|
+
|
21
|
+
if enable
|
22
|
+
term.c_lflag |= (Termios::ECHO | Termios::ICANON)
|
23
|
+
else
|
24
|
+
term.c_lflag &= ~Termios::ECHO
|
25
|
+
end
|
26
|
+
|
27
|
+
Termios::setattr(STDIN, Termios::TCSANOW, term)
|
28
|
+
end
|
29
|
+
rescue LoadError
|
30
|
+
def echo(enable)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
attr_reader :options
|
35
|
+
attr_reader :args
|
36
|
+
|
37
|
+
def initialize(args = ARGV)
|
38
|
+
@args = args
|
39
|
+
@options = { :verbose => 0, :recipes => [], :actions => [], :vars => {},
|
40
|
+
:pre_vars => {} }
|
41
|
+
|
42
|
+
OptionParser.new do |opts|
|
43
|
+
opts.banner = "Usage: #{$0} [options] [args]"
|
44
|
+
|
45
|
+
opts.separator ""
|
46
|
+
opts.separator "Recipe Options -----------------------"
|
47
|
+
opts.separator ""
|
48
|
+
|
49
|
+
opts.on("-a", "--action ACTION",
|
50
|
+
"An action to execute. Multiple actions may",
|
51
|
+
"be specified, and are loaded in the given order."
|
52
|
+
) { |value| @options[:actions] << value }
|
53
|
+
|
54
|
+
opts.on("-p", "--password [PASSWORD]",
|
55
|
+
"The password to use when connecting. If the switch",
|
56
|
+
"is given without a password, the password will be",
|
57
|
+
"prompted for immediately. (Default: prompt for password",
|
58
|
+
"the first time it is needed.)"
|
59
|
+
) { |value| @options[:password] = value }
|
60
|
+
|
61
|
+
opts.on("-r", "--recipe RECIPE",
|
62
|
+
"A recipe file to load. Multiple recipes may",
|
63
|
+
"be specified, and are loaded in the given order."
|
64
|
+
) { |value| @options[:recipes] << value }
|
65
|
+
|
66
|
+
opts.on("-s", "--set NAME=VALUE",
|
67
|
+
"Specify a variable and it's value to set. This",
|
68
|
+
"will be set after loading all recipe files."
|
69
|
+
) do |pair|
|
70
|
+
name, value = pair.split(/=/, 2)
|
71
|
+
@options[:vars][name.to_sym] = value
|
72
|
+
end
|
73
|
+
|
74
|
+
opts.on("-S", "--set-before NAME=VALUE",
|
75
|
+
"Specify a variable and it's value to set. This",
|
76
|
+
"will be set BEFORE loading all recipe files."
|
77
|
+
) do |pair|
|
78
|
+
name, value = pair.split(/=/, 2)
|
79
|
+
@options[:pre_vars][name.to_sym] = value
|
80
|
+
end
|
81
|
+
|
82
|
+
opts.separator ""
|
83
|
+
opts.separator "Framework Integration Options --------"
|
84
|
+
opts.separator ""
|
85
|
+
|
86
|
+
opts.on("-A", "--apply-to DIRECTORY",
|
87
|
+
"Create a minimal set of scripts and recipes to use",
|
88
|
+
"switchtower with the application at the given",
|
89
|
+
"directory. (Currently only works with Rails apps.)"
|
90
|
+
) { |value| @options[:apply_to] = value }
|
91
|
+
|
92
|
+
opts.separator ""
|
93
|
+
opts.separator "Miscellaneous Options ----------------"
|
94
|
+
opts.separator ""
|
95
|
+
|
96
|
+
opts.on("-h", "--help", "Display this help message") do
|
97
|
+
puts opts
|
98
|
+
exit
|
99
|
+
end
|
100
|
+
|
101
|
+
opts.on("-P", "--[no-]pretend",
|
102
|
+
"Run the task(s), but don't actually connect to or",
|
103
|
+
"execute anything on the servers. (For various reasons",
|
104
|
+
"this will not necessarily be an accurate depiction",
|
105
|
+
"of the work that will actually be performed.",
|
106
|
+
"Default: don't pretend.)"
|
107
|
+
) { |value| @options[:pretend] = value }
|
108
|
+
|
109
|
+
opts.on("-v", "--verbose",
|
110
|
+
"Specify the verbosity of the output.",
|
111
|
+
"May be given multiple times. (Default: silent)"
|
112
|
+
) { @options[:verbose] += 1 }
|
113
|
+
|
114
|
+
opts.on("-V", "--version",
|
115
|
+
"Display the version info for this utility"
|
116
|
+
) do
|
117
|
+
require 'switchtower/version'
|
118
|
+
puts "SwitchTower v#{SwitchTower::Version::STRING}"
|
119
|
+
exit
|
120
|
+
end
|
121
|
+
|
122
|
+
opts.separator ""
|
123
|
+
opts.separator <<DETAIL.split(/\n/)
|
124
|
+
You can use the --apply-to switch to generate a minimal set of switchtower
|
125
|
+
scripts and recipes for an application. Just specify the path to the application
|
126
|
+
as the argument to --apply-to, like this:
|
127
|
+
|
128
|
+
switchtower --apply-to ~/projects/myapp
|
129
|
+
|
130
|
+
You'll wind up with a sample deployment recipe in config/deploy.rb, some new
|
131
|
+
rake tasks in config/tasks, and a switchtower script in your script directory.
|
132
|
+
|
133
|
+
(Currently, --apply-to only works with Rails applications.)
|
134
|
+
DETAIL
|
135
|
+
|
136
|
+
if args.empty?
|
137
|
+
puts opts
|
138
|
+
exit
|
139
|
+
else
|
140
|
+
opts.parse!(args)
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
check_options!
|
145
|
+
|
146
|
+
password_proc = Proc.new do
|
147
|
+
sync = STDOUT.sync
|
148
|
+
begin
|
149
|
+
echo false
|
150
|
+
STDOUT.sync = true
|
151
|
+
print "Password: "
|
152
|
+
STDIN.gets.chomp
|
153
|
+
ensure
|
154
|
+
echo true
|
155
|
+
STDOUT.sync = sync
|
156
|
+
puts
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
if !@options.has_key?(:password)
|
161
|
+
@options[:password] = password_proc
|
162
|
+
elsif !@options[:password]
|
163
|
+
@options[:password] = password_proc.call
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
def execute!
|
168
|
+
if !@options[:recipes].empty?
|
169
|
+
execute_recipes!
|
170
|
+
elsif @options[:apply_to]
|
171
|
+
execute_apply_to!
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
private
|
176
|
+
|
177
|
+
def execute_recipes!
|
178
|
+
config = SwitchTower::Configuration.new
|
179
|
+
config.logger.level = options[:verbose]
|
180
|
+
config.set :password, options[:password]
|
181
|
+
config.set :pretend, options[:pretend]
|
182
|
+
|
183
|
+
options[:pre_vars].each { |name, value| config.set(name, value) }
|
184
|
+
|
185
|
+
# load the standard recipe definition
|
186
|
+
config.load "standard"
|
187
|
+
|
188
|
+
options[:recipes].each { |recipe| config.load(recipe) }
|
189
|
+
options[:vars].each { |name, value| config.set(name, value) }
|
190
|
+
|
191
|
+
actor = config.actor
|
192
|
+
options[:actions].each { |action| actor.send action }
|
193
|
+
end
|
194
|
+
|
195
|
+
def execute_apply_to!
|
196
|
+
require 'switchtower/generators/rails/loader'
|
197
|
+
Generators::RailsLoader.load! @options
|
198
|
+
end
|
199
|
+
|
200
|
+
APPLY_TO_OPTIONS = [:apply_to]
|
201
|
+
RECIPE_OPTIONS = [:password]
|
202
|
+
|
203
|
+
def check_options!
|
204
|
+
apply_to_given = !(@options.keys & APPLY_TO_OPTIONS).empty?
|
205
|
+
recipe_given = !(@options.keys & RECIPE_OPTIONS).empty? ||
|
206
|
+
!@options[:recipes].empty? ||
|
207
|
+
!@options[:actions].empty?
|
208
|
+
|
209
|
+
if apply_to_given && recipe_given
|
210
|
+
abort "You cannot specify both recipe options and framework integration options."
|
211
|
+
elsif !apply_to_given
|
212
|
+
abort "You must specify at least one recipe" if @options[:recipes].empty?
|
213
|
+
abort "You must specify at least one action" if @options[:actions].empty?
|
214
|
+
else
|
215
|
+
@options[:application] = args.shift
|
216
|
+
@options[:recipe_file] = args.shift
|
217
|
+
end
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
module SwitchTower
|
2
|
+
|
3
|
+
# This class encapsulates a single command to be executed on a set of remote
|
4
|
+
# machines, in parallel.
|
5
|
+
class Command
|
6
|
+
attr_reader :servers, :command, :options, :actor
|
7
|
+
|
8
|
+
def initialize(servers, command, callback, options, actor) #:nodoc:
|
9
|
+
@servers = servers
|
10
|
+
@command = command.strip.gsub(/\r?\n/, "\\\n")
|
11
|
+
@callback = callback
|
12
|
+
@options = options
|
13
|
+
@actor = actor
|
14
|
+
@channels = open_channels
|
15
|
+
end
|
16
|
+
|
17
|
+
def logger #:nodoc:
|
18
|
+
actor.logger
|
19
|
+
end
|
20
|
+
|
21
|
+
# Processes the command in parallel on all specified hosts. If the command
|
22
|
+
# fails (non-zero return code) on any of the hosts, this will raise a
|
23
|
+
# RuntimeError.
|
24
|
+
def process!
|
25
|
+
logger.debug "processing command"
|
26
|
+
|
27
|
+
loop do
|
28
|
+
active = 0
|
29
|
+
@channels.each do |ch|
|
30
|
+
next if ch[:closed]
|
31
|
+
active += 1
|
32
|
+
ch.connection.process(true)
|
33
|
+
end
|
34
|
+
|
35
|
+
break if active == 0
|
36
|
+
end
|
37
|
+
|
38
|
+
logger.trace "command finished"
|
39
|
+
|
40
|
+
if failed = @channels.detect { |ch| ch[:status] != 0 }
|
41
|
+
raise "command #{@command.inspect} failed on #{failed[:host]}"
|
42
|
+
end
|
43
|
+
|
44
|
+
self
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
def open_channels
|
50
|
+
@servers.map do |server|
|
51
|
+
@actor.sessions[server].open_channel do |channel|
|
52
|
+
channel[:host] = server
|
53
|
+
channel.request_pty :want_reply => true
|
54
|
+
|
55
|
+
channel.on_success do |ch|
|
56
|
+
logger.trace "executing command", ch[:host]
|
57
|
+
ch.exec command
|
58
|
+
ch.send_data options[:data] if options[:data]
|
59
|
+
end
|
60
|
+
|
61
|
+
channel.on_failure do |ch|
|
62
|
+
logger.important "could not open channel", ch[:host]
|
63
|
+
ch.close
|
64
|
+
end
|
65
|
+
|
66
|
+
channel.on_data do |ch, data|
|
67
|
+
@callback[ch, :out, data] if @callback
|
68
|
+
end
|
69
|
+
|
70
|
+
channel.on_extended_data do |ch, type, data|
|
71
|
+
@callback[ch, :err, data] if @callback
|
72
|
+
end
|
73
|
+
|
74
|
+
channel.on_request do |ch, request, reply, data|
|
75
|
+
ch[:status] = data.read_long if request == "exit-status"
|
76
|
+
end
|
77
|
+
|
78
|
+
channel.on_close do |ch|
|
79
|
+
ch[:closed] = true
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,193 @@
|
|
1
|
+
require 'switchtower/actor'
|
2
|
+
require 'switchtower/logger'
|
3
|
+
require 'switchtower/scm/subversion'
|
4
|
+
|
5
|
+
module SwitchTower
|
6
|
+
|
7
|
+
# Represents a specific SwitchTower configuration. A Configuration instance
|
8
|
+
# may be used to load multiple recipe files, define and describe tasks,
|
9
|
+
# define roles, create an actor, and set configuration variables.
|
10
|
+
class Configuration
|
11
|
+
Role = Struct.new(:host, :options)
|
12
|
+
|
13
|
+
DEFAULT_VERSION_DIR_NAME = "releases" #:nodoc:
|
14
|
+
DEFAULT_CURRENT_DIR_NAME = "current" #:nodoc:
|
15
|
+
DEFAULT_SHARED_DIR_NAME = "shared" #:nodoc:
|
16
|
+
|
17
|
+
# The actor created for this configuration instance.
|
18
|
+
attr_reader :actor
|
19
|
+
|
20
|
+
# The list of Role instances defined for this configuration.
|
21
|
+
attr_reader :roles
|
22
|
+
|
23
|
+
# The logger instance defined for this configuration.
|
24
|
+
attr_reader :logger
|
25
|
+
|
26
|
+
# The load paths used for locating recipe files.
|
27
|
+
attr_reader :load_paths
|
28
|
+
|
29
|
+
# The time (in UTC) at which this configuration was created, used for
|
30
|
+
# determining the release path.
|
31
|
+
attr_reader :now
|
32
|
+
|
33
|
+
def initialize(actor_class=Actor) #:nodoc:
|
34
|
+
@roles = Hash.new { |h,k| h[k] = [] }
|
35
|
+
@actor = actor_class.new(self)
|
36
|
+
@logger = Logger.new
|
37
|
+
@load_paths = [".", File.join(File.dirname(__FILE__), "recipes")]
|
38
|
+
@variables = {}
|
39
|
+
@now = Time.now.utc
|
40
|
+
|
41
|
+
set :application, nil
|
42
|
+
set :repository, nil
|
43
|
+
set :gateway, nil
|
44
|
+
set :user, nil
|
45
|
+
set :password, nil
|
46
|
+
|
47
|
+
set :deploy_to, Proc.new { "/u/apps/#{application}" }
|
48
|
+
|
49
|
+
set :version_dir, DEFAULT_VERSION_DIR_NAME
|
50
|
+
set :current_dir, DEFAULT_CURRENT_DIR_NAME
|
51
|
+
set :shared_dir, DEFAULT_SHARED_DIR_NAME
|
52
|
+
set :scm, :subversion
|
53
|
+
|
54
|
+
set :revision, Proc.new { source.latest_revision }
|
55
|
+
end
|
56
|
+
|
57
|
+
# Set a variable to the given value.
|
58
|
+
def set(variable, value)
|
59
|
+
@variables[variable] = value
|
60
|
+
end
|
61
|
+
|
62
|
+
alias :[]= :set
|
63
|
+
|
64
|
+
# Access a named variable. If the value of the variable is a Proc instance,
|
65
|
+
# the proc will be invoked and the return value cached and returned.
|
66
|
+
def [](variable)
|
67
|
+
set variable, @variables[variable].call if Proc === @variables[variable]
|
68
|
+
@variables[variable]
|
69
|
+
end
|
70
|
+
|
71
|
+
# Based on the current value of the <tt>:scm</tt> variable, instantiate and
|
72
|
+
# return an SCM module representing the desired source control behavior.
|
73
|
+
def source
|
74
|
+
@source ||= case scm
|
75
|
+
when Class then
|
76
|
+
scm.new(self)
|
77
|
+
when String, Symbol then
|
78
|
+
require "switchtower/scm/#{scm.to_s.downcase}"
|
79
|
+
SwitchTower::SCM.const_get(scm.to_s.downcase.capitalize).new(self)
|
80
|
+
else
|
81
|
+
raise "invalid scm specification: #{scm.inspect}"
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
# Load a configuration file or string into this configuration.
|
86
|
+
#
|
87
|
+
# Usage:
|
88
|
+
#
|
89
|
+
# load("recipe"):
|
90
|
+
# Look for and load the contents of 'recipe.rb' into this
|
91
|
+
# configuration.
|
92
|
+
#
|
93
|
+
# load(:file => "recipe"):
|
94
|
+
# same as above
|
95
|
+
#
|
96
|
+
# load(:string => "set :scm, :subversion"):
|
97
|
+
# Load the given string as a configuration specification.
|
98
|
+
def load(*args)
|
99
|
+
options = args.last.is_a?(Hash) ? args.pop : {}
|
100
|
+
args.each { |arg| load options.merge(:file => arg) }
|
101
|
+
|
102
|
+
if options[:file]
|
103
|
+
file = options[:file]
|
104
|
+
unless file[0] == ?/
|
105
|
+
load_paths.each do |path|
|
106
|
+
if File.file?(File.join(path, file))
|
107
|
+
file = File.join(path, file)
|
108
|
+
break
|
109
|
+
elsif File.file?(File.join(path, file) + ".rb")
|
110
|
+
file = File.join(path, file + ".rb")
|
111
|
+
break
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
load :string => File.read(file), :name => options[:name] || file
|
117
|
+
elsif options[:string]
|
118
|
+
logger.debug "loading configuration #{options[:name] || "<eval>"}"
|
119
|
+
instance_eval options[:string], options[:name] || "<eval>"
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
# Define a new role and its associated servers. You must specify at least
|
124
|
+
# one host for each role. Also, you can specify additional information
|
125
|
+
# (in the form of a Hash) which can be used to more uniquely specify the
|
126
|
+
# subset of servers specified by this specific role definition.
|
127
|
+
#
|
128
|
+
# Usage:
|
129
|
+
#
|
130
|
+
# role :db, "db1.example.com", "db2.example.com"
|
131
|
+
# role :db, "master.example.com", :primary => true
|
132
|
+
# role :app, "app1.example.com", "app2.example.com"
|
133
|
+
def role(which, *args)
|
134
|
+
options = args.last.is_a?(Hash) ? args.pop : {}
|
135
|
+
raise ArgumentError, "must give at least one host" if args.empty?
|
136
|
+
args.each { |host| roles[which] << Role.new(host, options) }
|
137
|
+
end
|
138
|
+
|
139
|
+
# Describe the next task to be defined. The given text will be attached to
|
140
|
+
# the next task that is defined and used as its description.
|
141
|
+
def desc(text)
|
142
|
+
@next_description = text
|
143
|
+
end
|
144
|
+
|
145
|
+
# Define a new task. If a description is active (see #desc), it is added to
|
146
|
+
# the options under the <tt>:desc</tt> key. This method ultimately
|
147
|
+
# delegates to Actor#define_task.
|
148
|
+
def task(name, options={}, &block)
|
149
|
+
raise ArgumentError, "expected a block" unless block
|
150
|
+
|
151
|
+
if @next_description
|
152
|
+
options = options.merge(:desc => @next_description)
|
153
|
+
@next_description = nil
|
154
|
+
end
|
155
|
+
|
156
|
+
actor.define_task(name, options, &block)
|
157
|
+
end
|
158
|
+
|
159
|
+
# Return the path into which releases should be deployed.
|
160
|
+
def releases_path
|
161
|
+
File.join(deploy_to, version_dir)
|
162
|
+
end
|
163
|
+
|
164
|
+
# Return the path identifying the +current+ symlink, used to identify the
|
165
|
+
# current release.
|
166
|
+
def current_path
|
167
|
+
File.join(deploy_to, current_dir)
|
168
|
+
end
|
169
|
+
|
170
|
+
# Return the path into which shared files should be stored.
|
171
|
+
def shared_path
|
172
|
+
File.join(deploy_to, shared_dir)
|
173
|
+
end
|
174
|
+
|
175
|
+
# Return the full path to the named release. If a release is not specified,
|
176
|
+
# +now+ is used (the time at which the configuration was created).
|
177
|
+
def release_path(release=now.strftime("%Y%m%d%H%M%S"))
|
178
|
+
File.join(releases_path, release)
|
179
|
+
end
|
180
|
+
|
181
|
+
def respond_to?(sym) #:nodoc:
|
182
|
+
@variables.has_key?(sym) || super
|
183
|
+
end
|
184
|
+
|
185
|
+
def method_missing(sym, *args, &block) #:nodoc:
|
186
|
+
if args.length == 0 && block.nil? && @variables.has_key?(sym)
|
187
|
+
self[sym]
|
188
|
+
else
|
189
|
+
super
|
190
|
+
end
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|