Class: Salticid::Host

Inherits:
Object show all
Defined in:
lib/salticid/host.rb

Direct Known Subclasses

Gateway

Defined Under Namespace

Modules: IP

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(name, opts = {}) ⇒ Host

Returns a new instance of Host.



4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# File 'lib/salticid/host.rb', line 4

def initialize(name, opts = {})
  @name = name.to_s
  @user = opts[:user].to_s
  @groups = opts[:groups] || []
  @roles = opts[:roles] || []
  @tasks = opts[:tasks] || []
  @salticid = opts[:salticid]
  @sudo = nil

  @on_log = proc { |message| }

  @ssh_lock = Mutex.new

  @env = {}
  @cwd = nil
  @role_proxies = {}
end

Dynamic Method Handling

This class handles dynamic methods through the method_missing method

#method_missing(meth, *args, &block) ⇒ Object

Missing methods are resolved as follows:

  1. Create a RoleProxy from a Role on this host

  2. From task_resolve

  3. Converted to a command string and exec!‘ed



386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
# File 'lib/salticid/host.rb', line 386

def method_missing(meth, *args, &block)
  if meth.to_s == "to_ary"
    raise NoMethodError
  end

  if args.empty? and rp = role_proxy(meth)
    rp
  elsif task = resolve_task(meth)
    task.run(self, *args, &block)
  else
    if args.last.kind_of? Hash
      opts = args.pop
    else
      opts = {}
    end
    str = ([meth] + args.map{|a| escape(a)}).join(' ')
    exec! str, opts, &block
  end
end

Instance Attribute Details

#envObject

Returns the value of attribute env.



2
3
4
# File 'lib/salticid/host.rb', line 2

def env
  @env
end

#groupsObject

Returns the value of attribute groups.



2
3
4
# File 'lib/salticid/host.rb', line 2

def groups
  @groups
end

#name(name = nil) ⇒ Object

Sets or gets the name of this host.



412
413
414
# File 'lib/salticid/host.rb', line 412

def name
  @name
end

#passwordObject

Returns the value of attribute password.



2
3
4
# File 'lib/salticid/host.rb', line 2

def password
  @password
end

#rolesObject

Returns the value of attribute roles.



2
3
4
# File 'lib/salticid/host.rb', line 2

def roles
  @roles
end

#salticidObject

Returns the value of attribute salticid.



2
3
4
# File 'lib/salticid/host.rb', line 2

def salticid
  @salticid
end

#tasksObject

Returns the value of attribute tasks.



2
3
4
# File 'lib/salticid/host.rb', line 2

def tasks
  @tasks
end

#user(user = nil) ⇒ Object

Returns the value of attribute user.



2
3
4
# File 'lib/salticid/host.rb', line 2

def user
  @user
end

Instance Method Details

#==(other) ⇒ Object



22
23
24
# File 'lib/salticid/host.rb', line 22

def ==(other)
  self.name == other.name
end

#append(str, file, opts = {}) ⇒ Object

Appends the given string to a file. Pass :uniq => true to only append if the string is not already present in the file.



29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# File 'lib/salticid/host.rb', line 29

def append(str, file, opts = {})
  file = expand_path(file)
  if opts[:uniq] and exists? file
    # Check to ensure the file does not contain the line already.
    begin
      grep(str, file) or raise
    rescue
      # We're clear, go ahead.
      tee '-a', file, :stdin => str
    end
  else
    # No need to check, just append.
    tee '-a', file, :stdin => str
  end
end

#as(user = nil) ⇒ Object

All calls to exec! within this block are prefixed by sudoing to the user.



46
47
48
49
50
# File 'lib/salticid/host.rb', line 46

def as(user = nil)
  old_sudo, @sudo = @sudo, (user || 'root')
  yield
  @sudo = old_sudo
end

#cd(dir = nil) ⇒ Object

Changes our working directory.



53
54
55
56
57
# File 'lib/salticid/host.rb', line 53

def cd(dir = nil)
  dir ||= homedir
  dir = expand_path(dir)
  @cwd = dir
end

#chmod(mode, path) ⇒ Object

Changes the mode of a file. Mode is numeric.



60
61
62
# File 'lib/salticid/host.rb', line 60

def chmod(mode, path)
  exec! "chmod #{mode.to_s(8)} #{escape(expand_path(path))}"
end

#chmod_r(mode, path) ⇒ Object

Changes the mode of a file, recursively. Mode is numeric.



65
66
67
# File 'lib/salticid/host.rb', line 65

def chmod_r(mode, path)
  exec! "chmod -R #{mode.to_s(8)} #{escape(expand_path(path))}"
end

#cwdObject

Returns current working directory. Tries to obtain it from exec ‘pwd’, but falls back to /.



71
72
73
74
75
76
77
78
# File 'lib/salticid/host.rb', line 71

def cwd
  @cwd ||= begin
    exec! 'pwd'
  rescue => e
    raise e
    '/'
  end
end

#dir?(path) ⇒ Boolean

Returns true if a directory exists

Returns:

  • (Boolean)


81
82
83
84
85
86
87
# File 'lib/salticid/host.rb', line 81

def dir?(path)
  begin
    ftype(path) == 'directory'
  rescue
    false
  end
end

#download(remote, local = nil, opts = {}) ⇒ Object

Downloads a file from the remote server. Local defaults to remote filename (in current path) if not specified.



91
92
93
94
95
96
97
98
99
100
101
102
# File 'lib/salticid/host.rb', line 91

def download(remote, local = nil, opts = {})
  remote_filename ||= File.split(remote).last
  if File.directory? local
    local = File.join(local, remote_filename)
  else
    local = remote_filename
  end

  remote = expand_path remote
  log "downloading from #{remote.inspect} to #{local.inspect}"
  ssh.scp.download!(remote, local, opts)
end

#escape(string) ⇒ Object

Quotes a string for inclusion in a bash command line



105
106
107
108
109
# File 'lib/salticid/host.rb', line 105

def escape(string)
  return '' if string.nil?
  return string unless string.to_s =~ /[\\\$`" \(\)\{\}\[\]]/
  '"' + string.to_s.gsub(/[\\\$`"]/) { |match| '\\' + match } + '"'
end

#exec!(command, opts = {}, &block) ⇒ Object

Runs a remote command. If a block is given, it is run in a new thread after stdin is sent. Its sole argument is the SSH channel for this command: you may use send_data to write to the processes stdin, and use ch.eof! to close stdin. ch.close will stop the remote process.

Options:

:stdin => Data piped to the process' stdin.
:stdout => A callback invoked when stdout is received from the process.
           The argument is the data received.
:stderr => Like stdout, but for stderr.
:echo => Prints stdout and stderr using print, if true.
:to => Shell output redirection to file. (like cmd >/foo)
:from => Shell input redirection from file. (like cmd </foo)


124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
# File 'lib/salticid/host.rb', line 124

def exec!(command, opts = {}, &block)
  # Options
  stdout = ''
  stderr = ''
  defaults = {
    :check_exit_status => true
  }
  
  opts = defaults.merge opts

  # First, set up the environment...
  if @env.size > 0
    command = (
      @env.map { |k,v| k.to_s.upcase + '=' + v } << command
    ).join(' ')
  end

  # Before execution, cd to cwd
  command = "cd #{escape(@cwd)}; " + command

  # Input redirection
  if opts[:from]
    command += " <#{escape(opts[:from])}"
  end

  # Output redirection
  if opts[:to]
    command += " >#{escape(opts[:to])}"
  end

  # After command, add a semicolon...
  unless command =~ /;\s*\z/
    command += ';'
  end

  # Then echo the exit status.
  command += ' echo $?; '


  # If applicable, wrap the command in a sudo subshell...
  if @sudo
    command = "sudo -S -u #{@sudo} bash -c #{escape(command)}"
    if @password
      opts[:stdin] = @password + "\n" + opts[:stdin].to_s
    end
  end

  buffer = ''
  echoed = 0
  status = nil
  written = false

  # Run ze command with callbacks.
  # Return status.
  channel = ssh.open_channel do |ch|
    ch.exec command do |ch, success|
      raise "could not execute command" unless success

      # Handle STDOUT
      ch.on_data do |c, data|
        # Could this data be the status code?
        if pos = (data =~ /(\d{1,3})\n\z/)
          # Set status
          status = $1

          # Flush old buffer
          opts[:stdout].call(buffer) if opts[:stdout]
          stdout << buffer

          # Save candidate status code
          buffer = data[pos .. -1]

          # Write the other part of the string to the callback
          opts[:stdout].call(data[0...pos]) if opts[:stdout]
          stdout << data[0...pos]
        else
          # Write buffer + data to callback
          opts[:stdout].call(buffer + data) if opts[:stdout]
          stdout << buffer + data
          buffer = ''
        end
        
        if opts[:echo] and echoed < stdout.length
          stdout[echoed..-1].split("\n")[0..-2].each do |fragment|
            echoed += fragment.length + 1
            log fragment
          end
        end
      end

      # Handle STDERR
      ch.on_extended_data do |c, type, data|
        if type == 1
          # STDERR
          opts[:stderr].call(data) if opts[:stderr]
          stderr << data
          log :stderr, stderr if opts[:echo]
        end
      end
      
      # Write stdin
      if opts[:stdin]
        ch.on_process do
          unless written
            ch.send_data opts[:stdin]
            written = true
          else
            # Okay, we wrote stdin
            unless block or ch.eof?
              ch.eof!
            end
          end
        end
      end

      # Handle close
      ch.on_close do
        if opts[:echo]
          # Echo last of input data
          stdout[echoed..-1].split("\n").each do |fragment|
            echoed += fragment.length + 1
            log fragment
          end
        end
      end
    end
  end
  
  if block
    # Run the callback
    callback_thread = Thread.new do
      if opts[:stdin]
        # Wait for stdin to be written before calling...
        until written
          sleep 0.1
        end
      end

      block.call(channel)
    end
  end

  # Wait for the command to complete.
  channel.wait

  # Let the callback thread finish as well
  callback_thread.join if callback_thread

  if opts[:check_exit_status]
    # Make sure we have our status.
    if status.nil? or status.empty?
      raise "empty status in host#exec() for #{command}, hmmm"
    end

    # Check status.
    status = status.to_i
    if  status != 0
      raise "#{command} exited with non-zero status #{status}!\nSTDERR:\n#{stderr}\nSTDOUT:\n#{stdout}"
    end
  end

  stdout.chomp
end

#exists?(path) ⇒ Boolean

Returns true when a file exists, otherwise false

Returns:

  • (Boolean)


289
290
291
# File 'lib/salticid/host.rb', line 289

def exists?(path)
  true if ftype(path) rescue false
end

#expand_path(path) ⇒ Object

Generates a full path for the given remote path.



294
295
296
297
# File 'lib/salticid/host.rb', line 294

def expand_path(path)
  path = path.to_s.gsub(/~(\w+)?/) { |m| homedir($1) }
  File.expand_path(path, cwd.to_s)
end

#file?(path) ⇒ Boolean

Returns true if a regular file exists.

Returns:

  • (Boolean)


300
301
302
# File 'lib/salticid/host.rb', line 300

def file?(path)
  ftype(path) == 'file' rescue false
end

#ftype(path) ⇒ Object

Returns the filetype, as string. Raises exceptions on failed stat.



305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
# File 'lib/salticid/host.rb', line 305

def ftype(path)
  path = expand_path(path)
  begin
    str = self.stat('-c', '%F', path).strip
    case str
    when /no such file or directory/i
      raise Errno::ENOENT, "#{self}:#{path} does not exist"
    when 'regular file'
      'file'
    when 'regular empty file'
      'file'
    when 'directory'
      'directory'
    when 'character special file'
      'characterSpecial'
    when 'block special file'
      'blockSpecial'
    when /link/
      'link'
    when /socket/
      'socket'
    when /fifo|pipe/
      'fifo'
    else
      raise RuntimeError, "unknown filetype #{str}"
    end
  rescue
    raise RuntimeError, "stat #{self}:#{path} failed - #{str}"
  end
end

#group(name) ⇒ Object

Adds this host to a group.



344
345
346
347
348
349
350
# File 'lib/salticid/host.rb', line 344

def group(name)
  group = name if name.kind_of? Salticid::Group
  group ||= @salticid.group name
  group.hosts |= [self]
  @groups |= [group]
  group
end

#group?(name) ⇒ Boolean

Abusing convention slightly… Returns the group by name if this host belongs to it, otherwise false.

Returns:

  • (Boolean)


338
339
340
341
# File 'lib/salticid/host.rb', line 338

def group?(name)
  name = name.to_s
  @groups.find{ |g| g.name == name } || false
end

#gw(gw = nil) ⇒ Object

Returns the gateway for this host.



353
354
355
356
357
358
359
# File 'lib/salticid/host.rb', line 353

def gw(gw = nil)
  if gw
    @gw = @salticid.host(gw)
  else
    @gw
  end
end

#homedir(user = (@sudo||@user)) ⇒ Object

Returns the home directory of the given user, or the current user if none specified.



363
364
365
# File 'lib/salticid/host.rb', line 363

def homedir(user = (@sudo||@user))
  exec! "awk -F: -v v=#{escape(user)} '{if ($1==v) print $6}' /etc/passwd"
end

#inspectObject



367
368
369
# File 'lib/salticid/host.rb', line 367

def inspect
  "#<#{@user}@#{@name} roles=#{@roles.inspect} tasks=#{@tasks.inspect}>"
end

#log(*args) ⇒ Object

Issues a logging statement to this host’s log. log :error, “message” log “message” is the same as log “info”, “message”



374
375
376
377
378
379
380
# File 'lib/salticid/host.rb', line 374

def log(*args)
  begin
    @on_log.call Message.new(*args)
  rescue
    # If the log handler is broken, keep going.
  end
end

#mode(path) ⇒ Object

Returns the file mode of a remote file.



407
408
409
# File 'lib/salticid/host.rb', line 407

def mode(path)
  stat('-c', '%a', path).oct
end

#on_log(&block) ⇒ Object



420
421
422
# File 'lib/salticid/host.rb', line 420

def on_log(&block)
  @on_log = block
end

#resolve_task(name) ⇒ Object

Finds a task for this host, by name.



425
426
427
428
429
430
431
432
433
434
# File 'lib/salticid/host.rb', line 425

def resolve_task(name)
  name = name.to_s
  @tasks.each do |task|
    return task if task.name == name
  end
  @salticid.tasks.each do |task|
    return task if task.name == name
  end
  nil
end

#role(role) ⇒ Object

Assigns roles to a host from the Salticid. Roles are unique in hosts; repeat assignments will not result in more than one copy of the role.



438
439
440
# File 'lib/salticid/host.rb', line 438

def role(role)
  @roles = @roles | [@salticid.role(role)]
end

#role?(role) ⇒ Boolean

Does this host have the given role?

Returns:

  • (Boolean)


443
444
445
# File 'lib/salticid/host.rb', line 443

def role?(role)
  @roles.any? { |r| r.name == role.to_s }
end

#role_proxy(name) ⇒ Object

Returns a role proxy for role on this host, if we have the role.



448
449
450
451
452
# File 'lib/salticid/host.rb', line 448

def role_proxy(name)
  if role = roles.find { |r| r.name == name.to_s }
    @role_proxies[name.to_s] ||= RoleProxy.new(self, role)
  end
end

#run(role, task, *args) ⇒ Object

Runs the specified task on the given role. Raises NoMethodError if either the role or task do not exist.



456
457
458
459
460
461
462
# File 'lib/salticid/host.rb', line 456

def run(role, task, *args)
  if rp = role_proxy(role)
    rp.__send__(task, *args)
  else
    raise NoMethodError, "No such role #{role.inspect} on #{self}"
  end
end

#sshObject

Opens an SSH connection and stores the connection in @ssh.



465
466
467
468
469
470
471
472
473
474
475
476
477
# File 'lib/salticid/host.rb', line 465

def ssh
  @ssh_lock.synchronize do
    if @ssh and not @ssh.closed?
      return @ssh
    end

    if tunnel
      @ssh = tunnel.ssh(name, user)
    else
      @ssh = Net::SSH.start(name, user)
    end
  end
end

#sudo(*args, &block) ⇒ Object

If a block is given, works like #as. Otherwise, just execs sudo with the given arguments.



481
482
483
484
485
486
487
# File 'lib/salticid/host.rb', line 481

def sudo(*args, &block)
  if block_given?
    as *args, &block
  else
    method_missing(:sudo, *args)
  end
end

#sudo_upload(local, remote, opts = {}) ⇒ Object

Uploads a file and places it in the final destination as root. If the file already exists, its ownership and mode are used for the replacement. Otherwise it inherits ownership from the parent directory.



492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
# File 'lib/salticid/host.rb', line 492

def sudo_upload(local, remote, opts={})
  remote = expand_path remote

  # TODO: umask this?
  local_mode = File.stat(local).mode & 07777
  File.chmod 0600, local


  # Get temporary filename
  tmpfile = '/'
  while exists? tmpfile
    tmpfile = '/tmp/sudo_upload_' + Time.now.to_f.to_s
  end

  # Upload
  upload local, tmpfile, opts

  # Get remote mode/user/group
  sudo do
    if exists? remote
      mode = self.mode remote
      user = stat('-c', '%U', remote).strip
      group = stat('-c', '%G', remote).strip
    else
      user = stat('-c', '%U', File.dirname(remote)).strip
      group = stat('-c', '%G', File.dirname(remote)).strip
      mode = local_mode
    end

    # Move and chmod
    mv tmpfile, remote
    chmod mode, remote
    chown "#{user}:#{group}", remote
  end
end

#task(name, &block) ⇒ Object

Finds (and optionally defines) a task.

Tasks are first resolved in the host’s task list, then in the Salticid’s task list. Finally, tasks are created from scratch. Any invocation of task adds that task to this host.

If a block is given, the block is assigned to the local (host) task. The task is dup’ed to prevent modifying a possible global task.

The task is returned at the end of the method.



538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
# File 'lib/salticid/host.rb', line 538

def task(name, &block)
  name = name.to_s

  if task = @tasks.find{|t| t.name == name}
    # Found in self
  elsif (task = @salticid.tasks.find{|t| t.name == name}) and not block_given?
    # Found in salticid
    @tasks << task
  else
    # Create new task in self
    task = Salticid::Task.new(name, :salticid => @salticid)
    @tasks << task
  end

  if block_given?
    # Remove the task from our list, and replace it with a copy.
    # This is to prevent local declarations from clobbering global tasks.
    i = @tasks.index(task) || @task.size
    task = task.dup
    task.block = block
    @tasks[i] = task
  end

  task
end

#to_sObject



564
565
566
# File 'lib/salticid/host.rb', line 564

def to_s
  @name.to_s
end

#to_stringObject



568
569
570
571
572
573
574
575
576
# File 'lib/salticid/host.rb', line 568

def to_string
  h = "Host #{@name}:\n"
  h << "  Groups: #{groups.map(&:to_s).sort.join(', ')}\n" 
  h << "  Roles: #{roles.map(&:to_s).sort.join(', ')}\n" 
  h << "  Tasks:\n"
  tasks = self.tasks.map(&:to_s)
  tasks += roles.map { |r| r.tasks.map { |t| "    #{r}.#{t}" }}
  h << tasks.flatten!.sort!.join("\n")
end

#tunnelObject

Returns an SSH::Gateway object for connecting to this host, or nil if no gateway is needed.



580
581
582
583
584
585
# File 'lib/salticid/host.rb', line 580

def tunnel
  if gw
    # We have a gateway host.
    @tunnel ||= gw.gateway_tunnel
  end
end

#upload(local, remote = nil, opts = {}) ⇒ Object

Upload a file to the server. Remote defaults to local’s filename (without path) if not specified.



589
590
591
592
593
# File 'lib/salticid/host.rb', line 589

def upload(local, remote = nil, opts={})
  remote ||= File.split(local).last
  remote = expand_path remote
  ssh.scp.upload!(local, remote, opts)
end