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,19 +1,96 @@
"""xceadmin command-line entry point.
"""xceadmin command-line entry point (design.md, ``xceadmin.cli``;
Requirements 1.2, 1.3, 2.2, 2.4, 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 8.1, 8.2).
This module wires the ``xceadmin`` console-script entry point to a click
command group. It intentionally has zero subcommands for now — ``config
build`` and ``config push`` are added by a later task (see
.kiro/specs/admin-config-tool/tasks.md, task 4.1).
Thin ``click`` layer only -- no business logic. Every raising exception
across ``xcecommon``/``xceadmin`` subclasses :class:`XceAdminError`
(``xcecommon.errors``), so this module has exactly one dispatch surface:
``except XceAdminError as e: raise click.ClickException(e.message)``.
Per-failure-mode message formatting happens at each exception's raise
site (7.1, 7.2, 7.3, 7.4), not here.
SIGINT handling (7.5): Python's default SIGINT handler already raises
``KeyboardInterrupt`` inside whatever blocking call is running (socket
recv included); ``xcecommon.wire.connect``'s callers (``xceadmin.push``)
already wrap every socket use in ``try/finally: sock.close()``, so the
connection closes automatically as ``KeyboardInterrupt`` unwinds -- no
custom ``signal.signal()`` registration is needed to get that guarantee.
This module's job is only to catch it and report a clean "cancelled"
message instead of a raw traceback.
"""
from __future__ import annotations
import click
from xcecommon.errors import XceAdminError
from xceadmin.build import build_cluster_config, write_output
from xceadmin.push import probe_config, push_config
_DEFAULT_OUTPUT = "cluster.bin"
_DEFAULT_CONTROLLER = "localhost:9991"
_DEFAULT_CERT_DIR = "ha/data"
@click.group()
def cli() -> None:
"""xceadmin: build and deploy XCE cluster configuration."""
@cli.group()
def config() -> None:
"""Build and push cluster configuration."""
@config.command("build")
@click.argument("configs_dir")
@click.option("-o", "output", default=_DEFAULT_OUTPUT, help="Output path, or - for stdout.")
def config_build(configs_dir: str, output: str) -> None:
"""Compile CONFIGS_DIR's YAML files into a ClusterConfig binary (1.1-1.7, 1.9)."""
try:
data = build_cluster_config(configs_dir)
digest = write_output(data, output)
except XceAdminError as e:
raise click.ClickException(e.message) from e
click.echo(digest, err=True) # SHA-256 to stderr, always (1.4)
@config.command("push")
@click.argument("cluster_bin_path")
@click.option(
"--controller", default=_DEFAULT_CONTROLLER, help="host:port of any controller node (2.2)."
)
@click.option(
"--cert-dir", default=_DEFAULT_CERT_DIR, help="Directory containing xce.crt/xce.key (2.4)."
)
@click.option("--dry-run", is_flag=True, help="Probe acceptance without committing (Requirement 6).")
def config_push(cluster_bin_path: str, controller: str, cert_dir: str, dry_run: bool) -> None:
"""Push CLUSTER_BIN_PATH to the controller cluster."""
try:
if dry_run:
result = probe_config(cluster_bin_path, controller, cert_dir)
if not result.accepted:
raise click.ClickException(result.reason) # 6.3
click.echo(
"OK: controller will accept this config "
f"(generation {result.current_generation}→{result.would_be_generation})"
) # 6.2
else:
result = push_config(cluster_bin_path, controller, cert_dir)
click.echo(f"versionTag={result.version_tag} ackedCount={result.acked_count}") # 2.5
if result.error or result.acked_count <= 0:
raise click.ClickException(result.error or "push rejected (ackedCount == 0)") # 2.7
# 2.6: positive ackedCount and no error -- fall through to exit 0.
except XceAdminError as e:
raise click.ClickException(e.message) from e
except OSError as e:
# ConnectionRefusedError and other bare OS-level failures propagate
# unwrapped out of xcecommon.wire by design (see wire.py's module
# docstring) -- this is the layer that formats them (7.1).
raise click.ClickException(f"cannot connect to {controller}: {e}") from e
except KeyboardInterrupt:
raise click.ClickException("cancelled") from None
def main() -> None:
"""Console-script entry point (see pyproject.toml [project.scripts])."""
cli()

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