001_記一次ansible api二次開發遇到的小問題

Joestar發表於2020-08-05

  在某次關於釋出系統的專案中,需要呼叫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,就得加個 ',' ,哈哈

相關文章