はじめてのAnsibleのモジュール開発

はじめに

Ansibleのモジュールは、ansibleコマンドやansible-playbookコマンドから利用されるスクリプトです。

モジュールは、引数を受け取り、何らかの処理を実行した後に、標準出力にJSON形式で実行結果を出力します。

+---------+                               +----------------+
| ansible +---{引数を格納したファイル}--->| module program |
+----^----+                               +--------+-------+
     |                                             |
     +----------{戻り値:stdout(JSON形式}-----------+

引数

引数は以下のようにハンドリングされます。(※v2系の場合)

  • モジュールには、引数としてkey=value形式のパラメータを渡すことができます
  • 引数は、パラメータファイル(ファイル名:args)に書き込まれます
  • Ansibleは、モジュールスクリプト実行時の引数(sys.argv[1])として、このファイル名を指定することで、パラメータファイル名をモジュールに渡します
  • モジュールは、このファイルを実行時に読み込んで、パラメータを利用します

戻り値

JSON形式のデータ構造を標準出力(stdout)に出力することで、モジュールの実行結果をansibleコマンドやansible-playbookコマンドに渡すことができます。

はじめてのAnsibleモジュール

本家のチュートリアルのサンプルモジュール(timetest.py)は、以下のとおりです。このモジュールは、実行環境に対して変更を加えないので、changedパラメータはFalseとして設定しています。

※本家のコードに対してchangedパラメータを追加しています

#!/usr/bin/python

import datetime
import json

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

このサンプルモジュールは、引数は受け取らず、現在時刻を取得して戻り値として返します。この戻り値は、前述の通りJSON形式である必要があるため、json.dumps()を利用して、データ構造をJSON形式に変換して標準出力に出力しています。

このサンプルモジュール(timetest.py)をカレントディレクトリに配置して、ansibleコマンドを実行してみましょう。

% echo "127.0.0.1 ansible_connection=local" | tee hosts
127.0.0.1 ansible_connection=local
% ansible localhost -i hosts -M . -m timetest.py
localhost | SUCCESS => {
    "changed": false,
    "time": "2017-01-19 14:01:30.328960"
}

想定通りの実行結果(現在時刻)が得られました。

モジュールにパラメータを指定する

モジュールに渡されたパラメータは、モジュールと同じディレクトリに“args”というファイル名で配置されます。Ansibleは、モジュール実行時に、このファイル名を最初の引数に指定して実行します。(※v2系の場合)

#!/usr/bin/python
"""
Description:
  Sample module for checking how to handle command-line options.
"""
from __future__ import print_function

import json
import shlex
import sys


def main():
    result = dict()
    result['changed'] = False
    result['message'] = 'Hello, World!'

    with open(sys.argv[1], 'rb') as args_file:
        args_buffer = args_file.read()

    for arg in shlex.split(args_buffer):
        (key, value) = arg.split('=')
        if key == 'name':
            result['message'] = 'Hello, {name:s}!'.format(name=value)

    print(json.dumps(result))


if __name__ == '__main__':
    main()

それでは、以下のサンプルモジュール(paramtest.py)をカレントディレクトリに配置して、ansibleコマンドを実行してみましょう。

% ansible localhost -i hosts -M . -m paramtest.py -a name=Hideki
localhost | SUCCESS => {
    "changed": false,
    "message": "Hello, Hideki!"
}

argsファイルの実際の内容は、以下のようにスペースで区切られた key=value形式となっています。このサンプルモジュールも、実行環境に変更を加えないため、changedパラメータはFalseとしています。

_ansible_version=2.2.1.0 _ansible_selinux_special_fs='['"'"'fuse'"'"', '"'"'nfs'"'"', '"'"'vboxsf'"'"', '"'"'ramfs'"'"']' name=Hideki _ansible_module_name=paramtest.py _ansible_verbosity=0 _ansible_syslog_facility=LOG_USER _ansible_diff=False _ansible_debug=False _ansible_check_mode=False _ansible_no_log=False

ここから、キーであるnameの値を取り出して戻り値にセットしています。

実行環境の状態を変化させる

次に、実行環境を変更するモジュールを書いていましょう。このサンプルモジュール(touchtest.py)は、以下のような仕様とします。

  • nameパラメータに指定したファイル名で空ファイルを作成する(changed: True)
  • nameパラメータに指定したファイル名と同名のファイルが存在していたら何もしない(changed: False)
#!/usr/bin/python
"""
Description:
  Sample module for checking how to change your system.
"""
from __future__ import print_function

import json
import shlex
import os
import sys


def main():
    result = dict()
    result['changed'] = False

    filename = None
    with open(sys.argv[1], 'rb') as args_file:
        args_buffer = args_file.read()

    for arg in shlex.split(args_buffer):
        (key, value) = arg.split('=')
        if key == 'name':
            filename = value

    if not os.path.exists(filename):
        result['changed'] = True
        target_fd = open(filename, 'w')
        target_fd.close()

    result['filename'] = filename
    print(json.dumps(result))


if __name__ == '__main__':
    main()

それでは、以下のサンプルモジュール(touchtest.py)をカレントディレクトリに配置して、ansibleコマンドを実行してみましょう。

まずはカレントディレクトリに存在していないファイル名(hello)を指定します。

% ansible localhost -i hosts -M . -m touchtest.py -a name=hello
localhost | SUCCESS => {
    "changed": true,
    "filename": "hello"
}

作成されたファイル(hello)のステータスを確認してみましょう。

% stat hello
16777220 44124007 -rw-r--r-- 1 saitou staff 0 0 "Jan 19 16:11:25 2017" "Jan 19 16:11:25 2017" "Jan 19 16:11:25 2017" "Jan 19 16:11:25 2017" 4096 0 0 hello

新たに空ファイルが作成されました。この処理では、実行環境に変更を加える(空ファイルが作成される)ため、changedパラメータをTrueに設定しています。

次に、全く同じ作業を実施してみます。

% ansible localhost -i hosts -M . -m touchtest.py -a name=hello
localhost | SUCCESS => {
    "changed": false,
    "filename": "hello"
}

ファイル(hello)のステータスを確認してみましょう。

% stat hello
16777220 44124007 -rw-r--r-- 1 saitou staff 0 0 "Jan 19 16:11:25 2017" "Jan 19 16:11:25 2017" "Jan 19 16:11:25 2017" "Jan 19 16:11:25 2017" 4096 0 0 hello

すでにファイルが存在しているため、os.path.exists(filename)の評価結果がTrueとなり、ファイルに関する操作がスキップされます。この場合は、実行環境が変化しませんから、changedパラメータにFalseをセットしてやります。

このようにして、1つ1つ丁寧にモジュールの中の処理で冪等性を担保してやるわけです。

Ansibleモジュールのお作法にしたがう

Ansibleが推奨しているモジュールの書式にしたがって、touchtest.pyを書き直してみましょう。

モジュールの基本構造

Ansibleでは、モジュールの作成が楽になるよう、さまざまな機能がライブラリとして提供されています。これらを利用すれば、引数のハンドリングや、戻り値のフォーマットなど、モジュールに共通する処理を自身で書く必要がなくなります。

このようなライブラリを利用する場合のモジュール基本構造が、本家のDeveloping Modulesというドキュメントで紹介されています。

from ansible.module_utils.basic import AnsibleModule

def main():
    module = AnsibleModule(
        argument_spec=dict(
            state=dict(default='present', choices=['present', 'absent']),
            name=dict(required=True),
            enabled=dict(required=True, type='bool'),
            something=dict(aliases=['whatever'])
        )
    )

if __name__ == '__main__':
    main()

お作法にしたがってモジュールを書いてみる

それでは、上記の基本構造を利用して、さきほどのtouchtest.pyを書き直してみましょう。ただ書き直すだけではなく、Ansibleのモジュールらしく、いくつか仕様を追加しています。

  • stateパラメータがpresentの場合
    • nameパラメータに指定したファイル名で空ファイルを作成する(changed: True)
    • nameパラメータに指定したファイル名と同名のファイルが存在していたら何もしない(changed: False)
  • stateパラメータがabsentの場合
    • nameパラメータに指定したファイルが存在していれば削除する(changed: True)
    • nameパラメータに指定したファイルだ存在しなければ何もしない(changed: False)

この仕様にしたがって、書き直したサンプルモジュール(myfile.py)は、以下の通りです。

#!/usr/bin/python
# -*- coding: utf-8 -*-
"""
Description:
  Sample module for checking how to change your system.
"""

import os

from ansible.module_utils.basic import AnsibleModule


def main():
    module = AnsibleModule(
        argument_spec=dict(
            state=dict(default='present', choices=['present', 'absent']),
            name=dict(required=True)
        )
    )

    state = module.params['state']
    filename = module.params['name']

    changed = False
    if state == 'absent':
        if os.path.exists(filename):
            changed = True
            try:
                os.unlink(filename)
            except OSError as err:
                module.fail_json(msg='{err}'.format(err=err))
        module.exit_json(changed=changed, filename=filename)

    if not os.path.exists(filename):
        changed = True
        try:
            target_fd = open(filename, 'w')
        except IOError as err:
            module.fail_json(msg='{err}'.format(err=err))
        target_fd.close()
    module.exit_json(changed=changed, filename=filename)


if __name__ == '__main__':
    main()

AnsibleModuleクラスのオブジェクトインスタンスを利用することで、このクラスが提供している、引数やJSON形式での戻り値のハンドリングを行ってくれるメソッドなどが利用できるようになります。

まずは、state==presentから。1回目の実行ではファイルが作成されるので、changed: Trueとなり、2回目の実行では、すでにファイルが存在していて新たに作成されることはありませんから、changed: Falseになっています。

% ansible localhost -i hosts -M . -m myfile.py -a "name=hello state=present"
localhost | SUCCESS => {
    "changed": true,
    "filename": "hello"
}
% ansible localhost -i hosts -M . -m myfile.py -a "name=hello state=present"
localhost | SUCCESS => {
    "changed": false,
    "filename": "hello"

続いてstate==absentで実行してみましょう。1回目の実行ではファイルが削除されてchanged: True、2回目の実行ではファイルが存在していませんから、changed: Falseになっています。

% ansible localhost -i hosts -M . -m myfile.py -a "name=hello state=absent"
localhost | SUCCESS => {
    "changed": true,
    "filename": "hello"
}
% ansible localhost -i hosts -M . -m myfile.py -a "name=hello state=absent"
localhost | SUCCESS => {
    "changed": false,
    "filename": "hello"
}

作成や削除に失敗した場合の処理についても、サンプルとして記述してあります。例えばディレクトリに対してstate=absentで削除処理を適用しようとすると、削除処理に失敗して、以下のようなエラーがfail_json()で戻されます。

% file hello
hello: directory
% ansible localhost -i hosts -M . -m myfile.py -a "name=hello state=absent"
localhost | FAILED! => {
    "changed": false,
    "failed": true,
    "msg": "[Errno 1] Operation not permitted: 'hello'"
}

AnsibleModuleクラスの機能(ほんの一部)

サンプルモジュール(myfile.py)では、ほんの一部しか利用していませんが、AnsibleModuleクラスは、モジュール開発を支援するために、さまざまな共通機能を提供してくれます。

以下では、myfile.pyで利用している機能のみを紹介します。

モジュールパラメータ処理: AnsibleModule().params
    module = AnsibleModule(
        argument_spec=dict(
            state=dict(default='present', choices=['present', 'absent']),
            name=dict(required=True)
        )
    )

AnsibleModuleクラスのオブジェクトインスタンス生成時に、argument_specにモジュールパラメータを渡すことで、argsファイルに記述されているモジュールのオプションパラメータを読み込んで辞書型の変数(params)に格納してくれます。

ここでは、statenameという2つのパラメータを取得するように指定していあす。辞書型で指定するオプションパラメータの要件には、以下のようなキーワードを指定できます。

  • aliased: 互換性を確保するのに必要なパラメータの別名を記述したリスト
  • choices: stateパラメータのように"present"や"absent"などのキーワード指定うためのキーワードリスト
  • default: 無指定だった場合のデフォルト値
  • required: 指定必須のパラメータかどうか(True or False)
  • type: int、bool、listなどのような渡されるパラメータ値の型

モジュール内では、以下のように渡されたオプションパラメータを利用することができます。

    state = module.params['state']
    filename = module.params['name']
正常終了: AnsibleModule.exit_json(changed, result)

モジュールが正常終了した場合に、JSON形式でメッセージを標準出力して終了します。

    module.exit_json(changed=changed, result=filename)

モジュールの動作結果として、実行環境に何らかの変更が加わった場合は、changed_にTrueを、変更がなかった場合はFalseを設定してやります。

resultには、必要に応じて利用者に知らせたい情報を設定します。

異常終了: AnsibleModule.fail_json(msg)

モジュールが異常終了した場合に、JSON形式でメッセージを標準出力して終了します。

    module.fail_json(msg='{err}'.format(err=err))

異常終了時に利用者に知らせたい情報を、msgに設定してやります。

冪等性問題

Ansibleは、冪等性が担保されていると良く言われていますが、これもモジュールの作り次第です。

冪等性を実現するために、モジュールの作成者は、AnsibleModuleクラスが提供している共通機能を上手に活用して、冪等性の担保するコードを実装してやる必要があります。

参考情報