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

@ -1,11 +1,12 @@
"""Unit tests for ``xcecommon.wire`` (design.md, ``xcecommon.wire``;
Requirements 2.1, 2.3, 2.4, 5.2, 6.1, 7.1, 7.3, 7.6).
Uses a real local TLS loopback server (threading) wrapped with this
repo's existing ``ha/data/xce.crt``/``xce.key`` -- the same cert pair
``deploy-ha.sh`` already copies for controller-to-controller TLS -- so
these tests exercise the real trust model (no CA, ``SSL_VERIFY_NONE``),
not a synthetic substitute.
Uses a real local TLS loopback server (threading) wrapped with a
self-signed cert/key generated fresh per test session (see conftest.py's
``tls_certs`` fixture, same generation approach ``deploy-ha.sh`` uses) --
so these tests exercise the real trust model (no CA, ``SSL_VERIFY_NONE``,
no client certificate -- matching ``netio.c``'s client-side
``create_ssl_context(NULL, NULL)``), not a synthetic substitute.
"""
from __future__ import annotations
@ -21,19 +22,10 @@ import pytest
from xcecommon import wire
from xcecommon.errors import ConnectionTimeoutError, ReplyDecodeError
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"
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
@ -43,8 +35,10 @@ class _TlsEchoServer:
receives the wrapped :class:`ssl.SSLSocket`.
"""
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)
@ -58,7 +52,9 @@ class _TlsEchoServer:
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
@ -75,7 +71,7 @@ class _TlsEchoServer:
@pytest.fixture
def echo_server():
def echo_server(tls_certs):
"""A server that reads exactly one frame and writes it straight back."""
servers: list[_TlsEchoServer] = []
@ -91,7 +87,7 @@ def echo_server():
wire.send_frame(conn, payload)
def _make() -> _TlsEchoServer:
srv = _TlsEchoServer(_handler)
srv = _TlsEchoServer(_handler, tls_certs.cert_file, tls_certs.key_file)
servers.append(srv)
return srv
@ -100,9 +96,6 @@ def echo_server():
srv.close()
CERT_DIR = str(REPO_HA_DATA_DIR)
# ---------------------------------------------------------------------------
# connect()
# ---------------------------------------------------------------------------
@ -110,7 +103,7 @@ CERT_DIR = str(REPO_HA_DATA_DIR)
def test_connect_establishes_real_tls_session(echo_server):
srv = echo_server()
sock = wire.connect("127.0.0.1", srv.port, CERT_DIR, timeout_s=5.0)
sock = wire.connect("127.0.0.1", srv.port, timeout_s=5.0)
try:
assert isinstance(sock, ssl.SSLSocket)
assert sock.getpeername()[1] == srv.port
@ -125,7 +118,7 @@ def test_connect_refused_propagates_unwrapped():
listener.close() # nothing listens on this port now
with pytest.raises(ConnectionRefusedError):
wire.connect("127.0.0.1", port, CERT_DIR, timeout_s=5.0)
wire.connect("127.0.0.1", port, timeout_s=5.0)
def test_connect_handshake_timeout_raises_connection_timeout_error_and_closes_cleanly():
@ -151,7 +144,7 @@ def test_connect_handshake_timeout_raises_connection_timeout_error_and_closes_cl
thread.start()
try:
with pytest.raises(ConnectionTimeoutError) as exc_info:
wire.connect("127.0.0.1", port, CERT_DIR, timeout_s=0.3)
wire.connect("127.0.0.1", port, timeout_s=0.3)
assert exc_info.value.host == "127.0.0.1"
assert exc_info.value.port == port
@ -175,7 +168,7 @@ def test_connect_handshake_timeout_raises_connection_timeout_error_and_closes_cl
# ---------------------------------------------------------------------------
def test_send_frame_writes_exact_known_good_byte_sequence(echo_server):
def test_send_frame_writes_exact_known_good_byte_sequence(tls_certs):
"""Requirement: framing matches the controller's own byte-for-byte,
verified against a known-good byte sequence (not just a round-trip).
"""
@ -191,9 +184,9 @@ def test_send_frame_writes_exact_known_good_byte_sequence(echo_server):
break
captured.extend(chunk)
srv = _TlsEchoServer(_capture_handler)
srv = _TlsEchoServer(_capture_handler, tls_certs.cert_file, tls_certs.key_file)
try:
sock = wire.connect("127.0.0.1", srv.port, CERT_DIR, timeout_s=5.0)
sock = wire.connect("127.0.0.1", srv.port, timeout_s=5.0)
try:
wire.send_frame(sock, b"xce!")
finally:
@ -209,7 +202,7 @@ def test_send_frame_writes_exact_known_good_byte_sequence(echo_server):
def test_recv_frame_round_trips_arbitrary_payload(echo_server):
srv = echo_server()
sock = wire.connect("127.0.0.1", srv.port, CERT_DIR, timeout_s=5.0)
sock = wire.connect("127.0.0.1", srv.port, timeout_s=5.0)
try:
payload = b"\x00\x01\xffcapnp-shaped-bytes" * 100
wire.send_frame(sock, payload)
@ -220,7 +213,7 @@ def test_recv_frame_round_trips_arbitrary_payload(echo_server):
def test_recv_frame_round_trips_empty_payload(echo_server):
srv = echo_server()
sock = wire.connect("127.0.0.1", srv.port, CERT_DIR, timeout_s=5.0)
sock = wire.connect("127.0.0.1", srv.port, timeout_s=5.0)
try:
wire.send_frame(sock, b"")
assert wire.recv_frame(sock) == b""
@ -228,13 +221,13 @@ def test_recv_frame_round_trips_empty_payload(echo_server):
sock.close()
def test_recv_frame_raises_reply_decode_error_on_truncated_header():
def test_recv_frame_raises_reply_decode_error_on_truncated_header(tls_certs):
def _close_after_partial_header(conn: ssl.SSLSocket) -> None:
conn.sendall(b"\x01\x02\x03") # 3 of 8 header bytes, then hang up
srv = _TlsEchoServer(_close_after_partial_header)
srv = _TlsEchoServer(_close_after_partial_header, tls_certs.cert_file, tls_certs.key_file)
try:
sock = wire.connect("127.0.0.1", srv.port, CERT_DIR, timeout_s=5.0)
sock = wire.connect("127.0.0.1", srv.port, timeout_s=5.0)
try:
with pytest.raises(ReplyDecodeError):
wire.recv_frame(sock)
@ -244,14 +237,14 @@ def test_recv_frame_raises_reply_decode_error_on_truncated_header():
srv.close()
def test_recv_frame_raises_reply_decode_error_on_truncated_payload():
def test_recv_frame_raises_reply_decode_error_on_truncated_payload(tls_certs):
def _promise_more_than_sent(conn: ssl.SSLSocket) -> None:
conn.sendall(struct.pack("=Q", 100))
conn.sendall(b"only ten!") # 9 bytes, not 100 -- then hang up
srv = _TlsEchoServer(_promise_more_than_sent)
srv = _TlsEchoServer(_promise_more_than_sent, tls_certs.cert_file, tls_certs.key_file)
try:
sock = wire.connect("127.0.0.1", srv.port, CERT_DIR, timeout_s=5.0)
sock = wire.connect("127.0.0.1", srv.port, timeout_s=5.0)
try:
with pytest.raises(ReplyDecodeError):
wire.recv_frame(sock)
@ -261,13 +254,13 @@ def test_recv_frame_raises_reply_decode_error_on_truncated_payload():
srv.close()
def test_recv_frame_raises_reply_decode_error_on_implausible_length():
def test_recv_frame_raises_reply_decode_error_on_implausible_length(tls_certs):
def _claim_huge_length(conn: ssl.SSLSocket) -> None:
conn.sendall(struct.pack("=Q", wire._MAX_FRAME_BYTES + 1))
srv = _TlsEchoServer(_claim_huge_length)
srv = _TlsEchoServer(_claim_huge_length, tls_certs.cert_file, tls_certs.key_file)
try:
sock = wire.connect("127.0.0.1", srv.port, CERT_DIR, timeout_s=5.0)
sock = wire.connect("127.0.0.1", srv.port, timeout_s=5.0)
try:
with pytest.raises(ReplyDecodeError):
wire.recv_frame(sock)
@ -284,7 +277,7 @@ def test_recv_frame_raises_reply_decode_error_on_implausible_length():
def test_send_request_returns_exact_reply_bytes(echo_server):
srv = echo_server()
sock = wire.connect("127.0.0.1", srv.port, CERT_DIR, timeout_s=5.0)
sock = wire.connect("127.0.0.1", srv.port, timeout_s=5.0)
try:
reply = wire.send_request(sock, b"pretend-capnp-request-bytes")
assert reply == b"pretend-capnp-request-bytes"
@ -298,7 +291,7 @@ def test_send_request_is_opcode_agnostic_works_for_any_byte_shape(echo_server):
identically, since both are just bytes to this module.
"""
srv = echo_server()
sock = wire.connect("127.0.0.1", srv.port, CERT_DIR, timeout_s=5.0)
sock = wire.connect("127.0.0.1", srv.port, timeout_s=5.0)
try:
for payload in (b"", b"\x00", b"short", b"x" * 10_000):
reply = wire.send_request(sock, payload)