forked from lei.chen/myxce
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:
parent
c33ef7d479
commit
eeda32c696
3 changed files with 356 additions and 16 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue