在某次关于发布系统的项目中,需要调用ansible来发布任务,其中一段代码是初始化ansible的连接,并传入一个source(目标机器)的值,代码段如下:
from .ansible_api import AnsibleClient
sources = '192.168.1.218'
client = AnsibleClient(sources) ...
完成后发现一直报错,如下所示错误信息:
[WARNING]: Unable to parse python/devops/192.168.1.218 as an inventory source
看字面意思描述,是说无法解析python/devops/192.168.1.218为一个inventory源。inventory是ansible专用的一个用来存储目标机器的文件。通过报错信息大概能够猜出,当我想要以IP地址的方式传入目标机器时候,ansible却把IP当做inventory文件来解析了。
先抛出结论,正确的传值方式,是在IP地址后增加一个 ',' ,让sources = '192.168.1.218,' ,再传入。或者传入两个以上的IP,如'192.168.1.218,192.168.1.219' 。
但是传入一个IP是我的一个正常应用场景,那么通过源码分析,我们来看看为何要出现以上结果。
实例化的AnsibleClient是在ansible_api文件中,有一个AnsibleClient类,其中有初始化方法__init__(self, source),代码如下:
class AnsibleClient:
def __init__(self, source):
self.source = source
self.loader = DataLoader()
self.inventory = InventoryManager(loader=self.loader, sources=self.source)
self.variable_manager = VariableManager(loader=self.loader, inventory=self.inventory)
self.passwords = dict(vault_pass='secret')
self.callback = None
在以上代码中,可以看到在实例化AnsibleClient连接时,之前的192.168.1.218会由source参数传进来,并且最终再传入InventoryManager
接着再查看InventoryManager的源码,源码所在文件位置为路径ansible/inventory/manager.py中,代码片段如下所示:
class InventoryManager(object):
def __init__(self, loader, sources=None):
if sources is None:
self._sources = []
elif isinstance(sources, string_types):
self._sources = [sources]
else:
self._sources = sources
self.parse_sources(cache=True)
通过以上代码,会发现在InventoryManager实例化时候,如果sources有值传入,那么sources会赋值给self._sources,并继续走self.parse_sources(cache=True)这段,通过利用print('报错信息在此!!!')断点大法,确实如此。
接着查看parse_sources方法,代码片段如下:
def parse_sources(self, cache=False):
''' iterate over inventory sources and parse each one to populate it''' parsed = False
# allow for multiple inventory parsing
for source in self._sources: if source:
if ',' not in source:
source = unfrackpath(source, follow=False)
parse = self.parse_source(source, cache=cache)
if parse and not parsed:
parsed = True
注意里面有一句if ',' not in source:,此时我们会走到这一分支,并进入unfrackpath这一步,接着查看这一方法的源代码,如下所示:
def unfrackpath(path, follow=True, basedir=None):
b_basedir = to_bytes(basedir, errors='surrogate_or_strict', nonstring='passthru') if b_basedir is None:
b_basedir = to_bytes(os.getcwd(), errors='surrogate_or_strict')
elif os.path.isfile(b_basedir):
b_basedir = os.path.dirname(b_basedir) b_final_path = os.path.expanduser(os.path.expandvars(to_bytes(path, errors='surrogate_or_strict'))) if not os.path.isabs(b_final_path):
b_final_path = os.path.join(b_basedir, b_final_path) if follow:
b_final_path = os.path.realpath(b_final_path) return to_text(os.path.normpath(b_final_path), errors='surrogate_or_strict')
这个方法是用来解析文件路径的,通过这个方法,让我们的'192.168.1.218',最终变成了'python/devops/192.168.1.218',此时,我们得到了报错信息提示源是谁了。但是我们传入的是192.168.1.218,不想被解析为文件,所以回到parse_sources方法,将if ',' not in source变为if ',' and '.' not in source:,跳过if分支,再次运行程序,报错信息变成了下面这样:
[WARNING]: Unable to parse 192.168.1.218 as an inventory source
成功了一半了,但是为何还会出现报错信息,得接着查
回到parse_sources方法接着往下,source通过跳过unfrackpathparse的if分支后,被传入self.parse_source(source, cache=cache)方法,该方法代码如下:
def parse_source(self, source, cache=False):
''' Generate or update inventory for the source provided ''' parsed = False
display.debug(u'Examining possible inventory source: %s' % source) # use binary for path functions
b_source = to_bytes(source) # process directories as a collection of inventories
if os.path.isdir(b_source):
display.debug(u'Searching for inventory files in directory: %s' % source)
for i in sorted(os.listdir(b_source)): display.debug(u'Considering %s' % i)
# Skip hidden files and stuff we explicitly ignore
if IGNORED.search(i):
continue # recursively deal with directory entries
fullpath = to_text(os.path.join(b_source, i), errors='surrogate_or_strict')
parsed_this_one = self.parse_source(fullpath, cache=cache)
display.debug(u'parsed %s as %s' % (fullpath, parsed_this_one))
if not parsed:
parsed = parsed_this_one
else:
# left with strings or files, let plugins figure it out # set so new hosts can use for inventory_file/dir vars
self._inventory.current_source = source # try source with each plugin
failures = []
for plugin in self._fetch_inventory_plugins(): plugin_name = to_text(getattr(plugin, '_load_name', getattr(plugin, '_original_path', '')))
display.debug(u'Attempting to use plugin %s (%s)' % (plugin_name, plugin._original_path)) # initialize and figure out if plugin wants to attempt parsing this file
try:
plugin_wants = bool(plugin.verify_file(source))
print(plugin_wants,source)
except Exception:
plugin_wants = False if plugin_wants:
try:
# FIXME in case plugin fails 1/2 way we have partial inventory
plugin.parse(self._inventory, self._loader, source, cache=cache)
try:
plugin.update_cache_if_changed()
except AttributeError:
# some plugins might not implement caching
pass
parsed = True
display.vvv('Parsed %s inventory source with %s plugin' % (source, plugin_name))
break
except AnsibleParserError as e:
display.debug('%s was not parsable by %s' % (source, plugin_name))
tb = ''.join(traceback.format_tb(sys.exc_info()[2]))
failures.append({'src': source, 'plugin': plugin_name, 'exc': e, 'tb': tb})
except Exception as e:
display.debug('%s failed while attempting to parse %s' % (plugin_name, source))
tb = ''.join(traceback.format_tb(sys.exc_info()[2]))
failures.append({'src': source, 'plugin': plugin_name, 'exc': AnsibleError(e), 'tb': tb})
else:
display.vvv("%s declined parsing %s as it did not pass its verify_file() method" % (plugin_name, source))
else:
if not parsed and failures:
# only if no plugin processed files should we show errors.
for fail in failures:
display.warning(u'\n* Failed to parse %s with %s plugin: %s' % (to_text(fail['src']), fail['plugin'], to_text(fail['exc'])))
if 'tb' in fail:
display.vvv(to_text(fail['tb']))
if C.INVENTORY_ANY_UNPARSED_IS_FAILED:
raise AnsibleError(u'Completely failed to parse inventory source %s' % (source))
if not parsed:
if source != '/etc/ansible/hosts' or os.path.exists(source):
# only warn if NOT using the default and if using it, only if the file is present
display.warning("Unable to parse %s as an inventory source" % source) # clear up, jic
self._inventory.current_source = None return parsed
该方法比较长,但是可以看到里面的报错信息出处,在这里"Unable to parse %s as an inventory source",是因为if not parsed条件进来的,那么parsed为何False,还得看这段代码动作。
首先最开始parsed被置为了False,然后通过print大法得知if os.path.isdir(b_source)分支没有进入,走的是后面else分支。通过代码各种变量命名规则,大概能够猜到这里面是在做inventory plugin的判断。
如果parsed被置为了False,那么一定会报错,为了避免就一定会在这段代码里parsed会有置为True的地方,那么plugin_wants = bool(plugin.verify_file(source))这一句就至关重要。
通过plugin.verify_file(source)接收到参数source(192.168.1.218)后,parsed被置为了False,通过方法字面意思知道verify_file是校验source是否为文件的。我们查看verify_file的源代码,文件位置为ansible/plugins/invenroy/host_list.py,代码如下:
def verify_file(self, host_list): valid = False
b_path = to_bytes(host_list, errors='surrogate_or_strict')
if not os.path.exists(b_path) and ',' in host_list:
valid = True
return valid
果然,如果想让返回结果为True的话,需要满足if条件,即文件不存在,并且 ',' 存在。
这里将if not os.path.exists(b_path) and ',' in host_list:这一句修改为if not os.path.exists(b_path):,运行程序,报错没了。
再回头捋一遍整个源码思路,当我们将IP地址传入ansible连接初始化时,会校验该IP地址是否为文件,如果是文件,那么就解析文件路径,最终看该文件内是否有目标机器。
不为文件,就校验inventory plugin的各种插件(默认开启5个,其中包括host_list)。如果该IP符合插件所需特征,就由插件来解析IP地址,如果不符合,最终抛出报错信息Unable to parse %s as an inventory source。
要想解析这个问题,有两个思路:
第一个,最简单的就是传入IP地址为一个的时候,末尾加上 ','
第二个,修改源码两个地方,一是跳过unfrackpath分支,二是让host_list.py的verify_file方法为真。上文均有过修改代码的描述。
好了,整个分析结束。我猜,ansible的意思是,既然初始化ansible连接需要传入host的list,那么单个IP地址不能称为list,如果想成为list,就得加个 ',' ,哈哈