feat(scheduler-core): wire xceadmin CLI commands for scheduler recording/dump (task 10.5)
Add scheduler command group with nested record sub-group (start/stop/status) and sibling dump command, delegating to the client functions from 10.3/10.4 with no business logic in the CLI layer. 220/220 xceadmin tests green.
This commit is contained in:
parent
8034fb6e1f
commit
98aa390ee8
3 changed files with 470 additions and 11 deletions
|
|
@ -1,6 +1,7 @@
|
|||
"""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,
|
||||
push`` wiring, task 10.5's ``scheduler record``/``scheduler dump``
|
||||
wiring; Requirements 1.2, 1.3, 2.2, 2.4, 5.4, 5.10, 7.1, 7.2, 7.3, 7.4,
|
||||
7.5, 7.6, 8.1, 8.2).
|
||||
"""
|
||||
|
||||
|
|
@ -16,8 +17,11 @@ import pytest
|
|||
import yaml
|
||||
from click.testing import CliRunner
|
||||
|
||||
from xcecommon.errors import ConnectionTimeoutError, ReplayFileError, SchedAdminError
|
||||
from xcecommon.schema import load_schema
|
||||
from xceadmin import cli as cli_module
|
||||
from xceadmin.cli import cli
|
||||
from xceadmin.scheduler import RecordResult
|
||||
|
||||
|
||||
def test_cli_help_exits_zero() -> None:
|
||||
|
|
@ -295,3 +299,321 @@ def test_config_push_dry_run_reports_generations(mock_controller, cluster_bin):
|
|||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "generation 4→5" in result.output
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# scheduler / scheduler record / scheduler dump (task 10.5): pure CLI-wiring
|
||||
# tests. Mirrors this file's own "config" section pattern of monkeypatching
|
||||
# the underlying business-logic function rather than standing up a mock TLS
|
||||
# server -- that deeper mock-server/redirect-following coverage is task
|
||||
# 10.6's job (record_start/stop/status and dump() themselves are already
|
||||
# covered at the unit level by test_scheduler.py's mock-TLS-server and
|
||||
# golden-file tests). These tests only prove: argument parsing, delegation
|
||||
# to the right function with the right arguments, success-output formatting,
|
||||
# and error-to-ClickException translation.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cli_help_shows_scheduler_command() -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--help"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "scheduler" in result.output
|
||||
|
||||
|
||||
def test_cli_scheduler_help_shows_record_and_dump() -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["scheduler", "--help"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "record" in result.output
|
||||
assert "dump" in result.output
|
||||
|
||||
|
||||
def test_cli_scheduler_record_help_shows_start_stop_status() -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["scheduler", "record", "--help"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "start" in result.output
|
||||
assert "stop" in result.output
|
||||
assert "status" in result.output
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _format_timestamp_ns / _format_record_result / _format_sched_admin_error:
|
||||
# pure formatting helpers, tested directly against requirements.md's own
|
||||
# "Status output" example (AC 10).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_format_timestamp_ns_renders_iso8601_utc() -> None:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
dt = datetime(2026, 7, 29, 2, 9, 0, tzinfo=timezone.utc)
|
||||
ns = int(dt.timestamp()) * 1_000_000_000
|
||||
|
||||
assert cli_module._format_timestamp_ns(ns) == "2026-07-29T02:09:00"
|
||||
|
||||
|
||||
def test_format_record_result_reproduces_requirements_md_example_verbatim() -> None:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
started_at_ns = int(datetime(2026, 7, 29, 2, 9, 0, tzinfo=timezone.utc).timestamp()) * 1_000_000_000
|
||||
result = RecordResult(
|
||||
recording=True,
|
||||
file="/var/log/xce/scheduler-replay-prod-cluster-1-20260729-020900.replay",
|
||||
event_count=14273,
|
||||
started_at=started_at_ns,
|
||||
)
|
||||
|
||||
text = cli_module._format_record_result(result)
|
||||
|
||||
assert text == (
|
||||
"recording: yes\n"
|
||||
"file: /var/log/xce/scheduler-replay-prod-cluster-1-20260729-020900.replay\n"
|
||||
"events: 14273\n"
|
||||
"started at: 2026-07-29T02:09:00"
|
||||
)
|
||||
|
||||
|
||||
def test_format_record_result_recording_false_prints_no() -> None:
|
||||
result = RecordResult(recording=False, file="", event_count=0, started_at=0)
|
||||
|
||||
assert cli_module._format_record_result(result).splitlines()[0] == f"{'recording:':<13}no"
|
||||
|
||||
|
||||
def test_format_sched_admin_error_enriches_message_when_still_recording() -> None:
|
||||
e = SchedAdminError(
|
||||
"scheduler admin request failed: already recording",
|
||||
error="already recording",
|
||||
recording=True,
|
||||
file="current.replay",
|
||||
event_count=7,
|
||||
started_at=5000,
|
||||
)
|
||||
|
||||
msg = cli_module._format_sched_admin_error(e)
|
||||
|
||||
assert msg.startswith("already recording")
|
||||
assert "current.replay" in msg
|
||||
assert "7 events" in msg
|
||||
|
||||
|
||||
def test_format_sched_admin_error_plain_when_not_recording() -> None:
|
||||
e = SchedAdminError(
|
||||
"scheduler admin request failed: not recording",
|
||||
error="not recording",
|
||||
recording=False,
|
||||
file="",
|
||||
event_count=0,
|
||||
started_at=0,
|
||||
)
|
||||
|
||||
assert cli_module._format_sched_admin_error(e) == "not recording"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# scheduler record start/stop/status: CLI wiring against a monkeypatched
|
||||
# xceadmin.scheduler function (delegation, output formatting).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_scheduler_record_start_success_prints_formatted_result(monkeypatch) -> None:
|
||||
captured = {}
|
||||
|
||||
def _fake_record_start(controller, filename):
|
||||
captured["controller"] = controller
|
||||
captured["filename"] = filename
|
||||
return RecordResult(
|
||||
recording=True, file="/var/log/xce/auto.replay", event_count=0, started_at=1000
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli_module, "record_start", _fake_record_start)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["scheduler", "record", "start"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["controller"] == cli_module._DEFAULT_CONTROLLER
|
||||
assert captured["filename"] is None
|
||||
assert "recording: yes" in result.output
|
||||
assert "/var/log/xce/auto.replay" in result.output
|
||||
|
||||
|
||||
def test_scheduler_record_start_passes_filename_and_controller_arguments(monkeypatch) -> None:
|
||||
captured = {}
|
||||
|
||||
def _fake_record_start(controller, filename):
|
||||
captured["controller"] = controller
|
||||
captured["filename"] = filename
|
||||
return RecordResult(recording=True, file=filename or "", event_count=0, started_at=1000)
|
||||
|
||||
monkeypatch.setattr(cli_module, "record_start", _fake_record_start)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli, ["scheduler", "record", "start", "myfile.replay", "--controller", "127.0.0.1:9999"]
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["controller"] == "127.0.0.1:9999"
|
||||
assert captured["filename"] == "myfile.replay"
|
||||
|
||||
|
||||
def test_scheduler_record_stop_success_prints_formatted_result(monkeypatch) -> None:
|
||||
def _fake_record_stop(controller):
|
||||
return RecordResult(recording=False, file="final.replay", event_count=14273, started_at=1000)
|
||||
|
||||
monkeypatch.setattr(cli_module, "record_stop", _fake_record_stop)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["scheduler", "record", "stop"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "recording: no" in result.output
|
||||
assert "events: 14273" in result.output
|
||||
|
||||
|
||||
def test_scheduler_record_status_success_prints_formatted_result(monkeypatch) -> None:
|
||||
def _fake_record_status(controller):
|
||||
return RecordResult(recording=True, file="current.replay", event_count=500, started_at=4000)
|
||||
|
||||
monkeypatch.setattr(cli_module, "record_status", _fake_record_status)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["scheduler", "record", "status"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "recording: yes" in result.output
|
||||
assert "events: 500" in result.output
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# scheduler record: error translation (SchedAdminError enrichment, plain
|
||||
# XceAdminError subclasses, bare OS-level failures, SIGINT) -- all must
|
||||
# become a clean click.ClickException, never a raw traceback.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_scheduler_record_start_already_recording_exits_nonzero_with_rich_message(monkeypatch) -> None:
|
||||
def _fake_record_start(controller, filename):
|
||||
raise SchedAdminError(
|
||||
"scheduler admin request failed: already recording",
|
||||
error="already recording",
|
||||
recording=True,
|
||||
file="current.replay",
|
||||
event_count=7,
|
||||
started_at=5000,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli_module, "record_start", _fake_record_start)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["scheduler", "record", "start"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "already recording" in result.output
|
||||
assert "current.replay" in result.output
|
||||
|
||||
|
||||
def test_scheduler_record_stop_not_recording_exits_nonzero_with_plain_message(monkeypatch) -> None:
|
||||
def _fake_record_stop(controller):
|
||||
raise SchedAdminError(
|
||||
"scheduler admin request failed: not recording",
|
||||
error="not recording",
|
||||
recording=False,
|
||||
file="",
|
||||
event_count=0,
|
||||
started_at=0,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli_module, "record_stop", _fake_record_stop)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["scheduler", "record", "stop"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "not recording" in result.output
|
||||
|
||||
|
||||
def test_scheduler_record_status_connection_timeout_exits_nonzero_not_raw_traceback(monkeypatch) -> None:
|
||||
def _fake_record_status(controller):
|
||||
raise ConnectionTimeoutError(f"timeout connecting to {controller}", "127.0.0.1", 9991)
|
||||
|
||||
monkeypatch.setattr(cli_module, "record_status", _fake_record_status)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["scheduler", "record", "status"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert not isinstance(result.exception, KeyError | AttributeError | TypeError)
|
||||
assert "timeout" in result.output
|
||||
|
||||
|
||||
def test_scheduler_record_start_connection_refused_exits_nonzero_not_raw_traceback(monkeypatch) -> None:
|
||||
def _fake_record_start(controller, filename):
|
||||
raise ConnectionRefusedError("refused")
|
||||
|
||||
monkeypatch.setattr(cli_module, "record_start", _fake_record_start)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["scheduler", "record", "start"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert cli_module._DEFAULT_CONTROLLER in result.output
|
||||
|
||||
|
||||
def test_scheduler_record_status_keyboard_interrupt_reports_cancelled(monkeypatch) -> None:
|
||||
def _fake_record_status(controller):
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
monkeypatch.setattr(cli_module, "record_status", _fake_record_status)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["scheduler", "record", "status"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "cancelled" in result.output
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# scheduler dump: CLI wiring against a monkeypatched xceadmin.scheduler.dump
|
||||
# (no network, no controller argument -- Requirement 5.20/task 10.4).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_scheduler_dump_echoes_returned_text_exactly(monkeypatch, tmp_path) -> None:
|
||||
fixture = tmp_path / "some.replay"
|
||||
fixture.write_bytes(b"unused -- dump() itself is monkeypatched here")
|
||||
captured = {}
|
||||
|
||||
def _fake_dump(path):
|
||||
captured["path"] = path
|
||||
return "=== ReplayHeader ===\nrecorderVersion: 1.0\n"
|
||||
|
||||
monkeypatch.setattr(cli_module, "dump", _fake_dump)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["scheduler", "dump", str(fixture)])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["path"] == str(fixture)
|
||||
assert result.output == "=== ReplayHeader ===\nrecorderVersion: 1.0\n"
|
||||
|
||||
|
||||
def test_scheduler_dump_missing_file_exits_nonzero_with_clean_message(monkeypatch) -> None:
|
||||
def _fake_dump(path):
|
||||
raise ReplayFileError(
|
||||
f"cannot read replay file {path!r}: no such file", path=path, reason="no such file"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli_module, "dump", _fake_dump)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["scheduler", "dump", "does-not-exist.replay"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert not isinstance(result.exception, KeyError | AttributeError | TypeError | FileNotFoundError)
|
||||
assert "does-not-exist.replay" in result.output
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue