myxce/src/xceadmin/tests/test_cli.py
Rongsong Shen 915e6fdf64 fix(admin-config-tool): unwrap Request reply envelope before decoding as RequestReply
config push crashed on every real controller with a capnp KjException --
xce_reply() wraps every reply in an outer Request{body.replymsg} envelope,
but discovery.py/push.py decoded raw wire bytes directly as a bare
RequestReply. Adds a shared decode_reply() unwrap point used by both call
sites, and fixes the same wrong-shape assumption baked into task 5.6's
and test_cli.py's mock-server fixtures.
2026-07-30 09:43:56 +00:00

297 lines
8.9 KiB
Python

"""Tests for the xceadmin CLI (design.md, ``xceadmin.cli``; task 1.1's
package-skeleton coverage plus task 4.1's ``config build``/``config
push`` wiring; Requirements 1.2, 1.3, 2.2, 2.4, 5.4, 7.1, 7.2, 7.3, 7.4,
7.5, 7.6, 8.1, 8.2).
"""
from __future__ import annotations
import hashlib
import ssl
import socket
import threading
from pathlib import Path
import pytest
import yaml
from click.testing import CliRunner
from xcecommon.schema import load_schema
from xceadmin.cli import cli
def test_cli_help_exits_zero() -> None:
"""`xceadmin --help` must run cleanly and print usage text."""
runner = CliRunner()
result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0
assert "Usage:" in result.output
def test_cli_help_shows_config_command() -> None:
"""`config build`/`config push` are reachable from the top-level group
(4.1's core acceptance bullet)."""
runner = CliRunner()
result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0
assert "Commands:" in result.output
assert "config" in result.output
def test_cli_config_help_shows_build_and_push() -> None:
runner = CliRunner()
result = runner.invoke(cli, ["config", "--help"])
assert result.exit_code == 0
assert "build" in result.output
assert "push" in result.output
def test_cli_group_invoked_with_no_args_does_not_traceback() -> None:
"""Invoking the bare group with no subcommand exits cleanly via click's
own SystemExit (no subcommand given), not an unhandled traceback from
application code."""
runner = CliRunner()
result = runner.invoke(cli, [])
assert isinstance(result.exception, SystemExit)
assert "Usage:" in result.output
def test_xcecommon_is_importable_independent_of_xceadmin() -> None:
"""xcecommon must be importable on its own, with no xceadmin import."""
import xcecommon # noqa: F401 - import success is the assertion
# ---------------------------------------------------------------------------
# config build
# ---------------------------------------------------------------------------
def test_config_build_writes_file_and_prints_sha256_to_stderr(tmp_path):
(tmp_path / "xce.yaml").write_text(
yaml.safe_dump(
{"cluster_name": "eda1", "heartbeat_interval": 30, "inactive_heartbeat_count": 3}
)
)
out_path = tmp_path / "out.bin"
runner = CliRunner()
result = runner.invoke(cli, ["config", "build", str(tmp_path), "-o", str(out_path)])
assert result.exit_code == 0, result.output
assert out_path.is_file()
expected_digest = hashlib.sha256(out_path.read_bytes()).hexdigest()
assert expected_digest in result.stderr
def test_config_build_dash_writes_to_stdout(tmp_path):
"""Byte-exact stdout content is already covered at the unit level
(test_config_build.py::test_write_output_dash_writes_to_stdout_buffer)
-- click's CliRunner captures output as decoded text and is lossy for
arbitrary binary (confirmed: UTF-8 encode/decode does not round-trip
an arbitrary byte sequence), so this test only confirms the CLI wires
`-o -` through to write_output without erroring, not exact bytes.
"""
(tmp_path / "xce.yaml").write_text(
yaml.safe_dump(
{"cluster_name": "eda1", "heartbeat_interval": 30, "inactive_heartbeat_count": 3}
)
)
runner = CliRunner()
result = runner.invoke(cli, ["config", "build", str(tmp_path), "-o", "-"], color=False)
assert result.exit_code == 0, result.output
def test_config_build_schema_violation_exits_nonzero_with_message(tmp_path):
(tmp_path / "queues.yaml").write_text(yaml.safe_dump({"queues": [{"priority": 1}]})) # missing name
runner = CliRunner()
result = runner.invoke(cli, ["config", "build", str(tmp_path)])
assert result.exit_code != 0
assert not isinstance(result.exception, KeyError | AttributeError | TypeError)
assert result.output.strip() != ""
def test_config_build_missing_configs_dir_does_not_raw_traceback(tmp_path):
missing = tmp_path / "does-not-exist"
out_path = tmp_path / "out.bin"
runner = CliRunner()
result = runner.invoke(cli, ["config", "build", str(missing), "-o", str(out_path)])
assert result.exit_code == 0, "a nonexistent dir behaves like one with zero known files present (1.5)"
# ---------------------------------------------------------------------------
# config push / config push --dry-run (mock controller)
# ---------------------------------------------------------------------------
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))
return context
class _MockController:
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)
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(self._cert_file, self._key_file).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(tls_certs):
servers: list[_MockController] = []
def _make(handler) -> _MockController:
srv = _MockController(handler, tls_certs.cert_file, tls_certs.key_file)
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")
return str(path)
def test_config_push_success_exits_zero(mock_controller, cluster_bin, wrap_reply):
from xcecommon import wire
mod = load_schema()
def _handler(conn):
wire.recv_frame(conn)
reply = mod.RequestReply.new_message()
reply.status = 0
upgrade_reply = reply.data.init("upgradeconfigreplymsg")
upgrade_reply.versionTag = 5
upgrade_reply.ackedCount = 3
upgrade_reply.error = ""
wire.send_frame(conn, wrap_reply(reply))
srv = mock_controller(_handler)
runner = CliRunner()
result = runner.invoke(
cli,
["config", "push", cluster_bin, "--controller", f"127.0.0.1:{srv.port}"],
)
assert result.exit_code == 0, result.output
assert "versionTag=5" in result.output
assert "ackedCount=3" in result.output
def test_config_push_zero_acked_count_exits_nonzero(mock_controller, cluster_bin, wrap_reply):
from xcecommon import wire
mod = load_schema()
def _handler(conn):
wire.recv_frame(conn)
reply = mod.RequestReply.new_message()
reply.status = 0
upgrade_reply = reply.data.init("upgradeconfigreplymsg")
upgrade_reply.versionTag = 0
upgrade_reply.ackedCount = 0
upgrade_reply.error = "no quorum"
wire.send_frame(conn, wrap_reply(reply))
srv = mock_controller(_handler)
runner = CliRunner()
result = runner.invoke(
cli,
["config", "push", cluster_bin, "--controller", f"127.0.0.1:{srv.port}"],
)
assert result.exit_code != 0
assert "no quorum" in result.output
def test_config_push_connection_refused_exits_nonzero_not_raw_traceback(cluster_bin):
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.bind(("127.0.0.1", 0))
port = listener.getsockname()[1]
listener.close()
runner = CliRunner()
result = runner.invoke(
cli,
["config", "push", cluster_bin, "--controller", f"127.0.0.1:{port}"],
)
assert result.exit_code != 0
assert result.output.strip() != ""
def test_config_push_dry_run_reports_generations(mock_controller, cluster_bin):
from xcecommon import wire
mod = load_schema()
def _handler(conn):
wire.recv_frame(conn)
reply = mod.ProbeConfigTagReply.new_message()
reply.configTag = 4
wire.send_frame(conn, reply.to_bytes())
srv = mock_controller(_handler)
runner = CliRunner()
result = runner.invoke(
cli,
[
"config",
"push",
"--dry-run",
cluster_bin,
"--controller",
f"127.0.0.1:{srv.port}",
],
)
assert result.exit_code == 0, result.output
assert "generation 4→5" in result.output