Шаблонизация в CLI может быть простой

от автора

кдпв

Однажды я был маленьким, и задавался вопросом — вот если Unix way это (упрощенно) небольшие, довольно простые утилиты и библиотеки, которые делают одну вещь, но делают её хорошо (Peter H. Salus: «…that do one thing and do it well»), то… Где тогда утилита, которая занимается шаблонизацией и не хватает звёзд с неба? Вот есть у тебя некоторый шаблон, и есть некоторые данные, которые ты имеешь желание в этот шаблон подставить. Брать для этого Jinja2? Писать что-то своё используя sed + awk? Или тащить %tool_name% на несколько мегабайт ради столь тривиальной задачи?

Спустя некоторое время, вновь столкнувшись с подобной задачей, и поняв что попытка найти что-то подходящее вновь претерпела фиаско, было принято волевое решение — да-да, написать свой прекрасный проект велосипед шаблонизатор для использования в CLI. Ограничения были выбраны следующие:

  • Статическая линковка — один бинарный файл без каких-либо зависимостей (он мне понадобится в docker scratch)
  • Итоговый размер должен быть минимально возможным (постараться уместиться в 100Кб без upx)

На чем писать, если хочется боли компактного результата и быстрого выполнения — естественно, берём C. Какой шаблонизатор использовать, если хочется минимализма? Под такую задачу хорошо подойдет mustache. И вот, спустя некоторое время появляется утилита под кодовым именем mustpl (must — mustache, tpl — template).

Как её использовать?

Предельно просто — дай на вход путь до файла с шаблоном, файла с данными для этого шаблона (или передай их в виде JSON-строки используя флаг -d), и опционально передай нужные переменные окружения. Для примера давай представим, что у нас есть следующий шаблон для Nginx (nginx.tpl):

server {   listen      8080;   server_name{{#names}} {{ . }}{{/names}};    location / {     root  /var/www/data;     index index.html index.htm;   } }

И мы имеем желание сгенерировать из него настоящий конфиг, подставив в качестве server_name значения example.com и google.com. Для этого достаточно выполнить:

$ export SERVER_NAME_1=example.com  $ mustpl -d '{"names": ["${SERVER_NAME_1:-fallback.com}", "google.com"]}' ./nginx.tpl server {   listen      8080;   server_name example.com google.com;    location / {     root  /var/www/data;     index index.html index.htm;   } }

Или другой пример, с циклом, но тем же конфигом для Nginx. Берём данные (data.json):

{   "servers": [     {       "listen": 8080,       "names": [         "example.com"       ],       "is_default": true,       "home": "/www/example.com"     },     {       "listen": 1088,       "names": [         "127-0-0-1.nip.io",         "127-0-0-2.nip.io"       ],       "home": "/www/local"     }   ] }

Берём шаблон (nginx.tpl):

{{#servers}} server {   listen      {{ listen }};   server_name{{#names}} {{ . }}{{/names}}{{#is_default}} default_server{{/is_default}};    location / {     root  {{ home }};     index index.html index.htm;   } }  {{/servers}}

И рендерим:

$ mustpl -f ./data.json ./nginx.tpl  server {   listen      8080;   server_name example.com default_server;    location / {     root  /www/example.com;     index index.html index.htm;   } }  server {   listen      1088;   server_name 127-0-0-1.nip.io 127-0-0-2.nip.io;    location / {     root  /www/local;     index index.html index.htm;   } }

Естественно, что конфигом Nginx вы не ограничены, да и вообще — рендерить можно любые тектовые данные. Единственное, наверняка будут сложности с python и yaml (там, где отступы имеют значение), но если что — создавайте issue, подумаем что можно придумать.

Красота — она в простоте. Кроме всего прочего, шаблонизатором поддерживаются и условия (это уже не совсем Logic-less получается, ну да ладно), и подключение других файлов-шаблонов, и escaping значений — все детали и нужные ссылки сможешь найти в readme файле репозитория с приложением.

А как установить?

На данный момент есть 3 пути по установке — это скачивание уже готового бинарного файла под необходимую архитектуру со страницы релизов, собственная сборка из исходников и использование готового docker-образа с приложением.

Для сборки потребуется только gccmusl-dev, если собираешь, скажем, в alpine linux), а docker-образ уже собран под наиболее популярные платформы, так что всё, что потребуется тебе сделать в твоём Dockerfile, это лишь:

COPY --from=ghcr.io/tarampampam/mustpl:latest /bin/mustpl /bin/mustpl

Крайне рекомендую не использовать тег latest из-за того, что при мажорных изменениях есть риск получить обратно-несовместимые изменения. Лучше всего использовать версионирование в формате X.Y.Z в связке с настроенным (dependa|renovate)bot.

Поддержки windows на данный момент нет так как «а зачем?». Если будет такой запрос — создайте issue, подумаем что можно сделать.

Но почему ты просто не взял %tool_name%?

Кроме того, что целью был минимальный размер итогового бинарного файла, отсутствие зависимостей и скорость работы, есть ещё как минимум одна очень важная причина — это комфортное использование в docker, а именно — навык парсить переменные окружения (примерно как envsubst) и возможность использования в качестве точки входа (entrypoint).

Скорее всего ты знаешь, что основной процесс, запускаемый в контейнере — должен иметь PID равный 1 (в неймспейсе контейнера). Нужно это для того, чтоб демон докера мог корректно общаться (отправляя сигналы) с приложением, что у тебя в этом самом контейнере крутится.

Как использовать в качестве docker entrypoint

Именно необходимость сохранить PID 1 является причиной тому что, как правило, в entrypoint-скриптах используются конструкции вида:

#!/bin/sh set -e  if [ -n "$MY_OPTION" ]; then # если переменная окружения имеется   sed -i "s~foo~bar ${MY_OPTION}~" /etc/app.cfg # то подставляем её в конфиг fi;  exec "$@" # <-- а вот это самое интересное

Которая в паре со следующими entrypoint/cmd в dockerfile:

ENTRYPOINT ["/docker-entrypoint.sh"]  CMD ["/bin/app", "--another", "flags"]

Работает следующим образом:

  • Запускается процесс sh c PID 1, который выполняет скрипт /docker-entrypoint.sh
  • Скрипт выполняет все необходимые модификации конфига некоторого приложения (если это необходимо), и вызывает exec (который заменяет текущий процесс новым, не изменяя при этом свой PID, детали в man exec)
  • Запускается процесс app с аргументами --another flags и его PID становится 1

И мне очень хотелось иметь возможность отказаться от этих самых entrypoint скриптов, так как они тянут массу зависимостей (а distroless же наше всё), да и писать их утомляет очень быстро. И было принято решение научить mustpl выполнять этот самый exec самостоятельно. Т.е. чтоб алгоритм запуска был следующий:

  • Запускается mustpl с PID 1, который читая файл шаблона и данные для него генерирует необходимый конфиг для некоторого приложения
  • Выполняет exec, запуская нужное приложение, не меняя PID (т.е. оставляя его равным 1)

Как это выглядит? Тоже очень просто, давай создадим файлы с шаблоном (template.ini):

[config] value = {{ my_option }}

Данными для него (data.json):

{   "my_option": "${MY_OPTION:-default value}" }

И следующий Dockerfile:

FROM alpine:latest  COPY --from=ghcr.io/tarampampam/mustpl /bin/mustpl /bin/mustpl  COPY ./data.json /data.json COPY ./template.ini /template.ini  ENTRYPOINT ["mustpl", "-f", "/data.json", "-o", "/rendered.txt", "/template.ini", "--"]  CMD ["sleep", "infinity"]

Теперь давай соберем образ и запустим его:

$ docker build --tag test:local . $ docker run --rm --name mustpl_example -e "MY_OPTION=foobar" test:local

В этот момент происходит следующее:

  • Запускается mustpl (т.к. он указан в entrypoint), который читает файлы /data.json и /template.ini
  • В данных шаблона значение для my_option заменяется на foobar, так как переменная окружения MY_OPTION установлена (мы же указали -e "MY_OPTION=foobar"; в противном случае там бы оказалось значение default value)
  • Шаблон рендерится, и сохраняется в /rendered.txt
  • mustpl сохраняет все аргументы, что были указаны после пути до файла с шаблоном (это единственный обязательный параметр), трактуя их как имя и параметры запускаемого приложения (в нашем случае это sleep с аргументом infinity), двойное тире -- необходимо чтоб любые последующие флаги не парсились mustpl а читались «как есть»
  • Запускается процесс sleep и PID равный 1 сохраняется уже за ним, а mustpl просто завершает свою работу (фактически происходит замена образа, но это сейчас не так важно)

Давай проверим, так ли это на самом деле (выполним в отдельном терминале):

$ docker exec mustpl_example ps aux PID   USER     TIME  COMMAND     1 root      0:00 sleep infinity # <-- PID как видим на самом деле == 1     7 root      0:00 ps aux  $ docker exec mustpl_example cat /rendered.txt [config] value = foobar # <-- а вот и наше значение!  $ docker kill mustpl_example

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

Вместо заключения

Область применения этой утилиты, естественно, ограничена. Да, она не умеет Jinja-like модификаторов, кастомных функций (хотя, они описаны в спецификации mustache), да много ещё чего. Но она умеет просто шаблонизировать, и если сделает кому-то жизнь чуточку проще — я буду счастлив. Инструкции по установке, готовые бинарники, документация — всё это найдете в репозитории этой тулы.

Отдельное спасибо jetexe и AlexndrNovikov за ревью и режим «желтой уточки».

p.s. Если вы видите этот тест, то это означает что в данный момент его автор рассматривает предложения по работе, вместо CV — профиль на GitHub, а писать можно в хабра-личку или телеграм. Довольно много разрабатываю на Go, играю (и нередко выигрываю) в DevOps, проектирую системы различной сложности.

p.p.s. Об очепятках, пожалуйста, пишите в хабра-личку. Заранее благодарен!


ссылка на оригинал статьи https://habr.com/ru/post/684898/