Newer
Older
TIASJudgeModel / tests / remote / test_config.py
"""サーバ設定の検証と環境変数の上書きのテスト(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"})