STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

インタラクティブなコマンドラインも自動化できるって知ってた?

こんにちは、tomorrowkeyです。

普段はAndroidアプリを書いていますが、100% Androidアプリ開発というわけでもないので、今回の記事ではAndroidではない話題を書いてみようかなと思います。

開発の現場ではたくさんのワークフローを実行しないといけない場面があり、それらをいつまでも放っておくとやらないといけないことが積み上がってしまい、本質的な開発に集中できません。

そういった単純な繰り返し実行を行うようであれば自動化をしたくなるのがソフトウェアエンジニアではないかと思います。単純にコマンドラインであればシェルスクリプトを書くことで自動化のワークフローを組むことができます。

もし自動化したいコマンドラインが実行中にパスワードの入力などインタラクティブな入力を求めるようなものではどうでしょうか。そのときにどのような手段をとれるでしょうか。

expect が使えるならそれが楽

Unix/Linux系の環境では、expect というツールが有名で、多くのユースケースで活用できます。 しかし、今回私が遭遇したシチュエーションでは、Windows環境で実行が必要なコマンドラインなうえに、そのコマンドがWSLに対応していないという状況だったため、 expect を使うことはできませんでした。

そこでRubyを使って expect 相当の実装をすることで課題に取り組みました。

基本的な考え方

インタラクティブなコマンドラインといっても、分解してみれば標準出力と標準入力のやり取りです。つまり

  • プログラムが標準出力にメッセージを出力
  • ユーザーが標準入力で応答
  • この繰り返し

この仕組みが理解できれば、外部プロセスの標準入出力を扱えるプログラミング言語なら何でも自動化が可能です。

RubyのOpen3.popen3を使った実装

RubyのOpen3ライブラリを使うと、外部プロセスの標準入力、標準出力、標準エラー出力を個別に制御できます。

基本的な使い方

1行だけの標準出力があったうえで、標準入力が求められるコマンドラインを扱うなら、このような実装で自動化ができます。

require 'open3'

Open3.popen3('interactive_command') do |stdin, stdout, stderr, wait_thr|
  # 標準出力から出力を読み取り
  output = stdout.gets
  puts "プログラムからの出力: #{output}"
  
  # 標準入力に応答を書き込み
  stdin.puts "yes"
  
  # プロセスの終了を待つ
  exit_status = wait_thr.value
end

すごく単純ですが expect の原始的な実装ができました。

より実践的な実装例

expect コマンドのエッセンスを取り入れ、より実践的な実装に近づけるならば、標準入力の直前の標準出力を待つように実装するとよいです。

require 'open3'

def automate_password_prompt(command, password)
  Open3.popen3(command) do |stdin, stdout, stderr, wait_thr|
    # パスワードプロンプトが表示されるまで待機
    while line = stdout.gets
      puts line
      if line.include?('Password:')
        # パスワードを送信
        stdin.puts password
        break
      end
    end
    
    # 残りの出力を表示
    while line = stdout.gets
      puts line
    end
    
    wait_thr.value
  end
end

より複雑な対話の自動化

例えば複数の入力が必要なケースではどうでしょうか。 標準入力する直前のキーワードと、そのときに入力したい内容をペアで設定できるようにするととても便利です。 ここまでくると expect と遜色ないくらいの機能を持つ実装になってきました。

require 'open3'

class InteractiveAutomator
  def initialize(command)
    @command = command
    @responses = {}
  end
  
  def add_response(prompt_pattern, response)
    @responses[prompt_pattern] = response
  end
  
  def execute
    Open3.popen3(@command) do |stdin, stdout, stderr, wait_thr|
      while wait_thr.alive?
        # 標準出力から1行読み込む
        line = stdout.gets
        next unless line
        
        puts line
        
        # 定義されたパターンにマッチするか確認
        @responses.each do |pattern, response|
          if line.match(pattern)
            # 対応する入力を送信する
            stdin.puts response
            break
          end
        end
      end
      
      wait_thr.value
    end
  end
end

# 使用例
automator = InteractiveAutomator.new('deployment-tool')
automator.add_response(/Enter environment/, 'production')
automator.add_response(/Confirm deployment/, 'yes')
automator.add_response(/Enter API key/, ENV['API_KEY'])
automator.execute

実践するうえでの注意点

エラー出力

メインスレッドで標準出力、標準入力を扱っていると標準エラー出力が無視されてしまいます。 標準エラー出力は実行ログに出力させるだけであれば別スレッドで継続してパススルー出力させておくとよいです。

Thread.new do
  while !stderr.eof?
    line = stderr.gets
    $stderr.puts line
  end
end

秘匿値の管理

例でも何度か触れているとおり、こういったインタラクティブな入力でよくあるのが、なにかしらのパスワードの入力です。 コード上にハードコーディングしてしまうと、それをコミットしてGitHubにアップロードしてしまう事故に繋がる危険性があります。 せめて環境変数に逃がして、コードからはそれを使うようにすると安心して管理できます。

まとめ

expectが使えない環境でも、RubyのOpen3を使えばインタラクティブなコマンドラインの自動化が可能です。 Rubyの実行環境があればどのプラットフォームでも動くことや、複雑なパターンにも対応が容易であることが非常に魅力的なソリューションです。 私が実際に使用したのは手元にあるWindows上での実行ですが、例えばCIで自動化させることを考えると、人間の手を離れたところで自動化させることができるのでワクワクしてきますよね。

もしインタラクティブなコマンドラインの自動化で困ったときは、ぜひこのアプローチも検討してみてください。