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.
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:
- As a store under
resources.<key>—.List(),.Fetch(...), and.GetSingle(...)return typed pointers ([]*resources.<key>.T/*resources.<key>.T). - 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:
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.
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 ornil— use when the index is unique and you want nil-safe access.Fetch(...)always returns a slice — use inforloops and when the index may match multiple resources.List()returns everything in the store — avoid onon-demandstores (fetches everything).
Narrowing the Watch¶
Three filters narrow what actually lands in the store:
namespace:— hard pin to a single namespace. Drops the need formetadata.namespaceinindexBy.labelSelector:— equality-only label-selector string applied to the resource itself ("app=myapp"or"app=nginx,env=prod"). Comma-separatedkey=valuepairs only; set-based syntax ("tier in (frontend,api)","!disabled") is not supported —pkg/controller/conversion.parseLabelSelectorsplits 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 byingressClassName/gatewayClassName.
Watch the filter in action: two Ingresses reach the playground, but only the haptic-class one survives the fieldSelector and reaches a backend.
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¶
- Bring-your-own-CRD example — a runnable, self-validating example: watch a custom CRD, route on it, and write status back, with no Go
- CRD Reference — field-level documentation
- Templating Guide — The
resourcesVariable —.List()/.Fetch()/.GetSingle()semantics from the template side - Performance — deciding when to narrow the watch versus scale the controller
pkg/k8sREADME — store implementation details