Ansibleのexpectモジュールでハマった話

# 経緯

導入したいミドルウェアで以下の制約事項と要件があったため、Ansibleの`expectモジュールで`解決しようとしました。

* ミドルウェアのインストールに専用の対話式インストーラの実行が必要
    * **サイレント実行のオプションは存在しない**
* インストール処理及びインストーラとコンフィグの配置は `Ansible` でやりたい

# 環境について

* OS
    * Amazon Linux 2
* Pythonバージョン
    * 2.7.18
* Ansibleバージョン
    * 2.9.9

# 動作テストで使うもの

動作テスト用のスクリプトは以下です。
メッセージの出力後に、入力を待ち、入力があったら後続の処理を実行します。

```bash:test.sh
#!/bin/bash

echo -n 'Please read the questions carefully and answer them. (y/n) [y]: '
read str

case "${str}" in
  [Yy]|"Yes"|"yes")
    echo "Let's begin!" ;;
  [Yy]|"No"|"no")
    echo "Goodbye!" ;;
  *)
    echo "undefined"
esac

cat << EOS
Sound the flute!
Now it's mute.
Bird's delight
Day and night;
Nightingale
In the dale,
Lark in sky,
Merrily,
Merrily, merrily, to welcome
in the year.
EOS

echo -n 'What is the name of this poem? (Spring/The Waste Land/Daffodils) [Spring]: '
read str

case "${str}" in
  "Spring")
    echo "You are correct." ;;
  "The Waste Land")
    echo "Incorrect." ;;
  "Daffodils")
    echo "Incorrect." ;;
  *)
    echo "undefined"
esac
```

Ansibleは以下の`main.yml`を実行します。

```yaml:Ansibleの内容
---
- name: Copy test.sh
  copy:
    src: "tmp/test.sh"
    dest: "/tmp/test.sh"
    owner: root
    group: root
    mode: 0755

- name: test expect module
  expect:
    command: ./test.sh
    chdir: /tmp/
    responses:
      "Please read the questions carefully and answer them. (y/n) [y]: ": "y"
      "What is the name of this poem? (Spring/The Waste Land/Daffodils) [Spring]: ": "Spring"
    echo: true
```

# 色々と試した結論

色々試した結果、`expectモジュール`を使う際は以下を気を付けた方が良いことが解りました。

* 事前に`pip`で`pexpect 3.3`以上をインストールする
* `responses`に書く検索文字は`文字列`ではなく`正規表現`として記載する
    * `[`, `]`, `(`, `)`, `?`, `.` など`正規表現`で使われる記号は`\\`でエスケープする
    * 毎回エスケープするのは可読性も良くないので、記号や繰り返し出現する文字は省く
* 入力待ちになる前に標準出力が複数行ある場合は`.*`を先頭に付与する

整理されてちゃんと動くようになった記法は以下の通りです。

```yaml:Ansibleの内容
---
- name: Copy test.sh
  copy:
    src: "tmp/test.sh"
    dest: "/tmp/test.sh"
    owner: root
    group: root
    mode: 0755

- name: test expect module
  expect:
    command: ./test.sh
    chdir: /tmp/
    responses:
      "Please read the questions carefully and answer them": "y"
      ".*What is the name of this poem": "Spring"
    echo: true
```

# 今回ハマった内容

## テスト1回目

### 実行結果

実行してみたところ、以下の通りエラーとなりました。
`pexpect`ライブラリが無いようです。

```yaml:実行結果
TASK [expect_module_test : test expect module] *********************************
task path: /tmp/packer-provisioner-ansible-local/60c164e8-08f2-e040-4694-33da125f33d8/roles/expect_module_test/tasks/main.yml:10
Using module file /usr/lib/python2.7/site-packages/ansible/modules/commands/expect.py
The full traceback is:
Traceback (most recent call last):
  File "/tmp/ansible_expect_payload_JdNn_2/ansible_expect_payload.zip/ansible/modules/commands/expect.py", line 108, in <module>
ImportError: No module named pexpect
fatal: [127.0.0.1]: FAILED! => changed=false
  invocation:
    module_args:
      chdir: /tmp/
      command: ./test.sh
      creates: null
      echo: true
      removes: null
      responses:
        'What is the name of this poem? (Spring/The Waste Land/Daffodils) [Spring]: ': Spring
        'Please read the questions carefully and answer them. (y/n) [y]: ': y
      timeout: 30
  msg: Failed to import the required Python library (pexpect) on ip-10-110-0-251.ap-northeast-1.compute.internal's Python /usr/bin/python. Please read module documentation and install in the appropriate location. If the required library is installed, but Ansible is using the wrong Python interpreter, please consult the documentation on ansible_python_interpreter

PLAY RECAP *********************************************************************
127.0.0.1                  : ok=2    changed=1    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0

```

### 原因調査

調べてみると`expectモジュール`の実行には`pexpect 3.3`以上が必要とのことでした。
凡ミス・・・。

### `pexpect`のインストール

以下の通り、`pip`で`pexpect 3.3`をインストールします。
`pexpect`のドキュメントを見ると`Python2.7`系は`pexpect 4.8`も使えるようですが、試しに`pexpect 4.8`で実行したところ、`expectモジュール`の実行に失敗しました。
どうもバージョンで挙動が異なる様なので、今回は`pexpect 3.3`を使用しました。

```bash
$ sudo pip install pexpect==3.3
```

## テスト2回目

### 実行結果

実行したところ、また失敗しました。
`Please read the questions carefully and answer them. (y/n) [y]: ` のメッセージ自体は想定通り出力されていますが、上手く標準出力の内容を読めていないように見えます。

```yaml:実行結果
TASK [expect_module_test : test expect module] *********************************
task path: /tmp/packer-provisioner-ansible-local/60c15f9d-e4a8-2f10-230d-8be76b541724/roles/expect_module_test/tasks/main.yml:10
The full traceback is:
WARNING: The below traceback may *not* be related to the actual failure.
  File "/tmp/ansible_expect_payload_piodgn/ansible_expect_payload.zip/ansible/modules/commands/expect.py", line 205, in main
fatal: [127.0.0.1]: FAILED! => changed=true
  cmd: ./test.sh
  delta: '0:00:30.125857'
  end: '2021-06-10 00:41:43.826759'
  invocation:
    module_args:
      chdir: /tmp/
      command: ./test.sh
      creates: null
      echo: true
      removes: null
      responses:
        'Please read the questions carefully and answer them. (y/n) [y]: ': y
        'What is the name of this poem? (Spring/The Waste Land/Daffodils) [Spring]: ': Spring
      timeout: 30
  msg: non-zero return code
  rc: 1
  start: '2021-06-10 00:41:13.700902'
  stdout: 'Please read the questions carefully and answer them. (y/n) [y]: '
  stdout_lines: <omitted>

PLAY RECAP *********************************************************************
127.0.0.1                  : ok=2    changed=1    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0
```

### 原因調査

[【小ネタ】Ansible expect モジュールの罠](https://ceblog.mediba.jp/post/155987719127/%E5%B0%8F%E3%83%8D%E3%82%BFansible-expect-%E3%83%A2%E3%82%B8%E3%83%A5%E3%83%BC%E3%83%AB%E3%81%AE%E7%BD%A0)を見ると、`responses`で検知する文字列の `[` `]` `(` `)` は `\\` でエスケープしているようです。

ここで改めて　`expectモジュール`のドキュメントを読むと、`responses`には期待する`文字列/正規表現`と応答の文字列マッピングを記載するとかいてあります。
でも記号が入ってたら`文字列`としてではなく`正規表現`としてマッチしているから、`正規表現`のみがマッチしているのでは？
ちょっとドキュメントの説明にモヤッとしますが、`正規表現`の記号は`\\`でエスケープするのが無難そう。

```text
Mapping of expected string/regex and string to respond with. If the response is a list, successive matches return successive responses. List functionality is new in 2.1.
```

出典: [expect – Executes a command and responds to prompts](https://docs.ansible.com/ansible/2.9/modules/expect_module.html?highlight=ansible%20doc)

### カッコをエスケープする

記事を参考に、カッコをエスケープしていきます。

```yaml:Ansibleの内容
---
- name: Copy test.sh
  copy:
    src: "tmp/test.sh"
    dest: "/tmp/test.sh"
    owner: root
    group: root
    mode: 0755

- name: test expect module
  expect:
    command: ./test.sh
    chdir: /tmp/
    responses:
      "Please read the questions carefully and answer them. \\(y/n\\) \\[y\\]: ": "y"
      "What is the name of this poem? \\(Spring/The Waste Land/Daffodils\\) \\[Spring\\]: ": "Spring"
    echo: true
```

## テスト3回目

### 実行結果

再び失敗しました。

```yaml:実行結果
TASK [expect_module_test : test expect module] *********************************
task path: /tmp/packer-provisioner-ansible-local/60c160b7-a58f-c24f-40d8-1071b388514b/roles/expect_module_test/tasks/main.yml:10
The full traceback is:
WARNING: The below traceback may *not* be related to the actual failure.
  File "/tmp/ansible_expect_payload_N0QzcP/ansible_expect_payload.zip/ansible/modules/commands/expect.py", line 205, in main
fatal: [127.0.0.1]: FAILED! => changed=true
  cmd: ./test.sh
  delta: '0:00:30.212636'
  end: '2021-06-10 00:46:25.353336'
  invocation:
    module_args:
      chdir: /tmp/
      command: ./test.sh
      creates: null
      echo: true
      removes: null
      responses:
        'Please read the questions carefully and answer them. \(y/n\) \[y\]: ': y
        'What is the name of this poem? \(Spring/The Waste Land/Daffodils\) \[Spring\]: ': Spring
      timeout: 30
  msg: non-zero return code
  rc: 1
  start: '2021-06-10 00:45:55.140700'
  stdout: |-
    Please read the questions carefully and answer them. (y/n) [y]: y
    Let's begin!
    Sound the flute!
    Now it's mute.
    Bird's delight
    Day and night;
    Nightingale
    In the dale,
    Lark in sky,
    Merrily,
    Merrily, merrily, to welcome
    in the year.
    What is the name of this poem? (Spring/The Waste Land/Daffodils) [Spring]:
  stdout_lines: <omitted>

PLAY RECAP *********************************************************************
127.0.0.1                  : ok=2    changed=1    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0
```

### 原因調査

1回目の質問については`y`と入力していますが、後続の質問については何も入力されていない事がわかります。
入力待ちになる前に、複数行の標準出力がある場合は記載方法を変える必要があるようです。

### 複数行の標準出力行を回避する

2回目の質問の前に、標準出力が複数行表示されているはずなので`.*`で回避して、末行の文字列だけマッチさせます。
また、`.`や`?`はエスケープしていませんでしたが、こちらもエスケープするようにしました。

```yaml:Ansibleの内容
---
- name: Copy test.sh
  copy:
    src: "tmp/test.sh"
    dest: "/tmp/test.sh"
    owner: root
    group: root
    mode: 0755

- name: test expect module
  expect:
    command: ./test.sh
    chdir: /tmp/
    responses:
      "Please read the questions carefully and answer them\\. \\(y/n\\) \\[y\\]: ": "y"
      ".*What is the name of this poem\\? \\(Spring/The Waste Land/Daffodils\\) \\[Spring\\]: ": "Spring"
    echo: true
```

## テスト4回目

### 実行結果

以下の通り成功しました。
`expectモジュール`の事例があまりなくて、少々苦労しましたが無事対話式のスクリプトをちゃんと実行することができました。
ただし、ここで気付いたのですが

```yaml:実行結果
TASK [expect_module_test : test expect module] *********************************
task path: /tmp/packer-provisioner-ansible-local/60c1637b-8b75-0021-f5c0-6f4b9fd137e0/roles/expect_module_test/tasks/main.yml:10
changed: [127.0.0.1] => changed=true
  cmd: ./test.sh
  delta: '0:00:00.234625'
  end: '2021-06-10 00:57:43.057192'
  invocation:
    module_args:
      chdir: /tmp/
      command: ./test.sh
      creates: null
      echo: true
      removes: null
      responses:
        '.*What is the name of this poem\? \(Spring/The Waste Land/Daffodils\) \[Spring\]: ': Spring
        'Please read the questions carefully and answer them\. \(y/n\) \[y\]: ': y
      timeout: 30
  rc: 0
  start: '2021-06-10 00:57:42.822567'
  stdout: |-
    Please read the questions carefully and answer them. (y/n) [y]: y
    Let's begin!
    Sound the flute!
    Now it's mute.
    Bird's delight
    Day and night;
    Nightingale
    In the dale,
    Lark in sky,
    Merrily,
    Merrily, merrily, to welcome
    in the year.
    What is the name of this poem? (Spring/The Waste Land/Daffodils) [Spring]: Spring
    You are correct.
  stdout_lines: <omitted>

PLAY RECAP *********************************************************************
127.0.0.1                  : ok=3    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
```

### エスケープが必要な記号の除去

ここまできて、実はエスケープする記号は除去したほうが可読性が良いことに気付く・・・。
以下の記載にするとよりシンプルですね。

```yaml:Ansibleの内容
---
- name: Copy test.sh
  copy:
    src: "tmp/test.sh"
    dest: "/tmp/test.sh"
    owner: root
    group: root
    mode: 0755

- name: test expect module
  expect:
    command: ./test.sh
    chdir: /tmp/
    responses:
      "Please read the questions carefully and answer them": "y"
      ".*What is the name of this poem": "Spring"
    echo: true
```

## テスト5回目

### 実行結果

```yaml:実行結果
TASK [expect_module_test : test expect module] *********************************
task path: /tmp/packer-provisioner-ansible-local/60c1637b-8b75-0021-f5c0-6f4b9fd137e0/roles/expect_module_test/tasks/main.yml:10
changed: [127.0.0.1] => changed=true
  cmd: ./test.sh
  delta: '0:00:00.234625'
  end: '2021-06-10 00:57:43.057192'
  invocation:
    module_args:
      chdir: /tmp/
      command: ./test.sh
      creates: null
      echo: true
      removes: null
      responses:
        '.*What is the name of this poem': Spring
        'Please read the questions carefully and answer them': y
      timeout: 30
  rc: 0
  start: '2021-06-10 00:57:42.822567'
  stdout: |-
    Please read the questions carefully and answer them. (y/n) [y]: y
    Let's begin!
    Sound the flute!
    Now it's mute.
    Bird's delight
    Day and night;
    Nightingale
    In the dale,
    Lark in sky,
    Merrily,
    Merrily, merrily, to welcome
    in the year.
    What is the name of this poem? (Spring/The Waste Land/Daffodils) [Spring]: Spring
    You are correct.
  stdout_lines: <omitted>

PLAY RECAP *********************************************************************
127.0.0.1                  : ok=3    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
```

# おわりに

`expectモジュール`の挙動が解っていればなんということはないですが、今後使う人の助けになればと思います。