feat(admin-config-tool): task 3.3 - xceadmin.push

push_config()/probe_config(): build/send Request{opcode:upgradeConfig}
and the direct-struct ProbeConfigTagRequest respectively, following
redirects to the primary for the former (ProbeConfigTagReply has no
redirect fields, so probe_config connects directly). Maps controller
status/reply-variant failures to typed results/errors. Tested against
a real local TLS mock controller built from the bundled schema, since
neither opcode has a live controller-side handler today (separately
documented, unrelated to this task).

Unblocked mid-task by bug-1461 (commit e53c086): controller.capnp had
a struct-name collision that miscompiled UpgradeConfigRequest.sha256/
.bytes, making this task's core request impossible to construct via
pycapnp until fixed at the schema root.
This commit is contained in:
Rongsong Shen 2026-07-22 09:30:54 +00:00
parent e53c086ec5
commit c33ef7d479
4 changed files with 455 additions and 10 deletions

View file

@ -0,0 +1,270 @@
"""Mock-controller tests for ``xceadmin.push`` (design.md, ``xceadmin.push``;
Requirements 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 3.1, 3.2, 3.3, 3.4, 6.1,
6.2, 6.3, 7.1, 7.2, 7.3).
``push_config``'s real controller counterpart is blocked on a separate,
already-documented controller-side dispatch-gate defect
(``ha_dispatch_gate()``/``cluster.c``'s inverted primary check --
spec.json ``blocked_on``), and ``probe_config``'s ``OPCode_probeConfigTag``
has no server dispatch case at all (``research.md``, "ProbeConfigTag
liveness"). Both are therefore tested against a real local TLS mock
server built from the actual bundled schema -- not a live cluster --
exactly as design.md's ``xceadmin.push`` Boundary Commitments anticipate.
"""
from __future__ import annotations
import ssl
import socket
import threading
from pathlib import Path
import pytest
from xcecommon import wire
from xcecommon.errors import ReplyDecodeError
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:
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(certfile=str(CERT_FILE), keyfile=str(KEY_FILE))
return context
class _MockController:
"""A one-shot TLS server: accepts one connection, hands the raw
accepted TLS socket to ``handler``, then closes.
"""
def __init__(self, handler):
self._handler = handler
self._listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._listener.bind(("127.0.0.1", 0))
self._listener.listen(1)
self.port = self._listener.getsockname()[1]
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
def _run(self) -> None:
try:
raw_conn, _ = self._listener.accept()
except OSError:
return
try:
tls_conn = _server_context().wrap_socket(raw_conn, server_side=True)
except (ssl.SSLError, OSError):
raw_conn.close()
return
try:
self._handler(tls_conn)
except OSError:
pass
finally:
tls_conn.close()
def close(self) -> None:
self._listener.close()
self._thread.join(timeout=5)
@pytest.fixture
def mock_controller():
servers: list[_MockController] = []
def _make(handler) -> _MockController:
srv = _MockController(handler)
servers.append(srv)
return srv
yield _make
for srv in servers:
srv.close()
@pytest.fixture
def cluster_bin(tmp_path):
path = tmp_path / "cluster.bin"
path.write_bytes(b"fake cluster config binary contents")
return str(path)
# ---------------------------------------------------------------------------
# push_config(): happy path, redirect-following, error mapping
# ---------------------------------------------------------------------------
def test_push_config_successful_commit(mock_controller, cluster_bin):
mod = load_schema()
received = {}
def _handler(conn):
request_bytes = wire.recv_frame(conn)
received["request_bytes"] = request_bytes
with mod.Request.from_bytes(request_bytes) as req:
received["opcode"] = req.opcode
received["sha256"] = bytes(req.body.upgradeconfigrequestmsg.sha256)
received["bytes"] = bytes(req.body.upgradeconfigrequestmsg.bytes)
reply = mod.RequestReply.new_message()
reply.status = 0
upgrade_reply = reply.data.init("upgradeconfigreplymsg")
upgrade_reply.versionTag = 7
upgrade_reply.ackedCount = 3
upgrade_reply.error = ""
wire.send_frame(conn, reply.to_bytes())
srv = mock_controller(_handler)
result = push.push_config(cluster_bin, f"127.0.0.1:{srv.port}", CERT_DIR)
assert result.version_tag == 7
assert result.acked_count == 3
assert result.error == ""
assert received["opcode"] == "upgradeConfig"
assert received["bytes"] == Path(cluster_bin).read_bytes()
import hashlib
assert received["sha256"] == hashlib.sha256(Path(cluster_bin).read_bytes()).digest()
def test_push_config_follows_one_redirect_to_primary(mock_controller, cluster_bin):
mod = load_schema()
def _standby_handler(conn):
wire.recv_frame(conn)
reply = mod.RequestReply.new_message()
reply.status = 0
reply.redirectHost = "127.0.0.1"
# port filled in once the primary server exists, see below
reply.redirectPort = _standby_handler.primary_port
reply.data.nodata = None
wire.send_frame(conn, reply.to_bytes())
def _primary_handler(conn):
wire.recv_frame(conn)
reply = mod.RequestReply.new_message()
reply.status = 0
upgrade_reply = reply.data.init("upgradeconfigreplymsg")
upgrade_reply.versionTag = 9
upgrade_reply.ackedCount = 2
upgrade_reply.error = ""
wire.send_frame(conn, reply.to_bytes())
primary_srv = mock_controller(_primary_handler)
_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)
assert result.version_tag == 9
assert result.acked_count == 2
def test_push_config_non_zero_status_reports_error_not_exception(mock_controller, cluster_bin):
mod = load_schema()
def _handler(conn):
wire.recv_frame(conn)
reply = mod.RequestReply.new_message()
reply.status = 1
reply.data.debugmsg = "simulated failure"
wire.send_frame(conn, reply.to_bytes())
srv = mock_controller(_handler)
result = push.push_config(cluster_bin, f"127.0.0.1:{srv.port}", CERT_DIR)
assert result.acked_count == 0
assert "status 1" in result.error
def test_push_config_wrong_reply_variant_raises_reply_decode_error(mock_controller, cluster_bin):
mod = load_schema()
def _handler(conn):
wire.recv_frame(conn)
reply = mod.RequestReply.new_message()
reply.status = 0
reply.data.nodata = None # not upgradeconfigreplymsg
wire.send_frame(conn, reply.to_bytes())
srv = mock_controller(_handler)
with pytest.raises(ReplyDecodeError):
push.push_config(cluster_bin, f"127.0.0.1:{srv.port}", CERT_DIR)
def test_push_config_default_port_used_when_omitted():
host, port = push._split_controller("somehost")
assert host == "somehost"
assert port == 9991
def test_push_config_explicit_port_parsed():
host, port = push._split_controller("somehost:1234")
assert host == "somehost"
assert port == 1234
# ---------------------------------------------------------------------------
# probe_config(): dry-run against the mock controller
# ---------------------------------------------------------------------------
def test_probe_config_reports_current_and_would_be_generation(mock_controller, cluster_bin):
mod = load_schema()
received = {}
def _handler(conn):
request_bytes = wire.recv_frame(conn)
received["request_bytes"] = request_bytes
with mod.ProbeConfigTagRequest.from_bytes(request_bytes) as req:
received["name"] = req.name
reply = mod.ProbeConfigTagReply.new_message()
reply.configTag = 41
wire.send_frame(conn, reply.to_bytes())
srv = mock_controller(_handler)
result = push.probe_config(cluster_bin, f"127.0.0.1:{srv.port}", CERT_DIR)
assert result.current_generation == 41
assert result.would_be_generation == 42
assert result.accepted is True
assert result.reason == ""
assert received["name"] == "" # matches config_repl.c's xce_probe_config_tag exactly
def test_probe_config_zero_generation_still_accepted(mock_controller, cluster_bin):
"""A fresh cluster with no config committed yet (configTag == 0) is
still a successful probe -- there is no rejection signal on this
wire shape (module docstring); the caller decides significance.
"""
mod = load_schema()
def _handler(conn):
wire.recv_frame(conn)
reply = mod.ProbeConfigTagReply.new_message()
reply.configTag = 0
wire.send_frame(conn, reply.to_bytes())
srv = mock_controller(_handler)
result = push.probe_config(cluster_bin, f"127.0.0.1:{srv.port}", CERT_DIR)
assert result.current_generation == 0
assert result.would_be_generation == 1
assert result.accepted is True