forked from lei.chen/myxce
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.
297 lines
8.9 KiB
Python
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
|