Files
Octop/tests/unit/cli/test_run_bind_resolution.py
Grapette.L f2d0baeb4e fix(config): bound OCTOP_PORT and --port to a bindable range (#1150)
* fix(config): 绑定端口越界时告警回落,--port 越界直接报错

OCTOP_PORT 只按 int() 解析、octop run --port 也只有 type=int,70000/-1
这类根本无法绑定的取值会盖掉 config.json 里本来可用的端口,进程死在
socket.bind 抛出的 OverflowError 里,报错既不含变量名也看不出端口来源;
--port 更是在启动之前就把该值写进 config.json,之后每次 octop run 都继续继承。

现按 resolve_bind 已经支持的「0 表示由系统随机分配端口」把取值收口到
0-65535:环境变量越界沿用「告警 + 不覆盖」的既有契约回落配置文件端口,
命令行 --port 越界交给 click.IntRange 在使用期错误里拦下、不落盘。

* docs: 补上绑定端口的取值范围说明

`octop run --port` 现在会渲染成 `INTEGER RANGE … [0<=x<=65535]`,环境变量表也顺手写明
`OCTOP_PORT` 接受 0–65535(0 = 由系统随机分配),与 `OCTOP_LOG_LEVEL` 那行「One of …」的写法一致。
2026-09-26 20:14:30 +08:00

151 lines
5.2 KiB
Python

"""Bind address resolution for `octop run`: CLI flag > env > config.json."""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import patch
import pytest
from click.testing import CliRunner
from octop.cli.commands.run import _save_configfile_overrides, resolve_bind, run
from octop.infra.errors import ErrorCode, OctopError
@pytest.fixture
def octop_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
monkeypatch.setenv("OCTOP_HOME", str(tmp_path))
monkeypatch.delenv("OCTOP_PORT", raising=False)
monkeypatch.delenv("OCTOP_BIND_HOST", raising=False)
return tmp_path
def _write_config(home: Path, **data: object) -> None:
(home / "config.json").write_text(json.dumps(data), encoding="utf-8")
def test_no_config_no_env(octop_home: Path) -> None:
assert resolve_bind(None, None) == (None, None)
def test_config_file_values(octop_home: Path) -> None:
_write_config(octop_home, bind_host="127.0.0.1", port=8088)
assert resolve_bind(None, None) == ("127.0.0.1", 8088)
def test_env_overrides_config_file(octop_home: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_write_config(octop_home, bind_host="127.0.0.1", port=8088)
monkeypatch.setenv("OCTOP_PORT", "9001")
monkeypatch.setenv("OCTOP_BIND_HOST", "0.0.0.0")
assert resolve_bind(None, None) == ("0.0.0.0", 9001)
def test_cli_flags_win_over_env_and_file(octop_home: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_write_config(octop_home, bind_host="127.0.0.1", port=8088)
monkeypatch.setenv("OCTOP_PORT", "9001")
assert resolve_bind("10.0.0.1", 7777) == ("10.0.0.1", 7777)
def test_invalid_env_port_falls_back_to_file(
octop_home: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_write_config(octop_home, port=8088)
monkeypatch.setenv("OCTOP_PORT", "not-a-number")
assert resolve_bind(None, None) == (None, 8088)
@pytest.mark.parametrize("value", ["70000", "-1"])
def test_out_of_range_env_port_falls_back_to_file(
octop_home: Path, monkeypatch: pytest.MonkeyPatch, value: str
) -> None:
_write_config(octop_home, port=8088)
monkeypatch.setenv("OCTOP_PORT", value)
assert resolve_bind(None, None) == (None, 8088)
def test_zero_env_port_still_requests_an_os_assigned_port(
octop_home: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_write_config(octop_home, port=8088)
monkeypatch.setenv("OCTOP_PORT", "0")
assert resolve_bind(None, None) == (None, 0)
def test_legacy_host_key(octop_home: Path) -> None:
_write_config(octop_home, host="192.168.1.10", port=1234)
assert resolve_bind(None, None) == ("192.168.1.10", 1234)
# --- issue #730: a corrupt config.json must never be treated as empty --------
def _corrupt(text: str) -> str:
"""Append a trailing comma before the closing brace (classic hand-edit slip)."""
stripped = text.rstrip()
assert stripped.endswith("}")
return stripped[:-1].rstrip() + ",}"
def test_corrupt_config_raises_instead_of_silently_using_defaults(octop_home: Path) -> None:
(octop_home / "config.json").write_text('{"bind_host": "0.0.0.0",}', encoding="utf-8")
with pytest.raises(OctopError) as excinfo:
resolve_bind(None, None)
assert excinfo.value.code is ErrorCode.CONFIG_FILE_CORRUPT
def test_save_overrides_on_corrupt_config_preserves_bytes(octop_home: Path) -> None:
cfg = octop_home / "config.json"
corrupt = _corrupt(
json.dumps({"bind_host": "0.0.0.0", "database": {"driver": "postgresql"}}, indent=2)
)
cfg.write_text(corrupt, encoding="utf-8")
with pytest.raises(OctopError) as excinfo:
_save_configfile_overrides(None, 8088)
assert excinfo.value.code is ErrorCode.CONFIG_FILE_CORRUPT
assert cfg.read_text(encoding="utf-8") == corrupt
def test_save_overrides_merges_without_dropping_keys(octop_home: Path) -> None:
cfg = octop_home / "config.json"
cfg.write_text(
json.dumps(
{
"bind_host": "0.0.0.0",
"database": {"driver": "postgresql", "host": "db.internal"},
"log_level": "debug",
}
),
encoding="utf-8",
)
_save_configfile_overrides(None, 8088)
data = json.loads(cfg.read_text(encoding="utf-8"))
assert data["port"] == 8088
assert data["bind_host"] == "0.0.0.0"
assert data["database"] == {"driver": "postgresql", "host": "db.internal"}
assert data["log_level"] == "debug"
def test_run_cli_on_corrupt_config_refuses_and_preserves_file(octop_home: Path) -> None:
"""POC regression: ``octop run --port`` used to wipe config.json to one key."""
cfg = octop_home / "config.json"
cfg.write_text(
json.dumps(
{
"bind_host": "0.0.0.0",
"database": {"driver": "postgresql", "host": "db.internal"},
},
indent=2,
),
encoding="utf-8",
)
corrupt = _corrupt(cfg.read_text(encoding="utf-8"))
cfg.write_text(corrupt, encoding="utf-8")
with patch("octop.cli.commands.run._run_uvicorn") as mocked:
result = CliRunner().invoke(run, ["--port", "8088"])
assert result.exit_code != 0
assert not mocked.called
assert cfg.read_text(encoding="utf-8") == corrupt