Ansibleでカスタムモジュールを作成してみる

# はじめに
Ansibleでは数多くの標準モジュールが用意されており、ほとんどの場合、これらを活用するだけで必要な処理を実現できます。
しかし、標準モジュールでは対応しきれないケースが発生することもあり、そのようなときに役立つのが「カスタムモジュール」となります。
カスタムモジュールを作成することで、自身で必要な処理を定義することができ、標準モジュールでは実現が難しかったことも行えるようになります。
今回は1からカスタムモジュールを作成してみようと思います。


# Ansibleとは
Ansibleとは、RedHat社が提供するオープンソースのIT自動化エンジンです。
AnsibleはPlaybookと呼ばれるYAMLファイルを使用して、タスクの自動化を行います。
Playbookでローカルシステムやリモートシステムの望ましい状態を宣言し、Ansibleが宣言した状態を維持することを保証します。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3780496/bbac7009-1905-5918-9354-e9e8a74d1048.jpeg" width="60%">


# モジュールとは
Ansibleのモジュールは、特定のタスクを実行するための再利用可能な部品のようなものとなります。
モジュールは、AnsibleのPlaybook内で使われ、ファイルの管理、パッケージのインストール、サービスの起動・停止などを自動化するために利用されます。

Ansibleでは標準のモジュールが多数用意されており、多くの操作が標準モジュールで行えます。
例）
・パッチ適用
・パッケージのインストール/アンインストール
・ディレクトリやファイルの作成/削除


## カスタムモジュールとは
カスタムモジュールはユーザーが独自に開発したモジュールです。
既存のモジュールで対応できない処理がある場合、自身でモジュールを作成して使用することができます。
言語はRubyやPython、bashなど JSON を返すことができる任意の言語を使用することが可能です。
引数の処理や応答の書き込みなどを行うライブラリを使用することができるため、おすすめの言語はPythonとなります。

## カスタムモジュールの作成
簡単にAnsibleやモジュールの説明を行ったところで、本題となるカスタムモジュールの作成に移ります。
今回は指定したファイルから特定の文字列を検索する「grepモジュール」を作成します。
文字列検索は shell モジュールから grep コマンドを実行することでも可能ですが、grep 専用のモジュールは存在しないため、理解度を深めるため作成してみようと思います。


### 作成手順

#### モジュールファイルの作成
モジュールファイルは以下のフォルダのいずれかに配置します
1. すべてのPlaybookとロールで使用する場合
   - 環境変数 `ANSIBLE_LIBRARY` で設定された path
   - config ファイル `DEFAULT_MODULE_PATH`(library) で指定された path
<br></br>
2. 特定のPlaybookやロールで使用する場合
   - プロジェクトディレクトリの「library」ディレクトリ配下
   - ロール内の「library」ディレクトリ配下

<br></br>
今回はプロジェクトディレクトリの「library」ディレクトリ配下にモジュールファイルを用意します。
なお、モジュールファイルにはPythonを使用します。
```shell
$ mkdir -v test_project #プロジェクトディレクトリ作成
$ mkdir -v ./test_project/library # libraryディレクトリ作成
$ touch ./test_project/library/grep.py #モジュールファイル作成
```

#### モジュールファイルの編集
はじめにPython2とPython3の互換性を持たせるため、行頭に以下の記述をします。
```python
#!/usr/bin/python
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
```


Ansibleモジュールを作成するための基本的なクラスをインポートします。
このクラスを使ってモジュールの引数や結果を管理します。
```python
from ansible.module_utils.basic import AnsibleModule
```

続いてrun_module関数にメインの処理を記載していきます
 - `module_args`でモジュールが受け取るパラメータの仕様を定義します
データ型やデフォルト値の指定などが可能です
 - `result`には実行結果として返す値を定義します
 - `module.params['key']`でPlaybookにて指定したパラメータの値を取得できます

```python
def run_module():
    module_args = dict(
        src = dict(type='str', required=True),
        check_list = dict(type='list', required=True),
        encoding = dict(type='str', required=False, default='utf-8')
    )

    result = dict(
        changed=False,
        lines=[],
        count=0
    )

    module = AnsibleModule(
        argument_spec = module_args
    )


    src = module.params['src']
    check_list = module.params['check_list']
    encoding = module.params['encoding']
    get_lines = []

    try:
        # ファイルの内容を1行ずつ分割して、リスト形式で変数に格納
        with open(src, encoding=encoding) as f:
            lines = f.read().splitlines()
    except (UnicodeDecodeError, IOError) as e:
        module.fail_json(msg="Failed to read file: {}".format(e))

    # ファイルの各行に検索対象の文字列が含まれているか確認
    for line in lines:
        for check_chara in check_list:
            if check_chara in line:
                get_lines.append(line)
                break

    result['lines'] = get_lines
    result['count'] = len(get_lines)
    module.exit_json(**result)
```

最後にrun_module関数を呼び出す処理を追加します
```python
def main():
    run_module()

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

最終的なコードは以下の通りです
※使用例やDOCUMENTATIONも簡単に追加しました
```grep.py
#!/usr/bin/python
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type

DOCUMENTATION = r'''
---
module: grep
short_description: This grep a string from a file
description: Grep a string from a file. Duplicate values are removed.
version_added: "0.1.0"

options:
    src:
        description: File path to grep
        required: true
        type: str
    check_list:
        description: String to be searched
        required: true
        type: list
    encoding:
        description: Encoding of the file (default is UTF-8)
        required: false
        type: str
        default: 'utf-8'

author:
    - ooki
'''

EXAMPLES = '''
- name: Check the alert log for errors
  grep:
    src: /path/to/alert_oracle.log
    check_list:
      - ORA-
    encoding: 'shift-jis'
  register: result
'''

from ansible.module_utils.basic import AnsibleModule


def run_module():
    module_args = dict(
        src = dict(type='str', required=True),
        check_list = dict(type='list', required=True),
        encoding = dict(type='str', required=False, default='utf-8')
    )

    result = dict(
        changed=False,
        lines=[],
        count=0
    )

    module = AnsibleModule(
        argument_spec = module_args
    )


    src = module.params['src']
    check_list = module.params['check_list']
    encoding = module.params['encoding']
    get_lines = []

    try:
        with open(src, encoding=encoding) as f:
            lines = f.read().splitlines()
    except (UnicodeDecodeError, IOError) as e:
        module.fail_json(msg="Failed to read file: {}".format(e))

    for line in lines:
        for check_chara in check_list:
            if check_chara in line:
                get_lines.append(line)
                break

    result['lines'] = get_lines
    result['count'] = len(get_lines)
    module.exit_json(**result)

def main():
    run_module()

if __name__ == '__main__':
    main()

```

### 動作確認
`grep`モジュールをPlaybookで使用して、動作確認を行います
以下の形式で`grep`モジュールのパラメータを指定します
| パラメータ | データ型 | 必須 |  説明 |
|:-----------|:------------|:------------|:------------|
| src  | string  | Yes |文字列を検索するファイルのPath         | 
| check_list     | list      | Yes  |検索対象の文字列のリスト |
| encoding       | string        | No         |ファイルの文字コード |


Playbookは下記を使用します
```playbook.yml
---
- name: Check custom module
  hosts: localhost
  tasks:
    - name: Exec grep module
      grep:
        src: "/home/systemi/work/ansible/test_project/test.log"
        check_list:
          - "ORA-"
        encoding: "utf-8"
      register: grep_result

    - name: Check result
      ansible.builtin.debug:
        var: grep_result
```
srcに指定している test.log の内容は下記の通りです
「ORA-00257:」の行が取得できれば、期待通りの結果になります
```test.log
Mon Aug  5 12:34:56 2024
Thread 1 advanced to log sequence 1234 (LGWR switch)
  Current log# 3 seq# 1234 mem# 0: /u01/app/oracle/oradata/ORCL/redo03.log
Mon Aug  5 12:35:00 2024
Errors in file /u01/app/oracle/diag/rdbms/orcl/ORCL/trace/alert_orcl.log:
ORA-00257: archiver error. Connect internal only, until freed.
Mon Aug  5 12:40:00 2024
Instance shutdown (abort)
```


<b>実行結果</b>
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3780496/a4c42217-ea6f-7c04-7f90-351188c3d608.png)
「ORA-00257:」の行が表示され、期待通りの結果となりました。

<br></br>
続いて、srcのファイルの文字コードを`shift-jis`へ変更して確認してみます。
使用するPlaybookと検索対象のログファイルは以下の通りです。

```playbook.yml
---
- name: Check custom module
  hosts: localhost
  tasks:
    - name: Exec grep module
      grep:
        src: "/home/systemi/work/ansible/test_project/test.log"
        check_list:
          - "重大"
          - "警告"
        encoding: "shift-jis"
      register: grep_result

    - name: Check result
      ansible.builtin.debug:
        var: grep_result
```
```test.log
2024/12/1 10:00:00 情報: システムが正常に起動しました
2024/12/1 10:05:00 警告: ディスクの空き容量が少なくなっています
2024/12/1 10:10:00 重大: ファイルが見つかりません
2024/12/1 10:15:00 情報: ユーザー(test_user)が作成されました
2024/12/1 10:20:00 重大: データベース接続に失敗しました
```
ログファイルから「重大」か「警告」を含む行が取得できれば期待通りの結果になります。

<b>実行結果</b>
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3780496/c5efd8c5-59a1-c34e-f514-77e858c10a65.png)
「重大」、「警告」を含む行のみ表示され、期待通りの結果となりました。

# さいごに
今回はPythonを使用してAnsibleのカスタムモジュールを作成しました。
まだまだ学習中ですので、少しずつできる幅を増やしていきたいと思います。

# 参考文献
[Ansible documentation](https://docs.ansible.com/)