Unreleased documentation. Choose your installed release in the version menu. Features described here may be absent from that release.
Key design decisions¶
This page summarizes the current architecture. The linked Architecture Decision Records (ADRs) explain the alternatives and tradeoffs.
Configuration validation strategy¶
Decision: Let HAProxy validate its own configuration. Production binaries contain no HAProxy configuration parser or Dataplane API schema validator.
Admission and configuration loading run haproxy -c synchronously.
Reconciliation runs that check in the asynchronous render gate; a refusal holds
subsequent renders until one passes. Each pod's HAProxy binary must also accept
a reload. Configured auxiliary-file validators run before dispatch.
ADR-0022 defines these paths and the recovery behavior. See Pluggable validators for auxiliary-file validation.
Template engine selection¶
Decision: Use Scriggo (consumed via the gitlab.com/haproxy-haptic/scriggo
fork) as the template engine.
Scriggo provides Go-like control flow, macros, and template inheritance in a
Go library. Its virtual filesystem lets HAPTIC compile named snippets into one
program. HAPTIC's render_glob extension includes snippets matching a pattern,
which supports the chart's extension points.
Related: ADR-0010 records the
follow-on decision to expose watched resources as typed top-level globals
instead of dig() chains.
Kubernetes client architecture¶
Decision: Use client-go with SharedInformerFactory directly — no
controller framework.
The watched resource set is operator-defined and only known at runtime (Rule #1: the Go code is resource-agnostic), so a framework's generated per-kind scaffolding buys nothing. Direct informer usage keeps the controller in charge of informer lifecycle, custom indexing, and cache behaviour without fighting framework defaults.
Related: ADR-0012
refines the store layer behind the informers — store: on-demand kinds
project informer bodies down to metadata to bound memory.
Concurrency model¶
Decision: Use goroutines and channels for concurrent components, with cancellable contexts managed by the lifecycle registry.
Watchers debounce resource events. Deployment uses bounded per-pod concurrency. Components and blocking operations receive contexts so shutdown can cancel work and wait for it to finish.
Observability Integration¶
Decision: Prometheus metrics plus structured log/slog logging with
event correlation via the Event Commentator (see
below). Distributed tracing is out of scope —
the controller emits no OpenTelemetry spans.
The metrics adapter (pkg/controller/metrics) observes domain events and updates
Prometheus counters and histograms. The commentator groups related events into
log messages. See pkg/controller/metrics/README.md for the metric catalog.
HAProxy request tracing is separate: the chart can export spans from access logs
through Vector.
Error handling strategy¶
Decision: Wrapped errors (fmt.Errorf + %w) with a small set of
custom error types at package boundaries.
pkg/dataplane.ValidationError identifies the failed validation phase;
pkg/controller/pipeline.PipelineError identifies the failed pipeline stage.
Template errors distinguish compilation, rendering, timeouts, and missing
templates. Callers use errors.Is and errors.As to inspect wrapped causes.
Event-driven architecture¶
Decision: Components coordinate through a homegrown EventBus
(pkg/events): async pub/sub, scatter-gather requests, pre-start
buffering, and subscriptions scoped to leadership. Business
logic lives in pure libraries (pkg/templating, pkg/dataplane,
pkg/k8s) with no event dependencies; only pkg/controller contains event
adapters.
Decoupling is the point: publishers don't know their consumers, new
features subscribe to existing events, and observers can correlate events across components. Pure libraries stay testable without event
infrastructure. The bus API surface (typed and lossy subscription variants,
drop accounting, Publish semantics) is documented in
pkg/events/README.md.
Three boundaries of the pattern are recorded as ADRs:
- Rendering is synchronous, not an event adapter
(ADR-0001).
The leader-only Coordinator drives
Pipeline.Executeas one direct call and publishesTemplateRenderedEventitself. The event hop was removed because it added hot-path latency and made the sequence harder to reason about. On the admission path render andhaproxy -cstill produce a single atomic verdict, because the reply carries it; on the reconcile path the check moved into the leader-onlyrendergatecomponent precisely to keep it off that call stack (ADR-0022). - The HTTP store ↔ proposal validator hop stays event-driven (ADR-0006), even though it looks like the same single-publisher/single-subscriber shape ADR-0001 removed. There the async coupling is load-bearing: it decouples the refresh-timer cadence from multi-second validation latency.
- Domain events require a concrete payload consumer (ADR-0019). Generic tracing doesn't keep an event alive. An observability subscriber qualifies only when it emits an operator-visible log, metric, or debug state that the publisher doesn't already emit.
Two invariants keep the pattern safe in practice:
- Subscribe in constructors, before
EventBus.Start(). All components subscribe during construction; the bus buffers pre-start events and flushes them onStart(), so no component can miss an event published during startup. Timing-based fixes (sleeps) are banned. - Events are immutable facts. Constructors defensively copy slices and
maps, all
Eventmethods use pointer receivers (enforced by the customeventimmutabilitylinter intools/linters/), and consumers treat events as read-only.
Request-response pattern (scatter-gather)¶
Decision: Configuration validation coordinates through the EventBus's
scatter-gather Request() API rather than hand-rolled response aggregation.
Config validation needs all of several validators (structure, template
compilation, JSONPath) to approve before a config becomes active. The bus
broadcasts a ConfigValidationRequest, correlates responses by request ID,
and enforces the timeout — the ConfigChangeHandler
(pkg/controller/configchange) aggregates the verdicts into
ConfigValidatedEvent or ConfigInvalidEvent. The validators themselves
are thin adapters in pkg/controller/validator over pure functions, so
adding a validator is one constructor plus one name in the expected-responder
list. The full flow is diagrammed in
Sequence Diagrams → Configuration Validation Process.
The rule of thumb: use scatter-gather when multiple responders must answer (validation, distributed queries); use plain pub/sub for fire-and-forget notification and observability; use a direct function call when there is exactly one callee and no coordination — ADR-0001 is that rule applied to the renderer.
Event commentator pattern¶
Decision: A dedicated component (pkg/controller/commentator)
subscribes to the full event stream and produces domain-aware log lines,
instead of scattering log statements through business logic.
The commentator keeps a ring buffer of recent events, so a log line can say what a single call site can't — which change triggered this reconciliation, how long the end-to-end deployment took across component boundaries:
INFO Reconciliation started trigger_event=resource.index.updated debounce_duration_ms=234
INFO Deployment completed total_instances=3 succeeded=3 failed=0 total_duration_ms=456
It subscribes lossily (SubscribeLossy): commentary is observability, so
under backpressure dropping events is preferable to slowing publishers.
Cross-event domain knowledge lives in this one component, business logic
stays free of logging clutter, and new event types get commentary without
touching their publishers. See pkg/controller/commentator/README.md for
the implementation.