Как стать автором
Обновить

Организация IPAM и переезд с phpIPAM в NetBox: советы и подводные камни

Время на прочтение21 мин
Количество просмотров13K

В прошлой статье я рассказал о том, как наладить кабель-менеджмент в NetBox — популярном опенсорс-инструменте для документирования инфраструктуры. В этом посте я перейду к тому, как организовать IP-адресацию и, по заявкам читателей, затрону перенос данных в NetBox из phpIPAM. Все процессы будут проиллюстрированы скриншотами NetBox версии 3.2, до которой мы обновились в тестовой среде.

IPAM (IP-адресация) в NetBox

Ведение IPAM — это базовая функция NetBox. DCIM, может быть, нужен и не всем, но без IPAM обойтись почти невозможно. Хотя на практике я не встречал, чтобы в NetBox приходили исключительно за IPAM. Как правило, две эти функции отлично дополняют друг друга в рамках единого инструмента.

Кто-то начинает вести учет IP-адресов в Excel, но по мере усложнения сети это становится все менее удобным. NetBox же сильно облегчает задачу, потому что из коробки предлагает учет префиксов, IP-адресов и VLAN, а также разделение на VRF и даже RT. Поэтому очень удобно совмещать DCIM и IPAM, связывая в одной системе устройства и их адреса, используемые для доступа к устройствам. Это помогает при автоматизации.

А теперь обо всем по порядку.

Иерархия IPAM в NetBox

В NetBox IP-адреса автоматически группируются по вложенности, от aggregates, которые представляют собой либо выделенные нами глобальные адреса, либо больше приватные сети. Далее aggregates распадаются на сети и подсети, которые используются для выделения IP-адресов. Всё привычно и понятно: в адресах должен быть порядок.

RIR — это провайдер, организация, ответственная за адресное пространство. Когда адреса внутренние, здесь указывается RFC-стандарт.

При создании сети в настройках важно указать, предназначена ли она для разделения на подсети или для раздачи IP-адресов. От этого зависит, как будет отображаться заполненность сети в интерфейсе NetBox. В первом случае это будет Container, во втором у сети может быть любой другой статус (например, Active).

Создавая подсеть, важно корректно указать, pool это или нет. От этого будет зависеть, раздается ли первый или последний IP-адрес.

Уже на старте следует решить, как быть с дублями. В конфигах NetBox можно настроить это поведение. Как правило, дублирование в рамках одного адресного пространства не допускается.

В таком сценарии при попытке добавления уже существующего адреса будет возникать ошибка. Стоит включить его как минимум во время переноса IP-пространства в NetBox из другой базы, если мы знаем, что там не всё точно, а желания воевать с каждым дублем нет. Так можно переехать по-быстрому, сохранив всё как есть.

В GUI NetBox также предусмотрены красивые визуализации заполненности для отслеживания свободных IP и сетей.

VLAN группы

Это наши классические VLAN, здесь следует отметить только разницу между VLAN в группе и в общем пространстве. В последнем случае можно заводить VLAN с дублированием и NAME, и ID — NetBox не будет против помойки любых масштабов. В рамках группы дублирование запрещено как по ID, так и по NAME.

В идеальном мире всё должно быть уникально и разложено по полочкам. VLAN-группы удобно использовать в качестве L2-доменов.

Разделения пространств VRF 

По умолчанию все адресное пространство идет в global table. Но вы можете разделить таблицы маршрутизации с помощью VRF, если привыкли это делать в своей среде.

Если вы в NetBox ведете учет виртуальных машин, то есть раздел Services, здесь можно указать, какие сервисы относительно портов виртуальным машинам предоставляются. Например, какие порты предполагаются как открытые на сервере. Или SSH на железе.

Как можно разметить пространство

В адресном пространстве должно быть легко искать сети и адреса. В IPAM могут приходить разные люди. Сетевым инженерам нужна новая сеть или адрес, безопаснику надо понять, к какому сегменту относится адрес и куда он хочет попасть. Наконец, наши интеграции должны легко находить необходимое. Для этого нужны фильтры, которые позволят через API получать нужное за минимальное количество запросов.

Советую выделить роли. Например, ваши DEV-, TEST- и PROD-среды. Или mgmnt-, data- и voice. Для сети может быть назначена только одна роль. Можно разделить на Sites или Tenants. Sites — это больше про географию, Tenants — про владение/ответственность за ресурсы.

Будет полезно продумать логическую вложенность для поиска. Например, мне надо найти mgmnt-адрес внутри сетей DC. Здесь логика бывает разная, можно как угодно скрещивать для поиска роли tenants, sites и tags. Теги (метки) удобны для сортировок агрегации, это межмодельная сущность: один тег может быть и на адресах, и на устройствах. Теги в NetBox цветные и красивые, на одной сущности их может быть сколько угодно, нет никаких ограничений.

Tenants — это сущность, разделяющая сами ресурсы и владение ими. Удобно для провайдеров услуг клиентам, но мы это не используем. Мы глобально выделяем большие роли DEV, TEST и PROD, а далее делим тегами как нам удобно. Для поиска и сортировок теги группируются как угодно: можно искать как в рамках модели, так и делать выборку по NetBox в целом.

В будущем мы раскидаем адреса и по sites/locations, чтобы получить еще больше возможностей для создания фильтров.

Резервирование IP

Отдельно расскажу об этой возможности. При сервисном подходе клиент приходит к нам за IP-адресом и на стороннем портале заполняет свои пожелания. Затем сервис через API NetBox может прийти и взять для клиента IP-адрес. После того как клиент сдает ресурсы (например, VM), сервис так же через API освобождает адрес. Таким образом, время сетевых инженеров или архитекторов освобождается от части многочисленных рутинных задач.

Удобно использовать автообнаружение в заданных сетях. Так, после добавления сети через некоторое время в рамках сайта, к которому эта сеть привязана, появились наши устройства. Через указанные адреса можно получить доступ на устройство из NetBox через Napalm, собирать прямо в NetBox таким образом соседей и смотреть конфигурации железок.

Что не так с phpIPAM

Для нас главная проблема phpIPAM в том, что это PHP, с которым копаться особого желания нет ни у кого. API, на мой взгляд, очень странный и непоследовательный, приходится писать много проверок, если надо что-то поискать. Его возможности сильно уступают NetBox в гибкости и многообразии endpoints и фильтров. Документация тоже оставляет желать лучшего.

NetBox же основан на привычном всем Python и дает полную свободу. Есть множество уже написанных для него плагинов, а если чего-либо не хватает, то всегда можно взять и написать свой. С версии 3.2 дружелюбность для писателей плагинов сильно возросла. Функциональность NetBox постоянно расширяется. На любые вопросы можно найти ответы в Slack.

Но, конечно, не стоит переходить на NetBox просто для галочки. В нашем случае NetBox начали использовать в первую очередь как DCIM, и уже когда прижился, появилось желание вместо двух систем держать на поддержке одну.

Переезд из phpIPAM в NetBox: планирование

Для начала нелишним будет оценить масштабы беспорядка в системе, из которой мы переезжаем. Следует узнать, не нужно ли перед переездом поработать с организацией текущей системы. Вполне возможно, что в ней уже что-то устарело, и по возможности стоит заранее прибраться. Затем надо решить, что и как мы будем переносить, определить сущности и составить план. Важно также определить, как будем переходить — разом или с учетом синхронизации по времени (хорошо если не в обе стороны).

Чтобы определить, что конкретно надо перенести из phpIPAM, стоит посмотреть по логам, кто им пользуется, пообщаться с заинтересованными сторонами и сформировать список требований к новой системе.

Если можно перейти сразу, то подойдет одноразовый скрипт без большого количества проверок и без логов. Если переносить под присмотром с ручной проверкой данных по обе стороны, такое решение можно реализовать за день. Мы переносили данные в таком формате; если все заинтересованные лица сразу готовы жить по-новому, можно сразу закрыть phpIPAM на редактирование. «Вошли и вышли, приключение на 20 минут».

Если требуется постепенный переход, когда пользователям надо оставить возможность работать и с NetBox, и с phpIPAM, придется писать полноценный ETL, хранить состояния, синхронизировать данные, писать много проверок и решать конфликты синхронизации. Это не наш размах, мы ограничились простым скриптом на один раз. Проверили вручную, что данные переехали корректно, прогнали некоторое количество интеграционных тестов и пошли пить кофе.

Когда в NetBox придут новые пользователи, для них нужно будет заводить группы и ограничивать права только на секцию IPAM в NetBox. Наверно, неплохо будет провести обучение по новой системе. Если с phpIPAM были какие-то интеграции, то коллегам надо будет дать время их переписать и помочь с освоением API NetBox. Он намного лучше, чем API phpIPAM, поэтому проблем быть не должно.

Переезд из phpIPAM в NetBox: реализация

Расскажу, как проходила миграция у нас. Пообщавшись с коллегами и внутри группы, мы решили, что переносить будем сети, IP-адреса и VLAN с имеющимися названиями и описаниями там, где они будут, а также информацию о шлюзах и DNS. Для этих данных мы создали кастомные поля в рамках модели префикса.

Логически в phpIPAM у нас было деление на секции. В NetBox оно реализовано через роли для больших сущностей — DEV, TEST, CERT, PROD. Поскольку роль может быть только одна, для дальнейшего разделения будем использовать теги. Они удобны для сортировок, ими можно связывать разные модели для группировки. Можно было бы для этих целей использовать tenants и sites, но это менее удобно и не совсем корректно.

С версии 3.0 в NetBox появились ip-range, но мы их не стали использовать. По сравнению со старыми способом получить адрес они не дают преимуществ, не очень понятен профит. К тому же мы не хотели потерять совместимость с обвесом, написанным под API более ранних версий.

Итак, после анализа мы определились с последовательностью действий при переезде:

  1. Заполнить aggregate

  2. Заполнить RIR

  3. Заполнить role

  4. Заполнить tag

  5. Перенести сети и описания

  6. Перенести адреса в сети

  7. Перенести VLAN и описания

  8. Связать сети и VLAN

  9. Раскидать роли

  10. Раскидать теги

  11. Проверить целостность данных

  12. Проверить интеграции с коллегами

На github есть реп с большой сложной переноской всего и вся из phpIPAM в NetBox. Но судя по комментам, там что-то не работает. Вместо того чтобы дебажить, что там не так (скорее всего, из-за смены API), мы решили написать решение сами для переноса только того, что нам надо. Для взаимодействия с API как в NetBox, так и phpIPAM удобно использовать token. Для phpIPAM нам хватить прав только на чтение, а к NetBox нужен полный доступ.

Вот как выглядит сценарий, отвечающий за перенос сетей и адресов. Комментарии в коде:

from typing import List, Dict, Optional
from datetime import datetime
from collections import Counter
from json.decoder import JSONDecodeError
from progress.bar import IncrementalBar
import ipaddress
import requests
import backoff
# подгружаем переменные, адрес апи и токены
from credential import NETBOX_API_TOKEN, NETBOX_API, PHP_IPAM_API, PHP_IPAM_API_TOKEN
# хедеры для апи запросов
HEADERS_NETBOX = {
    'Authorization': f'Token {NETBOX_API_TOKEN}',
    'Content-Type': 'application/json',
    'Accept': 'application/json',
}
HEADERS_PHPIPAM = {
    'Content-Type': 'application/json',
    'token': PHP_IPAM_API_TOKEN
}
# создаем счетчик с переменным
# количество адресов
# количество префиксов
counter = Counter(ipaddr=0, pref=0)
requests.packages.urllib3.disable_warnings()
# обрабатывает ошибки и пробует 5 раз
@backoff.on_exception(
    backoff.expo,
    (requests.exceptions.RequestException, JSONDecodeError),
    max_tries=5
    )
def pref_post_nb(
    pref: str,
    descr: Optional[str],
    default_gateway: Optional[str],
    gateway_from_id: Optional[str],
    dns_name: Optional[str],
    dns_servers: Optional[str]
) -> str:
    # добавляем префикс в NetBox
    netbox_api_endpoint = '/ipam/prefixes/'
    # если нет информации, приравниваем к пустой строке
    if descr is None: descr = ''
    if default_gateway is None: default_gateway = ''
    if dns_name is None: dns_name = ''
    if dns_servers is None: dns_servers = ''
    # сравниваем шлюз, если одинаковые, оставляем один
    # сущности не связанные в phpIPAM
    # дефолтного может не быть шлюза
    if default_gateway == gateway_from_id:
        payload = {
            'prefix': f'{pref}',
            'description': f'{descr}',
            'custom_fields':{
                'default_gateway': f'{default_gateway}',
                'dns_name': f'{dns_name}',
                'dns_servers': f'{dns_servers}'
            }
        }
    else:
        # если шлюзы отличаются или нет дефолтного, заносим оба
        payload = {
            'prefix': f'{pref}',
            'description': f'{descr}',
            'custom_fields':{
                'default_gateway': f'Default gateway: {default_gateway}, Gateway: {gateway_from_id}',
                'dns_name': f'{dns_name}',
                'dns_servers': f'{dns_servers}'
            }
        }
    pref_post_resp = requests.post(
        NETBOX_API + netbox_api_endpoint, headers=HEADERS_NETBOX, json=payload, verify=False
        ).json()
    return f'SEND: {payload}, RECIVE: {pref_post_resp}'


@backoff.on_exception(
    backoff.expo,
    (requests.exceptions.RequestException, JSONDecodeError),
    max_tries=5
    )
def ipaddr_post_nb(ipaddr: str, descr: Optional[str], host: Optional[str]) -> str:
    # добавляем адрес в NetBox
    netbox_api_endpoint = '/ipam/ip-addresses/'
    # если нет описания или имени хоста, приравниваем к пустой строке
    if descr is None: descr = ''
    if host is None: host = ''
    payload = {
        'address': f'{ipaddr}',
        'description': f'{descr}, hostname: {host}'
    }
    ipaddr_post_resp = requests.post(
        NETBOX_API + netbox_api_endpoint, headers=HEADERS_NETBOX, json=payload, verify=False
        ).json()
    return f'SEND: {payload}, RECIVE: {ipaddr_post_resp}'


@backoff.on_exception(
    backoff.expo,
    (requests.exceptions.RequestException, JSONDecodeError),
    max_tries=5
    )
def sec_get_from_phpipam() -> List[Dict[str, Optional[str]]]:
    # получаем секции из phpIPAM
    php_ipam_api_endpoint = '/autoNB/sections/'
    sections_phpipam_resp = requests.get(
        PHP_IPAM_API + php_ipam_api_endpoint, headers=HEADERS_PHPIPAM, verify=False
        ).json()['data']
    return sections_phpipam_resp


@backoff.on_exception(
    backoff.expo,
    (requests.exceptions.RequestException, JSONDecodeError),
    max_tries=5
    )
def pref_get_from_sec(sec_id: str) -> List[Dict[str, Optional[str]]]:
    # получаем префиксы из секции
    php_ipam_api_endpoint = f'/autoNB/sections/{int(sec_id)}/subnets/'
    pref_phpipam_resp = requests.get(
        PHP_IPAM_API + php_ipam_api_endpoint, headers=HEADERS_PHPIPAM, verify=False
        ).json()['data']
    return pref_phpipam_resp
@backoff.on_exception(
    backoff.expo,
    (requests.exceptions.RequestException, JSONDecodeError),
    max_tries=5
    )
def ipaddr_get_from_pref(pref_id: str) -> Optional[List[Dict[str, Optional[str]]]]:
    # получаем адреса из секции
    php_ipam_api_endpoint = f'/autoNB/subnets/{int(pref_id)}/addresses/'
    try:
        ipaddr_phpipam_resp = requests.get(
            PHP_IPAM_API + php_ipam_api_endpoint, headers=HEADERS_PHPIPAM, verify=False
            ).json()['data']
        return ipaddr_phpipam_resp
    # если адресов нет в префиксе
    except KeyError:
        return None
def pref_checker(pref: str) -> bool:
    # проверяем, что это префикс, а не адрес
    try:
        ipaddress.ip_network(pref)
        return True
    except ValueError:
        return False
@backoff.on_exception(
    backoff.expo,
    (requests.exceptions.RequestException, JSONDecodeError),
    max_tries=5
    )
def ipaddr_by_id(id: Optional[str]) -> str:
    # получить адрес шлюза по его ID
    # если нет данных, вернем пустую строку
    if id is None: return ''
    php_ipam_api_endpoint = f'/autoNB/addresses/{id}/'
    ipaddr_phpipam_resp = requests.get(
        PHP_IPAM_API + php_ipam_api_endpoint, headers=HEADERS_PHPIPAM, verify=False
        ).json()['data']['ip']
    # возвращаем адрес
    return ipaddr_phpipam_resp


@backoff.on_exception(
    backoff.expo,
    (requests.exceptions.RequestException, JSONDecodeError),
    max_tries=5
    )
def get_aggregate_from_netbox() -> List[str]:
    # получаем агрегаты из NetBox
    aggregates = []
    netbox_api_endpoint = '/ipam/aggregates/'
    aggregates_get_resp = requests.get(
        NETBOX_API + netbox_api_endpoint, headers=HEADERS_NETBOX, verify=False
        ).json()['results']
    for aggregate in aggregates_get_resp:
        aggregates.append(aggregate['prefix'])
    return aggregates
def main() -> None:
    # собираем логику
    # время старта
    start_time = datetime.now()
    # открываем файл, в который запишем лог для анализа, если что-то пошло не по плану
    with open('data/transfer_prefixes_from_phpipam_to_netbox.txt', 'a') as f:
        aggregates = get_aggregate_from_netbox()
        sections_phpipam_resp = sec_get_from_phpipam()
        # запускаем счетчик в консоль, отсчитывающий количество секций из phpIPAM
        bar = IncrementalBar('section : ' + '\t', max=len(sections_phpipam_resp))
        # проходимся по секциям
        for sec in sections_phpipam_resp:
            bar.next()
            # собираем префиксы из секции
            pref_phpipam_resp = pref_get_from_sec(sec['id'])
            # проверяем, что в секции есть префиксы
            if len(pref_phpipam_resp) != 0:
                # проходимся по префиксам из секции
                for pref in pref_phpipam_resp:
                    # проверяем по ключу, что сеть не пустое значение
                    # в phpIPAM бывают сети папки, нам такие не нужны
                    if pref['subnet'] is not None:
                        # склеиваем префикс с маской
                        pref_with_mask = f'{pref["subnet"]}/{pref["mask"]}'
                        # проверяем, что префикс не из агрегатов NetBox
                        # если из них, то его не надо добавлять как префикс
                        if pref_with_mask not in aggregates:
                            # проверяем, что это сеть, а не адрес
                            if pref_checker(pref_with_mask):
                                # получаем его шлюз по ID адреса, если нет, то None
                                gateway_from_id = ipaddr_by_id(pref.get('gatewayId'))
                                # добавляем префикс, вместе с его описанием, DNS и Gateway
                                added_pref = pref_post_nb(
                                    pref_with_mask,
                                    pref['description'],
                                    pref['Default Gateway'],
                                    gateway_from_id,
                                    # конструкция для неплоских словарей,
                                    # если такой информации в phpIPAM нет, то и поля такого
                                    # с вложенным словарем не будет
                                    # тогда первый get вернет пустой словарь второму get
                                    pref.get('nameservers', {}).get('name'),
                                    pref.get('nameservers', {}).get('namesrv1')
                                )
                                # увеличиваем счетчик
                                counter.update({'pref': 1})
                                # делаем запись в лог
                                f.write(f'{datetime.now()} {added_pref}\n')
                                # получаем все адреса из префикса в phpIPAM
                                ipaddr_phpipam_resp = ipaddr_get_from_pref(pref['id'])
                                # проверяем, что адреса есть в префиксе
                                if ipaddr_phpipam_resp is not None:
                                    # проходимся по всем адресам в префиксе
                                    for ipaddr in ipaddr_phpipam_resp:
                                        # склеиваем адрес с маской
                                        ipaddr_with_mask = f'{ipaddr["ip"]}/{pref["mask"]}'
                                        # добавляем в NetBox адресс, описание и имя хоста
                                        added_addr = ipaddr_post_nb(
                                            ipaddr_with_mask,
                                            ipaddr['description'],
                                            ipaddr['hostname']
                                        )
                                        counter.update({'ipaddr': 1})
                                        f.write(f'{datetime.now()}: {added_addr}\n')
                            else:
                                # если сеть оказалась адресом, добавляем как адрес
                                added_addr = ipaddr_post_nb(pref_with_mask, pref['description'], '')
                                counter.update({'ipaddr': 1})
                                f.write(f'{datetime.now()}: {added_addr} not a prefix\n')
        bar.finish()
    # завершаем и выкидываем в консоль немного статистики
    print(f'{counter["ipaddr"]} ip addreses added')
    print(f'{counter["pref"]} prefixes added')
    print(f'Script took {datetime.now() - start_time} to run')
if __name__ == '__main__':
    main()

Следующий сценарий отвечает за перенос VLAN.

from typing import List, Dict, Any, Optional, Union
from datetime import datetime
from collections import Counter
from json.decoder import JSONDecodeError
from progress.bar import IncrementalBar
import requests
import backoff
# подгружаем переменные, адрес апи и токены
from credential import NETBOX_API_TOKEN, NETBOX_API, PHP_IPAM_API, PHP_IPAM_API_TOKEN
# хедеры для апи запросов
HEADERS_NETBOX = {
    'Authorization': f'Token {NETBOX_API_TOKEN}',
    'Content-Type': 'application/json',
    'Accept': 'application/json',
}
HEADERS_PHPIPAM = {
    'Content-Type': 'application/json',
    'token': PHP_IPAM_API_TOKEN
}
# создаем счетчик с переменным
# количество групп - в качестве групп берем L2 домены из IPAM как есть
# количество VLAN
# количество префиксов, добавленных к VLAN
counter = Counter(groups_to_nb=0, vlan=0, vlan_to_pref=0)
requests.packages.urllib3.disable_warnings()
# обрабатывает ошибки и пробует 5 раз
@backoff.on_exception(
    backoff.expo,
    (requests.exceptions.RequestException, JSONDecodeError),
    max_tries=5
    )
def get_all_l2_domain_phpipam() -> List[Dict[str, Any]]:
    # собираем l2 домены в словарь
    l2_domains = []
    php_ipam_api_endpoint = '/autoNB/l2domains/'
    l2_domains_phpipam_resp = requests.get(
        PHP_IPAM_API + php_ipam_api_endpoint, headers=HEADERS_PHPIPAM, verify=False
        ).json()['data']
    for l2_domain in l2_domains_phpipam_resp:
        l2_domains.append({
            'id': l2_domain['id'],
            'name': l2_domain['name'],
            'description': l2_domain['description'],
        })
    return l2_domains
@backoff.on_exception(
    backoff.expo,
    (requests.exceptions.RequestException, JSONDecodeError),
    max_tries=5
    )
def get_l2_domain_by_id_phpipam(id:str) -> Dict[str, Any]:
    # берем l2 домен по его ID
    php_ipam_api_endpoint = f'/autoNB/l2domains/{id}/'
    l2_domain_phpipam_resp = requests.get(
        PHP_IPAM_API + php_ipam_api_endpoint, headers=HEADERS_PHPIPAM, verify=False
        ).json()['data']
    l2_domain_group = {
        'id': l2_domain_phpipam_resp['id'],
        'name': l2_domain_phpipam_resp['name'],
        'description': l2_domain_phpipam_resp['description'],
    }
    return l2_domain_group
@backoff.on_exception(
    backoff.expo,
    (requests.exceptions.RequestException, JSONDecodeError),
    max_tries=5
    )
def get_vlans_phpipam() -> List[Dict[str, Any]]:
    # собираем VLAN и phpIPAM
    vlans = []
    php_ipam_api_endpoint = '/autoNB/vlan/'
    vlans_phpipam_resp = requests.get(
        PHP_IPAM_API + php_ipam_api_endpoint, headers=HEADERS_PHPIPAM, verify=False
        ).json()['data']
    for vlan in vlans_phpipam_resp:
        vlans.append({
            'idphp': vlan['vlanId'],
            'name': vlan['name'],
            'vid': vlan['number'],
            'description': vlan['description'],
            'l2_domain': vlan['domainId'],
        })
    return vlans
@backoff.on_exception(
    backoff.expo,
    (requests.exceptions.RequestException, JSONDecodeError),
    max_tries=5
    )
def get_pref_for_vlans_phpipam(idphp) -> Union[List[str], bool]:
    # собираем префиксы, связанные с VLAN, в список
    ips = []
    php_ipam_api_endpoint = f'/autoNB/vlan/{idphp}/subnets/'
    try:
        pref_from_vlan_phpipam_resp = requests.get(
            PHP_IPAM_API + php_ipam_api_endpoint, headers=HEADERS_PHPIPAM, verify=False
            ).json()['data']
        for ip in pref_from_vlan_phpipam_resp:
            ips.append(f'{ip["subnet"]}/{ip["mask"]}')
        return ips
    except KeyError:
        ips = False
        return ips
@backoff.on_exception(
    backoff.expo,
    (requests.exceptions.RequestException, JSONDecodeError),
    max_tries=5
    )
def post_group_nb(name: str, description: Optional[str]) -> str:
    # добавляем l2 домены из phpIPAM как группы в NetBox
    netbox_api_endpoint = '/ipam/vlan-groups/'
    if description is None: description = ''
    # имя, описание и slug без пробелов
    payload = {
        'name': name,
        'description': description,
        'slug': f'{name.replace(" ", "").lower()}',
    }
    group_post_resp = requests.post(
        NETBOX_API + netbox_api_endpoint, headers=HEADERS_NETBOX, json=payload, verify=False
        ).json()
    return f'SEND: {payload}, RECIVE: {group_post_resp}'
@backoff.on_exception(
    backoff.expo,
    (requests.exceptions.RequestException, JSONDecodeError),
    max_tries=5
    )
def post_vlan_on_group_nb(vid : int, name: str, description: Optional[str], group_id: int) -> str:
    # добавляем VLAN в группы
    netbox_api_endpoint = '/ipam/vlans/'
    if description is None: description = ''
    # потребуется ID группы
    payload = {
        'name': name,
        'description': description,
        'vid': vid,
        'group': group_id,
    }
    pref_post_resp = requests.post(
        NETBOX_API + netbox_api_endpoint, headers=HEADERS_NETBOX, json=payload, verify=False
        ).json()
    return f'SEND: {payload}, RECIVE: {pref_post_resp}'
@backoff.on_exception(
    backoff.expo,
    (requests.exceptions.RequestException, JSONDecodeError),
    max_tries=5
    )
def patch_vlan_to_pref_nb(pref_id: int, vlan_id: int) -> str:
    # связываем VLAN и префикс
    netbox_api_endpoint = '/ipam/prefixes/'
    # потребуются ID VLAN и префиксов
    payload = [{
        'id': pref_id,
        'vlan': vlan_id,
    }]
    pref_patch_resp = requests.patch(
        NETBOX_API + netbox_api_endpoint, headers=HEADERS_NETBOX, json=payload, verify=False
        ).json()
    return f'SEND: {payload}, RECIVE: {pref_patch_resp}'
@backoff.on_exception(
    backoff.expo,
    (requests.exceptions.RequestException, JSONDecodeError),
    max_tries=5
    )
def get_vlan_id_from_nb(vlan: str, group_id: int) -> int:
    # получаем ID VLAN из NetBox
    netbox_api_endpoint = '/ipam/vlans/'
    # фильтруем по номеру VLAN и ID его группы
    params = {
        'vid': vlan,
        'group_id': group_id,
    }
    vlan_get_id_resp = requests.get(
        NETBOX_API + netbox_api_endpoint, headers=HEADERS_NETBOX, params=params, verify=False
        ).json()["results"][0]["id"]
    return vlan_get_id_resp
@backoff.on_exception(
    backoff.expo,
    (requests.exceptions.RequestException, JSONDecodeError),
    max_tries=5
    )
def get_pref_id_from_nb(prefix: str) -> int:
    # получаем ID префикса
    netbox_api_endpoint = '/ipam/prefixes/'
    params = {
        'prefix': prefix
    }
    pref_get_id_resp = requests.get(
        NETBOX_API + netbox_api_endpoint, headers=HEADERS_NETBOX, params=params, verify=False
        ).json()['results'][0]['id']
    return pref_get_id_resp
@backoff.on_exception(
    backoff.expo,
    (requests.exceptions.RequestException, JSONDecodeError),
    max_tries=5
    )
def get_group_id_from_nd() -> Dict[str, int]:
    # получаем ID групп из NetBox,
    # собираем в словарь имя и ID
    # если нет групп, возвращаем пустой словарь
    group_name_id = {}
    netbox_api_endpoint = '/ipam/vlan-groups/'
    groups_get_resp = requests.get(
        NETBOX_API + netbox_api_endpoint, headers=HEADERS_NETBOX, verify=False
        ).json()['results']
    if groups_get_resp:
        for group in groups_get_resp:
            group_name_id[group['name']] = group['id']
        return group_name_id
    else: return group_name_id
def main():
    # собираем логику
    # время старта
    start_time = datetime.now()
    # открываем файл, в который запишем лог для анализа, если что-то пошло не по плану
    with open('data/transfer_vlan_from_phpipam_to_netbox.txt', 'a') as f:
        # получаем VLANS
        vlans = get_vlans_phpipam()
        # запускаем счетчик в консоль, отсчитывающий количество VLAN
        bar = IncrementalBar('Vlans : ' + '\t', max=len(vlans))
        # получаем домены
        l2_domains = get_all_l2_domain_phpipam()
        # проверяем, какие группы уже есть в NetBox
        group_name_id_pre = get_group_id_from_nd()
        for l2_domain in l2_domains:
            # проверяем что нашей там нет, чтобы не создать дубликат
            if not group_name_id_pre.get(l2_domain['name']):
                # пробегаемся по доменам, создавая группы в NetBox
                post_groups_nb_results = post_group_nb(l2_domain['name'], l2_domain['description'])
                # записываем результат в лог с текущей датой
                f.write(f'{datetime.now()} {post_groups_nb_results}\n')
                # увеличиваем счетчик на 1
                counter.update({'groups_to_nb': 1})
        # собираем ID групп из NetBox
        group_name_id = get_group_id_from_nd()
        # проходимся по всем VLAN
        for vlan in vlans:
            # увеличиваем консольный счетчик на 1
            bar.next()
            # собираем в словарь всю информацию по VLAN, которая понадобится
            # выкладываем информацию о группе и о префиксах
            vlan['group'] = get_l2_domain_by_id_phpipam(vlan['l2_domain'])
            vlan['prefixes'] = get_pref_for_vlans_phpipam(vlan['idphp'])
            # берем ID группы этого VLAN из словаря
            group_id = group_name_id[vlan['group']['name']]
            # добавляем VLAN в NetBox
            post_vlan_result = post_vlan_on_group_nb(
                vlan['vid'],
                vlan['name'],
                vlan['description'],
                group_id,
            )
            f.write(f'{datetime.now()} {post_vlan_result}\n')
            counter.update({'vlan': 1})
            # если у VLAN есть префиксы, связываем их
            if vlan['prefixes']:
                # убеждаемся, что VLAN ID есть в группе NetBox
                try:
                    vlan_id_nb = get_vlan_id_from_nb(vlan['vid'], group_id)
                    for prefix in vlan['prefixes']:
                        # убеждаемся, что префикс есть в NetBox
                        try:
                            pref_id_nb = get_pref_id_from_nb(prefix)
                        except IndexError:
                            # если префикса нет, сохраняем в лог
                            f.write(f'Pref not exist {datetime.now()} {prefix}\n')
                            continue
                        # связываем VLAN и префикс
                        patch_vlan_to_pref_result = patch_vlan_to_pref_nb(pref_id_nb, vlan_id_nb)
                        f.write(f'{datetime.now()} {patch_vlan_to_pref_result}\n')
                        counter.update({'vlan_to_pref': 1})
                except IndexError:
                    # если есть совпадения имен, такого VLAN ID не будет в группе
                    f.write(f'Duplicate vlan name {datetime.now()} {vlan}\n')
                    continue
        bar.finish()
    # немного статистики в консоль
    print(f'{counter["groups_to_nb"]} groups added to NetBox')
    print(f'{counter["vlan"]} vlanes added to NetBox')
    print(f'{counter["vlan_to_pref"]} prefixes matched with vlans')
    print(f'Script took {datetime.now() - start_time} to run')
if __name__ == '__main__':
    main()

Запустили первый, запустили второй, проверили — можно и по кофе с булочкой :)

Если что-то не получилось или данные надо переделать, NetBox не даст легко удалить все адреса и VLAN, пока не удалены все привязанные к ним префиксы. Поэтому последовательность такая:

  1. Удалить все префиксы

  2. Удалить все VLAN

  3. Удалить все адреса

Если надо сделать это быстро, то можно сходить в базу данных и почистить там таблицы в той же последовательности. Мне спокойнее делать всё через API, чтобы перестраховаться от того, что я вдруг что-то не учел и в базе, из-за чего могут пойти ошибки.

Вот простенький сценарий для удаления всего из секции IPAM NetBox:

from typing import List, Dict, Optional
from datetime import datetime
from json.decoder import JSONDecodeError
import requests
import backoff
from credential import NETBOX_API_TOKEN, NETBOX_API
requests.packages.urllib3.disable_warnings()
HEADERS_NETBOX = {
    'Authorization': f'Token {NETBOX_API_TOKEN}',
    'Content-Type': 'application/json',
    'Accept': 'application/json',
}
@backoff.on_exception(
    backoff.expo,
    (requests.exceptions.RequestException, JSONDecodeError),
    max_tries=5
    )
def get_vlans(limit_per_page: int = 50) -> Optional[List[Dict[str, int]]]:
    # собираем ID VLAN пачками по 50
    vlan_ids = []
    netbox_api_endpoint = '/ipam/vlans/'
    params = {
        'limit': f'{limit_per_page}'
    }
    vlans_get_resp = requests.get(
        NETBOX_API + netbox_api_endpoint, params=params, headers=HEADERS_NETBOX, verify=False
        ).json()['results']
    # проверяем, что еще есть VLAN
    if vlans_get_resp:
        for vlan in vlans_get_resp:
            vlan_ids.append({'id': vlan['id']})
        return vlan_ids
    # если VLAN кончились, возвращаем None
    else: return
@backoff.on_exception(
    backoff.expo,
    (requests.exceptions.RequestException, JSONDecodeError),
    max_tries=5
    )
def delete_vlans(vlans: List[Dict[str, int]]):
    # удаляем VLAN пачкой по 50
    netbox_api_endpoint = '/ipam/vlans/'
    # отправляем список словарей с ID
    payload = vlans
    vlans_delete_resp = requests.delete(
        NETBOX_API + netbox_api_endpoint, headers=HEADERS_NETBOX, json=payload, verify=False
        )
    # выводим в консоль статус код запроса
    print(vlans_delete_resp.status_code)
@backoff.on_exception(
    backoff.expo,
    (requests.exceptions.RequestException, JSONDecodeError),
    max_tries=5
    )
def get_prefixes(limit_per_page: int = 50) -> Optional[List[Dict[str, int]]]:
    # собираем ID префиксов пачками по 50
    prefixes_ids = []
    netbox_api_endpoint = '/ipam/prefixes/'
    params = {
        'limit': f'{limit_per_page}'
    }
    prefixes_get_resp = requests.get(
        NETBOX_API + netbox_api_endpoint, params=params, headers=HEADERS_NETBOX, verify=False
        ).json()['results']
    # проверяем, что еще есть префиксы
    if prefixes_get_resp:
        for prefix in prefixes_get_resp:
            prefixes_ids.append({'id': prefix['id']})
        return prefixes_ids
    # если префиксы кончились, возвращаем None
    else: return
@backoff.on_exception(
    backoff.expo,
    (requests.exceptions.RequestException, JSONDecodeError),
    max_tries=5
    )
def delete_prefix(prefixes: List[Dict[str, int]]):
    # удаляем префиксы пачками по 50
    netbox_api_endpoint = '/ipam/prefixes/'
    # отправляем список словарей, в котором ID
    payload = prefixes
    prefixes_delete_resp = requests.delete(
        NETBOX_API + netbox_api_endpoint, headers=HEADERS_NETBOX, json=payload, verify=False
        )
    # выводим в консоль статус-код запроса
    print(prefixes_delete_resp.status_code)
@backoff.on_exception(
    backoff.expo,
    (requests.exceptions.RequestException, JSONDecodeError),
    max_tries=5
    )
def get_ipaddresses(limit_per_page: int = 50) -> Optional[List[Dict[str, int]]]:
    # собираем ID адресов пачками по 50
    ipaddresses_ids = []
    netbox_api_endpoint = '/ipam/ip-addresses/'
    params = {
        'limit': f'{limit_per_page}'
    }
    ipaddresses_get_resp = requests.get(
        NETBOX_API + netbox_api_endpoint, params=params, headers=HEADERS_NETBOX, verify=False
        ).json()['results']
    # проверяем, что еще есть адреса
    if ipaddresses_get_resp:
        for ipaddres in ipaddresses_get_resp:
            ipaddresses_ids.append({'id': ipaddres['id']})
        return ipaddresses_ids
    # если адреса кончились, возвращаем None
    else: return
@backoff.on_exception(
    backoff.expo,
    (requests.exceptions.RequestException, JSONDecodeError),
    max_tries=5
    )
def delete_ipaddr(ipaddresses: List[Dict[str, int]]):
    # удаляем адреса пачками по 50
    netbox_api_endpoint = '/ipam/ip-addresses/'
    # отправляем список словарей, в которых ID
    payload = ipaddresses
    ipaddresses_delete_resp = requests.delete(
        NETBOX_API + netbox_api_endpoint, headers=HEADERS_NETBOX, json=payload, verify=False
        )
    # выводим в консоль статус код запроса
    print(ipaddresses_delete_resp.status_code)
def main():
    # собираем логику
    # стартуем
    start_time = datetime.now()
    # крутимся, пока не вернется None
    while get_prefixes():
        # получаем пачка ID 50 штук
        prefixes_ids = get_prefixes()
        # удаляем пачку
        delete_prefix(prefixes_ids)
    while get_vlans():
        vlan_ids = get_vlans()
        delete_vlans(vlan_ids)
    while get_ipaddresses():
        ipaddresses_ids = get_ipaddresses()
        delete_ipaddr(ipaddresses_ids)
    print(f'Script took {datetime.now() - start_time} to run')
if __name__ == '__main__':
    main()
Содержимое файла credential.py с переменным для сценариев:
NETBOX_API_TOKEN = 'ТОКЕН'
NETBOX_API = 'https://netbox/api/'
 
PHP_IPAM_API = 'https://ipam/api/'
PHP_IPAM_API_TOKEN = 'ТОКЕН'

Лучше сначала разработать и проверить всё в тестовом NetBox и параллельно готовить всех пользователей к переходу и переходному этапу. Когда придет день официального перехода, делаем перенос на PROD NetBox, закрываем phpIPAM на редактирование, чтобы базы не разъезжались, и наблюдаем. Помогаем пользователям перейти на новую систему, решаем возникшие проблемы. PhpIPAM не тушим, чтобы у пользователей была возможность сравнить данные (вдруг мы что-то забыли). При этом держим наготове включение phpIPAM, если у кого-нибудь вдруг слетит критичная интеграция, вследствие чего он не получит свой адрес.

Много чего в моих примерах можно переписать под свои задачи. Например, для сборки JSON из phpIPAM можно прикрутить Mongo и хранить там данные, не пересобирая всё на лету. Это позволит не узнавать постоянно ID у NetBox. Если нужно будет делать ETL, вам пригодятся webhook из NetBox. Также желательно добавить тесты, и сравнить базы, чтобы их совпадение составило 100%, с выводом разницы, если она есть.

Все примеры взяты из песочницы NetBox. В следующей статье я расскажу, как мы освоили преимущества custom fields со ссылкой на другую модель, а также conditional webhooks на примере интеграции NetBox и Cisco ACI.

Теги:
Хабы:
+4
Комментарии3

Публикации

Информация

Сайт
www.rosbank.ru
Дата регистрации
Дата основания
Численность
5 001–10 000 человек
Местоположение
Россия