在某次关于发布系统的项目中,需要调用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,就得加个 ',' ,哈哈

08-05 20:00