Skip to content

Watching Resources

spec.watchedResources tells the controller which Kubernetes resources to subscribe to and how to make them available inside templates. This page explains the mental model. For field types, defaults, and validation rules see CRD Reference.

Watch it end-to-end — a watched Ingress feeding the render:

When a schema is loaded — the norm in production, where the controller fetches it live from the kube-apiserver — watched resources arrive as strongly-typed values you navigate with dotted field access (ing.spec.rules), covered in Typed Access in Templates below. For the cases where you're working with untyped data — an inline map[string]any literal, a schema-less custom resource, or a genuinely-optional field that may be absent — navigate with dig(...) and supply a default with fallback(...) so a missing field never breaks the render. Try that below.

One service omits spec.port; give every server line a port, defaulting to 80 when the field is absent.

{%- var services = []any{
    map[string]any{"name": "api",   "spec": map[string]any{"port": 8080}},
    map[string]any{"name": "web",   "spec": map[string]any{"port": 3000}},
    map[string]any{"name": "cache", "spec": map[string]any{}},
} -%}
{% for _, svc := range services -%}
{%- var name = svc | dig("name") | fallback("") -%}
{#- TODO: cache has no spec.port  dig() returns nil and the port comes out blank -#}
{%- var port = svc | dig("spec", "port") -%}
server {{ name }} {{ name }}.svc:{{ port }}
{% end -%}
Peek at the solution

Keep the raw dig result, pipe it through fallback(80), and use a nil check to flag the line that was defaulted.

{%- var services = []any{
    map[string]any{"name": "api",   "spec": map[string]any{"port": 8080}},
    map[string]any{"name": "web",   "spec": map[string]any{"port": 3000}},
    map[string]any{"name": "cache", "spec": map[string]any{}},
} -%}
{% for _, svc := range services -%}
{%- var name = svc | dig("name") | fallback("") -%}
{%- var portVal = svc | dig("spec", "port") -%}
{%- var port = portVal | fallback(80) -%}
server {{ name }} {{ name }}.svc:{{ port }}{% if portVal == nil %}  # default port{% end %}
{% end -%}

Anatomy of an Entry

watchedResources:
  ingresses:                                # arbitrary key — appears in templates as resources.ingresses
    apiVersion: networking.k8s.io/v1        # GroupVersion of the watched resource
    resources: ingresses                    # plural name as it appears in the REST API
    indexBy:                                # JSONPaths that form the composite lookup key
      - metadata.namespace
      - metadata.name
    namespace: ""                           # pin to one namespace, or leave empty to watch cluster-wide
    labelSelector: ""                       # "app=myapp" — string, not matchLabels object
    enableValidationWebhook: false          # include this kind in the webhook fan-out
    store: full                             # "full" (default) or "on-demand"
    debounceInterval: ""                    # Go duration string; empty / invalid uses the 2s default

All selector fields are plain label-selector strings — the matchLabels/matchExpressions object form that Prometheus Operator and others use is not accepted here.

Two Store Types

Every entry uses one of two store backends. The choice controls memory footprint and rendering latency.

store: full (default) — MemoryStore

  • Keeps the full resource object in-process after trimming fields listed in watchedResourcesIgnoreFields.
  • .List(), .Fetch(...), .GetSingle(...) all resolve from memory with no API hit.
  • Right for anything the templates iterate over (Ingresses, Services, EndpointSlices, small ConfigMaps).

store: on-demand — CachedStore

  • Stores only the index keys in-process; fetches the full object lazily on .Fetch() / .GetSingle() and caches the result.
  • Cache TTL is auto-derived from dataplane.driftPreventionInterval (× 2.2) — it's not a user-configurable field.
  • Right for large, rarely-touched resources — TLS Secrets with 20 kB certificate bodies, ConfigMaps used only for a handful of entries.
  • .List() on a cached store forces a fetch for every reference; avoid it.

Use a mix: store: full for everything the templates iterate, store: on-demand for Secrets holding certificates or auth data.

Typed Access in Templates

Every entry is exposed to templates two equivalent ways when a schema is loaded for the resource:

  1. As a store under resources.<key>.List(), .Fetch(...), and .GetSingle(...) return typed pointers ([]*resources.<key>.T / *resources.<key>.T).
  2. As a typed top-level global named <key> — a typed slice of the same shape ([]*resources.<key>.T).

Both surfaces share the same typed pointer; iterating either way yields *resources.<key>.T with strongly-typed .Metadata.Namespace, .Spec.X, etc. Without a schema, both surfaces fall back to []any / map[string]any and the chart's dig()-based snippets work unchanged.

The typed shape comes from the resource's OpenAPI v3 schema. In production the controller fetches schemas live from the kube-apiserver. Offline (haptic-controller validate), schemas come from --schema-dir / HAPTIC_SCHEMA_DIR. The repo's tests/schemas/ directory bundles schemas for the chart's Gateway API CRDs, haptic CRDs, and the K8s built-ins it watches (Namespace, Service, Secret, EndpointSlice, Ingress) — --schema-dir tests/schemas unlocks typed access for every chart-watched resource.

A misspelled field name in a template fails when the controller boots (or when validate runs against a schema-dir), not at the next reconcile. The <key>.T type expression also works in macro signatures, type-switch case clauses (case *resources.<key>.T), and slice types for sharded rendering.

See Typed Resource Access for the field-name convention, type-switch dispatch pattern, when to prefer typed vs untyped access, and the worked-example snippet.

Indexing (indexBy)

The .Fetch() template method takes the index keys in the order you listed them. With:

indexBy: ["metadata.namespace", "metadata.name"]

these are all valid:

{% for _, ing := range resources.ingresses.Fetch("default", "my-app") %}  {# exact match #}
{% for _, ing := range resources.ingresses.Fetch("default") %}           {# prefix: all in namespace #}
{% for _, ing := range resources.ingresses.List() %}                     {# everything #}

Supply fewer keys than indexBy defines to get a prefix scan — useful for one-to-many relationships. Always returns an empty slice (never nil) so templates iterate safely.

Run the prefix scan below: three Ingresses across two namespaces, but Fetch("shop") returns only the two in shop.

apiVersion: haproxy-haptic.org/v1alpha1
kind: HAProxyTemplateConfig
metadata:
  name: indexby-demo
spec:
  watchedResources:
    ingresses:
      apiVersion: networking.k8s.io/v1
      resources: ingresses
      indexBy:
        - metadata.namespace
        - metadata.name
  haproxyConfig:
    template: |
      global
        log stdout format raw local0
      defaults
        mode http
        timeout connect 5s
        timeout client 30s
        timeout server 30s
      frontend http
        bind :80
        default_backend unmatched
      {%- for _, ing := range resources.ingresses.Fetch("shop") %}
      backend {{ ing.metadata.name }}
        server app {{ ing.metadata.name }}.svc:80
      {%- end %}
      backend unmatched
        http-request deny deny_status 404
apiVersion: v1
kind: List
items:
  - apiVersion: networking.k8s.io/v1
    kind: Ingress
    metadata:
      name: web
      namespace: shop
  - apiVersion: networking.k8s.io/v1
    kind: Ingress
    metadata:
      name: api
      namespace: shop
  - apiVersion: networking.k8s.io/v1
    kind: Ingress
    metadata:
      name: blog
      namespace: content

Canonical Index Shapes

Resource indexBy Why
Ingress / Service / ConfigMap ["metadata.namespace", "metadata.name"] Standard unique lookup
EndpointSlice ["metadata.labels.kubernetes\\.io/service-name"] One-to-many: many slices per Service
Secret (when sharded by type) ["metadata.namespace", "type"] Group TLS vs. basic-auth vs. opaque
Namespace-pinned resource ["metadata.name"] namespace: already narrows the watch

Escape dots in JSONPath keys that contain them (labels.kubernetes\\.io/service-name), otherwise the path parser reads the dot as a subfield separator.

.GetSingle() vs .Fetch()

  • GetSingle(...) returns a single object or nil — use when the index is unique and you want nil-safe access.
  • Fetch(...) always returns a slice — use in for loops and when the index may match multiple resources.
  • List() returns everything in the store — avoid on on-demand stores (fetches everything).

Narrowing the Watch

Three filters narrow what actually lands in the store:

  • namespace: — hard pin to a single namespace. Drops the need for metadata.namespace in indexBy.
  • labelSelector: — equality-only label-selector string applied to the resource itself ("app=myapp" or "app=nginx,env=prod"). Comma-separated key=value pairs only; set-based syntax ("tier in (frontend,api)", "!disabled") is not supported — pkg/controller/conversion.parseLabelSelector splits on , and =, dropping anything else.
  • fieldSelector: — a client-side JSONPath equality filter applied after the list is fetched (format "field.path=value", e.g. "spec.ingressClassName=haproxy"). Unlike Kubernetes' native field selectors it can target any field, not just the server-supported ones, because the watcher evaluates it itself (at the cost of fetching the full list first). A resource that stops matching is handled as a delete; one that starts matching, as an add. This is what the bundled ingress / gateway libraries use to scope by ingressClassName / gatewayClassName.

Watch the filter in action: two Ingresses reach the playground, but only the haptic-class one survives the fieldSelector and reaches a backend.

apiVersion: haproxy-haptic.org/v1alpha1
kind: HAProxyTemplateConfig
metadata:
  name: fieldselector-demo
spec:
  watchedResources:
    ingresses:
      apiVersion: networking.k8s.io/v1
      resources: ingresses
      fieldSelector: "spec.ingressClassName=haptic"
      indexBy:
        - metadata.namespace
        - metadata.name
  haproxyConfig:
    template: |
      global
        log stdout format raw local0
      defaults
        mode http
        timeout connect 5s
        timeout client 30s
        timeout server 30s
      frontend http
        bind :80
        default_backend unmatched
      {%- for _, ing := range resources.ingresses.List() %}
      backend {{ ing.metadata.name }}
        server app {{ ing.metadata.name }}.svc:80
      {%- end %}
      backend unmatched
        http-request deny deny_status 404
apiVersion: v1
kind: List
items:
  - apiVersion: networking.k8s.io/v1
    kind: Ingress
    metadata:
      name: shop
      namespace: default
    spec:
      ingressClassName: haptic
  - apiVersion: networking.k8s.io/v1
    kind: Ingress
    metadata:
      name: legacy
      namespace: default
    spec:
      ingressClassName: nginx

Need to scope by namespace labels rather than a single name? Watch the namespaces resource and gate inside the template, or run separate controller instances per scope.

Trimming Fields

spec.watchedResourcesIgnoreFields drops noisy subtrees before they're indexed, cutting memory use. Reasonable defaults:

watchedResourcesIgnoreFields:
  - metadata.managedFields
  - metadata.annotations['kubectl.kubernetes.io/last-applied-configuration']

Applies uniformly to every watched-resource store. Fields that are referenced by indexBy must not be trimmed.

HTTP Resources

Templates can fetch arbitrary HTTP content via the http.Fetch(url, opts) template function — a separate mechanism from Kubernetes watching. The controller auto-registers any URL that appears in an http.Fetch() call during template rendering, periodically refreshes it at a per-URL delay, and surfaces the cached body back to the template on the next render.

For fixture-based mocking during validation tests, set per-test httpResources directly on the test (spec.validationTests[].httpResources, sibling to fixtures — not nested inside it); see CRD Reference. There is no top-level spec.httpResources field.

Validating Webhook Scope

Setting enableValidationWebhook: true on an entry registers that kind with the admission webhook, so creates/updates are rendered against an overlay store before being accepted. Set it only on the kinds you actually want validated in-band — the default is off to avoid dragging unrelated kinds (e.g. EndpointSlice churn) through the webhook path.

Debounce Override

Each watcher uses a leading-edge refractory window to coalesce bursts of changes into a single store-update event before forwarding to the Reconciler. This is the only debounce layer — the Reconciler itself fires immediately on every event, and reload throttling lives in the deployer's minDeploymentInterval (see the architecture overview). The default is 2 seconds (set in pkg/k8s/types.DefaultDebounceInterval) and works well for most workloads. Override per-resource via debounceInterval (the bundled chart sets it to "0" on EndpointSlice so pod-IP rotations react instantly during rolling restarts):

watchedResources:
  httproutes:
    apiVersion: gateway.networking.k8s.io/v1
    resources: httproutes
    indexBy: ["metadata.namespace", "metadata.name"]
    debounceInterval: "500ms"   # react fast on canary rollouts
  endpointslices:
    apiVersion: discovery.k8s.io/v1
    resources: endpointslices
    indexBy: ["metadata.namespace", "metadata.labels.kubernetes\\.io/service-name"]
    debounceInterval: "30s"     # absorb endpoint churn on large clusters

Empty / invalid strings fall back to the 2s default silently — the validating webhook does not reject unparseable values, so a typo just leaves you with the default. Format is any Go duration string ("500ms", "10s", "1m30s", …); "0" disables debouncing so every change fires immediately.

Troubleshooting

Symptom Likely cause
.List() returns empty Controller hasn't finished initial sync — check haptic_reconciliation_total or kubectl logs … \| grep "initial sync"
.Fetch(ns, name) returns empty for a resource that exists indexBy doesn't match what you passed, or labelSelector / namespace: is filtering it out
OOMKilled on controller Switch large resources (TLS Secrets, big ConfigMaps) to store: on-demand; add watchedResourcesIgnoreFields entries
Template rendering slow, many API logs You're calling .List() on an on-demand store, or .Fetch() consistently missing the cache — profile with /debug/pprof/profile, consider store: full if the total size is modest
kubectl apply on CRD rejected with "watchedResources must be non-empty" The CRD schema requires at least one entry; see CRD Reference

See Also