#!/usr/bin/env python3 """Contract test: the runtime-map fast path (blocked IPs). Why this file exists -------------------- `add_ip_to_runtime_map()` and `remove_ip_from_runtime_map()` spent their whole existence sending add map #0 1 del map #0 to `/tmp/haproxy-cli` and returning True whenever socat exited 0. Neither command has ever worked. Two independent defects: * **No `@1` prefix.** `/tmp/haproxy-cli` is HAProxy's MASTER CLI socket; map commands are worker commands. The master answers `Unknown command: 'add', but maybe one of the following ones is a better match: ...` -- and **socat still exits 0**, so `result.returncode == 0` was true and the function logged "Added IP x to runtime map". * **`#0` is not a valid map id.** Ids are assigned at config-parse time and move on every config regeneration; on the live edge `blocked_ips.map` is id 37 and `trusted_ips.map` is 10. There is no id 0. Any hardcoded number is wrong -- the map must be referenced by its FILE PATH, which is what haproxy.cfg itself names in `map_ip(/etc/haproxy/blocked_ips.map,0)`. And a third silence that makes a response-body check alone insufficient: `@1 add map #0 1` returns an **empty body**, exit 0, and adds nothing anywhere -- while `@1 del map #0 ` answers `Unknown map identifier.`. The add path can therefore only be trusted after reading the entry back. IP blocking still worked, because `update_blocked_ips_map()` rewrites `/etc/haproxy/blocked_ips.map` and the callers reload HAProxy, which re-reads it. The FILE is authoritative; this is the no-reload fast path, and it has never once run while reporting that it did. What it enforces ---------------- 1. The command strings actually sent: `@1` prefix first, map referenced by PATH and never by `#`, and the value `1` that `map_ip(...,0) -m int gt 0` requires. 2. Every captured rejection is classified as FAILURE (returns False), not success -- including the two that carry no error text at all. 3. Success is only reported when the entry reads back in the state asked for. Not the exit status, not an empty reply. 4. No source in this repo builds a map command with a `#` reference. Comments may describe the old form; code may not use it. Runs fully offline: `_cli_send()` is replaced, so no socket, no socat, no HAProxy, no network. Running ------- python3 scripts/test-runtime-map-contract.py """ import ast import io import os import re import sys import glob import logging import tempfile import unittest 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) _LOG_DIR = tempfile.mkdtemp(prefix='haproxy-mgr-test-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) ) import haproxy_manager # noqa: E402 logging.getLogger().setLevel(logging.CRITICAL) MAP = haproxy_manager.BLOCKED_IPS_MAP_PATH IP = '192.0.2.77' # --------------------------------------------------------------------------- # Responses captured VERBATIM from the haproxy-manager container on the live # edge (HAProxy 3.0.11, 2026-08-22). socat exited 0 for every single one of # them, which is the entire reason none of this is decided on exit status. # --------------------------------------------------------------------------- # What the MASTER socket answers to an unprefixed `add map ...` -- i.e. the # reply the old code read as success. MASTER_REJECTS_ADD = """\ Unknown command: 'add', but maybe one of the following ones is a better match: @! : send a command to the process @master : send a command to the master process hard-reload : achieve a hard-reload (-st) of haproxy reload : achieve a soft-reload (-sf) of haproxy user : lower the level of the current CLI session to user help [] : list matching or all commands prompt [timed] : toggle interactive mode with prompt quit : disconnect """ MASTER_REJECTS_DEL = MASTER_REJECTS_ADD.replace("'add'", "'del'") # `@1 del map #0 ` / `@1 get map /etc/haproxy/nope.map `. UNKNOWN_MAP_IDENTIFIER = 'Unknown map identifier. Please use # or .\n' # `@1 add map ` with the value omitted (as the old docs showed). ADD_MAP_MISSING_VALUE = ( "'add map' expects three parameters (map identifier, key and value) or one " "parameter (map identifier) and a payload\n") # `@1 del map ` for a key the runtime map does not hold. KEY_NOT_FOUND = 'Key not found.\n' # A successful mutation. THIS IS THE WHOLE PROBLEM: it is byte-for-byte what # `@1 add map #0 1` also returns while adding nothing at all. MUTATION_OK = '' GET_FOUND = ('type=ip, case=sensitive, found=yes, idx=tree, key="%s", ' 'value="1", type="str"\n' % IP) GET_NOT_FOUND = 'type=ip, case=sensitive, found=no\n' # `@1 get map #0 ` -- "found", but with no value. haproxy.cfg matches with # `-m int gt 0`, so an entry like this does NOT block. GET_FOUND_NO_VALUE = ('type=ip, case=sensitive, found=yes, idx=tree, key="%s", ' 'value=none\n' % IP) SHOW_MAP_SAMPLE = ( '0x7f6e5a788700 101.36.109.130 1\n' '0x7f6e5a788780 101.47.140.218 1\n' '0x7f6e5a1fd500 %s 1\n' % IP) class FakeSocket(object): """Replaces _cli_send(). Records every command, answers from a script. `script` is a list of (substring, response) pairs, consulted in order; the first whose substring appears in the command wins. Anything unmatched is an explicit test bug, not a silent default. """ def __init__(self, script): self.script = script self.sent = [] def __call__(self, command, socket_path, timeout): self.sent.append(command) for needle, response in self.script: if needle in command: return response raise AssertionError('test script has no response for %r' % command) def with_socket(script): """Install a FakeSocket for the duration of a `with` block.""" class _Ctx(object): def __enter__(self): self.fake = FakeSocket(script) self._real = haproxy_manager._cli_send haproxy_manager._cli_send = self.fake return self.fake def __exit__(self, *exc): haproxy_manager._cli_send = self._real return False return _Ctx() # Every command the happy path needs, in the order the code issues them. HAPPY_ADD = [('get map', GET_FOUND), ('add map', MUTATION_OK)] HAPPY_DEL = [('get map', GET_NOT_FOUND), ('del map', MUTATION_OK)] class CommandsAreWellFormed(unittest.TestCase): """Guard 1: the exact bytes on the wire. Both original defects are visible here and nowhere else -- a `#0` map reference and a missing `@1` are perfectly ordinary-looking Python. """ def test_add_sends_worker_prefixed_path_referenced_command(self): with with_socket(HAPPY_ADD) as fake: self.assertTrue(haproxy_manager.add_ip_to_runtime_map(IP)) self.assertEqual(fake.sent[0], '@1 add map %s %s 1' % (MAP, IP)) def test_del_sends_worker_prefixed_path_referenced_command(self): with with_socket(HAPPY_DEL) as fake: self.assertTrue(haproxy_manager.remove_ip_from_runtime_map(IP)) self.assertEqual(fake.sent[0], '@1 del map %s %s' % (MAP, IP)) def test_every_command_is_tried_on_the_worker_first(self): """`/tmp/haproxy-cli` is the MASTER socket; bare map commands 404.""" for run in (lambda: haproxy_manager.add_ip_to_runtime_map(IP), lambda: haproxy_manager.remove_ip_from_runtime_map(IP)): with with_socket(HAPPY_ADD + HAPPY_DEL) as fake: run() for command in fake.sent: self.assertTrue(command.startswith('@1 '), '%r is missing the @1 worker prefix' % command) def test_no_command_references_a_map_by_id(self): """Map ids move on every config regeneration. Path, always.""" script = HAPPY_ADD + HAPPY_DEL + [('show map', SHOW_MAP_SAMPLE)] with with_socket(script) as fake: haproxy_manager.add_ip_to_runtime_map(IP) haproxy_manager.remove_ip_from_runtime_map(IP) haproxy_manager.runtime_map_keys(MAP) for command in fake.sent: self.assertNotRegex( command, r'\bmap\s+#', '%r references a map by id; ids are not stable' % command) self.assertIn(MAP, command, '%r does not name the map file' % command) def test_add_carries_the_value_the_config_matches_on(self): """`map_ip(...,0) -m int gt 0`: a valueless entry does not block.""" self.assertEqual(haproxy_manager.BLOCKED_IPS_MAP_VALUE, '1') with with_socket(HAPPY_ADD) as fake: haproxy_manager.add_ip_to_runtime_map(IP) self.assertTrue(fake.sent[0].endswith(' %s 1' % IP), '%r has no value; HAProxy rejects it' % fake.sent[0]) class RejectionIsFailure(unittest.TestCase): """Guard 2+3: nothing may report success unless the map really changed. Every response below was returned by the live socket with **exit code 0**. The old code returned True for all of them. """ def test_master_socket_rejection_of_add_is_failure(self): with with_socket([('get map', GET_NOT_FOUND), ('add map', MASTER_REJECTS_ADD)]): self.assertFalse(haproxy_manager.add_ip_to_runtime_map(IP)) def test_master_socket_rejection_of_del_is_failure(self): with with_socket([('get map', GET_FOUND), ('del map', MASTER_REJECTS_DEL)]): self.assertFalse(haproxy_manager.remove_ip_from_runtime_map(IP)) def test_unknown_map_identifier_is_failure(self): with with_socket([('get map', GET_NOT_FOUND), ('add map', UNKNOWN_MAP_IDENTIFIER)]): self.assertFalse(haproxy_manager.add_ip_to_runtime_map(IP)) with with_socket([('get map', GET_FOUND), ('del map', UNKNOWN_MAP_IDENTIFIER)]): self.assertFalse(haproxy_manager.remove_ip_from_runtime_map(IP)) def test_missing_value_rejection_is_failure(self): """Carries no marker word at all -- caught by 'a mutation says nothing'.""" with with_socket([('get map', GET_NOT_FOUND), ('add map', ADD_MAP_MISSING_VALUE)]): self.assertFalse(haproxy_manager.add_ip_to_runtime_map(IP)) def test_silent_noop_add_is_failure(self): """The `#0` failure mode: accepted, empty reply, nothing added. Nothing in the response distinguishes this from success. Only the read-back does -- which is why the read-back is not optional. """ with with_socket([('get map', GET_NOT_FOUND), ('add map', MUTATION_OK)]): self.assertFalse(haproxy_manager.add_ip_to_runtime_map(IP)) def test_add_that_lands_without_a_value_is_failure(self): with with_socket([('get map', GET_FOUND_NO_VALUE), ('add map', MUTATION_OK)]): self.assertFalse(haproxy_manager.add_ip_to_runtime_map(IP)) def test_del_that_leaves_the_key_behind_is_failure(self): with with_socket([('get map', GET_FOUND), ('del map', MUTATION_OK)]): self.assertFalse(haproxy_manager.remove_ip_from_runtime_map(IP)) def test_key_not_found_on_del_is_the_requested_end_state(self): """Not a failure: the runtime map already lacks the key.""" with with_socket([('get map', GET_NOT_FOUND), ('del map', KEY_NOT_FOUND)]): self.assertTrue(haproxy_manager.remove_ip_from_runtime_map(IP)) def test_verified_success_is_reported_as_success(self): with with_socket(HAPPY_ADD): self.assertTrue(haproxy_manager.add_ip_to_runtime_map(IP)) with with_socket(HAPPY_DEL): self.assertTrue(haproxy_manager.remove_ip_from_runtime_map(IP)) def test_a_runtime_failure_never_raises_into_the_request_handler(self): """The map file + reload still enforces the block; degrade, don't 500.""" with with_socket([('get map', GET_NOT_FOUND), ('add map', MASTER_REJECTS_ADD)]): self.assertIs(haproxy_manager.add_ip_to_runtime_map(IP), False) def test_mutations_are_checked_for_an_empty_body_not_a_marker_list(self): """_HAPROXY_CLI_ERROR_MARKERS can only know rejections already seen.""" with self.assertRaises(haproxy_manager.HaproxyCliError): with with_socket([('add map', 'something nobody has ever seen\n')]): haproxy_manager.haproxy_cli('add map %s x 1' % MAP, worker=True, expect_empty=True) def test_the_new_error_markers_are_recognised(self): for response in (UNKNOWN_MAP_IDENTIFIER, KEY_NOT_FOUND, MASTER_REJECTS_ADD): self.assertTrue(haproxy_manager._cli_response_is_error(response), '%r must be classified as an error' % response[:40]) for response in (GET_FOUND, GET_NOT_FOUND, SHOW_MAP_SAMPLE): self.assertFalse(haproxy_manager._cli_response_is_error(response), '%r is data, not an error' % response[:40]) def test_socat_exit_zero_carries_no_information(self): """FakeSocket never signals failure any other way, and neither did socat.""" with with_socket([('get map', GET_NOT_FOUND), ('add map', MASTER_REJECTS_ADD)]) as fake: self.assertFalse(haproxy_manager.add_ip_to_runtime_map(IP)) self.assertTrue(fake.sent, 'the command was sent and "succeeded" at the ' 'process level; only the body says otherwise') class ReadBack(unittest.TestCase): """The read-back primitives the guarantees above rest on.""" def test_lookup_reports_found_with_value(self): with with_socket([('get map', GET_FOUND)]): self.assertEqual(haproxy_manager.runtime_map_lookup(MAP, IP), (True, '1')) def test_lookup_reports_not_found(self): with with_socket([('get map', GET_NOT_FOUND)]): self.assertEqual(haproxy_manager.runtime_map_lookup(MAP, IP), (False, None)) def test_lookup_raises_on_a_rejected_reference(self): with with_socket([('get map', UNKNOWN_MAP_IDENTIFIER)]): with self.assertRaises(haproxy_manager.HaproxyCliError): haproxy_manager.runtime_map_lookup(MAP, IP) def test_keys_parses_show_map_output(self): with with_socket([('show map', SHOW_MAP_SAMPLE)]): self.assertEqual( haproxy_manager.runtime_map_keys(MAP), {'101.36.109.130', '101.47.140.218', IP}) def test_empty_map_is_not_a_rejection(self): with with_socket([('show map', '')]): self.assertEqual(haproxy_manager.runtime_map_keys(MAP), set()) def test_rejected_show_map_still_raises(self): with with_socket([('show map', UNKNOWN_MAP_IDENTIFIER)]): with self.assertRaises(haproxy_manager.HaproxyCliError): haproxy_manager.runtime_map_keys(MAP) MAP_BY_ID_RE = re.compile(r'\b(?:add|del|clear|show|get)\s+map\s+#') class NoSourceBuildsAMapIdCommand(unittest.TestCase): """Guard 4: `map #` may be described in comments, never executed. Scanning string literals rather than raw text is deliberate -- the whole reason this bug is documented at length in the source is so the next reader does not reintroduce it, and a plain grep would fail on those comments. """ def test_no_python_string_literal_builds_a_map_id_command(self): tree = ast.parse(io.open('haproxy_manager.py', encoding='utf-8').read()) offenders = [ node.value for node in ast.walk(tree) if isinstance(node, ast.Constant) and isinstance(node.value, str) and MAP_BY_ID_RE.search(node.value) and not (ast.get_docstring(tree) == node.value) ] # Docstrings are string literals too; exclude any literal that is a # docstring of a module/class/function. docstrings = set() for node in ast.walk(tree): if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): doc = ast.get_docstring(node, clean=False) if doc: docstrings.add(doc) offenders = [o for o in offenders if o not in docstrings] self.assertEqual(offenders, [], 'these string literals build a map command with an ' 'unstable # reference') def test_no_shell_or_template_code_line_uses_a_map_id(self): targets = (glob.glob('scripts/*.sh') + glob.glob('templates/*.tpl') + ['Dockerfile']) offenders = [] for path in targets: if not os.path.exists(path): continue for lineno, line in enumerate( io.open(path, encoding='utf-8').read().splitlines(), 1): if line.lstrip().startswith('#'): continue # a comment describing the old form is fine if MAP_BY_ID_RE.search(line): offenders.append('%s:%d: %s' % (path, lineno, line.strip())) self.assertEqual(offenders, [], 'map ids are assigned at config-parse time and move; ' 'reference the map file by path') if __name__ == '__main__': unittest.main(verbosity=2)