feat(deploy-control): 添加直接IP绑定功能支持

新增ipaddress模块导入以支持IP地址处理,
添加DEPLOY_DIRECT_PUBLIC_HOST_BIND_IP环境变量配置,
实现IP地址验证、直接URL构建和端口分配功能,
当基础域名是IP地址时自动使用直接绑定模式,
支持IPv4和IPv6地址格式并添加相应参数传递
This commit is contained in:
2026-06-16 10:29:45 +08:00
parent b736fc9c81
commit 29845657f5
2 changed files with 136 additions and 1 deletions

View File

@ -1,6 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from __future__ import annotations from __future__ import annotations
import ipaddress
import json import json
import os import os
import re import re
@ -56,6 +57,7 @@ PUBLIC_SCHEME = os.environ.get("DEPLOY_PUBLIC_SCHEME", "http").strip() or "http"
PUBLIC_BASE_DOMAIN = os.environ.get("DEPLOY_PUBLIC_BASE_DOMAIN", "localhost").strip() PUBLIC_BASE_DOMAIN = os.environ.get("DEPLOY_PUBLIC_BASE_DOMAIN", "localhost").strip()
PUBLIC_HOST_TEMPLATE = os.environ.get("DEPLOY_PUBLIC_HOST_TEMPLATE", "{slug}.{base_domain}").strip() PUBLIC_HOST_TEMPLATE = os.environ.get("DEPLOY_PUBLIC_HOST_TEMPLATE", "{slug}.{base_domain}").strip()
PUBLIC_PORT = int(os.environ.get("DEPLOY_PUBLIC_PORT", "8088").strip() or "8088") PUBLIC_PORT = int(os.environ.get("DEPLOY_PUBLIC_PORT", "8088").strip() or "8088")
DIRECT_PUBLIC_HOST_BIND_IP = os.environ.get("DEPLOY_DIRECT_PUBLIC_HOST_BIND_IP", "0.0.0.0").strip() or "0.0.0.0"
AUTO_START_PROXY = os.environ.get("DEPLOY_AUTO_START_PROXY", "1").strip() not in {"0", "false", "False"} AUTO_START_PROXY = os.environ.get("DEPLOY_AUTO_START_PROXY", "1").strip() not in {"0", "false", "False"}
HEALTH_TIMEOUT_SECONDS = float(os.environ.get("DEPLOY_HEALTH_TIMEOUT_SECONDS", "60").strip() or "60") HEALTH_TIMEOUT_SECONDS = float(os.environ.get("DEPLOY_HEALTH_TIMEOUT_SECONDS", "60").strip() or "60")
HEALTH_INTERVAL_SECONDS = float(os.environ.get("DEPLOY_HEALTH_INTERVAL_SECONDS", "1").strip() or "1") HEALTH_INTERVAL_SECONDS = float(os.environ.get("DEPLOY_HEALTH_INTERVAL_SECONDS", "1").strip() or "1")
@ -195,6 +197,39 @@ def build_public_url(host: str) -> str:
return f"{PUBLIC_SCHEME}://{netloc}" return f"{PUBLIC_SCHEME}://{netloc}"
def public_base_domain_ip() -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
value = PUBLIC_BASE_DOMAIN.strip().strip("[]")
try:
return ipaddress.ip_address(value)
except ValueError:
return None
def build_direct_public_url(host: ipaddress.IPv4Address | ipaddress.IPv6Address, host_port: int) -> str:
host_value = f"[{host}]" if host.version == 6 else str(host)
return f"http://{host_value}:{host_port}"
def pick_instance_host_port(instance_id: str) -> int:
args = [
str(REGISTRY_TOOL),
"--registry",
str(REGISTRY_PATH),
"next-port",
"--start",
"20000",
"--end",
"29999",
]
if instance_id:
args.extend(["--exclude-instance-id", instance_id])
output = run_command(args)
try:
return int(output.strip())
except ValueError as exc:
raise ApiError(HTTPStatus.BAD_GATEWAY, f"invalid registry port response: {output}") from exc
def build_internal_api_base_url(record: dict[str, Any]) -> str: def build_internal_api_base_url(record: dict[str, Any]) -> str:
container_name = str(record.get("container_name", "") or "").strip() container_name = str(record.get("container_name", "") or "").strip()
if container_name: if container_name:
@ -247,7 +282,13 @@ def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]:
if existing is None: if existing is None:
ensure_network() ensure_network()
public_host = build_public_host(slug=slug, instance_id=instance_id, username=username) public_host = build_public_host(slug=slug, instance_id=instance_id, username=username)
public_url = build_public_url(public_host) direct_public_host = public_base_domain_ip()
host_port: int | None = None
if direct_public_host is not None:
host_port = pick_instance_host_port(instance_id)
public_url = build_direct_public_url(direct_public_host, host_port)
else:
public_url = build_public_url(public_host)
authz_base_url = str(payload.get("authz_base_url", "") or DEFAULT_AUTHZ_BASE_URL).strip() authz_base_url = str(payload.get("authz_base_url", "") or DEFAULT_AUTHZ_BASE_URL).strip()
authz_outlook_mcp_url = str( authz_outlook_mcp_url = str(
payload.get("authz_outlook_mcp_url", "") or DEFAULT_AUTHZ_OUTLOOK_MCP_URL payload.get("authz_outlook_mcp_url", "") or DEFAULT_AUTHZ_OUTLOOK_MCP_URL
@ -279,6 +320,9 @@ def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]:
"--network", "--network",
INSTANCE_NETWORK_NAME, INSTANCE_NETWORK_NAME,
] ]
if host_port is not None:
command.extend(["--host-port", str(host_port)])
command.extend(["--host-bind-ip", DIRECT_PUBLIC_HOST_BIND_IP])
if authz_base_url: if authz_base_url:
command.extend(["--authz-base-url", authz_base_url]) command.extend(["--authz-base-url", authz_base_url])
if DEFAULT_AUTHZ_INTERNAL_TOKEN: if DEFAULT_AUTHZ_INTERNAL_TOKEN:

View File

@ -0,0 +1,91 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
from typing import Any
SERVER_PATH = Path(__file__).resolve().parents[1] / "server.py"
def _load_server_module():
spec = importlib.util.spec_from_file_location("deploy_control_server_public_url_tests", SERVER_PATH)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_create_instance_uses_direct_host_port_url_when_base_domain_is_ip(monkeypatch) -> None:
server = _load_server_module()
commands: list[list[str]] = []
record: dict[str, Any] = {
"instance_id": "urldebug",
"container_name": "app-instance-urldebug",
"host_port": 20005,
"public_url": "http://172.19.207.40:20005",
}
lookups = iter([None, None, record])
monkeypatch.setattr(server, "PUBLIC_BASE_DOMAIN", "172.19.207.40")
monkeypatch.setattr(server, "PUBLIC_PORT", 8088)
monkeypatch.setattr(server, "get_registry_record", lambda **_kwargs: next(lookups))
monkeypatch.setattr(server, "ensure_network", lambda: None)
monkeypatch.setattr(server, "ensure_proxy", lambda: None)
monkeypatch.setattr(server, "wait_for_backend", lambda _record: None)
monkeypatch.setattr(server, "pick_instance_host_port", lambda _instance_id: 20005)
def capture_command(args: list[str], **_kwargs: Any) -> str:
commands.append(args)
return ""
monkeypatch.setattr(server, "run_command", capture_command)
server.create_or_get_instance({
"username": "urldebug",
"password": "secret",
"instance_id": "urldebug",
})
create_command = commands[0]
assert create_command[create_command.index("--host-port") + 1] == "20005"
assert create_command[create_command.index("--host-bind-ip") + 1] == "0.0.0.0"
assert create_command[create_command.index("--public-url") + 1] == "http://172.19.207.40:20005"
assert create_command[create_command.index("--instance-host") + 1] == "urldebug.172.19.207.40"
def test_create_instance_keeps_router_url_when_base_domain_is_dns(monkeypatch) -> None:
server = _load_server_module()
commands: list[list[str]] = []
record: dict[str, Any] = {
"instance_id": "urldebug",
"container_name": "app-instance-urldebug",
"host_port": 20005,
"public_url": "https://urldebug.apps.example.com",
}
lookups = iter([None, None, record])
monkeypatch.setattr(server, "PUBLIC_SCHEME", "https")
monkeypatch.setattr(server, "PUBLIC_BASE_DOMAIN", "apps.example.com")
monkeypatch.setattr(server, "PUBLIC_PORT", 443)
monkeypatch.setattr(server, "get_registry_record", lambda **_kwargs: next(lookups))
monkeypatch.setattr(server, "ensure_network", lambda: None)
monkeypatch.setattr(server, "ensure_proxy", lambda: None)
monkeypatch.setattr(server, "wait_for_backend", lambda _record: None)
monkeypatch.setattr(server, "pick_instance_host_port", lambda _instance_id: 20005)
def capture_command(args: list[str], **_kwargs: Any) -> str:
commands.append(args)
return ""
monkeypatch.setattr(server, "run_command", capture_command)
server.create_or_get_instance({
"username": "urldebug",
"password": "secret",
"instance_id": "urldebug",
})
create_command = commands[0]
assert "--host-port" not in create_command
assert create_command[create_command.index("--public-url") + 1] == "https://urldebug.apps.example.com"