кдпв


Однажды я был маленьким, и задавался вопросом — вот если 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 за ревью и режим "желтой уточки".

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


  1. alef13
    26.08.2022 21:20
    +1

    всегда sed хватало...


  1. MentalBlood
    26.08.2022 21:51
    +2

    Пример неудачный: написание результата кажется проще чем комбинация шаблона и данных


  1. Tibor128
    26.08.2022 22:15

    кажется Вы изобрели envsubst.

    upd: дочитал...


  1. git-merge
    27.08.2022 00:06
    +1

    1. идея tool -data data.yaml template.tpl - очень хорошая, я тоже об этом думал.

    2. идея пробросить туда ENV тоже весьма

    а вот собственный язык шаблонов здесь всё портит (конечно это моё мнение).

    мне кажется что очень хорошим вариантом шаблонного языка является какой-нибудь язык-embedded.

    • Например perl-embedded (что-то вроде Mojo)

    • JS-embedded (масса вариаций вроде ejs)

    • конечно же PurePHP :)

    • так далее.

      Что хорошо в embedded языках, так это то,

    • что шаблоны сложнее hello world тоже доступны и сложная логика на них реализуема.

    • что не требуется учить язык чтоб их использовать

    • и главное: оно легко расширяемо (какую-нибудь библиотеку форматирования даты времени очень просто подключить в такие шаблоны)


    1. masai
      27.08.2022 10:04
      +1

      а вот собственный язык шаблонов здесь всё портит (конечно это моё мнение).

      Тут реализовано подмножество популярного mustache.

      мне кажется что очень хорошим вариантом шаблонного языка является какой-нибудь язык-embedded.

      Бывает полезно, но из пушки по воробьям в данном случае.


      1. paramtamtam Автор
        27.08.2022 13:11

        Но поиграться с тем же Lua мне было бы интересно, спасибо за идею


        1. masai
          27.08.2022 15:30

          Плюсую Lua. Пробовал работать с разными встраиваемыми языками и Lua оказался самым беспроблемным. Если нравится синтаксис Python, то можно Starlark ещё посмотреть.


  1. Oxyd
    27.08.2022 08:19
    +6

    Всё уже придумано до нас. ;-)

    export VAR1="bla-bla-bla" VAR2="blablabla" VAR3="blabla-bla"
    envsubst < file_with.vars|tee file_with.vars >/dev/null
    И вот как-то так оно и работает

    Если кому нужна подсвечивалка по регуляркам, ловите, мне не жалко. ;-)

    #!/usr/bin/env bash
    #
    # hlt approved by shellcheck ;-)
    #
    
    hl_start="\\\e[0;1;4;32m"
    hl_end="\\\e[0m"
    
    usage() {
    	cat << 'EOF'
    hlt: 'sed -E' regular expression highlighter from file or stdin.
      Usage: 
          hlt '<regular expression>' <file>
          or command | hlt '<regular expression>'
    EOF
    exit 1
    }
    
    if [[ -z "$1" ]]; then
    	usage
    fi
    
    if [[ -n "$2" ]]; then
    	file="$2"
    else 
    	file="/dev/stdin"
    fi
    
    result="$(sed -E "s/(""$1"")/$hl_start\1$hl_end/g" "$file")"
    echo -e "$result"
    
    exit 0

    А главное практически всегда есть в системе. И да. это реально самый простой шаблонизатор. Но творение автора тоже может быть применимо в определённых кейсах.

    PS: Такое чуйство, что автор раньше писал статьи для Журнал Хакер ;-)


    1. paramtamtam Автор
      27.08.2022 13:13

      Спасибо Дане за счастливое детство и сломанную психику <3


  1. alef13
    27.08.2022 11:32
    +3

    Однажды я был маленьким, и задавался вопросом — вот если Unix way это (упрощенно) небольшие, довольно простые утилиты и библиотеки, которые делают одну вещь, но делают её хорошо (Peter H. Salus: "...that do one thing and do it well"), то… Где тогда утилита, которая занимается шаблонизацией и не хватает звёзд с неба?

    GNU m4


  1. Taraflex
    28.08.2022 08:44
    -1

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

    Есть mustache шаблонизатор на bash
    github.com/tests-always-included/mo
    32 KB
    Если вырезать комментарии, то выйдет наверное вполовину меньше.