mirror of
https://github.com/TencentCloud/Octop.git
synced 2026-10-20 06:19:17 +08:00
* 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 …」的写法一致。
151 lines
5.2 KiB
Python
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
|