2014年12月18日木曜日

rubyのexpectモジュールを使って任意のコマンドを発行



目的
複数台のサーバやネットワーク機器にログインし、
任意のコマンドを発行する。
その出力結果をノード単位にファイルに保存する。



実現手段
rubyのexpectモジュールを利用する。

expectモジュールの使い方自体はすぐにわかると思うが、
仮想端末(pty)のmaster、slave周りの動作を
頭にイメージしながらコードを読み書きすると
ぐっとおもしろさが増すだろう。
理解できないと使えないわけでは決してないが、
動きを把握しきれていないものを使っても楽しくないだろう。
そのため以下のリンクを一読することを強く強くオススメする。



コードの実行例
コードを示す前にツールの使い方とその実行結果を見てもらう。

対象のノードをファイルに記載する。
sshもしくは、telnetでログインできることが前提である。
$ vi auto.txt
node1
node2
node3


ノードを羅列したファイル名を引数に実行する。
ノードへのログイン名とパスワード、
そして発行するコマンドを打っていく。
最後はエンタだけでよい。
$ ./auto.rb auto.txt
login name: 
login password:  
command : ls -l
command : pwd
command : 


コマンドの実行結果がノードごとのファイルに保存される。
$ ls /var/tmp/output_*
/var/tmp/output_node1
/var/tmp/output_node2
/var/tmp/output_node3



コード
#!/usr/bin/ruby
require 'pty'
require 'expect'
require 'timeout'

$expect_verbose = true


class Auto

def initialize(file)
  @file = file
  @cmds = Array.new

  @timeout= 10

  @write_file_prefix = "/var/tmp/write" # + '_' + node

  @prompt_password = "[Pp]assword: "
  @prompt_answer = "yes/no"
  @prompt_crlf = "\\r|\\n"
  @prompt_expect = ">|#|\\$"
end


def get_cmds()
  print "login name : "
  @name = gets
  @name.chomp!

  print "login password : "
  @password = gets
  @password.chomp!

  @cmd_login = "/usr/bin/telnet -l #{@name}"
  #@cmd_login = "/usr/bin/ssh -l #{@name}"

  loop do
    print "command : "
    cmd = gets
    if cmd == "\n"
      @cmds << cmd
      break
    else
      @cmds << cmd
     end
   end
end


def read_nodes()
  open(@file) do | fp |
    while host = fp.gets
      next if host =~ /^[\s\t]*\n/
      @host = host.chomp!

      spawn()
    end
  end
end


def spawn()
  PTY.spawn("#{@cmd_login} #{@host}") do | read, write |
  @read = read
  @write = write
  #@write.sync = true

  login()
  execute()
  end
end


def login()
  loop do
    @read.expect(/(#{@prompt_password}|#{@prompt_answer})/, @timeout) do | match |
      case match[0]
      when /#{@prompt_password}/
      @write.puts @password
      return
      when /#{@prompt_answer}/
        @write.puts "yes"
      end
    end
  end
end


def execute()
  cmds = @cmds.dup

  wfp = open("#{@write_file_prefix}_#{@host}", "w")

  still_login = true
  while cmds.length != 0
    @read.expect(/(#{@prompt_crlf}|#{@prompt_expect})/, @timeout) do | match |
      if match[0] =~ /#{@prompt_expect}/
        cmd = cmds.shift
       @write.puts cmd
       still_login = false
      else
        wfp.print match[0] unless still_login == true
      end
    end
  end
  wfp.close
end

end


auto = Auto.new(ARGV.shift)
auto.get_cmds()
auto.read_nodes()

(補足1)
コマンドを順次実行するexecute()メソッド内で、
still_loginというフラグを使っている。

ログインすると、以下のような応答が返ることがある。
Last login: YYYYMMDD from node1

コマンドの実行記録と、その結果だけをファイルに保存したかったため、
コマンドを発行するまでの応答は無視するための措置として導入した。


(補足2)
コマンドを発行する条件を下としている。
>
#
$
@prompt_expect変数内で指定している部分である。

ただし、プロンプトが返る前に、応答出力に上記が含まれると
そこでコマンドが発行されてしまうため意図しない動作をすることがあるかもしれない。
誤認識させないように何かしらの手を加えてもいいポイントだろう。


(補足3)
実行するコマンドを読み込む処理で、
コマンドの終わりを示すためにエンタを押すだけの部分も
それをコマンドとして読み込ませている。

if cmd == "\n"
@cmds << cmd
break
else
@cmds << cmd
end

この理由だが、spawnした処理はexpect関数を
呼び出さなくなった段階では終了を迎える。
コマンドの実行結果を待ちたいので、エンタだけのコマンドを追加し、
expect処理を走らせているわけである。

@input.expect(/(#{@prompt_crlf}|#{@prompt_expect})/, @timeout) ~の部分である。

説明が難しいが、実際に実行させて挙動を見れば一目だろう。


(参考)
expectを利用してscpを自動化