switchtower 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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