Skip to content

fix: make varnishParameters reach varnishd, and give ESI a real switch (#109) - #112

Merged
jensens merged 14 commits into
mainfrom
fix/varnish-parameters
Oct 5, 2026
Merged

jensens merged 14 commits into
mainfrom
fix/varnish-parameters

Conversation

@jensens

@jensens jensens commented Sep 30, 2026

Copy link
Copy Markdown
Member

Closes #109.

What was broken

Two layers, the second hidden behind the first:

  1. spec.varnishParameters went nowhere. The CRD field existed, was documented as "passed to varnishd via -p flags", and had a security blocklist — but no code ever emitted a -p arg. Nothing a user set could reach varnishd.
  2. ESI could never be enabled. The VCL generator gated ESI on varnishParameters["feature"] containing +esi. There is no such varnishd feature bit: varnishd -p feature=+esi answers Unknown feature bit (+esi) and refuses to start. Verified by hand against varnish:8.0.2. So the documented path to ESI would have crash-looped the cache, and the real esi_* bits only modify ESI behaviour — ESI itself is enabled solely by beresp.do_esi.

What this changes

  • Parameters reach varnishd: rendered as -p key=value, sorted by key. The sort is load-bearing rather than cosmetic: Go map order is random, and an unsorted arg slice would make the pod template differ on every reconcile and roll the StatefulSet forever.
  • ESI gets a first-class switch: new spec.esi.enabled. The impossible feature-token gate is gone, along with the tests that pinned its parsing. The do_esi block still fires only on the backend's standard Surrogate-Control: content="ESI/1.0"; bodies not starting with < additionally need the real bit feature: "+esi_disable_xml_check", which is now settable for the first time.
  • The key blocklist became a live security boundary, so it was hardened. vcc_allow_inline_c and cc_command" are blocked to prevent arbitrary C compilation from VCL, but the check was a bare map lookup: " cc_command"with a leading space walked straight past it. Harmless while parameters went nowhere; consequential the moment they reach a real process's argv. Keys must now match^[a-z][a-z0-9_]*$(the actual shape of varnishd parameter names), enforced **twice**: in the webhook, and by a CEL rule on the field itself so the gate also holds on a cluster that installed the CRDs without the admission webhook. The webhook'sfailurePolicy=fail` covers a webhook that is down, not one that was never installed.
  • ESI end-to-end test (suite: full, hence this PR's e2e-full label): a backend serving <esi:include> markup plus Surrogate-Control, a cache with esi.enabled and the real xml-check bit, and assertions that the fragment text actually appears in the assembled response and that an ESI child span carrying varnish.esi=true reaches the collector. The page body contains no copy of the fragment token, so the assertion cannot pass without varnish performing the include.

Verification

lint, unit, envtest (now including CEL-vs-webhook layer-distinguishing specs), helm unittest, both boundary/label checks, and zero CRD drift — all green. Each change was reviewed; reviewers independently reproduced the load-bearing proofs rather than trusting reports: the CEL backstop's non-vacuity (by stripping the validation and watching the test fall through to the webhook), the generator rewire (by reinstating the old token gate and watching the new tests fail), and the CRD codegen (by re-running controller-gen).

The ESI E2E has never executed — this PR's labelled run is its first.

Note

A stale bin/controller-gen-v0.20.1 in a local worktree actually contained v0.20.0, and the Makefile's go-install-tool reuses a cached binary by filename without checking its version, producing phantom CRD annotation drift. Local-only (bin/ is gitignored), but the cache check is a footgun worth knowing about.

Assisted-by: Claude Opus 5

spec.varnishParameters was documented as "runtime parameters passed to
varnishd via -p flags" and carried a webhook blocklist, but nothing
ever emitted -p args: the field was inert.

Add varnishParamArgs, appended after the fixed -j/-T/-S and -s storage
args in the varnish container's Args. Entries are sorted by key before
being rendered: Go map iteration order is randomized per process, so
an unsorted args slice would make the pod template differ on every
reconcile for unchanged params, and controllerutil.CreateOrUpdate
would roll the StatefulSet forever.

Covers both the pure helper (empty/nil, single, and a 7-key sorted
case that would almost certainly fail on an unsorted implementation)
and the reconcile path (args present, absent when unset, ordering
relative to the fixed and storage args).

Assisted-by: Claude Opus 5
The generator gated beresp.do_esi on VarnishParams["feature +esi"] — a
key that was never the intended convention, just an artifact, and
impossible to set correctly: varnishd's actual "feature" parameter
takes one comma-separated list of tokens (e.g.
"+esi,+esi_disable_xml_check"), not one map entry per flag.

Switch to parsing VarnishParams["feature"] as that comma-separated
list and matching the exact "+esi" token. A substring check
(strings.Contains(v, "+esi")) would be wrong: it also matches
"+esi_disable_xml_check", which would silently turn on ESI processing
for a param that only meant to disable XML sniffing.

Updates the two existing tests that used the old "feature +esi": "on"
shape to the real "feature": "+esi" convention, and adds two cases:
"+esi_disable_xml_check" alone must not enable do_esi (the substring
trap), and "+esi,+esi_disable_xml_check" together must.

Assisted-by: Claude Opus 5
…vention

Now that spec.varnishParameters actually reaches varnishd (-p args)
and ESI detection uses the real "feature" parameter, spell out the
convention where it was previously either wrong or entirely absent:

- CRD doc comment: bare parameter names as keys (e.g.
  thread_pool_min: "100"), the comma-separated "feature" list for ESI
  (feature: "+esi", or "+esi,+esi_disable_xml_check" when response
  bodies don't start with '<'), and the webhook blocklist.
- Regenerated config/crd/bases via `make manifests generate` and
  synced the manually-maintained chart copy at
  charts/cloud-vinyl/crds/vinylcache.yaml (plain cp, per this repo's
  established practice — see 78a5a3a).
- docs/sources/reference/vinylcache-spec.md had no varnishParameters
  section at all; added one with the same convention and examples.

Assisted-by: Claude Opus 5
…listed ones

forbiddenVarnishParams[k] was a bare, unnormalized map lookup. Before
this branch that was harmless because nothing consumed VarnishParams;
now varnishParamArgs (controller-side) renders the raw map key
straight into the varnish container's argv, which turns the blocklist
into a live security boundary — and an exact-string lookup is
bypassable with padding or case: "  cc_command", "cc_command ", or
"CC_COMMAND" all slip past forbiddenVarnishParams while still reaching
varnishd as (or close enough to) the blocked parameter.

Add isValidVarnishParamKey, an allowlist shape check
(^[a-z][a-z0-9_]*$, varnishd's own parameter-name shape) run
unconditionally in ValidateVinylCache alongside the existing blocklist
check, with its own error message. This closes the whole class of
normalization bypasses in one rule instead of trying to enumerate
them.

Tests (internal/webhook/vinylcache_validator_test.go): leading- and
trailing-space padded keys rejected, an uppercase key rejected, a
legitimate lowercase key accepted, a cleanly-blocked key still
rejected by the blocklist, and a blocked key with padding rejected —
specifically by the shape rule, since the blocklist's exact lookup
does not and should not catch it.

Documents the now-enforced key shape in the CRD field comment and
docs/sources/reference/vinylcache-spec.md, regenerates
config/crd/bases via `make manifests generate`, and syncs the
manually-maintained chart copy at charts/cloud-vinyl/crds/vinylcache.yaml
(plain cp, per this repo's established practice).

Assisted-by: Claude Opus 5
The varnishParameters example in vinylcache-spec.md listed
thread_pool_min, thread_pool_max, feature in YAML-insertion order and
then claimed a rendered -p line in that same order — but
varnishParamArgs sorts by key, so the real order is feature,
thread_pool_max, thread_pool_min (`f` < `t`, then `thread_pool_max` <
`thread_pool_min` since 'a' < 'i'). Fixed the rendered line to match
and kept the sorted-args sentence, since that's the point the example
is making.

Also pins an already-correct but previously untested behavior:
varnishd's feature list uses a leading '-' to explicitly disable a
feature, so "feature": "-esi" must not be mistaken for "+esi" by
hasVarnishFeature's exact-token match
(TestGenerate_ESI_ExplicitDisable_NoDoESI).

Assisted-by: Claude Opus 5
The webhook-only fix from the previous commit (isValidVarnishParamKey
in internal/webhook) has a gap: failurePolicy=fail on the
ValidatingWebhookConfiguration only protects against the webhook being
down at request time, not against it never having been installed at
all — a cluster that applies these CRDs without also applying the
operator's webhook configuration has no gate whatsoever on what
varnishParamArgs renders into varnishd's argv.

Add a CEL x-kubernetes-validations rule directly on
spec.varnishParameters (api/v1alpha1/vinylcache_types.go) via
+kubebuilder:validation:XValidation, enforcing the same
^[a-z][a-z0-9_]*$ shape at the API server's CRD schema layer —
independent of, and prior to, any admission webhook. Regenerated
config/crd/bases via `make manifests generate` (confirmed the
x-kubernetes-validations entry is present on the varnishParameters
property with the expected rule and message) and re-synced
charts/cloud-vinyl/crds/vinylcache.yaml (verified byte-identical).

Added two envtest specs to internal/webhook/v1alpha1's Ginkgo suite
(the layer that runs a real API server plus the real webhook
manager). Confirmed empirically, not just from Kubernetes docs, that
CRD schema/CEL validation executes before the validating admission
webhook chain is invoked: a shape-invalid-only key (THREAD_POOL_MIN)
is rejected with the CEL rule's own message and no "admission webhook
... denied the request" wrapper, proving ValidateVinylCache is never
reached for it; a shape-valid-but-blocklisted key (cc_command) is
rejected with that wrapper instead, since only the webhook's blocklist
catches it. Verified the first assertion isn't vacuous by temporarily
stripping the x-kubernetes-validations block from the generated CRD
and re-running: the same key then fell through to the webhook and
failed the test's "no admission webhook wrapper" assertion exactly as
expected, then restored the CRD and confirmed green again.

self.all(k, ...) does iterate a map's keys in CEL as documented; this
was exercised directly rather than assumed (the THREAD_POOL_MIN and
cc_command specs above only pass because the rule evaluates per-key).

Assisted-by: Claude Opus 5
…s exist

CLAUDE.md's envtest bullet said the webhook suite "boots envtest but
exercises no admission decision yet." That stopped being true with
P3's tracing-validation specs, and this branch added two more (CRD-level
CEL rejection vs. webhook rejection, in internal/webhook/v1alpha1's
Ginkgo suite) — so the note was actively misleading about the layer this
branch itself just exercised.

Updated the sentence to say what's actually covered (still keeping the
file's "say what a layer is for, not what it currently covers" framing,
since that principle holds regardless of today's spec count).

Assisted-by: Claude Opus 5
Unblocks the second half of #109: P3 proved ESI child spans at the unit
level against recorded VSL fixtures (NOTES.md's esi.txt section), but
no E2E was possible until spec.varnishParameters actually reached
varnishd (fixed earlier on this branch). This adds that E2E.

- e2e/fixtures/backends/esi-backend.yaml: a purpose-built ConfigMap+
  python:3.12-slim backend (ealen/echo-server cannot emit ESI markup).
  Serves /esi/* with Surrogate-Control: content="ESI/1.0" and a body
  that deliberately does NOT start with '<' (mirrors
  internal/tracer/vsl/testdata/truthbackend.py and NOTES.md's "Rig
  deviation" scenario exactly), and /frag -> "FRAGMENT".

- e2e/fixtures/vinylcaches/esi-cache.yaml: own CR name to avoid
  colliding with tracing/tracing-truth's caches. varnishParameters
  carries feature=+esi_disable_xml_check, the real varnishd token the
  non-'<' body needs.

  DELIBERATELY omits "+esi": the documented convention
  ({"feature": "+esi"} to gate generator.go's HasESI) turns out to be
  unusable against real varnishd — that map renders verbatim into
  varnishd's actual -p feature=... argument, and varnishd 8.0.2 has no
  "esi" feature bit at all. Verified by hand against a varnish:8.0.2
  container: `-p feature=+esi` refuses to start ("Unknown feature bit
  (+esi)"), and rejects the whole value even combined with a real
  token. Worked around via the pre-existing vcl.snippets.
  vclBackendResponse mechanism to set beresp.do_esi directly instead
  (also verified by hand: produces correct assembly, "before FRAGMENT
  after"). This is a real bug in code already on this branch
  (generator.go's HasESI gate); out of scope for a fixtures-only
  change, flagged here and in the fixture's own comment for a
  follow-up issue.

- e2e/tests/tracing-esi/chainsaw-test.yaml: suite: full. Reuses the
  sink+probe fixtures, transport-only retry on first traffic. Asserts
  assembly happened (-body-capture '(FRAGMENT)', proving inclusion
  rather than proxying — the raw backend body has no literal
  "FRAGMENT" text), the ESI child span (-span-name "varnish request"
  -attr varnish.esi=true), and a same-page parent span
  (url.path=/esi/page) as a sanity check. Trace-id linking between
  parent and child is NOT asserted: -assert-spans's stdout contract
  only ever prints a match count, never a matched span's trace/span
  id, so there is no non-fragile way to pull it back out for
  comparison; that claim is already pinned at the unit level.

Validated: python3 yaml.safe_load, hack/check-suite-labels.sh,
hack/check-e2e-boundary.sh, and `chainsaw lint test` all pass. First
execution is CI, under the e2e-full label — no local cluster run was
available here.

Assisted-by: Claude Opus 5
…vention

The previously documented way to enable ESI — {"feature": "+esi"} in
spec.varnishParameters, matched by the generator's HasESI gate — is
unusable against real varnishd: that map renders verbatim into
varnishd's actual "-p feature=..." argument, and real varnishd 8.0.2
has no "esi" feature bit at all. Verified by hand against a
varnish:8.0.2 container: `-p feature=+esi` refuses to start at all
("Unknown feature bit (+esi)"), rejecting the whole feature value even
combined with a real token like esi_disable_xml_check. Any CR using
the documented convention would crash-loop its StatefulSet before any
ESI traffic could ever run.

Add spec.esi.enabled as a first-class boolean field instead. It never
reaches varnishd's argv, so it carries none of the above risk.
spec.varnishParameters' "feature" key remains relevant alongside it
for the one real varnishd feature bit ESI sometimes needs
(esi_disable_xml_check, for response bodies that don't start with
'<') — that part of the old guidance was correct and is kept.

Regenerates config/crd/bases and the chart's CRD copy (make manifests
generate) to match.

Assisted-by: Claude Opus 5
Companion to the previous commit's api change: HasESI now reads
input.Spec.ESI.Enabled directly instead of exact-token-parsing
spec.varnishParameters["feature"] for "+esi". Deletes the now-dead
hasVarnishFeature helper.

Test changes:
- TestGenerate_ESI_NoVmodImport, TestGenerate_ESI_
  EnablesDoESIOnSurrogateControl: rewritten to set spec.esi.enabled
  instead of the old VarnishParams["feature"] map; same assertions.
- TestGenerate_NoESI_NoDoESI: unchanged, still passes (makeMinimalInput
  leaves esi.enabled unset/false).
- TestGenerate_ESI_DisableXMLCheckAlone_NoDoESI,
  TestGenerate_ESI_MultiValueFeatureList_EnablesDoESI,
  TestGenerate_ESI_ExplicitDisable_NoDoESI: DELETED. These existed
  solely to pin hasVarnishFeature's comma-separated-list/substring-trap/
  explicit-disable token parsing, a mechanism that no longer exists —
  there is nothing left for them to test.
- TestGenerate_ESI_VarnishParamsFeatureIgnored: ADDED. Pins that
  varnishParameters["feature"] — including the exact "+esi" token the
  removed convention asked for — now has no effect on do_esi at all;
  only spec.esi.enabled does. This is the concrete regression guard for
  the fix itself.

Assisted-by: Claude Opus 5
{"feature": "+esi"} was documented as how to turn ESI on; it crashes
real varnishd (see the api/generator commits earlier in this branch).
Removes that guidance, adds an "esi" section for the new
spec.esi.enabled field, and keeps varnishParameters' own example
limited to what's genuinely valid: thread_pool_min/max plus
feature=+esi_disable_xml_check (the one real varnishd feature bit ESI
sometimes still needs).

Assisted-by: Claude Opus 5
Follows up the earlier commit in this test's own history that flagged
{"feature": "+esi"} as broken against real varnishd and worked around
it via a vcl.snippets.vclBackendResponse injection. That workaround is
no longer needed: spec.esi.enabled (added earlier on this branch) is
now the real, supported way to turn ESI on.

esi-cache.yaml: esi.enabled: true replaces the vclBackendResponse
snippet; varnishParameters keeps feature=+esi_disable_xml_check, the
real token the backend's non-'<' body still needs. This combination is
the first fixture in the repo exercising both the new field and the
-p plumbing reaching varnishd for real, in one scenario.

chainsaw-test.yaml: description updated to match — drops the
BLOCKED-AND-WORKED-AROUND framing (the bug it described is fixed) and
explains the new field instead. Assertions, retry idiom, and budget
arithmetic are unchanged.

Re-validated: python3 yaml.safe_load, hack/check-suite-labels.sh,
hack/check-e2e-boundary.sh, and `chainsaw lint test` all pass.

Assisted-by: Claude Opus 5
…comment

TestVarnishParamArgs_SortedByKey used "feature": "+esi" as arbitrary
sort-order test data -- the exact value this branch declares
crash-inducing for varnishd. Swap it for a neutral real varnish
param (default_ttl) that preserves the pinned alphabetical order.

The do_esi comment in vcl_backend_response.vcl.tmpl called
esi_disable_xml_check "the esi feature flag enabled"; there is no
such bit. do_esi is meaningful on its own, and
feature=+esi_disable_xml_check only matters when a response body
doesn't start with '<'. Reword the comment to say so.

Assisted-by: Claude Opus 5
@jensens jensens added the e2e-full Run the full E2E suite on this PR label Sep 30, 2026
heading_anchors=0 means MyST never generates heading anchors for this
project, so [varnishParameters](#varnishparameters) and [esi](#esi)
(added alongside the varnishParameters/esi docs) resolved to nothing
and failed the `-W` docs build. Replace them with plain prose
pointers, matching the rest of this file which has no internal links.

Assisted-by: Claude Opus 5
@jensens
jensens merged commit 4826828 into main Oct 5, 2026
8 checks passed
@jensens
jensens deleted the fix/varnish-parameters branch October 5, 2026 09:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

e2e-full Run the full E2E suite on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

varnishParameters never reach varnishd, and the ESI import gate referenced a nonexistent vmod

1 participant