已合并
feat:新增实例绑定功能 #468
feat:新增实例绑定功能 #468
已合并
王欣创建于 22 天前
40 个文件变更+2645-2853
Mapplications/agent_runtime/src/agent_runtime/main.py+30-3文件内容审核中,请稍后刷新重试
Mapplications/manager/.env.example+1-10文件内容审核中,请稍后刷新重试
Mapplications/manager/manager_server/scripts/enterprise_config_chat.py+1-1文件内容审核中,请稍后刷新重试
@@ -56,6 +56,8 @@ def create_app() -> FastAPI:
56 description="JiuwenClaw EE 管理平面(Claw Manager)",56 description="JiuwenClaw EE 管理平面(Claw Manager)",
57 version=__version__,57 version=__version__,
58 lifespan=lifespan,58 lifespan=lifespan,
59+ # 关闭尾斜杠自动 307:经 manager-web / Ingress 反代时 Location 会泄漏集群内网 DNS
60+ redirect_slashes=False,
59 )61 )
60 application.add_middleware(62 application.add_middleware(
61 CORSMiddleware,63 CORSMiddleware,
@@ -1,341 +0,0 @@
1-"""本地拉起 Gateway + AgentServer(开发/联调用),并写入 per-instance 工作区配置。"""
2- 
3-from __future__ import annotations
4- 
5-import os
6-import socket
7-import subprocess
8-import sys
9-import time
10-from pathlib import Path
11-from typing import Any
12- 
13-import yaml
14-from openjiuwen_runtime.foundation.db.handler import DBHandler
15- 
16-from manager_server.infrastructure.config import Settings
17-from manager_server.core.instance.instance_service import (
18- create_instance_row,
19- dumps_auth_config,
20- generate_unique_jiuwenclaw_id,
21- get_instance_row,
22- merge_instance_data,
23-)
24- 
25- 
26-def _repo_root() -> Path:
27- # .../claw_manager/src/manager_server/core/instance/instance_provisioner.py -> repo root
28- return Path(__file__).resolve().parents[7]
29- 
30- 
31-def _resolve_provision_python(repo_root: Path, settings: Settings) -> str:
32- """解析用于拉起 Gateway / AgentServer 的 Python 解释器。
33- 
34- 优先级:``CLAWMANAGER_PROVISION_PYTHON`` > 仓库根 ``.venv`` > 当前进程的 ``sys.executable``。
35- Manager 常在 ``claw_manager/.venv`` 中运行,而 jiuwenclaw 依赖(如 openjiuwen)安装在仓库根 ``.venv``。
36- """
37- if settings.provision_python:
38- explicit = Path(settings.provision_python).expanduser()
39- if not explicit.is_file():
40- raise FileNotFoundError(f"provision_python not found: {explicit}")
41- return str(explicit.resolve())
42- 
43- if os.name == "nt":
44- venv_candidates = (
45- repo_root / ".venv" / "Scripts" / "python.exe",
46- repo_root / ".venv" / "Scripts" / "python",
47- )
48- else:
49- venv_candidates = (
50- repo_root / ".venv" / "bin" / "python",
51- repo_root / ".venv" / "bin" / "python3",
52- )
53- for candidate in venv_candidates:
54- if candidate.is_file():
55- return str(candidate.resolve())
56- 
57- return sys.executable
58- 
59- 
60-def _pick_port() -> int:
61- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
62- s.bind(("127.0.0.1", 0))
63- return int(s.getsockname()[1])
64- 
65- 
66-def _materialize_instance_config(
67- *,
68- instance_dir: Path,
69- template_path: Path,
70- rest_port: int,
71- agent_sqlite: Path,
72-) -> None:
73- instance_dir.mkdir(parents=True, exist_ok=True)
74- cfg_dir = instance_dir / "config"
75- cfg_dir.mkdir(parents=True, exist_ok=True)
76- raw = yaml.safe_load(template_path.read_text(encoding="utf-8")) or {}
77- if not isinstance(raw, dict):
78- raw = {}
79- ext = raw.get("extensions")
80- if not isinstance(ext, dict):
81- ext = {}
82- raw["extensions"] = ext
83- ext["extension_dirs"] = "${EXTENSION_DIRS}"
84- ac = ext.get("agent_client_rest")
85- if not isinstance(ac, dict):
86- ac = {}
87- ext["agent_client_rest"] = ac
88- db = ac.get("database")
89- if not isinstance(db, dict):
90- db = {}
91- ac["database"] = db
92- 
93- # 支持通过环境变量配置数据库类型
94- db_type = os.getenv("JIUWENCLAW_GATEWAY_DB_TYPE", "mysql").strip().lower()
95- if db_type == "mysql":
96- db["db_type"] = "mysql"
97- db["db"] = {
98- "host": os.getenv("DB_HOST", os.getenv("JIUWENCLAW_GATEWAY_DB_HOST", "localhost")),
99- "port": int(os.getenv("DB_PORT", os.getenv("JIUWENCLAW_GATEWAY_DB_PORT", "3306"))),
100- "user": os.getenv("DB_USER", os.getenv("JIUWENCLAW_GATEWAY_DB_USER", "root")),
101- "password": os.getenv("DB_PASSWORD", os.getenv("JIUWENCLAW_GATEWAY_DB_PASSWORD", "123456")),
102- "db_name": os.getenv("DB_NAME", os.getenv("JIUWENCLAW_GATEWAY_DB_NAME", "gateway")),
103- }
104- else:
105- # 默认使用 SQLite
106- db["db_type"] = "sqlite"
107- db["sqlite_path"] = str(agent_sqlite.resolve())
108- 
109- # 保留模板中的 enabled: ${AGENT_CLIENT_REST_ENABLED:-true},便于 AgentServer 进程关闭 REST、仅 Gateway 监听端口
110- ac["host"] = "127.0.0.1"
111- ac["port"] = rest_port
112- (cfg_dir / "config.yaml").write_text(
113- yaml.safe_dump(raw, allow_unicode=True, sort_keys=False),
114- encoding="utf-8",
115- )
116- 
117- 
118-def _child_env_common(
119- *,
120- settings: Settings,
121- jiuwenclaw_id: str,
122- instance_dir: Path,
123- management_api_base: str,
124- extension_dirs: str,
125-) -> dict[str, str]:
126- out = os.environ.copy()
127- out["JIUWENCLAW_DATA_DIR"] = str(instance_dir.resolve())
128- out["JIUWENCLAW_CONFIG_DIR"] = str((instance_dir / "config").resolve())
129- out["EXTENSION_DIRS"] = extension_dirs
130- out["JIUWENCLAW_ID"] = jiuwenclaw_id
131- out["MANAGEMENT_API_BASE"] = management_api_base
132- 
133- db_type = os.getenv("JIUWENCLAW_GATEWAY_DB_TYPE", "mysql").strip().lower()
134- out["JIUWENCLAW_GATEWAY_DB_TYPE"] = db_type
135- out["AGENT_CLIENT_DB_TYPE"] = db_type
136- out["MANAGER_WS_CLIENT_DB_TYPE"] = db_type
137- if db_type == "mysql":
138- host = os.getenv("DB_HOST", os.getenv("JIUWENCLAW_GATEWAY_DB_HOST", "localhost"))
139- port = os.getenv("DB_PORT", os.getenv("JIUWENCLAW_GATEWAY_DB_PORT", "3306"))
140- user = os.getenv("DB_USER", os.getenv("JIUWENCLAW_GATEWAY_DB_USER", "root"))
141- password = os.getenv("DB_PASSWORD", os.getenv("JIUWENCLAW_GATEWAY_DB_PASSWORD", "123456"))
142- db_name = os.getenv("DB_NAME", os.getenv("JIUWENCLAW_GATEWAY_DB_NAME", "gateway"))
143- out["DB_HOST"] = host
144- out["DB_PORT"] = str(port)
145- out["DB_USER"] = user
146- out["DB_PASSWORD"] = password
147- out["DB_NAME"] = db_name
148- out["JIUWENCLAW_GATEWAY_DB_HOST"] = host
149- out["JIUWENCLAW_GATEWAY_DB_PORT"] = str(port)
150- out["JIUWENCLAW_GATEWAY_DB_USER"] = user
151- out["JIUWENCLAW_GATEWAY_DB_PASSWORD"] = password
152- out["JIUWENCLAW_GATEWAY_DB_NAME"] = db_name
153- 
154- return out
155- 
156- 
157-async def provision_local_jiuwenclaw(
158- handler: DBHandler,
159- settings: Settings,
160- *,
161- jiuwenclaw_name: str,
162- creator_id: str,
163- description: str | None,
164-) -> dict[str, Any]:
165- if not settings.allow_local_provision:
166- raise ValueError("local provision disabled (set MANAGER_ALLOW_LOCAL_PROVISION=true)")
167- 
168- repo_root = Path(settings.provision_repo_root or _repo_root()).resolve()
169- gateway_ext = (repo_root / "packages" / "jiuwenclaw-ee" / "gateway" / "extensions").resolve()
170- ext_dirs = settings.provision_extension_dirs or str(gateway_ext)
171- tpl = Path(
172- settings.instance_config_template or (repo_root / "docker" / "config" / "config.yaml")
173- ).resolve()
174- if not tpl.is_file():
175- raise FileNotFoundError(f"config template not found: {tpl}")
176- 
177- root = Path(settings.provision_workspace_root).expanduser().resolve()
178- jiuwenclaw_id = await generate_unique_jiuwenclaw_id(handler)
179- instance_dir = root / jiuwenclaw_id
180- if instance_dir.exists():
181- raise FileExistsError(f"workspace already exists: {instance_dir}")
182- 
183- agent_port = _pick_port()
184- web_port = _pick_port()
185- rest_port = _pick_port()
186- agent_db = instance_dir / "agent_client.db"
187- 
188- _materialize_instance_config(
189- instance_dir=instance_dir,
190- template_path=tpl,
191- rest_port=rest_port,
192- agent_sqlite=agent_db,
193- )
194- 
195- management_api_base = f"http://127.0.0.1:{rest_port}"
196- 
197- await create_instance_row(
198- handler,
199- {
200- "jiuwenclaw_id": jiuwenclaw_id,
201- "jiuwenclaw_name": jiuwenclaw_name,
202- "creator_id": creator_id,
203- "description": description,
204- "k8s_master_host": "local-provision",
205- "k8s_auth_type": "none",
206- "k8s_auth_config": dumps_auth_config({}),
207- "k8s_namespace": "local",
208- # Gateway 经 Manager WS 注册成功后由 register_gateway_via_ws 置为 online
209- "status": "pending",
210- "resource_quota": None,
211- "data": {
212- "management_api_base": management_api_base,
213- "provision": {
214- "workspace": str(instance_dir),
215- "ports": {
216- "agent_server": agent_port,
217- "web": web_port,
218- "agent_client_rest": rest_port,
219- },
220- },
221- },
222- "group_id": "default",
223- "space_id": "default",
224- }
225- )
226- 
227- py = _resolve_provision_python(repo_root, settings)
228- py_path = settings.provision_pythonpath or f"{repo_root}:{repo_root / 'packages'}"
229- base_env = _child_env_common(
230- settings=settings,
231- jiuwenclaw_id=jiuwenclaw_id,
232- instance_dir=instance_dir,
233- management_api_base=management_api_base,
234- extension_dirs=ext_dirs,
235- )
236- base_env["PYTHONPATH"] = py_path
237- base_env["PYTHONUNBUFFERED"] = "1"
238- base_env["PYTHONUTF8"] = "1"
239- base_env["PYTHONIOENCODING"] = "utf-8"
240- 
241- env_as = dict(base_env)
242- env_as["AGENT_SERVER_HOST"] = "127.0.0.1"
243- env_as["AGENT_SERVER_PORT"] = str(agent_port)
244- # AgentServer 不加载 Gateway 扩展(如 manager_ws_client),避免与 Gateway 争抢 Manager WS 连接
245- env_as["EXTENSION_DIRS"] = ""
246- # 与 Gateway 共用同一 REST 端口时,仅由 Gateway 挂载 agent_client_rest,避免双进程抢端口导致 62160 无监听
247- env_as["AGENT_CLIENT_REST_ENABLED"] = "false"
248- 
249- env_gw = dict(base_env)
250- env_gw["AGENT_SERVER_URL"] = f"ws://127.0.0.1:{agent_port}"
251- env_gw["WEB_HOST"] = "127.0.0.1"
252- env_gw["WEB_PORT"] = str(web_port)
253- env_gw["AGENT_CLIENT_REST_ENABLED"] = "true"
254- 
255- log_dir = instance_dir / "provision_logs"
256- log_dir.mkdir(parents=True, exist_ok=True)
257- with (
258- open(
259- log_dir / "agentserver.log",
260- "a",
261- encoding="utf-8",
262- errors="replace",
263- buffering=1,
264- ) as log_as,
265- open(
266- log_dir / "gateway.log",
267- "a",
268- encoding="utf-8",
269- errors="replace",
270- buffering=1,
271- ) as log_gw,
272- ):
273- proc_as = subprocess.Popen(
274- [py, "-m", "jiuwenclaw.app_agentserver", "--port", str(agent_port)],
275- env=env_as,
276- cwd=str(repo_root),
277- stdout=log_as,
278- stderr=subprocess.STDOUT,
279- )
280- time.sleep(1.5)
281- proc_gw = subprocess.Popen(
282- [py, "-m", "jiuwenclaw.app_gateway", "--port", str(web_port), "-u", f"ws://127.0.0.1:{agent_port}"],
283- env=env_gw,
284- cwd=str(repo_root),
285- stdout=log_gw,
286- stderr=subprocess.STDOUT,
287- )
288- 
289- full_prov: dict[str, Any] = {
290- "workspace": str(instance_dir),
291- "ports": {"agent_server": agent_port, "web": web_port, "agent_client_rest": rest_port},
292- "pids": {"agentserver": proc_as.pid, "gateway": proc_gw.pid},
293- "python": py,
294- "logs": {
295- "agentserver": str(log_dir / "agentserver.log"),
296- "gateway": str(log_dir / "gateway.log"),
297- },
298- }
299- await merge_instance_data(handler, jiuwenclaw_id, {"provision": full_prov})
300- 
301- return {
302- "jiuwenclaw_id": jiuwenclaw_id,
303- "status": "pending",
304- "management_api_base": management_api_base,
305- "ports": {"agent_server": agent_port, "web": web_port, "agent_client_rest": rest_port},
306- "workspace": str(instance_dir),
307- "pids": {"agentserver": proc_as.pid, "gateway": proc_gw.pid},
308- "python": py,
309- "logs": {
310- "agentserver": str(log_dir / "agentserver.log"),
311- "gateway": str(log_dir / "gateway.log"),
312- },
313- }
314- 
315- 
316-async def terminate_local_if_present(handler: DBHandler, jiuwenclaw_id: str) -> None:
317- row = await get_instance_row(handler, jiuwenclaw_id)
318- if row is None:
319- return
320- prov = (row.data or {}).get("provision") if isinstance(row.data, dict) else None
321- pids = prov.get("pids") if isinstance(prov, dict) else None
322- if not isinstance(pids, dict):
323- return
324- import signal
325- 
326- for pid in pids.values():
327- if pid is None:
328- continue
329- try:
330- os.kill(int(pid), signal.SIGTERM)
331- except (ValueError, OSError):
332- pass
333- time.sleep(0.5)
334- for pid in pids.values():
335- if pid is None:
336- continue
337- try:
338- os.kill(int(pid), 0)
339- os.kill(int(pid), signal.SIGKILL)
340- except (ValueError, OSError):
341- pass
Mapplications/manager/manager_server/tests/unit_tests/manager/conftest.py+17-0文件内容审核中,请稍后刷新重试
@@ -0,0 +1,104 @@
1+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2+"""gateway online 全量同步触发条件。"""
3+ 
4+from __future__ import annotations
5+ 
6+from unittest.mock import AsyncMock, MagicMock, patch
7+ 
8+import pytest
9+ 
10+from manager_server.core.instance.instance_service import (
11+ maybe_full_sync_gateway_on_online,
12+)
13+ 
14+pytestmark = pytest.mark.unit
15+ 
16+ 
17+@pytest.mark.asyncio
18+async def test_skip_when_already_online():
19+ handler = AsyncMock()
20+ with patch(
21+ "manager_server.core.instance.instance_data_lifecycle.sync_data_to_gateway_on_register",
22+ new_callable=AsyncMock,
23+ ) as sync_mock:
24+ await maybe_full_sync_gateway_on_online(
25+ handler, "jid-1", previous_gateway_status="online"
26+ )
27+ sync_mock.assert_not_awaited()
28+ 
29+ 
30+@pytest.mark.asyncio
31+async def test_sync_when_pending_to_online():
32+ handler = AsyncMock()
33+ row = MagicMock(gateway_config_host="http://gw:8080", data=None)
34+ with (
35+ patch(
36+ "manager_server.core.instance.instance_service.get_instance_row",
37+ new_callable=AsyncMock,
38+ return_value=row,
39+ ),
40+ patch(
41+ "manager_server.manager_config_push.endpoint.resolve_gateway_endpoint",
42+ return_value="http://gw:8080",
43+ ),
44+ patch(
45+ "manager_server.core.instance.instance_data_lifecycle.sync_data_to_gateway_on_register",
46+ new_callable=AsyncMock,
47+ return_value={"ok": True},
48+ ) as sync_mock,
49+ ):
50+ await maybe_full_sync_gateway_on_online(
51+ handler, "jid-1", previous_gateway_status="pending"
52+ )
53+ sync_mock.assert_awaited_once_with(handler, "jid-1")
54+ 
55+ 
56+@pytest.mark.asyncio
57+async def test_sync_when_offline_to_online():
58+ handler = AsyncMock()
59+ row = MagicMock(gateway_config_host="http://gw:8080", data=None)
60+ with (
61+ patch(
62+ "manager_server.core.instance.instance_service.get_instance_row",
63+ new_callable=AsyncMock,
64+ return_value=row,
65+ ),
66+ patch(
67+ "manager_server.manager_config_push.endpoint.resolve_gateway_endpoint",
68+ return_value="http://gw:8080",
69+ ),
70+ patch(
71+ "manager_server.core.instance.instance_data_lifecycle.sync_data_to_gateway_on_register",
72+ new_callable=AsyncMock,
73+ return_value={"ok": True},
74+ ) as sync_mock,
75+ ):
76+ await maybe_full_sync_gateway_on_online(
77+ handler, "jid-1", previous_gateway_status="offline"
78+ )
79+ sync_mock.assert_awaited_once()
80+ 
81+ 
82+@pytest.mark.asyncio
83+async def test_skip_when_no_gateway_host():
84+ handler = AsyncMock()
85+ row = MagicMock(gateway_config_host=None, data=None)
86+ with (
87+ patch(
88+ "manager_server.core.instance.instance_service.get_instance_row",
89+ new_callable=AsyncMock,
90+ return_value=row,
91+ ),
92+ patch(
93+ "manager_server.manager_config_push.endpoint.resolve_gateway_endpoint",
94+ return_value=None,
95+ ),
96+ patch(
97+ "manager_server.core.instance.instance_data_lifecycle.sync_data_to_gateway_on_register",
98+ new_callable=AsyncMock,
99+ ) as sync_mock,
100+ ):
101+ await maybe_full_sync_gateway_on_online(
102+ handler, "jid-1", previous_gateway_status="offline"
103+ )
104+ sync_mock.assert_not_awaited()
Mapplications/manager/manager_web/src/pages/OverviewPage.tsx+6-6文件内容审核中,请稍后刷新重试
Mapplications/manager/manager_web/src/pages/iam/instanceBinding.tsx+1-1文件内容审核中,请稍后刷新重试
Mapplications/manager/manager_web/src/pages/instance/InstanceDetailPage.tsx+7-2文件内容审核中,请稍后刷新重试
Mapplications/manager/manager_web/src/pages/instance/InstanceListPage.tsx+38-43文件内容审核中,请稍后刷新重试
Mapplications/manager/manager_web/src/types/instance.ts+14-21文件内容审核中,请稍后刷新重试