Ansibleのモジュール開発(Python実装編)

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

サバゲーの物欲が止まらない藤本です。

前回のAnsibleのモジュール開発(基礎編)でAnsibleのモジュールを開発する上でのルールや考え方をご紹介させていただきました。前回は簡単な処理しか実装しなかったため、シェルスクリプトで記載しても問題ありませんでしたが、複雑な処理となるとエラーハンドリングやバリデーションチェックなど辛くなってきます。そこでPythonを利用すれば、Ansibleに含まれるPythonのユーティリティライブラリを利用でき、複雑な処理を軽減することができます。ただユーティリティライブラリの情報は公式ドキュメントでもあまり記載されておらず、Ansibleの標準モジュールを参考にしてね、とだけ記載されており、情報があまりありません。今回は標準モジュールとユーティリティライブラリのソースコードを読んでみて分かったことをご紹介したいと思います。

その他のAnsibleのモジュール開発シリーズは以下をご参照ください。

Ansibleの標準モジュール

Ansibleの標準モジュールはAnsibleが持つGithubアカウントのansible本体のリポジトリとは別で、coreモジュール、extrasモジュールで開発されています。これらモジュールはansible本体のリポジトリでサブモジュールとして定義されています。

ユーティリティライブラリ

各標準モジュールは共通して下記のPythonライブラリをimportしています。

from ansible.module_utils.basic import *

ansible/module_utils配下にユーティリティライブラリが配置されています。basic.pyの他にもAWSへのアクセス、クレデンシャル情報取得などを扱うec2.pyや、HTTPリクエストを操作するurls.pyなどがあります。

今回はbasic.pyに絞って機能をご紹介します。

AnsibleModuleクラス

AnsibleModuleクラスも各標準モジュールのmain関数内で共通して実装されています。必須ではありませんが、PythonでAnsibleモジュールを開発する場合、基本的にはこのAnsibleModuleクラスを定義するものだとお考えください。

def main():
    module = AnsibleModule(
:
    )

引数を受け取る

前回のエントリにて、引数はモジュールのスクリプトと一緒にargsというファイルにkey=valueの形式で渡されることをご紹介しました。ユーティリティライブラリを利用すると、AnsibleModuleクラスで引数を受け取ることができます。AnsibleModuleクラスの第一引数にモジュール引数を並べたdictを与えます。しかもただ受け取るだけではなく、各モジュール引数のdictはモジュール引数のメタデータを定義することができます。例えば、引数を与えなかった場合のデフォルト値、引数が必須かどうか、設定可能な値、値の型、ログへの保管の有無などを指定することができます。

まずはシェルスクリプトと比較でコードをご紹介します。

シェルスクリプト

library/option_sh.sh

#!/bin/bash
source `dirname $0`/args
echo "{\"message_key\":\"$message\"}"
# ansible -m option_sh -a message=hello 10.255.0.121
10.255.0.121 | SUCCESS => {
    "changed": false,
    "message_key": "hello"
}
Python

library/option_py.py

#!/usr/bin/env python
from ansible.module_utils.basic import *
module = AnsibleModule(
  dict(
    message=dict()
  )
)
print '{"message_key":"%s"}' % (module.params['message']) 
# ansible -m option_py -a message=hello 10.255.0.121
10.255.0.121 | SUCCESS => {
    "changed": false,
    "message": "hello"
}

AnsibleModuleインスタンスのparamsにdict型で引数が格納されます。

続いて、標準モジュールのservice.pyを参考にどのようなメタデータを付与できるか見てみましょう。

service.py

:
def main():
    module = AnsibleModule(
        argument_spec = dict(
            name = dict(required=True),
            state = dict(choices=['running', 'started', 'stopped', 'restarted', 'reloaded']),
            sleep = dict(required=False, type='int', default=None),
            pattern = dict(required=False, default=None),
            enabled = dict(type='bool'),
            runlevel = dict(required=False, default='default'),
            arguments = dict(aliases=['args'], default=''),
        ),
        supports_check_mode=True
    )
:

namerequiredにTrueが指定されているため、引数を指定しない場合、バリデーションエラーとなります。
statechoiceに配列が指定されているため、配列にない文字列を与えた場合、バリデーションエラーとなります。
sleeptypeにintが指定されているため、数値以外の値を指定した場合、バリデーションエラーとなります。
runleveldefaultにdefaultが指定されているため、値を指定しない場合、defaultが設定されます。
argumentsaliasesにargsが指定されているため、argsのキーでもアクセスすることができます。

といったように引数のメタデータを設定することができます。

他にもno_logを指定すると、ログに情報が残らなくなります。パスワードや秘密鍵といった閲覧されたら困るものはno_logを指定しましょう。

標準出力

前回のエントリにて、モジュールはJSON形式で標準出力することが唯一のルールとご紹介しました。またJSONにおいて、変更の有無(changed)、モジュールの成否(failed)を与えることで処理結果を利用者に分かりやすく伝えることが可能です。シェルスクリプトではJSONを文字列として扱うため、扱う内容が増えてくると非常に辛いです。この標準出力を分かりやすく扱うことができるメソッドがAnsibleModuleクラスに定義されています。

exit_json : 正常終了
fail_json : 異常終了

こちらもシェルスクリプトと比較して実装を見てみましょう。

シェルスクリプト

library/ok_sh.sh

#!/bin/sh
echo '{}'

library/changed_sh.sh

#!/bin/sh
echo '{"changed": true}'

library/failed_sh.sh

#!/bin/sh
echo '{"failed": true}'

linux.yml

- hosts: all
  tasks:
  - ok_sh:
  - changed_sh:
  - failed_sh:

ansible-status-python

ユーティリティライブラリを使えば、引数として渡すだけで済みます。

Python

library/ok_py.py

#!/usr/bin/env python
from ansible.module_utils.basic import *
module = AnsibleModule({})
module.exit_json()

library/changed_py.py

#!/usr/bin/env python
from ansible.module_utils.basic import *
module = AnsibleModule({})
module.exit_json(changed=True)

library/failed_py.py

#!/usr/bin/env python
from ansible.module_utils.basic import *
module = AnsibleModule({})
module.fail_json(msg='error')

linux.yml

- hosts: all
  tasks:
  - ok_py:
  - changed_py:
  - failed_py:

ansible-status-shell

返したいパラメータが増えても可読性を保っておけますね。

コマンド実行

AnsibleはOSの操作を行うケースがほとんどであるため、モジュールではOSのコマンドもよく利用します。Pythonでコマンドを実行する場合、subprocessを利用することが推奨されていますが、標準出力、標準エラーの取得、リターンコードの判定、クロージング処理を都度実装するのは手間です。そこでAnsibleModuleにはrun_commandメソッドが用意されています。run_commandメソッドを利用することでこれらの手間を考えることなく、コマンドを渡すと、コマンドのリターンコード、標準出力、標準エラーを受け取ることができます。

library/run_py.py

#!/usr/bin/env python
from ansible.module_utils.basic import *
module = AnsibleModule({})
rc, stdout, stderr = module.run_command("mkdir -v /tmp/test_dir")
module.exit_json(
    changed=True,
    rc=rc,
    stdout=stdout,
    stderr=stderr
)
# ansible -m run_py 10.255.0.121
10.255.0.121 | SUCCESS => {
    "changed": true,
    "rc": 0,
    "stderr": "",
    "stdout": "mkdir: ディレクトリ `/tmp/test_dir' を作成しました\n",
    "stdout_lines": [
        "mkdir: ディレクトリ `/tmp/test_dir' を作成しました"
    ]
}

リターンコード、標準出力、標準エラーのそれぞれの値を取得できています。またrun_commandを利用すると、自動で標準出力がstdout_linesのキーで配列としてセットされます。

OS判定

Ansibleのモジュールは一つのモジュールで複数のOSディストリビューション・バージョンに対応させることができます。標準モジュールで言えば、user、group、hostname、serviceが複数のOSディストリビューション・バージョンに対応しています。例えば、serviceモジュールはサービスの起動/停止/再起動の操作、自動起動の設定を行うことができます。RedHat系、Debian系、FreeBSD系といったLinux Distributionの違いで設定するコマンド、ファイルが異なります。また同じRedHat系でも5系ではSysVinit、6系ではUpstart、7系ではSystemdといったOSバージョンの違いでも異なります。これらの違いを吸収して、一つのPythonスクリプトで実装しています。どのように実装しているかと言うと、ユーティリティライブラリのload_platform_subclass()を利用しています。この関数を利用することで、モジュール内で定義するclassに設定したplatformdistributionと実行環境の情報を比較し、一致するclassをインスタンス化します。

それではソースコードで動作を見てみましょう。

今回用意したOSは以下の6つです。

  • Amazon Linux : 52.193.251.70
  • CentOS 6 : 10.255.0.120
  • CentOS 7 : 10.255.0.121
  • Ubuntu 12.04 : 10.255.0.122
  • Ubuntu 14.04 : 10.255.0.123
  • OS X : localhost

library/os_dist.py

#!/usr/bin/env python

from ansible.module_utils.basic import *

class Linux(object):
    platform = 'Generic'
    distribution = None

    def __new__(cls, *args, **kwargs):
        return load_platform_subclass(Linux, args, kwargs)

    def hello(self):
        return 'I am %s' % (self.distribution if self.distribution else self.platform)

class AmazonLinux(Linux):
    platform = 'Linux'
    distribution = 'Amazon'

class CentOS6(Linux):
    platform = 'Linux'
    distribution = 'Centos'

class CentOS7(Linux):
    platform = 'Linux'
    distribution = 'Centos linux'

class Ubuntu(Linux):
    platform = 'Linux'
    distribution = 'Ubuntu'

class OSX(Linux):
    platform = 'Darwin'
    distribution = None

def main():
    module = AnsibleModule({})
    linux = Linux()
    module.exit_json(msg=linux.hello())

main()
# ansible -m os_dist 10.255.0.120
10.255.0.120 | SUCCESS => {
    "changed": false,
    "msg": "I am Centos"
}

# ansible -m os_dist 10.255.0.121
10.255.0.121 | SUCCESS => {
    "changed": false,
    "msg": "I am Centos linux"
}

# ansible -m os_dist 10.255.0.122
10.255.0.122 | SUCCESS => {
    "changed": false,
    "msg": "I am Ubuntu"
}

# ansible -m os_dist 10.255.0.123
10.255.0.123 | SUCCESS => {
    "changed": false,
    "msg": "I am Ubuntu"
}

# ansible -m os_dist 52.193.251.70
52.193.251.70 | SUCCESS => {
    "changed": false,
    "msg": "I am Amazon"
}

# ansible -m os_dist localhost
localhost | SUCCESS => {
    "changed": false,
    "msg": "I am Darwin"
}

Ansible実行対象のOSに応じたclassを呼び出すことができています。各classに定義したplatformdistributionで呼び出すclassを変えています。まずdistributionを照合し、該当するclassがあればインスタンスを返します。distributionに該当するものがない場合、platformを照合し、該当するclassがあればインスタンスを返します。platformに該当するものもない場合、親classのインスタンスを返します。

それではdistributionplatformはどのように参照しているのでしょうか?

platformの参照

platformはユーティリティライブラリbasic.pyget_platform()により返されます。

basic.py

def get_platform():
    ''' what's the platform?  example: Linux is a platform. '''
    return platform.system()

get_platform()ではPythonの標準ライブラリのplatformを利用しています。platform.system()を実行しているだけです。platform.system()はLinux OSの場合、unameコマンドから取得したものとなります。今回用意したAmazon Linux、CentOS、UbuntuはLinuxが返るため、これらのLinuxディストリビューションの判定には使えません。OSXやFreeBSD、AIXなどは個別のディストリビューションが返るようです。

distributionの参照

distributionはユーティリティライブラリbasic.pyget_distribution()により返されます。

basic.py

def get_distribution():
    ''' return the distribution name '''
    if platform.system() == 'Linux':
        try:
            supported_dists = platform._supported_dists + ('arch',)
            distribution = platform.linux_distribution(supported_dists=supported_dists)[0].capitalize()
            if not distribution and os.path.isfile('/etc/system-release'):
                distribution = platform.linux_distribution(supported_dists=['system'])[0].capitalize()
                if 'Amazon' in distribution:
                    distribution = 'Amazon'
                else:
                    distribution = 'OtherLinux'
        except:
            # FIXME: MethodMissing, I assume?
            distribution = platform.dist()[0].capitalize()
    else:
        distribution = None
    return distribution

get_distribution()も同じくPythonの標準ライブラリのplatformを利用しています。platform.linux_distribution()を実行しています。platform.linux_distribution()/etc配下のsystem-releaselsb-releaseといったファイルからディストリビューション名を取得しています。またAmazon Linuxには対応できていないので、Amazon Linuxの場合、get_distribution()内で個別で判定しています。

OSバージョン判定

load_platform_subclass()により、OSディストリビューションに応じたクラスのインスタンス化は可能となりました。しかし、バージョンの判定ができていません。load_platform_subclass()で判断できるのはOS種別、ディストリビューションのみです。CentOSは6、7でget_distribution()で返す値が異なるため判定できますが、Ubuntuは12.04、14.04でも同じ値を返します。そのため、Ubuntuは12.04、14.04の判定は別途実装する必要があります。標準モジュールではhostnameモジュールが対応しています。処理系を更に別クラスに切り出して実装しています。実行環境のOSバージョンはget_distribution_version()によって取得することができます。取得したバージョンに応じて処理系クラスを取得します。

それではソースコードで動作を見てみましょう。

library/os_ver.py

#!/usr/bin/env python

from distutils.version import LooseVersion
from ansible.module_utils.basic import *

class Strategy(object):
    version = 'none'

    def get_version(self):
        return 'My version is ' + self.version

class CentOS6Strategy(Strategy):
    version = '6'

class CentOS7Strategy(Strategy):
    version = '7'

class Ubuntu12Strategy(Strategy):
    version = '12'

class Ubuntu14Strategy(Strategy):
    version = '14'

class AmazonStrategy(Strategy):
    version = '2016.03'

class OSXStrategy(Strategy):
    version = 'Yosemite'

class Linux(object):
    platform = 'Generic'
    distribution = None
    strategy_class = Strategy

    def __new__(cls, *args, **kwargs):
        return load_platform_subclass(Linux, args, kwargs)

    def __init__(self):
        self.strategy = self.strategy_class()

    def hello(self):
        return 'I am %s. %s' % (self.distribution if self.distribution else self.platform, self.strategy.get_version())

class Amazon(Linux):
    platform = 'Linux'
    distribution = 'Amazon'
    strategy_class = AmazonStrategy

class CentOS6(Linux):
    platform = 'Linux'
    distribution = 'Centos'
    strategy_class = CentOS6Strategy

class CentOS7(Linux):
    platform = 'Linux'
    distribution = 'Centos linux'
    strategy_class = CentOS7Strategy

class Ubuntu(Linux):
    platform = 'Linux'
    distribution = 'Ubuntu'
    version = get_distribution_version()
    if version and LooseVersion('13') > LooseVersion(version) >= LooseVersion('12'):
        strategy_class = Ubuntu12Strategy
    elif version and LooseVersion('15') > LooseVersion(version) >= LooseVersion('14'):
        strategy_class = Ubuntu14Strategy

class OSX(Linux):
    platform = 'Darwin'
    distribution = None
    strategy_class = OSXStrategy

def main():
    module = AnsibleModule({})
    linux = Linux()
    module.exit_json(msg=linux.hello())

main()
# ansible -m os_ver 10.255.0.120
10.255.0.120 | SUCCESS => {
    "changed": false,
    "msg": "I am Centos. My version is 6"
}

# ansible -m os_ver 10.255.0.121
10.255.0.121 | SUCCESS => {
    "changed": false,
    "msg": "I am Centos linux. My version is 7"
}

# ansible -m os_ver 10.255.0.122
10.255.0.122 | SUCCESS => {
    "changed": false,
    "msg": "I am Ubuntu. My version is 12"
}

# ansible -m os_ver 10.255.0.123
10.255.0.123 | SUCCESS => {
    "changed": false,
    "msg": "I am Ubuntu. My version is 14"
}

# ansible -m os_ver 52.193.251.70
52.193.251.70 | SUCCESS => {
    "changed": false,
    "msg": "I am Amazon. My version is 2016.03"
}

# ansible -m os_ver localhost
localhost | SUCCESS => {
    "changed": false,
    "msg": "I am Darwin. My version is Yosemite"
}

Ubuntuクラスを見てみてください。get_distribution_version()を取得し、12.xxであれば、Ubuntu12Strategyクラス、14.xxであれば、Ubuntu14Strategyクラスをインスタンス化しています。それによりバージョンに応じた処理を行うことができます。

まとめ

いかがでしたでしょうか?

今回はAnsibleのモジュール開発に必要な処理をAnsibleのユーティリティライブラリを利用することで簡単に実装する方法をご紹介しました。Ansibleの標準モジュールは可読性が高いものが多く、Python入門の学習対象としていいように思えました。次回は実際に何かしらのユースケースで実装します。

前回、および今回の一連のソースコードをGithubにアップロードしました。色々試してみたい方はcloneして試してみてください。

develop-ansible-module