はじめに
Ansible で定義した変数を ansible_spec などのツールや自分で書いたプログラムなどで使いまわしたいことがありました。ざっと探して手軽にやりたいことがすぐにできるようなものがなさそうだったので、変数を抽出するようなものを作成しました(ansible のコードを読んで適宜利用するのが正しそうですが、読むのも手間がかかるのと ansible が行えること全てを考慮する必要はなかったので作成しました)。ここでは作成したものについてまとめています。
要件
取得する変数は以下のようなものを想定しています。
- 単純な変数や配列、辞書やそれらを合わせたもの
- 変数の値を参照する変数(多重に参照するものも含む)
- 計算式や split などが含まれるもの
具体的には以下の yml ファイルから以下に示す結果が得られば良いとしています。
■ymlファイルの例
simple: 'simple variable'
simple_array:
- 'array1'
- 'array2'
simple_dict:
dict1: 'dict1'
dict2: 'dict1'
dict_array:
dict_array1:
- 'array1'
- "{{ simple }}"
simple_ref: "{{ simple }}/{{ simple }}"
simple_array_ref: "{{ simple_array }}"
simple_dict_ref: "{{ simple_dict.dict1 }}"
dict_array_ref: "{{ dict_array.dict_array1[0] }}"
multi_ref: "{{ simple_ref }}"
simple_int: 10
simple_float: 1.5
sum: "{{ simple_int + simple_float + 10 }}"
split: "{{ 'split1/split2'.split('/') }}"
■結果として得たいもの(今回作成したもので実際に得られるもの)
{"simple": "simple variable", "simple_array": ["array1", "array2"], "simple_dict": {"dict1": "dict1", "dict2": "dict1"}, "dict_array": {"dict_array1": ["array1", "simple variable"]}, "simple_ref": "simple variable/simple variable", "simple_array_ref": ["array1", "array2"], "simple_dict_ref": "dict1", "dict_array_ref": "array1", "multi_ref": "simple variable/simple variable", "simple_int": 10, "simple_float": 1.5, "sum": "21.5", "split": ["split1", "split2"]}
上記では yml ファイルは1つしかありませんが、Ansible ではインベントリなど複数箇所に変数を定義したyml ファイルを置くので、ファイルが複数あっても読み取れるようにします。
作成したもの
jinja2 を利用するため、Python で実装しています。
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from jinja2.nativetypes import NativeEnvironment
from jinja2 import Environment
import re
import yaml
import json
j2_env = Environment()
j2_native_env = NativeEnvironment()
def recursive_update(all_dict, target):
val_pattern = r'\{\{\s*.*?\s*\}\}'
if isinstance(target, str) and re.search(val_pattern, target):
res = variable_check(all_dict, target)
return res
# valueが配列なら再度解析する
elif isinstance(target, (list)):
res = []
for next_target in target:
res.append(recursive_update(all_dict, next_target))
return res
# valueが辞書なら再度解析する
elif isinstance(target, dict):
res = {}
for k, next_target in target.items():
res[k] = recursive_update(all_dict, next_target)
return res
return target
def variable_check(all_dict, target):
val_pattern = r'\{\{\s*.*?\s*\}\}'
lookup_pattern = r'lookup(.*?)'
array_pattern = r'\[\d+\]'
matched_list = re.findall(val_pattern, str(target))
res = target
res_dict = {}
non_native_flag = False
for match in matched_list:
# 以下のような変数に対応する
# {{ num * 5 }}
# {{ '1.5' | replace('.', '') }}
# {{ array[0] }}
match = re.sub('\{\{\s*|\s*\}\}', '', match)
match_splited = re.split('\s+|\|', match)
for split in match_splited:
# 配列なら[\d+]を削らないと対象を探せないので削除
split = re.sub(array_pattern, '', split)
# array等の.で表されるものはスタートだけとって、全て辿る
split = split.split('.')[0]
if split in all_dict:
res_dict[split] = recursive_update(all_dict, all_dict[split])
# native_envで"aaa{{ hogehoge }}1"のような形式がうまく扱えなかったので別途対応
if not re.match('^\[.*\]$|^\{.*\}$', str(res_dict[split])):
non_native_flag= True
# loopupのようなライブラリは実行できないので、その場合はそのままの値を入れる
if re.search(lookup_pattern, str(target)):
res = target
else:
if non_native_flag:
res = j2_env.from_string(str(target)).render(res_dict)
else:
res = j2_native_env.from_string(str(target)).render(res_dict)
# unicodeを変更
if not isinstance(res, list) and not isinstance(res, dict):
res = str(res)
return res
def get_ansible_vars():
var_dict = {}
# 優先度が弱い順に読み込み
file_list = ['./inventories/staging/group_vars/all/main.yml',
'./roles/common/vars/main.yml']
read_yml_files(var_dict, file_list)
return var_dict
def read_yml_files(var_dict, file_list):
for yml in file_list:
with open(yml, "r") as f:
var_dict.update(yaml.load(f))
if __name__ == '__main__':
all_vars_dict = get_ansible_vars()
print(all_vars_dict)
result = {}
for k, target in all_vars_dict.items():
result[k] = recursive_update(all_vars_dict, target)
# 標準出力で結果を出す
print(json.dumps(result))
■実行
$ python parse_variable.py
{"simple": "simple variable", "simple_array": ["array1", "array2"], "simple_dict": {"dict1": "dict1", "dict2": "dict1"}, "dict_array": {"dict_array1": ["array1", "simple variable"]}, "simple_ref": "simple variable/simple variable", "simple_array_ref": ["array1", "array2"], "update_check": "role", "simple_dict_ref": "dict1", "dict_array_ref": "array1", "multi_ref": "simple variable/simple variable", "simple_int": 10, "simple_float": 1.5, "sum": "21.5", "split": ["split1", "split2"]}
実行には以下のパッケージ・ファイルが必要
■パッケージ
pip install jinja2==2.10
■ファイル(実行テスト用に適当にymlを記述したもの)
simple: 'simple variable'
simple_array:
- 'array1'
- 'array2'
simple_dict:
dict1: 'dict1'
dict2: 'dict1'
dict_array:
dict_array1:
- 'array1'
- "{{ simple }}"
simple_ref: "{{ simple }}/{{ simple }}"
simple_array_ref: "{{ simple_array }}"
update_check: 'inventory'
simple_dict_ref: "{{ simple_dict.dict1 }}"
dict_array_ref: "{{ dict_array.dict_array1[0] }}"
multi_ref: "{{ simple_ref }}"
simple_int: 10
simple_float: 1.5
sum: "{{ simple_int + simple_float + 10 }}"
split: "{{ 'split1/split2'.split('/') }}"
update_check: 'role'
詳細
変数抽出は以下の2つの処理を行っています。以降で、詳細を記述します。
- 対象となる yml ファイルを全て読み込む。
- 読み込んだファイルの情報をもとに変数抽出する。
ymlファイルの読み取り
Ansible では roles や group_vars、 inventories 以下などに変数を記述した yml ファイルを配置します
(どういった配置になるかなどは Best Practice を参考)。
また、変数には 優先度 があるため、それを考慮する必要があります。
下記のように 辞書 に更新する形で yml ファイルを読み込むことで、優先度を考慮することができます。
def get_ansible_vars():
var_dict = {}
# 優先度が低い順に読み込み
file_list = ['./inventories/staging/group_vars/all/main.yml',
'./roles/common/vars/main.yml']
read_yml_files(var_dict, file_list)
return var_dict
def read_yml_files(var_dict, file_list):
for yml in file_list:
with open(yml, "r") as f:
# 後からreadされた変数優先度が高ければ上書きする
var_dict.update(yaml.load(f))
上記を実行すると下記のように単純に変数を含んだ辞書ができます。
{'simple': 'simple variable', 'simple_array': ['array1', 'array2'], 'simple_dict': {'dict1': 'dict1', 'dict2': 'dict1'}, 'dict_array': {'dict_array1': ['array1', '{{ simple }}']}, 'simple_ref': '{{ simple }}/{{ simple }}', 'simple_array_ref': '{{ simple_array }}', 'update_check': 'role', 'simple_dict_ref': '{{ simple_dict.dict1 }}', 'dict_array_ref': '{{ dict_array.dict_array1[0] }}', 'multi_ref': '{{ simple_ref }}', 'simple_int': 10, 'simple_float': 1.5, 'sum': '{{ simple_int + simple_float + 10 }}', 'split': "{{ 'split1/split2'.split('/') }}"}
この時点で、2つの yml ファイルに記述されている update_check の値が'role'になっていることから、
変数の読み込みの優先度をつけれていることがわかります。
変数抽出
ymlファイルの読み取りで作成した辞書を対象に処理を実施します。recursive_update、variable_check 関数を用います。
recursive_update
def recursive_update(all_dict, target):
val_pattern = r'\{\{\s*.*?\s*\}\}'
if isinstance(target, str) and re.search(val_pattern, target):
res = variable_check(all_dict, target)
return res
# valueが配列なら再度解析する
elif isinstance(target, list):
res = []
for next_target in target:
res.append(recursive_update(all_dict, next_target))
return res
# valueが辞書なら再度解析する
elif isinstance(target, dict):
res = {}
for k, next_target in target.items():
res[k] = recursive_update(all_dict, next_target)
return res
return target
■機能
ymlの変数の値を返す。
■引数
all_dict(dict): 変数の参照先の候補
target(str): 対象となる変数の値(例、{{ simple_ref }}, 'simple variable'など)
■戻り値
対象の変数の値
■処理内容
yml ファイルから読み込んだ変数の情報を与えることで、求めたい変数の値を返します。例えば、上記で要件に挙げている、{{ simple_ref }} を target として与えると "simple variable/simple variable"が取得できます。
処理の内容としてはtargetとして与えられた変数の値を見て、1~4のどれかの処理を実行します。
- target の値が{{ 変数名 }}といった形であれば、variable_check 関数に target の値を渡し、その結果を返す。
- target の型が配列であれば、配列の各値を順に recursive_update 関数に渡し、再帰的に処理をする。
- target の型が辞書であれば、辞書の value の値を順に recursive_update 関数に渡し、再帰的に処理をする。
- target が上記以外の(配列, 辞書でなく、変数を参照しない)値であればそのまま返す。
それぞれ具体例を挙げると以下のようになります。
- target の値が{{ simple_ref }}であれば variable_check 関数が対応する値(この場合は"simple variable/simple variable")を返すため、この結果を返す。
- target の値が['array1', 'array2']といった配列であれば'array1', 'array2'それぞれを target として recursive_update 関数に渡して再帰的に処理を実行する。最終的には配列を返す。
- target の値が{'dict1': 'dict1', 'dict2': 'dict1'}といった辞書であればそれぞれ、辞書の値である'dict1'、'dict2'を target として recursive_update 関数に渡して再帰的に処理を実行する。最終的には辞書を返す。
- target の値が'simple variable'など単なる値であればそのまま返す。
再帰的に処理を実施するため{'dict_array1': ['array1', '{{ simple }}']}(上記要件のdict_array)のような複雑な値でも、
- {'dict_array1': ['array1', '{{ simple }}']}を処理、辞書なので値を再処理する。
- ['array1', '{{ simple }}']を処理、配列なので各値を再処理する。
- 'array1'を処理、単なる文字列なので"array1"が返る。
- '{{ simple }}'を処理、variable_check関数を実行し、"simple variable"が返る。
- 2の処理結果として["array1", "simple variable"]が返る。
- 1の処理結果として{"dict_array1": ["array1", "simple variable"]}が返る。
のように対応する値を返すことができます。考え方は下記と同様です。
(複雑なJSONから特定のデータを取り出す)
variable_check
def variable_check(all_dict, target):
val_pattern = r'\{\{\s*.*?\s*\}\}'
lookup_pattern = r'lookup(.*?)'
array_pattern = r'\[\d+\]'
matched_list = re.findall(val_pattern, str(target))
res = target
res_dict = {}
non_native_flag = False
for match in matched_list:
# 以下のような変数に対応する
# {{ num * 5 }}
# {{ '1.5' | replace('.', '') }}
# {{ array[0] }}
match = re.sub('\{\{\s*|\s*\}\}', '', match)
match_splited = re.split('\s+|\|', match)
for split in match_splited:
# 配列なら[\d+]を削らないと対象を探せないので削除
split = re.sub(array_pattern, '', split)
# array等の.で表されるものはスタートだけとって、全て辿る
split = split.split('.')[0]
if split in all_dict:
res_dict[split] = recursive_update(all_dict, all_dict[split])
# native_envで"aaa{{ hogehoge }}1"のような形式がうまく扱えなかったので別途対応
if not re.match('^\[.*\]$|^\{.*\}$', str(res_dict[split])):
non_native_flag= True
# loopupのようなライブラリは実行できないので、その場合はそのままの値を入れる
if re.search(lookup_pattern, str(target)):
res = target
else:
if non_native_flag:
res = j2_env.from_string(str(target)).render(res_dict)
else:
res = j2_native_env.from_string(str(target)).render(res_dict)
# unicodeを変更
if not isinstance(res, list) and not isinstance(res, dict):
res = str(res)
return res
■機能
{{ simple_ref }} のような変数の値を取得する
■引数
all_dict(dict): 変数の参照先の候補
target(str): 対象となる変数の値(例、{{ simple_ref }})
■戻り値
対象の変数の値
■処理内容
targetの値が{{ simple_ref }} のような変数参照をしている場合にその変数の値を返します。
'{{ simple_int + simple_float + 10 }}'などの変数の値をsimple_int, simple_float, 10など、適宜分割し、最後に jinja2 のテンプレートとしてレンダリングします。
jinja2 の NativeEnvironment では"aaa{{ simple }}11"といった数値や文字列が混じった場合のレンダリングがうまくいかないケースがあったので以下の部分で通常の Environment も利用するように場合分けしています(変数が配列や辞書でなければEnvironmentを利用します)。
if split in all_dict:
res_dict[split] = recursive_update(all_dict, all_dict[split])
# native_envで"aaa{{ hogehoge }}1"のような形式がうまく扱えなかったので別途対応
if not re.match('^\[.*\]$|^\{.*\}$', str(res_dict[split])):
non_native_flag= True
# ----------------------------------- 中略 -----------------------------------
if non_native_flag:
res = j2_env.from_string(str(target)).render(res_dict)
else:
res = j2_native_env.from_string(str(target)).render(res_dict)
また、jinja2 の結果は unicode で返されるため、配列と辞書以外は str に型を変えます。
# unicodeを変更
if not isinstance(res, list) and not isinstance(res, dict):
res = str(res)
注意点
jinja2 のレンダリングを行う都合上、レンダリング自体が失敗するような場合には動作しなくなってしまいます。例えば、以下の場合には動作しないです。
- 変数にlookup などのプラグインやマジック変数、Ansible Vaultの値が含まれる場合
- コード中で実施していますが、適宜レンダリングしないようにする必要があります。
- 多重に数値計算をする場合
- {{ 変数名 }} の値を全て str で返しているので以下のような場合は失敗します。(sum が str 扱いになり、 sum2 の計算がうまくいかない)
simple_int: 10
sum: "{{ simple_int + 10 }}"
sum2: "{{ sum + 10 }}"
その他
hosts ファイルからグループの取得
本ページでは直接ymlファイルのパスを指定して変数の読み込みを行なっていますが、実際には引数や設定用のコンフィグファイルなど、何らかの形にしてパスを渡すことが多いです。中でも hosts ファイルに設定しているグループ毎に変数を設定をすることもあるため、hosts ファイルからグループの情報を取得する方法を一例を記載します。各ホストがどのグループに所属しているかの辞書を取得できます。
def get_parent_group_dict('inventories/staging/hosts'):
relation_dict = {}
current_group = ''
with open(file, 'r') as f:
for line in f:
line = line.rstrip('\n')
if line.startswith('#') or line == '' :
continue
if line.startswith('[') and line.endswith(']'):
current_group = re.sub('\[|\]', '', line.replace(':children', ''))
continue
line = line.split()[0]
if line in relation_dict:
relation_dict[line].append(current_group)
else:
relation_dict[line] = [current_group]
parent_group_dict = {}
for k, v in relation_dict.items():
parent_list = []
if hasinvalue(k, relation_dict):
continue
parse_relation_dict(parent_list, k, v, relation_dict)
parent_group_dict[k] = parent_list
return parent_group_dict
def hasinvalue(target, target_dict):
result = False
for v in target_dict.values():
if target in v:
result = True
break
return result
def parse_relation_dict(parent_list, target, group_list, relation_dict):
for group in group_list:
parent_list.append(group)
if group in relation_dict:
parse_relation_dict(parent_list, group, relation_dict[group], relation_dict)
本ページのタイトルから逸脱するので詳細は割愛します。
Ansible Vaultの変数について
Ansible Vaultの変数はここら辺の記事など見れば取得できるかと思いますが、Ansible Vaultの値を扱うこと自体に抵抗があったので、あえて値を取得しないようにしています。
まとめ
要件に書いたようなようなymlファイルを読み取り、Ansibleで定義した変数の値を取得できました。ただし、当然定義していないプラグインやAnsibleのマジック変数の値を含む変数や多重に計算を行うような変数の取得はできません。
これを使う利点としては、Ansible以外のツール利用時に変数と同様の値を再定義しなくて良いことや、別途追加したい変数を別に追加しやすい(別にymlファイルを作成して読み込ませるようにするだけ)ことが挙げられます。