Ansible v2でPythonじゃない自作モジュールを使う際の注意点

:warning: **これはアルファ時点の古い情報です。Ansible 2.0.0正式版では、旧スタイルのモジュールも動く様に対応されています。**
**ただし、これから作る自作モジュールのスタイルとしては`new`, `non_native_want_json`が推奨される点は変わりませんので、記事は残しておきます。**

Ansible 2.0.0のalpha版が出たので、そろそろ本腰入れて使ってみようと思ったら、bashスクリプトで書いた自作モジュールが動かなくなっていたので、対応方法のメモ。

# 自作モジュール: parrot
ここでは、以下の様な与えられた引数をオウム返しするだけの`parrot`と言うカスタム・モジュールを例にして話を進めます。
（ちなみに、このまま使うと値にスペースが入る場合には正しく動かないです）

```bash:parrot
#!/bin/bash

# 引数ファイルが与えられなかったらエラー
if [ -z $1 ]; then
    echo "{\"failed\": true, \"msg\": \"no arguments provided.\"}"
    exit 1
fi

result="{\"changed\": false"
# 引数をkey, valueに分解してresultに突っ込む
for kv in $(cat $1); do
    echo $kv
    escaped=$(echo $kv | sed -e 's/"/\\\"/g')
    key=${escaped%%=*}
    value=${escaped#*=}
    if [ $key ]; then
        result+=", \"$key\": \"$value\""
    fi
done
result+="}"

echo $result
exit 0
```

# 現象

### 1.9.2では動く
この`parrot`モジュールをAnsible 1.9.2で実行すると、下の様に入力した引数がそのまま実行結果として出力されます。

```shell-session
$ ansible localhost -m parrot -a 'foo=bar hoge=fuga'

localhost | success >> {
    "changed": false,
    "foo": "bar",
    "hoge": "fuga"
}
```

### 2.0.0だと動かない
ところが、Ansible 2.0.0で全く同じ様に実行しても、引数が与えられていないと怒られてしまいした！

```shell-session
ansible localhost -m parrot -a 'hoge=fuga foo=bar'

localhost | FAILED! => {
    "changed": false,
    "failed": true,
    "msg": "no arguments provided."
}
```

### エラーの原因
これは一体どうしたことだろうと、`-vvv`を付けて、実行されているコマンドを確認してみると…

```shell-session
# 1.9.2の場合
<localhost> EXEC ['/bin/sh', '-c', u'LANG=en_US.UTF-8 LC_CTYPE=en_US.UTF-8 /bin/bash /tmp/ansible-tmp-1441005841.0-58446508593101/parrot /tmp/ansible-tmp-1441005841.0-58446508593101/arguments; rm -rf /tmp/ansible-tmp-1441005841.0-58446508593101/ >/dev/null 2>&1']

# 2.0.0の場合
localhost EXEC LANG=en_US.UTF-8 LC_MESSAGES=en_US.UTF-8 LC_CTYPE=en_US.UTF-8 /bin/bash /tmp/ansible-tmp-1441005817.19-202560708428255/parrot; rm -rf "/tmp/ansible-tmp-1441005817.19-202560708428255/" >/dev/null 2>&1
```

上の実行結果をよく見てみると、1.9.2では`parrot`への第一引数として`arguments`と言うファイルが渡されているのに対して、2.0.0では`parrot`が引数なしで実行されているのがわかります。
`parrot`内では第一引数で渡されたパスのファイルを読みに行ってansible引数を取得しているので、argumentsファイルが存在しないと当然エラーになってしまいます。

# ファイル経由の引数渡しはv2では使えない！
では、なぜv2では`arguments`ファイルが渡されていないのでしょうか？
実はAnsible v1のモジュールには3つの異なるスタイルが存在し、それぞれ異なる形式で引数情報が渡されています。

### Ansibleモジュールの3つのスタイル
* old
    * 1.4未満で使われていた形式
    * モジュールへの引数はスペース区切りの`arguments`ファイルで渡される
        * e.g. `hoge=fuga foo=bar`
    * スペース入りの値などの扱いが困難
    * `parrot`モジュールはこの形式
* non_native_want_json
    * `old`と同様に引数は`arguments`ファイル経由で渡される
    * `old`と違って、`arguments`ファイルの中身はJSON
        * e.g. `{"foo": "bar", "hoge": "fuga"}`
    * モジュール内に`WANT_JSON`と書かれている場合にこのスタイルになる。
* new
    * 1.4以降の組み込みモジュールで使われている形式
    * 変数系の情報は下で説明する仕様でモジュールファイル内に展開される
    * 引数用の外部ファイルは使われない。
    * モジュール内に`#<<INCLUDE_ANSIBLE_MODULE_COMMON>>`と書かれている場合にこのスタイルになる。

ソースを確認したところ、v2では`arguments`ファイル経由での引数渡しが使えなくなっているようです。
具体的な後方互換ポリシーについては未確認ですが、一度削除されたファイル経由での引数渡しが今後v2で再実装される見込みは薄いでしょう。

### v1でのモジュール内文字列展開の仕様
* `"<<ANSIBLE_VERSION>>"`: モジュールを実行するAnsibleのバージョン文字列
* `"<<INCLUDE_ANSIBLE_MODULE_ARGS>>"`: モジュールに与えられた名前なし引数文字列
* `"<<INCLUDE_ANSIBLE_MODULE_COMPLEX_ARGS>>"`: モジュールに与えられた名前付き引数のpythonコード用JSON文字列（reprを使っている）
* `<<SELINUX_SPECIAL_FILESYSTEMS>>`: SELinux情報

### v2でのモジュール内文字列展開の仕様変更点
なお、v2では展開の仕様にもいくつか変更点がありました。

* 追加された展開パターン
    * `<<INCLUDE_ANSIBLE_MODULE_WINDOWS_ARGS>>`
        * PowerShell用の名前付き引数のJSON文字列
        * `"<<INCLUDE_ANSIBLE_MODULE_COMPLEX_ARGS>>"`との違いは、引用符が含まれない~~だけ~~
        * `"<<INCLUDE_ANSIBLE_MODULE_COMPLEX_ARGS>>"`はpythonコード用だが、こちらは単純にダンプされたJSON
* 削除された展開パターン
    * `"<<INCLUDE_ANSIBLE_MODULE_ARGS>>"`
        * そもそもv2では`command`系などの一部の組み込みモジュール以外では、名前なし引数の利用が許可されていません
    * `<<SELINUX_SPECIAL_FILESYSTEMS>>`

# 自作モジュールを新方式に対応させる
ということで、`parrot`をv2でも使えるようにするには1ファイル内で引数を受け取れるようにモジュールを修正するしかなさそうです。
~~モジュール内の文字列展開はバージョン1.4以降であればv1でも使えますので、v1/v2間での互換性についてはそんなに気にする必要はなさそうです。~~
嘘でした。
`"<<INCLUDE_ANSIBLE_MODULE_COMPLEX_ARGS>>"`はpython内でそのまま使えるように`repr`を使っているので、`{"foo": "bar\"baz"}`の様なJSONの場合、`'{"foo": "bar\\"baz"}'`の様にエスケープされてしまいます。
そのため、v2用には`<<INCLUDE_ANSIBLE_MODULE_WINDOWS_ARGS>>`を使い、v1用に`non_native_want_json`を使う、と言う対応が必要になりそうです。
~~Windows用じゃないモジュールに`<<INCLUDE_ANSIBLE_MODULE_WINDOWS_ARGS>>`を入れるというのは気持ちが悪いけれど…~~
2015/09/11時点のdevelブランチ最新では代わりに`<<INCLUDE_ANSIBLE_MODULE_JSON_ARGS>>`という表記も可能になっています。

### 修正されたparrot
という訳で、修正後の`parrot`はこんな感じになりました。

```bash:fixed_parrot
#!/bin/bash
# WANT_JSON

ANSIBLE_VERSION="<<ANSIBLE_VERSION>>"

# jqがなかったらエラー
if ! type jq >/dev/null 2>&1; then
    echo "{\"failed\": true, \"msg\": \"jq not found.\"}"
    exit 1
fi

# v1の場合は$1に引数ファイルのパスが入る
if [[ $ANSIBLE_VERSION == 1.* ]]; then
    MODULE_COMPLEX_ARGS=$(cat $1)
# v2の場合はINCLUDE_ANSIBLE_MODULE_JSON_ARGSを使う
else
    MODULE_COMPLEX_ARGS=$(cat << 'EOF'
<<INCLUDE_ANSIBLE_MODULE_JSON_ARGS>>
EOF
                       )
fi

echo $MODULE_COMPLEX_ARGS | jq '.changed=false'
exit 0
```

JSONの操作については、[jq](https://stedolan.github.io/jq/)を使っちゃってます。
自分の場合、Ansibleモジュールをわざわざbashで書きたいのって、CoreOSの操作くらいなんですが、CoreOSには幸い`jq`がインストールされているので楽しました。

ポイントとしては

* `WANT_JSON`コメントで`non_native_want_json`スタイルを適用
* `ANSIBLE_VERSION`の値によって、引数の取得方法を場合分け

と言ったあたりでしょうか。

# まとめ
今回はbashスクリプトを例にとって自作モジュールのv2対応法を試してみましたが、このやり方は組み込みでヘルパーが用意されているPythonやPowerShell以外でモジュールを作る場合にはそのまま適用可能なものです。
v2への移行は時期尚早と言う場合でも、`WANT_JSON`コメントを使って引数をJSONとして取得する方法は、JSONパーサーが使える言語/環境であれば是非とも導入すべきかと思います。

# 参考
https://gist.github.com/mildred/9ce28b926b567548037d (モジュール・スタイルごとの挙動の違いについて）

# 追記（2015/09/11）
`<<INCLUDE_ANSIBLE_MODULE_WINDOWS_ARGS>>`がイケていなかったので、`<<INCLUDE_ANSIBLE_MODULE_JSON_ARGS>>`と言うリプレイサーを足したら、取り込んでくれました。
https://github.com/ansible/ansible/pull/12267