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
|
|
@ -289,11 +289,12 @@
|
|||
- _Verified: 2026-08-05 — `dump(replay_file_path)` (extends `scheduler.py`, task 10.3's file) is a pure local file decoder, zero network I/O (confirmed no `wire`/`discovery`/`connect` reachable from its code path). Key finding, empirically reproduced: pycapnp's `from_bytes()` does NOT validate a message's bytes against the schema you ask it to decode as — decoding real `SnapshotEntry` bytes as `ReplayEvent` raises nothing and returns plausible-looking garbage field values, ruling out trial-decode-and-catch as a message-type-discrimination strategy. Instead, `dump()` reads each message's raw segment-table/root-pointer framing directly (`_peek_message()`) to extract its `(dataWordCount, pointerCount)` shape — computed live from each candidate schema's `cls.schema.node.struct`, never hardcoded — and matches against the four replay message types' actual shapes (`ReplayHeader=(1,5)`, `SnapshotEntry=(1,1)`, `ReplayEvent=(2,1)`, `ReplaySession=(1,2)`) before ever calling the real decoder; AC 20's stream-ordering rules (message 1 = ReplayHeader, message 2 = SnapshotEntry) are enforced via position-restricted shape dicts, not inferred. Adversarial review independently reproduced the trial-decode-unsafe finding from scratch (own probe script against the live schema) and went further: built an actual modified schema (one extra field on SnapshotEntry) and reproduced a real shape collision end-to-end, finding it caused an uncaught raw capnp exception to escape `dump()` — a genuine, if forward-looking, gap since today's four shapes are independently confirmed pairwise-distinct. Closed immediately: added a pairwise-distinctness assertion right after the four shapes are computed, raising a typed `ReplayFileError(reason="ambiguous message shapes")` naming the colliding types before any message is decoded, restoring `dump()`'s own docstring guarantee ("never lets a raw exception escape") unconditionally rather than only-if-the-schema-stays-lucky; added `test_dump_raises_on_ambiguous_message_shapes` (monkeypatches `_shape_of()` to simulate the exact collision the review reproduced) proving the guard fires — independently re-verified by me via a fresh `meson compile -C xce.build xceadmin` run (201 passed, 3 skipped, up from 188 pre-task) before marking complete. Test fixture (`_build_full_replay_stream()`, `test_scheduler.py`) is hand-built via pycapnp's write-side API since no `.replay` fixture existed anywhere in the repo yet (no C-side simulation driver built so far); confirmed byte-for-byte framing-compatible with `recording.c`'s real `write_capnp_message()` (plain concatenated `to_bytes()` output, zero extra framing). Snapshot entries are summarized (counts/names) rather than exhaustively enumerated, decisions are fully enumerated per-item — a defensible reading of AC 20's "listing... the snapshot entry" (singular) vs. "each event/session/decision" (per-item) wording, confirmed reasonable by review, not blocking._
|
||||
- _Boundary: XceAdminSchedulerCLI — not (P), same file as 10.3 (see 10.3)_
|
||||
|
||||
- [ ] 10.5 Wire the xceadmin CLI commands
|
||||
- [x] 10.5 Wire the xceadmin CLI commands
|
||||
- Add the scheduler command group with a nested record sub-group (start/stop/status commands) and a sibling dump command, each delegating to the functions built in 10.3/10.4 with no business logic in the CLI layer itself
|
||||
- Observable: invoking each of the four CLI commands against the underlying functions produces the same output the functions themselves return, with errors surfaced as clean CLI error messages
|
||||
- _Requirements: 5.10_
|
||||
- _Depends: 10.3, 10.4_
|
||||
- _Verified: 2026-08-06 — `cli.py` (design.md's exact target file) adds `@cli.group() scheduler`, `@scheduler.group("record") scheduler_record` (start/stop/status), and `@scheduler.command("dump") scheduler_dump` — matching the literal surface `xceadmin scheduler record start [FILE]`/`record stop`/`record status`/`scheduler dump <FILE>` exactly (verified via a live `CliRunner --help` smoke test at every level, not just read). CLI function names are prefixed (`scheduler_record_start` etc., not bare `record_start`) specifically to avoid shadowing the directly-imported `xceadmin.scheduler.record_start`/`record_stop`/`record_status`/`dump` functions they delegate to — click infers the CLI-visible command name from the decorator's explicit string argument, not the Python function name, so this is invisible in `--help` output (logged as a do-not-repeat in cerebrum.md: same bare name would compile fine but recurse infinitely at call time). Pure delegation + formatting only, no business logic: `record_start`/`record_stop`/`record_status`/`dump` are called unmodified; a new `_format_record_result()` renders `RecordResult` in requirements.md AC 10's exact "Status output" column-13 layout (pinned by a verbatim-reproduction test), shared by all three record subcommands per AC 10's "returned by recordStart/recordStop for confirmation" wording. Error handling: the existing `except XceAdminError as e: raise click.ClickException(e.message)` catch-all (config_push's precedent) is reused as-is for `scheduler dump`'s `ReplayFileError`/`SchemaNotFoundError`; the three `record` subcommands add one narrower branch ahead of it — `except SchedAdminError` — since `SchedAdminError`'s own docstring (task 10.3) explicitly documents that its `recording`/`file`/`event_count` state is meant to be surfaced richer than a bare message (e.g. "already recording" alone doesn't say which file); enriched only when `e.recording` is true (the only case with genuinely useful extra state — "not recording"/"scheduler not connected"/"request timeout"/"invalid record file name" all report `recording=false` and fall through to the plain reason string, matching precedent). `OSError`/`KeyboardInterrupt` handling mirrors `config_push`'s existing branches verbatim (same underlying `wire.connect` socket pattern). New tests in `test_cli.py` (19 added, all monkeypatching `xceadmin.cli`'s imported `record_start`/`record_stop`/`record_status`/`dump` bindings rather than a mock TLS server — full mock-server/redirect coverage is task 10.6's job): `--help` reachability at every group level, direct unit tests of the three formatting/error-translation helpers (incl. a verbatim requirements.md-example reproduction), argument/option parsing and delegation (explicit filename + `--controller`), success-output formatting for all three record actions, `SchedAdminError` enrichment vs. plain-message fallback, connection-timeout/connection-refused/`KeyboardInterrupt` all becoming clean `ClickException`s not raw tracebacks, and `dump`'s exact-text echo (`click.echo(text, nl=False)`, since `dump()` already ends its own text with `\n`) plus its error translation. Verified via the real `meson compile -C xce.build xceadmin` command (custom_target wrapping `meson-build.sh`'s pytest run): 220 passed, 3 skipped (up from 201/3 pre-task; the 3 skips are the same pre-existing live-cluster E2E tests, unaffected) — zero failures, zero new skips. No linter/formatter is configured for this package (re-confirmed, matching task 10.4's prior finding) so none was invented/run.
|
||||
|
||||
- [ ] 10.6 xceadmin scheduler CLI tests
|
||||
- Test record start/stop/status against a mock TLS server (including redirect-following) and dump against a fixture replay file
|
||||
|
|
|
|||
|
|
@ -1,33 +1,45 @@
|
|||
"""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).
|
||||
Requirements 1.2, 1.3, 2.2, 2.4, 5.10, 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 8.1,
|
||||
8.2).
|
||||
|
||||
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.
|
||||
site (7.1, 7.2, 7.3, 7.4), not here. The ``scheduler record`` commands
|
||||
(task 10.5) extend this with one additional, narrower branch --
|
||||
:class:`~xcecommon.errors.SchedAdminError` is caught ahead of the generic
|
||||
``XceAdminError`` catch-all so its richer reply state (``recording``/
|
||||
``file``/``event_count``) can be surfaced instead of the bare ``.message``
|
||||
string -- see ``_format_sched_admin_error`` for why.
|
||||
|
||||
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.
|
||||
recv included); ``xcecommon.wire.connect``'s callers (``xceadmin.push``,
|
||||
``xceadmin.scheduler``) 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
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import click
|
||||
|
||||
from xcecommon.errors import XceAdminError
|
||||
from xcecommon.errors import SchedAdminError, XceAdminError
|
||||
from xceadmin.build import build_cluster_config, write_output
|
||||
from xceadmin.push import probe_config, push_config
|
||||
from xceadmin.scheduler import RecordResult, dump, record_start, record_status, record_stop
|
||||
|
||||
_DEFAULT_OUTPUT = "cluster.bin"
|
||||
_DEFAULT_CONTROLLER = "localhost:9991"
|
||||
_STATUS_LABEL_WIDTH = 13 # len("started at:") + 2 -- requirements.md's "Status output" example
|
||||
|
||||
|
||||
@click.group()
|
||||
|
|
@ -87,6 +99,130 @@ def config_push(cluster_bin_path: str, controller: str, dry_run: bool) -> None:
|
|||
raise click.ClickException("cancelled") from None
|
||||
|
||||
|
||||
@cli.group()
|
||||
def scheduler() -> None:
|
||||
"""Control and inspect the scheduler's runtime event recording (5.10)."""
|
||||
|
||||
|
||||
@scheduler.group("record")
|
||||
def scheduler_record() -> None:
|
||||
"""Start, stop, or query scheduler event recording."""
|
||||
|
||||
|
||||
def _format_timestamp_ns(started_at_ns: int) -> str:
|
||||
"""``startedAt`` is nanoseconds since the Unix epoch (requirements.md
|
||||
AC 10's ``SchedAdminReply.startedAt`` field doc) -- rendered in the
|
||||
same timezone-less ISO-8601 form as requirements.md's own "Status
|
||||
output" example (``2026-07-29T02:09:00``).
|
||||
"""
|
||||
return datetime.fromtimestamp(started_at_ns / 1_000_000_000, tz=timezone.utc).strftime(
|
||||
"%Y-%m-%dT%H:%M:%S"
|
||||
)
|
||||
|
||||
|
||||
def _format_record_result(result: RecordResult) -> str:
|
||||
"""Formats a :class:`~xceadmin.scheduler.RecordResult` exactly as
|
||||
requirements.md's "Status output" example -- shared by ``record
|
||||
start``/``record stop``/``record status`` since AC 10 documents all
|
||||
three actions as returning the same confirmation fields.
|
||||
"""
|
||||
rows = [
|
||||
("recording:", "yes" if result.recording else "no"),
|
||||
("file:", result.file),
|
||||
("events:", str(result.event_count)),
|
||||
("started at:", _format_timestamp_ns(result.started_at)),
|
||||
]
|
||||
return "\n".join(f"{label:<{_STATUS_LABEL_WIDTH}}{value}" for label, value in rows)
|
||||
|
||||
|
||||
def _format_sched_admin_error(e: SchedAdminError) -> str:
|
||||
""":class:`~xcecommon.errors.SchedAdminError` carries the full reply
|
||||
state, not just the reason string -- its docstring explains why: "so
|
||||
a caller that wants to display it (e.g. 'already recording -- current
|
||||
file: X') does not have to re-query ``record status`` separately." A
|
||||
bare ``e.message`` (``"scheduler admin request failed: already
|
||||
recording"``) would throw that state away, so this surfaces the
|
||||
current file/event count too whenever the reply says recording is
|
||||
still active -- a deliberate, documented departure from
|
||||
``config_push``'s bare-``e.message`` precedent, made only because
|
||||
``SchedAdminError`` (unlike every other ``XceAdminError`` subclass
|
||||
this CLI catches) carries state genuinely worth showing. Every other
|
||||
``SchedAdminReply{ok=false}`` reason (``"not recording"``,
|
||||
``"scheduler not connected"``, ``"request timeout"``, ``"invalid
|
||||
record file name"``) reports ``recording=false``, so falls through to
|
||||
the plain reason string -- matching precedent for those cases, since
|
||||
there is no extra state to add.
|
||||
"""
|
||||
if e.recording and e.file:
|
||||
return f"{e.error} (currently recording to {e.file!r}, {e.event_count} events so far)"
|
||||
return e.error
|
||||
|
||||
|
||||
def _run_scheduler_admin_command(controller: str, call: Callable[[], RecordResult]) -> RecordResult:
|
||||
"""Runs one scheduler-admin client call (``record_start``/
|
||||
``record_stop``/``record_status``, all ``xceadmin.scheduler``),
|
||||
translating every raised failure mode into a single
|
||||
``click.ClickException`` -- factored out since ``record start``/
|
||||
``stop``/``status`` differ only in which zero-arg callable they
|
||||
invoke (DRY over three otherwise-identical try/except blocks).
|
||||
"""
|
||||
try:
|
||||
return call()
|
||||
except SchedAdminError as e:
|
||||
raise click.ClickException(_format_sched_admin_error(e)) from e
|
||||
except XceAdminError as e:
|
||||
raise click.ClickException(e.message) from e
|
||||
except OSError as e:
|
||||
# Same rationale as config_push's own OSError branch: bare OS-level
|
||||
# failures (ConnectionRefusedError etc.) propagate unwrapped out of
|
||||
# xcecommon.wire by design -- this is the layer that formats them.
|
||||
raise click.ClickException(f"cannot connect to {controller}: {e}") from e
|
||||
except KeyboardInterrupt:
|
||||
raise click.ClickException("cancelled") from None
|
||||
|
||||
|
||||
@scheduler_record.command("start")
|
||||
@click.argument("file", required=False)
|
||||
@click.option(
|
||||
"--controller", default=_DEFAULT_CONTROLLER, help="host:port of any controller node (2.2)."
|
||||
)
|
||||
def scheduler_record_start(file: str | None, controller: str) -> None:
|
||||
"""Start scheduler event recording, optionally to FILE (auto-generated when omitted)."""
|
||||
result = _run_scheduler_admin_command(controller, lambda: record_start(controller, file))
|
||||
click.echo(_format_record_result(result))
|
||||
|
||||
|
||||
@scheduler_record.command("stop")
|
||||
@click.option(
|
||||
"--controller", default=_DEFAULT_CONTROLLER, help="host:port of any controller node (2.2)."
|
||||
)
|
||||
def scheduler_record_stop(controller: str) -> None:
|
||||
"""Stop scheduler event recording."""
|
||||
result = _run_scheduler_admin_command(controller, lambda: record_stop(controller))
|
||||
click.echo(_format_record_result(result))
|
||||
|
||||
|
||||
@scheduler_record.command("status")
|
||||
@click.option(
|
||||
"--controller", default=_DEFAULT_CONTROLLER, help="host:port of any controller node (2.2)."
|
||||
)
|
||||
def scheduler_record_status(controller: str) -> None:
|
||||
"""Report current scheduler event-recording state."""
|
||||
result = _run_scheduler_admin_command(controller, lambda: record_status(controller))
|
||||
click.echo(_format_record_result(result))
|
||||
|
||||
|
||||
@scheduler.command("dump")
|
||||
@click.argument("file")
|
||||
def scheduler_dump(file: str) -> None:
|
||||
"""Decode FILE (a local .replay file) to human-readable text."""
|
||||
try:
|
||||
text = dump(file)
|
||||
except XceAdminError as e:
|
||||
raise click.ClickException(e.message) from e
|
||||
click.echo(text, nl=False) # dump() already ends its own text with "\n" (5.20).
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Console-script entry point (see pyproject.toml [project.scripts])."""
|
||||
cli()
|
||||
|
|
|
|||
|
|
@ -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