Unreleased documentation. Choose your installed release in the version menu. Features described here may be absent from that release.
Read resources in templates¶
Use watched Kubernetes resources to decide what your templates generate. These examples show typed field access, filtering, and lookups across resource types. To add or change a watch, use Watch resources. For loops and conditionals, start with template syntax.
The resources variable¶
Each watchedResources key becomes a store under resources. For example,
the ingresses watch is available as resources.ingresses. Choose a method
based on what you need:
| Method | Result |
|---|---|
List() |
All objects in the watch |
Fetch(keys...) |
Objects whose index starts with those keys |
GetSingle(keys...) |
One matching object, or nil if none exists; multiple matches fail the render |
APIVersion() |
The API group/version the watch uses |
For a watch indexed by namespace and name:
{% for _, ingress := range resources.ingresses.Fetch("default") %}
# {{ ingress.metadata.name }}
{% end %}
This lists Ingresses in default. Use Fetch("default", "my-app") for that
specific Ingress. See watch indexing
for the configuration and matching rules.
Typed resource access¶
Read fields using their names in Kubernetes YAML, such as ingress.metadata.name
or ingress.spec.rules. HAPTIC checks those names against the resource's schema,
so a misspelled field fails template compilation.
{% for _, gateway := range resources.gateways.List() %}
# {{ gateway.metadata.namespace }}/{{ gateway.metadata.name }}: {{ len(gateway.spec.listeners) }} listeners
{% end %}
The controller loads schemas from the Kubernetes API server. For offline
validation, pass --schema-dir. The live
examples in these docs include schemas for the bundled resource types.
For resources without a schema, use dig() as shown in safe iteration.
The compiler also accepts generated Go field names such as Metadata.Name.
Use the typed-resource reference
when declaring macro parameters or passing resources through an any value.
Collection pipelines¶
Chain helpers to filter, flatten, or remove duplicates from resources. This example collects unique addresses from EndpointSlices whose endpoints name a target pod:
{%%
var addresses = resources.endpoints.List() |
flat_map(slice => slice.endpoints) |
reject(endpoint => endpoint.targetRef.name == "") |
flat_map(endpoint => endpoint.addresses) |
unique()
%%}
The values keep their types between stages, so field access still works and
misspelled names fail compilation. map produces one result per input;
flat_map combines the slices each call returns. Use filter to keep matching
items or reject to remove them. See the collection helper reference
for grouping and sorting.
x => expr¶
slice => slice.endpoints is a function of one argument. HAPTIC infers its input
and result types. Use func when the body needs more than one expression:
{% var names = pods | map(func(p *resources.pods.T) string {
if p.metadata.labels["app"] != "" { return p.metadata.labels["app"] }
return p.metadata.name
}) %}
For a multiline chain, use {%% %%} and put each pipe at the end of its line.
{{ }} expressions can't span lines. Use an explicit loop when you need
break, to register a file, or to record an Event.
Handle optional fields¶
Range an optional typed slice directly; an absent slice produces no iterations:
Don't wrap a typed slice in fallback(slice, []any{}): that loses the element
type and prevents typed field access in the loop. Use len(slice) > 0 to check
whether it contains entries.
For an optional object, use if to check whether any of its fields are set:
{% if ingress.spec.defaultBackend.service %}
# Default backend: {{ ingress.spec.defaultBackend.service.name }}
{% end %}
An absent object and an explicitly empty object both have the zero value of
the generated struct. Typed access doesn't distinguish them. Use not, and,
and or for conditions involving these objects; Go's !, &&, and ||
operators require Boolean values.
dig() returns nil for zero values in optional typed fields, allowing
fallback() to supply a default. It preserves zero values in required fields.
For an untyped map, a missing key returns nil, while an explicit empty value
remains empty.
To choose which fields identify a resource, configure
indexBy on its watch.
Common patterns¶
Reading a custom annotation¶
Custom annotations are the usual way to let application teams opt individual Ingresses into behavior your templates control, without a controller fork or a new release. Read the annotation off the resource and branch on its value.
The config below defines the haptic.example.com/balance annotation: when an Ingress carries it, its backend uses that load-balancing algorithm; otherwise it falls back to roundrobin. The shop Ingress sets leastconn; blog sets nothing. Run it, then edit either Ingress's annotation in the Resources panel and watch the balance line follow.
ingress.metadata.annotations is a typed map[string]string, so indexing an absent key returns "" — the algo != "" check covers both a missing annotation and an empty one. Pick an annotation prefix you own (here haptic.example.com/) so it can't collide with another controller's. The same read-and-branch pattern drives rate limits, header rewrites, custom ACLs — anything HAProxy can express. In the chart, place the snippet under a features-* or backend-directives-* extension point so the bundled libraries pick it up (see Template Libraries).
Servers named after their pods (avoid reloads)¶
This loop emits one server line per endpoint. Add an endpoint and run it again:
Plain text demonstrates the output but doesn't describe runtime operations to
HAPTIC. For reload-free updates, use the bundled BackendServers and Backend
macros: they record server identities and options as well as emitting text.
See Reload-free routing.
Put shared server options on default-server to avoid repeating them. HAPTIC
copies those options into runtime server-creation commands because HAProxy
doesn't inherit them during add server. Changing an existing server's options,
such as check or proto, still requires a reload; address, port, weight, and
maintenance-state changes can use the Runtime API.
Cross-Resource Lookups¶
Use an Ingress's namespace and backend Service name to find its EndpointSlices. Both keys matter: different namespaces can have Services with the same name. This example prints the matching addresses as comments in the output; the Ingress library handles production backend generation.
Fetch(namespace, serviceName) returns the EndpointSlices for that Service in
that namespace. The argument order matches indexBy; dots in label keys need
escaping. See watch indexing.
Safe Iteration¶
For an untyped list, use dig() to read the field and toSlice() to make it
safe to range over when absent. The second endpoint below has no addresses,
so it produces no server line. For typed resources, range the slice directly.
Filtering with conditionals¶
Test a field before you use it to skip resources that lack it. Only the map with an http field produces a backend line:
Challenge: Add health checks¶
Put the loop-and-dig pattern to work:
Challenge: Default a missing port¶
Combine dig() with fallback() to supply a default when a field is absent: