Ansibleのモジュール開発(Python実装編)
はじめに
サバゲーの物欲が止まらない藤本です。
前回の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 ) :
name
はrequired
にTrueが指定されているため、引数を指定しない場合、バリデーションエラーとなります。
state
はchoice
に配列が指定されているため、配列にない文字列を与えた場合、バリデーションエラーとなります。
sleep
はtype
にintが指定されているため、数値以外の値を指定した場合、バリデーションエラーとなります。
runlevel
はdefault
にdefaultが指定されているため、値を指定しない場合、defaultが設定されます。
arguments
はaliases
に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:
ユーティリティライブラリを使えば、引数として渡すだけで済みます。
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は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に設定したplatform
、distribution
と実行環境の情報を比較し、一致する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に定義したplatform
、distribution
で呼び出すclassを変えています。まずdistribution
を照合し、該当するclassがあればインスタンスを返します。distribution
に該当するものがない場合、platform
を照合し、該当するclassがあればインスタンスを返します。platform
に該当するものもない場合、親classのインスタンスを返します。
それではdistribution
、platform
はどのように参照しているのでしょうか?
platformの参照
platform
はユーティリティライブラリbasic.py
のget_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.py
のget_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-release
、lsb-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して試してみてください。