Ansibleのshellモジュールでヒアドキュメントを使うための引数cmdをソースから追ってみた


この記事は[Ansible Blogger Advent Calendar 2018](https://qiita.com/advent-calendar/2018/ansibleblogger) の23日目の記事です。


# はじめに
Ansibleにはshellでコマンドを実行できる`shell`モジュールがあります。
そして`shell`を使うなら当然ヒアドキュメントも使いたくなりますよね。

しかし、Ansibleでヒアドキュメントを使用する場合、[公式](https://docs.ansible.com/ansible/2.5/modules/shell_module.html)には`shell`モジュールでplaybook内に複数行書かずに`script`モジュールを使用するようにとあります。

> Rather than using here documents to create multi-line scripts inside playbooks, use the script module instead.

しかし、そのためにわざわざスクリプトファイルを作成するのは非常に面倒です。管理対象のファイルは極力減らしたいものです。

そこで今回は`shell`モジュールでヒアドキュメントの使用する方法について調べました。



# 前提
この記事ではAnsibleのバージョンは[pipリポジトリの2.7.5](https://pypi.org/project/ansible/)(2018/12/16現在で最新)を使用します。

# `shell`モジュールでヒアドキュメントが使えない問題
`shell`モジュールで普通にヒアドキュメントを使うとこうなります。
先頭に半角スペースが挿入され、さらにインデントが無視されるのでEOFが認識されていません。


```yml
---
- name: heredoc test
  hosts: 127.0.0.1
  connection: local
  tasks:
    - name: shell module
      shell: |
        cat <<EOF
        aaa
          bbb
        ccc
        EOF
      register: result_shell

    - name: debug
      debug:
        var: result_shell.stdout_lines
```

```

TASK [debug] *******************************************************************************************************************************************************************************************************************************************************************
ok: [127.0.0.1] => {
    "result_shell.stdout_lines": [
        " aaa",
        " bbb",
        " ccc",
        " EOF"
    ]
}
```

# どうすればいいの？
既に同様の問題で[issue](https://github.com/ansible/ansible/issues/39137)が立てられていました。
そして`shell`モジュールの引数`cmd`を使うことで解決できるとあります。


```yml

---
- name: heredoc test
  hosts: 127.0.0.1
  connection: local
  tasks:
    - name: shell module + cmd args
      shell:
        cmd: |
          cat <<EOF
          aaa
            bbb
          ccc
          EOF
      register: result_shell

    - name: debug
      debug:
        var: result_shell.stdout_lines
```


実行結果がこちら。たしかにインデント、EOFが認識されていることがわかります。
めでたしめでたし。。。でも

```
TASK [debug] *******************************************************************************************************************************************************************************************************************************************************************
ok: [127.0.0.1] => {
    "result_shell.stdout_lines": [
        "aaa",
        "  bbb",
        "ccc"
    ]
}
```

# でも引数`cmd`って何？
`shell`モジュールの引数`cmd`って何者なんでしょう？実はこれ[公式ドキュメント](https://docs.ansible.com/ansible/latest/modules/shell_module.html)にも`ansible-doc shell`でも記載されていません。

`cmd`を使うことで問題を解決出来るのは分かりましたが、これをどのような時に使用するのか、また使うことによる影響も分からず、理解せずに使用したくないのでソースを調べてみました。

なお、ソースは[pipリポジトリの2.7.5](https://pypi.org/project/ansible/)を対象とし、調査の内容は私が個人的に調査した結果であるため、間違っている可能性もあることをご了承ください。
(もし間違っていたらご指摘いただけますと幸いですm(__)m )

# ソースから`shell`モジュールの引数`cmd`を調べてみた
結論から言うと、

- 引数`cmd`を使用した場合、コマンドは一切加工されずにコマンドとして実行される
- 引数`cmd`を使用しない場合、コマンドは半角スペース、改行で分割され、さらに半角スペースで結合されコマンドとして実行される

という流れで処理されていることが分かり、自分なりに納得できる答えが得られました。
つまり引数`cmd`は
> コマンドからスペース、改行を維持したまま実行したい場合に使用する

ものであると私は理解しました。

以下にソースを辿った経緯を記載します。

## 1. `vvv`オプションで実行
まずは`ansible-playbook`を`-vvv`オプション付きで実行し詳細情報を確認しました。すると`_raw_params`という変数ですでにインデントが無視されたコマンドが格納されています。ということで`_raw_params`が作成される過程を追っていきます。

```json
changed: [127.0.0.1] => {
    "changed": true,
    "cmd": "cat <<EOF\n aaa\n bbb\n ccc\n EOF",
    "delta": "0:00:00.012905",
    "end": "2018-12-18 21:50:24.363250",
    "invocation": {
        "module_args": {
            "_raw_params": "cat <<EOF\n aaa\n bbb\n ccc\n EOF",
            "_uses_shell": true,
            "argv": null,
            "chdir": null,
            "creates": null,
            "executable": null,
            "removes": null,
            "stdin": null,
            "warn": true
        }
    },
    "rc": 0,
    "start": "2018-12-18 21:50:24.350345",
    "stderr": "",
    "stderr_lines": [],
    "stdout": " aaa\n bbb\n ccc\n EOF",
    "stdout_lines": [
        " aaa",
        " bbb",
        " ccc",
        " EOF"
    ]
}
```

## 2. `_raw_params`をgrepしてみる
17ファイルヒットしました。なんとか1個ずつ見られそうな数です。その中で`playbook/task.py`で`cmd`を`pop`して`args['_raw_params']`に格納しているコードがありました。怪しいですね。
t
<img width="466" alt="screenshot 2018-12-19 0.00.06.png" src="https://qiita-image-store.s3.amazonaws.com/0/250010/11e5a4e1-0289-0554-3ff3-c653e7858284.png">

さらに`playbook/task.py`の94行目の該当のコードのコメントを見てみると「`cmd`は`_raw_params`に相当しこれを上書きする」という旨の記載があります。


```python:playbook/task.py
        # the command/shell/script modules used to support the `cmd` arg,
        # which corresponds to what we now call _raw_params, so move that
        # value over to _raw_params (assuming it is empty)
        if action in ('command', 'shell', 'script'):
            if 'cmd' in args:
                if args.get('_raw_params', '') != '':
                    raise AnsibleError("The 'cmd' argument cannot be used when other raw parameters are specified."
                                       " Please put everything in one or the other place.", obj=ds)
                args['_raw_params'] = args.pop('cmd')
```
以上のことから、`cmd`に定義したコマンドは`cmd`を使用しない場合と同じように`_raw_params`に格納され実行されるということが分かりました。しかしこれだけでは先頭に半角スペースが追加される原因はまだわかりません。
ということでさらに`_raw_params`が作成される過程を追ってみました。

## 3. `_raw_params`が作成される過程を追ってみる
上記の`playbook/task.py`で`_raw_params`はリスト`args`の要素の1つなので`args`が作成される過程を追います。
すると183行目で`args_parser.parse()`の結果が格納されています。

```python:playbook/task.py
        args_parser = ModuleArgsParser(task_ds=ds)
        try:
            (action, args, delegate_to) = args_parser.parse()
        except AnsibleParserError as e:
```
`args_parser.parse()`を追っていくと`parsing/mod_args.py`の199行目で`dict`型の場合はそのまま返されていますが、`string`型の場合は`parse_kv(thing, check_raw=check_raw)`の結果が格納されていることが分かります。

```python:parsing/mod_args.py
        if isinstance(thing, dict):
            # form is like: { xyz: { x: 2, y: 3 } }
            args = thing
        elif isinstance(thing, string_types):
            # form is like: copy: src=a dest=b
            check_raw = action in FREEFORM_ACTIONS
            args = parse_kv(thing, check_raw=check_raw)
```

`parse_kv`を追っていくと`module_utils/splitter.py`の162、185行目で改行、スペースで`split`されています。

```python:module_utils/splitter.py
items = args.strip().split('\n')
```

```python:module_utils/splitter.py
        tokens = item.strip().split(' ')
```

そして最終的に`module_utils/splitter.py`の100行目で分割した要素を半角スペース付きで`join`していました。

```python:module_utils/splitter.py
        # recombine the free-form params, if any were found, and assign
        # them to a special option for use later by the shell/command module
        if len(raw_params) > 0:
            options[u'_raw_params'] = ' '.join(raw_params)

    return options
```
以上のことから「インデントが無視され先頭に半角スペースが追加される」謎が解明されました。

# おわりに
`shell`モジュールでヒアドキュメントを使用する方法、そのための引数`cmd`についてソースを調査した結果、その過程を紹介してみました。初めはドキュメントに記載がなく戸惑いましたが、ソースを辿ることでその意味、ユースケースを理解することが出来、ansibleとも少し仲良くなれた気がします。

また今回ソースを読んで自分なりに理解することで少し自信にもなりましたので、今後は積極的にソースを読んでいきたいと思いました。


ちなみに「半角スペースが追加される問題」は[ansibleのGitHubリポジトリ](https://github.com/ansible/ansible/commit/b72e989e1837ccad8dcdc926c43ccbc4d8cdfe44)では修正されているような雰囲気です。
(今回使用したpipの2.7.5では未修正でした)