Skip to content

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.

apiVersion: haproxy-haptic.org/v1alpha1
kind: HAProxyTemplateConfig
metadata:
  name: haproxy-config
  namespace: default
spec:
  credentialsSecretRef:
    name: haproxy-credentials

  podSelector:
    matchLabels:
      app.kubernetes.io/component: loadbalancer

  watchedResources:
    ingresses:
      apiVersion: networking.k8s.io/v1
      resources: ingresses
      indexBy:
        - metadata.namespace
        - metadata.name

  haproxyConfig:
    template: |
      global
          daemon
      defaults
          timeout connect 5s
      frontend http
          bind *:80

Try it: add health checks to the servers

This config renders two backends from an inline list, but the generated server lines have no health checking — HAProxy keeps routing to a pod even after it dies. Add check to the generated server line so every server gets an active health check.

apiVersion: haproxy-haptic.org/v1alpha1
kind: HAProxyTemplateConfig
metadata:
  name: health-check-demo
spec:
  haproxyConfig:
    template: |
      global
        log stdout format raw local0
        daemon
      defaults
        mode http
        timeout connect 5s
        timeout client 30s
        timeout server 30s
      frontend http
        bind *:80
        default_backend web
      {%- var backends = []any{
        map[string]any{"name": "web", "servers": []any{"10.0.0.1:8080", "10.0.0.2:8080"}},
        map[string]any{"name": "api", "servers": []any{"10.0.1.5:9000"}},
      } %}
      {%- for _, be := range backends %}
      backend {{ be | dig("name") | tostring() }}
      {%- for i, addr := range be | dig("servers") | toSlice() %}
        server srv{{ i }} {{ addr | tostring() }}
      {%- end %}
      {%- end %}
Peek at the solution

Append check to the server line inside the loop, so HAProxy health-checks each pod and stops sending traffic to unhealthy ones. Pair it with init-addr last when a server address is a DNS name, so HAProxy still starts if the name is briefly unresolvable.

apiVersion: haproxy-haptic.org/v1alpha1
kind: HAProxyTemplateConfig
metadata:
  name: health-check-demo
spec:
  haproxyConfig:
    template: |
      global
        log stdout format raw local0
        daemon
      defaults
        mode http
        timeout connect 5s
        timeout client 30s
        timeout server 30s
      frontend http
        bind *:80
        default_backend web
      {%- var backends = []any{
        map[string]any{"name": "web", "servers": []any{"10.0.0.1:8080", "10.0.0.2:8080"}},
        map[string]any{"name": "api", "servers": []any{"10.0.1.5:9000"}},
      } %}
      {%- for _, be := range backends %}
      backend {{ be | dig("name") | tostring() }}
      {%- for i, addr := range be | dig("servers") | toSlice() %}
        server srv{{ i }} {{ addr | tostring() }} check
      {%- end %}
      {%- end %}

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 username
  • dataplane_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.

podSelector:
  matchLabels:
    app.kubernetes.io/component: loadbalancer

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.

controller:
  configPublishing:
    compressionThreshold: 1048576  # 1 MiB (default)
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 HAProxyCfg resource stores compressed content with spec.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.

logging:
  level: DEBUG  # TRACE, DEBUG, INFO, WARN, ERROR (case-insensitive)

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:

  • apiVersion and apiVersions are 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 / validationTests entry whose requires names 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 haproxyConfigresources, 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., podSelector vs pod_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:

  1. Admission webhook - Runs embedded validation tests (if webhook enabled)
  2. Controller startup - Validates configuration before starting
  3. CLI command - haptic-controller validate runs 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 validate before 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