Ansibleタスクのモジュール名をFQCN形式に変換

# 要約

Ansibleタスクのyamlファイルを読んでモジュール名をFQCN形式に変換して保存するスクリプトを紹介します。

# 解説

Ansible 2.10からタスクでモジュール名をFQCNで書く亊が推奨されるようになった。実際には後方互換のリダイレクタが有効なので、従来の記法でも問題なく動作する。

一件ずつ変更するのはとても面倒なので、ファイルを読み込んでモジュール名を抽出、リダイレクタの設定からFQCNに変換するPythonスクリプトを書いた。全然大したコードではないですが、どうせ書いたからお裾分けします。

# 機能

* 指定したディレクトリの指定したglobパターンのファイルを対象として処理する。
* ファイルをテキストとして読み込んで正規表現で置換するので、YAMLの文法チェックなどは行わない。
* 各タスクの`name`キーの次のキーをモジュール名と見做し、指定したリダイレクタの設定(`ansible_builtin_runtime.yml`)で変換して書き換える。
  * 従って`name`キーの無いタスクは無視される。
* `debug`・`template`などリダイレクタの設定に無いものは変換しない。
* 変換の結果ファイル単位で差分があれば上書き保存する。

# 用意するもの
## 環境
* Python 3.7以上
* Ansible 2.10

## 情報
以下の三つの情報をスクリプト冒頭の定数に定義する。

* バージョンアップ先のAnsibleライブラリのansible_builtin_runtime.ymlのパス(例: `site-packages/ansible/config/ansible_builtin_runtime.yml`)
* 対象となるAnsibleプロジェクトのディレクトリのパス
* そのディレクトリからのタスクYAMLを示すglobパターン(例:`**/tasks/*.yml`)

前述のように確認せずにファイルを書き換えるので、大事なコードを事前にコミットしてワークスペースを綺麗にしておく。

# 制限事項

* 本来はプラグインやフィルターもFQCNに変換するべきらしい(リダイレクタを無効にするとこれらも旧名では機能しない)が、あくまでもタスクのみが対象です。
* 一切の保証はありません。ころんでも泣かない。

# 参考
* [\[Ansible\] モジュールの指定などを FQCN 表記に移行する手順 \- てくなべ \(tekunabe\)](https://tekunabe.hatenablog.jp/entry/2020/09/19/ansible_fqcn#-2-FQCN-%E3%81%AB%E4%BF%AE%E6%AD%A3%E3%81%97%E3%81%A6%E5%86%8D%E5%AE%9F%E8%A1%8C)
* [Ansible 2\.10 Porting Guide — Ansible Documentation](https://docs.ansible.com/ansible/devel/porting_guides/porting_guide_2.10.html)
* [Conversion to Collection \- YAML roundtrip with ruamel \| Linux System Roles](https://linux-system-roles.github.io/linux-system-roles.github.io/2020/09/collection-conversion-ruamel-roundtrip)

# 実行例

## 対象のタスクファイル

```yaml
---

- hosts: win_host
  gather_facts: yes
  tasks:
    - name: get terminal service behaviour on failure
      win_command: sc qfailure TermService
      register: qfailure
      changed_when: no

    - name: debug
      debug: var=qfailure
```

### 実行

```bash
$ python convert2fqcn.py
processing old.yml
  checking win_command...
    converted to ansible.windows.win_command
  checking debug...
    not found.
```

## 結果

`win_command`が`ansible.windows.win_command`に変換された。値の部分は変更されていない。また`debug`も変換されていない。

差分を表示する為に変換前後でファイル名を変えているが、実際には上書きするのでご注意下さい。

```diff
--- old.yml	2021-03-25 10:01:46.297632163 +0900
+++ new.yml	2021-03-25 09:55:20.492025609 +0900
@@ -7 +7 @@
-      win_command: sc qfailure TermService
+      ansible.windows.win_command: sc qfailure TermService
```

# スクリプト

```python:convert2fqcn.py
from pathlib import Path
import re
import yaml

'''
python  convert2fqcn.py

Ansibleタスクのモジュール名をFQCN形式に変換します。以下に指定したディレクトリ配下に見つかった
ファイルを直接書き換えます。

'''


# Ansibleモジュールのリダイレクト用config 'ansible_builtin_runtime.yml'へのパス
CONVERTION_MAP_PATH = './ansible-2.10-venv/lib/python3.6/site-packages/ansible/config/ansible_builtin_runtime.yml.back'
# 置換を行うパス
TARGET_DIR = 'roles/'
# 置換を行うタスクファイルのglobパターン。前項からの相対パスで表す。
TASKS_GLOB_PATTERN = '**/tasks/*.yml'

map_cache = {}

# nameキーで始まるオブジェクトにマッチ
name_line_regex = ' *- name:.*\n'
# 次の行のコロンまでをモジュール名と判定
module_line_regex = '([\S_]+):'
module_name_regex = f'({ name_line_regex } *){module_line_regex}'


def main():
    role_path = Path(TARGET_DIR).rglob(TASKS_GLOB_PATTERN)
    for path in role_path:
        with open(path, 'r') as role_file:
            print(f'processing {path}')
            task_data = role_file.read()
            new_task_data = replace(task_data)
        if new_task_data != task_data:
            with open(path, 'w') as role_file:
                role_file.write(new_task_data)


def load_covertion_map(convertion_map_path):
    runtime_data = yaml.load(open(convertion_map_path))
    lis = runtime_data.get('plugin_routing').get('modules')
    return lis


def replace(task_data):
    r = re.compile(module_name_regex)
    return re.sub(r, convert_task, task_data)


def convert_task(match_obj):
    res = None
    if match_obj.groups():
        prev_lines, old_key = match_obj.groups()
        print(f'  checking {old_key}...')
        entry = query(old_key)
    else:
        # マッチしなければ呼ばれないのでここには来ないはず。
        return None
    if entry:
        new_key = entry.get('redirect')
        print(f'    converted to {new_key}')
        key = new_key
    else:
        print('    not found.')
        key = old_key
    # キー区切りのコロンまでマッチしているので必ず付ける。
    return prev_lines + key + ':'


def query(key):
    cached = map_cache.get(key)
    if cached:
        return cached
    entry = CONVERTION_MAP.get(key)
    map_cache[key] = entry
    return entry


if __name__ == '__main__':
    CONVERTION_MAP = load_covertion_map(CONVERTION_MAP_PATH)
    main()

```