Продолжаем прокачивать Ansible

    Поводом для этой статьи послужил пост в чате @pro_ansible:

    Vladislav ? Shishkov, [17.02.21 20:59] Господа, есть два вопроса, касаются кастомной долгой операции, например, бекапа: 1. Можно ли через ансибл прикрутить прогрессбар выполнения кастомного баша? (если через плагин, то пните в какой-нибудь пример или документацию плиз) 2. Вроде хочется для этого баша написать плагин, но встает вопрос, как быть и как решать моменты выполнения, которые идемпотентны?

    Беглый поиск по задворкам памяти ничего подходящего не подсказал. Тем не менее, я точно вспомнил, что код Ansible легко читаемый, и «искаропки» поддерживает расширение как плагинами, так и обычными Python-модулями. А раз так, то ничего не мешает в очередной раз раздвинуть границы возможного. Hold my beer!...

    Понятно, что стандартный Ansible уже умеет делать оба шага, только вот полученный «выхлоп» собирается в единое целое и передаётся на управляющий хост уже по завершению процесса, а мы хотим это сделать в реальном времени. Следовательно, можно как минимум посмотреть на существующую реализацию, а как максимум - каким-то образом переиспользовать существующий код.

    Исходный вопрос можно свести к двум простейшим шагам:

    1. Захватить stdout команды на целевом хосте

    2. Передать его на управляющий хост.

    Передаём данные на управляющий хост

    Предлагаю начать «с конца»: с организации дополнительного канала передачи на управляющий хост. Решение этого вопроса выглядит достаточно очевидным: вспоминаем, что Ansible работает поверх ssh, и используем функцию обратного проброса порта:

    Код на Python
    # добавляем куда-нибудь сюда:
    # https://github.com/ansible/ansible/blob/5078a0baa26e0eb715e86c93ec32af6bc4022e45/lib/ansible/plugins/connection/ssh.py#L662
    self._add_args(
        b_command,
        (b"-R", b"127.0.0.1:33333:" + to_bytes(self._play_context.remote_addr, errors='surrogate_or_strict', nonstring='simplerepr') + b":33335"),
        u"ANSIBLE_STREAMING/streaming set"
    )

    Как это работает? При сборке аргументов командной строки для установления ssh-соединения эта конструкция предоставит нам на целевом хосте порт 33333 по адресу 127.0.0.1, который будет туннелировать входящие соединения на контроллер - прямиком на порт 33335.

    Для простоты используем netcat (ну правда, ну что за статья без котиков?): nc -lk 33335.

    В этот момент, кстати, уже можно запустить Ansible и проверить, что туннель работает так, как следует: хотя пока по нему ничего и не передаётся, мы уже можем на целевом хосте зайти в консоль и выполнить nc 127.0.0.1 33333, введя какую-нибудь фразу и увидев её как результат работы команды выше.

    Перехватываем stdout

    Полдела сделано - идём дальше. Мы хотим перехватить stdout какой-то команды - по логике работы Ansible нам подойдёт модуль «shell». Забавной, что он оказался пустышкой - в нём ни строчки кода, кроме документации и примеров, зато находим в нём отсылку к модулю command. С ним всё оказалось хорошо, кроме того факта, что нужная функция в нём напрямую не описана, хотя и использована. Но это уже было почти попадание «в яблочко», потому что в итоге она нашлась в другом файле.

    Под мысленное «просто добавь воды» просто добавляем щепотку своего кода:

    Опять код
    # в начале basic.py, рядом с прочими import'ами 
    import socket
    
    # в функции run_command - где-нибудь тут:
    # https://github.com/ansible/ansible/blob/5078a0baa26e0eb715e86c93ec32af6bc4022e45/lib/ansible/module_utils/basic.py#L2447
    clientSocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM);
    clientSocket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
    clientSocket.connect(("127.0.0.1",33333));
    
    # в функции run_command - где-нибудь тут:
    # https://github.com/ansible/ansible/blob/5078a0baa26e0eb715e86c93ec32af6bc4022e45/lib/ansible/module_utils/basic.py#L2455
    clientSocket.send(b_chunk);
    
    # в функции run_command - где-нибудь тут
    # https://github.com/ansible/ansible/blob/5078a0baa26e0eb715e86c93ec32af6bc4022e45/lib/ansible/module_utils/basic.py#L2481
    clientSocket.close()

    Собираем воедино и запускаем

    Осталось сделать что? Правильно, определиться со способом подключения изменённых модулей в стоковый Ansible. На всякий случай напоминаю: мы поправили один connection plugin, и один модуль из стандартной библиотеки Ansible. Новичкам в этом деле могу рекомендовать статью хабраюзера chemtech с расшифровкой моего доклада на «Стачке-2019» (там как раз в том числе объясняется, какие Python-модули куда складывать), ну а опытным бойцам эти пояснения вроде и не нужны :-)

    Итак, время «Ч». Результат в виде статичной картинки не очень показателен, поэтому я настроил tmux и запустил запись скринкаста.

    Для внимательных зрителей скринкаста

    В анимации можете увидеть два полезных побочных эффекта:

    • Теперь мы видим stdout всех не-Python процессов, которые запускаются Ansible'ом на целевом хосте - например, тех, что запускаются при сборе фактов;

    • Настройки переиспользования ssh-соединений из другой моей статьи позволяют получать этот самый stdout от удалённой команды уже после отключения Ansible от хоста.

    Хотите ко мне на тренинг по Ansible?

    Раз уж вы здесь - значит, вам как минимум интересно то, что я пишу об Ansible. Так вот, у меня есть опыт ведения такого тренинга внутри компании для коллег.

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

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

    Хотите на авторский тренинг по Ansible?

    • 37,1%Shut up and take my money!13
    • 40,0%Прочитал, на тренинг не хочу14
    • 22,9%Да я и сам могу такой тренинг провести8

    Комментарии 8

      0

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

        0
        Спасибо за наводку, отметился в нём.
        0

        Меня ваш код сильно смутил. Если вы пишите в сокет, а из этого сокета никто не читает, то процесс блокируется. Более того, ничего, из того, что вы сделали, не требует участия ансибла — вы command можете ровно так же завернуть в любой враппер (включая, например, socat) и спокойно читать, не привнося неожиданностей в работу самого ансибла.


        Если же говорить про решение силами ансибла, то async для этого и придуман. stdout при этом брать тяжело (потому что он зарезервирован под результаты выполнения), но простейший вариант будет просто писать статистику локально, а poll брать статистику с удалённой машины, до тех пор, пока задача всё ещё работает, и показывать через debug или прямую запись в stdout ансибла на контроллере.

          0
          Я просто текст из своего поста в чате здесь оставлю:
          [В ответ на Timur Gadiev]
          Ну смотри, сейчас это в виде MVP.
          Соответственно, есть следующие проблемы:
          1. Аргументы для ssh захардкожены — для передачи выбран кастомный порт, который, возможно, будет занят
          2. Вся обработка приёма потоков stdout на контроллере как таковая отсутствует
          3. Нет разграничения потоков от разных команд
          4. На целевой тачке никак не обрабатывается ситуация, когда порт на контроллере по какой-то причине никто не слушает (error 32 — Broken pipe)

          Время поста — 13:38 MSK. Sapienti sat.

          Понимаете, фокус в том, что критиковать могут «не только лишь все», а вот делать… В общем, с удовольствием почитаю вашу статью с продакшн-качества кодом, который будет решать какую-нибудь из давних болячек Ansible (конкретно эта датирована январём 2018).
            0

            Я не уверен, что я хочу нырять в ansible-core. Но у меня есть идея, которые я, надеюсь, сделает жизнь людям лучше. Одна из них — это remote testinfra, т.е. запуск тестов testinfra на удалённой машине (с правильно заполненным host в localhost). Это будет, к сожалению, action plugin, потому что в виде модуля оно не даст правильного контроля за копированием теста.


            Алсо, вот эту штуку (которая ссылка) я бы сделал чуть-чуть по-другому. Я бы предоставлял всем subprocess'ам на ансибле дополнительный fd для логгинга (echo hello >3), который бы писался в соответствующее поле register, а при async'е позволял бы получить для выполняемого в бэкграунде задания.


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


            Качество моего кода под ансибл можете посмотреть тут — https://github.com/amarao/collection_ip

              0
              Ну вообще-то статья была не о том, чтобы качеством кода меряться, а о самом концепте.

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

              А если всё же о коде, то вот чего: код парсит экранный вывод утилит командной строки. О том, что это bad practice, в интернетах писалось 100500 раз, и это перебивает любое качество кода.
                0

                вы про коллекцию ip?


                1. У ip есть обещанный правильный вывод (-o).
                2. Между ip и ядром есть netlink (man 7 netlink), но реализация своего клиента netlink'а гарантировано будет хуже, чем ip.
                3. Пользователь, которому вернули ошибку уровня netlink ничего не сможет сделать или понять, а для ip — сможет воспроизвести руками и понять что там не так.

                iproute2 — это не совсем "утилита", примерно как udev.

          +1

          Я решал похожую задачу для долгоиграющих процессов, остановился на screen и передаче аутпута через файл:


          • с процессом можно взаимодействовать вручную на машине, если нужно через screen
          • не нужен отдельный порт – вывод передается через tail основным ansible-каналом
          • минус в том, что интеррапт ансибла не прерывает сам процесс (но может быть это и плюс), а так же если процесс ждет ввода ансибл об этом не знает и тоже ждет
          • приходится костылить для получения exit code

                      self._shell(" && ".join([
                          "echo -e 'logfile %s\\nlogfile flush %d' >> %s" % (logfile, delay, config_path),
                          "sudo touch %s" % logfile,
                          "sudo chmod a+rw %s" % logfile
                      ]))
                      exit_code = "EXIT CODE "
          
                      started = datetime.datetime.now()
                      self._shell("screen -L -c %s -S %s -d -m sh -c $'(%s); echo %s$?'" %
                                  (config_path, task_id, command.replace("'", "\\'"), exit_code))
          
                      self._display.display("Command started in screen -r %s" % task_id)
                      self._display.display("> %s\n" % command, color=C.COLOR_VERBOSE)
          
                      offset = 1
                      rc = None
                      while True:
                          status = self._raw("screen -S %s -Q info" % task_id)
                          alive = status["rc"] == 0
          
                          chunk = self._shell("tail -c +%d %s" % (offset, logfile))["stdout"]
                          chunk_len = len(to_bytes(chunk))
                          offset = offset + chunk_len
          
                          last = chunk.rfind(exit_code)
                          if last > -1:
                              rc = chunk[last + len(exit_code):]
                              rc = int(rc) if rc.isnumeric() else None
          
                          if chunk_len > 0:
                              self._display.display(chunk[2:] if chunk.startswith("\r\n") else chunk)

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое