ADR-0017: Library content moves to a HAProxyTemplateLibrary kind¶
Status¶
Accepted 2026-08-08. Supersedes ADR-0016's
spec.partial + ordered CRD_NAME mechanism. ADR-0016 never shipped in a
release: spec.partial landed in 30ce6540, after v0.1.0, and is absent from
v0.1.0's CRD. Only snapshot consumers of main are affected.
Context¶
ADR-0016 split the configuration across N HAProxyTemplateConfig objects, each
marked spec.partial, merged in CRD_NAME order. It solved the size problem
and created three others.
The completeness gate latched. configloader.record() asked whether every
configured name had been seen at least once. Once that flipped, a later
single-object event re-merged against the stale copies it still held, so a torn
apply rendered a mixed set with no signal. Libraries deliberately override one
another, so a missing member changes behaviour rather than removing it — a
config missing its WAF library renders successfully and serves traffic unarmed.
Merge order was stated three times. CRD_NAME on the Deployment, the set of
objects actually applied, and the chart's prepareLibraries evaluation all had
to agree. Nothing enforced that they did.
spec.partial waived the completeness rule for every object. Because eight
of nine chart objects are fragments, the CRD's CEL rule had to exempt them all,
so the apiserver could judge completeness for nothing the chart installs.
Measurement¶
Rendered chart defaults, 9 objects, 1.96 MB of spec:
| spec field | share |
|---|---|
templateSnippets |
62.1% |
validationTests |
31.8% |
| everything else | 6.1% |
94% of the bulk is two fields. Moving content out leaves ~120 KB — one object, with 92% of the etcd budget spare.
Decision¶
A new HAProxyTemplateLibrary kind carries template library content. One
HAProxyTemplateConfig references them through an ordered spec.libraryRefs.
It is named for the capability, not for one of its fields: a library defines
templateSnippets, validationTests, maps, files, sslCertificates,
k8sResources, templatingSettings and haproxyConfig — measured on the
bundled chart, seven of the eight are in use by at least one library and four
by four or more. "Template library" is also the term the chart already uses
(controller.templateLibraries.*, haptic.prepareLibraries, the
template-library component label), so the CRD and the values now read as one
idea.
spec:
libraryRefs:
- {name: haptic-config-base, revision: "base-43dc4467f7e88090"}
- {name: haptic-config-gateway, revision: "gateway-5da793f017afc1c5"}
Libraries carry content only — no podSelector, watchedResources,
dataplane, validators, controller or logging. A library cannot redefine
the controller's operational identity. templatingSettings is carried, since
libraries ship template-context defaults and the config merges last, so an
operator always wins.
The watchedResources union lands on the config, via the pre-existing
haptic.watchedResourcesUnion that the ClusterRole and the webhook already
consume.
The revision is compared, never recomputed. The writer stamps the same string on both sides in one apply; the controller only ever compares them.
| case | content | stamp | outcome |
|---|---|---|---|
kubectl edit a snippet |
changed | unchanged | matches → renders the edit |
| torn apply | mixed | mismatched | holds last-good |
| writer rewrites both | changed | changed | matches → renders |
Verifying a content hash would break row 1, which is the experimentation case this design exists to protect. It would also reintroduce a failure mode that cost a previous attempt dearly: the two sides hash different bytes once the apiserver prunes, defaults, or reorders fields.
A Helm release counter is not usable — .Release.Revision is always 1 under
helm template | kubectl apply and under Argo CD, so it would silently never
change for a large share of users. A content digest changes exactly when
content does, under every delivery method, which is why the chart uses one as
the source of an otherwise opaque string.
Merge order is declared once, in spec.libraryRefs. CRD_NAME returns to a
single name.
The CEL rule gets stronger, not weaker. podSelector and
watchedResources are now required unconditionally, because nothing else can
supply them. haproxyConfig is required unless libraryRefs names something
that can.
The controller stamps an ownerReference from the config onto each library
it references, so Argo CD and kubectl tree show the relationship and
helm uninstall cannot strand the content objects. The chart cannot do this —
an ownerReference needs the owner's UID, which does not exist until the config
is applied. It is best-effort: a failure logs and never blocks a valid
configuration from loading.
Consequences¶
Per-object size stops being a ceiling. Nothing binds one library to one
object: a library approaching the limit is partitioned by key across
…-gateway-1, …-gateway-2, and the parts merge identically because their keys
are disjoint. This is a mechanism any config author has, not a vendor
privilege — the same test ADR-0014 applied.
Document order is no longer merge order. Helm sorts rendered manifests by
kind, so a helm template stream lists every HAProxyTemplateConfig before any
HAProxyTemplateLibrary — the reverse. conversion.AssembleSources orders by
libraryRefs instead, and validate -f and the pre-rollout hook use it.
The _global accumulation and duplicate-name rules are unchanged. The
"only the last source may override" exemption now lands exactly on the object an
operator edits, because assembly appends the config after its snippets — the
override point stopped being a positional accident.
The Helm release Secret is untouched by this. It holds the rendered
manifest, so object count is irrelevant to it. It sits at 74.1% (default) /
85.1% (all libraries) of the 1 MiB limit and is gated by
make chart-size-check at 950,000 bytes. Splitting into separate Helm releases
per library was considered and rejected on 2026-08-08: it costs a lot of
convenience and is not needed yet.
Verification¶
make test6,563 tests; five loader tests cover missing ref, revision mismatch, in-place edit still renders, delete holds, config overrides snippetsmake lint0 findings, arch-go compliantmake lint-chart428 chart tests- rendered defaults: config 20,143 B (1.3% of the etcd limit), 8 snippets
objects, largest
gatewayat 53%, zero operational fields on any of them validate -fassembles the realhelm templatestream despite Helm's kind-sorted document order