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 можно почитать на Хабре и в официальной документации.
Ну и, конечно же, не могу не упомянуть моего друга и бывшего коллегу Николая Никифорова, который внес неоценимый вклад в разработку описанного решения.