feat(admin-config-tool): task 4.1 - xceadmin.cli

Wire config build/config push --dry-run onto the click group.
Thin layer only: one XceAdminError dispatch surface plus an OSError
catch (ConnectionRefusedError and friends propagate unwrapped from
xcecommon.wire by design and need formatting here), and a
KeyboardInterrupt handler for a clean "cancelled" message (socket
cleanup already happens via push.py's own try/finally).
This commit is contained in:
Rongsong Shen 2026-07-22 09:40:48 +00:00
parent c33ef7d479
commit eeda32c696
3 changed files with 356 additions and 16 deletions

View file

@ -1,14 +1,29 @@
"""Tests for the xceadmin package skeleton (task 1.1).
Covers Requirement 5.4: xcecommon is importable as a standalone package
independent of xceadmin's CLI, and the xceadmin console-script entry point
is backed by a genuinely working (if currently empty) click group.
"""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
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)
def test_cli_help_exits_zero() -> None:
"""`xceadmin --help` must run cleanly and print usage text."""
@ -19,19 +34,30 @@ def test_cli_help_exits_zero() -> None:
assert "Usage:" in result.output
def test_cli_help_shows_zero_commands() -> None:
"""The click group has no subcommands yet (config build/push land later)."""
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:" not in result.output
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 registered yet), not an unhandled
traceback from application code."""
own SystemExit (no subcommand given), not an unhandled traceback from
application code."""
runner = CliRunner()
result = runner.invoke(cli, [])
@ -42,3 +68,239 @@ def test_cli_group_invoked_with_no_args_does_not_traceback() -> None:
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() -> 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):
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)
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",
)
@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")
return str(path)
def test_config_push_success_exits_zero(mock_controller, cluster_bin):
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, reply.to_bytes())
srv = mock_controller(_handler)
runner = CliRunner()
result = runner.invoke(
cli,
["config", "push", cluster_bin, "--controller", f"127.0.0.1:{srv.port}", "--cert-dir", CERT_DIR],
)
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):
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, reply.to_bytes())
srv = mock_controller(_handler)
runner = CliRunner()
result = runner.invoke(
cli,
["config", "push", cluster_bin, "--controller", f"127.0.0.1:{srv.port}", "--cert-dir", CERT_DIR],
)
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}", "--cert-dir", CERT_DIR],
)
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}",
"--cert-dir",
CERT_DIR,
],
)
assert result.exit_code == 0, result.output
assert "generation 4→5" in result.output