Ansible 自作モジュール rpm操作

# 背景と注意事項

``Ansible``には``rpm``モジュールがない。

そうれはそう、``yum``があるから。

構成管理ツールなので``--force``とかできる``rpm``は``Ansible``の中では非推奨というわけだ。

が、現場では過去のシステムを継ぎ接ぎで開発しているものがあって、どうしても64bitマシンに32bitの``rpm``を入れたいときがあるわけだ。これを``yum``で実装した際に「64bitのパッケージあるから入れてやらないぞ」となったりするわけ。

あるべき姿は構成管理を視野にいれた開発であって、<font color="red">これは非推奨モジュールである。</font>

# 実現したいこと

あんまり難しいのやるつもりはなくて

- ``rpm``ファイルは既に``copy``などでノードに配置されている
    - これはいつか``copy``相当の組み込みでファイルを配置できるようにしたい
- ``rpm``コマンドのオプションを受け取ってそのまま使う
    - これもいつかはフラグ管理したい
- 指定された``rpm``ファイルが存在することを確認
- 指定された``rpm``が未インストールであることを確認
- 指定された``rpm``をまとめて、指定されたオプションでインストール

こんぐらいだろうか。

# 雛形を作る

過去の記事を見てね。

- [Ansible 自作モジュール作成 ～その1:引数受け取りたい人生～](https://qiita.com/Ki2neudon/items/f6e1ee4fc8f29e12893f)

```python:rpm.py
#!/usr/bin/python
# -*- coding: utf-8 -*-

from ansible.module_utils.basic import AnsibleModule

# メイン処理
#-----------------------------------------------------------
def main():
    # AnsibleModuleクラス: moduleを作成
    module = AnsibleModule(

        # 引数受け取り
        argument_spec=dict(

            # ココに引数受け取り処理を書く

        ),
        # 引数チェックを有効
        supports_check_mode=True
    )

    # 指定されたファイルの存在チェック

    # 変更要否判定
    changed = False

    # rpmインストール

    # 終了処理

if __name__ == '__main__':
    main()
```

こんなところかな

# 引数の受け取り

今回受け付ける引数は以下のとおり

|引数名|必須|デフォルト値|引数の意味|
|---|:---:|---|---|
|pkgs|○|-|パッケージのリスト|
|opts||-Uvh|rpmコマンドに与えるオプション|
|chdir||/|パッケージ格納ディレクトリ|

ちなみに``リスト``の受け取り方が調べて悩んでわからず、某Q&Aに投稿したけど翌日自己解決した。
参考：[Ansible with_listで渡した配列をモジュールの中でループに使用するには](https://teratail.com/questions/266701)

```python:rpm.py
:#(省略)

# 引数受け取り
argument_spec=dict(
    # パッケージリスト(必須,list型)
    pkgs = dict(required = True, type = list),
    # オプション(str型,[-Uvh])
    opts = dict(required = False, type = str, default = '-Uvh'),
    # パッケージ格納ディレクトリ(str型)
    chdir = dict(required = False, type = str, default = '/'),
),
# 引数チェックを有効
supports_check_mode=True

:#(省略)
```

なお、上記のように部分的に抜粋する場合は行頭のインデントを削っています。横に長くなっちゃうから。

# 指定されたファイルの存在チェック

前回、``mkfifo``するモジュールを作成したときは何も考えずに``stat``コマンドや``test``コマンド使っちゃったけど、流石にそんぐらいモジュール何かあるだろうと考えた。

参考：[pythonでファイルの存在を確認する](https://qiita.com/tortuepin/items/4a0669d8f275e966229e)

```python:rpm.py
:#(省略)

from ansible.module_utils.basic import AnsibleModule
import os

:#(省略)

# 冗長になるので変数入れ直し
pkgs = module.params['pkgs']
chdir = module.params['chdir']

# パッケージリスト毎にループ
for pkg in pkgs:
    # 指定されたファイルの存在チェック
    if (os.path.isfile(chdir + '/' + pkg)):
        # ファイルが配置されていない
        module.fail_json(msg = chdir + '/' + pkg + ' is not found.')

:#(省略)
```

心配なのでココで一度動作確認しておこう。まだ``/tmp/``に``rpm``を配置していないぞ。

```yml:test.yml
- name: ntp install
  rpm:
    pkgs: '{{ item }}'
    opts: '-ivh'
    chdir: '/tmp'
  with_list:
    - [ 'ntp-4.2.6p5-15.el6.centos.x86_64.rpm','ntpdate-4.2.6p5-15.el6.centos.x86_64.rpm' ]
```

```bash:動作確認
$ ansible-playbook -i test_grp -l test_srv -u root test.yml -vvv
:#(省略)

ok: [192.168.56.104] => (item=[u'ntp-4.2.6p5-15.el6.centos.x86_64.rpm', u'ntpdate-4.2.6p5-15.el6.centos.x86_64.rpm']) => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python"
    },
    "ansible_loop_var": "item",
    "changed": false,
    "invocation": {
        "module_args": {
            "chdir": "/tmp",
            "opts": "-ivh",
            "pkgs": [
                "ntp-4.2.6p5-15.el6.centos.x86_64.rpm",
                "ntpdate-4.2.6p5-15.el6.centos.x86_64.rpm"
            ]
        }
    },
    "item": [
        "ntp-4.2.6p5-15.el6.centos.x86_64.rpm",
        "ntpdate-4.2.6p5-15.el6.centos.x86_64.rpm"
    ]
}
```

あれ・・・ちょっとデバッグ。

```python:rpm.py
# 冗長になるので変数入れ直し
pkgs = module.params['pkgs']
chdir = module.params['chdir']
num = 0
hog = 0
# パッケージリスト毎にループ
for pkg in pkgs:
    num += 1
    # 指定されたファイルの存在チェック
    if (os.path.isfile(chdir + '/' + pkg)):
        hog += 1
        # ファイルが配置されていない
        module.fail_json(msg = chdir + '/' + pkg + ' is not found.')

:#(省略)

module.exit_json(num = num, hog = hog)
```

```bash:実行結果
ok: [192.168.56.104] => (item=[u'ntp-4.2.6p5-15.el6.centos.x86_64.rpm', u'ntpdate-4.2.6p5-15.el6.centos.x86_64.rpm']) => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python"
    },
    "ansible_loop_var": "item",
    "changed": false,
    "hog": 0,  ★
    "invocation": {
        "module_args": {
            "chdir": "/tmp",
            "opts": "-ivh",
            "pkgs": [
                "ntp-4.2.6p5-15.el6.centos.x86_64.rpm",
                "ntpdate-4.2.6p5-15.el6.centos.x86_64.rpm"
            ]
        }
    },
    "item": [
        "ntp-4.2.6p5-15.el6.centos.x86_64.rpm",
        "ntpdate-4.2.6p5-15.el6.centos.x86_64.rpm"
    ],
    "num": 2  ★
}
```

うーん``if (os.path.isfile(chdir + '/' + pkg)):``が間違えてるのかな。いろいろ試した結果``if os.path.isfile(chdir + '/' + pkg) == False:``だと動いた。うーん、なぜなのか。

```python:rpm.py
:#(省略)

from ansible.module_utils.basic import AnsibleModule
import os

:#(省略)

# 冗長になるので変数入れ直し
pkgs = module.params['pkgs']
chdir = module.params['chdir']

# パッケージリスト毎にループ
for pkg in pkgs:
    # 指定されたファイルの存在チェック
    if os.path.isfile(chdir + '/' + pkg) == False:
        # ファイルが配置されていない
        module.fail_json(msg = chdir + '/' + pkg + ' is not found.')

:#(省略)
```

# 変更要否判定

変更要否、つまり既にすべての``pkg``がインストールされていたら作業は不要なわけだ。``rpm``パッケージがインストールされているかどうかは以下のコマンドでわかる。

```bash
$ rpm -q ntp-4.2.6p5-15.el6.centos.x86_64
ntp-4.2.6p5-15.el6.centos.x86_64
$ echo $?
0

$ rpm -q hoge
パッケージ hoge はインストールされていません。
$ echo $?
1
```

しかし``rpm``ファイルは、ファイル名の末尾``.rpm``を抜いたものがパッケージ名とは限らない。

```bash
$ ls -1 jdk-8u192-linux-x64.rpm
jdk-8u192-linux-x64.rpm

$ rpm -q jdk-8u192-linux-x64
パッケージ jdk-8u192-linux-x64 はインストールされていません。

$ rpm -qa | grep jdk
jdk1.8-1.8.0_192-fcs.x86_64
```

上記の例だとファイル名は``jdk-8u192-linux-x64.rpm``だがパッケージ名は``jdk1.8-1.8.0_192-fcs.x86_64``である。

これを判別するためには``rpm -qp rpmファイル名``でパッケージ名を調べる必要がある。

```bash
$ rpm -qp jdk-8u192-linux-x64.rpm
警告: jdk-8u192-linux-x64.rpm: ヘッダ V3 RSA/SHA256 Signature, key ID ec551f03: NOKEY
jdk1.8-1.8.0_192-fcs.x86_64
```

上記の標準出力で得られたものがパッケージ名だ。

この処理をモジュールに組み込む

```python:rpm.py
:#(省略)

# 冗長になるので変数入れ直し
pkgs = module.params['pkgs']
chdir = module.params['chdir']

# 変更要否判定
changed = False

# インストール対象rpmファイルリスト
rpms = []

# パッケージリスト毎にループ
for pkg in pkgs:

    # ファイルのフルパスを作成
    fpath = chdir + '/' + pkg

    # 指定されたファイルの存在チェック
    if os.path.exists(fpath) == False:
        # ファイルが配置されていない
        module.fail_json(msg = fpath + ' is not found.')

    # rpmファイルからパッケージ名を取得
    rc, pkg_name, stderr = module.run_command('rpm -qp ' + fpath)

    # パッケージのインストール状況確認
    rc, stdout, stderr = module.run_command('rpm -q ' + pkg_name)

    # インストール済みか
    if rc != 0:
        # インストールされていないので変更要
        changed = True
        # インストール対象に追加
        rpms.append(fpath)

:#(省略)
```

# rpmインストール

インストール対象rpmリスト``rpms``が空っぽだったら終了・・・という判定は ``chagned != True`` に任せることにする。

```python:rpm.py
:#(省略)

# rpmインストール要否判定
if changed != True:
    # インストール対象なし
    module.exit_json()

# rpmリスト作成
rpm_line = ' '.join(rpms)

# rpmインストール
rc, stdout, stderr = module.run_command('rpm ' + module.params['opts'] + ' ' + rpm_line)

# rpmインストール結果判定
if rc != 0:
    # インストール失敗
    module.fail_json(stdout = stdout, stderr = stderr)

# 終了処理
module.exit_json(changed = changed)

:#(省略)
```

# 試運転

```yml:test.yml
- name: ntp install
  rpm:
    pkgs: '{{ item }}'
    opts: '-ivh'
    chdir: '/tmp'
  with_list:
    - [ 'ntp-4.2.6p5-15.el6.centos.x86_64.rpm','ntpdate-4.2.6p5-15.el6.centos.x86_64.rpm' ]
```

```bash
# パッケージファイルが配置されていることを確認
$ ssh root@192.168.56.104 "ls -l /tmp/*rpm"
-rw-rw-r--. 1 root root 614364  5月 11 17:01 2020 /tmp/ntp-4.2.6p5-15.el6.centos.x86_64.rpm
-rw-rw-r--. 1 root root  80836  5月 11 17:00 2020 /tmp/ntpdate-4.2.6p5-15.el6.centos.x86_64.rpm

# パッケージファイルがインストールされていないことを確認
$ ssh root@192.168.56.104 "rpm -qa | grep ntp"
$

# 実行！
$ ansible-playbook -i test_grp -l test_srv -u root test.yml -vvv
:#(省略)
changed: [192.168.56.104] => (item=[u'ntp-4.2.6p5-15.el6.centos.x86_64.rpm', u'ntpdate-4.2.6p5-15.el6.centos.x86_64.rpm']) => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python"
    },
    "ansible_loop_var": "item",
    "changed": true,
    "invocation": {
        "module_args": {
            "chdir": "/tmp",
            "opts": "-ivh",
            "pkgs": [
                "ntp-4.2.6p5-15.el6.centos.x86_64.rpm",
                "ntpdate-4.2.6p5-15.el6.centos.x86_64.rpm"
            ]
        }
    },
    "item": [
        "ntp-4.2.6p5-15.el6.centos.x86_64.rpm",
        "ntpdate-4.2.6p5-15.el6.centos.x86_64.rpm"
    ],
    "msg": "/tmp/ntp-4.2.6p5-15.el6.centos.x86_64.rpm /tmp/ntpdate-4.2.6p5-15.el6.centos.x86_64.rpm"
}

# 冪等性確認のためもう一度実行！
$ ansible-playbook -i test_grp -l test_srv -u root test.yml -vvv
ok: [192.168.56.104] => (item=[u'ntp-4.2.6p5-15.el6.centos.x86_64.rpm', u'ntpdate-4.2.6p5-15.el6.centos.x86_64.rpm']) => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python"
    },
    "ansible_loop_var": "item",
    "changed": false,
    "invocation": {
        "module_args": {
            "chdir": "/tmp",
            "opts": "-ivh",
            "pkgs": [
                "ntp-4.2.6p5-15.el6.centos.x86_64.rpm",
                "ntpdate-4.2.6p5-15.el6.centos.x86_64.rpm"
            ]
        }
    },
    "item": [
        "ntp-4.2.6p5-15.el6.centos.x86_64.rpm",
        "ntpdate-4.2.6p5-15.el6.centos.x86_64.rpm"
    ]
}
```

いいんじゃないでしょうか。

# 作成したモジュール

```python:rpm.py
#!/usr/bin/python
# -*- coding: utf-8 -*-

from ansible.module_utils.basic import AnsibleModule
import os

# メイン処理
#-----------------------------------------------------------
def main():
    # AnsibleModuleクラス: moduleを作成
    module = AnsibleModule(

        # 引数受け取り
        argument_spec=dict(
            # パッケージリスト(必須,list型)
            pkgs = dict(required = True, type = list),
            # オプション(str型,[-Uvh])
            opts = dict(required = False, type = str, default = '-Uvh'),
            # パッケージ格納ディレクトリ(str型)
            chdir = dict(required = False, type = str, default = '/'),
        ),
        # 引数チェックを有効
        supports_check_mode=True
    )

    # 冗長になるので変数入れ直し
    pkgs = module.params['pkgs']
    chdir = module.params['chdir']

    # 変更要否判定
    changed = False

    # インストール対象rpmファイルリスト
    rpms = []

    # パッケージリスト毎にループ
    for pkg in pkgs:

        # ファイルのフルパスを作成
        fpath = chdir + '/' + pkg

        # 指定されたファイルの存在チェック
        if os.path.exists(fpath) == False:
            # ファイルが配置されていない
            module.fail_json(msg = fpath + ' is not found.')

        # rpmファイルからパッケージ名を取得
        rc, pkg_name, stderr = module.run_command('rpm -qp ' + fpath)

        # パッケージのインストール状況確認
        rc, stdout, stderr = module.run_command('rpm -q ' + pkg_name)

        # インストール済みか
        if rc != 0:
            # インストールされていないので変更要
            changed = True
            # インストール対象に追加
            rpms.append(fpath)

    # rpmインストール要否判定
    if changed != True:
        # インストール対象なし
        module.exit_json()

    # rpmリスト作成
    rpm_line = ' '.join(rpms)

    # rpmインストール
    rc, stdout, stderr = module.run_command('rpm ' + module.params['opts'] + ' ' + rpm_line)

    # rpmインストール結果判定
    if rc != 0:
        # インストール失敗
        module.fail_json(stdout = stdout, stderr = stderr)

    # 終了処理
    module.exit_json(changed = changed)

if __name__ == '__main__':
    main()
```