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]
711_R0
712_R0

# основные роутеры Cisco на складах
[wh-cr-r0]
7119_R0
7129_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.kifr-ru.local
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 можно почитать на Хабре и в официальной документации.

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

Комментарии (0)