Ansible используют почти все, и мы не исключение. В отделе сетевой инфраструктуры мы с его помощью автоматизируем доставку конфигураций на коммутаторы и маршрутизаторы. Наша сеть постоянно расширяется, внедряются новые фичи, которые тянут за собой новые плейбуки и роли, но одно всегда оставалось неизменным — список хостов мы держали в ini-файле. И когда количество хостов стало исчисляться сотнями, пришло понимание, что вручную контролировать inventory в файле не очень-то и удобно.
«Постойте» - подумали мы - «у нас же есть актуальный список всех сетевых устройств в Nautobot, и в нашей сети именно он является источником истины. Надо всего лишь подружить Ansible и Nautobot». Что ж, давайте посмотрим, как умеют договариваться эти двое.

Но сначала о том, как был устроен наш файл inventory. Все хосты в нём разбиты на вложенные друг в друга группы: по роли (роутер, коммутатор), по производителю, по типу локации (центральный офис, магазины, склады), по назначению (основной роутер, резервный, тестовый). Выглядел inventory примерно так:
# все роутеры Cisco [all-cr:children] dc-cr hq-cr branch-cr-r0 branch-cr-r1 test-cr # роутеры Cisco в ЦОД [dc-cr] DC_R0 DC_R1 # роутеры Cisco в центральном офисе [hq-cr] HQ_R0 HQ_R1 # основные роутеры Cisco в филиалах [branch-cr-r0:children] st-cr-r0 wh-cr-r0 # резервные роутеры Cisco в филиалах [branch-cr-r1:children] st-cr-r1 wh-cr-r1 # основные роутеры Cisco в магазинах [st-cr-r0] 111_R0 112_R0 # основные роутеры Cisco на складах [wh-cr-r0] 1119_R0 1129_R0 # output omitted
Отказываться от привычных групп мы не собирались, так что предстояло придумать, как синхронизировать состав этих групп и содержимое Nautobot.
Для тех, кто ещё не слышал про Nautobot, скажем пару слов о нём и его месте в нашей инфраструктуре:
это форк Netbox,
мы про него уже писали,
в нём описаны все наши сетевые устройства, с интерфейсами и адресами, эта информация актуальна;
также мы учитываем в Nautobot серверы и виртуальные машины;
все устройства и серверы привязаны к локациям;
если разрезов учёта «из коробки» не хватает, мы используем custom fields или пишем плагины;
у Nautobot есть очень хорошо описанный API;
в Nautobot есть вся нужная информация для inventory.
Какие же есть способы решения возникшей задачи? Первое, что приходит в голову — скрипт, который запросит данные из Nautobot и подготовит файл с хостами по заданному алгоритму.
Вариант № 1: простая синхронизация по расписанию
API у Nautobot вполне достаточно, выбираем любимый язык программирования и добавляем написанный скрипт в cron. Алгоритм простой: обращаемся к API по HTTP(s), дёргаем список устройств по заданным параметрам и пишем в файл. Нам нужно получить от Nautobot сведения, которые позволят распределить хосты по нужным группам. Для сохранения преемственности данные в файл будем писать в формате INI, хотя, на мой взгляд, YAML был бы попроще.
Примерный код скрипта на Python мог бы выглядеть так:
nautobot2ansible_v1.py
import os, requests url = os.getenv('NAUTOBOT_GRAPHQL_URL') headers = { "Content-Type": "application/json", "Accept-Encoding": "gzip", "Authorization": f"Token {os.getenv('NAUTOBOT_TOKEN')}", } query = """ { devices( status: ["active"], manufacturer: "Cisco", has_primary_ip: true ){ name primary_ip4 { address } device_role { slug } site { slug cf_format } tags { slug } } } """ response = requests.post(url, headers=headers, json={"query": query}) if response.status_code == 200: inventory = configparser.ConfigParser(allow_no_value=True) for device in response.json()["data"]["devices"]: device_name = device["name"] device_ip = device["primary_ip4"]["address"].split("/")[0] # соберем секцию исходя из какой-то нашей логики # первая часть зависит от формата филиала, данные хранятся в custom field 'format' section_part_1 = { "store": "st", "warehouse": "wh" }.get(device["site"]["cf_format"], "other") # вторая часть зависит от роли устройства section_part_2 = { "router": "cr", "access_switch": "cs", }.get(device["device_role"]["slug"], "cd") # третья часть зависит от тега, навешенного на устройство tags = [tag["slug"] for tag in device["tags"]] if "primary_router" in tags: section_part_3 = "r0" elif "secondary_router" in tags: section_part_3 = "r1" else: section_part_3 = "rx" if device["device_role"]["slug"] == "router": section = f"{section_part_1}-{section_part_2}-{section_part_3}" else: section = f"{section_part_1}-{section_part_2}" if not inventory.has_section(section): inventory.add_section(section) inventory.set(section, f"{device_name} ansible_host={device_ip}") with open("cisco_hosts", 'w') as inventory_file: inventory.write(inventory_file)
В коде используется обращение не к привычному всем нам REST API, а к более современному GraphQL. Подробнее про работу с GraphQL в Nautobot можно почитать тут. Почему GraphQL? Он быстрее, проще, и отдаёт только нужные данные.
Преимущества такого решения: относительная простота реализации и, пожалуй, на этом всё.
Недостатки:
Данные актуальны только на момент выполнения скрипта, изменения в период между синхронизацией inventory и запуском плейбука учитываться не будут.
Набор групп статичен и захардкожен в скрипте.
Итак, начинаем избавляться от недостатков. Первым делом надо придумать, как актуализировать список хостов на момент запуска плейбуков.
Вариант № 2: Dynamic inventory (свой)
Если вы посмотрите ansible.cfg, то наверняка найдёте в нём примерно такие строки:
[inventory]
# enable inventory plugins, default: 'host_list', 'script', 'auto', 'yaml', 'ini', 'toml'
Параметр script означает, что «из коробки» Ansible позволяет в качестве источника списка хостов применять не только привычные нам YAML или INI, а любые исполняемые скрипты. Достаточно положить скрипт в нужную папку, указать правильный шебанг и дать права на исполнение. Подробнее можно почитать в оригинале.
Код получается почти такой же, как в первом варианте. Основные отличия:
Скрипт в зависимости от входных аргументов выводит или список хостов (--list), или данные одного хоста (--host {hostname}).
Скрипт не изменяет файл, а возвращает JSON.
Если требуются данные только одного хоста, то в запросе к Nautobot появляется дополнительное условие.
nautobot2ansible_v2.py
#! /usr/bin/python3 import requests import argparse import json import os, sys parser = argparse.ArgumentParser() parser.add_argument( "--list", action='store_true' ) parser.add_argument( "--host", action='store', ) args = parser.parse_args() inventory = { "_meta": { "hostvars": {} } } # Выводим пустые vars и выходим if not args.list and not args.host: print(json.dumps(inventory, indent=4)) sys.exit(0) url = os.getenv('NAUTOBOT_GRAPHQL_URL') headers = { "Content-Type": "application/json", "Accept-Encoding": "gzip", "Authorization": f"Token {os.getenv('NAUTOBOT_TOKEN')}", } if args.host: limit_device = f'name: "{args.host}",' else: limit_device = "" query = """ { devices(%s status: ["active"], manufacturer:"Cisco", has_primary_ip:true) { name primary_ip4 { address } device_role { slug } site { slug cf_format } tags { slug } } } """ % (limit_device) response = requests.post(url, headers=headers, json={"query": query}, verify=False) if response.status_code == 200: for device in response.json()["data"]["devices"]: device_name = device["name"] device_ip = device["primary_ip4"]["address"].split("/")[0] # соберем секцию исходя из какой-то нашей логики # первая часть зависит от формата филиала, данные хранятся в custom field 'format' section_part_1 = { "store": "st", "warehouse": "wh" }.get(device["site"]["cf_format"], "other") # вторая часть зависит от роли устройства section_part_2 = { "router": "cr", "access_switch": "cs", }.get(device["device_role"]["slug"], "cd") # третья часть зависит от тега, навешенного на устройство tags = [tag["slug"] for tag in device["tags"]] if "primary_router" in tags: section_part_3 = "r0" elif "secondary_router" in tags: section_part_3 = "r1" else: section_part_3 = "rx" if device["device_role"]["slug"] == "router": section = f"{section_part_1}-{section_part_2}-{section_part_3}" else: section = f"{section_part_1}-{section_part_2}" if section not in inventory: inventory[section] = { "hosts": [] } inventory[section]["hosts"].append(device_name) inventory["_meta"]["hostvars"][device_name] = { "ansible_host": device_ip } # Выводим hosts и vars if args.list: print(json.dumps(inventory, indent=4)) # Выводим vars конкретного хоста elif args.host: host_vars = { "_meta": { "hostvars": inventory["_meta"]["hostvars"][args.host] } } print(json.dumps(host_vars, indent=4))
Такое решение позволяет нам получать нужные группы и актуальный список хостов в момент выполнения плейбука. Это уже самый настоящий динамический inventory, создаваемый на лету. Но всё ещё остаётся недостаток с фиксированным набором групп. Хотелось бы оперативно добавлять группы и регулировать их наполнение через интерфейс Nautobot. Так что двигаемся дальше и продолжаем генерировать идеи.
Вариант № 3 - Dynamic inventory (чужой)
Давайте посмотрим, что там в сообществе? Может, есть готовый велосипед, который нас устроит? И действительно, разработчики Nautobot позаботились о ленивых сетевых инженерах и в составе большой коллекции для Ansible galaxy предоставили плагин networktocode.nautobot.inventory. Никакой код писать не надо: установили плагин, заполнили согласно документации файл inventory, и получили актуальный список хостов, разбитый по группам. Причём группировать можно по широкому набору полей: по производителю, роли оборудования, локации, даже по тегам. Всё формируется на лету: добавили устройства нового производителя — сразу в списке хостов появляется группа с таким же названием.
Предположим, что мы создали следующий файл inventory.yml:
plugin: networktocode.nautobot.inventory api_endpoint: https://nautobot validate_certs: True config_context: False group_by: - device_roles - tags - manufacturers - sites query_filters: - status: active - role: router - role: access_switch device_query_filters: - has_primary_ip: 'true'
В результате мы получаем (вывод неполный, только несколько коммутаторов и маршрутизаторов для примера):
ansible-inventory --graph -i inventory.yml @all: |--@device_roles_access_switch: | |--111_SW1 | |--111_SW2 | |--112_SW1 | |--112_SW2 |--@device_roles_router: | |--111_R0 | |--111_R1 | |--112_R0 | |--112_R1 |--@manufacturers_Cisco: | |--111_R0 | |--111_R1 | |--112_R0 | |--112_R1 | |--111_SW1 | |--111_SW2 | |--112_SW1 | |--112_SW2 |--@sites_111: | |--111_R0 | |--111_R1 | |--111_SW1 | |--111_SW2 |--@sites_112: | |--112_R0 | |--112_R1 | |--112_SW1 | |--112_SW2 |--@tags_primary_router: | |--111_R0 | |--112_R0 |--@tags_secondary_router: | |--111_R1 | |--112_R1
Достоинства решения:
Не простое, а очень простое внедрение.
Возможность передавать в переменные хоста данные из Nautobot (custom fields, local context и т. д.).
Разнообразные способы группировки хостов.
Ну и куда же без недостатков:
Нельзя просто так взять и объединить группы.
В целом, очень хороший вариант, особенно если нет времени на разработку аналогичного, но своего, решения. Надо было только привыкнуть к названиям новых групп и отсутствию старых. Но мы не стали мириться с недостатками и захотели ещё большего удобства и наглядности.
Вариант № 4: Dynamic inventory (свой, удобный, красивый)
В итоге мы пришли к следующей концепции:
В Nautobot создаётся список групп, которые содержат формализованные фильтры по устройствам и виртуальным машинам. Интерфейс должен позволять сразу увидеть список хостов, входящих в группу по заданным условиям. Скрипт динамического inventory получает по API список групп с условиями, и затем по каждому условию получает список хостов, который вносит в состав соответствующей группы.
Например, сетевому инженеру нужна группа, в которую входят маршрутизаторы, удовлетворяющие следующим условиям:
модель: Cisco 4300 series;
тег: primary_router;
локация: склады;
статус: active.
Инженер добавляет в Nautobot группу с названием cr4300_wh_r0 и в её настройках указывает нужные фильтры. Можно посмотреть список устройств, попадающих в группу, и проконтролировать правильность фильтра. Этого достаточно, чтобы созданная группа появилась в Ansible inventory, нет необходимости править файлы inventory на сервере с Ansible. И, разумеется, концепция должна работать не только для сетевых устройств, но и для серверов, и для ВМ, которые учитываются в Nautobot.
Для реализации задуманного были написаны плагин для Nautobot и скрипт динамического inventory. Основные составляющие плагина:
модель данных;
View, отвечающий за формирование списка хостов по условию группы;
шаблон HTML для удобного просмотра списка хостов по условию группы.
Модель данных предельно проста: в таблице храним имя группы, параметры фильтра в JSON и статус. Не забываем про возможность выдачи данных по GraphQL, это делается простым добавлением нужного декоратора.
models.py
from nautobot.core.models.generics import PrimaryModel from nautobot.extras.models import StatusModel from nautobot.extras.utils import extras_features @extras_features( "statuses", "graphql", ) class AnsibleGroup(PrimaryModel, StatusModel): name = models.CharField(max_length=20, unique=True, blank=False) device_filters = models.JSONField(encoder=DjangoJSONEncoder) description = models.CharField(max_length=150, blank=False, default="")
View полностью приводить не буду, класс стандартный, как для любого плагина, и отличается от всех остальных только наличием функции get_extra_context, которая отвечает за передачу дополнительного содержимого из View в шаблон. Именно в ней составляется список устройств в соответствии с параметрами фильтра:
views.py
def get_extra_context(self, request, instance): # фильтры, которые можно использовать в запросе DEVICE_PREFILTER_KEYS = { "status": "status__slug__in", "role": "device_role__slug__in", "name": "name__in", "manufacturer": "platform__manufacturer__slug__in", "site": "site__slug__in", } VM_PREFILTER_KEYS = { "status": "status__slug__in", "role": "role__slug__in", "name": "name__in", "site": "cluster__site__slug__in", } # фильтры для ограничения уже полученного набора данных DEVICE_POSTFILTER_KEYS = ["model", "cf_format", "platform", "tag", "tag_any"] VM_POSTFILTER_KEYS = ["cf_format", "platform", "tag", "tag_any"] context = super().get_extra_context(request, instance) if self.action == "retrieve": dev_filters = instance.device_filters # фильтр по умолчанию device_kw_args = { "status__slug__in": ["active"], } for key, filter_value in dev_filters.items(): if type(filter_value) is str: cur_filter = [filter_value] else: cur_filter = filter_value if key in DEVICE_PREFILTER_KEYS: device_kw_args.update({DEVICE_PREFILTER_KEYS[key]: cur_filter}) # получаем список устройств, ограниченный префильтрами devices_raw = ( Device.objects.restrict(request.user, "view") .filter(**device_kw_args) ) devices = [] for device in devices_raw: device_tags = device.tags.slugs() for key in DEVICE_POSTFILTER_KEYS: cur_filter = dev_filters.get(key) if cur_filter: if type(cur_filter) is str: cur_filter = [cur_filter] if key == "model" and not any(filter_value.lower() in device.device_type.slug.lower() for filter_value in cur_filter): # ищем вхождение фильтра "model" в device_type.slug break elif key == "cf_format" and not any(filter_value.lower() == device.site.cf.get("Format", "Other").lower() for filter_value in cur_filter): # проверяем на соответствие формату сайта (custom field "format") break elif key == "platform" and not any(filter_value.lower() == device.platform.napalm_driver.lower() for filter_value in cur_filter): # проверяем на соответствие платформе (конкретную платформу храним в поле napalm_driver) break elif key == "tag" and not all(filter_value in device_tags for filter_value in cur_filter): # все теги должны совпадать с фильтром break elif key == "tag_any" and not any(filter_value in device_tags for filter_value in cur_filter): # хотя бы один тег должен совпадать с фильтром break else: devices.append(device) device_table = DeviceTable(devices) # спрячем лишние столбцы for column in ("tenant", "rack", "location"): device_table.columns.hide(column) paginate = { "paginator_class": EnhancedPaginator, "per_page": get_paginate_count(request), } RequestConfig(request, paginate).configure(device_table) # передадим в шаблон полученную таблицу context["device_table"] = device_table # дальше идёт код для подготовки списка VirtualMachine, он аналогичен коду для Device return context
Можно заметить, что поля в фильтре делятся на два типа:
Первые участвуют в запросе к данным и применяются на стороне сервера БД.
Значения вторых проверяются уже на полученных данных.
Поля первого типа используются для подготовки запроса к таблице Device. Результаты запроса последовательно сверяются со значениями полей второго типа. Итоговый список хостов выводится пользователю в HTML-шаблоне. На стороне Ansible в динамическом inventory работает точно такой же алгоритм: скрипт запрашивает список групп в Nautobot и для каждой из них делает GraphQL-запрос с последующей генераций списка хостов.
Вот так пользователь видит группу и список хостов:

А чтобы при создании новой или редактировании существующей группы пользователи сразу видели возможности плагина, на экран выводится справка по фильтру:

Приводить код скрипта динамического inventory, работающего на стороне Ansible, смысла уже не вижу, для его создания мы просто соединили принципы из второго вариант и логику из View созданного плагина. Такой вариант связки Nautobot + Ansible оказался самым удобным в использовании. Интерфейс плагина наглядный, максимально простой, и в то же время позволяет реализовать любые комбинации условий для создания новых групп.
О том, как писать плагины для Nautobot можно почитать на Хабре и в официальной документации.
Ну и, конечно же, не могу не упомянуть моего друга и бывшего коллегу Николая Никифорова, который внес неоценимый вклад в разработку описанного решения.
