fix(chv): cell 不再把 /run/chv 的组改回 root
chv-cell 每次跑 cell 都 _ensure_bind_source(BROKER_DIR, uid=0, gid=0),把 /run/chv 的组改回 root,抹掉 tmpfiles 的 chv-browser 组;之后 chv-browser 重启时 os.remove(browser.sock) 报 Permission denied、无限 crash-loop(构建 #8 客机 62 次重启才 被发现,03:42 驱动两次跑 cell 正好在补丁后、重启前)。改:组 改取 chv-browser 的 gid(不存在回落 0);serve() 删 socket 失败时报错说清修法,不再只剩裸 traceback。
This commit is contained in:
1 parent
0bfb00ab1b
commit
435664925e
3 files changed
+105
-3
No files matched your search
@@ -550,12 +550,27 @@ def handle_request(agent, req):
|
|||||||
"unmount_full/ping)" % (op,)}
|
"unmount_full/ping)" % (op,)}
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_stale_socket(sock_path):
|
||||||
|
"""删掉上次残留的 socket。删不掉时不静默吞掉:目录权限不对
|
||||||
|
(/run/chv 须是 0775 root:chv-browser)就会无限 crash-loop,
|
||||||
|
报错必须说清修法(2026-11-08 构建 #8 live 实证:cell 曾把组
|
||||||
|
改回 root,服务 62 次重启才被发现)。"""
|
||||||
|
if not os.path.exists(sock_path):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
os.remove(sock_path)
|
||||||
|
except PermissionError:
|
||||||
|
raise PermissionError(
|
||||||
|
"删不掉旧 socket %s:/run/chv 须是 0775 root:chv-browser "
|
||||||
|
"(tmpfiles.d/chv.conf),且跑 cell 不可把它 chown 回 "
|
||||||
|
"root:root" % sock_path)
|
||||||
|
|
||||||
|
|
||||||
def serve(sock_path=BROWSER_SOCK, shots_dir=SHOTS_DIR,
|
def serve(sock_path=BROWSER_SOCK, shots_dir=SHOTS_DIR,
|
||||||
profile_dir=None, cdp_port=None):
|
profile_dir=None, cdp_port=None):
|
||||||
"""监听 Unix socket,逐连接处理。socket 0600(只给属主 +
|
"""监听 Unix socket,逐连接处理。socket 0600(只给属主 +
|
||||||
broker 的 root 连)。建不起来即抛错,不要静默带病跑。"""
|
broker 的 root 连)。建不起来即抛错,不要静默带病跑。"""
|
||||||
if os.path.exists(sock_path):
|
_remove_stale_socket(sock_path)
|
||||||
os.remove(sock_path)
|
|
||||||
os.makedirs(os.path.dirname(sock_path), exist_ok=True)
|
os.makedirs(os.path.dirname(sock_path), exist_ok=True)
|
||||||
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
srv.bind(sock_path)
|
srv.bind(sock_path)
|
||||||
|
|||||||
+17
-1
@@ -57,6 +57,7 @@ nsenter 进单元 init 的 netns 做。veth 建不起来即大声失败,不静
|
|||||||
未装;本启动器由 root 写映射,不碰这两处限制。
|
未装;本启动器由 root 写映射,不碰这两处限制。
|
||||||
"""
|
"""
|
||||||
import ctypes
|
import ctypes
|
||||||
|
import grp
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import pwd
|
import pwd
|
||||||
@@ -519,6 +520,20 @@ def cell_network_commands(cell_pid):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _broker_dir_gid():
|
||||||
|
"""broker 目录(/run/chv)的组:chv-browser 要在里面建 socket,
|
||||||
|
组须是 chv-browser(0775 时组可写);该组不存在时回落 root(0)。
|
||||||
|
|
||||||
|
2026-11-08 构建 #8 live 实证:这里曾写死 gid=0,每次跑 cell 都
|
||||||
|
把 /run/chv 的组改回 root,抹掉 tmpfiles 的 chv-browser 组;
|
||||||
|
之后 chv-browser 重启时 os.remove(browser.sock) 报 Permission
|
||||||
|
denied、无限 crash-loop(62 次重启才被发现)。"""
|
||||||
|
try:
|
||||||
|
return grp.getgrnam("chv-browser").gr_gid
|
||||||
|
except KeyError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _ensure_bind_source(path, uid=CELL_UID, gid=CELL_GID):
|
def _ensure_bind_source(path, uid=CELL_UID, gid=CELL_GID):
|
||||||
"""bind 源目录须存在且属 chv(单元内 root 即客机 chv,属主不对
|
"""bind 源目录须存在且属 chv(单元内 root 即客机 chv,属主不对
|
||||||
就写不进 workspace)。root 建的目录要 chown 回 chv。"""
|
就写不进 workspace)。root 建的目录要 chown 回 chv。"""
|
||||||
@@ -738,7 +753,8 @@ def run_cell(command, network="veth"):
|
|||||||
return 2
|
return 2
|
||||||
_ensure_bind_source(WORKSPACE)
|
_ensure_bind_source(WORKSPACE)
|
||||||
_ensure_bind_source(CODEX_HOME)
|
_ensure_bind_source(CODEX_HOME)
|
||||||
_ensure_bind_source(BROKER_DIR, uid=0, gid=0)
|
# broker 目录不写死 gid=0:组须留给 chv-browser(见 _broker_dir_gid)。
|
||||||
|
_ensure_bind_source(BROKER_DIR, uid=0, gid=_broker_dir_gid())
|
||||||
spec = build_sandbox_spec(command, network=network)
|
spec = build_sandbox_spec(command, network=network)
|
||||||
print("chv-cell: 启动单元:%s"
|
print("chv-cell: 启动单元:%s"
|
||||||
% json.dumps(spec, ensure_ascii=False), file=sys.stderr)
|
% json.dumps(spec, ensure_ascii=False), file=sys.stderr)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ def _load_script(name):
|
|||||||
|
|
||||||
|
|
||||||
BROWSER = _load_script("chv-browser.py")
|
BROWSER = _load_script("chv-browser.py")
|
||||||
|
CELL = _load_script("chv-cell.py")
|
||||||
EXECD = _load_script("chv-execd.py")
|
EXECD = _load_script("chv-execd.py")
|
||||||
|
|
||||||
|
|
||||||
@@ -244,6 +245,76 @@ class SnapshotApiTest(unittest.TestCase):
|
|||||||
self.assertEqual(snap["a11y"]["role"], "WebArea")
|
self.assertEqual(snap["a11y"]["role"], "WebArea")
|
||||||
|
|
||||||
|
|
||||||
|
class CellBrokerDirTest(unittest.TestCase):
|
||||||
|
def test_broker_dir_gid_prefers_chv_browser_group(self):
|
||||||
|
fake = mock.Mock()
|
||||||
|
fake.gr_gid = 991
|
||||||
|
with mock.patch.object(CELL.grp, "getgrnam",
|
||||||
|
return_value=fake) as g:
|
||||||
|
self.assertEqual(CELL._broker_dir_gid(), 991)
|
||||||
|
g.assert_called_once_with("chv-browser")
|
||||||
|
|
||||||
|
def test_broker_dir_gid_falls_back_to_root_when_group_missing(self):
|
||||||
|
with mock.patch.object(CELL.grp, "getgrnam",
|
||||||
|
side_effect=KeyError("chv-browser")):
|
||||||
|
self.assertEqual(CELL._broker_dir_gid(), 0)
|
||||||
|
|
||||||
|
def test_ensure_bind_source_chowns_to_given_ids(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
CELL._ensure_bind_source(tmp, uid=os.getuid(),
|
||||||
|
gid=os.getgid())
|
||||||
|
st = os.stat(tmp)
|
||||||
|
self.assertEqual((st.st_uid, st.st_gid),
|
||||||
|
(os.getuid(), os.getgid()))
|
||||||
|
|
||||||
|
def test_run_cell_keeps_browser_group_on_broker_dir(self):
|
||||||
|
# 接线钉死:run_cell 传给 broker 目录的 gid 必须是
|
||||||
|
# _broker_dir_gid(),绝不能再写死 gid=0(2026-11-08 live
|
||||||
|
# 根因:gid=0 每次跑 cell 都把 /run/chv 改回 root:root,
|
||||||
|
# chv-browser 下次重启删不掉旧 socket、无限 crash-loop)。
|
||||||
|
src = (ROOT / "chv-guest" / "chv-cell.py").read_text()
|
||||||
|
self.assertIn("_ensure_bind_source(BROKER_DIR, uid=0, "
|
||||||
|
"gid=_broker_dir_gid())", src)
|
||||||
|
self.assertNotIn("_ensure_bind_source(BROKER_DIR, uid=0, "
|
||||||
|
"gid=0)", src)
|
||||||
|
|
||||||
|
|
||||||
|
class StaleSocketTest(unittest.TestCase):
|
||||||
|
def test_remove_stale_socket_removes_existing_file(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
sock = os.path.join(tmp, "browser.sock")
|
||||||
|
open(sock, "w").write("stale")
|
||||||
|
BROWSER._remove_stale_socket(sock)
|
||||||
|
self.assertFalse(os.path.exists(sock))
|
||||||
|
|
||||||
|
def test_remove_stale_socket_missing_is_noop(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
BROWSER._remove_stale_socket(os.path.join(tmp, "nope.sock"))
|
||||||
|
|
||||||
|
def test_remove_stale_socket_permission_error_names_fix(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
sub = os.path.join(tmp, "run-chv")
|
||||||
|
os.makedirs(sub)
|
||||||
|
sock = os.path.join(sub, "browser.sock")
|
||||||
|
open(sock, "w").write("stale")
|
||||||
|
os.chmod(sub, 0o555)
|
||||||
|
try:
|
||||||
|
# 以 root 跑单测时目录权限挡不住,用 mock 钉死分支。
|
||||||
|
if os.geteuid() == 0:
|
||||||
|
with mock.patch.object(
|
||||||
|
BROWSER.os, "remove",
|
||||||
|
side_effect=PermissionError(13, "deny")):
|
||||||
|
with self.assertRaises(PermissionError) as ctx:
|
||||||
|
BROWSER._remove_stale_socket(sock)
|
||||||
|
else:
|
||||||
|
with self.assertRaises(PermissionError) as ctx:
|
||||||
|
BROWSER._remove_stale_socket(sock)
|
||||||
|
self.assertIn("0775 root:chv-browser", str(ctx.exception))
|
||||||
|
self.assertIn("chv.conf", str(ctx.exception))
|
||||||
|
finally:
|
||||||
|
os.chmod(sub, 0o755)
|
||||||
|
|
||||||
|
|
||||||
class ScreenshotTest(unittest.TestCase):
|
class ScreenshotTest(unittest.TestCase):
|
||||||
def test_jpeg_q70_width_1280(self):
|
def test_jpeg_q70_width_1280(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
|||||||
Reference in new issue
Block a user