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.
@@ -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