Продвинутая Helm-шаблонизация: выжимаем максимум

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

NB. Всё описанное было проверено с werf, но так как шаблоны в этой утилите практически идентичны Helm-шаблонам, то и всё нижеприведенное будет полностью или почти полностью совместимо с обычным Helm обеих версий (v2 и v3).

А теперь разберем, как получить от Helm-шаблонов всё, что можно… и даже всё, что нельзя!

1. Include/define-шаблоны как полноценные функции

В Helm-чартах регулярно используется функция define для того, чтобы выносить часто используемые части шаблонов в одно общее место. В большинстве случаев применение define (и сопутствующего ей include) ограничивается вынесением в define’ы простых фрагментов шаблонов: аннотаций, лейблов, имен ресурсов.

На самом же деле define’ы можно использовать ещё и как полноценные функции, эффективно абстрагируя за ними нашу логику.

1.1. Передача аргументов

Хотя include принимает только один аргумент, ничто не мешает пробросить список с несколькими аргументами:

{{- include "testFunc" (list $val1 $val2) }}

… и потом, внутри шаблона, получить аргументы вот так:

{{- define "testFunc" }}
  {{- $arg1 := index . 0 }}
  {{- $arg2 := index . 1 }}

Полный пример:

{{- define "testFunc" }}
  {{- $arg1 := index . 0 }}
  {{- $arg2 := index . 1 }}

  # Объединим аргументы в одну строку и вернем результат:
  {{ print $arg1 $arg2 }}
{{- end }}

---
{{- $val1 := "foo" }}
{{- $val2 := "bar" }}

{{- include "testFunc" (list $val1 $val2) }}
#   ==> string "foobar"

 

1.2. Передача текущего и глобального контекста

Глобальный контекст ($) — словарь, который содержит в себе все встроенные объекты, в том числе объект Values. Текущий контекст (.) по умолчанию указывает на глобальный контекст, но пользователь может менять, на какую переменную текущий контекст указывает.

Внутри шаблона текущим (и глобальным) контекстом становится аргумент, переданный в include, в нашем случае — список из аргументов. Но таким образом внутри шаблона нам теперь ничего кроме списка аргументов не доступно, даже $.Values. Исправить это можно, передав через список аргументов также и контексты:

{{- include "testFunc" (list $ . $arg) }}

Теперь остаётся восстановить глобальный контекст, который был за пределами include’а, чтобы к нему снова можно было обращаться через $:

{{- define "testFunc" }}
  {{- $ := index . 0 }}

… а также восстановить текущий контекст, чтобы обращаться к нему, как и раньше, через точку:

  {{- with index . 1 }}

В конечном итоге всё будет выглядеть так:

.helm/values.yaml:
-------------------------------------------------------------
key: "value"

-------------------------------------------------------------
.helm/templates/testFunc.yaml:
-------------------------------------------------------------
{{- define "testFunc" }}
  {{- $ := index . 0 }}
  {{- $stringArg := index . 2 }}

  {{- with index . 1 }}
    # И вот мы имеем доступ к "реальным" глобальному
    # и относительному контекстам, прямо как за пределами
    # include/define:
    {{ cat $stringArg $.Values.key .Values.key }}
  {{- end }}
{{- end }}

---
{{- $arg := "explicitlyPassed" }}
{{- include "testFunc" (list $ . $arg) }}
#   ==> string "explicitlyPassed value value"

 

1.3. Передача опциональных аргументов

Есть несколько способов сделать передачу опциональных аргументов в шаблон. Самый гибкий и удобный из них — передавать в списке аргументов также и словарь с опциональными аргументами:

{{- include "testFunc" (list $requiredArg (dict "optionalArg2" "optionalValue2")) }}

А теперь добавим немного магии в шаблон, чтобы корректно обрабатывать отсутствие опциональных аргументов:

{{- define "testFunc" }}
  ...
  {{- $optionalArgs := dict }}
  {{- if ge (len .) 2 }}{{ $optionalArgs = index . 1 }}{{ end }}

После этого можно обращаться к опциональным аргументам через {{ $optionalArgs.optionalArg2 }}. Полный пример:

{{- define "testFunc" }}
  {{- $requiredArg := index . 0 }}
  {{- $optionalArgs := dict }}
  {{- if ge (len .) 2 }}{{ $optionalArgs = index . 1 }}{{ end }}

  # Проверяем на наличие опциональных аргументов
  # и используем их, если нашли:
  {{- if hasKey $optionalArgs "optionalArg1" }}
    {{- cat "Along with" $requiredArg "we have at least" $optionalArgs.optionalArg1 }}
  {{- else if hasKey $optionalArgs "optionalArg2" }}
    {{- cat "Along with" $requiredArg "we have" $optionalArgs.optionalArg2 }}
  {{- else }}
    {{- cat "We only have" $requiredArg }}
  {{- end }}
{{- end }}

---
{{- $requiredArg := "requiredValue" }}

# Вызовем шаблон без опциональных аргументов:
{{- include "testFunc" (list $requiredArg) }}
#   ==> string "We only have requiredValue"

# А теперь - с одним из двух опциональных аргументов:
{{- include "testFunc" (list $requiredArg (dict "optionalArg2" "optionalValue2")) }}
#   ==> string "Along with requiredValue we have optionalValue2"

 

1.4. Вложенные include’ы, рекурсия

Также, находясь внутри шаблона, есть возможность делать include других шаблонов. В том числе, мы можем рекурсивно вызывать тот же шаблон, внутри которого мы сейчас находимся, — всё как с функциями в полноценных языках:

{{- define "testFunc" }}
  {{- $num := . }}

  {{- if lt $num 10 }}
    # Делаем include другого шаблона:
    {{- include "print" $num }}
    # Рекурсивно вызываем тот же шаблон, внутри которого
    # мы сейчас находимся:
    {{- include "testFunc" (add 1 $num) }}
  {{- end }}
{{- end }}

{{- define "print" }}
  {{- print . }}
{{- end }}

---
{{- include "testFunc" 0 }}
#   ==> string "0123456789"

 

1.5. Возвращение из шаблонов полноценных типов данных

include работает предельно просто: на место {{ include }} просто подставляется текст, который был отрендерен внутри шаблона. По умолчанию возможность вернуть из шаблона что-то кроме строки отсутствует. Таким образом, мы не можем, например, вернуть список или словарь в словаре, чтобы потом пройтись по ним циклом и получить их значения. Но есть возможность это обойти с помощью сериализации.

Сериализуем данные в JSON (или YAML) внутри шаблона:

{{- define "returnJson" }}
  {{- $result := dict "key1" (dict "nestedKey1" "nestedVal1") }}
  {{- $result | toJson }}
{{- end }}

При вызове этого шаблона наши сериализованные данные вернутся как строка. Убедимся в этом:

{{ include "returnJson" . | typeOf }}
#   ==> string "string"

А теперь сделаем десериализацию полученной из шаблона строки и проверим, какой тип данных мы получили:

{{- include "returnJson" . | fromJson | typeOf }}
#   ==> string "map[string]interface {}"

Таким образом, во втором случае мы получаем не просто строку, а полноценный словарь с вложенным в него другим словарем. С ним можно работать обычными для словарей функциями:

{{- include "returnJson" . | fromJson | values }}
#   ==> string "
]"

Такую сериализацию, очевидно, можно применять для любых типов: списков, словарей, булевых значений и т. п., в том числе вложенных друг в друга.

1.6. Include в условиях if-блоков и в функции ternary

Всё, что возвращает include в условиях блоков if, так и остаётся строкой, не преобразовывается в булевые значения и другие типы. То есть, если мы возвращаем из шаблона true, оно становится строкой "true". Любая непустая строка эквивалентна в условии if-блока булевому true. Поэтому, если мы возвращаем false, то оно тоже превращается в непустую строку "false", которая тоже становится булевым true.

Избежать превращения false в true в условии if-блока можно, если ничего не возвращать из шаблона — вместо того, чтобы возвращать false. В таком случае из шаблона вернется пустая строка, которая в условии if-блока станет нам булевым false:

{{- define "returnPseudoBoolean" }}
  {{- if eq . "pleaseReturnTrue" }}
true
  {{- else if eq . "pleaseReturnFalse" }}
  {{- end }}
{{- end }}

Так мы можем делать include’ы, которые будут работать в условиях if-блоков:

{{- if include "returnPseudoBoolean" "pleaseReturnTrue" }}
  {{- print "Первый if вернёт True" }}
{{- end }}
#   ==> string "Первый if вернёт True"

{{- if include "returnPseudoBoolean" "pleaseReturnFalse" }}
{{- else }}
  {{- print "Второй if вернёт False" }}
{{- end }}
#   ==> string "Второй if вернёт False"

Другое дело — функция ternary: она как раз ожидает реальные булевые значения, а не строки. Вернуть булевые значения из шаблона можно, дополнительно прогнав вывод шаблона через функцию empty. Получится аналог того, что происходит под капотом условий if-блоков:

{{- ternary "Сработало True" "Сработало False" (include "returnBoolean" "pleaseReturnTrue" | not | empty) }}
#   ==> string "Сработало True"

 

2. Эффективное использование функции tpl

Функция tpl — мощный инструмент для шаблонизации там, где она была невозможна. Она показала себя полезной прежде всего для шаблонизации значений в values.yaml. Но у этой функции есть несколько ограничений, не дающих ей полностью раскрыться. Разберем эти ограничения и способы их обхода.

2.1. Сделаем обёртку для Values

Чтобы не дублировать каждый раз логику, в которую мы сейчас обернём нашу функцию tpl, можно вынести эту логику в шаблон. Назовём его "value" и будем использовать как обертку для всех наших Values. Так что теперь вместо {{ $.Values.key }} везде будет использоваться {{ include "value" (list $ . $.Values.key }}.

Сам шаблон получится таким:

.helm/values.yaml:
-------------------------------------------------------------
key1: "Значение ключа key2: {{ $.Values.key2 }}"
key2: "value2"

-------------------------------------------------------------
.helm/templates/test.yaml:
-------------------------------------------------------------
{{- define "value" }}
  # Сразу пробросим контексты, они понадобятся нам позже:
  {{- $ := index . 0 }}
  {{- $val := index . 2 }}

  {{- with index . 1 }}
    {{- tpl $val $ }}
  {{- end }}
{{- end }}

---
{{- include "value" (list $ . $.Values.key1) }}
#   ==> String "Значение ключа key2: value2"

Пока что мы просто передаём в функцию tpl третий аргумент шаблона. Этот аргумент — простое Value. Ничего кроме этого не делаем и получаем результат.

NB: Обработку остальных типов данных (не строк) в шаблоне "value" мы реализовывать не будем. Используя конструкции вида {{- if kindIs "map" $val }}, вы можете сами попробовать реализовать специфичные для разных типов данных обработки.

2.2. Передача текущего контекста

Функция tpl требует, чтобы единственным аргументом, который ей передаётся, был словарь с объектом Template. Таким словарём является глобальный контекст ($), его-то обычно и передают как аргумент в функцию tpl. И здесь мы не можем использовать трюк с передачей в качестве единственного аргумента списка с несколькими вложенными аргументами, т. к. в этом списке не будет необходимого объекта Template. И всё же есть несколько способов передать вместе с глобальным контекстом и текущий контекст — рассмотрим самый простой.

Первый шаг — создадим новый ключ в глобальном контексте, значением которого будет наш текущий контекст, и после этого передадим глобальный контекст в tpl. Таким образом {{- tpl $val $ }} превратится в это:

    {{- tpl $val (merge (dict "RelativeScope" .) $) }}

Теперь локальный контекст доступен с помощью {{ $.RelativeScope }} в нашей строке-шаблоне $val, которая передаётся в функцию tpl для рендеринга.

Второй шаг — обернём строку-шаблон $val в блок with, который «восстановит» текущий контекст и позволит обращаться к нему через привычную точку:

    {{- tpl (cat "{{- with $.RelativeScope -}}" $val "{{- end }}") (merge (dict "RelativeScope" .) $) }} 

И теперь можно использовать не только глобальный, но и относительный контекст в наших values.yaml:

.helm/values.yaml:
-------------------------------------------------------------
key1: "Значение ключа key2: {{ .key2 }}"
key2: "value2"

-------------------------------------------------------------
.helm/templates/test.yaml:
-------------------------------------------------------------
{{- define "value" }}
  {{- $ := index . 0 }}
  {{- $val := index . 2 }}

  {{- with index . 1 }}
    {{- tpl (cat "{{- with $.RelativeScope -}}" $val "{{- end }}") (merge (dict "RelativeScope" .) $) }}
  {{- end }}
{{- end }}

---
# Изменим текущий контекст:
{{- with $.Values }}
# Попробуем использовать относительный путь к key1:
{{- include "value" (list $ . .key1) }}
{{- end }}
#   ==> String "Значение ключа key2: value2"

Доступ к текущему контексту полезен, например, при генерации YAML-фрагментов в цикле, где очень часто нужен доступ именно к контексту в текущей итерации.

Так же, как мы пробросили текущий контекст, можно передавать и любых другие дополнительные аргументы в функцию tpl. Надо только прикрепить аргументы к глобальному контексту и передать глобальный контекст в функцию tpl.

2.3. Проблемы с производительностью tpl

У функции tpl есть известные проблемы с производительностью (Issue). Поэтому вызов tpl для каждого Value, даже когда это не нужно, может сильно замедлить рендеринг больших чартов. Чтобы избежать ненужных запусков функции tpl, можно просто добавить проверку на наличие {{ в строке-шаблоне. Если фигурных скобок в строке-шаблоне не будет, то значение вернётся из шаблона «value» как есть, без передачи значения в функцию tpl:

{{- define "value" }}
  {{- $ := index . 0 }}
  {{- $val := index . 2 }}

  {{- with index . 1 }}
    {{- if contains "{{" $val }}
      {{- tpl (cat "{{- with $.RelativeScope -}}" $val "{{- end }}") (merge (dict "RelativeScope" .) $) }}
    {{- else }}
      {{- $val }}
    {{- end }}
  {{- end }}
{{- end }}

---
{{- with $.Values }}
{{- include "value" (list $ . .key1) }}
{{- end }}

При этом, если фигурные скобки в строке-шаблоне будут присутствовать, то она, как и прежде, будет пропущена через функцию tpl. Такое простое решение может ускорить рендеринг шаблонов в десятки раз.

3. Отладка

С увеличением количества логики в чартах отладка может сильно усложниться. Помимо банальных helm render и helm lint есть ещё и функция fail, которая во многих случаях является лучшей альтернативой простому {{ $valueToDump }}. Функция fail не требует того, чтобы чарты рендерились без ошибок, и может использоваться в любом месте, сразу же давая результат, без необходимости передавать его в манифест. Нужно лишь, чтобы рендеринг добрался до вызова этой функции.

Сделать дамп текущего контекста:

{{- fail (toYaml $.Values) }}
#   ==> "key1: val1
#        key2: val2
#        ...."

Вариант для дебага циклов/рекурсий (порядок не гарантирован, но при желании можно упорядочить по timestamp):

{{- range (list "val1" "val2") }}
  {{- $_ := set $.Values.global (toString now) (toYaml .) }}
{{- end }}

{{ fail (toYaml $.Values.global) }}
#   ==> "2020-12-12 19:52:10.750813319 +0300 MSK m=+0.202723745: |
#          val1
#        2020-12-12 19:52:10.750883773 +0300 MSK m=+0.202794200: |
#          val2"

Схожим образом можно сохранить любые промежуточные результаты, которые потом отобразить разом с помощью функции fail:

{{- $_ := set $.Values.global "value1" $val1 }}
{{- $_ := set $.Values.global "value2" $val2 }}

{{ fail (toYaml $.Values.global) }}
#   ==> "value1: val1
#        value2: val2"

 

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

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

Чувствуете, что даже этого не хватает, чтобы держать чарты обслуживаемыми и расширяемыми? Тогда, возможно, стоит задуматься о том, чтобы генерировать YAML программно, подставляя генерируемые манифесты подобно тому, как это делается в ПО, отвечающем за развертывание. Можно привести cdk8s как пример программной генерации YAML: пусть оно ещё и весьма сырое, саму идею наглядно демонстрирует. И пока светлое будущее без YAML-шаблонизации не наступило, нам не должно быть стыдно за то, что мы эксплуатируем шаблонизаторы, которые уже очень давно эксплуатируют нас.

источник