fix(chv): 数据盘挂载升格为 oneshot 服务,浏览器启动时重解析 profile
This commit is contained in:
1 parent
c7ffb1fe6f
commit
5dc75128f8
5 files changed
+145
-12
No files matched your search
+17
-5
@@ -323,14 +323,26 @@ VC_ARGS=(-a "$OUT/disk.raw"
|
|||||||
--run-command 'dpkg-query -W -f "\${Package} \${Version}\n" > /app/guest-versions-dpkg.txt; pip3 freeze > /app/guest-versions-pip.txt || true'
|
--run-command 'dpkg-query -W -f "\${Package} \${Version}\n" > /app/guest-versions-dpkg.txt; pip3 freeze > /app/guest-versions-pip.txt || true'
|
||||||
--run-command 'useradd --system --home-dir /var/lib/chv-browser --shell /usr/sbin/nologin chv-browser && mkdir -p /var/lib/chv-browser /run/chv/shots && chown chv-browser:chv-browser /var/lib/chv-browser /run/chv/shots && chmod 0755 /run/chv /run/chv/shots'
|
--run-command 'useradd --system --home-dir /var/lib/chv-browser --shell /usr/sbin/nologin chv-browser && mkdir -p /var/lib/chv-browser /run/chv/shots && chown chv-browser:chv-browser /var/lib/chv-browser /run/chv/shots && chmod 0755 /run/chv /run/chv/shots'
|
||||||
--run-command 'printf "[Unit]\nDescription=CHV cell broker (Muse P2)\nAfter=network.target\n\n[Service]\nType=simple\nExecStart=/usr/bin/python3 /usr/local/bin/chv-broker\nRestart=on-failure\nRestartSec=2\n\n[Install]\nWantedBy=multi-user.target\n" > /etc/systemd/system/chv-broker.service'
|
--run-command 'printf "[Unit]\nDescription=CHV cell broker (Muse P2)\nAfter=network.target\n\n[Service]\nType=simple\nExecStart=/usr/bin/python3 /usr/local/bin/chv-broker\nRestart=on-failure\nRestartSec=2\n\n[Install]\nWantedBy=multi-user.target\n" > /etc/systemd/system/chv-broker.service'
|
||||||
# 浏览器单元排在数据盘挂载之后(home-chv-workspace.mount 是
|
# 数据盘挂载服务(2026-11-08 构建 #10 驱动实证):此前挂盘只靠
|
||||||
# cloud-init 生成的挂载单元)。绝不能 After=cloud-final.service:
|
# cloud-final 的 runcmd 调 chv-data-mount,home-chv-workspace.mount
|
||||||
|
# 是 mount(8) 运行时生成的临时单元、启动事务加载时根本不存在,
|
||||||
|
# 浏览器 After=它纯属空操作——浏览器 21:46:40 启动,挂盘 21:46:45
|
||||||
|
# 才完成,profile 回落本机盘。升格为 oneshot 服务:After=
|
||||||
|
# cloud-config.service(chv 用户与 /home/chv 由它建好),
|
||||||
|
# WantedBy=multi-user.target;浏览器 After 它即真有序。runcmd
|
||||||
|
# 里那行保留(脚本幂等:已挂载即退出),作二次保险。绝不能
|
||||||
|
# After=cloud-final.service(成环,见浏览器单元注释)。
|
||||||
|
--run-command 'printf "[Unit]\nDescription=CHV data disk mount (CHVDATA -> /home/chv/workspace)\nAfter=cloud-config.service\n\n[Service]\nType=oneshot\nExecStart=/usr/local/bin/chv-data-mount\nRemainAfterExit=yes\n\n[Install]\nWantedBy=multi-user.target\n" > /etc/systemd/system/chv-data-mount.service'
|
||||||
|
# 浏览器单元排在数据盘挂载服务之后。绝不能 After=cloud-final.service:
|
||||||
# cloud-final 自己 After=multi-user.target,与浏览器的
|
# cloud-final 自己 After=multi-user.target,与浏览器的
|
||||||
# Before=multi-user.target(WantedBy 隐含)成环,systemd 启动时
|
# Before=multi-user.target(WantedBy 隐含)成环,systemd 启动时
|
||||||
# 直接删掉浏览器任务(2026-11-08 构建 #9 驱动实证:服务从未启动、
|
# 直接删掉浏览器任务(2026-11-08 构建 #9 驱动实证:服务从未启动、
|
||||||
# NRestarts=0、无日志,journal 明言 Job deleted to break cycle;
|
# NRestarts=0、无日志,journal 明言 Job deleted to break cycle;
|
||||||
# 构建 #8 同款问题一直被手动 restart 掩盖)。
|
# 构建 #8 同款问题一直被手动 restart 掩盖)。也绝不能
|
||||||
--run-command 'printf "[Unit]\nDescription=CHV browser sub-agent (Muse P2)\nAfter=network.target home-chv-workspace.mount\n\n[Service]\nType=simple\nUser=chv-browser\nGroup=chv-browser\nExecStart=/usr/bin/python3 /usr/local/bin/chv-browser\nRestart=always\nRestartSec=2\n\n[Install]\nWantedBy=multi-user.target\n" > /etc/systemd/system/chv-browser.service'
|
# After=home-chv-workspace.mount:那是 mount(8) 运行时生成的
|
||||||
|
# 临时单元(FragmentPath 为空),启动时不存在、排序被忽略,
|
||||||
|
# 构建 #10 驱动实证浏览器比挂盘早 5 秒启动、profile 回落。
|
||||||
|
--run-command 'printf "[Unit]\nDescription=CHV browser sub-agent (Muse P2)\nAfter=network.target chv-data-mount.service\n\n[Service]\nType=simple\nUser=chv-browser\nGroup=chv-browser\nExecStart=/usr/bin/python3 /usr/local/bin/chv-browser\nRestart=always\nRestartSec=2\n\n[Install]\nWantedBy=multi-user.target\n" > /etc/systemd/system/chv-browser.service'
|
||||||
--run-command 'printf "[Unit]\nDescription=CHV guest runtime daemon (stage 2: heartbeat/resources/snapshot)\nAfter=network.target\n\n[Service]\nType=simple\nExecStart=/usr/bin/python3 /usr/local/bin/chv-daemon\nRestart=always\nRestartSec=2\n\n[Install]\nWantedBy=multi-user.target\n" > /etc/systemd/system/chv-daemon.service'
|
--run-command 'printf "[Unit]\nDescription=CHV guest runtime daemon (stage 2: heartbeat/resources/snapshot)\nAfter=network.target\n\n[Service]\nType=simple\nExecStart=/usr/bin/python3 /usr/local/bin/chv-daemon\nRestart=always\nRestartSec=2\n\n[Install]\nWantedBy=multi-user.target\n" > /etc/systemd/system/chv-daemon.service'
|
||||||
# 构建 #6 驱动实证(2026-11-08):execd 单元缺 KillMode 时默认
|
# 构建 #6 驱动实证(2026-11-08):execd 单元缺 KillMode 时默认
|
||||||
# control-group,kill -9 主进程后 systemd 重启会 SIGTERM 整个
|
# control-group,kill -9 主进程后 systemd 重启会 SIGTERM 整个
|
||||||
@@ -348,7 +360,7 @@ VC_ARGS=(-a "$OUT/disk.raw"
|
|||||||
# root:chv-browser(组在镜像构建时已建,tmpfiles-setup 启动时
|
# root:chv-browser(组在镜像构建时已建,tmpfiles-setup 启动时
|
||||||
# 能解析——与 syncd 那行因 chv 用户尚不存在被跳过不同);
|
# 能解析——与 syncd 那行因 chv 用户尚不存在被跳过不同);
|
||||||
# socket 本身仍由服务 chmod 0600,broker 以 root 连,不受影响。
|
# socket 本身仍由服务 chmod 0600,broker 以 root 连,不受影响。
|
||||||
--run-command 'printf "d /run/chv 0775 root chv-browser -\nd /run/chv/shots 0755 chv-browser chv-browser -\nd /run/chv/tasks 0755 root root -\n" > /etc/tmpfiles.d/chv.conf && systemctl enable chv-broker chv-browser chv-daemon chv-execd chv-syncd || true')
|
--run-command 'printf "d /run/chv 0775 root chv-browser -\nd /run/chv/shots 0755 chv-browser chv-browser -\nd /run/chv/tasks 0755 root root -\n" > /etc/tmpfiles.d/chv.conf && systemctl enable chv-broker chv-browser chv-daemon chv-data-mount chv-execd chv-syncd || true')
|
||||||
# P1 交接(Muse P1 的 vsock_notify.py):文件在仓库里存在时拷入
|
# P1 交接(Muse P1 的 vsock_notify.py):文件在仓库里存在时拷入
|
||||||
# 镜像(P1 分支才有,本分支尚无);不存在时只警告、不失败——
|
# 镜像(P1 分支才有,本分支尚无);不存在时只警告、不失败——
|
||||||
# 合并后自动生效,P2 不改该脚本内容。
|
# 合并后自动生效,P2 不改该脚本内容。
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ class BrowserAgent:
|
|||||||
profile_dir=None, cdp_port=None, full_browsers_dir=None):
|
profile_dir=None, cdp_port=None, full_browsers_dir=None):
|
||||||
self._factory = playwright_factory or _load_playwright
|
self._factory = playwright_factory or _load_playwright
|
||||||
self._shots_dir = shots_dir
|
self._shots_dir = shots_dir
|
||||||
|
self._profile_explicit = bool(profile_dir)
|
||||||
if profile_dir:
|
if profile_dir:
|
||||||
self._profile_dir = profile_dir
|
self._profile_dir = profile_dir
|
||||||
self._profile_on_data_disk = profile_dir.startswith(
|
self._profile_on_data_disk = profile_dir.startswith(
|
||||||
@@ -261,6 +262,7 @@ class BrowserAgent:
|
|||||||
else:
|
else:
|
||||||
self._profile_dir, self._profile_on_data_disk = \
|
self._profile_dir, self._profile_on_data_disk = \
|
||||||
resolve_profile_dir()
|
resolve_profile_dir()
|
||||||
|
self._full_browsers_explicit = bool(full_browsers_dir)
|
||||||
self._full_browsers_dir = (
|
self._full_browsers_dir = (
|
||||||
full_browsers_dir or resolve_full_browsers_dir(
|
full_browsers_dir or resolve_full_browsers_dir(
|
||||||
self._profile_on_data_disk))
|
self._profile_on_data_disk))
|
||||||
@@ -302,6 +304,25 @@ class BrowserAgent:
|
|||||||
if self._context is not None and engine == self._engine:
|
if self._context is not None and engine == self._engine:
|
||||||
return True, ""
|
return True, ""
|
||||||
self._close_context()
|
self._close_context()
|
||||||
|
if not self._profile_explicit:
|
||||||
|
# 迟挂载自愈:首次启动(或旧上下文已关)时按当前挂载状态
|
||||||
|
# 重解析 profile——进程启动时数据盘若还没挂上,__init__
|
||||||
|
# 记的是回落路径;等挂盘完成后再 open 时应拿到数据盘
|
||||||
|
# profile,而不是把回落态锁死到服务重启。已跑起来的
|
||||||
|
# 上下文不受影响(上面已提前返回)。
|
||||||
|
self._profile_dir, self._profile_on_data_disk = \
|
||||||
|
resolve_profile_dir()
|
||||||
|
if not self._full_browsers_explicit:
|
||||||
|
self._full_browsers_dir = resolve_full_browsers_dir(
|
||||||
|
self._profile_on_data_disk)
|
||||||
|
try:
|
||||||
|
# profile 目录自己建好并收紧到 0700:不依赖
|
||||||
|
# chv-data-mount 的时序(它在 cloud-final 才跑,
|
||||||
|
# 可能晚于浏览器首次启动),登录态目录绝不留 0755。
|
||||||
|
os.makedirs(self._profile_dir, exist_ok=True)
|
||||||
|
os.chmod(self._profile_dir, 0o700)
|
||||||
|
except OSError:
|
||||||
|
pass # 只读/无权限时由 Playwright 启动报错,status 明说
|
||||||
try:
|
try:
|
||||||
if self._pw is None:
|
if self._pw is None:
|
||||||
self._pw = self._factory()
|
self._pw = self._factory()
|
||||||
|
|||||||
@@ -795,7 +795,7 @@ class BuildDryRunTest(unittest.TestCase):
|
|||||||
"chv-browser", "chv-browser.service",
|
"chv-browser", "chv-browser.service",
|
||||||
"chv-broker.service", "useradd --system",
|
"chv-broker.service", "useradd --system",
|
||||||
"systemctl enable chv-broker chv-browser "
|
"systemctl enable chv-broker chv-browser "
|
||||||
"chv-daemon chv-execd chv-syncd",
|
"chv-daemon chv-data-mount chv-execd chv-syncd",
|
||||||
"chv-daemon.service", "chv-execd.service",
|
"chv-daemon.service", "chv-execd.service",
|
||||||
"chv-syncd.service"):
|
"chv-syncd.service"):
|
||||||
self.assertIn(needle, out, needle)
|
self.assertIn(needle, out, needle)
|
||||||
|
|||||||
@@ -421,7 +421,7 @@ class BuildWiringTest(unittest.TestCase):
|
|||||||
self.assertIn("%s.service" % name, text)
|
self.assertIn("%s.service" % name, text)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"systemctl enable chv-broker chv-browser "
|
"systemctl enable chv-broker chv-browser "
|
||||||
"chv-daemon chv-execd chv-syncd", text)
|
"chv-daemon chv-data-mount chv-execd chv-syncd", text)
|
||||||
# 99-chv-sync.cfg 的 runcmd 顺序一条不改。
|
# 99-chv-sync.cfg 的 runcmd 顺序一条不改。
|
||||||
cfg_line = next(
|
cfg_line = next(
|
||||||
line for line in text.splitlines()
|
line for line in text.splitlines()
|
||||||
|
|||||||
@@ -151,6 +151,81 @@ class ProfileResolveTest(unittest.TestCase):
|
|||||||
self.assertFalse(on_data)
|
self.assertFalse(on_data)
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileReresolveTest(unittest.TestCase):
|
||||||
|
def _agent_no_explicit(self, tmp):
|
||||||
|
ctx = FakeContext([FakePage()])
|
||||||
|
entered_holder = {}
|
||||||
|
|
||||||
|
class PW:
|
||||||
|
def __enter__(self):
|
||||||
|
entered = FakeEntered(ctx)
|
||||||
|
entered_holder["entered"] = entered
|
||||||
|
return entered
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
agent = BROWSER.BrowserAgent(
|
||||||
|
playwright_factory=lambda: PW(), shots_dir=tmp,
|
||||||
|
full_browsers_dir=os.path.join(tmp, "browsers"))
|
||||||
|
return agent, ctx, entered_holder
|
||||||
|
|
||||||
|
def test_ensure_browser_reresolves_profile_when_disk_appears(self):
|
||||||
|
# 进程启动时数据盘未挂(回落),首次 open 时盘已挂:应拿到
|
||||||
|
# 数据盘 profile,而不是把回落态锁死到服务重启。构建 #10
|
||||||
|
# 驱动实证的启动竞态(浏览器比挂盘早 5 秒)即靠此自愈。
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
data_profile = os.path.join(tmp, "workspace-profile")
|
||||||
|
calls = []
|
||||||
|
orig = BROWSER.resolve_profile_dir
|
||||||
|
|
||||||
|
def fake_resolve(env=None, ismount=None):
|
||||||
|
calls.append(1)
|
||||||
|
if len(calls) == 1:
|
||||||
|
return (BROWSER.PROFILE_FALLBACK_DIR, False)
|
||||||
|
return (data_profile, True)
|
||||||
|
|
||||||
|
BROWSER.resolve_profile_dir = fake_resolve
|
||||||
|
try:
|
||||||
|
agent, ctx, holder = self._agent_no_explicit(tmp)
|
||||||
|
self.assertEqual(agent._profile_dir,
|
||||||
|
BROWSER.PROFILE_FALLBACK_DIR)
|
||||||
|
ok, err = agent._ensure_browser()
|
||||||
|
self.assertTrue(ok, err)
|
||||||
|
self.assertEqual(holder["entered"].calls[0][0],
|
||||||
|
data_profile)
|
||||||
|
self.assertTrue(agent._profile_on_data_disk)
|
||||||
|
finally:
|
||||||
|
BROWSER.resolve_profile_dir = orig
|
||||||
|
|
||||||
|
def test_explicit_profile_never_reresolved(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
orig = BROWSER.resolve_profile_dir
|
||||||
|
BROWSER.resolve_profile_dir = lambda **kw: (
|
||||||
|
os.path.join(tmp, "other-profile"), True)
|
||||||
|
try:
|
||||||
|
agent, ctx, holder = _agent([FakePage()], tmp)
|
||||||
|
ok, err = agent._ensure_browser()
|
||||||
|
self.assertTrue(ok, err)
|
||||||
|
self.assertEqual(
|
||||||
|
holder["entered"].calls[0][0],
|
||||||
|
os.path.join(tmp, "profile"))
|
||||||
|
finally:
|
||||||
|
BROWSER.resolve_profile_dir = orig
|
||||||
|
|
||||||
|
def test_ensure_browser_creates_profile_dir_0700(self):
|
||||||
|
# 不依赖 chv-data-mount 的时序(它在 cloud-final 才跑,
|
||||||
|
# 可能晚于浏览器首次启动),登录态目录绝不留 0755。
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
agent, ctx, holder = _agent([FakePage()], tmp)
|
||||||
|
profile = os.path.join(tmp, "profile")
|
||||||
|
self.assertFalse(os.path.exists(profile))
|
||||||
|
ok, err = agent._ensure_browser()
|
||||||
|
self.assertTrue(ok, err)
|
||||||
|
self.assertEqual(oct(os.stat(profile).st_mode & 0o777),
|
||||||
|
"0o700")
|
||||||
|
|
||||||
|
|
||||||
class FullPresentTest(unittest.TestCase):
|
class FullPresentTest(unittest.TestCase):
|
||||||
def test_headless_shell_dir_alone_is_not_full(self):
|
def test_headless_shell_dir_alone_is_not_full(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
@@ -394,8 +469,11 @@ class ScreenshotTest(unittest.TestCase):
|
|||||||
self.assertFalse(second["changed"])
|
self.assertFalse(second["changed"])
|
||||||
self.assertEqual(second["size"], 0)
|
self.assertEqual(second["size"], 0)
|
||||||
self.assertIsNone(second["path"])
|
self.assertIsNone(second["path"])
|
||||||
# 文件只落了一张。
|
# 文件只落了一张(profile 目录是 _ensure_browser 建的,
|
||||||
self.assertEqual(len(os.listdir(tmp)), 1)
|
# 不计入截图数)。
|
||||||
|
self.assertEqual(
|
||||||
|
len([f for f in os.listdir(tmp)
|
||||||
|
if f.endswith(".jpg")]), 1)
|
||||||
|
|
||||||
|
|
||||||
class CdpAndStatusTest(unittest.TestCase):
|
class CdpAndStatusTest(unittest.TestCase):
|
||||||
@@ -551,15 +629,37 @@ class BuildWiringTest(unittest.TestCase):
|
|||||||
# 绝不能 After=cloud-final.service:cloud-final 自己
|
# 绝不能 After=cloud-final.service:cloud-final 自己
|
||||||
# After=multi-user.target,与浏览器的 Before=multi-user.target
|
# After=multi-user.target,与浏览器的 Before=multi-user.target
|
||||||
# 成环,systemd 启动时直接删掉浏览器任务(2026-11-08 构建 #9
|
# 成环,systemd 启动时直接删掉浏览器任务(2026-11-08 构建 #9
|
||||||
# 驱动实证)。排在数据盘挂载单元之后才是真依赖。
|
# 驱动实证)。也绝不能 After=home-chv-workspace.mount:那是
|
||||||
|
# mount(8) 运行时生成的临时单元,启动事务加载时不存在、
|
||||||
|
# 排序被忽略,构建 #10 驱动实证浏览器比挂盘早 5 秒启动、
|
||||||
|
# profile 回落本机盘。真依赖是 chv-data-mount.service。
|
||||||
text = (ROOT / "chv-guest" / "build.sh").read_text()
|
text = (ROOT / "chv-guest" / "build.sh").read_text()
|
||||||
idx = text.find("Description=CHV browser sub-agent")
|
idx = text.find("Description=CHV browser sub-agent")
|
||||||
self.assertGreater(idx, 0)
|
self.assertGreater(idx, 0)
|
||||||
block = text[idx:idx + 1200]
|
block = text[idx:idx + 1200]
|
||||||
self.assertIn("Restart=always", block)
|
self.assertIn("Restart=always", block)
|
||||||
self.assertIn("After=network.target home-chv-workspace.mount",
|
self.assertIn("After=network.target chv-data-mount.service",
|
||||||
block)
|
block)
|
||||||
self.assertNotIn("cloud-final.service", block)
|
self.assertNotIn("cloud-final.service", block)
|
||||||
|
self.assertNotIn("home-chv-workspace.mount", block)
|
||||||
|
|
||||||
|
def test_build_sh_data_mount_service_orders_after_cloud_config(self):
|
||||||
|
# 挂盘服务是 oneshot、RemainAfterExit,排在 cloud-config
|
||||||
|
# (建 chv 用户与 /home/chv)之后、multi-user 之前;且已
|
||||||
|
# enable,启动时真跑。构建 #10 驱动实证了无此服务时浏览器
|
||||||
|
# 比挂盘早启动、profile 回落的根因。
|
||||||
|
text = (ROOT / "chv-guest" / "build.sh").read_text()
|
||||||
|
idx = text.find("Description=CHV data disk mount")
|
||||||
|
self.assertGreater(idx, 0)
|
||||||
|
block = text[idx:idx + 800]
|
||||||
|
self.assertIn("After=cloud-config.service", block)
|
||||||
|
self.assertIn("Type=oneshot", block)
|
||||||
|
self.assertIn("RemainAfterExit=yes", block)
|
||||||
|
self.assertIn("ExecStart=/usr/local/bin/chv-data-mount", block)
|
||||||
|
self.assertIn("WantedBy=multi-user.target", block)
|
||||||
|
self.assertIn("systemctl enable chv-broker chv-browser "
|
||||||
|
"chv-daemon chv-data-mount chv-execd chv-syncd",
|
||||||
|
text)
|
||||||
|
|
||||||
def test_tmpfiles_lets_browser_bind_its_socket(self):
|
def test_tmpfiles_lets_browser_bind_its_socket(self):
|
||||||
# 阶段 3 live 根因:/run/chv 0755 root:root 时 chv-browser
|
# 阶段 3 live 根因:/run/chv 0755 root:root 时 chv-browser
|
||||||
@@ -622,7 +722,7 @@ class BuildWiringTest(unittest.TestCase):
|
|||||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||||
out = proc.stdout + proc.stderr
|
out = proc.stdout + proc.stderr
|
||||||
self.assertIn("Restart=always", out)
|
self.assertIn("Restart=always", out)
|
||||||
self.assertIn("home-chv-workspace.mount", out)
|
self.assertIn("chv-data-mount.service", out)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in new issue
Block a user