租户 config 的 mcp_servers.* 指向共享 home 路径,但宿主共享 home 里原先没有 MCP 脚本,宿主 app-server 启动 MCP 即文件 不存在、握手断掉,面板 mcp-servers-list 三服务器 toolsError 全是 handshake failed;14:43 的手工拷入能修一次,但下次 MCP 脚本改动后面板又会跑旧版。 本批只在服务端 main() 启动时同步一次:清单唯一定义在 cloud/shared_home_mcp.py(7 文件:playwright_mcp/browser/ remote、websearch_mcp/websearch、imagegen_mcp/imagegen,含 同目录兄弟模块),解析优先用 host-config-host.json 的 codex_home,兜底用 tenants_root 父目录/codex-home;缺失 拷入、不同刷新(原子替换)、相同不动;源缺失/目标不可写 只记 warning,绝不阻塞启动。create_server() 不做同步, 避免单测写向生产共享路径。 被否决的落点:worker _tenant_dirs(只在下个任务派发时跑, 面板要等下一个任务才更新,且不修共享路径本身);deploy host_deploy.sh(会在 shell 里复制第二份清单,且重启不 部署时不刷新);面板请求时同步(把读请求变成写请求, 失败会把面板读数变成 502 风险)。
214 lines
8.5 KiB
Python
214 lines
8.5 KiB
Python
"""Tests for shared-home MCP script sync (cloud/shared_home_mcp.py).
|
|
|
|
No production path is ever touched here: every sync target is a
|
|
temp dir, and the server-resolution tests monkeypatch the resolver
|
|
inputs instead of reading the repo's host-config-host.json (which
|
|
points at the production shared home).
|
|
|
|
Coverage:
|
|
- the 7-file list is the single source of truth (exact set);
|
|
- missing files are copied;
|
|
- differing files are refreshed, same files are untouched
|
|
(mtime unchanged);
|
|
- missing source files do not crash and are reported;
|
|
- an uncreatable target and a per-file unwritable destination
|
|
(a directory in place of a file, root-proof) do not crash and
|
|
do not block the other files;
|
|
- server resolution: local worker home wins, tenants-root
|
|
parent is the fallback;
|
|
- server main() actually calls the sync at startup and survives
|
|
a sync error (fake server, no real port, no real DB).
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
|
|
from shared_home_mcp import SHARED_HOME_MCP_FILES # noqa: E402
|
|
from shared_home_mcp import sync_shared_home_mcp # noqa: E402
|
|
|
|
try:
|
|
from cloud.server import _resolve_shared_home_mcp
|
|
from cloud.server import main as _server_main
|
|
_SERVER_OK = True
|
|
except ImportError:
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
|
try:
|
|
from cloud.server import _resolve_shared_home_mcp
|
|
from cloud.server import main as _server_main
|
|
_SERVER_OK = True
|
|
except ImportError:
|
|
_resolve_shared_home_mcp = None
|
|
_server_main = None
|
|
_SERVER_OK = False
|
|
|
|
|
|
def _write_source(source_dir, contents):
|
|
for name, data in contents.items():
|
|
with open(os.path.join(source_dir, name), "wb") as fh:
|
|
fh.write(data)
|
|
|
|
|
|
class SharedHomeMcpSyncTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self._tmp = tempfile.TemporaryDirectory()
|
|
self.source = os.path.join(self._tmp.name, "cloud")
|
|
self.target = os.path.join(self._tmp.name, "shared-home")
|
|
os.makedirs(self.source)
|
|
self.contents = {name: ("# %s v2\n" % name).encode()
|
|
for name in SHARED_HOME_MCP_FILES}
|
|
|
|
def tearDown(self):
|
|
self._tmp.cleanup()
|
|
|
|
def test_file_list_is_exactly_the_seven(self):
|
|
self.assertEqual(len(SHARED_HOME_MCP_FILES), 7)
|
|
self.assertEqual(len(set(SHARED_HOME_MCP_FILES)), 7)
|
|
self.assertEqual(set(SHARED_HOME_MCP_FILES), {
|
|
"playwright_mcp.py", "playwright_browser.py",
|
|
"playwright_remote.py",
|
|
"websearch_mcp.py", "websearch.py",
|
|
"imagegen_mcp.py", "imagegen.py",
|
|
})
|
|
|
|
def test_missing_files_are_copied(self):
|
|
_write_source(self.source, self.contents)
|
|
result = sync_shared_home_mcp(self.target, self.source)
|
|
self.assertEqual(sorted(result["copied"]),
|
|
sorted(SHARED_HOME_MCP_FILES))
|
|
self.assertEqual(result["refreshed"], [])
|
|
self.assertEqual(result["unchanged"], [])
|
|
self.assertEqual(result["missing"], [])
|
|
self.assertIsNone(result["error"])
|
|
for name, data in self.contents.items():
|
|
with open(os.path.join(self.target, name), "rb") as fh:
|
|
self.assertEqual(fh.read(), data)
|
|
|
|
def test_differing_files_are_refreshed(self):
|
|
_write_source(self.source, self.contents)
|
|
os.makedirs(self.target)
|
|
for name in SHARED_HOME_MCP_FILES:
|
|
with open(os.path.join(self.target, name), "wb") as fh:
|
|
fh.write(b"# old\n")
|
|
result = sync_shared_home_mcp(self.target, self.source)
|
|
self.assertEqual(sorted(result["refreshed"]),
|
|
sorted(SHARED_HOME_MCP_FILES))
|
|
self.assertEqual(result["copied"], [])
|
|
self.assertIsNone(result["error"])
|
|
|
|
def test_same_files_are_untouched(self):
|
|
_write_source(self.source, self.contents)
|
|
os.makedirs(self.target)
|
|
mtimes = {}
|
|
for name, data in self.contents.items():
|
|
path = os.path.join(self.target, name)
|
|
with open(path, "wb") as fh:
|
|
fh.write(data)
|
|
os.utime(path, (1000000000, 1000000000))
|
|
mtimes[name] = os.stat(path).st_mtime
|
|
result = sync_shared_home_mcp(self.target, self.source)
|
|
self.assertEqual(sorted(result["unchanged"]),
|
|
sorted(SHARED_HOME_MCP_FILES))
|
|
self.assertEqual(result["copied"], [])
|
|
self.assertEqual(result["refreshed"], [])
|
|
for name, mtime in mtimes.items():
|
|
self.assertEqual(os.stat(os.path.join(self.target, name)).st_mtime,
|
|
mtime)
|
|
|
|
def test_missing_source_does_not_crash(self):
|
|
# Source dir exists but is empty: every file is missing.
|
|
result = sync_shared_home_mcp(self.target, self.source)
|
|
self.assertEqual(sorted(result["missing"]),
|
|
sorted(SHARED_HOME_MCP_FILES))
|
|
self.assertEqual(result["copied"], [])
|
|
self.assertIsNotNone(result["error"])
|
|
self.assertIn("missing source", result["error"])
|
|
|
|
def test_uncreatable_target_does_not_crash(self):
|
|
_write_source(self.source, self.contents)
|
|
# Target path is an existing file, not a directory:
|
|
# makedirs fails even as root, and must not raise.
|
|
blocker = os.path.join(self._tmp.name, "blocker")
|
|
with open(blocker, "wb") as fh:
|
|
fh.write(b"x")
|
|
result = sync_shared_home_mcp(blocker, self.source)
|
|
self.assertEqual(result["copied"], [])
|
|
self.assertIsNotNone(result["error"])
|
|
|
|
def test_one_unwritable_file_does_not_block_others(self):
|
|
_write_source(self.source, self.contents)
|
|
os.makedirs(self.target)
|
|
bad = SHARED_HOME_MCP_FILES[0]
|
|
os.makedirs(os.path.join(self.target, bad))
|
|
result = sync_shared_home_mcp(self.target, self.source)
|
|
self.assertEqual(len(result["copied"]),
|
|
len(SHARED_HOME_MCP_FILES) - 1)
|
|
self.assertIn(bad, result["error"])
|
|
for name in SHARED_HOME_MCP_FILES[1:]:
|
|
self.assertTrue(os.path.isfile(os.path.join(self.target, name)))
|
|
|
|
def test_no_shared_home_does_not_crash(self):
|
|
result = sync_shared_home_mcp(None, self.source)
|
|
self.assertIsNotNone(result["error"])
|
|
self.assertEqual(result["copied"], [])
|
|
|
|
|
|
@unittest.skipUnless(_SERVER_OK, "cloud.server import unavailable")
|
|
class SharedHomeMcpServerTest(unittest.TestCase):
|
|
def test_local_worker_home_wins(self):
|
|
import cloud.server as server_mod
|
|
with mock.patch.object(server_mod, "_local_worker_codex_home",
|
|
return_value="/tmp/fake-shared"):
|
|
self.assertEqual(_resolve_shared_home_mcp(object()),
|
|
"/tmp/fake-shared")
|
|
|
|
def test_tenants_root_parent_is_fallback(self):
|
|
import cloud.server as server_mod
|
|
|
|
class _FakeServer:
|
|
tenants_root = "/tmp/fake-root/tenants"
|
|
|
|
with mock.patch.object(server_mod, "_local_worker_codex_home",
|
|
return_value=None):
|
|
self.assertEqual(_resolve_shared_home_mcp(_FakeServer()),
|
|
"/tmp/fake-root/codex-home")
|
|
|
|
def test_main_calls_sync_and_survives_sync_error(self):
|
|
import cloud.server as server_mod
|
|
|
|
class _FakeServer:
|
|
rate_limiter = None
|
|
server_address = ("127.0.0.1", 0)
|
|
|
|
def serve_forever(self):
|
|
raise KeyboardInterrupt
|
|
|
|
calls = {}
|
|
fake = _FakeServer()
|
|
|
|
def _fake_sync(target):
|
|
calls["target"] = target
|
|
return {"copied": [], "refreshed": [], "unchanged": [],
|
|
"missing": [], "error": "boom (test)"}
|
|
|
|
with mock.patch.object(server_mod, "create_server",
|
|
return_value=fake), \
|
|
mock.patch.object(server_mod, "_resolve_shared_home_mcp",
|
|
return_value="/tmp/fake-shared"), \
|
|
mock.patch.object(server_mod, "_sync_shared_home_mcp",
|
|
side_effect=_fake_sync), \
|
|
mock.patch.object(server_mod, "start_rate_limit_sweeper"):
|
|
# No exception: sync error is warning-only, and the
|
|
# KeyboardInterrupt from serve_forever ends main().
|
|
_server_main(["--db", "/tmp/fake.db", "--port", "0"])
|
|
self.assertEqual(calls.get("target"), "/tmp/fake-shared")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|