＜Ansible＞「expect」モジュールの使い方

#はじめに
この記事では、Ansibleの「expect」モジュールについて説明します。
「expect」モジュールは対話式のコマンドに対応するもので、事前に入力情報をPlaybookに記述することでプロンプトに対しての自動応答をします。
#必要条件
このモジュールを実行するには、ホストに「python（バージョンが2.6以上）」と「pexpect（バージョンが3.3以上）」がインストールされてある必要があります。
# パラメータ（アーギュメント）
パラメータ（アーギュメント）とは、モジュールのオプションのことです。

|パラメータ  |選択肢：<font color="MediumBlue">デフォルト</font>  |機能  |
|---|:-:|---|
|**chdir**  |－  |コマンド実行前に移動するディレクトリのパスを指定する。  |
|**command**  |－  |実行するコマンド指定する。  |
|**creates**  |－  |作成するファイルのパスを指定する。<br>指定したファイルが既に存在する場合、この処理は実行されない。  |
|**echo**  |**選択肢：** <br> **・**<font color="MediumBlue">**no**←</font> <br> **・**yes  |プロンプトに対しての応答文字列（入力値）が戻り値「stdout」に格納されるかどうか指定する。  |
|**removes**  |－  |削除するファイルのパスを指定する。<br>指定したファイルが存在しない場合、この処理は実行されない。  |
|**responses**  |－  |プロンプトをキー、応答（入力）するものを値として設定する。  |
|**timeout**  |**デフォルト：** <font color="MediumBlue">30</font>  |「responses」で設定したプロンプトを探す秒数を設定する。  |
#実践（「expect」モジュールで実行するPlaybook）
それでは、「expect」モジュールがどのような振舞をするのか、実際に作成したPlaybookで確認していきます。

まずは、「expect」モジュールの実行対象となるPlaybookの説明をします。
処理の内容は、最小値と最大値を入力し、その範囲内の市町村数を持つ都道府県を外部ファイルに書き込みというものです。
ディレクトリ構成、ファイルの中身は以下の通りです。
##### ディレクトリ構成

```
/etc/ansible
├── input_vars.yml     # 変数定義ファイル
├── main.yml           # Playbook
├── error_message.yml  # 条件に当てはまる都道府県が存在しなかった場合の処理を記述している外部ファイル
└── output_list.yml    # 条件に当てはまる都道府県が存在した場合の処理を記述している外部ファイル
```
#####/etc/ansible/input_vars.yml（変数定義ファイル）
都道府県と、それに対応する市町村数を定義しています。

```
---
Prefectures:
  - Prefecture: 北海道
    NumberOfMunicipalities: 179
  - Prefecture: 青森県
    NumberOfMunicipalities: 40
  - Prefecture: 岩手県
    NumberOfMunicipalities: 33
  - Prefecture: 宮城県
    NumberOfMunicipalities: 35
  - Prefecture: 秋田県
    NumberOfMunicipalities: 25
  - Prefecture: 山形県
    NumberOfMunicipalities: 35
  - Prefecture: 福島県
    NumberOfMunicipalities: 59
  - Prefecture: 茨城県
    NumberOfMunicipalities: 44
  - Prefecture: 栃木県
    NumberOfMunicipalities: 25
  - Prefecture: 群馬県
    NumberOfMunicipalities: 35
  - Prefecture: 埼玉県
    NumberOfMunicipalities: 63
  - Prefecture: 千葉県
    NumberOfMunicipalities: 54
  - Prefecture: 東京都
    NumberOfMunicipalities: 39
  - Prefecture: 神奈川県
    NumberOfMunicipalities: 33
  - Prefecture: 新潟県
    NumberOfMunicipalities: 30
  - Prefecture: 富山県
    NumberOfMunicipalities: 15
  - Prefecture: 石川県
    NumberOfMunicipalities: 19
  - Prefecture: 福井県
    NumberOfMunicipalities: 17
  - Prefecture: 山梨県
    NumberOfMunicipalities: 27
  - Prefecture: 長野県
    NumberOfMunicipalities: 77
  - Prefecture: 岐阜県
    NumberOfMunicipalities: 42
  - Prefecture: 静岡県
    NumberOfMunicipalities: 35
  - Prefecture: 愛知県
    NumberOfMunicipalities: 54
  - Prefecture: 三重県
    NumberOfMunicipalities: 29
  - Prefecture: 滋賀県
    NumberOfMunicipalities: 19
  - Prefecture: 京都府
    NumberOfMunicipalities: 26
  - Prefecture: 大阪府
    NumberOfMunicipalities: 43
  - Prefecture: 兵庫県
    NumberOfMunicipalities: 41
  - Prefecture: 奈良県
    NumberOfMunicipalities: 39
  - Prefecture: 和歌山県
    NumberOfMunicipalities: 30
  - Prefecture: 鳥取県
    NumberOfMunicipalities: 19
  - Prefecture: 島根県
    NumberOfMunicipalities: 19
  - Prefecture: 岡山県
    NumberOfMunicipalities: 27
  - Prefecture: 広島県
    NumberOfMunicipalities: 23
  - Prefecture: 山口県
    NumberOfMunicipalities: 19
  - Prefecture: 徳島県
    NumberOfMunicipalities: 24
  - Prefecture: 香川県
    NumberOfMunicipalities: 17
  - Prefecture: 愛媛県
    NumberOfMunicipalities: 20
  - Prefecture: 高知県
    NumberOfMunicipalities: 34
  - Prefecture: 福岡県
    NumberOfMunicipalities: 60
  - Prefecture: 佐賀県
    NumberOfMunicipalities: 20
  - Prefecture: 長崎県
    NumberOfMunicipalities: 21
  - Prefecture: 熊本県
    NumberOfMunicipalities: 45
  - Prefecture: 大分県
    NumberOfMunicipalities: 18
  - Prefecture: 宮崎県
    NumberOfMunicipalities: 45
  - Prefecture: 鹿児島県
    NumberOfMunicipalities: 43
  - Prefecture: 沖縄県
    NumberOfMunicipalities: 41
...
```
#####/etc/ansible/main.yml（Playbook）

```
---
# 最小値と最大値の入力
- name: Output the prefectures
  hosts: localhost
  gather_facts: False
  vars_files:
    - input_vars.yml
  vars:
    ansible_python_interpreter: /usr/bin/python3
    now_date: "{{ lookup('pipe','date +%Y%m%d%H%M') }}\n"
  vars_prompt:
    - name: "minimum"
      prompt: |
              最小値を入力してください
      private: no
    - name: "maximum"
      prompt: |
              最大値を入力してください
      private: no
  tasks:
    # 入力した数値から、条件に当てはまる都道府県をリストに格納
    - name: Define a variable from the number you enter
      set_fact:
        prefectures: "{{ Prefectures | selectattr('NumberOfMunicipalities', '>=', minimum | int) | selectattr('NumberOfMunicipalities', '<=', maximum | int) | map(attri
bute='Prefecture' ) | list | to_yaml }}"

    # リスト内の値の有無により処理を分岐
    - block:
        # 動的にerror_message.ymlを呼び出して実行
        - name: Call error_message.yml dynamically to execute
          include_tasks: error_message.yml
          when: "{{ prefectures }} == []"
      rescue:
        # 動的にoutput_list.ymlを呼び出して実行
        - name: Call output_list.yml dynamically to execute
          include_tasks: output_list.yml
...
```
#####/etc/ansible/error_message.yml（条件に当てはまる都道府県が存在しなかった場合の処理を記述している外部ファイル）

```
---
# ファイルへのエラーメッセージ書き込み
- name: Write the error message
  lineinfile:
    path: ./qiita.txt
    create: yes
    line: "{{ now_date }}条件に当てはまる都道府県はありません。\n"
...
```
#####/etc/ansible/output_list.yml（条件に当てはまる都道府県が存在した場合の処理を記述している外部ファイル）

```
---
# ファイルへのリスト書き込み
- name: Write a list
  lineinfile:
    path: ./qiita.txt
    create: yes
    line: '{{ now_date }}{{ prefectures }}'
...
```
作成したファイルは以上です。
それでは、Playbookである/etc/ansible/output_list.yml実際に処理結果を確認してみましょう。
まずは、条件に当てはまる都道府県が存在しなかった場合の処理です。

```
最小値を入力してください
: 20
最大値を入力してください
: 30

PLAY [Output the prefectures] ******************************************************************************************************************************************

TASK [Define a variable from the number you enter] *********************************************************************************************************************
ok: [localhost]

TASK [Call error_message.yml dynamically to execute] *******************************************************************************************************************
 [WARNING]: conditional statements should not include jinja2 templating delimiters such as {{ }} or {% %}. Found: {{ prefectures }} == []

fatal: [localhost]: FAILED! => {"msg": "The conditional check '{{ prefectures }} == []' failed. The error was: template error while templating string: unexpected char u'\\u79cb' at 7. String: {% if [秋田県, 栃木県, 新潟県, 山梨県, 三重県, 京都府, 和歌山県, 岡山県, 広島県, 徳島県, 愛媛県, 佐賀県, 長崎県]\n == [] %} True {% else %} False {% endif %}\n\nThe error appears to be in '/etc/ansible/main.yml': line 29, column 11, but may\nbe elsewhere in the file depending on the exact syntax problem.\n\nThe offending line appears to be:\n\n        # error_message.ymlを呼び出して実行\n        - name: Call error_message.yml dynamically to execute\n          ^ here\n"}

TASK [Call output_list.yml dynamically to execute] *********************************************************************************************************************
included: /etc/ansible/output_list.yml for localhost

TASK [Write a list] ****************************************************************************************************************************************************
changed: [localhost]

PLAY RECAP *************************************************************************************************************************************************************
localhost                  : ok=3    changed=1    unreachable=0    failed=0    skipped=0    rescued=1    ignored=0
```
「最小値を入力してください」、「最大値を入力してください」というプロンプトに対してそれぞれ「20」、「30」という数値を入力しています。
これらの数値から、「set_fact」モジュール（main.ymlの23行目）で定義した変数「prefectures」の条件が作成でき、その条件に当てはまる都道府県と、日時が/etc/ansible/qiita.txt にリストで出力されます。
#####/etc/ansible/qiita.txt（条件に当てはまる都道府県が存在した場合）

```
202005221812
[秋田県, 栃木県, 新潟県, 山梨県, 三重県, 京都府, 和歌山県, 岡山県, 広島県, 徳島県, 愛媛県, 佐賀県, 長崎県]

```
次に、条件に当てはまる都道府県が存在しない場合の処理結果です。

```
最小値を入力してください
: 30
最大値を入力してください
: 20

PLAY [Output the prefectures] ******************************************************************************************************************************************

TASK [Define a variable from the number you enter] *********************************************************************************************************************
ok: [localhost]

TASK [Call error_message.yml dynamically to execute] *******************************************************************************************************************
 [WARNING]: conditional statements should not include jinja2 templating delimiters such as {{ }} or {% %}. Found: {{ prefectures }} == []

included: /etc/ansible/error_message.yml for localhost

TASK [Write the error message] *****************************************************************************************************************************************
changed: [localhost]

PLAY RECAP *************************************************************************************************************************************************************
localhost                  : ok=3    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
```
条件に当てはまる都道府県が存在しないので、/etc/ansible/qiita.txt には、以下のように日時と「条件に当てはまる都道府県はありません。」というメッセージが追記されます。
#####/etc/ansible/qiita.txt（条件に当てはまる都道府県が存在しなかった場合）

```
202005221812
[秋田県, 栃木県, 新潟県, 山梨県, 三重県, 京都府, 和歌山県, 岡山県, 広島県, 徳島県, 愛媛県, 佐賀県, 長崎県]

202005221835
条件に当てはまる都道府県はありません。

```
#実践（「expect」モジュールを使用したPlaybook）
それでは、実際に/etc/ansible/main.yml を実行する、「expect」モジュールを使用したPlaybookの説明をしていきます。
ディレクトリ構成、ファイルの中身は以下の通りです。
##### ディレクトリ構成

```
/etc/ansible
├── input_vars.yml     # 変数定義ファイル
├── expect.yml         # 「expect」モジュールを使用したPlaybook
├── main.yml           # 「expect」モジュールで実行するPlaybook
├── error_message.yml  # 条件に当てはまる都道府県が存在しなかった場合の処理を記述している外部ファイル
└── output_list.yml    # 条件に当てはまる都道府県が存在した場合の処理を記述している外部ファイル
```
#####/etc/ansible/main.yml（「expect」モジュールを使用したPlaybook）

```
---
- hosts: localhost
  gather_facts: False
  tasks:
  # プロンプトに対しての自動応答
  - name: Executes a command and responds to prompts
    expect:
      command: ansible-playbook main.yml
      responses:
        "最小値を入力してください": 30
        "最大値を入力してください": 40
...
```
「command」アーギュメントで実行するコマンドのパス、「responses」アーギュメントでプロンプト文字列と入力値を設定しています。
これらのアーギュメントは「expect」モジュールを使う上で必須です。

実行結果は以下のとおりです。

```
PLAY [localhost] *******************************************************************************************************************************************************

TASK [Executes a command and responds to prompts] *********************************************************************************************************************
changed: [localhost]

PLAY RECAP *************************************************************************************************************************************************************
localhost                  : ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
```
標準出力には、「expect」モジュールで使用したコマンドの結果だけが表示されます。
#####/etc/ansible/qiita.txt

```
202005221812
[秋田県, 栃木県, 新潟県, 山梨県, 三重県, 京都府, 和歌山県, 岡山県, 広島県, 徳島県, 愛媛県, 佐賀県, 長崎県]

202005221835
条件に当てはまる都道府県はありません。

202005250950
[青森県, 岩手県, 宮城県, 山形県, 群馬県, 東京都, 神奈川県, 新潟県, 静岡県, 奈良県, 和歌山県, 高知県]

```
/etc/ansible/qiita.txt には、条件に当てはまる都道府県が追記されていることを確認できます。

勿論、当てはまる都道府県が存在しない最小値と最大値の設定をした場合は、日時と「条件に当てはまる都道府県はありません。」というメッセージが追記されます。
#「echo」アーギュメントの効果の詳細
表では、「echo」アーギュメントについて、"プロンプトに対しての応答文字列（入力値）が戻り値「stdout」に格納されるかどうか指定" と説明していますが、これでは説明が足りないので、補足のため、ここで詳細を説明していきます。

まず、タスクの実行結果の戻り値というのは、以下の方法で確認できます。

・「-vvv」オプションを付与した ansible-playbook コマンドの実行
・「debug」モジュールを利用したレジスタ変数（「register」ディレクティブで定義。戻り値が格納されている。）の確認

戻り値「stdout」というのは、コマンドを使用した場合の標準出力結果が格納されています。
「echo」アーギュメントの値を "yes" にすることによって、戻り値「stdout」がどのように変化するか、「debug」モジュールを利用して確認していきます。

ディレクトリ構成、ファイルの中身は以下の通りです。
##### ディレクトリ構成
```
/etc/ansible
├── expect.yml        # Playbook
└── opportunity.sh    # Playbookで実行するシェルスクリプト
```
#####/etc/ansible/opportunity.sh（Playbookで実行するシェルスクリプト）
```
#!/bin/bash
echo "相手：出身地はどちらですか。"
echo -n 貴方：
read input_value
echo "相手：そうなんですね。${input_value}は良いところですよね。"
```
出身地を単語で入力すると、会話が成り立つという内容のものです。

```
相手：出身地はどちらですか。
貴方：東京都
相手：そうなんですね。東京都は良いところですよね。
```
#####/etc/ansible/expect.yml（Playbook）
```
---
- hosts: localhost
  gather_facts: False
  tasks:
  # プロンプトに対しての自動応答
  - name: Executes a command and responds to prompts
    expect:
      command: ./opportunity.sh
      responses:
        "出身地はどちらですか": 東京都
      echo: yes
    register: result

  # 戻り値の表示
  - name: Display the return value
    debug:
      msg: "{{ result.stdout_lines }}"
...
```
「echo」アーギュメントの値を "yes" にすることで、プロンプトに対しての応答文字列（入力値）が戻り値「stdout」に格納されます。
ここで、実際に「echo」アーギュメントの値を "yes" にした場合と "no"（デフォルト）にした場合の標準出力結果を比較することで、「echo」アーギュメントの効果を確認していきます。
※標準出力結果では、戻り値「stdout_lines」だけを表示させます。「stdout_lines」には「stdout」の値がリストで格納されており、見やすい（比較しやすい）という理由からです。
#####「echo」アーギュメントの値を "yes" にした場合の標準出力結果
```
PLAY [localhost] *******************************************************************************************************************************************************

TASK [Executes a command and responds to prompts] **********************************************************************************************************************
changed: [localhost]

TASK [Display the return value] ****************************************************************************************************************************************
ok: [localhost] => {
    "msg": [
        "相手：出身地はどちらですか。",
        "貴方：東京都",
        "相手：そうなんですね。東京都は良いところですよね。"
    ]
}

PLAY RECAP *************************************************************************************************************************************************************
localhost                  : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
```
#####「echo」アーギュメントの値を "no" にした場合（デフォルト）の標準出力結果
```
PLAY [localhost] *******************************************************************************************************************************************************

TASK [Executes a command and responds to prompts] **********************************************************************************************************************
changed: [localhost]

TASK [Display the return value] ****************************************************************************************************************************************
ok: [localhost] => {
    "msg": [
        "相手：出身地はどちらですか。",
        "貴方：相手：そうなんですね。東京都は良いところですよね。"
    ]
}

PLAY RECAP *************************************************************************************************************************************************************
localhost                  : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
```
「echo」アーギュメントの値を "yes" にすることで、プロンプトに対しての応答文字列（入力値）が戻り値「stdout（stdout_lines）」に格納されることを確認できました。
#処理は成功するが、実行結果が「failed」になるコマンド
ここで、「command」アーギュメントで設定するコマンドについて説明します。
設定するコマンドによって、処理は成功しますが、実行結果が「failed」になるものがあります。
それが、以下の2つです。

・「mv」コマンド
・「rm」コマンド

「ls」コマンドや「chmod」コマンドを試してみましたが、「failed」にはならず、正常に実行されました。
ファイルの作成、削除に関わるコマンドを実行することで「failed」になるみたいです。
「creates」アーギュメント、「removes」アーギュメントでファイルの作成・削除をすることができるので、わざわざ「command」アーギュメントでファイル作成・削除コマンドを指定する必要がないということなのでしょうか。
#機能しないアーギュメント
「expect」モジュールのアーギュメントとして定義しても機能しないものがありました。
以下2つです。

・creates
・removes

「echo」アーギュメントの説明に使ったPlaybookのタスクに「creates」と「removes」を付与して、これらのアーギュメントの効果を確認していきます。
#####/etc/ansible/expect.yml（「creates」、「removes」を付与）
```
---
- hosts: localhost
  gather_facts: False
  tasks:
  # プロンプトに対しての自動応答
  - name: Executes a command and responds to prompts
    expect:
      creates: /etc/ansible/abc.txt
      removes: /etc/ansible/qiita.txt
      command: ./opportunity.sh
      responses:
        "出身地はどちらですか": 東京都
      echo: yes
    register: result

  # 戻り値の表示
  - name: Display the return value
    debug:
      msg: "{{ result.stdout_lines }}"
...
```
実行結果は以下のとおりです。

```
PLAY [localhost] *******************************************************************************************************************************************************

TASK [Executes a command and responds to prompts] **********************************************************************************************************************
changed: [localhost]

TASK [Display the return value] ****************************************************************************************************************************************
ok: [localhost] => {
    "msg": [
        "相手：出身地はどちらですか。",
        "貴方：東京都",
        "相手：そうなんですね。東京都は良いところですよね。"
    ]
}

PLAY RECAP *************************************************************************************************************************************************************
localhost                  : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
```
何の問題もなく、処理が成功していますが、「creates」アーギュメントで指定したファイルは作成されていませんし、「removes」アーギュメントで指定したファイルは削除されませんでした。

しかし、タスクを実行する前に、ファイルパスが存在しているかどうかの確認作業は行っているようです。

以下のように「removes」アーギュメントに存在しないファイルパス（/etc/ansible/sonzaishinai.txt）を指定したPlaybookを実行し、戻り値を格納したレジスタ変数を表示させます。
#####/etc/ansible/expect.yml（「removes」アーギュメントに存在しないファイルパスを定義）
```
---
- hosts: localhost
  gather_facts: False
  tasks:
  # プロンプトに対しての自動応答
  - name: Executes a command and responds to prompts
    expect:
      removes: /etc/ansible/sonzaishinai.txt
      command: ./opportunity.sh
      responses:
        "出身地はどちらですか": 東京都
      echo: yes
    register: result

  # 戻り値の表示
  - name: Display the return value
    debug:
      msg: "{{ result }}"
...
```

```
PLAY [localhost] *******************************************************************************************************************************************************

TASK [Executes a command and responds to prompts] **********************************************************************************************************************
ok: [localhost]

TASK [Display the return value] ****************************************************************************************************************************************
ok: [localhost] => {
    "msg": {
        "changed": false,
        "cmd": "./opportunity.sh",
        "failed": false,
        "rc": 0,
        "stdout": "skipped, since /etc/ansible/sonzaishinai.txt does not exist",
        "stdout_lines": [
            "skipped, since /etc/ansible/sonzaishinai.txt does not exist"
        ]
    }
}

PLAY RECAP *************************************************************************************************************************************************************
localhost                  : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
```
指定したファイルパス（/etc/ansible/sonzaishinai.txt）が存在しないことを理由に、「removes」アーギュメントの処理（ファイル削除）を行っていないことが分かりました。

機能しない原因についてですが、調べてみた結果、関連する情報は見当たりませんでした。
原因を探れば機能するものなのか、それとも機能しない状態が正しいのか、まだ把握できていません。
原因が分かり次第、情報を追記したいと思います。