unlock unlimited code runs for $79, limited time →
NOSSLETTER.techLIVE
back to OSSpedia
api-designerror-handling

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 tpl calls (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 tpl function is registered with the current template instance, so it inherits all options and functions (like missingkey=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 tpl function to the template after all options are set and before parsing/execution, so nested tpl calls 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 tpl function for users.

From the review thread

nitishfy: tpl clones the raw global baseTemplate, which never has goTemplateOptions applied since those are only applied to the outer template in Replace() So a missing key can behave inconsistently

twobiers: You're right. I've modified the code to lazily include the tpl function 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.