From 29845657f5b4a233571e1285fa1f126684808d1e Mon Sep 17 00:00:00 2001 From: steven_li Date: Tue, 16 Jun 2026 10:29:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(deploy-control):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5IP=E7=BB=91=E5=AE=9A=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增ipaddress模块导入以支持IP地址处理, 添加DEPLOY_DIRECT_PUBLIC_HOST_BIND_IP环境变量配置, 实现IP地址验证、直接URL构建和端口分配功能, 当基础域名是IP地址时自动使用直接绑定模式, 支持IPv4和IPv6地址格式并添加相应参数传递 --- deploy-control/server.py | 46 ++++++++++++- deploy-control/tests/test_public_url.py | 91 +++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 deploy-control/tests/test_public_url.py diff --git a/deploy-control/server.py b/deploy-control/server.py index c178d5f..49eef15 100755 --- a/deploy-control/server.py +++ b/deploy-control/server.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 from __future__ import annotations +import ipaddress import json import os 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_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") +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"} 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") @@ -195,6 +197,39 @@ def build_public_url(host: str) -> str: 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: container_name = str(record.get("container_name", "") or "").strip() if container_name: @@ -247,7 +282,13 @@ def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]: if existing is None: ensure_network() 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_outlook_mcp_url = str( 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", 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: command.extend(["--authz-base-url", authz_base_url]) if DEFAULT_AUTHZ_INTERNAL_TOKEN: diff --git a/deploy-control/tests/test_public_url.py b/deploy-control/tests/test_public_url.py new file mode 100644 index 0000000..ee4307e --- /dev/null +++ b/deploy-control/tests/test_public_url.py @@ -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"