ADR-0022: The HAPTIC agent replaces the Data Plane API; the controller decides, the agent applies¶
Status¶
Accepted 2026-08-18. Obsoletes ADR-0009. Supersedes ADR-0020 §Decision steps
1–3 (syntax parse, schema validation, haproxy -c before the deploy) for the
reconcile path; the webhook and the config-load gate keep all of it.
ADR-0013's Option 1 reasoning is restated in HAProxy's terms rather than the
API's. ADR-0013's spec.json reference is historical: the generated validators
survive only for the WASM playground.
Context¶
Route propagation is HAPTIC's remaining measurable gap: create an HTTPRoute →
first 200 takes ≈1 s tuned (seconds by default) against 10–30 ms for an xDS
control plane. The structural cause is that every route adds a backend
section, so every route change reloads HAProxy, and the reload path pushes the
whole config through the Data Plane API (DPAPI).
Measured 2026-08-17 (hack/spikes/, HAProxy 3.4.3, real chart renders):
| routes | DPAPI raw push, skip reload | DPAPI push + reload | write files + master reload |
|---|---|---|---|
| 300 | 199 ms | 396 ms | 203 ms |
| 1000 | 422 ms | 677 ms | 261 ms |
| 3000 | 1145 ms | 1602 ms | 487 ms |
The DPAPI push is CPU-bound in dataplaneapi: client-native parses the whole
config into its model, runs validate_cmd, writes, and parses again — it does
not store the bytes it is handed (handlers/configuration/raw/raw.go,
client-native/v6/configuration/raw.go, transaction.go). Its runtime
surface has no add backend/publish backend/del backend and no raw CLI
(v3.4.2, 2026-08-14), so HAProxy 3.4's dynamic backends — the mechanism that
makes a route add reload-free — are unreachable through it. HAPTIC uses a small
subset of the API and carries ~7.7 k hand-written lines plus ~30 k generated
per-version clients for it.
Two constraints shaped the replacement. RULE #2: validation is never traded
away; haproxy -c stays where operator input enters. And "no custom config
parsing": the config text is parsed only by HAProxy in production.
Decision¶
- The HAPTIC agent (
haptic agent, same image, theagentcontainer of every HAProxy pod) replaces the DPAPI. It owns the pod's file tree and the HAProxy sockets and exposes two calls:GET /v1/stateandPOST /v1/apply(pkg/dataplane/agent/api). - The controller decides, the agent executes. The render declares its
structure as a
renderplan.Plan(sections by token substitution, backend records, map entries, file kinds);deployplan.Diff(next, Baseline)classifies the change per pod intoruntime | file_only | reloadand composes typed ops. The agent writes the files transactionally, runs the ops verbatim on the workerstats socket, reloads through the master socket when told or as fallback, paces reloads, and reports. No HAProxy config parser exists in any production binary; client-native's parser survives only in the differential CI test and behind the playground build tag (depguardenforces it). - Validation moves, it does not shrink. The webhook and the config-load
gate keep the full
haproxy -c. The reconcile pipeline is render-only; the samehaproxy -c -drruns asynchronously inrendergate(leader-only, own semaphore slot). A failure reverts every pod that runs the failed plan to the agent's durable last-known-good set (mode: revert_lkg) and flips the gate to validate-before-dispatch until a render passes. HAProxy is the synchronous gate at apply: a rejected command or reload is a NACK, the old worker keeps serving, the agent restores the LKG files. The DPAPI schema check is dropped; it validated conformance to the DPAPI's model, which is no longer a requirement. Enterprise pods lose the per-pod DPAPIvalidate_cmd; the pod's own binary rejects a bad config at reload time, the journal rolls it back, the NACK carries HAProxy's message. - Hard cutover. Chart and controller upgrade together; removed values
failwith their replacement; nodpapi|agentmode flag. Version skew during the roll degrades to full-state + reload, never to a refusal. - Servers are named after pods and backends carry
guids, so endpoint churn isadd server/set server/deferreddel serveron every supported version and route add/remove isadd backend … from <profile>/del backendon 3.4. The SRV_n slot pool goes.
Alternatives considered¶
- Keep the DPAPI, add what is missing upstream. Rejected: the raw push is O(config) by construction and sits on the reload-free path; the missing runtime endpoints are not on the upstream roadmap.
- A smart agent that parses the config and decides runtime-vs-reload itself. Rejected by a judge panel: a classification bug would ship in the data plane, the parser would have to exist in production, and the controller already has the structure the render produced.
- Master-socket relay (
@1,@@1) instead of a worker socket. Rejected by measurement:@1 c1; c2relays onlyc1,@@is absent on 3.0/3.1, session state andwaitdo not hold. The chart'sglobalgains a workerstats socket; the master socket servesreload/show proconly. - gRPC / a raw CLI endpoint / TLS in the first version. Cut for simplicity; TLS is additive later.
Consequences¶
- Reconcile latency drops from render + validate + DPAPI push + reload to render + apply (map/cert/server changes in single-digit ms; a route add/remove reload-free on 3.4 once the chart's profiles land).
- ~86 k lines of DPAPI client, generated clients, comparator, orchestrator and
parser code are deleted; ~3 k lines of agent,
deployplanandrenderplanreplace them. - The template-author API (
Backend(),BackendServers(),RegisterMap()) carries the burden of declaring structure; it is strict-mode and documented with the chart's macros. - HAProxy pods pull the HAPTIC image;
haproxy.podSpec.imagePullSecretsdefaults to the controller's. - Metrics migrate (
haptic_dataplane_api_operations_total→haptic_deploy_apply_total{pod,mode}, …); the full table is the "Where the old metrics went" section of the Monitoring page.
The Enterprise coverage gap¶
tests/integration/enterprise_botmgmt_test.go is dropped with this cutover. It
pushed Enterprise-only sections — bot-management profiles, captchas, WAF
profiles and WAF global — through the DPAPI's model against an HAProxy
Enterprise pod, and it needed a HAPEE_KEY to pull that image and its modules.
What it proved has no successor and needs none: the DPAPI accepted or rejected those sections against its own schema, and that schema is gone. What replaces it is weaker in one specific way and stronger in another.
- Weaker: no CI job deploys an Enterprise binary any more, so no test observes an Enterprise-only directive being parsed. HAPTIC has no Enterprise image in CI and no licence to obtain one.
- Stronger: the pod's own binary is now the judge. An Enterprise directive
reaches an Enterprise pod verbatim, where it either parses or does not — where
the DPAPI's model could accept a section HAProxy Enterprise would reject, or
reject one it would accept. The reload-time rejection path, the journal
rollback and the NACK carrying HAProxy's own message are covered on Community
HAProxy by
tests/agentandtests/integration, and they are the same code on either edition.
The residual risk is an Enterprise-only deployment defect — the agent
mishandling something specific to that binary. Decision 12 of the cutover bounds
the known one: the agent container runs as the Enterprise image's haproxyUid
from a Community-based controller image, so the agent must not depend on a
passwd entry or $HOME. It does not, and the docker suite runs it with a
read-only root filesystem to keep it that way. Anything past that is untested
until an Enterprise image is available to CI.