diff --git a/cloud/chv/executor.py b/cloud/chv/executor.py index 0043496..72c8103 100644 --- a/cloud/chv/executor.py +++ b/cloud/chv/executor.py @@ -313,6 +313,37 @@ def _disk_image_type(path: str) -> str: return "Qcow2" if str(path).endswith(".qcow2") else "Raw" +# CHV API 错误体截断上限(p2 修复,见 _api_error_body_text): +# 审计事件与 ChvFailure detail 里只记前 500 字符。取 500 的 +# 理由:CHV 的 400 体通常是一行 JSON {"error": "..."}(真机 +# 实测 <200 字符),500 足够装下完整原话;再长基本是重复字段 +# 或超大体,截断可保审计事件与异常串有界(审计 spill 文件按 +# 1MB 轮转;异常串还会进 warm_pool_acquire_failed 的 200 字符 +# 二次截断,见 _resume_clean_pool)。 +CHV_API_ERROR_BODY_LIMIT = 500 + + +def _api_error_body_text(body) -> str: + """CHV API 错误响应体转可记文本(p2 修复:非 2xx 时不再 + 丢弃 body,见 attach_tenant_data_disk / _create_vm): + dict/list 压成一行 JSON,其余 str();超 + CHV_API_ERROR_BODY_LIMIT 字符截断并标明(超出部分丢弃, + 只留前 500 字符的可定位信息)。body 本身是 CHV 的错误 + 文本(只含盘路径/状态,不含密钥),可进审计与异常。""" + if isinstance(body, (dict, list)): + try: + text = json.dumps(body, ensure_ascii=False, default=str) + except Exception: + text = str(body) + elif isinstance(body, str): + text = body + else: + text = str(body) + if len(text) > CHV_API_ERROR_BODY_LIMIT: + return text[:CHV_API_ERROR_BODY_LIMIT] + "…(已截断)" + return text + + def _default_popen_launcher(cmd): """默认 VMM 启动器:独立会话子进程,输出丢弃(纯移动)。""" return subprocess.Popen( @@ -2072,16 +2103,19 @@ class CloudHypervisorExecutor(Executor): + 可选 seed 盘 + 可选数据盘;attach_data_disk=False 时 不挂数据盘(干净池出生)。失败即 ChvFailure。""" socket_path = self.api_socket(task) - status, _body = self._api_call( + status, body = self._api_call( socket_path, "PUT", "/vm.create", self.build_vm_config( task_disk, seed_disk=seed_disk, data_disk=self._provision_data_disk_path(task) if attach_data_disk else None)) if status not in (200, 204): + # p2 修复(同类问题,见 attach_tenant_data_disk): + # vm.create 失败时 body 原样记入异常,不再丢弃。 raise ChvFailure( "vm_create_failed", - "vm.create 失败:status=%s" % status) + "vm.create 失败:status=%s body=%s" + % (status, _api_error_body_text(body))) self._emit("vm_create", task, "vm=%s disk=%s" % (name, task_disk)) @@ -3795,7 +3829,12 @@ class CloudHypervisorExecutor(Executor): /home/chv/workspace)→ 写 VM 归属标记。任一步失败即 ChvFailure(fail-closed,调用方回落冷启或拒绝,不把 未挂盘的 VM 当已绑定发给任务)。返回 {vm, tenant_id, - data_disk}。""" + 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( @@ -3803,14 +3842,19 @@ class CloudHypervisorExecutor(Executor): "挂载数据盘须带 tenant_id:无归属的任务不能绑盘") disk = data_disk or self._provision_tenant_data_disk(tenant) self._verify_task_disk_source(vm, tenant, disk) - status, _body = self._api_call( + status, body = self._api_call( self.api_socket({"id": vm}), "PUT", "/vm.add-disk", - {"path": disk}) + {"path": disk, "image_type": _disk_image_type(disk)}) if status not in (200, 204): + body_text = _api_error_body_text(body) + self._emit("data_disk_attach_failed", task, + "vm=%s status=%s body=%s" + % (vm, status, body_text), + "data_disk_attach_failed") raise ChvFailure( "data_disk_attach_failed", - "数据盘热挂载失败:vm=%s status=%s" - % (vm, status)) + "数据盘热挂载失败: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: diff --git a/cloud/tests/test_chv_closeup.py b/cloud/tests/test_chv_closeup.py index edc97f4..5e2337d 100644 --- a/cloud/tests/test_chv_closeup.py +++ b/cloud/tests/test_chv_closeup.py @@ -195,12 +195,14 @@ class WakeFailureTest(CloseupCase): def api(socket_path, method, path, payload=None): self.api_calls.append((method, path)) if method == "PUT" and path == "/vm.create": - return (500, {}) + return (500, {"error": "boom-create"}) return (200, {}) ex = self.make_exec(api_client=api) with self.assertRaises(ChvFailure) as ctx: ex.wake_vm(self._task()) self.assertEqual(ctx.exception.kind, "vm_create_failed") + # p2 同类修复:vm.create 失败的 body 也须进异常 detail。 + self.assertIn("boom-create", str(ctx.exception)) self.assertIn("vm_failed", self._audit_events()) self.assertEqual(ex._vms, {}) self.assertTrue(any(c[-1] == "shutdown" for c in self.calls)) diff --git a/cloud/tests/test_chv_hybrid_a.py b/cloud/tests/test_chv_hybrid_a.py index 540883a..c343e01 100644 --- a/cloud/tests/test_chv_hybrid_a.py +++ b/cloud/tests/test_chv_hybrid_a.py @@ -489,6 +489,9 @@ class DataDiskAttachTest(HybridACase): (m, p, pl) for m, p, pl in self.api_calls if p == "/vm.add-disk") self.assertEqual(payload["path"], out["data_disk"]) + # CHV v54 已移除盘格式自动探测:热插盘 body 须与 create + # 时盘同款带 image_type(租户数据盘是 raw 稀疏盘 → Raw)。 + self.assertEqual(payload["image_type"], "Raw") # 客内挂载脚本被调过(chv-data-mount)。 self.assertTrue(any( "/usr/local/bin/chv-data-mount" in call @@ -496,6 +499,50 @@ class DataDiskAttachTest(HybridACase): self.assertEqual( read_vm_tenant(self.state_dir, "chv-pool-0"), "tenant-a") + def test_attach_failure_records_chv_body(self): + # p2 修复:非 2xx 时 CHV 的原话(body)须进异常 detail + # 与审计事件,不再丢弃;失败仍 fail-closed(不挂载、不 + # 写归属标记)。 + def api(socket_path, method, path, payload=None): + self.api_calls.append((method, path, payload)) + if path == "/vm.add-disk": + return (400, {"error": "Image type required for disk"}) + return (200, {}) + ex = self.make_exec(api_client=api) + with self.assertRaises(ChvFailure) as ctx: + ex.attach_tenant_data_disk( + self._task(project="tenant-a"), "chv-pool-0") + self.assertEqual(ctx.exception.kind, "data_disk_attach_failed") + self.assertIn("Image type required for disk", str(ctx.exception)) + self.assertIn("status=400", str(ctx.exception)) + details = self._event_details("data_disk_attach_failed") + self.assertEqual(len(details), 1) + self.assertIn("Image type required for disk", details[0]) + self.assertIn("status=400", details[0]) + # 挂载脚本没跑、归属标记没写。 + self.assertFalse(any( + "/usr/local/bin/chv-data-mount" in call + for call in self.calls if call and call[0] == "ssh")) + self.assertIsNone(read_vm_tenant(self.state_dir, "chv-pool-0")) + + def test_attach_failure_truncates_long_body(self): + # 超 500 字符的 body 只记前 500 字符并标明,不把整段 + # 塞进审计与异常。 + def api(socket_path, method, path, payload=None): + self.api_calls.append((method, path, payload)) + if path == "/vm.add-disk": + return (400, {"error": "E" * 600}) + return (200, {}) + ex = self.make_exec(api_client=api) + with self.assertRaises(ChvFailure) as ctx: + ex.attach_tenant_data_disk( + self._task(project="tenant-a"), "chv-pool-0") + self.assertIn("…(已截断)", str(ctx.exception)) + self.assertNotIn("E" * 600, str(ctx.exception)) + details = self._event_details("data_disk_attach_failed") + self.assertEqual(len(details), 1) + self.assertIn("…(已截断)", details[0]) + def test_dispatch_pool_hit_binds_tenant_disk_and_destroys_vm(self): ex = self.make_exec() ex.prewarm_clean_pool(slot=0)