feat: add tpl function to ApplicationSet go-templating (#16302)
Project: argoproj/argo-cd | Language: Go | Concepts: api-design, error-handling | Difficulty: mid
Link: https://github.com/argoproj/argo-cd/pull/26614
The problem
The ApplicationSet controller's Go templating did not support a function like Helm's tpl, which allows users to render ad-hoc templates using dynamic input. This limited the flexibility of templates, especially when users needed to evaluate or "nest" templates at runtime.
Why it's non-trivial
A naive implementation of tpl could:
- Fail to apply the correct template options (like error handling for missing keys), resulting in inconsistent or confusing behavior.
- Allow unbounded recursion, which could crash the controller if users accidentally or maliciously created self-referential templates.
- Not integrate with the existing template context, causing unexpected results or security issues.
The approach
1. Implementing the tpl function with recursion protection and correct context:
+const TPL_FUNC_MAX_RECURSION_DEPTH = 3
...
+func tplFun(parent *template.Template) func(string, any) (string, error) {
+ callDepth := 0
+ return func(tpl string, vals any) (string, error) {
+ callDepth++
+ defer func() {
+ callDepth--
+ }()
+
+ if callDepth > TPL_FUNC_MAX_RECURSION_DEPTH {
+ return "", fmt.Errorf("maximum recursion depth %d exceeded in tpl function", TPL_FUNC_MAX_RECURSION_DEPTH)
+ }
+ ...
+ t, err := parent.Clone()
+ if err != nil {
+ return "", fmt.Errorf("cannot clone template: %w", err)
+ }
+ t, err = t.New(parent.Name()).Parse(tpl)
+ if err != nil {
+ return "", fmt.Errorf("cannot parse template %q: %w", tpl, err)
+ }
+ var buf strings.Builder
+ if err := t.Execute(&buf, vals); err != nil {
+ return "", fmt.Errorf("error during tpl function execution for %q: %w", tpl, err)
+ }
+ return strings.ReplaceAll(buf.String(), "<no value>", ""), nil
+ }
+}
What this does:
- Defines a maximum recursion depth for
tplcalls (set to 3). - Clones the parent template to inherit all options and functions.
- Parses and executes the provided template string with the given values.
- Returns an error if recursion goes too deep or if parsing/execution fails.
2. Registering the tpl function at the right time:
+func withTplFunc(t *template.Template) *template.Template {
+ return t.Funcs(template.FuncMap{"tpl": tplFun(t)})
+}
What this does:
- Ensures the
tplfunction is registered with the current template instance, so it inherits all options and functions (likemissingkey=error).
3. Hooking tpl into the rendering pipeline:
@@ -409,6 +409,7 @@ func (r *Render) Replace(tmpl string, replaceMap map[string]any, useGoTemplate b
for _, option := range goTemplateOptions {
cloned = cloned.Option(option)
}
+ cloned = withTplFunc(cloned)
parsed, err := cloned.Parse(tmpl)
if err != nil {
return "", fmt.Errorf("failed to parse template %s: %w", tmpl, err)
What this does:
- Adds the
tplfunction to the template after all options are set and before parsing/execution, so nestedtplcalls work as expected.
4. Thorough unit testing for edge cases:
+ {
+ name: "tpl disallow infinite recursion",
+ fieldVal: "{{ tpl .self . }}",
+ errorMessage: "failed to execute go template {{ tpl .self . }}: template: base:1:3: executing \"base\" at <tpl .self .>: error calling tpl: error during tpl function execution for \"{{ tpl .self . }}\": template: base:1:3: executing \"base\" at <tpl .self .>: error calling tpl: error during tpl function execution for \"{{ tpl .self . }}\": template: base:1:3: executing \"base\" at <tpl .self .>: error calling tpl: error during tpl function execution for \"{{ tpl .self . }}\": template: base:1:3: executing \"base\" at <tpl .self .>: error calling tpl: maximum recursion depth 3 exceeded in tpl function",
+ params: map[string]any{
+ "self": "{{ tpl .self . }}",
+ },
+ },
What this does:
- Tests that recursion is properly bounded and produces a clear error instead of crashing.
5. Documentation update:
-- `toYaml` / `fromYaml` / `fromYamlArray` helm like functions
+- `toYaml` / `fromYaml` / `fromYamlArray` / `tpl` helm like functions
What this does:
- Documents the new
tplfunction for users.
From the review thread
nitishfy: tpl clones the raw global
baseTemplate, which never hasgoTemplateOptionsapplied since those are only applied to the outer template inReplace()So a missing key can behave inconsistently
twobiers: You're right. I've modified the code to lazily include the
tplfunction and covered it with test cases.
nitishfy: tpl can recurse into itself, and unlike other recursion in this engine, it isn't bounded and it crashes the whole process. I tried reproducing it here:
r := &Render{} params := map[string]any{ "self": `{{ tpl .self . }}`, } r.Replace(`{{ tpl .self . }}`, params, true, nil)This can crash the controller for every tenant.
twobiers: Good catch.
@gheinrich-cw thanks for the patch! I've used it as the basis, but increased the maximum allowed recursion depth to 3. I believe in sophisticated scenarios there might be legitimate use-cases for using nested templates.
Takeaways
- When exposing user-defined template evaluation, always bound recursion to prevent denial-of-service or crashes.
- Register custom template functions after all options and context are set, so they inherit the correct behavior (e.g., error handling for missing keys).
- Unit test edge cases, especially for error and recursion handling, to ensure robustness and predictable error messages.
- Update documentation whenever you add user-facing template features or functions.