#!/usr/bin/env python3 """Build gate: render the real HAProxy config and hand it to the real `haproxy -c`. Why this file exists -------------------- On 2026-08-14 a change to hap_listener.tpl rendered perfectly, passed every unit test in scripts/ (13 green), and was then rejected outright by HAProxy: [ALERT] config : parsing [/etc/haproxy/haproxy.cfg:...] : invalid arg 2 in converter 'regsub' : ... unexpected empty replacement string Nothing between "commit" and "running in production" would have caught it. The unit tests assert on the *text* of the rendered config with regexes, which tells you what the template says, never whether HAProxy will accept it. And scripts/test-config-rollback.py stubs the `haproxy` binary with a shell script that only rejects a literal sentinel token, so its "validation" has never parsed a single line of real HAProxy syntax. The failure mode this guards is not cosmetic. When haproxy.cfg is invalid, scripts/init.py refuses to start HAProxy but the container still comes up: ports 80/443 are unbound, every site on the host is down, and /health keeps answering 200 because the Flask API is fine. So: render the config through the SAME code path production uses (haproxy_manager.generate_config(), templates and all), then run the actual `haproxy -c` against the result and gate on its exit code. This runs as a RUN step in the Dockerfile, which means it also validates against the exact haproxy binary that ships in the image being built - note that the Dockerfile installs haproxy UNPINNED, so that binary can move under us between builds. Any syntax the new binary rejects now fails the build instead of failing at 3am on an edge node. What it covers -------------- * every template generate_config() touches, assembled in the real order * a domain with SSL + a backend, a wildcard domain, a cert-only domain with no backend, and two template_override backends * blocked-IP map entries (single IP and CIDR) * BOTH sides of the two conditional blocks in hap_listener.tpl - {%- if suspension_enabled %} and {%- if coraza_spoe_backend %} - because a syntax error inside a conditional ships undetected otherwise. Scenario "full" turns both on; scenario "default" leaves both off, which is the byte-identical-to-standalone shape. Warnings vs failures -------------------- `haproxy -c` emits warnings on a clean config here (at minimum "Can't load stats file" because /var/lib/haproxy/stats.dat doesn't exist at build time, plus assorted path_reg/ACL advisories). Those are NOT failures. This gate keys on the process EXIT CODE only, and dumps the full output when it is non-zero. Running ------- python3 scripts/validate-rendered-config.py Needs the real `haproxy` binary, the application's Python dependencies, and write access to /etc/haproxy (several templates reference files there by absolute path - see _REAL_PATH_NOTE below). Inside the image build all three hold. On a workstation, run it in the container instead. """ import logging import os import re import shutil import sqlite3 import subprocess import sys import tempfile MODULE_DIR = os.path.abspath( os.environ.get('HAPROXY_MANAGER_DIR', os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')) ) os.chdir(MODULE_DIR) sys.path.insert(0, MODULE_DIR) # Same trick the other suites use: haproxy_manager configures logging at import # time against /var/log/haproxy-manager.log. Redirect the handlers so this runs # without root and without polluting the image's log files. _LOG_DIR = tempfile.mkdtemp(prefix='haproxy-validate-logs-') _real_file_handler = logging.FileHandler logging.FileHandler = ( lambda filename, *a, **kw: _real_file_handler( os.path.join(_LOG_DIR, os.path.basename(filename)), *a, **kw) ) try: import haproxy_manager as hm # noqa: E402 finally: logging.FileHandler = _real_file_handler # The application logs a lot at INFO during a render, and legitimately logs at # ERROR about things that are only true in this harness ("no existing HAProxy # config on disk ... ROLLBACK IS NOT AVAILABLE" - correct, there is no live # config during a build). Silence it so the build log carries the gate's own # verdict and haproxy's output, which is what matters. logging.getLogger('haproxy_manager').setLevel(logging.CRITICAL) # _REAL_PATH_NOTE # --------------- # Most paths haproxy_manager writes to are module-level constants and are # redirected into a temp dir below. Two cannot be: # # /etc/haproxy/blocked_ips.map - hardcoded inside hap_listener.tpl's # map_ip() converter # /etc/haproxy/coraza-spoe.cfg - hardcoded in the `filter spoe engine` # line, and parsed by haproxy -c # # Redirecting the constants without editing the templates would just make # haproxy read a different (missing) file, so those two are left at their real # paths. Everything this script creates under /etc/haproxy is removed again on # exit; pre-existing files (the baked trusted_ips.*) are never touched. ETC_HAPROXY = '/etc/haproxy' # Files referenced with `-f` / map_ip() from the templates. A missing `-f` file # is a FATAL haproxy error, so a gate that didn't create these would fail for # reasons that have nothing to do with the config being tested. STUB_FILES = { os.path.join(ETC_HAPROXY, 'trusted_ips.list'): '# validation stub\n203.0.113.10\n', os.path.join(ETC_HAPROXY, 'trusted_ips.map'): '# validation stub\n203.0.113.11 1\n', os.path.join(ETC_HAPROXY, 'cloudflare_ips.list'): '# validation stub\n198.51.100.0/24\n', os.path.join(ETC_HAPROXY, 'trusted_proxies.list'): '# validation stub\n192.0.2.0/24\n', os.path.join(ETC_HAPROXY, 'wpadmin_gate_exempt.list'): '# validation stub\nexempt.example.test\n', os.path.join(ETC_HAPROXY, 'suspended_domains.list'): 'suspended.example.test\n', # `lf-file` on the Coraza deny rule; loaded at parse time. Present in the # image (COPY errors /haproxy/errors), stubbed for anything else. '/haproxy/errors/403-waf.html': '
blocked %[unique-id]\n', } # (suspension_enabled, coraza_spoe_backend) combinations to render + validate. SCENARIOS = ( ('default', {}), ('full', { 'HAPROXY_SUSPENSION_ENABLED': 'true', 'HAPROXY_CORAZA_SPOE_BACKEND': '127.0.0.1:9000', }), ) def log(msg): sys.stdout.write(f'[validate-config] {msg}\n') sys.stdout.flush() def fail(msg): sys.stderr.write(f'[validate-config] FAIL: {msg}\n') sys.stderr.flush() raise SystemExit(1) class CreatedFiles: """Tracks what we put on disk outside the temp dir so it can be removed. Two sources: files we create explicitly, and files generate_config() itself writes into /etc/haproxy (blocked_ips.map, coraza-spoe.cfg and their .backup copies). The latter are caught by diffing the directory listing, which also picks up anything a future change starts writing there. """ def __init__(self): self.explicit = [] self.etc_before = self._listdir(ETC_HAPROXY) @staticmethod def _listdir(path): try: return set(os.listdir(path)) except OSError: return set() def ensure(self, path, content): """Create path with content if it does not already exist.""" if os.path.exists(path): return os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, 'w') as fh: fh.write(content) os.chmod(path, 0o644) self.explicit.append(path) def cleanup(self): for path in self.explicit: try: os.unlink(path) except OSError: pass for name in self._listdir(ETC_HAPROXY) - self.etc_before: try: os.unlink(os.path.join(ETC_HAPROXY, name)) except OSError: pass def require_haproxy_binary(): """Fail closed. A gate that skips itself when the binary is missing is a gate that would have let the 2026-08-14 change through.""" path = shutil.which('haproxy') if not path: fail('no `haproxy` binary on PATH - this gate cannot validate anything. ' 'Run it inside the image (the Dockerfile installs haproxy).') version = subprocess.run([path, '-v'], capture_output=True, text=True) log(f'using {path}: {version.stdout.strip().splitlines()[0] if version.stdout else "unknown version"}') def make_self_signed_cert(certs_dir): """HAProxy loads every file in the `bind ... ssl crt