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