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