ci: gate the build on a real haproxy -c of the rendered config
A config change can render perfectly, pass every unit test in scripts/, and
still be rejected outright by HAProxy. That happened on 2026-08-14: an inline
`regsub((^|/)wp-admin/.*,\1wp-login.php)` in a redirect location produced
"invalid arg 2 in converter 'regsub'". Thirteen tests were green. It was only
caught because someone built an image by hand and ran `haproxy -c`.
Nothing between commit and production would have stopped it. The unit suites
assert on the TEXT of the rendered config with regexes, which says what the
template emits, never whether HAProxy accepts it. test-config-rollback.py's
"validation" stubs the haproxy binary with a shell script that rejects one
sentinel token and has never parsed a line of real syntax. And
.gitea/workflows/build-push.yaml is checkout -> build -> push, with no tests
at all.
The production consequence is not a broken deploy, it is a silent outage:
init.py refuses to start HAProxy on an invalid config while the container
still comes up, so ports 80/443 are unbound, every site on the host is down,
and /health keeps answering 200.
scripts/validate-rendered-config.py renders the config through the real
generate_config() - every template, real order, both conditional branches
({%- if suspension_enabled %} and {%- if coraza_spoe_backend %}) rendered on
in one scenario and off in the other - creates the stub files the config
loads via `-f` (a missing one is a FATAL haproxy error and would be a false
failure), then runs `haproxy -c` and gates on its EXIT CODE. Warnings are
expected on a clean config ("Can't load stats file", path_reg advisories) and
are not failures; on a real failure the full haproxy output plus the offending
config lines go to the build log.
It runs as a Dockerfile RUN rather than a CI step so it cannot be skipped, so
it protects local builds too, and - the reason that matters most - so it
validates against the EXACT haproxy binary in the image being built. The
Dockerfile installs haproxy unpinned, so that binary moves between builds;
this turns "the new haproxy rejects our config" from a silent production risk
into a build failure. Gating in CI instead would also have meant splitting
build-push-action's single build-and-push step.
The six existing unit suites run in the same step. They had never run
anywhere automated either, and they cost about five seconds.
Verified both ways: the clean build passes and the gate's output appears in
the log; reintroducing the known-bad regsub into a copy of the template fails
the build with HAProxy's own "invalid arg 2 in converter 'regsub' : missing
arguments (got 1/2)".
This commit is contained in:
+31
@@ -46,6 +46,37 @@ COPY wpadmin_gate_exempt.list /haproxy/defaults/wpadmin_gate_exempt.list
|
||||
COPY errors /haproxy/errors
|
||||
RUN chmod +x /haproxy/scripts/*
|
||||
RUN pip install -r requirements.txt
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build gate: no image ships unless the real haproxy binary accepts the config
|
||||
# this image's templates actually produce.
|
||||
#
|
||||
# On 2026-08-14 a template change rendered fine, passed all 13 unit tests, and
|
||||
# was rejected by HAProxy ("invalid arg 2 in converter 'regsub'"). It was only
|
||||
# caught because someone built an image by hand and ran `haproxy -c`. Nothing
|
||||
# in the build or in CI would have stopped it: .gitea/workflows/build-push.yaml
|
||||
# is checkout -> build -> push, and test-config-rollback.py's "haproxy" is a
|
||||
# shell stub that only rejects a sentinel token. In production an invalid
|
||||
# haproxy.cfg means init.py refuses to start HAProxy while the container stays
|
||||
# Up - ports 80/443 unbound, every site on the host down, /health still 200.
|
||||
#
|
||||
# This lives in the Dockerfile rather than in the workflow deliberately:
|
||||
# * it cannot be skipped, and it protects local `docker build` too;
|
||||
# * no workflow restructuring (build-push-action builds and pushes in one
|
||||
# step, so gating in CI would mean splitting build from push);
|
||||
# * it validates against the EXACT haproxy binary in this image. Line 26
|
||||
# installs haproxy unpinned, so that binary moves between builds - this
|
||||
# turns "the new haproxy rejects our config" from a silent production
|
||||
# risk into a build failure.
|
||||
#
|
||||
# The unit suites run here too. They had never run anywhere automated either,
|
||||
# and they cost a few seconds.
|
||||
RUN python3 /haproxy/scripts/test-wpadmin-gate.py \
|
||||
&& python3 /haproxy/scripts/test-trusted-proxy-gate.py \
|
||||
&& python3 /haproxy/scripts/test-xmlrpc-rate-limit.py \
|
||||
&& python3 /haproxy/scripts/test-config-rollback.py \
|
||||
&& python3 /haproxy/scripts/test-cert-write-safety.py \
|
||||
&& python3 /haproxy/scripts/test-cert-scripts.py \
|
||||
&& python3 /haproxy/scripts/validate-rendered-config.py
|
||||
# Create log directories
|
||||
RUN mkdir -p /var/log && touch /var/log/haproxy-manager.log /var/log/haproxy-manager-errors.log
|
||||
RUN chmod 755 /var/log/haproxy-manager.log /var/log/haproxy-manager-errors.log
|
||||
|
||||
Reference in New Issue
Block a user