Unreleased documentation. Choose your installed release in the version menu. Features described here may be absent from that release.
Template reference¶
Look up template context variables, HAPTIC functions, file paths, and status-patch helpers. For syntax and runnable examples, start with the templating guide.
Context variables¶
All templates have access to the following top-level variables:
| Variable | Type | Description |
|---|---|---|
resources |
map of stores | Kubernetes resources indexed per watchedResources config — entries are wrappers exposing .List() / .Fetch(keys...) / .GetSingle(keys...) / .APIVersion() (the group/version this resource is actually watched at), plus the type-carrying .T |
controller |
map of stores | Controller-managed stores; currently only controller.haproxy_pods for the discovered HAProxy pod set |
pathResolver |
object | Resolves filenames to HAProxy paths — see pathResolver |
capabilities |
map (bool values) | Feature flags for the lowest HAProxy version in the fleet. See version checks. |
currentConfig |
server index (or nil) | The servers the running configuration has: currentConfig.ServerIndex["<backend>"]["<server>"].Address and .Port (a pointer — check it for nil before you read it). Nil on first deployment — guard with {% if !isNil(currentConfig) %}. |
currentFiles |
map[string]string |
Last validated map, general-file, and crt-list contents, keyed by base filename. Empty on first deployment; excludes Secret data. See previous output. |
dataplane |
config.Dataplane block |
The CRD's spec.dataplane block — the agent port, timeouts, and the auxiliary paths |
shared |
*SharedContext |
Thread-safe compute-once cache for expensive computations (shared.ComputeIfAbsent(key, factory) + shared.Get(key); no Set — prevents racy check-then-act patterns) |
templateSnippets |
list | Names of all available template snippets — useful for dynamic render_glob patterns |
runtimeEnvironment |
object | Runtime info exposed by the controller (for example runtimeEnvironment.GOMAXPROCS) |
fileRegistry |
object | Register generated files and get their deployed paths. See file registration. |
http |
object | HTTP fetcher for http.Fetch("https://example.com/...") — see Watching Resources — HTTP Resources for the auto-registration and refresh mechanism |
extraContext |
map | The full templatingSettings.extraContext map. Read a key with extraContext.key or extraContext["key"] — see Custom Template Variables. |
renderMode |
string | "admission" for a proposed resource change; "reconcile" for live state, config loading, and haptic validate. See admission checks. |
admissionSubject |
map | Identifies the watched resource under admission review. Empty outside single-resource admission. See admission checks. |
Check HAProxy features¶
Use capabilities for feature checks, for example
{% if capabilities.supports_crt_list %}…{% end %}. Keys use snake_case;
a misspelled key evaluates to false rather than raising an error.
The flags reflect the lowest HAProxy version reported by the fleet. Before pod
discovery, they reflect the controller image's HAProxy binary. The chart selects
matching controller and HAProxy versions through haproxyVersion.
The chart also sets extraContext.haproxyVersion. If you deploy without the
chart, set that value yourself when your templates need it. Prefer capabilities
for feature checks, since a fleet can temporarily contain mixed versions.
Read previous output¶
Use currentFiles["filename"] to read a previously validated auxiliary file.
The map is always non-nil; len(currentFiles) == 0 covers the first deployment.
The controller sets this variable; extraContext.currentFiles can't override it.
The files come from the last complete, validated configuration. If that published set is incomplete, rendering fails until it's available.
Register generated files¶
fileRegistry.Register(kind, filename, content) registers a file during rendering
and returns (path, error). Handle the error before using the path. kind can be "file", "cert", "map",
"crt-list", or "ca-file". For example, the bundled libraries use it to
assemble CA bundles and client certificates from Secrets.
For "file", an optional fourth argument, reloadOnPush, defaults to true.
Pass false only for a file read by a sidecar rather than HAProxy; a content
change then deploys without reloading HAProxy.
Limit a check to the admitted resource¶
Combine renderMode with admissionSubject when a rule should reject only the
resource being created or edited. A fail() against live state aborts the whole
render and can prevent unrelated changes from deploying. Use
recordEvent() when you need to report a live violation without
blocking the render.
admissionSubject contains namespace, name, and a stores map of affected
watchedResources aliases. Check an alias with
admissionSubject | dig("stores", alias) | fallback(false). The store field
is set only when exactly one alias is affected.
The map is empty during reconciliation, configuration proposals, and bulk overlays.
Both admissionSubject and renderMode are controller-set variables;
extraContext can't override them.
Functions and filters¶
Every entry below is callable in two equivalent styles: as a plain function (fn(x, args...)) or via the pipe operator (x | fn(args...)), which passes the left-hand value as the first argument. The pipe requires parentheses on the right side — {{ value | toLower() }} works, {{ value | toLower }} is a parse error. The one exception is len, a language builtin: call len(x) directly.
| Function | Purpose | Example |
|---|---|---|
fallback(value, default) |
Return default if value is nil. Empty strings and zeroes pass through — only dig() on optional typed fields normalises zero values to nil first |
fallback(dig(settings, "port"), 80) |
dig(obj, "k1", "k2", ...) |
Walk a nested map / typed struct without nil-checking each level (navigates JSON tags on typed structs) | dig(ing, "metadata", "annotations") |
toSlice(v) |
Coerce any to []any (safe to range over even if nil) |
for _, r := range toSlice(dig(item, "rules")) |
to_str_map(v) |
Copy a string-keyed map to map[string]string; each value must be a deterministic scalar |
for k, v := range route.metadata.labels \| to_str_map() |
shard_slice(items, idx, n) |
Type-preserving split of a slice into n shards, returning shard idx — input element type is kept |
shard_slice(gateways, i, totalShards) |
tostring(v) |
Convert nil, a boolean, a finite number, a string, or a pointer to one of those scalars to text. Composite values and custom formatting methods fail the render; use field access or toJSON() instead |
name = tostring(dynamicName) |
toint(v), tofloat(v) |
Numeric conversions from any |
port = toint(annotation) |
len(v) |
Length of slice / map / string (language builtin — not pipeable) | len(ing.spec.rules) |
keys(m) |
Sorted keys of a map | for _, k := range keys(annotations) |
merge(a, b) |
New map combining a and b (b wins on conflict) |
merge(defaults, overrides) |
toLower(s) / toUpper(s) |
Case conversion | host = toLower(rule.host) |
replace(s, old, new), split(s, sep), join(slice, sep), strip(s), trim(s, cutset), hasPrefix(s, p), hasSuffix(s, p) |
String operations (strip trims whitespace; trim takes an explicit cutset) |
join(items, ", ") |
first_seen(prefix, keys...) |
Returns true only the first time the key tuple is seen — for deduplicating |
if first_seen("backend", svc.metadata.namespace, svc.metadata.name) |
sanitize_regex(s) |
Escape regex metacharacters in user input | sanitize_regex(annotation) |
regex_search(s, pattern) |
True when the RE2 pattern matches anywhere in the string. Both arguments are coerced with tostring() first, and a pattern that doesn't compile aborts the render |
{% if regex_search(name, "ssl.*passthrough") %} |
semver_gte(version, "3.3") |
Compare a semver string (major.minor) against a target | if semver_gte(extraContext.haproxyVersion, "3.3") (the chart auto-populates extraContext.haproxyVersion; outside the chart, set it yourself via templatingSettings.extraContext.haproxyVersion — see Custom Template Variables) |
fail(msg) |
Abort rendering with an error message (surfaces in validation tests and webhooks) | fail("missing required annotation") |
b64decode(s) |
Decode base64 strings (Secret .data values) |
{{ secret.data.password \| b64decode() }} |
b64encode(s) |
Encode a value as standard base64 | {{ configmap.data.schema \| b64encode() }} |
parse_yaml(text) |
Decode one YAML document into a value plus an error; rejects duplicate keys and additional documents | {% var value, err = parse_yaml(text) %} |
public_key_info(pem) |
Parse one RSA, ECDSA, or Ed25519 public key; returns a map with algorithm, bits, curve, and canonical PUBLIC KEY PEM in pem, plus an error. Private keys and extra PEM blocks are rejected |
{% var info, err = public_key_info(publicKey) %} |
untar_gz(archive) |
Expand a .tar.gz archive into a map of entry path to content. Returns (map[string]string, error); any failure returns no files. Keeps regular files and their directory paths; select with keys() + glob_match(). Rejects path traversal and invalid compression checksums. Limits: 4096 entries, 8 MiB per file, 32 MiB of extracted content, and 64 MiB for the complete decompressed stream, including skipped entries and metadata |
{%- var files, err = untar_gz(archive) %} |
glob_match(items, pattern) |
Filter strings by glob pattern | {{ templateSnippets \| glob_match("backend-*") }} |
map_extract(items, keyPath) |
Pluck one field (dotted key path) from each item into a flat slice | {{ routes \| map_extract("routeId") }} |
indent(s, n) |
Indent lines by N spaces (first and blank lines excluded) | {{ render "snippet" \| indent(4) }} |
debug(v, label) |
Output as JSON comment | {{ routes \| debug("routes") }} |
toJSON(v) |
Convert a value to JSON; an unsupported value fails the render | {{ myMap \| toJSON() }} |
basename(path) |
Filename portion of a path, like Unix basename |
basename("/etc/haproxy/maps/hosts.map") |
namespace(init) |
Mutable map[string]any for accumulating state across loop iterations |
{%- var acc = namespace(map[string]any{"n": 0}) %} |
isNil(v) |
Nil check that also catches a typed nil pointer boxed in an any |
{% if !isNil(currentConfig) %} |
coalesce(value, default) |
First non-nil of the two — the plain-call spelling of fallback |
coalesce(annotation, "default") |
Strings and types¶
| Function | Purpose | Example |
|---|---|---|
strings_contains(s, sub), strings_split(s, sep), strings_splitn(s, sep, n), strings_trim(s), strings_lower(s), strings_replace(s, old, new) |
any-tolerant string operations — they coerce their arguments, so they work on values read out of a map[string]any without a cast first. strings_trim trims whitespace; strings_splitn caps the result at n parts |
strings_splitn(annotation, ":", 2) |
title(s) |
Title-case a string | title(mode) |
isdigit(s) |
True when the string is non-empty and all digits — check before toint() |
{% if isdigit(port) %} |
toStringSlice(items) |
Copy scalar elements from a slice to []string; a composite element fails the render |
toStringSlice(hosts) |
cidr_partition(cidrs) |
Split a set of IPv4/IPv6 prefixes (a bare address is a host prefix) into the disjoint blocks each prefix is a union of, as map[input][]block. A map_ip lookup over all blocks names exactly one block for any address, so per-route CIDR lists become two map lookups instead of a config line |
cidr_partition(allowlists)["10.0.0.0/8"] |
sort_strings(items) |
Sort deterministic scalar elements lexicographically, returning []string |
sort_strings(keys(m)) |
sort_ints(items) |
Sort a []any numerically, returning []int — use for ports and IDs, where sort_strings would put "10" before "2". Non-integer entries coerce through toint() and sort to the front |
sort_ints(ports) |
ceil(f) |
Round a float up | ceil(tofloat(total) / 4) |
seq(n) |
[]int{0, 1, …, n-1}, for fixed-count loops |
{% for _, i := range seq(count) %} |
append(slice, item) |
Go's builtin, type-preserving; append(dst, src...) spreads a slice of the same type. Widening into []any is a compile error — box per element in a loop. A slice reached through any is asserted at the boundary |
append(gf["hosts"].([]any), h) |
dig_string(obj, default, keys...) |
dig + fallback + tostring in one call, for annotation and metadata lookups |
ing \| dig_string("", "metadata", "annotations", key) |
join_key(sep, parts...) |
Join any values into one composite key string | join_key("_", ns, name, port) |
make_guid(parts...) |
Build a value for HAProxy's guid directive from parts joined by :, auto-truncating with a hash suffix past its 127-character limit |
guid {{ make_guid("be", beKey) }} |
selectattr(items, attr[, op, value]) |
Jinja2-style attribute filter. op is eq, ne or in; omitted, it keeps items whose attr is truthy. attr is one literal key — a dotted path matches nothing, so prefer filter with a closure |
selectattr(rules, "host", "ne", "") |
Collection pipelines¶
Type-preserving stages, chained with |. Each stage retains type information for its result, so typed field access works in later stages and misspelled fields fail compilation. Predicates are closures — write them long-hand (func(e T) bool { … }) or as x => expr with both types inferred. See Templating — Collection pipelines for the guided version.
| Function | Purpose | Example |
|---|---|---|
map(items, fn) |
One output per input | pods \| map(p => p.metadata.name) |
filter(items, pred) |
Keep the elements the predicate accepts | ingresses \| filter(i => len(i.spec.tls) > 0) |
reject(items, pred) |
Drop them instead, so the call site reads as a positive statement | eps \| reject(e => e.targetRef.name == "") |
flat_map(items, fn) |
Map to slices and concatenate, flattening exactly one level | slices \| flat_map(s => s.endpoints) |
unique(items) |
First occurrence of each distinct element, input order preserved | hosts \| unique() |
unique_by(items, key) |
First element per key. key is a closure, or an attribute path for any-shaped data |
hosts \| unique_by(h => toLower(h)) |
group_by(items, key) |
Bucket by string key, input order preserved within each bucket. Same two key forms. Iterate the result through keys() — Go map order isn't stable, and a reordered render reads as a change to the controller |
ingresses \| group_by("metadata.namespace") |
sort_by(items, criteria) |
Sort by JSONPath expressions — see sort_by modifiers |
routes \| sort_by([]string{"$.priority:desc"}) |
sort_by(items, cmp) |
Sort with a func(a, b T) int comparator (Go's cmp convention: negative when a sorts first), for orderings the criteria language can't state. Stable, like the criteria form |
routes \| sort_by(func(a, b Route) int { return a.Rank - b.Rank }) |
sort_by is the one stage that returns (value, error). As a pipe stage that's invisible — the pipe keeps only the first result, so x | sort_by(…) assigns to one variable. A direct call returns both and needs two: var rows, err = sort_by(items, criteria).
The attribute-path form of unique_by and group_by navigates dotted paths:
"metadata.namespace" reads the namespace field inside metadata.
Governance helpers¶
Read or derive fields on watched resources by JSONPath. The chart's governance rules use these helpers.
| Function | Purpose | Example |
|---|---|---|
resource(name) |
The per-render items of a watched resource named dynamically | resource(extraContext.targetStore) |
jsonpathGet(item, path) |
Read a concrete JSONPath out of an item | jsonpathGet(ing, "$.spec.ingressClassName") |
deriveResource(name, item, path, value) |
Publish a detached resource value to subsequent List, Fetch, GetSingle, and resource reads. Returns the transformed item |
ing = deriveResource("ingresses", ing, "$.metadata.annotations.x", "1") |
jsonpathSet(item, path, value) |
Write a concrete JSONPath into an item, in place. Returns whether the write landed | jsonpathSet(ing, "$.metadata.annotations.x", "1") |
Without incremental snippets, a root can call deriveResource before later
reads. When incremental snippets are configured, migrate every derivation
producer to the one component that declares the deriveResource effect for its
source. The derived view freezes before roots render, so a later root call fails.
Incremental values¶
| Function | Purpose | Example |
|---|---|---|
incremental_values(group, cell) |
Return the winning structured values published by a configured incremental group, in deterministic winner order | {% for _, host := range incremental_values("tls", "hosts") %} |
incremental_value_count(group, cell) |
Return how many winning values a cell holds without decoding them. Use it for presence tests: the root then depends on the count alone, so a change to one value doesn't re-run the root | {% if incremental_value_count("tls", "hosts") > 0 %} |
An incremental component that declares the publishValue effect publishes a
value with shared.Publish(cell, key, value). incremental_values is available
only to root templates. It can evaluate a group before the group's normal
render call, but roots must still render one or more complete group sequences.
Each sequence contains every component in snippet-name order and stays within
one root template. Repeating a sequence mounts its cached text again without
re-executing component bodies or effects.
An auxiliary root may read a group mounted in haproxy.cfg, which renders
first. Once a root starts its own producer sequence, that sequence must complete
before the root reads the group. A group mounted only in auxiliary roots must
complete its sequence in each consuming root because auxiliary roots render
concurrently.
Each call returns newly detached, immutable values. Unknown groups and groups
without publishValue fail; an absent cell in a known publication group returns
an empty slice.
Inside a component that declares the producer group in consumes, use
shared.Select(group, cell, key) for one exact winner or
shared.SelectValues(group, cell) for all winners in canonical order. Use
shared.Count(group, cell) for the number of unique winning publication keys
in a cell. These calls require a complete authenticated producer-group call and
register exact dependencies. Present and missing Select reads change only for
that key; losing or unrelated publishers don't execute the consumer.
SelectValues reruns the consumer only when the returned values or their order
changes. Count is O(1) and reruns it only when the observed count changes;
deleting the last owner changes it, while promoting another owner for the same
key doesn't.
Use shared.PublishRanked(cell, key, rank, value) when ownership has a chart
precedence rule. The lexicographically smallest non-empty rank wins, followed
by the normal deterministic owner order. Don't mix ranked and rank-free owners
for the same cell and key.
For complete coverage including crypto, encoding, and Scriggo built-ins (abs, min, max, sprintf, now(), etc.), see the Scriggo built-ins reference.
sort_by modifiers¶
sort_by criteria accept modifiers: :desc (descending), :exists (by field presence), | length (by length).
sort_by is a stable sort: items whose keys compare equal on every criterion keep their original input order. Add a final deterministic criterion (such as $.name) only when you want a specific order for otherwise-equal items rather than input order.
Example — route precedence sorting:
{% var sorted, sortErr = sort_by(routes, []string{
"$.match.method:exists:desc",
"$.match.headers | length:desc",
"$.match.path.value | length:desc",
}) %}
{% if sortErr != nil %}{{ fail("sorting routes: " + sortErr.Error()) }}{% end %}
Regex flavor¶
HAPTIC has two regex surfaces, and they use different engines:
- Template-level regex —
regex_search,sanitize_regex, and theregex_replacepost-processor — runs on Go'sregexppackage, which implements RE2 syntax. RE2 has no backreferences and no look-around assertions; a pattern that needs those won't compile. Theregex_replacepost-processor also runs line by line, so a pattern can't span a newline, and^/$anchor to each line rather than the whole document. - HAProxy-runtime regex — patterns HAProxy evaluates itself, such as
map_reglookups, a Gateway APIRegularExpressionpath match, orserver-alias-regex— uses HAProxy's Perl Compatible Regular Expressions (PCRE) engine, which does support backreferences and look-around. A pattern that works in one surface may be rejected by the other.
Regex safety with user-supplied patterns
Template-level regex runs on RE2, which is linear-time and can't catastrophically backtrack, so a pattern in regex_search, sanitize_regex, or regex_replace is never a regular-expression denial-of-service (ReDoS) vector, whatever the input. PCRE does backtrack, and HAPTIC imposes no PCRE match limit — a pathological pattern-and-input pair reaching the HAProxy-runtime surface is a potential data-plane CPU-exhaustion risk. When patterns can come from tenants you don't control (for example an Ingress or HTTPRoute authored in a namespace you don't own), match on the RE2 side where you can, and review any tenant-supplied runtime regex.
Emitting warnings¶
Use recordEvent() to publish a Kubernetes Warning Event
without aborting the render. Use fail(message) when invalid input must stop
configuration generation.
For troubleshooting a template value, debug(value, label) writes a JSON
comment into the generated configuration. The comment is visible in the
rendered HAProxyCfg, so don't use it for secrets.
pathResolver¶
pathResolver is available in every template. Its GetPath(filename, type) method returns the path that HAProxy should use to reference an auxiliary file (map, error file, certificate, crt-list). Use it instead of writing paths by hand so the controller and HAProxy agree on where files live.
Arguments: filename (string), type (one of "map", "file", "cert", "crt-list")
{# Map files — resolves to maps/host.map #}
use_backend %[req.hdr(host),lower,map({{ pathResolver.GetPath("host.map", "map") }})]
{# General files — resolves to general/504.http #}
errorfile 504 {{ pathResolver.GetPath("504.http", "file") }}
{# SSL certificates — resolves to ssl/example_com.pem (dots in cert/crt-list names are sanitized to _) #}
bind *:443 ssl crt {{ pathResolver.GetPath("example.com.pem", "cert") }}
{# crt-list files — resolves to general/cert-list.txt #}
bind *:443 ssl crt-list {{ pathResolver.GetPath("cert-list.txt", "crt-list") }}
GetPath returns paths relative to HAProxy's default-path. The bundled base
library sets this automatically. If you replace that library, include the
following directive in your global section so HAProxy can locate the files:
Status-patch functions¶
Templates register status patches with statusPatch(); the controller applies them to the resource's /status subresource via Server-Side Apply (SSA) after each reconciliation phase. The Templating Guide — Status Patches walks through the pattern with runnable examples.
statusPatch()¶
Registers a status patch for a Kubernetes resource with outcome-keyed variants. Pass the resource object itself; the function reads apiVersion, kind, metadata.namespace, metadata.name, metadata.uid, and metadata.resourceVersion generically. Each variant's value is the resource's .status content directly (for example conditions, loadBalancer) — the controller writes it under .status via SSA, so don't wrap it in another status key.
Parameters:
| Parameter | Type | Description |
|---|---|---|
resource |
resource object | The watched resource to patch — a typed resource or an unstructured resource map |
variants |
map[string]any |
Status payloads keyed by pipeline phase |
ownedLists (optional) |
map[string]any |
JSON pointers within .status, mapped to selectors identifying the list entries your template owns |
Variants:
| Key | Applied When |
|---|---|
rendered |
After successful template rendering (before deployment) |
deployed |
After successful HAProxy deployment |
renderFailed |
When a later rendering phase fails |
deployFailed |
When HAProxy deployment fails |
Templates render all variants upfront. The controller selects the variant for the current outcome; pass the original watched resource to preserve its identity.
If another controller writes entries to the same list, declare which entries you own. HAPTIC replaces matching entries and preserves the others, rereading the resource if a concurrent update causes a conflict.
{{ statusPatch(resource, map[string]any{
"deployed": map[string]any{
"entries": []any{map[string]any{"writer": "my-controller", "ready": true}},
},
}, map[string]any{"/entries": map[string]any{"writer": "my-controller"}}) }}
Each selector must be a nonempty object. Every entry you supply for that list must match it. Pass an empty list to remove your entries while retaining other writers' entries. Omitting the list from a variant leaves it out of that patch.
condition()¶
Creates a metav1.Condition-compatible map.
Parameters: type, status, reason, message, observedGeneration, lastTransitionTime
{{ condition("Accepted", "True", "Accepted", "Resource is accepted", 1, "2024-01-01T00:00:00Z") | toJSON() }}
transitionTime()¶
Returns the correct lastTransitionTime for a condition: preserves the existing timestamp if the condition status hasn't changed, or returns the current time if it has changed or doesn't exist yet.
Parameters: existingConditions (the resource's existing conditions list), type, status.
For nested conditions, find the entry by its identity and owner before calling
transitionTime(). Its position in a list can change when other controllers
write status. For Gateway API routes, match controllerName and parentRef,
then pass that entry's conditions list.
Event functions¶
recordEvent()¶
Records a Kubernetes Warning Event for a watched resource. Pass the resource
object and inspect the resulting Event with kubectl describe <kind> <name> or
kubectl get events. Only the leader emits Events.
Parameters:
| Parameter | Type | Description |
|---|---|---|
resource |
resource object | The watched resource to record the Event against — a typed resource, a map, or an unstructured object (for example, an item from resources.ingresses.List()) |
reason |
string |
Short, machine-readable PascalCase reason (for example, RouteConflict) |
message |
string |
Human-readable description |
{% recordEvent(ingress,
"RouteConflict", "host \"" + rule.host + "\" path \"" + path.path + "\" is already served by another Ingress") %}
The Event is a side-effect only — the call renders nothing. Identical (resource, reason, message) tuples emitted during one render collapse into a single Event. The controller re-emits on every reconcile while the condition holds, so the standard Kubernetes Event aggregation keeps it fresh and it ages out (default TTL ~1 hour) once the template stops recording it. The bundled Ingress library uses this to surface route conflicts on the losing Ingress.
Typed resource types¶
With a schema loaded, resources.<name>.List() and .Fetch() return
[]*resources.<name>.T; .GetSingle() returns *resources.<name>.T or nil.
A top-level variable named <name> also exposes the typed resource list.
Use these types in macros, functions, slices, or type switches:
Nested types use the field path: resources.gateways.SpecListeners is the
listener type and resources.endpoints.endpoints is the EndpointSlice endpoint
type. The watch must have a schema defining that path.
JSON field names and generated Go names both work: gateway.metadata.name
and gateway.Metadata.Name. Generated names capitalize the first character;
apiVersion becomes ApiVersion, not APIVersion. Non-letter/digit characters
become underscores. Prefer JSON field names in your templates.
For examples and schema setup, see Read resources in templates.