feat(admin-config-tool): drop ha/data/ entirely, match real client TLS

deploy-ha.sh no longer takes a <data-dir> argument or --build-config
flag. Every deploy now generates a fresh self-signed TLS cert/key
(openssl, RSA 2048, 10y validity) directly into the deploy dir's
certs/, and builds cluster.bin directly into controller/ctrl-1/conf/
via xceadmin -- no intermediate staging directory, nothing preserved
across runs. run-ha-soak.sh (/xce-test-ha) updated to match: dropped
its own XCE_HA_DATADIR-based precondition entirely. Updated 4 callers
(run-ha-soak.sh, test-heartbeat-tolerance.sh, test-commit-durability.sh,
both xce-deploy-ha/xce-test-ha SKILL.md docs).

Separately: xcecommon.wire.connect() was loading a client-side TLS
cert, diverging from the real reference client -- netio.c's
create_ssl_context() presents no certificate at all when acting as a
client (cert_file/key_file both NULL, bare TLS_client_method()).
Fixed connect() to match, and since nothing else consumed cert_dir
(verify_mode=CERT_NONE throughout, no CA check either), removed it
end-to-end: discovery.follow_redirects(), push.push_config()/
probe_config(), and the --cert-dir CLI flag.

Server-side cert loading (mock TLS test servers) is unaffected --
moved into a shared session-scoped tls_certs fixture (new
tests/conftest.py, openssl-generated) replacing 3 duplicated
REPO_HA_DATA_DIR/skipif blocks. Net result: those tests no longer skip
under a hermetic nix build sandbox (which never had ha/data/) --
verified 165/165 passed, 0 skipped, via both meson compile and a full
nix build --option sandbox false .#xcecore_debug rebuild (previously
128 passed / 37 skipped there).

ha/data/{xce.crt,xce.key} removed from git tracking. Requirements
2.3/2.4 and 8.3/8.4/8.5 marked superseded in requirements.md with
dated correction notes.
This commit is contained in:
Rongsong Shen 2026-07-22 14:50:51 +00:00
parent db2b5f17f0
commit ee8473bd99
20 changed files with 244 additions and 297 deletions

View file

@ -26,20 +26,10 @@ from xcecommon.errors import ConnectionTimeoutError, NoPrimaryError, ReplyDecode
from xcecommon.schema import load_schema
from xceadmin import push
REPO_HA_DATA_DIR = Path(__file__).resolve().parents[3] / "ha" / "data"
CERT_FILE = REPO_HA_DATA_DIR / "xce.crt"
KEY_FILE = REPO_HA_DATA_DIR / "xce.key"
CERT_DIR = str(REPO_HA_DATA_DIR)
pytestmark = pytest.mark.skipif(
not (CERT_FILE.is_file() and KEY_FILE.is_file()),
reason="ha/data/xce.crt or ha/data/xce.key not present",
)
def _server_context() -> ssl.SSLContext:
def _server_context(cert_file: Path, key_file: Path) -> ssl.SSLContext:
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(certfile=str(CERT_FILE), keyfile=str(KEY_FILE))
context.load_cert_chain(certfile=str(cert_file), keyfile=str(key_file))
return context
@ -48,8 +38,10 @@ class _MockController:
accepted TLS socket to ``handler``, then closes.
"""
def __init__(self, handler):
def __init__(self, handler, cert_file: Path, key_file: Path):
self._handler = handler
self._cert_file = cert_file
self._key_file = key_file
self._listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._listener.bind(("127.0.0.1", 0))
self._listener.listen(1)
@ -63,7 +55,9 @@ class _MockController:
except OSError:
return
try:
tls_conn = _server_context().wrap_socket(raw_conn, server_side=True)
tls_conn = _server_context(self._cert_file, self._key_file).wrap_socket(
raw_conn, server_side=True
)
except (ssl.SSLError, OSError):
raw_conn.close()
return
@ -80,11 +74,11 @@ class _MockController:
@pytest.fixture
def mock_controller():
def mock_controller(tls_certs):
servers: list[_MockController] = []
def _make(handler) -> _MockController:
srv = _MockController(handler)
srv = _MockController(handler, tls_certs.cert_file, tls_certs.key_file)
servers.append(srv)
return srv
@ -127,7 +121,7 @@ def test_push_config_successful_commit(mock_controller, cluster_bin):
srv = mock_controller(_handler)
result = push.push_config(cluster_bin, f"127.0.0.1:{srv.port}", CERT_DIR)
result = push.push_config(cluster_bin, f"127.0.0.1:{srv.port}")
assert result.version_tag == 7
assert result.acked_count == 3
@ -166,7 +160,7 @@ def test_push_config_follows_one_redirect_to_primary(mock_controller, cluster_bi
_standby_handler.primary_port = primary_srv.port
standby_srv = mock_controller(_standby_handler)
result = push.push_config(cluster_bin, f"127.0.0.1:{standby_srv.port}", CERT_DIR)
result = push.push_config(cluster_bin, f"127.0.0.1:{standby_srv.port}")
assert result.version_tag == 9
assert result.acked_count == 2
@ -184,7 +178,7 @@ def test_push_config_non_zero_status_reports_error_not_exception(mock_controller
srv = mock_controller(_handler)
result = push.push_config(cluster_bin, f"127.0.0.1:{srv.port}", CERT_DIR)
result = push.push_config(cluster_bin, f"127.0.0.1:{srv.port}")
assert result.acked_count == 0
assert "status 1" in result.error
@ -203,7 +197,7 @@ def test_push_config_wrong_reply_variant_raises_reply_decode_error(mock_controll
srv = mock_controller(_handler)
with pytest.raises(ReplyDecodeError):
push.push_config(cluster_bin, f"127.0.0.1:{srv.port}", CERT_DIR)
push.push_config(cluster_bin, f"127.0.0.1:{srv.port}")
def test_push_config_default_port_used_when_omitted():
@ -239,7 +233,7 @@ def test_probe_config_reports_current_and_would_be_generation(mock_controller, c
srv = mock_controller(_handler)
result = push.probe_config(cluster_bin, f"127.0.0.1:{srv.port}", CERT_DIR)
result = push.probe_config(cluster_bin, f"127.0.0.1:{srv.port}")
assert result.current_generation == 41
assert result.would_be_generation == 42
@ -263,7 +257,7 @@ def test_probe_config_zero_generation_still_accepted(mock_controller, cluster_bi
srv = mock_controller(_handler)
result = push.probe_config(cluster_bin, f"127.0.0.1:{srv.port}", CERT_DIR)
result = push.probe_config(cluster_bin, f"127.0.0.1:{srv.port}")
assert result.current_generation == 0
assert result.would_be_generation == 1
@ -296,7 +290,7 @@ def test_probe_config_has_no_wire_level_rejection_signal_by_design(
srv = mock_controller(_handler)
result = push.probe_config(cluster_bin, f"127.0.0.1:{srv.port}", CERT_DIR)
result = push.probe_config(cluster_bin, f"127.0.0.1:{srv.port}")
assert result.accepted is True
assert result.reason == ""
@ -337,7 +331,7 @@ def test_push_config_propagates_too_many_redirects_end_to_end(mock_controller, c
servers["e"] = mock_controller(_make_redirect_handler(lambda: 1))
with pytest.raises(TooManyRedirectsError):
push.push_config(cluster_bin, f"127.0.0.1:{servers['a'].port}", CERT_DIR)
push.push_config(cluster_bin, f"127.0.0.1:{servers['a'].port}")
def test_push_config_propagates_no_primary_error_on_connection_timeout(cluster_bin, monkeypatch):
@ -349,10 +343,10 @@ def test_push_config_propagates_no_primary_error_on_connection_timeout(cluster_b
eventually raise on a genuine timeout.
"""
def _fake_connect(host, port, cert_dir, timeout_s=30.0):
def _fake_connect(host, port, timeout_s=30.0):
raise ConnectionTimeoutError(f"timeout connecting to {host}:{port}", host, port)
monkeypatch.setattr(wire, "connect", _fake_connect)
with pytest.raises(NoPrimaryError):
push.push_config(cluster_bin, "127.0.0.1:9991", CERT_DIR)
push.push_config(cluster_bin, "127.0.0.1:9991")