[Ansible]Role実行時のパラメータチェックはAnsibleに任せよう

# はじめに

Ansible 2.11で出ていた`argument_specs`を今更ながら知ったのでどのようなものか試してみました。

# Ansible Roleの変数チェックの問題点

AnsibleでRoleを使う場合、基本的にはパラメータを渡してその値をもとに何らかの処理を実行します。

しかし、Roleとそれを利用するPlaybook開発者は別の場合が多いので、Playbook開発者側はパラメータをよく理解しないまま実行してRole実行途中にエラーが発生するということがよくありました。

筆者もよく悩まされていて、Role実行時の最初のタスクで以下コードのようなassertを実行してパラメータチェックすることで対応していました。

```yaml
- ansible.builtin.assert:
    that:
      - var1 is defined
      - var1 is string
      - var1 | length > 0
```

しかし、冗長なコードとなりログにassertのタスクが大量に出てくるのが課題となっていました。

# Role引数の検証定義

Ansible 2.11より、Roleのメタ情報としてRole実行時のパラメータ定義ができるようになっています。

ロールディレクトリの`meta/argument_specs.yml`にパラメータ定義を記載しておくことで、Role実行時にPlaybook実行変数がパラメータ定義の条件を満たしているか検証してくれます。

検証に失敗すると、Roleを実行せずにエラーを出してくれるため何が理由でエラーが起こったのか分かりやすいです。

https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_reuse_roles.html#id8

以下のように`argument_specs.main.options`配下に各パラメータの定義を行います。

```yaml:roles/xxx/meta/argument_specs.yml
---
argument_specs:
  main:
    options:
      myapp_int:
        type: "int"
        required: false
        default: 42
        description:
          - "The integer value, defaulting to 42."
          - "This is a second paragraph."
```

typeは以下型を識別することができ、list, dictの場合はさらにその配下の型も定義できます。

- str
- list
- dict
- bool
- int
- float
- path
- raw
- jsonarg
- json
- bytes
- bits

注意点として

`default`は、ドキュメント用のものであるため、このファイルで定義していても自動で作られるものでないため注意が必要です。
個別に`defaults/main.yml`などで定義が必要となります。

また、調べた限りだとmapのkey名をワイルドカードとしての定義はできませんでした。
例えば以下のように、os_familyの種類名をmap keyにしてその配下がlistであることのチェックはできません。
※ほかにやり方あればコメントいただきたいです

```yaml
# チェック定義可能
os_family:
  - { name: rhel, distro: [rhel, centos, oraclelinux] }
  - { name: debian, distro: [debian, ubuntu] }

# チェック定義不可
os_family:
  rhel: [rhel, centos, oraclelinux]
  debian: [debian, ubuntu]
```
# Playbook実行時の結果

ファイル構成は以下の通り

```bash
./
├── ansible.cfg
├── playbook.yml
└── roles
    └── sukina_ranking_happyou
        ├── meta
        │   └── argument_specs.yml
        └── tasks
            └── main.yml
```

ファイルの中身は、以下の通り。

<details><summary>Playbook</summary>

```yaml:playbook.yml
---
- name: Playbook
  hosts: localhost
  gather_facts: false
  vars:
    doragon_name: 好きな惣菜発表ドラゴン
    happyou_category: ネットワーク機器
    # happyou_category: サーバ機器
    category_rankings:
      - category: 惣菜
        ranking:
          - からあげ
          - ハンバーグ
          - とんかつ
          - 肉を甘辛く炒めたやつ
      - category: ネットワーク機器
        ranking:
          - PaloAlto
          - Juniper
          - F5
          - ASA以外のCisco機全般
  roles:
    - sukina_ranking_happyou
```
</details>

<details><summary>Roleタスク</summary>

```yaml:/roles/sukina_ranking_happyou/tasks/main.yml
---
- name: "{{ doragon_name }}が好きな{{ happyou_category }}を発表します"
  ansible.builtin.debug:
    msg: |-
      ┌─────────────────────────────┐
      │　{{ item }}
      └─────────────────────────────┘
                                      Oo｡.
                                            <<
                                      ___( ^     )
                                      ___         )____
                                          | |      _ ] _ \\
                                          | |_|    )
                                          | |_|     \\ 
                                          ""(   ) _ _ ^~
                                          _ ( _)
  loop: "{{ category_rankings | json_query(query) }}"
  vars:
    query: "[?category==`{{ happyou_category }}`].ranking[]"
```
</details>

<details><summary>argument_specs</summary>

```yaml:roles/sukina_ranking_happyou/meta/argument_specs.yml
---
argument_specs:
  main:
    short_description: Sukina_ranking_happyou
    description:
      - 好きな何かのランキングを発表します
    author:
      - miyuk
    options:
      doragon_name:
        type: str
        required: true
        description: 何かを発表するドラゴンの名前
      happyou_category:
        type: str
        choices:
          - 惣菜
          - ネットワーク機器
        required: true
        description: 発表する好きなカテゴリ名
      category_rankings:
        type: list
        elements: dict
        required: true
        description: 好きなカテゴリ
        options:
          category:
            type: str
            choices:
              - 惣菜
              - ネットワーク機器
            required: true
            description: カテゴリ名
          ranking:
            type: list
            elements: str
            required: true
            description: ランキング
```
</details>

検証成功・失敗時のPlaybook結果は以下の通り。

<details><summary>実行結果(検証成功)</summary>

`happyou_category: ネットワーク機器`で実行

```text:Playbook結果一部省略
PLAY [Playbook] ***************************************************************************************************

TASK [sukina_ranking_happyou : Validating arguments against arg spec 'main' - Sukina_ranking_happyou] *************
ok: [localhost] => changed=false 
  msg: The arg spec validation passed
  validate_args_context:
    argument_spec_name: main
    name: sukina_ranking_happyou
    path: /root/work/test/roles/sukina_ranking_happyou
    type: role

TASK [sukina_ranking_happyou : 好きな惣菜発表ドラゴンが好きなネットワーク機器を発表します] ************************
ok: [localhost] => (item=PaloAlto) => 
  msg: |-
    ┌─────────────────────────────┐
    │　PaloAlto
    └─────────────────────────────┘
                                    Oo｡.
                                          <<
                                    ___( ^     )
                                    ___         )____
                                        | |      _ ] _ \\
                                        | |_|    )
                                        | |_|     \\
                                        ""(   ) _ _ ^~
                                        _ ( _)
ok: [localhost] => (item=Juniper) => 
  msg: |-
    ┌─────────────────────────────┐
    │　Juniper
    └─────────────────────────────┘
                                    Oo｡.
                                          <<
                                    ___( ^     )
                                    ___         )____
                                        | |      _ ] _ \\
                                        | |_|    )
                                        | |_|     \\
                                        ""(   ) _ _ ^~
                                        _ ( _)
ok: [localhost] => (item=F5) => 
  msg: |-
    ┌─────────────────────────────┐
    │　F5
    └─────────────────────────────┘
                                    Oo｡.
                                          <<
                                    ___( ^     )
                                    ___         )____
                                        | |      _ ] _ \\
                                        | |_|    )
                                        | |_|     \\
                                        ""(   ) _ _ ^~
                                        _ ( _)
ok: [localhost] => (item=ASA以外のCisco機全般) => 
  msg: |-
    ┌─────────────────────────────┐
    │　ASA以外のCisco機全般
    └─────────────────────────────┘
                                    Oo｡.
                                          <<
                                    ___( ^     )
                                    ___         )____
                                        | |      _ ] _ \\
                                        | |_|    )
                                        | |_|     \\
                                        ""(   ) _ _ ^~
                                        _ ( _)

PLAY RECAP ********************************************************************************************************
localhost                  : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
```
</details>

<details><summary>実行結果(検証失敗)</summary>

`happyou_category: ネットワーク機器`で実行

```text:Playbook結果一部省略
PLAY [Playbook] ***************************************************************************************************

TASK [sukina_ranking_happyou : Validating arguments against arg spec 'main' - Sukina_ranking_happyou] *************
fatal: [localhost]: FAILED! => changed=false 
  argument_errors:
  - 'value of happyou_category must be one of: 惣菜, ネットワーク機器, got: サーバ機器'
  argument_spec_data:
    category_rankings:
      description: 好きなカテゴリ
      elements: dict
      options:
        category:
          choices:
          - 惣菜
          - ネットワーク機器
          description: カテゴリ名
          required: true
          type: str
        ranking:
          description: ランキング
          elements: str
          required: true
          type: list
      required: true
      type: list
    doragon_name:
      description: 何かを発表するドラゴンの名前
      required: true
      type: str
    happyou_category:
      choices:
      - 惣菜
      - ネットワーク機器
      description: 発表する好きなカテゴリ名
      required: true
      type: str
  msg: |-
    Validation of arguments failed:
    value of happyou_category must be one of: 惣菜, ネットワーク機器, got: サーバ機器
  validate_args_context:
    argument_spec_name: main
    name: sukina_ranking_happyou
    path: /root/work/test/roles/sukina_ranking_happyou
    type: role

PLAY RECAP ********************************************************************************************************
localhost                  : ok=0    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0   
```
</details>

ドラゴンAAは[こちら](https://qiita.com/picric_acid/items/c4d306e55bb9ce7bca70)参考にさせていただきました。

Role実行前に`Validating arguments against arg spec 'main'`というタスクが自動で実行されており、ここで検証してくれていることがわかります。

おそらく、[validate_argument_spec](https://docs.ansible.com/ansible/latest/collections/ansible/builtin/validate_argument_spec_module.html)モジュールが実行されているかと思います。

# さいごに

`argument_specs`を使うことで、大体のパラメータチェックは可能そうなので今後使っていきたい。

今回の記事を書くにあたり同じネタがないか調べてみたところ日本語でヒットしたのはてくなべブログのみでした。さすがです。。。

https://tekunabe.hatenablog.jp/entry/2022/03/05/ansible_stumble_35

# 参考リンク

- https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_reuse_roles.html#role-argument-validation
- https://docs.ansible.com/ansible/latest/collections/ansible/builtin/validate_argument_spec_module.html
- https://docs.ansible.com/ansible/latest/collections/ansible/builtin/import_role_module.html
- https://tekunabe.hatenablog.jp/entry/2022/03/05/ansible_stumble_35