ぽよメモ

レガシーシステム考古学専攻

Moleculeを使う際にTestinfraからAnsible Factsを参照する

概要

 Ansible公式のテストフレームワークであるMoleculeは,Ansibleによる構築の後に,デフォルトではTestinfraによるテストを実行します. Testinfraにはhost.ansible.get_variables()というAPIがあり,これを介してAnsibleの変数を取得できるように見えます.が,実際にはinventoryやhost_vars,group_varsに記載されているもの以外の値を取ることができません.

 ここでは,Testinfraによるテストコードから,gather_factsによって収集される変数やRoleのdefaultsやvars内で定義されている変数を参照するハックを紹介します.ただし,これは完全にAnsibleの挙動と同じであるとは言えないかもしれないという点に注意してください.

環境

  • macOS 10.15.2 Catalina
  • Python 3.7.5
  • Ansible 2.9.2
  • Molecule 2.22
  • Testinfra 3.4.0

ここでは単体のRoleのテストのみを取り扱います.Roleのディレクトリの構造は以下の通りです.

$ tree -L 2 /path/to/role
/path/to/role
├── LICENSE
├── Pipfile
├── Pipfile.lock
├── README.md
├── defaults
│   └── main.yml
├── meta
│   └── main.yml
├── molecule
│   └── default
├── tasks
│   └── main.yml
├── tests
│   ├── inventory
│   └── test.yml
└── vars
    └── main.yml

Testinfraにおける問題点

 TestinfraはAnsibleと完全に統合されているわけではないため,構築の実行中に使用される変数を取得することができません.これにより何が起こるかというと,複数のプラットフォームに跨ったテストを書くときにifを大量に並べることになります.

テストコード中では以下のようにOSの情報を取得できます*1

def test_hoge(host):
    dist = host.system_info.distribution
    if dist == 'debian':
        # some action
    elif dist == 'ubuntu':
        # some action
    elif dist == 'darwin':
        # some action
    elif dist == 'centos':
        # some action

正直めちゃくちゃ面倒臭い……インストールするパッケージのバージョンチェックなども,このままではバージョンをハードコードすることになり,Role内の値を変更するときにテストコード内も全て変更したりする必要が出てきます.

 さて,ここで救世主のようにみえるTestinfraのAPIHost.ansible.get_variables()の実際の実装をみてみましょう.

https://github.com/philpep/testinfra/blob/3.4.0/testinfra/utils/ansible_runner.py#L172-L189

ansible-inventoryコマンドを叩いてインベントリの情報を収集している以外は過去のバージョンとの互換性を保つためのコードのようです.つまり,get_variables()とは言いつつも,実際にはインベントリに記述されている情報しか取っていません

既存のアプローチ

いくつかのIssueで議論されています.

github.com

github.com

これらの中でも直接YAMLとして読み込んだり,include_varssetupを使用した方法が紹介されていますが,これらは誤った用法によりうまく変数を解釈できていません.

  • YAMLとして読み取る
    • これは論外
  • include_varsを使う
    • 一見うまく動作するように思えるが,これも実際には変数の解釈をしない
    • 例) hoge: "{{ ansible_python_interpreter }}"のようなYAMLをロードしても{"hoge": "{{ ansible_python_interpreter }}"}が返ってくるだけ
  • include_vars + setup + ansible.template.Templar

Testinfraから変数を参照する

 ようやく本題です.変数を取得する方法は雑にいうとAnsibleのinclude_varssetupモジュールを使い,自分でファイル読み込みの優先順位を定義して順に読み取っていきます.読み込んだ変数を優先度順に ansible.template.Templar で解決していきます.
 Testinfraが内部で採用しているテストフレームワークであるpytestの作法に則って,fixtureとして実装します.

setup

まず,setupモジュールを使用してAnsibleのFactsを収集します.

import pytest

@pytest.fixture(scope='module')
def ansible_facts(host):
    return host.ansible("setup", "gather_subset=min")["ansible_facts"]

これはそれなりに重いので,

  1. gather_subsetfilterなどの引数を使って必要な変数のみ取得*2
  2. fixtureのスコープをmoduleにすることで,複数回の取得を防ぐ

等で多少早くなります(たぶん).

 このansible_factsというfixtureは単にいつも使っているFacts*3がそのまま返ってきます.

include_vars

 include_varsを使って各変数定義ファイルを読んでいきます.

from ansible.template import Templar
from ansible.parsing.dataloader import DataLoader

@pytest.fixture(scope='module')
def ansible_vars(host, ansible_facts):
    # リストの後にいくほど優先度が高い
    # host.system_infoを利用すれば,OSごとに読み取るvarsを変更している場合にも対応可能
    var_files = [
        "../../defaults/main.yml",
        host.ansible.get_variables(),
        "../../vars/main.yml",
    ]
    templar = Templar(loader=DataLoader())
    # 優先度順に読んでいく
    all_vars = {}
    all_vars.update(ansible_facts)
    for f in var_files:
        if isinstance(f, str):
            var_from_f = host.ansible(
                "include_vars", f"file={f}")["ansible_facts"]
        else:
            var_from_f = f
        # 変数の出現順に埋め込みを解決
        for key, val in var_from_f.items():
            templar.available_variables = all_vars
            all_vars[key] = templar.template(val)
    return all_vars

ディクショナリの順序が保証されているPython 3.6以降でしかうまく動作しない気はしています.

 templar.template()は引数にディクショナリも取ることができるのですが,その際,解決すべき変数がそのディクショナリ内に含まれている場合にうまく動作しません. 例えば以下のようなファイルを読み込み,そのまま渡すとうまく解決することができずにエラーになります.

---
hoge: "hoge"
hogefuga: "{{ hoge }}fuga"

そのため,まずはhogeという変数を解決してTemplarの持つ変数のディクショナリを更新し,次にhogefugaを解決するようにしました.

 

テストで使う

 実際のテストコードへの実装は非常に容易です.例えばインストールされているべきパッケージの一覧をinstalled_packagesとして定義しているなら,以下のようにします.

def test_hoge(host, ansible_vars):
    expected_packages = ansible_vars.get("installed_packages")
    for expected_pkg in expected_packages:
        actual_pkg = host.package(expected_pkg)
        assert actual_pkg.is_installed

メリット

  • 煩わしいOSの判別を一部不要にできる可能性がある
  • Role側がデフォルト値を変えただけなどの場合に,テストを手動で修正する必要が無くなる

デメリット

  • そもそもRoleのデフォルト値が間違っていたりするとテストの意味がなくなる
  • setupが重いため,内容次第ではテストが遅くなる可能性がある
  • 完全にansible-playbookコマンドによる実行と同等とはいえず,また,内部のAPIを直接参照しているため,今後も互換性が担保されるかわからない

現時点での制限

  • Role中のタスクでset_factしたりregisterしている変数は参照できない
  • 変数の優先順位を人が解決する必要がある

などなど.現時点での実装があまり賢いとは思っていないので,もしより良い方法を知っている人がおられれば教えていただけると幸いです.