Ansible Playbookのexpectハマリポイント

シェル実行時にサーバーから入力待ち状態のコマンドプロンプトが発生した場合に、自動で応答できるようにするAnsibleの expect – Executes a command and responds to prompts がある。

いくつかハマリポイントがあるため、そのメモ。

環境

  • Macを使ってVagrant x Ansibleで構築
  • CentOS 8
  • Vagrant 2.2.6
  • ansible-playbook 2.9.3

pexpectのインストール

まずpipが必要になる。いろいろな記事で $ pip install pexpect と書かれていたが、サーバーにログインしてpipを実行すると、コマンドが見つからないと言われてしまう。

$ vagrant ssh
$ pip install pexpect
-bash: pip: command not found

pipはPythonのバージョンによって、pip2とpip3があるらしい。どれがインストールされているか確認すると、python3-pipがあったため、pip3を利用する。

$ yum list installed | grep pip
Failed to set locale, defaulting to C
libpipeline.x86_64                  1.5.0-2.el8                                     @anaconda
platform-python-pip.noarch          9.0.3-15.el8                                    @BaseOS
python3-pip.noarch                  9.0.3-15.el8                                    @AppStream

$ pip3 --version
pip 9.0.3 from /usr/lib/python3.6/site-packages (python 3.6)

$ pip3 install pexpect でインストールすればよいことがわかったため、pexpectをAnsibleのタスクでインストールする。

- name: Install pexpect for prompts
  become: yes
  shell: "pip3 install pexpect"

pexpectのタスク実行

書き方は正規表現

Ansibleのドキュメント expect には、以下のようなタスクの記載例がある。

- name: Case insensitive password string match
  expect:
    command: passwd username
    responses:
      (?i)password: "MySekretPa$$word"
  # you don't want to show passwords in your logs
  no_log: true

- name: Generic question with multiple different responses
  expect:
    command: /path/to/custom/command
    responses:
      Question:
        - response1
        - response2
        - response3

連続で質問に答える場合は、「Question」を利用し、一問一答であればそのままYML形式で書けばよい。このときに、質問はPythonの正規表現形式で記載する必要があるため、カッコやはてなマークなどの記号に注意する必要がある。

たとえばNG例として、以下のような質問をされた場合(Gemfileを上書きしますか?の質問)、

Overwrite /home/vagrant/sample-rails/Gemfile? (enter "h" for help) [Ynaqdhm]

そのまま質問をタスクに記述すると、以下のような記載になる。

[NG記載例]

- name: Rails new
  expect:
    command: "bundle exec rails new ."
    responses:
      Overwrite /home/vagrant/sample-rails/Gemfile? (enter "h" for help) [Ynaqdhm]: "Y"

この状態だと、正規表現が適用されてしまうため、うまく動かない。そこで、質問されたときの重要なキーワードだけ残して、それ以外は「(.*)」にすると、うまく動いた。

[正しい記載例]

- name: Rails new
  expect:
    command: "bundle exec rails new ."
    responses:
      (.*)Overwrite (.*)Gemfile(.*): "Y"

質問の引用符の有無

GitHubで「become yes expect rsponses」などのキーワードでソースコード検索すると、質問をダブルクォーテーションなどの引用符で囲むケースもあった。しかし、自分の場合は囲っても囲わなくても、結果は変わらなかった。ただし、挙動が変わったという記事も見かけたため、要注意。

なお、Ansibleの公式ドキュメントは引用符を囲まない形式で記載されている。

[Ansibleの公式ドキュメント]

- name: Case insensitive password string match
  expect:
    command: passwd username
    responses:
      (?i)password: "MySekretPa$$word"

[引用符で囲む例(正しい書き方なのかは不明)]

- name: Case insensitive password string match
  expect:
    command: passwd username
    responses:
      "(?i)password": "MySekretPa$$word"

タイムアウト

質問が発生するタスクの実行自体に時間がかかる場合、ちゃんと回答したとしても(たとえば、なにか大量にインストールするなどで時間がかかった場合など)、デフォルトのタイムアウト設定30秒を過ぎると途中でタイムアウトされてしまう。

たとえば、 $ rails new . を実行して、Gemfileに記載されたGemをすべてインストールするタスクを実行した場合、以下のように途中でタイムアウトになってしまう。

fatal: [192.168.33.10]: FAILED! => {
    "changed": true,
    "cmd": "bundle exec rails new .",
    "delta": "0:00:30.144455",
    "end": "2020-02-22 17:10:17.228032",
    "invocation": {
        "module_args": {
            "chdir": "/home/vagrant/sample-rails/",
            "command": "bundle exec rails new .",
            "creates": null,
            "echo": false,
            "removes": null,
            "responses": {
                "Overwrite {{ sample_rails_path }}Gemfile? (enter \"h\" for help) [Ynaqdhm]": "Y"
            },
            "timeout": 30
        }
    },
    "msg": "command exceeded timeout",
    "rc": null,
    "start": "2020-02-22 17:09:47.083577",
    "stdout": "\u001b[1m\u001b[34m       exist\u001b[0m  \r\n\u001b[1m\u001b[32m      create\u001b[0m  README.md\r\n\u001b[1m\u001b[32m      create\u001b[0m  Rakefile\r\n\u001b[1m\u001b[32m      create\u001b[0m  .ruby-version\r\n\u001b[1m\u001b[32m      create\u001b[0m  config.ru\r\n\u001b[1m\u001b[32m      create\u001b[0m  .gitignore\r\n\u001b[1m\u001b[31m    conflict\u001b[0m  Gemfile\r\nOverwrite /home/vagrant/sample-rails/Gemfile? (enter \"h\" for help) [Ynaqdhm] ",
    "stdout_lines": [
        "\u001b[1m\u001b[34m       exist\u001b[0m  ",
        "\u001b[1m\u001b[32m      create\u001b[0m  README.md",
        "\u001b[1m\u001b[32m      create\u001b[0m  Rakefile",
        "\u001b[1m\u001b[32m      create\u001b[0m  .ruby-version",
        "\u001b[1m\u001b[32m      create\u001b[0m  config.ru",
        "\u001b[1m\u001b[32m      create\u001b[0m  .gitignore",
        "\u001b[1m\u001b[31m    conflict\u001b[0m  Gemfile",
        "Overwrite /home/vagrant/sample-rails/Gemfile? (enter \"h\" for help) [Ynaqdhm] "
    ]
}

サーバーにログインして、rails newして時間を測ったところ、だいたい7分ほど時間がかかることがわかった。
そこで、タイムアウト設定(timeout)を20分(1,200秒)に変更した。

[Tasks]

- name: Rails new
  expect:
    command: "bundle exec rails new ."
    responses:
      (.*)Overwrite (.*)Gemfile(.*): "Y"
    timeout: 1200

そうすると、タイムアウトせずに完了できた。

なお、タイムアウト設定を外したい場合は、 timeout: null と記載する。
ただし、コマンドプロンプトの入力待ち状態で止まってしまうと、インストールに時間がかかっているのか、入力待ち状態が延々続いていのか判別できなくなってしまうため、要注意。サーバーが入力待ちで止まっているのかかどうかは、デバッグモードの $ ansible-playbook -i hosts site.yml -vvv でも確認ができない。