Ansibleの Developing Modules を和訳してみた

ちょっと訳があって、Ansibleのモジュール開発方法を解説している Developing Modules を和訳してみた。だいたいあってると思うんだけど、意図を計りかねるところもあったので、想像しながら意訳してみた。それほど外れてないと思う。

Developing Modules

Ansibleのモジュールは再利用可能な機能単位で、ansibleコマンドやansible-playbookコマンド、そしてAnsibleのAPI経由で呼び出して利用することができます。

  • 参考情報: About Modules にモジュール開発に役立つ情報がリストアップされています

モジュールは、さまざまなプログラム言語で記述できます。モジュールのサーチパスは、環境変数ANSIBLE_LIBRARYか、コマンドラインオプションの--module-pathで指定します。

ansible-modules-core, ansible-modules-extrasは、ansible本体と一緒にデフォルトのサーチパスにあるライブラリパスにインストールされますが、独自のパスを利用シたい場合は、上記のような方法で追加する必要があります。 あなたのPlaybookのトップレベルディレクトリにある libraryディレクトリは、デフォルトでモジュールのサーチパスに入っています。

モジュールを作成したら、modules-extras projectにPull Requestを出してみてください。Ansibleのモジュールプロジェクトには、より一般的に利用されているモジュール群が登録されているmodules-core projectがあります。Extrasモジュールは、定期的にCoreモジュールに昇格する場合がありますが、これらのモジュールに決定的な違いはありません。どちらもAnsible本体と一緒にパッケージングされます。

Tutorial

まず最初に、現在のシステムの時刻を取得するモジュールを作成してみましょう。 ここではPythonを利用しますが、ファイルに対するI/O制御と、標準出力への出力機能を持っている他の言語(例えば、bashなどのシェルスクリプト,C++,clojure,Rubyなどなど)で、モジュールを記述することが可能です。

Pythonでモジュールを記述する場合、Coreモジュールが共通で利用しているモジュールとして最低限必要となる基本的な機能(さまざまな関数やクラスなど)を、同じようにimportして利用することができますが、Python以外の言語では、このようなショートカットは提供されません。そこで、まずは、このような便利な利用せずにモジュールを記述してみましょう。ショートカットを利用する方法は、のちほど紹介します。

本来、このような場合だとcommandモジュールを利用すれば、この例のようにシステム時刻を取得するようなモジュールは必要ありませんが、ここでは、あえて作成してみることにします。

CoreやExtrasなどのAnsibleのモジュールのコードは、モジュールを書くための良い教材です。コードを読みましょう。目的にもよりますが、async_wrapperのように、ユーザが直接利用しないモジュールは参考にしないほうが良いでしょう。参考にするなら、serviceモジュールやyumモジュールあたりがオススメです。

それでは、Pythonで書かれたサンプルコード(timetest.py)を見てみましょう。

#!/usr/bin/python

import datetime
import json

date = str(datetime.datetime.now())
print json.dumps({
    "time" : date
})

Testing Modules

Ansibleと一緒に提供される、テストスクリプトをチェックアウトして利用します。

(注1) --recursiveオプションを付与してgit cloneを実行すると、Ansible本体と同時にCoreとExtraモジュールもあわせてチェックアウトしてくれます。

(注2)ここではv1系のAnsibleでテストするため、オリジナルのドキュメントのパスから変更しています。

$ git clone git@github.com:ansible/ansible.git --recursive
$ source ansible/v1/hacking/env-setup

test-moduleスクリプトで、先ほど作成したtimetest.pyモジュールを実行してみましょう。

$ ansible/v1/hacking/test-module -m ./timetest.py

以下のような結果が出力されます。

(注3) 2015-07-14時点でのテストスクリプト実行結果に差し替えています

* including generated source, if any, saving to: /Users/saitou/.ansible_module_generated
* this may offset any line numbers in tracebacks/debuggers!
***********************************
RAW OUTPUT
{"time": "2015-07-14 16:52:13.819876"}


***********************************
PARSED OUTPUT
{
    "time": "2015-07-14 16:52:13.819876"
}

モジュールが動作しなかった場合は、コードのtypoなどを再確認してくださいね。

Reading Input

次に、現在時刻を変更してみましょう。変更する時刻は、キーになるパラメータ名と、その値となる時刻のペアを time=<string> 形式でモジュールに渡されます。

Ansibleは、モジュールに渡されたパラメータを引数ファイルに保存します。モジュールは、この引数ファイルを読み込んで適切にパースしてやる必要があります。引数ファイルは、ただのテキストファイルですから、パーサ次第でさまざまな形式に対応できますが、ここでは基本的な key=value 形式の引数をパースすることにします。

時刻を設定するためのサンプルは、以下の通りです。

time time="March 14 22:10"

もしも、timeパラメータが指定されなかった場合は、現在時刻を取得して返すことにしましょう。

  • メモ: 時刻の設定や取得をするなら、shellモジュールを使うべきですが、ここではチュートリアルのために、あえて新たにモジュールを作成することにしました。

以下のサンプルモジュール(time)を見てください。このモジュールの動きを、コード内のコメントとして記載しています。また、このモジュールのコードは、若干冗長に感じるかもしれませんが、これは、モジュール作成のチュートリアルに利用するためです。このサンプルコードは、実際には、もう少し短くシンプルに書くことができます。

(注4) MacOS Xの場合は、rc = os.system("date -s \"%s\"" % value) を rc = os.system("date \"%s\"" % value)としてください。

#!/usr/bin/python

# import some python modules that we'll use.  These are all
# available in Python's core

import datetime
import sys
import json
import os
import shlex

# read the argument string from the arguments file
args_file = sys.argv[1]
args_data = file(args_file).read()

# for this module, we're going to do key=value style arguments
# this is up to each module to decide what it wants, but all
# core modules besides 'command' and 'shell' take key=value
# so this is highly recommended

arguments = shlex.split(args_data)
for arg in arguments:

    # ignore any arguments without an equals in it
    if "=" in arg:

        (key, value) = arg.split("=")

        # if setting the time, the key 'time'
        # will contain the value we want to set the time to

        if key == "time":

            # now we'll affect the change.  Many modules
            # will strive to be 'idempotent', meaning they
            # will only make changes when the desired state
            # expressed to the module does not match
            # the current state.  Look at 'service'
            # or 'yum' in the main git tree for an example
            # of how that might look.

            rc = os.system("date -s \"%s\"" % value)

            # always handle all possible errors
            #
            # when returning a failure, include 'failed'
            # in the return data, and explain the failure
            # in 'msg'.  Both of these conventions are
            # required however additional keys and values
            # can be added.

            if rc != 0:
                print json.dumps({
                    "failed" : True,
                    "msg"    : "failed setting the time"
                })
                sys.exit(1)

            # when things do not fail, we do not
            # have any restrictions on what kinds of
            # data are returned, but it's always a
            # good idea to include whether or not
            # a change was made, as that will allow
            # notifiers to be used in playbooks.

            date = str(datetime.datetime.now())
            print json.dumps({
                "time" : date,
                "changed" : True
            })
            sys.exit(0)

# if no parameters are sent, the module may or
# may not error out, this one will just
# return the time

date = str(datetime.datetime.now())
print json.dumps({
    "time" : date
})

それでは実行してみましょう。システムの時刻を修正します。

(注5) sudo権限が必要です。影響範囲が大きいので、テストであっても、あまり大幅なシステム時刻の変更はしないようにしましょう。

sudo ansible/v1/hacking/test-module -m ./time -a time=\"0714195315\"

実行結果は以下の通りです

(注6) 2015-07-14時点でのテストスクリプト実行結果に差し替えています

(注7) -a オプションで、time引数を与えなかった場合は、現在時刻を出力して正常終了します。

* including generated source, if any, saving to: /Users/saitou/.ansible_module_generated
* this may offset any line numbers in tracebacks/debuggers!
***********************************
RAW OUTPUT
2015年 7月14日 火曜日 19時53分00秒 JST
{"changed": true, "time": "2015-07-14 19:53:00.002979"}


***********************************
PARSED OUTPUT
{
    "changed": true,
    "time": "2015-07-14 19:53:00.002979"
}


$ sudo ansible/v1/hacking/test-module -m ./time
* including generated source, if any, saving to: /Users/saitou/.ansible_module_generated
* this may offset any line numbers in tracebacks/debuggers!
***********************************
RAW OUTPUT
{"time": "2015-07-15 11:23:53.311061"}


***********************************
PARSED OUTPUT
{
    "time": "2015-07-15 11:23:53.311061"
}

Module Provided 'Facts'

setupモジュールを利用すると、Ansibleが動作するシステムのさまざまなパラメータを取得することができます。この情報は、Playbookとtemplateモジュールで利用するtemplateの中で再利用することが可能です。さらに、Ansibleのシステムに手を加えることなしに、独自のパラメータを追加することができます。この場合、モジュールの戻り値として ansible_factsキーと、その値として、再利用したい独自パラメータを辞書型のデータを設定してやります。

{
    "changed" : True,
    "rc" : 5,
    "ansible_facts" : {
        "leptons" : 5000,
        "colors" : {
            "red"   : "FF0000",
            "white" : "FFFFFF"
        }
    }
}

これらのパラメータは、setupモジュールの実行直後から、Playbook内で利用可能となります。site_factsと呼ばれるモジュールを作成して、Playbookの先頭で実行して独自のパラメータを設定し、以降のセクションで利用するのは良い考えです。そして、われわれは、Ansibleのシステム情報を収集するセクションの改善に関して、常に門戸を開いています。

Common Module Boilerplate

最初に書いたとおり、Pythonでモジュールを記述するときは、非常に強力な共通モジュールを利用することができます。依然として、モジュールは1つのファイルとしてターゲットノードに転送されますが、パラメータファイルは不要となります。これにより、モジュールコードを簡単に記述できるだけでなく、モジュールの実行時間短縮という面でも効果があります。尚、ここでは、上記に関して詳細には説明しません。最良の学習方法は、Coreモジュールのソースコードを読むことです。

groupモジュールやuserモジュールを例として、説明が必要となる点を紹介します。重要な箇所は、以下のとおりで、モジュールの最後の部分に記述されています。

from ansible.module_utils.basic import *
if __name__ == '__main__':
    main()

さらに、興味深いのはAnsibleModuleクラスのインスタンスであるmoduleです。

module = AnsibleModule(
    argument_spec = dict(
        state     = dict(default='present', choices=['present', 'absent']),
        name      = dict(required=True),
        enabled   = dict(required=True, choices=BOOLEANS),
        something = dict(aliases=['whatever'])
    )
)

AnsibleModuleクラスは、モジュールに渡される引数や、実行結果を返す際の戻り値をハンドリングするための多くの機能を提供しており、引数のチェックなどを簡単に行うことができます。

モジュールの実行が成功した場合の戻り値を生成するコードは、以下のとおりです。

module.exit_json(changed=True, something_else=12345)

モジュールの実行が失敗した場合の戻り値を生成するコードは以下のようにシンプルなものです。このとき、必須のパラメータとして、msgにエラーの概要を設定します。

module.fail_json(msg="Something fatal happened")

その他にも、便利な機能としてmodule.sha1(path)のようなクラスががあります。詳しくは lib/ansible/module_common.py をチェックアウトして実装を確認してみてください。

構築したモジュールをテストするための最も良い方法は、hacking/test-moduleスクリプトをチェックアウトして利用することです。そして、このスクリプトが、Ansible本体以外から独立してモジュールをテスト実行するための唯一の手段です。

独自に作成したモジュールを、Ansibleのソースツリーにマージしてほしいなら、AnsibleModuleクラスを利用していることが必須要件となっています。

Check Mode

モジュールでは、オプションとしてチェックモードをサポートしています。あなたのモジュールで、チェックモードをサポートしたければ、AnsibleModuleオブジェクトのインスタンス生成時に、引数として supports_check_mode=True を渡す必要があります。チェックモードが指定されているか否かは、AnsibleModule.check_modeがTrueになっていることで判定できます。以下がその例です。

module = AnsibleModule(
    argument_spec = dict(...),
    supports_check_mode=True
)

if module.check_mode:
    # Check if any changes would be made but don't actually make those changes
    module.exit_json(changed=check_if_system_state_would_be_changed())

モジュールの開発者には、チェックモードが有効化されているときに、システム状態が変更されないことを保証する責任があります。

あなたのモジュールで、チェックモードをサポートしていない場合に、利用者がAnsibleでチェックモードを指定すると、Ansibleは単純にモジュールの実行をスキップします。

Common Pitfalls

モジュール内に、以下のようなコードを書いてはいけません。なぜなら、モジュールの出力はJSON形式でしか認められていないからです。

print "some status message"

モジュールは、いかなるメッセージも標準エラー出力に出力すべきではありません。なぜなら、最終的に標準出力と標準エラー出力はマージされてしまうので、JSON形式でのメッセージパースを妨げてしまう原因となるからです。もしも、stderrや、その他の出力が有効なJSON形式で返された場合、その出力はAnsibleの実行結果として表示されるでしょうが、実際のコマンド実行は失敗に終わります。

このスクリプトは、さまざまな問題を警告してくれます。開発中のモジュールをテストする時は、必ずtest-moduleスクリプトを利用しましょう。

Conventions/Recommendations

これまで紹介したサンプルコードを思い出しつつ、モジュール開発の際の、基本的な規則とガイドラインを以下にまとめました。

  • Playbookに配置したモジュールは、実行時に、name:パラメータに設定した文字列で呼び出されます(たとえばhandlerセクションに書くhandlerなど)。これはモジュールの別名のようなものです。
  • もしも、あなたが独自のパラメータをfactsとして返すモジュールを配置するのであれば、モジュール名は site_facts とするのが良いでしょう。
  • モジュールのbolean型の値は、デフォルトの'yes','no','on','off','true','false','1','0',1,0 の他、AnsibleModuleクラスで定義されている、choicesパラメータとboolean関数で定義をオーバーライドすることができます。
  • 依存関係を少なくするために、インクルードするモジュールは最小限にしましょう。依存関係がある場合は、モジュールの先頭にコメントとして記述します。また、インポートに失敗した場合は、ImportErrorをキャッチして、はJSONでエラーメッセージを返してください(module.fail_json(msg))。
  • モジュールは、Ansibleが自動転送できるように、必要なコードをすべて含んだ、1つのファイルで構成しなければなりません。
  • 作成したAnsibleのモジュールを、RPMでパッケージ化して配布するのもあなたの自由です。その場合は、Ansibleが動作するホストの/usr/share/ansible/以下に配置してください。
  • モジュールからの出力は、JSON形式のみが許可されています。すべての戻り値は辞書型のデータ構造でなければなりません。なお、この辞書型データはネストさせることができます。配列はスカラはサポートされませんが、たとえば配列を辞書型データの値として利用することはできます。
  • 実行時の失敗通知には、msgパラメータに設定する概要説明と、'failed'キーを一緒に含まなければなりません。トレースバックを発生させるモジュールは、一般的に見て行儀の悪いモジュールです。しかし、AnsibleModuleクラスを利用していれば、トレースバックのような解析できないメッセージを、自動的に実行失敗時の戻り値として変換し、AnsibleModuleクラスで定義されているfail_json()を呼び出して返してくれます。
  • モジュールのリターンコードは、実際には重要ではありませんが、0が成功、それ以外を失敗として利用するのは、将来も継続して保証される可能性が高いです。
  • 多くの実行対象ホストからの出力が集約できるように、モジュールは関連のある情報のみを出力ようにするべきです。あらゆる情報を含んでいるログのような出力を返すのは、お行儀が良いとはいえません。

Documenting Your Module

Coreモジュールは、DOCUMENTATIONにモジュールの概要説明がYAML形式で定義されている必要があります。YAMLの編集に対応したテキストエディタを使えば、キーワードがハイライトされるので便利です。モジュール内のDOCUMENTATION変数に文字列として代入する前に、あらかじめYAML形式に沿っているかどうかをチェックしておきましょう。

Example

概要説明のサンプルが examples/DOCUMENTATION.yml <https://github.com/ansible/ansible/blob/devel/examples/DOCUMENTATION.yml>_ にあるので、checkoutして見てみましょう。

あなたのモジュール内のDOCUMENTATION変数に、以下のような概要説明を設定します。

#!/usr/bin/python
# Copyright header....

DOCUMENTATION = '''
---
module: modulename
short_description: This is a sentence describing the module
# ... snip ...
'''

descriptionnotes フィールドの記述には、いくつかの特別なマクロを利用することができます。

U()はURL、M()はモジュール、I()はイタリック、そしてC()は等幅フォーマット表記に置換されるマクロです。C()はファイル名やオプション名に利用し、I()にはパラメータを、モジュールの名前の指定にはM(module)を利用しましょう。

コロンや'を含む、利用方法のサンプルをYAML形式で記述するのは難しいですが、これらは以下の通り、モジュール内のEXAMPLES変数にプレーンテキストで設定します。

EXAMPLES = '''
- action: modulename opt1=arg1 opt2=arg2
'''

モジュールをPull Requestするためには、EXAMPLES変数とDOCUMENTATION変数に、そのモジュールの適切な概要説明や利用方法が設定されている必要があります。

Building & Testing

作成したモジュールを'library'ディレクトリ以下に配置して、make webdocsコマンドを実行すると、'docsite/'ディレクトリ以下に'modules.html'ファイルが作成されます。

tip

もしも、あなたがYAMLの構文に問題を抱えているなら、 YAML Lint <http://www.yamllint.com/>_ を利用して、構文をチェックすることができます。

tip

シェルの環境変数ANSIBLE_KEEP_REMOTE_FILESに1を設定しておくと、Ansibleが実行後のモジュールを削除するのを防ぐことができ、モジュールのデバッグに役立ちます。

Module Paths

あなたのモジュールを、Ansibleが見つけられない問題が発生しているなら、そのモジュールが、シェルの環境変数ANSIBLE_LIBRARY_PATHのパスに含まれていることを確認してください。

あなたがモジュールプロジェクトをforkしている場合は、以下のように設定します。

ANSIBLE_LIBRARY=~/ansible-modules-core:~/ansible-modules-extras

このようにすれば、forkしたモジュールプロジェクトがAnsibleの標準のモジュールよりも先にロードされます。このような状況では、forkしたバージョンのバグを報告しないように注意が必要です。

あなたが開発しているプロジェクトに対して、Ansibleの正規のバージョンとは別のバージョン名をつけるのは悪くない考えです。どのバージョンをPullすれば良いか理解できているはずなので、間違いを犯すことはないでしょう。これは安全性の観点では悪くない考えです。

Getting Your Module Into Ansible

他のモジュールとの依存関係を最小限にした高品質なモジュールは、AnsibleのCore,Extrasリリースに含めることもできます。ただし、このようなモジュールは、Pythonで記述されAnsibleModuleクラスが提供している機能を適切に利用しており、その他の部分も首尾一貫した記述になっていなければなりません。必要ならメーリングリストで要件を確認して、github上にある Extras <https://github.com/ansible/ansible-modules-extras>_ プロジェクトにPull Requestを投げてください。Extrasプロジェクトに含まれるモジュールは、Coreモジュールに昇格するチャンスがあります。CoreとExtrasは同じ品質ですが、開発の優先度はCoreモジュールの方が、わずかに高く設定されています。

Module checklist

  • スクリプトの1行目は、つねに #!/usr/bin/python であるべきです。この場合、常に ansible_python_interpreter に設定されたPythonインタプリタが利用されます。

  • 以下のポイントを確認してください。

    • モジュールのパラメータが必須の場合は、requiredに true を、必須でない場合は false のどちらかを必ず設定します。
    • required を false に設定した場合は、default に 'null' であってもデフォルト値を設定する必要があります。
    • required を 'true' に設定した場合は、default にデフォルト値を設定する必要はありません。
    • 何も設定していないのにもかかわらず、 aliases: []choices: [] のようなパラメータを記述する必要はないので削除しましょう。
    • バージョンは浮動小数点ではなく、カレントの開発バージョン番号です。
    • モジュールの実際の引数が、ドキュメントと同じ仕様であることを確認しましょう。
    • パスワードのような機密情報を引数に指定する場合は、 no_log=True に設定しましょう。
    • 依存しているライブラリなどは、requirements=[] パラメータに記述しましょう。
    • Authorには、あなたのgithubのIDを設定しましょう。
    • U(),C(),I(),M()マクロをちゃんと使っていますよね?
    • モジュールの先頭部分のコメントで、GPLライセンスであることを記載しましょう。
    • そのモジュールはチェックモードを利用していますか? チェックモードを利用するように修正されていますか? それをドキュメントに書いておきましょう。
    • Examples: に記述されている実行例は、ちゃんと動きますか?
    • Returns: にモジュールの実行結果の戻り値の概要説明を書きましょう。
  • モジュール内では適切な例外処理を行わなければなりません。
    • 例外発生時に、どのような状態だったかというような有用なメッセージを msg に設定して返しましょう。
    • 全ての例外を一括処理するfinallyのように、有用なエラーメッセージを含んでいない例外処理方法は避けましょう。
  • モジュールには、sys.exit()を使ってはいけません。module.fail_json()を使いましょう。
  • main()内で、カスタムモジュールをインポートした場合の例外のハンドリング例は以下の通りです。HAS_LIBをmodule.fail_json()で返します。

.

try:
    import foo
    HAS_LIB=True
except:
    HAS_LIB=False
  • 戻り値のキーとして、NAやNoneが使われるような場合は、通常は他の値として定義するなど、データ構造には一貫性をもたせる必要があります。

  • そのモジュールは冪等性を担保していますか?もしも担保していなければ、ドキュメントに明記しましょう。

  • コードの末尾付近にある from ansible.module_utils.basic import * には、さまざまな関数が含まれています。これを利用すれば、デバッグのためのコード記述量を少なくできます。

  • 以下のように、以下のようなif文による条件でmain()関数を呼び出すことが可能です。

.

if __name__ == '__main__':
    main()
  • 他のモジュールとの整合させたいなら、あなたのモジュールのパラメータに aliases オプションで別名を設定することができます。

  • pep8の規約に準拠していることは、良いことですが必須ではありません。具体的に1行の長さを80文字までとする条件は、可読性の向上という観点では邪魔となります。

  • action/commandモジュールを使うは避けましょう。それと同じような機能を提供する他の方法があります。

  • あるモジュールのために利用する固有の情報を取得するような場合は、そのモジュールとは別に、モジュール名_factsモジュールを作成して、情報を収集させましょう。

  • モジュールの中から別のモジュールを実行したい...そんな時は、Playbookのroleセクションを書きましょう。

Deprecating and making module aliases

Ansible v1.8以降では、モジュール名の先頭に_を付与することで、そのモジュールを非推奨扱いにすることができます。たとえば、 old_cloud.py_old_cloud.py に変更すると、モジュールとしては有効で利用可能な状態ですが、主要なドキュメントやモジュールリストからは除外されます。

また、モジュールの別名として、_で始まるシンボリックリンクを利用して、古い名前を維持することもできます。以下のサンプルは、statモジュール呼び出す際に、別名のfileinfoを利用することができます。

EXAMPLES = '''
ln -s stat.py _fileinfo.py
ansible -m stat -a "path=/tmp" localhost
ansible -m fileinfo -a "path=/tmp" localhost
'''

参考となるサイトへのリンク

modules

モジュールに関する情報

developing_plugins

プラグインを開発するのに必要な情報

developing_api

PythonのコードからAnsibleのAPIを利用するための情報

GitHub Core modules directory

Coreモジュールのソースコードリポジトリ

Github Extras modules directory

Extrasモジュールのソースコードリポジトリ

Mailing List

開発者向けのメーリングリスト

irc.freenode.net

AnsibleのIRCチャンネル