"""サーバ設定の検証と環境変数の上書きのテスト(SPEC_03「サーバ設定」「サーバ上での誤実行の防止」)."""
from __future__ import annotations
from pathlib import Path
import pytest
import yaml
from pydantic import ValidationError
from tias_judge_model.remote.config import (
detect_self_server,
is_self_check_disabled,
load_remote_config,
resolve_jump_host,
)
REPO_ROOT = Path(__file__).resolve().parents[2]
def _base() -> dict:
return {
"jump_host": "labpc",
"connect_timeout_sec": 15,
"gpu_free": {
"max_memory_used_mib": 1024,
"max_utilization_percent": 10,
"observe_sec": 10,
"sample_interval_ms": 500,
},
"servers": [
{
"name": "Newton",
"address": "192.168.23.14",
"user": "hasegawa",
"priority": 1,
"remote_dir": "~/work/TIASJudgeModel",
"data_dir": "~/data/TIASJudgeModel",
"uv": "~/.local/bin/uv",
"data_ready": True,
},
{
"name": "Archimedes",
"address": "192.168.23.15",
"user": "hasegawa",
"priority": 2,
"remote_dir": "~/work/TIASJudgeModel",
"data_dir": "~/data/TIASJudgeModel",
"uv": "~/.local/bin/uv",
"data_ready": False,
},
],
}
def _write(tmp_path: Path, data: dict) -> Path:
path = tmp_path / "remote.yaml"
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
return path
def test_repository_remote_yaml_loads() -> None:
config = load_remote_config(REPO_ROOT / "configs" / "remote.yaml")
assert len(config.servers) >= 1
assert {s.name for s in config.servers} >= {"Newton"}
def test_valid_config_loads(tmp_path: Path) -> None:
config = load_remote_config(_write(tmp_path, _base()))
assert [s.name for s in config.servers] == ["Newton", "Archimedes"]
assert config.jump_host == "labpc"
assert config.gpu_free.observe_sec == 10
assert config.servers[1].data_ready is False
def test_jump_host_null_means_direct(tmp_path: Path) -> None:
data = _base()
data["jump_host"] = None
assert load_remote_config(_write(tmp_path, data)).jump_host is None
def _invalid(tmp_path: Path, data: dict) -> None:
with pytest.raises(ValidationError):
load_remote_config(_write(tmp_path, data))
def test_servers_must_not_be_empty(tmp_path: Path) -> None:
data = _base()
data["servers"] = []
_invalid(tmp_path, data)
def test_servers_are_required(tmp_path: Path) -> None:
data = _base()
del data["servers"]
_invalid(tmp_path, data)
def test_duplicate_server_names_are_rejected(tmp_path: Path) -> None:
data = _base()
data["servers"][1]["name"] = "Newton"
_invalid(tmp_path, data)
@pytest.mark.parametrize("name", ["New ton", "Newton;rm", "サーバ", "a/b", ""])
def test_server_name_characters(tmp_path: Path, name: str) -> None:
data = _base()
data["servers"][0]["name"] = name
_invalid(tmp_path, data)
@pytest.mark.parametrize("name", ["Newton_2", "gpu-01", "A1"])
def test_server_name_allowed_characters(tmp_path: Path, name: str) -> None:
data = _base()
data["servers"][0]["name"] = name
assert load_remote_config(_write(tmp_path, data)).servers[0].name == name
@pytest.mark.parametrize("priority", ["high", 1.5])
def test_priority_must_be_integer(tmp_path: Path, priority: object) -> None:
data = _base()
data["servers"][0]["priority"] = priority
_invalid(tmp_path, data)
@pytest.mark.parametrize(
("key", "value", "ok"),
[
("max_memory_used_mib", 0, True),
("max_memory_used_mib", -1, False),
("max_utilization_percent", 0, True),
("max_utilization_percent", 100, True),
("max_utilization_percent", -1, False),
("max_utilization_percent", 101, False),
("observe_sec", 0, True),
("observe_sec", 120, True),
("observe_sec", -1, False),
("observe_sec", 121, False),
("sample_interval_ms", 100, True),
("sample_interval_ms", 5000, True),
("sample_interval_ms", 99, False),
("sample_interval_ms", 5001, False),
],
)
def test_gpu_free_ranges(tmp_path: Path, key: str, value: int, ok: bool) -> None:
data = _base()
data["gpu_free"][key] = value
if ok:
assert getattr(load_remote_config(_write(tmp_path, data)).gpu_free, key) == value
else:
_invalid(tmp_path, data)
@pytest.mark.parametrize(("value", "ok"), [(1, True), (0, False), (-5, False)])
def test_connect_timeout_at_least_one(tmp_path: Path, value: int, ok: bool) -> None:
data = _base()
data["connect_timeout_sec"] = value
if ok:
assert load_remote_config(_write(tmp_path, data)).connect_timeout_sec == value
else:
_invalid(tmp_path, data)
def test_unknown_top_level_key_is_rejected(tmp_path: Path) -> None:
data = _base()
data["jump_hots"] = "labpc"
_invalid(tmp_path, data)
def test_unknown_server_key_is_rejected(tmp_path: Path) -> None:
data = _base()
data["servers"][0]["data_redy"] = True
_invalid(tmp_path, data)
def test_unknown_gpu_free_key_is_rejected(tmp_path: Path) -> None:
data = _base()
data["gpu_free"]["observe_seconds"] = 5
_invalid(tmp_path, data)
def test_missing_required_server_field_is_rejected(tmp_path: Path) -> None:
data = _base()
del data["servers"][0]["address"]
_invalid(tmp_path, data)
def test_missing_file_is_value_error(tmp_path: Path) -> None:
with pytest.raises(ValueError):
load_remote_config(tmp_path / "missing.yaml")
class TestJumpHostEnv:
def _config(self, tmp_path: Path, jump: str | None = "labpc"):
data = _base()
data["jump_host"] = jump
return load_remote_config(_write(tmp_path, data))
def test_no_env_uses_config(self, tmp_path: Path) -> None:
assert resolve_jump_host(self._config(tmp_path), {}) == "labpc"
def test_env_overrides(self, tmp_path: Path) -> None:
env = {"TIAS_REMOTE_JUMP": "otherjump"}
assert resolve_jump_host(self._config(tmp_path), env) == "otherjump"
def test_empty_env_means_direct(self, tmp_path: Path) -> None:
assert resolve_jump_host(self._config(tmp_path), {"TIAS_REMOTE_JUMP": ""}) is None
def test_env_sets_jump_when_config_is_null(self, tmp_path: Path) -> None:
config = self._config(tmp_path, None)
assert resolve_jump_host(config, {}) is None
assert resolve_jump_host(config, {"TIAS_REMOTE_JUMP": "labpc"}) == "labpc"
class TestSelfServer:
def test_hostname_matching_server_name_is_detected(self, tmp_path: Path) -> None:
config = load_remote_config(_write(tmp_path, _base()))
server = detect_self_server(config, "Newton")
assert server is not None and server.name == "Newton"
def test_other_hostname_is_not_detected(self, tmp_path: Path) -> None:
config = load_remote_config(_write(tmp_path, _base()))
assert detect_self_server(config, "my-laptop") is None
def test_allow_self_env(self) -> None:
assert is_self_check_disabled({"TIAS_REMOTE_ALLOW_SELF": "1"})
assert not is_self_check_disabled({})
assert not is_self_check_disabled({"TIAS_REMOTE_ALLOW_SELF": "0"})