Ansible + SoT или Ещё раз про динамический inventory

от автора

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 можно почитать на Хабре и в официальной документации.

Ну и, конечно же, не могу не упомянуть моего друга и бывшего коллегу Николая Никифорова, который внес неоценимый вклад в разработку описанного решения.


ссылка на оригинал статьи https://habr.com/ru/articles/774864/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *