fix(chv-hybrid): p2 第三轮:attach 轮询等挂盘落地 + detach 走 sudo 窄规则 umount
ci / test (push) Failing after 47m51s

第 2 轮复测(0dc8328)p2 仍 FAIL 的两处根因:
1. 挂盘时序竞态:add-disk 返回 06:59:10,root hotplug 路径
   (cloud-init-hotplugd → cloud-final runcmd → chv-data-mount)
   06:59:14–16 才挂上;旧 attach 以 chv 调 chv-data-mount 并判
   rc==0,但 /dev/vd? 属 root:disk、chv 的 blkid/mkfs 必败,
   脚本又静默 exit 0,rc 检查是空洞的成功假象。
2. 卸载权限:挂载是 root 做的,chv 的 plain umount 恒 rc=32
   must be superuser,release 按现写法永远失败、VM 留置。

修法选 (a) 轮询,不选 (b) root 特权挂载:挂盘本身已有 root
路径在跑,host 侧不另开新的 root 挂载通道(新特权面只扩
攻击面);轮询以 chv 只读判定挂载态,零新增特权。
- attach:删 chv 调 chv-data-mount,改为轮询 mountpoint -q
  /home/chv/workspace && test -w(默认 60 秒/2 秒,可经
  CHV_DATA_MOUNT_TIMEOUT_SECONDS / CHV_DATA_MOUNT_INTERVAL_SECONDS
  注入短值);超时记 data_disk_mount_timeout 审计并抛同名
  kind,不写归属标记。attach 返回成功 = 挂盘真实发生。
  resume_seconds 因此真实包含挂盘等待(§2.1 口径),≤2 秒
  目标不改口径、不改目标。
- detach:改走 sudo -n /usr/bin/umount /home/chv/workspace;
  失败即 False,release 留置 VM 不回收(fail-closed)。
- chv-data-mount.sh:盘已找到后 mkfs/mount 失败改 exit 1
  (原全部失败路径 exit 0);无盘仍 exit 0(启动期 no-op,
  不挡 boot)。本脚本只由 root 跑(service/runcmd)。
- build.sh:apt 加 sudo;写 /etc/sudoers.d/chv-data-umount
  窄规则(chv 仅能以 root 跑 umount /home/chv/workspace
  这一条精确 argv,NOPASSWD,无 shell/其它命令/挂载权),
  0440 + visudo -cf 作构建门;buildinfo/lockfile 登记
  sudo 与 chv-data-umount。

特权边界:本轮唯一新增提权面就是上述一条 sudoers 规则;
attach 零提权(只读轮询),挂载权仍只在 root 的
hotplug/service 路径手里。旧镜像(无 sudo/无规则)上
detach 返回 False、release 留置,须重建镜像才恢复回收。

测试:新增 10 项(attach 等三次轮询才成功、超时 loud 失败、
永不以 chv 调 chv-data-mount、detach 精确 sudo argv、
sudo 失败 False、卸载失败 release 留置、客机脚本 exit 码、
build.sh sudo/窄规则/buildinfo 登记);另修 2 处旧断言
(attach 不再调挂载脚本)与 stage4 lockfile 夹具(补 sudo
dpkg 条目),并把 attach 热插段抽为 _hotplug_tenant_disk
(50 行函数上限)。全量 Ran 4688,OK (skipped=15,均为
环境 skip:无 PG DSN/驱动、缺 SearXNG_URL、fallback state
文件在位)。
This commit is contained in:
host committed 2026-11-09 08:17:01 +00:00
1 parent 0dc8328ecc
commit a859d731d5
8 files changed
+351 -37

No files matched your search

+123 -30
View File
@@ -263,6 +263,32 @@ HYBRID_COLD_RESTORE_TARGET_SECONDS = 30
HYBRID_SNAPSHOT_PER_TENANT = 1
HYBRID_SNAPSHOT_RETENTION_DAYS = 30
HYBRID_RESIDENT_TIERS = ("paid", "heavy")
# p2 第三轮(feat-chv-hybrid,2026-11-09 第 2 轮复测):
# 数据盘挂盘等待上限/轮询间隔。真机时序:attach 的 add-disk
# 返回 06:59:10,root 的 hotplug 路径(cloud-init-hotplugd →
# cloud-final runcmd → chv-data-mount)06:59:14–16 才把盘挂
# 上——热插后挂盘不是同步发生的,须轮询等它落地。60 秒上限
# 覆盖 hotplug + cloud-final 重跑的实测 ~6 秒并留足余量;
# 测试经 CHV_DATA_MOUNT_TIMEOUT_SECONDS /
# CHV_DATA_MOUNT_INTERVAL_SECONDS 注入短值(调用时现读,
# 与 _hybrid_env_float 同款非法值回默认)。≤2 秒热恢复目标
# 不变(DESIGN-HYBRID §2.1),本轮只修“attach 返回成功 =
# 挂盘真实发生”的语义,不改口径、不改目标。
DATA_MOUNT_TIMEOUT_SECONDS = 60
DATA_MOUNT_POLL_INTERVAL_SECONDS = 2
# 客内工作区挂载点(chv-data-mount 的挂载目标,LABEL=CHVDATA)。
DATA_MOUNT_POINT = "/home/chv/workspace"
# 卸载特权路径(p2 第三轮):挂载是 root 做的,chv 身份的
# plain umount 恒报 rc=32 must be superuser。唯一提权入口是
# 镜像里的 sudoers 窄规则(/etc/sudoers.d/chv-data-umount,
# 见 chv-guest/build.sh):chv ALL=(root) NOPASSWD: 仅
# /usr/bin/umount /home/chv/workspace 这一条精确 argv,
# 无 shell、无其它命令、无挂载权。sudo -n 绝不交互问密码,
# 规则缺失/镜像未重建时直接失败(detach 返回 False →
# release 不回收、VM 留置待人工),fail-closed,不回落
# plain umount(那条路恒失败,回落只是噪音)。
DATA_UMOUNT_PRIVILEGED_COMMAND = (
"sudo", "-n", "/usr/bin/umount", DATA_MOUNT_POINT)
CLEAN_POOL_DIR_NAME = "clean_pool"
# 干净池预热时拒绝的租户同步键(Gitea 同步变量):池 VM 出生
# 时不带租户,显式带这些键进预热即调用方错误,fail-closed。
@@ -3820,28 +3846,57 @@ class CloudHypervisorExecutor(Executor):
"租户数据盘的归属标记是 %s,与请求方 %s 不一致,"
"拒绝挂载" % (owner, tenant))
def attach_tenant_data_disk(self, task: dict, vm: str,
data_disk: str | None = None) -> dict:
"""分配时挂载租户数据盘(§3.1 首选方案):供给/复用该租户
的数据盘 → 过任务盘来源不变量 → CHV 热挂载(PUT
/vm.add-disk,CHV 54 的热插盘端点)→ 客内挂载脚本
(chv-data-mount,按 LABEL=CHVDATA 挂到
/home/chv/workspace)→ 写 VM 归属标记。任一步失败即
ChvFailure(fail-closed,调用方回落冷启或拒绝,不把
未挂盘的 VM 当已绑定发给任务)。返回 {vm, tenant_id,
data_disk}。热挂载 body 与 create 时盘同款:path +
image_type(CHV v54 已移除盘格式自动探测,只发 path
时真机回 400,见 p2 复测);非 2xx 时 CHV 的 body
原样记入 data_disk_attach_failed 审计事件与异常
detail(超 500 字符截断,见 _api_error_body_text),
不再丢弃。"""
tenant = self._tenant_id(task)
if not tenant:
raise ChvFailure(
"task_disk_source_invalid",
"挂载数据盘须带 tenant_id:无归属的任务不能绑盘")
disk = data_disk or self._provision_tenant_data_disk(tenant)
self._verify_task_disk_source(vm, tenant, disk)
def _data_mount_poll_params(self):
"""挂盘等待参数(超时,间隔):测试可经
CHV_DATA_MOUNT_TIMEOUT_SECONDS /
CHV_DATA_MOUNT_INTERVAL_SECONDS 注入短值;非法/
缺失/<=0 即回生产默认,绝不因配错而停表(与
_hybrid_env_float 同款)。"""
return (
self._hybrid_env_float(
"CHV_DATA_MOUNT_TIMEOUT_SECONDS",
DATA_MOUNT_TIMEOUT_SECONDS),
self._hybrid_env_float(
"CHV_DATA_MOUNT_INTERVAL_SECONDS",
DATA_MOUNT_POLL_INTERVAL_SECONDS))
def _wait_data_disk_mounted(self, task: dict, vm: str) -> bool:
"""等客内数据盘真正挂上 /home/chv/workspace:轮询
`mountpoint -q <挂载点> && test -w <挂载点>`(chv
身份只读判定,不需特权:mountpoint 读内核挂载态,
test -w 验任务随后真能写进去)。每轮一次 SSH,上
限见 _data_mount_poll_params;超时即 False(调用方
fail-closed,绝不把未挂盘的 VM 当已绑定发给任务)。
注意:轮询不等 chv-data-mount 脚本本身——第 2 轮
复测已证实该脚本以 chv 跑时 /dev/vd? 属 root:disk,
blkid 读不到、mkfs 必败,旧 attach 的 rc==0 检查是
空洞的;真正挂盘的是 root 的 hotplug 路径
(cloud-init-hotplugd → cloud-final runcmd →
chv-data-mount),轮询等的就是它落地。"""
timeout, interval = self._data_mount_poll_params()
attempts = max(1, int(timeout / interval))
for attempt in range(attempts):
try:
result = self._call_runner(self.build_ssh_command(
{"id": vm},
["sh", "-c",
"mountpoint -q %s && test -w %s"
% (DATA_MOUNT_POINT, DATA_MOUNT_POINT)]))
if getattr(result, "returncode", 1) == 0:
return True
except Exception:
pass
if attempt + 1 < attempts:
self.sleeper(interval)
return False
def _hotplug_tenant_disk(self, task: dict, vm: str, disk: str):
"""CHV 热插盘(PUT /vm.add-disk,CHV 54 端点):body
与 create 时盘同款 path + image_type(只发 path 时
真机回 400 "Image type required for disk",见 p2 复测);
非 2xx 时 CHV 的 body 原样记入
data_disk_attach_failed 审计事件与异常 detail(超 500
字符截断,见 _api_error_body_text),不再丢弃。"""
status, body = self._api_call(
self.api_socket({"id": vm}), "PUT", "/vm.add-disk",
{"path": disk, "image_type": _disk_image_type(disk)})
@@ -3855,13 +3910,40 @@ class CloudHypervisorExecutor(Executor):
"data_disk_attach_failed",
"数据盘热挂载失败:vm=%s status=%s body=%s"
% (vm, status, body_text))
result = self._call_runner(self.build_ssh_command(
{"id": vm}, ["/usr/local/bin/chv-data-mount"]))
if getattr(result, "returncode", 1) != 0:
def attach_tenant_data_disk(self, task: dict, vm: str,
data_disk: str | None = None) -> dict:
"""分配时挂载租户数据盘(§3.1 首选方案):供给/复用该租户
的数据盘 → 过任务盘来源不变量 → CHV 热挂载(PUT
/vm.add-disk)→ 轮询等客内挂盘真正落地
(_wait_data_disk_mounted,修法 (a):挂盘已有 root
hotplug 路径在跑,不另开 root 挂载通道;旧的 chv 调
chv-data-mount 判 rc==0 已删除,那是静默 exit 0 的
成功假象)→ 写 VM 归属标记。任一步失败即 ChvFailure
(fail-closed,不把未挂盘的 VM 当已绑定发给任务)。
超时记 data_disk_mount_timeout 审计并抛同名 kind,
不写归属标记;resume_seconds 因此真实包含挂盘等待
(§2.1 口径),≤2 秒目标不改口径。"""
tenant = self._tenant_id(task)
if not tenant:
raise ChvFailure(
"data_disk_mount_failed",
"客内挂载数据盘失败:vm=%s rc=%s"
% (vm, getattr(result, "returncode", None)))
"task_disk_source_invalid",
"挂载数据盘须带 tenant_id:无归属的任务不能绑盘")
disk = data_disk or self._provision_tenant_data_disk(tenant)
self._verify_task_disk_source(vm, tenant, disk)
self._hotplug_tenant_disk(task, vm, disk)
timeout, _interval = self._data_mount_poll_params()
if not self._wait_data_disk_mounted(task, vm):
self._emit("data_disk_mount_timeout", task,
"vm=%s timeout=%s data_disk=%s"
% (vm, timeout, disk),
"data_disk_mount_timeout")
raise ChvFailure(
"data_disk_mount_timeout",
"数据盘热挂载后 %s 秒内客内 /home/chv/workspace "
"仍未挂上(vm=%s,热插已成功、root hotplug 挂盘"
"未落地),已拒绝绑定,不写归属标记"
% (timeout, vm))
write_vm_tenant(self.state_dir, vm, tenant, disk=disk)
return {"vm": vm, "tenant_id": tenant, "data_disk": disk}
@@ -3869,10 +3951,21 @@ class CloudHypervisorExecutor(Executor):
"""客内卸载租户数据盘(umount /home/chv/workspace,落盘
回外部存储):成功 True;失败 False(调用方不得继续销毁
VM,见 release_pool_vm——同步/卸载失败的 VM 不回收,
等人工/重试,绝不丢改动后静默回收)。"""
等人工/重试,绝不丢改动后静默回收)。
特权边界(p2 第三轮):挂载是 root 做的,chv 身份的
plain umount 恒报 rc=32 must be superuser(第 2 轮复
测实测),故此处走 sudo -n 窄规则(见
DATA_UMOUNT_PRIVILEGED_COMMAND 与 build.sh 的
/etc/sudoers.d/chv-data-umount):仅允许 chv 以 root
跑 `/usr/bin/umount /home/chv/workspace` 这一条精确
argv,无密码、无 shell、无其它命令、无挂载权;sudo
-n 失败即 False,不回落 plain umount。旧镜像(无
sudo/无该规则)上同样返回 False、release 留置 VM,
fail-closed,须重建镜像才恢复回收。"""
try:
result = self._call_runner(self.build_ssh_command(
{"id": vm}, ["umount", "/home/chv/workspace"]))
{"id": vm},
list(DATA_UMOUNT_PRIVILEGED_COMMAND)))
except Exception:
return False
return getattr(result, "returncode", 1) == 0