salticid 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
data/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright (c) 2012 Kyle Kingsbury
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.markdown ADDED
@@ -0,0 +1,11 @@
1
+ Salticid
2
+ =====
3
+
4
+ Salticid is a deployment tool I wrote for Vodpod and Showyou in a couple of weekends. Its only design goals were:
5
+
6
+ 1. Magic
7
+ 2. More Magic
8
+
9
+ It violates every principle of good software development possible. It is clunky, slow, buggy, and dangerous.
10
+
11
+ It also worked surprisingly well. YMMV. d=('_~)=b
@@ -0,0 +1,21 @@
1
+ module Net
2
+ module SSH
3
+ class Shell
4
+ # Like execute! but returns an array: the status and the output.
5
+ def exec!(command, klass=Net::SSH::Shell::Process, &callback)
6
+ result = ''
7
+
8
+ process = klass.new(self, command, callback)
9
+ process.on_output do |p, output|
10
+ result << output
11
+ end
12
+
13
+ process.run if processes.empty?
14
+ processes << process
15
+ wait!
16
+
17
+ [process.exit_status, result]
18
+ end
19
+ end
20
+ end
21
+ end
data/lib/salticid.rb ADDED
@@ -0,0 +1,205 @@
1
+ require 'rubygems'
2
+ require 'net/ssh'
3
+ require 'net/scp'
4
+ require 'net/ssh/gateway'
5
+
6
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
7
+
8
+ require 'salticid/version'
9
+
10
+ class Salticid
11
+ def self.log(str)
12
+ File.open('salticid.log', 'a') do |f|
13
+ f.puts str
14
+ end
15
+ end
16
+
17
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), 'net-ssh-shell', 'lib'))
18
+ require 'monkeypatch'
19
+ require 'snippets/init'
20
+ require 'salticid/message'
21
+ require 'salticid/task'
22
+ require 'salticid/role'
23
+ require 'salticid/role_proxy'
24
+ require 'salticid/host'
25
+ require 'salticid/gateway'
26
+ require 'salticid/group'
27
+
28
+ attr_accessor :gw, :groups, :hosts, :roles, :tasks
29
+
30
+ def initialize
31
+ @gw = nil
32
+ @hosts = []
33
+ @groups = []
34
+ @roles = []
35
+ @tasks = []
36
+ end
37
+
38
+ # Define a gateway.
39
+ def gw(name = nil, &block)
40
+ if name == nil
41
+ return @gw
42
+ end
43
+
44
+ # Get gateway from cache or set new one.
45
+ name = name.to_s
46
+
47
+ unless gw = @hosts.find{|h| h.name == name}
48
+ gw = Salticid::Gateway.new(name, :salticid => self)
49
+ @hosts << gw
50
+ # Set default gw
51
+ @gw = gw
52
+ end
53
+
54
+
55
+ if block_given?
56
+ gw.instance_exec &block
57
+ end
58
+
59
+ gw
60
+ end
61
+
62
+ def host(name, &block)
63
+ name = name.to_s
64
+ unless host = @hosts.find{|h| h.name == name}
65
+ host = Salticid::Host.new(name, :salticid => self)
66
+ @hosts << host
67
+ end
68
+
69
+ if block_given?
70
+ host.instance_exec &block
71
+ end
72
+
73
+ host
74
+ end
75
+
76
+ # Tries to guess what hosts we would run the given string on.
77
+ def hosts_for(string)
78
+ first = string[/^(\w+)\.\w+/, 1]
79
+ if role = @roles.find { |r| r.name == first }
80
+ return role.hosts
81
+ else
82
+ raise "Sorry, I didn't understand what hosts to run #{string.inspect} on."
83
+ end
84
+ end
85
+
86
+ # Assigns a group to this Salticid. Runs the optional block in the group's
87
+ # context. Returns the group.
88
+ def group(name, &block)
89
+ # Get group
90
+ group = name if name.kind_of? Salticid::Group
91
+ name = name.to_s
92
+ group ||= @groups.find{|g| g.name == name}
93
+ group ||= Salticid::Group.new(name, :salticid => self)
94
+
95
+ # Store
96
+ @groups |= [group]
97
+
98
+ # Run block
99
+ if block_given?
100
+ group.instance_exec &block
101
+ end
102
+
103
+ group
104
+ end
105
+
106
+ # Loads one or more file globs into the current salticid.
107
+ def load(*globs)
108
+ skips = globs.grep(/^-/)
109
+ (globs - skips).each do |glob|
110
+ glob += '.rb' if glob =~ /\*$/
111
+ Dir.glob(glob).sort.each do |path|
112
+ next unless File.file? path
113
+ next if skips.find {|pat| path =~ /#{pat[1..-1]}$/}
114
+ instance_eval(File.read(path), path)
115
+ end
116
+ end
117
+ end
118
+
119
+ # Defines a new role. A role is a package of tasks.
120
+ def role(name, &block)
121
+ name = name.to_s
122
+
123
+ unless role = @roles.find{|r| r.name == name}
124
+ role = Salticid::Role.new(name, :salticid => self)
125
+ @roles << role
126
+ end
127
+
128
+ if block_given?
129
+ role.instance_eval &block
130
+ end
131
+
132
+ role
133
+ end
134
+
135
+ # Finds (and optionally defines) a task.
136
+ # task :foo => returns a Task
137
+ # task :foo do ... end => defines a Task with given block
138
+ def task(name, &block)
139
+ name = name.to_s
140
+
141
+ unless task = @tasks.find{|t| t.name == name}
142
+ task = Salticid::Task.new(name, :salticid => self)
143
+ @tasks << task
144
+ end
145
+
146
+ if block_given?
147
+ task.block = block
148
+ end
149
+
150
+ task
151
+ end
152
+
153
+ def to_s
154
+ "Salticid"
155
+ end
156
+
157
+ # An involved description of the salticid
158
+ def to_string
159
+ h = ''
160
+ h << "Groups\n"
161
+ groups.each do |group|
162
+ h << " #{group}\n"
163
+ end
164
+
165
+ h << "\nHosts:\n"
166
+ hosts.each do |host|
167
+ h << " #{host}\n"
168
+ end
169
+
170
+ h << "\nRoles\n"
171
+ roles.each do |role|
172
+ h << " #{role}\n"
173
+ end
174
+
175
+ h << "\nTop-level tasks\n"
176
+ tasks.each do |task|
177
+ h << " #{task}\n"
178
+ end
179
+
180
+ h
181
+ end
182
+
183
+ # Unknown methods are resolved as groups, then hosts, then roles, then tasks.
184
+ # Can you think of a better order?
185
+ #
186
+ # Blocks are instance_exec'd in the context of the found object.
187
+ def method_missing(meth, &block)
188
+ name = meth.to_s
189
+
190
+ found = @groups.find { |g| g.name == name }
191
+ found ||= @hosts.find { |h| h.name == name }
192
+ found ||= @roles.find { |r| r.name == name }
193
+ found ||= @tasks.find { |t| t.name == name }
194
+
195
+ unless found
196
+ raise NoMethodError
197
+ end
198
+
199
+ if block
200
+ found.instance_exec &block
201
+ end
202
+
203
+ found
204
+ end
205
+ end
@@ -0,0 +1,17 @@
1
+ module Salticid
2
+ def self.config
3
+ @config
4
+ end
5
+
6
+ def self.config=(config)
7
+ @config = config
8
+ end
9
+
10
+ def self.load_config(file)
11
+ @config = Construct.load(file)
12
+
13
+ @config.define :hosts, :default => []
14
+
15
+ @config
16
+ end
17
+ end
@@ -0,0 +1,23 @@
1
+ class Salticid::Gateway < Salticid::Host
2
+ def initialize(*args)
3
+ @tunnel_lock = Mutex.new
4
+ super *args
5
+ end
6
+
7
+ # Gateways don't need gateways.
8
+ def gw
9
+ end
10
+
11
+ # Tunnel for connecting to other hosts.
12
+ def gateway_tunnel
13
+ # Multiple hosts will be asking for this tunnel at the same time.
14
+ # We need to only create one.
15
+ @tunnel_lock.synchronize do
16
+ @gateway_tunnel ||= Net::SSH::Gateway.new(name, user)
17
+ end
18
+ end
19
+
20
+ # We don't need tunnels either
21
+ def tunnel
22
+ end
23
+ end
@@ -0,0 +1,99 @@
1
+ class Salticid::Group
2
+ # A collection of hosts and other groups.
3
+
4
+ attr_reader :name
5
+ attr_accessor :parent
6
+ attr_accessor :groups, :hosts
7
+
8
+ def initialize(name, opts = {})
9
+ @name = name.to_s
10
+ @salticid = opts[:salticid]
11
+ @parent = opts[:parent]
12
+ @hosts = []
13
+ @groups = []
14
+ end
15
+
16
+ def ==(other)
17
+ self.class == other.class and
18
+ self.name == other.name and
19
+ self.parent == other.parent
20
+ end
21
+
22
+ # Runs the block in the context of each.
23
+ def each_host(&block)
24
+ hosts.each do |host|
25
+ host.instance_exec &block
26
+ end
27
+ end
28
+
29
+ # Finds all hosts (recursively) that are members of this group or subgroups.
30
+ def hosts
31
+ @hosts + @groups.map { |m|
32
+ m.hosts
33
+ }.flatten.uniq
34
+ end
35
+
36
+ # Creates a sub-group of this group.
37
+ def group(name, &block)
38
+ # Get group
39
+ name = name.to_s
40
+ group = @groups.find{|g| g.name == name}
41
+ group ||= Salticid::Group.new(name, :salticid => @salticid, :parent => self)
42
+
43
+ # Store
44
+ @groups |= [group]
45
+
46
+ # Run block
47
+ if block
48
+ group.instance_exec &block
49
+ end
50
+
51
+ group
52
+ end
53
+
54
+ # Adds a host (by name) to the group. Returns the host.
55
+ def host(name)
56
+ host = @salticid.host name
57
+ host.groups |= [self]
58
+ @hosts |= [host]
59
+ end
60
+
61
+ def inspect
62
+ "#<Salticid::Group #{path}>"
63
+ end
64
+
65
+ # Unknown methods are resolved as groups, then hosts. Blocks are instance_exec'd in the found context.
66
+ def method_missing(meth, &block)
67
+ name = meth.to_s
68
+ found = @groups.find { |g| g.name == name }
69
+ found ||= @hosts.find { |h| h.name == name }
70
+
71
+ unless found
72
+ raise NoMethodError
73
+ end
74
+
75
+ if block
76
+ found.instance_exec &block
77
+ end
78
+
79
+ found
80
+ end
81
+
82
+ def path
83
+ if @parent
84
+ @parent.path + '/' + @name
85
+ else
86
+ '/' + @name
87
+ end
88
+ end
89
+
90
+ def to_s
91
+ @name
92
+ end
93
+
94
+ def to_string
95
+ h = "Group #{@name}:\n"
96
+ h << " Hosts:\n"
97
+ h << hosts.map { |h| " #{h}" }.join("\n")
98
+ end
99
+ end
@@ -0,0 +1,600 @@
1
+ class Salticid::Host
2
+ attr_accessor :env, :name, :user, :groups, :roles, :tasks, :salticid, :password
3
+
4
+ def initialize(name, opts = {})
5
+ @name = name.to_s
6
+ @user = opts[:user].to_s
7
+ @groups = opts[:groups] || []
8
+ @roles = opts[:roles] || []
9
+ @tasks = opts[:tasks] || []
10
+ @salticid = opts[:salticid]
11
+ @sudo = nil
12
+
13
+ @on_log = proc { |message| }
14
+
15
+ @ssh_lock = Mutex.new
16
+
17
+ @env = {}
18
+ @cwd = nil
19
+ @role_proxies = {}
20
+ end
21
+
22
+ def ==(other)
23
+ self.name == other.name
24
+ end
25
+
26
+ # Appends the given string to a file.
27
+ # Pass :uniq => true to only append if the string is not already present in
28
+ # the file.
29
+ def append(str, file, opts = {})
30
+ file = expand_path(file)
31
+ if opts[:uniq] and exists? file
32
+ # Check to ensure the file does not contain the line already.
33
+ begin
34
+ grep(str, file) or raise
35
+ rescue
36
+ # We're clear, go ahead.
37
+ tee '-a', file, :stdin => str
38
+ end
39
+ else
40
+ # No need to check, just append.
41
+ tee '-a', file, :stdin => str
42
+ end
43
+ end
44
+
45
+ # All calls to exec! within this block are prefixed by sudoing to the user.
46
+ def as(user = nil)
47
+ old_sudo, @sudo = @sudo, (user || 'root')
48
+ yield
49
+ @sudo = old_sudo
50
+ end
51
+
52
+ # Changes our working directory.
53
+ def cd(dir = nil)
54
+ dir ||= homedir
55
+ dir = expand_path(dir)
56
+ @cwd = dir
57
+ end
58
+
59
+ # Changes the mode of a file. Mode is numeric.
60
+ def chmod(mode, path)
61
+ exec! "chmod #{mode.to_s(8)} #{escape(expand_path(path))}"
62
+ end
63
+
64
+ # Changes the mode of a file, recursively. Mode is numeric.
65
+ def chmod_r(mode, path)
66
+ exec! "chmod -R #{mode.to_s(8)} #{escape(expand_path(path))}"
67
+ end
68
+
69
+ # Returns current working directory. Tries to obtain it from exec 'pwd',
70
+ # but falls back to /.
71
+ def cwd
72
+ @cwd ||= begin
73
+ exec! 'pwd'
74
+ rescue => e
75
+ raise e
76
+ '/'
77
+ end
78
+ end
79
+
80
+ # Returns true if a directory exists
81
+ def dir?(path)
82
+ begin
83
+ ftype(path) == 'directory'
84
+ rescue
85
+ false
86
+ end
87
+ end
88
+
89
+ # Downloads a file from the remote server. Local defaults to remote filename
90
+ # (in current path) if not specified.
91
+ def download(remote, local = nil, opts = {})
92
+ remote_filename ||= File.split(remote).last
93
+ if File.directory? local
94
+ local = File.join(local, remote_filename)
95
+ else
96
+ local = remote_filename
97
+ end
98
+
99
+ remote = expand_path remote
100
+ log "downloading from #{remote.inspect} to #{local.inspect}"
101
+ ssh.scp.download!(remote, local, opts)
102
+ end
103
+
104
+ # Quotes a string for inclusion in a bash command line
105
+ def escape(string)
106
+ return '' if string.nil?
107
+ return string unless string.to_s =~ /[\\\$`" ]/
108
+ '"' + string.to_s.gsub(/[\\\$`"]/) { |match| '\\' + match } + '"'
109
+ end
110
+
111
+ # Runs a remote command. If a block is given, it is run in a new thread
112
+ # after stdin is sent. Its sole argument is the SSH channel for this command:
113
+ # you may use send_data to write to the processes stdin, and use ch.eof! to
114
+ # close stdin. ch.close will stop the remote process.
115
+ #
116
+ # Options:
117
+ # :stdin => Data piped to the process' stdin.
118
+ # :stdout => A callback invoked when stdout is received from the process.
119
+ # The argument is the data received.
120
+ # :stderr => Like stdout, but for stderr.
121
+ # :echo => Prints stdout and stderr using print, if true.
122
+ # :to => Shell output redirection to file. (like cmd >/foo)
123
+ # :from => Shell input redirection from file. (like cmd </foo)
124
+ def exec!(command, opts = {}, &block)
125
+ # Options
126
+ stdout = ''
127
+ stderr = ''
128
+ defaults = {
129
+ :check_exit_status => true
130
+ }
131
+
132
+ opts = defaults.merge opts
133
+
134
+ # First, set up the environment...
135
+ if @env.size > 0
136
+ command = (
137
+ @env.map { |k,v| k.to_s.upcase + '=' + v } << command
138
+ ).join(' ')
139
+ end
140
+
141
+ # Before execution, cd to cwd
142
+ command = "cd #{escape(@cwd)}; " + command
143
+
144
+ # Input redirection
145
+ if opts[:from]
146
+ command += " <#{escape(opts[:from])}"
147
+ end
148
+
149
+ # Output redirection
150
+ if opts[:to]
151
+ command += " >#{escape(opts[:to])}"
152
+ end
153
+
154
+ # After command, add a semicolon...
155
+ unless command =~ /;\s*$/
156
+ command += ';'
157
+ end
158
+
159
+ # Then echo the exit status.
160
+ command += ' echo $?; '
161
+
162
+
163
+ # If applicable, wrap the command in a sudo subshell...
164
+ if @sudo
165
+ command = "sudo -S -u #{@sudo} bash -c #{escape(command)}"
166
+ opts[:stdin] = @password + "\n" + opts[:stdin].to_s
167
+ end
168
+
169
+ buffer = ''
170
+ echoed = 0
171
+ status = nil
172
+ written = false
173
+
174
+ # Run ze command with callbacks.
175
+ # Return status.
176
+ channel = ssh.open_channel do |ch|
177
+ ch.exec command do |ch, success|
178
+ raise "could not execute command" unless success
179
+
180
+ # Handle STDOUT
181
+ ch.on_data do |c, data|
182
+ # Could this data be the status code?
183
+ if pos = (data =~ /(\d{1,3})\n$/)
184
+ # Set status
185
+ status = $1
186
+
187
+ # Flush old buffer
188
+ opts[:stdout].call(buffer) if opts[:stdout]
189
+ stdout << buffer
190
+
191
+ # Save candidate status code
192
+ buffer = data[pos .. -1]
193
+
194
+ # Write the other part of the string to the callback
195
+ opts[:stdout].call(data[0...pos]) if opts[:stdout]
196
+ stdout << data[0...pos]
197
+ else
198
+ # Write buffer + data to callback
199
+ opts[:stdout].call(buffer + data) if opts[:stdout]
200
+ stdout << buffer + data
201
+ buffer = ''
202
+ end
203
+
204
+ if opts[:echo] and echoed < stdout.length
205
+ stdout[echoed..-1].split("\n")[0..-2].each do |fragment|
206
+ echoed += fragment.length + 1
207
+ log fragment
208
+ end
209
+ end
210
+ end
211
+
212
+ # Handle STDERR
213
+ ch.on_extended_data do |c, type, data|
214
+ if type == 1
215
+ # STDERR
216
+ opts[:stderr].call(data) if opts[:stderr]
217
+ stderr << data
218
+ log :stderr, stderr if opts[:echo]
219
+ end
220
+ end
221
+
222
+ # Write stdin
223
+ if opts[:stdin]
224
+ ch.on_process do
225
+ unless written
226
+ ch.send_data opts[:stdin]
227
+ written = true
228
+ else
229
+ # Okay, we wrote stdin
230
+ unless block or ch.eof?
231
+ ch.eof!
232
+ end
233
+ end
234
+ end
235
+ end
236
+
237
+ # Handle close
238
+ ch.on_close do
239
+ if opts[:echo]
240
+ # Echo last of input data
241
+ stdout[echoed..-1].split("\n").each do |fragment|
242
+ echoed += fragment.length + 1
243
+ log fragment
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end
249
+
250
+ if block
251
+ # Run the callback
252
+ callback_thread = Thread.new do
253
+ if opts[:stdin]
254
+ # Wait for stdin to be written before calling...
255
+ until written
256
+ sleep 0.1
257
+ end
258
+ end
259
+
260
+ block.call(channel)
261
+ end
262
+ end
263
+
264
+ # Wait for the command to complete.
265
+ channel.wait
266
+
267
+ # Let the callback thread finish as well
268
+ callback_thread.join if callback_thread
269
+
270
+ if opts[:check_exit_status]
271
+ # Make sure we have our status.
272
+ if status.nil? or status.empty?
273
+ raise "empty status in host#exec() for #{command}, hmmm"
274
+ end
275
+
276
+ # Check status.
277
+ status = status.to_i
278
+ if status != 0
279
+ raise "#{command} exited with non-zero status #{status}!\nSTDERR:\n#{stderr}\nSTDOUT:\n#{stdout}"
280
+ end
281
+ end
282
+
283
+ stdout.chomp
284
+ end
285
+
286
+ # Returns true when a file exists, otherwise false
287
+ def exists?(path)
288
+ true if ftype(path) rescue false
289
+ end
290
+
291
+ # Generates a full path for the given remote path.
292
+ def expand_path(path)
293
+ path = path.to_s.gsub(/~(\w+)?/) { |m| homedir($1) }
294
+ File.expand_path(path, cwd.to_s)
295
+ end
296
+
297
+ # Returns true if a regular file exists.
298
+ def file?(path)
299
+ ftype(path) == 'file' rescue false
300
+ end
301
+
302
+ # Returns the filetype, as string. Raises exceptions on failed stat.
303
+ def ftype(path)
304
+ path = expand_path(path)
305
+ begin
306
+ str = self.stat('-c', '%F', path).strip
307
+ case str
308
+ when /no such file or directory/i
309
+ raise Errno::ENOENT, "#{self}:#{path} does not exist"
310
+ when 'regular file'
311
+ 'file'
312
+ when 'regular empty file'
313
+ 'file'
314
+ when 'directory'
315
+ 'directory'
316
+ when 'character special file'
317
+ 'characterSpecial'
318
+ when 'block special file'
319
+ 'blockSpecial'
320
+ when /link/
321
+ 'link'
322
+ when /socket/
323
+ 'socket'
324
+ when /fifo|pipe/
325
+ 'fifo'
326
+ else
327
+ raise RuntimeError, "unknown filetype #{str}"
328
+ end
329
+ rescue
330
+ raise RuntimeError, "stat #{self}:#{path} failed - #{str}"
331
+ end
332
+ end
333
+
334
+ # Abusing convention slightly...
335
+ # Returns the group by name if this host belongs to it, otherwise false.
336
+ def group?(name)
337
+ name = name.to_s
338
+ @groups.find{ |g| g.name == name } || false
339
+ end
340
+
341
+ # Adds this host to a group.
342
+ def group(name)
343
+ group = name if name.kind_of? Salticid::Group
344
+ group ||= @salticid.group name
345
+ group.hosts |= [self]
346
+ @groups |= [group]
347
+ group
348
+ end
349
+
350
+ # Returns the gateway for this host.
351
+ def gw(gw = nil)
352
+ if gw
353
+ @gw = @salticid.host(gw)
354
+ else
355
+ @gw
356
+ end
357
+ end
358
+
359
+ # Returns the home directory of the given user, or the current user if
360
+ # none specified.
361
+ def homedir(user = (@sudo||@user))
362
+ exec! "awk -F: -v v=#{escape(user)} '{if ($1==v) print $6}' /etc/passwd"
363
+ end
364
+
365
+ def inspect
366
+ "#<#{@user}@#{@name} roles=#{@roles.inspect} tasks=#{@tasks.inspect}>"
367
+ end
368
+
369
+ # Issues a logging statement to this host's log.
370
+ # log :error, "message"
371
+ # log "message" is the same as log "info", "message"
372
+ def log(*args)
373
+ begin
374
+ @on_log.call Message.new(*args)
375
+ rescue
376
+ # If the log handler is broken, keep going.
377
+ end
378
+ end
379
+
380
+ # Missing methods are resolved as follows:
381
+ # 0. Create a RoleProxy from a Role on this host
382
+ # 1. From task_resolve
383
+ # 2. Converted to a command string and exec!'ed
384
+ def method_missing(meth, *args, &block)
385
+ if meth.to_s == "to_ary"
386
+ raise NoMethodError
387
+ end
388
+
389
+ if args.empty? and rp = role_proxy(meth)
390
+ rp
391
+ elsif task = resolve_task(meth)
392
+ task.run(self, *args, &block)
393
+ else
394
+ if args.last.kind_of? Hash
395
+ opts = args.pop
396
+ else
397
+ opts = {}
398
+ end
399
+ str = ([meth] + args.map{|a| escape(a)}).join(' ')
400
+ exec! str, opts, &block
401
+ end
402
+ end
403
+
404
+ # Returns the file mode of a remote file.
405
+ def mode(path)
406
+ stat('-c', '%a', path).oct
407
+ end
408
+
409
+ # Sets or gets the name of this host.
410
+ def name(name = nil)
411
+ if name
412
+ @name = name.to_s
413
+ else
414
+ @name
415
+ end
416
+ end
417
+
418
+ def on_log(&block)
419
+ @on_log = block
420
+ end
421
+
422
+ # Finds a task for this host, by name.
423
+ def resolve_task(name)
424
+ name = name.to_s
425
+ @tasks.each do |task|
426
+ return task if task.name == name
427
+ end
428
+ @salticid.tasks.each do |task|
429
+ return task if task.name == name
430
+ end
431
+ nil
432
+ end
433
+
434
+ # Assigns roles to a host from the Salticid. Roles are unique in hosts; repeat
435
+ # assignments will not result in more than one copy of the role.
436
+ def role(role)
437
+ @roles = @roles | [@salticid.role(role)]
438
+ end
439
+
440
+ # Does this host have the given role?
441
+ def role?(role)
442
+ @roles.any? { |r| r.name == role.to_s }
443
+ end
444
+
445
+ # Returns a role proxy for role on this host, if we have the role.
446
+ def role_proxy(name)
447
+ if role = roles.find { |r| r.name == name.to_s }
448
+ @role_proxies[name.to_s] ||= RoleProxy.new(self, role)
449
+ end
450
+ end
451
+
452
+ # Runs the specified task on the given role. Raises NoMethodError if
453
+ # either the role or task do not exist.
454
+ def run(role, task, *args)
455
+ if rp = role_proxy(role)
456
+ rp.__send__(task, *args)
457
+ else
458
+ raise NoMethodError, "No such role #{role.inspect} on #{self}"
459
+ end
460
+ end
461
+
462
+ # Opens an SSH connection and stores the connection in @ssh.
463
+ def ssh
464
+ @ssh_lock.synchronize do
465
+ if @ssh and not @ssh.closed?
466
+ return @ssh
467
+ end
468
+
469
+ if tunnel
470
+ @ssh = tunnel.ssh(name, user)
471
+ else
472
+ @ssh = Net::SSH.start(name, user)
473
+ end
474
+ end
475
+ end
476
+
477
+ # If a block is given, works like #as. Otherwise, just execs sudo with the
478
+ # given arguments.
479
+ def sudo(*args, &block)
480
+ if block_given?
481
+ as *args, &block
482
+ else
483
+ method_missing(:sudo, *args)
484
+ end
485
+ end
486
+
487
+ # Uploads a file and places it in the final destination as root.
488
+ # If the file already exists, its ownership and mode are used for
489
+ # the replacement. Otherwise it inherits ownership from the parent directory.
490
+ def sudo_upload(local, remote, opts={})
491
+ remote = expand_path remote
492
+
493
+ # TODO: umask this?
494
+ local_mode = File.stat(local).mode & 07777
495
+ File.chmod 0600, local
496
+
497
+
498
+ # Get temporary filename
499
+ tmpfile = '/'
500
+ while exists? tmpfile
501
+ tmpfile = '/tmp/sudo_upload_' + Time.now.to_f.to_s
502
+ end
503
+
504
+ # Upload
505
+ upload local, tmpfile, opts
506
+
507
+ # Get remote mode/user/group
508
+ sudo do
509
+ if exists? remote
510
+ mode = self.mode remote
511
+ user = stat('-c', '%U', remote).strip
512
+ group = stat('-c', '%G', remote).strip
513
+ else
514
+ user = stat('-c', '%U', File.dirname(remote)).strip
515
+ group = stat('-c', '%G', File.dirname(remote)).strip
516
+ mode = local_mode
517
+ end
518
+
519
+ # Move and chmod
520
+ mv tmpfile, remote
521
+ chmod mode, remote
522
+ chown "#{user}:#{group}", remote
523
+ end
524
+ end
525
+
526
+ # Finds (and optionally defines) a task.
527
+ #
528
+ # Tasks are first resolved in the host's task list, then in the Salticid's task
529
+ # list. Finally, tasks are created from scratch. Any invocation of task adds
530
+ # that task to this host.
531
+ #
532
+ # If a block is given, the block is assigned to the local (host) task. The
533
+ # task is dup'ed to prevent modifying a possible global task.
534
+ #
535
+ # The task is returned at the end of the method.
536
+ def task(name, &block)
537
+ name = name.to_s
538
+
539
+ if task = @tasks.find{|t| t.name == name}
540
+ # Found in self
541
+ elsif (task = @salticid.tasks.find{|t| t.name == name}) and not block_given?
542
+ # Found in salticid
543
+ @tasks << task
544
+ else
545
+ # Create new task in self
546
+ task = Salticid::Task.new(name, :salticid => @salticid)
547
+ @tasks << task
548
+ end
549
+
550
+ if block_given?
551
+ # Remove the task from our list, and replace it with a copy.
552
+ # This is to prevent local declarations from clobbering global tasks.
553
+ i = @tasks.index(task) || @task.size
554
+ task = task.dup
555
+ task.block = block
556
+ @tasks[i] = task
557
+ end
558
+
559
+ task
560
+ end
561
+
562
+ def to_s
563
+ @name.to_s
564
+ end
565
+
566
+ def to_string
567
+ h = "Host #{@name}:\n"
568
+ h << " Groups: #{groups.map(&:to_s).sort.join(', ')}\n"
569
+ h << " Roles: #{roles.map(&:to_s).sort.join(', ')}\n"
570
+ h << " Tasks:\n"
571
+ tasks = self.tasks.map(&:to_s)
572
+ tasks += roles.map { |r| r.tasks.map { |t| " #{r}.#{t}" }}
573
+ h << tasks.flatten!.sort!.join("\n")
574
+ end
575
+
576
+ # Returns an SSH::Gateway object for connecting to this host, or nil if no
577
+ # gateway is needed.
578
+ def tunnel
579
+ if gw
580
+ # We have a gateway host.
581
+ @tunnel ||= gw.gateway_tunnel
582
+ end
583
+ end
584
+
585
+ # Upload a file to the server. Remote defaults to local's filename (without
586
+ # path) if not specified.
587
+ def upload(local, remote = nil, opts={})
588
+ remote ||= File.split(local).last
589
+ remote = expand_path remote
590
+ ssh.scp.upload!(local, remote, opts)
591
+ end
592
+
593
+ def user(user = nil)
594
+ if user
595
+ @user = user
596
+ else
597
+ @user
598
+ end
599
+ end
600
+ end