feat(admin-config-tool): task 3.2 - xceadmin.discovery

Generic, opcode-agnostic redirect-following against RequestReply's
redirectHost/redirectPort. Follows up to 3 hops, displaying each
redirect target; a 4th redirect raises TooManyRedirectsError. A
connection timeout to any hop is interpreted as "no primary
available" and raised as NoPrimaryError naming that hop.
This commit is contained in:
Rongsong Shen 2026-07-22 07:58:25 +00:00
parent 72798d4aee
commit 7ed3a3941f
3 changed files with 232 additions and 1 deletions

View file

@ -0,0 +1,140 @@
"""Unit tests for ``xceadmin.discovery`` (design.md, ``xceadmin.discovery``;
Requirements 3.1, 3.2, 3.3, 3.4).
"""
from __future__ import annotations
import pytest
from xcecommon.errors import ConnectionTimeoutError, NoPrimaryError, TooManyRedirectsError
from xcecommon.schema import load_schema
from xceadmin import discovery
def _reply_bytes(redirect_host: str = "", redirect_port: int = 0) -> bytes:
mod = load_schema()
msg = mod.RequestReply.new_message()
if redirect_host:
msg.redirectHost = redirect_host
if redirect_port:
msg.redirectPort = redirect_port
return msg.to_bytes()
def test_follow_redirects_no_redirect_returns_first_reply_unchanged():
reply = _reply_bytes()
calls = []
def send_fn(host, port):
calls.append((host, port))
return reply
final_host, final_port, reply_bytes = discovery.follow_redirects(
"node1", 9991, "ha/data", send_fn
)
assert (final_host, final_port) == ("node1", 9991)
assert reply_bytes == reply
assert calls == [("node1", 9991)]
def test_follow_redirects_follows_one_redirect(capsys):
first_reply = _reply_bytes(redirect_host="node2", redirect_port=9991)
second_reply = _reply_bytes()
responses = {("node1", 9991): first_reply, ("node2", 9991): second_reply}
calls = []
def send_fn(host, port):
calls.append((host, port))
return responses[(host, port)]
final_host, final_port, reply_bytes = discovery.follow_redirects(
"node1", 9991, "ha/data", send_fn
)
assert (final_host, final_port) == ("node2", 9991)
assert reply_bytes == second_reply
assert calls == [("node1", 9991), ("node2", 9991)]
stderr = capsys.readouterr().err
assert "node1:9991 -> node2:9991" in stderr
def test_follow_redirects_follows_exactly_three_hops(capsys):
replies = {
("a", 1): _reply_bytes("b", 2),
("b", 2): _reply_bytes("c", 3),
("c", 3): _reply_bytes("d", 4),
("d", 4): _reply_bytes(), # final answer, no more redirects
}
calls = []
def send_fn(host, port):
calls.append((host, port))
return replies[(host, port)]
final_host, final_port, _ = discovery.follow_redirects("a", 1, "ha/data", send_fn)
assert (final_host, final_port) == ("d", 4)
assert calls == [("a", 1), ("b", 2), ("c", 3), ("d", 4)]
assert capsys.readouterr().err.count("redirected:") == 3
def test_follow_redirects_raises_too_many_redirects_on_fourth_hop():
replies = {
("a", 1): _reply_bytes("b", 2),
("b", 2): _reply_bytes("c", 3),
("c", 3): _reply_bytes("d", 4),
("d", 4): _reply_bytes("e", 5), # 4th redirect -- exceeds the limit
}
def send_fn(host, port):
return replies[(host, port)]
with pytest.raises(TooManyRedirectsError) as exc_info:
discovery.follow_redirects("a", 1, "ha/data", send_fn)
assert exc_info.value.hops == 4
def test_follow_redirects_raises_no_primary_error_on_connection_timeout():
def send_fn(host, port):
raise ConnectionTimeoutError(f"timeout connecting to {host}:{port}", host, port)
with pytest.raises(NoPrimaryError) as exc_info:
discovery.follow_redirects("node1", 9991, "ha/data", send_fn)
assert exc_info.value.host == "node1"
assert exc_info.value.port == 9991
def test_follow_redirects_no_primary_error_names_last_hop_not_initial_hop():
replies = {("a", 1): _reply_bytes("b", 2)}
def send_fn(host, port):
if (host, port) == ("b", 2):
raise ConnectionTimeoutError("timeout connecting to b:2", host, port)
return replies[(host, port)]
with pytest.raises(NoPrimaryError) as exc_info:
discovery.follow_redirects("a", 1, "ha/data", send_fn)
assert exc_info.value.host == "b"
assert exc_info.value.port == 2
def test_follow_redirects_zero_port_is_not_treated_as_a_redirect():
"""redirectHost set but redirectPort left at its capnp default (0) is
not a real redirect -- both fields must be non-empty (3.1).
"""
reply = _reply_bytes(redirect_host="orphan-host", redirect_port=0)
def send_fn(host, port):
return reply
final_host, final_port, reply_bytes = discovery.follow_redirects(
"node1", 9991, "ha/data", send_fn
)
assert (final_host, final_port) == ("node1", 9991)
assert reply_bytes == reply