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


この記事はAnsible Blogger Advent Calendar 2018 の23日目の記事です。

はじめに

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

しかし、Ansibleでヒアドキュメントを使用する場合、公式には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(2018/12/16現在で最新)を使用します。

shellモジュールでヒアドキュメントが使えない問題

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

---
- 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が立てられていました。
そしてshellモジュールの引数cmdを使うことで解決できるとあります。


---
- 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って何者なんでしょう?実はこれ公式ドキュメントにもansible-doc shellでも記載されていません。

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

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

ソースからshellモジュールの引数cmdを調べてみた

結論から言うと、

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

という流れで処理されていることが分かり、自分なりに納得できる答えが得られました。
つまり引数cmd

コマンドからスペース、改行を維持したまま実行したい場合に使用する

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

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

1. vvvオプションで実行

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

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.pycmdpopしてargs['_raw_params']に格納しているコードがありました。怪しいですね。
t

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

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()の結果が格納されています。

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)の結果が格納されていることが分かります。

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されています。

module_utils/splitter.py
items = args.strip().split('\n')
module_utils/splitter.py
        tokens = item.strip().split(' ')

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

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リポジトリでは修正されているような雰囲気です。
(今回使用したpipの2.7.5では未修正でした)