Шаблонизация в 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-образа с приложением.

Для сборки потребуется только gcc (и musl-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, подумаем что можно сделать.

Кроме того, что целью был минимальный размер итогового бинарного файла, отсутствие зависимостей и скорость работы, есть ещё как минимум одна очень важная причина — это комфортное использование в 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. Об очепятках, пожалуйста, пишите в хабра-личку. Заранее благодарен!

© Habrahabr.ru