Adds record_start()/record_stop()/record_status() to new src/xceadmin/src/xceadmin/scheduler.py, following push_config()'s exact pipeline shape (load_schema -> follow_redirects -> decode_reply). Deliberately diverges from push_config()'s "return failure as data" convention because this task's own Observable text requires raising a typed error on an error reply, not just connection/decode failure -- a new SchedAdminError (xcecommon/errors.py) carries the full reply state (error/recording/file/ event_count/started_at) when SchedAdminReply.ok is false. test_scheduler.py mirrors test_config_push_mock.py's real mock-TLS-server technique, covering all three actions, redirect-following (standby-to- primary), and the error-reply-raises-SchedAdminError path across the error codes reference table. 188 passed, 3 skipped via the real xceadmin test runner (meson compile -C xce.build xceadmin).
230 lines
8.9 KiB
Python
230 lines
8.9 KiB
Python
"""Tests for the xcecommon.errors exception hierarchy (task 1.2).
|
|
|
|
Covers Requirements 7.1, 7.2, 7.3, 7.4: every specific failure mode gets its
|
|
own XceAdminError subclass, and catching the common base type recovers a
|
|
human-readable message regardless of which subclass was actually raised —
|
|
the single CLI dispatch surface described in design.md's "xceadmin.cli —
|
|
Implementation Note" (`except XceAdminError as e: raise
|
|
click.ClickException(e.message)`).
|
|
|
|
This task only builds the exception hierarchy itself — the code that would
|
|
actually raise these exceptions (schema loading, YAML validation, wire
|
|
protocol) lands in later tasks, so these tests raise each exception
|
|
directly rather than driving it through real application logic.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from xcecommon.errors import (
|
|
ConnectionTimeoutError,
|
|
DuplicateEntryError,
|
|
NoPrimaryError,
|
|
ReplyDecodeError,
|
|
ReservedNameError,
|
|
SchedAdminError,
|
|
SchemaNotFoundError,
|
|
TooManyRedirectsError,
|
|
ValidationError,
|
|
XceAdminError,
|
|
)
|
|
|
|
ALL_SUBCLASSES = [
|
|
SchemaNotFoundError,
|
|
ValidationError,
|
|
ReservedNameError,
|
|
DuplicateEntryError,
|
|
ConnectionTimeoutError,
|
|
ReplyDecodeError,
|
|
TooManyRedirectsError,
|
|
NoPrimaryError,
|
|
SchedAdminError,
|
|
]
|
|
|
|
|
|
def test_base_exception_carries_message() -> None:
|
|
"""XceAdminError itself carries a .message attribute, recoverable via
|
|
both .message and str(e)."""
|
|
with pytest.raises(XceAdminError) as exc_info:
|
|
raise XceAdminError("something went wrong")
|
|
|
|
assert exc_info.value.message == "something went wrong"
|
|
assert str(exc_info.value) == "something went wrong"
|
|
|
|
|
|
@pytest.mark.parametrize("exc_type", ALL_SUBCLASSES)
|
|
def test_every_subclass_is_a_xceadminerror(exc_type: type[XceAdminError]) -> None:
|
|
"""Every specific failure mode subclasses the common base — this is what
|
|
lets xceadmin.cli use one dispatch surface instead of catching 8
|
|
unrelated types (design.md, xceadmin.cli Implementation Note)."""
|
|
assert issubclass(exc_type, XceAdminError)
|
|
assert issubclass(exc_type, Exception)
|
|
|
|
|
|
def test_schema_not_found_error_carries_path_and_message() -> None:
|
|
"""Requirement 7.4: missing controller.capnp reports the path so the
|
|
operator can check the installation."""
|
|
path = "/opt/xceadmin/schema_files/controller.capnp"
|
|
with pytest.raises(XceAdminError) as exc_info:
|
|
raise SchemaNotFoundError(f"schema not found: {path}", path=path)
|
|
|
|
caught = exc_info.value
|
|
assert isinstance(caught, SchemaNotFoundError)
|
|
assert caught.message == f"schema not found: {path}"
|
|
assert caught.path == path
|
|
|
|
|
|
def test_validation_error_carries_file_json_pointer_and_constraint() -> None:
|
|
"""Requirement 4.1 / design.md xcecommon.validation: schema validation
|
|
failures report file, JSON-pointer field path, and violated constraint."""
|
|
with pytest.raises(XceAdminError) as exc_info:
|
|
raise ValidationError(
|
|
"queues.yaml: /queues/0/name does not match pattern",
|
|
file="queues.yaml",
|
|
json_pointer="/queues/0/name",
|
|
constraint="pattern",
|
|
)
|
|
|
|
caught = exc_info.value
|
|
assert isinstance(caught, ValidationError)
|
|
assert caught.message == "queues.yaml: /queues/0/name does not match pattern"
|
|
assert caught.file == "queues.yaml"
|
|
assert caught.json_pointer == "/queues/0/name"
|
|
assert caught.constraint == "pattern"
|
|
|
|
|
|
def test_reserved_name_error_recovers_message_via_base_type() -> None:
|
|
"""Requirement 4.2: the reserved name "lost_and_found" is rejected with
|
|
a clear message explaining the reservation."""
|
|
message = '"lost_and_found" is reserved and cannot be used as a queue or host name'
|
|
with pytest.raises(XceAdminError) as exc_info:
|
|
raise ReservedNameError(message)
|
|
|
|
assert isinstance(exc_info.value, ReservedNameError)
|
|
assert exc_info.value.message == message
|
|
|
|
|
|
def test_duplicate_entry_error_recovers_message_via_base_type() -> None:
|
|
"""Requirement 4.3: duplicate entries within a file/section are
|
|
reported by name."""
|
|
message = "duplicate queue name 'batch' in queues.yaml"
|
|
with pytest.raises(XceAdminError) as exc_info:
|
|
raise DuplicateEntryError(message)
|
|
|
|
assert isinstance(exc_info.value, DuplicateEntryError)
|
|
assert exc_info.value.message == message
|
|
|
|
|
|
def test_connection_timeout_error_carries_host_and_port() -> None:
|
|
"""Requirement 7.6 (declared here, raised by a later task): "timeout
|
|
connecting to <host:port>"."""
|
|
with pytest.raises(XceAdminError) as exc_info:
|
|
raise ConnectionTimeoutError(
|
|
"timeout connecting to localhost:9991", host="localhost", port=9991
|
|
)
|
|
|
|
caught = exc_info.value
|
|
assert isinstance(caught, ConnectionTimeoutError)
|
|
assert caught.message == "timeout connecting to localhost:9991"
|
|
assert caught.host == "localhost"
|
|
assert caught.port == 9991
|
|
|
|
|
|
def test_reply_decode_error_carries_reason() -> None:
|
|
"""Requirement 7.3: a corrupt/truncated capnp reply or cluster.bin
|
|
reports "invalid cluster.bin: <reason>"."""
|
|
with pytest.raises(XceAdminError) as exc_info:
|
|
raise ReplyDecodeError(
|
|
"invalid cluster.bin: truncated capnp message",
|
|
reason="truncated capnp message",
|
|
)
|
|
|
|
caught = exc_info.value
|
|
assert isinstance(caught, ReplyDecodeError)
|
|
assert caught.message == "invalid cluster.bin: truncated capnp message"
|
|
assert caught.reason == "truncated capnp message"
|
|
|
|
|
|
def test_too_many_redirects_error_carries_hop_count() -> None:
|
|
"""Requirement 3.2 (declared here, raised by a later task): more than 3
|
|
redirect hops is an error."""
|
|
with pytest.raises(XceAdminError) as exc_info:
|
|
raise TooManyRedirectsError(
|
|
"followed 3 redirects without reaching a primary", hops=3
|
|
)
|
|
|
|
caught = exc_info.value
|
|
assert isinstance(caught, TooManyRedirectsError)
|
|
assert caught.message == "followed 3 redirects without reaching a primary"
|
|
assert caught.hops == 3
|
|
|
|
|
|
def test_no_primary_error_carries_host_and_port() -> None:
|
|
"""Requirement 3.4 (declared here, raised by a later task): no primary
|
|
elected within the discovery timeout."""
|
|
with pytest.raises(XceAdminError) as exc_info:
|
|
raise NoPrimaryError(
|
|
"no primary available at localhost:9991", host="localhost", port=9991
|
|
)
|
|
|
|
caught = exc_info.value
|
|
assert isinstance(caught, NoPrimaryError)
|
|
assert caught.message == "no primary available at localhost:9991"
|
|
assert caught.host == "localhost"
|
|
assert caught.port == 9991
|
|
|
|
|
|
def test_sched_admin_error_carries_error_and_reply_state() -> None:
|
|
"""Requirement 5.10 (declared here, raised by a later task): a
|
|
``SchedAdminReply{ok=false, error=...}`` is surfaced as a typed error
|
|
carrying the reason plus the reply's recording/file/event/started-at
|
|
state (e.g. "already recording" still reports the current file)."""
|
|
with pytest.raises(XceAdminError) as exc_info:
|
|
raise SchedAdminError(
|
|
"scheduler admin request failed: already recording",
|
|
error="already recording",
|
|
recording=True,
|
|
file="/var/log/xce/current.replay",
|
|
event_count=42,
|
|
started_at=1234567890,
|
|
)
|
|
|
|
caught = exc_info.value
|
|
assert isinstance(caught, SchedAdminError)
|
|
assert caught.message == "scheduler admin request failed: already recording"
|
|
assert caught.error == "already recording"
|
|
assert caught.recording is True
|
|
assert caught.file == "/var/log/xce/current.replay"
|
|
assert caught.event_count == 42
|
|
assert caught.started_at == 1234567890
|
|
|
|
|
|
def test_catching_base_type_recovers_message_for_every_subclass() -> None:
|
|
"""The entire point of the shared exception contract (design.md,
|
|
xceadmin.cli Implementation Note): a single `except XceAdminError as e`
|
|
clause recovers e.message no matter which of the 8 subclasses was
|
|
actually raised — this is what lets the future CLI layer (task 4.1) use
|
|
one dispatch surface instead of 8 separate except clauses."""
|
|
cases: list[tuple[XceAdminError, str]] = [
|
|
(SchemaNotFoundError("msg-a", path="/x"), "msg-a"),
|
|
(ValidationError("msg-b", file="f", json_pointer="/p", constraint="c"), "msg-b"),
|
|
(ReservedNameError("msg-c"), "msg-c"),
|
|
(DuplicateEntryError("msg-d"), "msg-d"),
|
|
(ConnectionTimeoutError("msg-e", host="h", port=1), "msg-e"),
|
|
(ReplyDecodeError("msg-f", reason="r"), "msg-f"),
|
|
(TooManyRedirectsError("msg-g", hops=3), "msg-g"),
|
|
(NoPrimaryError("msg-h", host="h", port=1), "msg-h"),
|
|
(
|
|
SchedAdminError(
|
|
"msg-i", error="e", recording=False, file="", event_count=0, started_at=0
|
|
),
|
|
"msg-i",
|
|
),
|
|
]
|
|
|
|
for exc, expected_message in cases:
|
|
try:
|
|
raise exc
|
|
except XceAdminError as caught:
|
|
assert caught.message == expected_message
|
|
else:
|
|
pytest.fail("expected XceAdminError to be raised and caught")
|