Serverspecで使うWinRMクライアントをInvoke-commandに置き換えてみた

# はじめに

WindowsでServerspecを使ってみました。

# バージョン

記事作成で用いたOS・モジュールのバージョンはこちらです。

- Windows 7 Pro SP1 32bit 日本語版
- ruby 2.2.3p173 (2015-08-18 revision 51636) [i386-mingw32]
- rspec (3.4.0)
- rspec-core (3.4.1)
- rspec-its (1.2.0)
- serverspec (2.26.0)
- specinfra (2.47.0)

※rubyスクリプトは全て文字コードを「UTF-8」で保存しています。
※バッチファイルは「Shift-JIS」で保存しています。

# まずはirbからWinRMを使ってみる

ドメインアカウントは接続エラーになるので、ローカルアカウントで接続してみます。


```irb
PS C:\serverspec\sample> irb
irb(main):001:0> require 'winrm'
=> true
irb(main):002:0> require "kconv"
=> true
irb(main):003:0> endpoint = "http://remotehost:5985/wsman"
=> "http://remotehost:5985/wsman"
irb(main):004:0> objWinRm = WinRM::WinRMWebService.new(endpoint, :plaintext, :user=>"localuser1", :pass=>"password", :basic_auth_only=>true, :disable_sspi=>true)
=> #<WinRM::WinRMWebService:0x233d780 （省略）>
irb(main):005:0> objWinRm.locale("ja-JP")
=> "ja-JP"
irb(main):006:0>
```

日本語を含むファイルを表示してみます。

```irb
irb(main):007:0* status   = objWinRm.powershell("type C:\\serverspec\\test.cmd") do |stdout, stderr|
irb(main):008:1*   print Kconv.tosjis( stdout )     if stdout
irb(main):009:1>   print Kconv.tosjis( stderr )     if stderr
irb(main):010:1> end
echo あいうえお
exit 0
=> {:data=>[{:stdout=>"echo \u3042\u3044\u3046\u3048\u304A"}, {:stdout=>"\r\n"}, {:stdout=>"exit 0\r\n"}], :exitcode=>0}

```

問題なく日本語が表示されました。
次にバッチファイルを実行してみます。

```irb
irb(main):011:0> status   = objWinRm.powershell("C:\\serverspec\\test.cmd") do |stdout, stderr|
irb(main):012:1*   print Kconv.tosjis( stdout )     if stdout
irb(main):013:1>   print Kconv.tosjis( stderr )     if stderr
irb(main):014:1> end
C:\Users\localuser1>echo 鐃緒申鐃緒申鐃緒申鐃緒申鐃緒申鐃緒申鐃緒申鐃緒申鐃緒申鐃緒申
C:\Users\localuser1>exit 0
=> {:data=>[{:stdout=>"\r\n"}, {:stdout=>"C:\\Users\\localuser1>echo \uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\r\n\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\r\n"}, {:stdout=>"\r\nC:\\Users\\localuser1>exit 0 \r\n"}], :exitcode=>0}
irb(main):015:0>
```

英語ロケールで実行されてしまうのか国際化に対応していないコマンドを実行すると文字化けが起こるようです。

# Invoke-CommandでWinRMクライアントを作る

winrmバックエンドからPowershellを実行するためクラスを作成します。

```ruby:C：\serverspec\sample\lib\CustomWinRM.rb
require 'rubygems'
require 'open3'
require 'kconv'
class CustomWinRm
  @my_name = 'CustomWinRm'

  def initialize(args = {})
    @user_name = args['user']      if args.key?('user')
    @user_pass = args['pass']      if args.key?('pass')
    @host_name = args['host']      if args.key?('host')
    @winrm_client = args['winrm']  if args.key?('winrm')
    @run_mode = args['mode']       if args.key?('mode')
  end

  def run_command(command)
    retcode = -1
    exec_cmd =  "#{@winrm_client} -h #{@host_name} -u #{@user_name} -p #{@user_pass}"
    if @run_mode == "ps1"
      exec_cmd << " -es \"#{command}\""
    else
      command = "#{command}".gsub("\\\"","\"").gsub("\"","\\\"")
      exec_cmd << " -cmd \"#{command}\""
    end
    exec_cmd = "powershell -NoProfile -command \"#{exec_cmd}\"; exit $LASTEXITCODE"  if ".ps1" == File.extname("#{@winrm_client}")
    begin
      stdout, stderr, status  = Open3.capture3(exec_cmd)
      retcode = status.exitstatus  if ! status.nil?
      retcode = -1                 if retcode.nil? || "#{retcode}" == ''
    rescue Exception => excptn
      puts Kconv.tosjis( "EXCEPTION : #{excptn.message}" )
      retcode = -1
      stdout = ""
      stderr = excptn.message
    end
    return {:exitcode => retcode, :stdout => stdout, :stderr => stderr}
  end

  def run_cmd(command)
    @run_mode = "cmd"
    return run_command(command)
  end
  alias :cmd :run_cmd

  def run_powershell_script(command)
    @run_mode = "ps1"
    return run_command(Base64.strict_encode64("#{command}".encode('UTF-16LE')))
  end
  alias :powershell :run_powershell_script

end
```

WinRMクライアント本体となるPowershellスクリプトを作成します。

```powershell:C：\serverspec\sample\lib\WinRm.ps1
param (
  [string]$h = "${ENV:COMPUTERNAME}",
  [string]$u = "${ENV:COMPUTERNAME}\Administrator",
  [string]$p = "ここにパスワード",
  [string]$c = "hostname",
  [string]$cmd,
  [string]$es
)
set-variable -name RET_SUCCESS -value  0 -option constant
set-variable -name RET_EXCPTN  -value -1 -option constant
$ErrorActionPreference = "stop"
$Error.Clear()
$retcd = $RET_SUCCESS
$cmdstatus = ""
if (![String]::IsNullOrEmpty($cmd)) {$c = "powershell -NoProfile -command `"${cmd}; exit $LASTEXITCODE`""}
if (![String]::IsNullOrEmpty($es))  {$c = "powershell -NoProfile -encodedCommand ${es}"}
try {
  $password = ConvertTo-SecureString $p -asplaintext -force
  $cred = New-Object System.Management.Automation.PsCredential $u, $password
  $session = New-PSSession -ComputerName $h -credential $cred
  Invoke-Command -Session $session -ScriptBlock {cmd /c $args[0]} -args $c
  $cmdstatus = Invoke-Command -Session $session -ScriptBlock {$LASTEXITCODE}
  $retcd = [int]$cmdstatus
} catch [Exception] {
  $retcd = $RET_EXCPTN
  Write-Output $_.Exception.Message
} finally {
  if ( $NULL -ne $session ) {Remove-PSSession -Session $session}
}
Write-Output ""
exit $retcd
```

irbから実行してみます。

```irb
PS C:\serverspec\sample> irb
irb(main):001:0> $LOAD_PATH << "C:\\serverspec\\sample\\lib"
=> [（省略）, "C:\\serverspec\\sample\\lib"]
irb(main):002:0> require 'CustomWinRm'
=> true
irb(main):003:0> winrm = CustomWinRm.new({"user" => "mydomain\\domainuser1", "pass" => "password", "host" => "remotehost", "winrm" => ".\\lib\\WinRm.ps1"})
=> #<CustomWinRm:0x25b5a30 @user_name="mydomain\\domainuser1", @user_pass="パスワード", @host_name="remotehost", @winrm_client=".\\lib\\WinRm.ps1">
irb(main):004:0> winrm.run_command("C:\\serverspec\\test.cmd")
=> {:exitcode=>0, :stdout=>"\nC:\\Users\\domainuser1\\Documents>echo あいうえお \nあいうえお\n\nC:\\Users\\domainuser1\\Documents>exit 0 \n\n", :stderr=>""}
irb(main):005:0>
```

今度は文字化けせずにすみました。またドメインアカウントで接続することができました。

# Serverspecで実行してみる

SpecInfraをちょっとだけ修正します。

```ruby:lib\specinfra\backend\winrm.rb
15,16c15,21
<         stdout, stderr = [:stdout, :stderr].map do |s|
<           result[:data].select {|item| item.key? s}.map {|item| item[s]}.join
---
>         if Specinfra.configuration.customwinrm.nil? || ! Specinfra.configuration.customwinrm
>           stdout, stderr = [:stdout, :stderr].map do |s|
>             result[:data].select {|item| item.key? s}.map {|item| item[s]}.join
>           end
>         else
>           stdout = result[:stdout]
>           stderr = result[:stderr]
```

spec_helper.rbでデフォルトのWinRMを今回作成したものへ置き換えます。

```ruby:C：\serverspec\sample\spec\spec_helper.rb
require 'serverspec'
require 'yaml'
require 'CustomWinRm'
params = {}
params['hostname'] = ENV['TARGET_HOST']
params['run_env'] = "development"
puts ""
puts "##################################################"
puts "HOST: #{params['hostname']} : #{params['run_env']}"
puts "##################################################"
properties = YAML.load_file("env/properties.#{params['run_env']}.yml")
set_property = properties
set :backend, :winrm
set :os, :family => 'windows'
Specinfra.configuration.customwinrm = true
winrm = CustomWinRm.new({"user" => "#{properties[:winrm_user]}", "pass" => "#{properties[:winrm_pass]}", "host" => "#{ENV['TARGET_HOST']}", "winrm" => ".\\lib\\WinRm.ps1"})
Specinfra.configuration.winrm = winrm
```

テストケースでは「標準出力内容の確認」と「ファイル内容の確認」を試してみます。

```ruby:C：\serverspec\sample\spec\sample\sample_spec.rb
require 'spec_helper'
require 'CustomHelper'

describe command ("ipconfig /all | Select-String #{'ノード'.encode('Windows-31J')}") do
  its(:stdout) { should match /#{"ハイブリッド".encode("Windows-31J")}/}
end

describe file("C:\\serverspec\\test.cmd") do
  it { should be_file }
  it { should contain "#{'あいうえお'.encode('Windows-31J')}" }
  it { should_not contain "#{'かきくけこ'.encode('Windows-31J')}" }
  its(:content) { should match /#{"あいうえお".encode("Windows-31J")}/ }
  its(:content) { should_not match /#{"かきくけこ".encode("Windows-31J")}/ }
end
```

その他、serverspecの実行に必要なファイルを用意します。

```ruby:C：\serverspec\sample\Rakefile
require 'rake'
require 'rspec/core/rake_task'
require 'yaml'
servers		= YAML.load_file("env/servers.yml")
task :spec    => 'spec:all'
task :default => :spec
namespace :spec do
  task :all => servers.keys.map {|key| 'spec:' + key }
  servers.keys.each do |key|
    desc "Run serverspec to #{key}"
    RSpec::Core::RakeTask.new(key.to_sym) do |t|
      ENV['TARGET_HOST']	= key
      role_pattern		= servers[key][:roles].join(',')
      t.pattern = "spec/{" + role_pattern + "}/*_spec.rb"
      t.fail_on_error = false
    end
  end
end
```

```yaml:C：\serverspec\sample\env\servers.yml
REMOTEHOST:
 :roles:
  - sample
```

```yaml:C：\serverspec\sample\env\properties.development.yml
:winrm_user: localuser1
:winrm_pass: password
```

実行してみます。

```ruby
PS C:\serverspec\sample> rake

##################################################
HOST: REMOTEHOST : development
##################################################

Command "ipconfig /all | Select-String ノード"
  stdout
    should match /ハイブリッド/

File "C:\serverspec\test.cmd"
  should be file
  should contain "あいうえお"
  should not contain "かきくけこ"
  content
    should match /あいうえお/
  content
    should not match /かきくけこ/

Finished in 16.65 seconds (files took 1.3 seconds to load)
6 examples, 0 failures

PS C:\serverspec\sample>
```

うまくいきました。

但し、デフォルトのWinRMを使うよりもかなり遅いです。
672ケース（168ケースx4サーバー）のテスト所要時間は次の通りです。
- デフォルトのWinRM：427秒
- Invoke-Command：541秒

# さらに便利に使うためには

以下の記事に処理速度向上についてまとめましたので、ぜひ参照下さい。

* [ServerspecでWinRMクライアントをC#で作成したコマンドに置き換えてみた](http://qiita.com/drafts/627aa60b57171f67d295/edit)