В последнее время я всё чаще и чаще сталкиваюсь с тем, что Ansible используют таким образом, что этот комок костылей и граблей просто нереально разгрести адекватному человеку без употребления фенибута или чего-либо мощнее. Одним из таких костылей является запуск ансибл в скрипте, чтобы предварительно сгенерировать inventory. Мне искренне непонятно, почему люди не читают документацию, а если и читают, то умудряются написать гигантский костыль в виде скрипта запуска, но не могут разобраться с тем, чтобы написать динамический инвенторий.

Это побудило меня написать такой маленький туториал по реализации динамического инвентория практически для любой версии Ansible. В качестве языка я выбрал Python только потому, что его будет проще всего прочитать и адаптировать под любой другой язык.


Для ленивых

Те, кому лень читать всю портянку текста к коду, можете просто зайти на подготовленный gist либо прочитать итоговый скрипт (не забудьте сделать его исполняемым):

#!/usr/bin/env python

"""
This is an example script for implementing dynamic inventory in Ansible.
It could have been written in bash, but python is much easier to read.
"""

import argparse
import json

# Example hosts store
store = {
    "postgres-1": {
        "metadatafromstore": {
            "ip": ['172.16.1.1'],
            'discs': ['/dev/vda', '/dev/sda'],
            'users': ['grey'],
            'mounts': {
                "/": '/dev/vda',
                "/opt/postgres/data": "/dev/sda",
            },
        }
    },
    "apache": {
        "metadatafromstore": {
            "ip": ['172.16.1.3'],
            'discs': ['/dev/vda'],
            'mounts': {
                "/": '/dev/vda',
            },
        }
    },
}


def get_host_vars(host):
    data = {
        'ansible_host': host,
        'ansible_user': 'root',
    }
    if host in store:
        metadata = store[host].get('metadatafromstore', {}) or {}
        if metadata.get('ip'):
            data['ansible_host'] = metadata['ip'][0]
        if 'users' in metadata:
            data['ansible_user'] = metadata['users'][0]
        data.update(metadata)
    return data


def get_vars(host, pretty=False):
    """
    Function which return json data of host's variables.
    """
    return json.dumps({}, indent=pretty)


def get_list(pretty=False):
    """
    Function which return inventory data for hosts.
    Example contains all variants of groups. Syntax as yaml inventory but with '_meta' section.

    - 'hostvars' is all variables for hosts.
    - 'all' is default group which should be always created.
    - 'ungrouped' is testing group with hosts.
    """
    hostvars, ungrouped = {}, []

    for host in store:
        ungrouped.append(host)
        hostvars[host] = get_host_vars(host)

    data = {
        '_meta': {
          'hostvars': hostvars
        },
        'all': {
            'children': [
                'ungrouped'
            ]
        },
        'ungrouped': {
            'hosts': ungrouped
        }
    }
    return json.dumps(data, indent=pretty)


# Parse arguments.
# Ansible require two: '--list' and output of all data and '--host [hostname] for getting variables about one host'
parser = argparse.ArgumentParser()
parser.add_argument(
    '--pretty',
    action='store_true',
    default=False,
    help="Pretty print JSON"
)
parser.add_argument(
    "--list",
    action='store',  # also better use store_true
    nargs="*",
    default="dummy",
    help="Show JSON of all managed hosts"
)
parser.add_argument(
    "--host",
    action='store',
    help="Display vars related to the host"
)
args = parser.parse_args()

# Print output will be parsed via ansible as inventory (like a file).
if args.host:
    print(get_vars(args.host, args.pretty))
elif len(args.list) >= 0:
    print(get_list(args.pretty))
else:
    raise ValueError("Expecting either --host $HOSTNAME or --list")

Теория плагинов

Итак, чтобы можно было легко и непринуждённо писать свои собственные скрипты на любом ЯП, лучшим началом будет разобраться в теории.

Плагины инвенториев

Когда вы запускаете любой из cli инструментов ansible, он автоматически оборачивает указанный вами инвенторий в менеджер, который занимается непосредственным парсингом. Менеджер, в свою очередь, подгружает конфигурацию из ansible.cfg (которая также проходит несколько этапов парсинга, но это сейчас не так важно). В этой конфигурации существует настройка со списком плагинов по умолчанию, которые будут использованы для парсинга инвентория. Вот как выглядит эта настройка "из коробки":

[inventory]
enable_plugins = host_list, script, auto, yaml, ini, toml

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

пока %username% не пошёл исправлять меня в комментариях

На самом деле этот плагин сложнее, чем кажется. Он считывает всеми возможными способами файл, потом читает в нём параметр plugin и уже на базе него начинает парсинг. Это удобно, когда сам инвенторий является параметром настройки для плагина инвентория. Современный ансибл расширен понятием коллекции, которые ещё больше расширяют возможности по добавлению функционала, так что это самый важный плагин.

Если этот порядок нарушить, то какой-нибудь плагин инвентория может так и не заработать, потому что, например, файл будет в формате yaml и считается этим плагином вполне себе валидно. Когда менеджер начинает парсить файл, то он автоматически смотрит на то, что запускает (директория или файл), и последовательно начинает применять плагины. Первый успешно считавший является валидным. Именно поэтому они стоят в таком порядке.

Плагин script

Теперь давайте разберёмся, как работает плагин, позволяющий без написания собственных плагинов реализовать скрипт, который будет брать нужную нам информацию откуда угодно и превращать в инвенторий.

Плагин считает файл подходящим для него по следующим критериям:

  • Файл начинается с #!

  • У файла для текущего пользователя есть права на исполнение.

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

Принцип работы этого плагина заключается в том, чтобы запустить скрипт с параметром --list, получить вывод из пайпа, спарсить его загрузчиком в словарь и положить данные в объект инвентория. Затем для каждого хоста инвентория скрипт вызывается с параметром --host имя_хоста, чтобы получить переменные данного хоста. Насчёт последнего есть нюанс, который я расскажу чуть позже.

В этом вся суть плагина. Ничего сложного. Осталось только разобраться с тем, что должно попасть на выход для каждого из вызовов.

Разбор скрипта

Итак, теперь последовательно разберёмся с тем, что мы должны получить на выходе. Мы будем идти по порядку непосредственного написания кода (т.е. так, как нужно писать такой плагин, чтоб не запутаться).

Аргументы

Прежде всего нам нужно убедиться, что наш скрипт реагирует на два типа аргументов, которые требуются плагину. Если говорить за Python, то тут уже есть готовый инструмент "из батареек" под названием argparse. Я подробно разбирать сам модуль и инструмент не буду, потому что там довольно много гибких настроек. Но нам важны две вещи.

Во-первых, на вызов --list нужно реагировать без разбора остальных параметров, по большому счёту. Важно именно наличие этого параметра. Не ругайтесь на пример скрипта, потому что моя цель была показать именно это. Лучшим вариантом будет использование action="store_true", который обозначит, что вызов произошёл с этим аргументом. Такой вызов должен содержать данные по следующей схеме (вывод в json):

{
  "_meta": {
    "hostvars": {} // здесь должны быть переменные, где ключ - имя хоста, а значение - json object с переменными
  },
  "all": {} // здесь и далее у нас идут описания групп хостов.
}

Чтобы правильно описывать группы, нам достаточно посмотреть на yaml плагин, потому что логика практически полностью совместима. Но всё же я уточню.

Прежде всего, полученный json парсится в словарь, в котором описаны все группы (ключ - имя группы, значение - данные). Каждая группа в себе может содержать следующие параметры:

  • vars - набор переменных для хостов конкретной группы. Опять же простой набор: ключ - имя переменной, значение - данные.

  • hosts - список (именно list) имён хостов, содержащихся в группе. Не совместим со следующим параметром. Каждое новое имя создаёт новый хост в инвентории.

  • children - список имён дочерних групп для вложенности. Не совместим с предыдущим пунктом. Поэтому нужно выбирать: либо у вас составная группа, либо группа хостов. Представленный список имён должен быть объявлен также в json объекте.

Однако, в коде плагина присутствует очень не очевидный хак, который может запутать.

  • Если данные группы это НЕ объект, то данные оборачиваются в объект с ключом hosts, в который в итоге попадают данные. Это значит, что если в теле объекта будет: {"all": ["host1", "host2"]}, то мы просто получим группу all с хостами. Это вроде должно упростить код в некоторых случаях, но также может и запутать. В моём примере скрипта, я намерено не использую этот хак, потому что считаю его антипаттерном.

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

Единственным явным исключением в парсинге групп, является _meta, которая содержит в себе структуру hostvars. Это не группа, а специальное хранилище метаданных. По умолчанию, hostvars будет None, если не указать их в _meta и парсинг инвентория будет нуждаться в ещё одном запуске. Поэтому, если есть возможность указать переменные хоста при этом наборе данных, то лучше это сделать сразу в hostvars. Ключ в hostvars - это имя хоста, а значение - объект с переменными.

Во-вторых, если мы не объявили hostvars в нашем выводе, то нам потребуется реализовать вызов с аргументами --host hostname. Хочу отметить, что просмотрев код плагина для версии ansible 2.9, я убедился, что при наличии hostvars, не требуется реализация текущего вызова совсем. Однако, если мы всё же не указали переменные в первом вызове, то нам необходимо взять параметр хоста и вывести в стандартный вывод json объект с переменными. В целом я не знаю кейсов, в которых это бы было действительно полезно (разве что для разделения кода и при нехватке памяти на вызов). Но возможность есть, поэтому было бы неправильно её не упомянуть. В моём примере переменные пустые, потому что я реализовал более простой и быстрый вариант. Если бы мой вывод не умещался бы в память, то, наверное, следовало бы его разделить.

Хранилище

Как уже можно заметить, в качестве хранилища может быть что угодно. Например, база данных (не забудьте, что где-то надо будет указать параметры подключения, поэтому и существуют плагины), cobbler, OpenStack или другое облако. Важно, чтобы возвращаемый набор переменных в инвентории содержал все необходимые параметры для подключения к описанному хосту от места запуска данного скрипта.

В гитхабе ansible есть несколько полезных примеров, чтобы создать собственный скрипт. Но они действительно сложно написаны, что и побудило меня написать данную статью. Здесь можно посмотреть на другие примеры, которые могут помочь глубже понять интеграцию, а где-то вдохновиться на написание своего скрипта.

Запуск

Запуск скрипта производится точно таким же образом, как и файлового инвентория. Как я уже сказал, важно, чтобы он начинался на #!, что в linux системах интерпретируется как вызываемый скрипт, и содержал в fs acl права на исполнение от текущего пользователя.

Интерпретатор, от которого происходит исполнение, не имеет значения. Вы можете указать его в том же окружении, что и вызываемый ansible или в совершенно другом окружении, в котором будет необходимый набор библиотек.


Итоги

Подведём итог. Мы рассмотрели то, как устроен ansible-плагин для динамических инвенториев из скрипта, в котором по факту нужно реализовать только один вызов: script_name --list. При должной сноровке и автоматизации других процессов можно легко управлять инфраструктурой как кодом в любой среде. Это открывает такой набор возможностей, который избавляет от рутины генерации "портянок" YAML-ей из разных систем. При этом такой скрипт будет работать практически везде и не зависеть от версии ansible. Скрипт может быть написан на любом языке, в том числе на bash, ruby, js или любом другом. Он может быть простой обёрткой для вызова бинаря, который будет выводить все данные.

Всем автоматизации рутины!

Полезные ссылки

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


  1. CarrolCox
    13.12.2022 10:18
    +3

    "ансибл"

    "инвентарь"

    хорошие слова, возьмите, пользуйтесь


    1. bb0ff
      15.12.2022 11:56

      Насчёт ансибла согласен, но inventory всё же скорее «список», «каталог» или «перечень», чем «инвентарь».


      1. onegreyonewhite Автор
        15.12.2022 12:53

        Я надеюсь, что эти два комментария формат троллинга законопроектов на чистоту языка. Вообще очень тяжело переводить подобные термины, но соглашусь с предыдущим оратором, что inventory и инвентарь немного разные понятия в контексте ансибл. "Перечень" - более подходящий вариант, но если говорить за контекст, то там это скорее имя собственное, потому что им названа конкретная подвижная часть.