HAProxyTemplateConfig CRD Reference¶
Overview¶
The HAProxyTemplateConfig custom resource configures HAPTIC. It provides schema validation, status conditions, and embedded testing capabilities.
API Group: haproxy-haptic.org
API Version: v1alpha1
Kind: HAProxyTemplateConfig
Short Names: htplcfg, haptpl
The schema is deliberately resource-agnostic — you template whatever you watch, so it works on a bespoke CRD exactly as it does on Ingress.
▶ Open the custom-CRD example in the playground — HAPTIC templating any resource, not just Ingress.
Basic Example¶
Run the whole custom resource in your browser to watch it render to a minimal haproxy.cfg.
Try it: add health checks to the servers¶
Spec Fields¶
credentialsSecretRef (required)¶
References a Secret containing Dataplane API credentials.
credentialsSecretRef:
name: haproxy-credentials
namespace: default # Optional, defaults to config namespace
Required Secret keys:
dataplane_username- Dataplane API usernamedataplane_password- Dataplane API password
Credentials are used only for the production Dataplane API; config validation runs locally against the haproxy binary and needs no credentials.
podSelector (required)¶
Labels that identify which HAProxy pods the controller should manage. The Helm chart ships app.kubernetes.io/component: loadbalancer (plus dynamically-set app.kubernetes.io/name / app.kubernetes.io/instance); use any labels your HAProxy pods actually carry.
At least one label must be specified.
controller¶
Controller-level settings for leader election and config publishing.
controller:
leaderElection:
enabled: true
leaseName: "" # empty = defaults to "haptic-leader"; the Helm chart sets this to the release fullname
leaseDuration: 30s # default (DefaultLeaderElectionLeaseDuration)
renewDeadline: 20s # default (DefaultLeaderElectionRenewDeadline)
retryPeriod: 5s # default (DefaultLeaderElectionRetryPeriod)
Note
There is no reconciler-level debounce knob. The Reconciler fires immediately on every resource/HTTP event; batching is per-watcher (spec.watchedResources.<name>.debounceInterval, default 2s) and reload throttling is the deployer's spec.dataplane.minDeploymentInterval.
Note
These are the controller's built-in defaults from pkg/core/config/defaults.go — deliberately 2x the values kube-controller-manager and kube-scheduler ship with (15s/10s/2s), so the leader rides out multi-second API-server or CPU starvation stalls without losing the lease. The Helm chart sets the same values; setting any of these fields on the CRD only matters if you need different values (e.g. faster crash-failover, or clusters with significant clock skew).
See High Availability for leader election details.
configPublishing¶
Controls how rendered configurations are stored in HAProxyCfg CRD resources.
| Field | Type | Default | Description |
|---|---|---|---|
compressionThreshold |
int64 | 1048576 | Compress content when size exceeds this threshold (bytes). A value of 0 is treated as unset — the 1 MiB default applies (compression can't currently be disabled) |
How compression works:
- When HAProxy configuration exceeds the threshold, it's compressed using zstd and base64-encoded
- The
HAProxyCfgresource stores compressed content withspec.compressed: true - Reduces etcd storage and speeds up watch events for large configurations
Fetching decompressed content:
# View HAProxyCfg resources
kubectl get haproxycfg -n haptic
# Fetch and decompress content (requires zstd)
kubectl get haproxycfg <name> -n haptic -o jsonpath='{.spec.content}' | base64 -d | zstd -d
# If not compressed (spec.compressed is false), content is plain text
kubectl get haproxycfg <name> -n haptic -o jsonpath='{.spec.content}'
logging¶
Log level configuration.
If not set (empty string), the controller uses the LOG_LEVEL environment variable. If neither is set, defaults to INFO.
dataplane¶
Dataplane API connection, deployment, and validation settings.
dataplane:
port: 5555 # Dataplane API port (default 5555)
minDeploymentInterval: 2s # Minimum gap between deployments (default 2s)
driftPreventionInterval: 60s # Periodic redeploy to correct drift (default 60s)
deploymentTimeout: 30s # Safety net for lost deployments (default 30s)
configPublishInterval: 10s # Throttle for HAProxyCfg CRD republishes (default 10s)
reloadVerificationTimeout: 10s # Wait for HAProxy to confirm graceful reload (default 10s)
syncTimeout: 2m # Per-endpoint sync timeout (default 2m)
mapsDir: /etc/haproxy/maps # Used for both validation and deployment
sslCertsDir: /etc/haproxy/ssl # chart-set; the controller's built-in default is /etc/haproxy/certs
generalStorageDir: /etc/haproxy/general
configFile: /etc/haproxy/haproxy.cfg
The three *Dir paths are used by the controller's local haproxy -c validation step as well as for deployment — they must match the paths the Dataplane API server is configured to manage (configFile is used only by local validation; the Dataplane API manages its own config-file path). The Helm chart keeps them in sync by deriving both sides from a single set of chart values.
watchedResourcesIgnoreFields¶
JSONPath expressions for fields to remove from all watched resources.
watchedResourcesIgnoreFields:
- metadata.managedFields
- metadata.annotations['kubectl.kubernetes.io/last-applied-configuration']
Reduces memory usage by filtering unnecessary data.
watchedResources (required)¶
Defines which Kubernetes resources to watch.
watchedResources:
ingresses:
apiVersion: networking.k8s.io/v1
resources: ingresses
enableValidationWebhook: true # Optional
indexBy:
- metadata.namespace
- metadata.name
labelSelector: "app=myapp" # Optional, equality-only ("k=v[,k=v]"); set-based syntax not supported
fieldSelector: "spec.ingressClassName=haproxy" # Optional, client-side JSONPath equality ("field.path=value"); matches any field
store: full # or "on-demand" for cached store
debounceInterval: "" # Optional Go duration string; empty / invalid uses the 2s default, an explicit "0" disables debouncing
Instead of a single apiVersion, an entry can declare an ordered
apiVersions candidate list together with optional: true. The controller
resolves the entry to the first candidate the cluster serves — at startup
and again whenever a matching CRD is installed, upgraded, or removed — so
your configuration works across CRD releases without redeployment:
watchedResources:
tcproutes:
apiVersions:
- gateway.networking.k8s.io/v1
- gateway.networking.k8s.io/v1alpha2
optional: true # no served candidate → drop the watch, strip dependent features
resources: tcproutes
indexBy:
- metadata.namespace
- metadata.name
Rules:
apiVersionandapiVersionsare mutually exclusive; exactly one must be set.- A required entry (no
optional) whose candidates are all unserved fails startup with an error naming the resource — the controller retries and converges when the CRD appears. - An optional entry whose candidates are all unserved is dropped, and every
templateSnippets/validationTestsentry whoserequiresnames it is stripped from the effective configuration. - Templates read the resolved version via
resources.<name>.APIVersion(). - The current resolution is visible at
/debug/vars/effectiveConfigResolution.
See Watching Resources for detailed configuration.
templateSnippets¶
Reusable template fragments.
templateSnippets:
backend-name:
requires: [ingresses] # Optional: strip this snippet when the named optional watched resources are unavailable
template: |
ing_{{ ingress.metadata.namespace }}_{{ ingress.metadata.name }}
Include in templates: {{ render "backend-name" }}
requires entries must name watchedResources keys. A snippet that must
survive stripping may reach a stripped resource only through compile-safe
seams — render "..." default "", render_glob extension points, or shared
state — never a direct typed resources.<name> reference. validationTests
entries accept the same requires field.
maps¶
HAProxy map file templates.
maps:
host.map:
template: |
{% for _, ingress := range resources.ingresses.List() %}
{% for _, rule := range ingress.spec.rules %}
{{ rule.host }} {{ ingress.metadata.name }}_backend
{% end %}
{% end %}
Reference in config: {{ pathResolver.GetPath("host.map", "map") }}
files¶
General auxiliary files (error pages, etc.).
files:
503.http:
template: |
HTTP/1.1 503 Service Unavailable
<html><body><h1>503</h1></body></html>
Reference in config: errorfile 503 {{ pathResolver.GetPath("503.http", "file") }}
sslCertificates¶
SSL certificate templates.
sslCertificates:
example-com:
template: |
{% var secret = resources.secrets.GetSingle("default", "tls-cert") %}
{{ b64decode(secret.data["tls.crt"]) }}
{{ b64decode(secret.data["tls.key"]) }}
Reference in config: bind :443 ssl crt {{ pathResolver.GetPath("example-com", "cert") }}
k8sResources¶
Templates that emit Kubernetes resources for the controller to apply via Server-Side Apply. Each entry's rendered output is parsed as one or more YAML documents (multi-doc supported via --- separators); each document must declare apiVersion, kind, and metadata.name (plus metadata.namespace for namespaced kinds).
The controller injects an OwnerReference to the HAProxyTemplateConfig CR (controller=true, blockOwnerDeletion=true) on every full-ownership applied resource, so cascade-delete (e.g. helm uninstall) GCs the rendered objects. Resources that disappear from the rendered set across reconciliations are pruned. The applier respects the haproxy-haptic.org/ownership: partial annotation: when present on a rendered resource the SSA payload omits the managed-by label and the OwnerReference, the resource is excluded from the orphan-cleanup set, and the annotation itself is stripped before apply — useful for jointly-owned objects on which HAPTIC only contributes a subset of fields (Server-Side Apply's per-list-map-entry merge keeps each owner's contribution intact).
Templates have full access to the same engine context as haproxyConfig — resources, filters, templateSnippets, fileRegistry, extraContext, and the per-render shared cache — so a k8sResources template can render extension points (render_glob patterns) and read shared state populated by the main config template.
k8sResources:
edge-service:
template: |
apiVersion: v1
kind: Service
metadata:
name: edge
namespace: {{ extraContext["controllerNamespace"] }}
spec:
type: LoadBalancer
selector:
app.kubernetes.io/component: loadbalancer
ports:
- name: http
port: 80
targetPort: http
protocol: TCP
---
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
name: edge-default
namespace: {{ extraContext["controllerNamespace"] }}
labels:
kubernetes.io/service-name: edge
addressType: IPv4
endpoints:
- addresses: ["10.0.0.1"]
ports:
- name: http
port: 80
protocol: TCP
Use this when the resource shape derives from observed cluster state (Ingresses, Gateways, Endpoints, …); use the chart's own static templates/*.yaml for fixed install-time wiring (RBAC, the dataplane Service, etc.). The chart's libraries/base.yaml ships a canonical example: the haproxy-service entry that renders the user-facing HAProxy LoadBalancer Service from listener state.
haproxyConfig (required)¶
Main HAProxy configuration template.
haproxyConfig:
template: |
global
daemon
maxconn 4096
defaults
mode http
timeout connect 5s
frontend http
bind *:80
use_backend %[req.hdr(host),map({{ pathResolver.GetPath("host.map", "map") }})]
See Templating Guide for syntax and filters.
Every template-bearing entry (haproxyConfig, and each entry under maps, files, sslCertificates, and k8sResources) also accepts an optional postProcessing list applied to the rendered output:
haproxyConfig:
template: |
...
postProcessing:
- type: regex_replace # params: pattern, replace
params:
pattern: '\n{3,}'
replace: "\n\n"
# or a Scriggo transform — the rendered output is available as `input`:
# - type: template
# params:
# source: "{{ input }}"
templatingSettings¶
Template rendering configuration and custom variables.
templatingSettings:
extraContext:
debug:
enabled: true
verboseHeaders: false
environment: production
featureFlags:
rateLimiting: true
caching: false
customTimeout: 30
Fields:
| Field | Type | Required | Description |
|---|---|---|---|
extraContext |
map[string]any |
No | Custom variables, exposed to templates as the extraContext map. Read a key with extraContext["key"], or extraContext \| dig("key") \| fallback(default) when it may be unset |
engine |
string | No | Template engine. Only valid value (and default): scriggo |
Usage in templates:
Custom variables are exposed as the extraContext map. Read a key with bracket access, or dig + fallback when it might be absent:
{% if extraContext["environment"] == "production" %}
timeout client {{ extraContext | dig("customTimeout") | fallback("300") }}s
{% else %}
timeout client 300s
{% end %}
The extraContext field accepts any valid JSON value (strings, numbers, booleans, objects, arrays). This allows you to configure template behavior for different environments, enable feature flags, or inject custom metadata without modifying controller code.
See Templating Guide - Custom Template Variables for detailed examples and use cases.
validationTests¶
Embedded validation tests (optional; run by the admission webhook, the validate CLI, and the controller itself on config load).
validationTests:
test-basic-ingress:
description: Validate basic ingress routing
fixtures:
ingresses:
- apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: test-ingress
namespace: default
spec:
rules:
- host: example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: test-service
port:
number: 80
assertions:
- type: haproxy_valid
description: Generated config must be valid
- type: contains
target: haproxy.cfg
pattern: "example.com"
description: Config must include host
Tests accept the same requires field as templateSnippets:
when an optional watched resource named there is unavailable, the test is
stripped from the effective configuration at load time.
Tests additionally accept requiresFields — a list of schema field paths in
the form <watchedResourceKey>.<field.path>:
validationTests:
test-httproute-cors-filter:
requires: [httproutes]
requiresFields: [httproutes.spec.rules.filters.cors]
# ...
When any listed field is absent from the resolved schema generation of its
watched resource, the test is stripped at load time. This covers clusters
that serve the resource at the same API version as newer releases but with
an older schema generation lacking the field (for example, Gateway API v1.1
serves httproutes at v1 without the CORS filter — the apiserver prunes
the field from fixtures, the feature never activates, and without stripping
the test would fail the fail-closed load gate). The first dot-segment must
name a watchedResources key; array levels in the remaining path are
descended transparently (spec.rules.filters.cors matches the field inside
the rules[] / filters[] items). The current stripping outcome is visible
at /debug/vars/effectiveConfigResolution.
Beyond description/fixtures/assertions/requires/requiresFields, each test also accepts httpResources (mocked responses for http.Fetch() calls), currentConfig (a simulated live HAProxy config for runtime-context assertions), extraContext (per-test overrides of templatingSettings.extraContext), and minHAProxyVersion (skip the test on older HAProxy).
See Validation Tests for the full test-framework reference (fixtures, assertion types, CLI usage) and CRD & Validation Design for the design rationale.
migrationCoverage¶
Per-migration-source annotation coverage declarations (optional). Each entry names a source controller (source, unique), how to recognise resources it manages (detect.ingressClasses, detect.annotationPrefixes), and a map of the source's annotation keys to their migration classification (annotations). The controller treats this as opaque data — it's contributed by the template libraries, merged by the Helm chart, and consumed by tooling such as migrate-check; no entry influences rendering or reconciliation. See Migrating for the tooling that reads it.
validators¶
Pluggable validator sidecars consulted by the admission webhook (optional). Each entry names a validator (RFC 1123 label), points at a Unix domain socket inside the controller pod, and lists file-glob patterns matched against rendered file paths to decide which files to send to that validator.
validators:
- name: spoa-hub
socketPath: /var/run/haptic-validators/spoa-hub.sock
files:
- "/etc/haproxy-spoa-hub/*.toml"
timeoutMs: 5000 # optional per-call deadline
maxConnections: 4 # optional connection-pool ceiling
See Pluggable Validators for the wire protocol, sidecar wiring, and full field reference.
Status Subresource¶
The controller updates the status field with validation results. Real fields are documented in pkg/apis/haproxytemplate/v1alpha1/types_config.go:
status:
observedGeneration: 1 # tracks .metadata.generation
lastValidated: "2025-01-27T10:00:00Z" # last successful validation timestamp
validationStatus: Valid # Valid, Invalid, or Unknown
validationMessage: "All validation tests passed" # human-readable summary
validationErrors: # populated when Invalid; each entry names template + error context
- "haproxy.cfg: parse error at line 12: …"
conditions:
- type: Ready
status: "True"
reason: ValidationSucceeded
lastTransitionTime: "2025-01-27T10:00:00Z"
validationStatus is the printer column shown by kubectl get htplcfg.
Command-Line Management¶
View Configurations¶
# List all configs
kubectl get haproxytemplateconfig
kubectl get htplcfg # Short name
# View specific config
kubectl get htplcfg haproxy-config -o yaml
# Watch for changes
kubectl get htplcfg -w
Validate Before Applying¶
# Validate local file
haptic-controller validate -f haproxy-config.yaml
# Validate deployed config
kubectl get htplcfg -n haptic haproxy-config -o yaml > /tmp/haproxy-config.yaml
haptic-controller validate -f /tmp/haproxy-config.yaml
Edit Configuration¶
# Interactive edit
kubectl edit htplcfg haproxy-config
# Apply from file
kubectl apply -f haproxy-config.yaml
# Patch specific fields
kubectl patch htplcfg haproxy-config --type=merge -p '
spec:
logging:
level: DEBUG
'
Migration from ConfigMap¶
Earlier pre-release builds accepted configuration as a ConfigMap with snake_case field names. That path was removed before the first tagged release. If you're still on an unreleased build that ships the old format, the mapping is:
Old (ConfigMap):
apiVersion: v1
kind: ConfigMap
metadata:
name: haproxy-config
data:
config: |
pod_selector:
match_labels:
app: haproxy
# ... rest of YAML config
New (CRD):
apiVersion: haproxy-haptic.org/v1alpha1
kind: HAProxyTemplateConfig
metadata:
name: haproxy-config
spec:
credentialsSecretRef:
name: haproxy-credentials
podSelector:
matchLabels:
app: haproxy
# ... rest of configuration as spec fields
Key differences:
- Configuration is now strongly typed with validation
- Credentials moved to separate Secret reference
- Field names use camelCase (e.g.,
podSelectorvspod_selector) - Validation tests can be embedded inline
Validation¶
The CRD includes OpenAPI schema validation that checks:
- Required fields are present
- Field types are correct
- String lengths meet minimum/maximum requirements
- Integer values are within valid ranges
- Enum values match allowed options
Additional validation occurs when:
- Admission webhook - Runs embedded validation tests (if webhook enabled)
- Controller startup - Validates configuration before starting
- CLI command -
haptic-controller validateruns tests locally
Best Practices¶
Security:
- Never include credentials in the CRD - use credentialsSecretRef
- Restrict RBAC access to HAProxyTemplateConfig resources
- Use separate namespaces for controller and configs in multi-tenant scenarios
Organization:
- One HAProxyTemplateConfig per controller instance
- Use descriptive names that indicate purpose or environment
- Label configs for filtering:
environment: production
Testing:
- Include validation tests for critical routing paths
- Test with realistic fixtures, not toy examples
- Run
haptic-controller validatebefore applying changes - Use CI/CD to validate configs in pull requests
Templates:
- Use templateSnippets for reusable logic
- Keep haproxyConfig template focused on structure
- Comment complex template logic
- Test templates with various resource combinations
See Also¶
- Templating Guide — template syntax, filters, context variables
- Watching Resources — store types, indexing, selectors
- Validation Tests — writing and running embedded tests
- CRD & Validation Design — rationale behind the CRD shape and validation layers
- Getting Started — installation walkthrough