"""Cloud Hypervisor 每任务磁盘供给(叶子模块)。 单任务单 VM 的文件系统独立性就靠这里:copy 模式每任务 复制一份基础镜像,两个任务绝不写同一文件;reuse 模式 直接返回基础镜像路径(仅单租户实验,生产拦截见执行器 preflight,须同时设 CHV_REUSE_SINGLE_TENANT_EXPERIMENT=1 并审计放行)。 feat-chv-fix(P1-5)配额口径:不再只比基础镜像大小。 check_disk_quota 改为三项:state_dir 已用总量 + 本次 拷贝(基础镜像大小)+ 宿主剩余空间必须容纳本次拷贝; 镜像不可读时不再静默跳过,报 disk_quota_unverified (preflight 在 wake 路径上会先报 image_missing,这里 是直接调用者的第二道门)。provision_task_disk 对已存在 磁盘校验大小:与源不一致即重拷;源不可读即报失败, 绝不复用脏磁盘。 feat-chv-opt-landing(阶段 1):黄金镜像只读 base + qcow2 overlay 分层(disk_mode="overlay")、数据盘独立 持久(data.raw,绝不进快照/GC)、快照与 overlay 的 tenant 归属标记(登记条目 tenant 字段 + 每 VM tenant.json + 持久 VM 登记 persistent/.json)。overlay 与 数据盘只在 overlay 模式下供给;copy/reuse 语义不变。 """ import os import shutil import subprocess from collections.abc import Callable from cloud.chv.errors import ChvFailure def _dir_total_bytes(path): total = 0 for root, _dirs, files in os.walk(path): for name in files: try: total += os.path.getsize(os.path.join(root, name)) except OSError: pass return total def check_disk_quota(rootfs: str, disk_gb: float, state_dir: str | None = None, increment_bytes: int | None = None) -> None: """磁盘配额门(feat-chv-fix 口径): - disk_gb 未设时不检查; - 镜像不可读时报 disk_quota_unverified,不静默跳过; - 镜像大小超过 disk_gb 即 disk_quota_exceeded; - state_dir 已给时:已用总量 + 本次拷贝超过 disk_gb 即 disk_quota_exceeded;宿主剩余空间不足以容纳本次 拷贝即 disk_quota_exceeded(宿主余量校验)。 - increment_bytes(feat-chv-opt-landing):本次实际新增 的磁盘字节数,overlay 模式传 0(只建空 overlay,不拷 base,base 本身不计入增量);不给时默认等于镜像大小 (copy 模式口径不变)。镜像大小超过 disk_gb 的硬帽 仍按镜像本身算,与增量无关。 """ if disk_gb is None or not rootfs: return try: size = os.path.getsize(rootfs) except OSError: raise ChvFailure( "disk_quota_unverified", "磁盘镜像不可读,配额无法校验:%s" % rootfs) if size > disk_gb * 1024 ** 3: raise ChvFailure( "disk_quota_exceeded", "磁盘镜像 %d 字节超过配额 %.1f GB" % (size, disk_gb)) increment = size if increment_bytes is None else increment_bytes if state_dir and os.path.isdir(state_dir): used = _dir_total_bytes(state_dir) if used + increment > disk_gb * 1024 ** 3: raise ChvFailure( "disk_quota_exceeded", "磁盘总量 %d 字节 + 本次 %d 字节超过配额 %.1f GB" % (used, increment, disk_gb)) try: free = shutil.disk_usage(state_dir).free except OSError: free = None if free is not None and free < increment: raise ChvFailure( "disk_quota_exceeded", "宿主剩余空间 %d 字节不足以容纳本次磁盘拷贝 %d 字节" % (free, increment)) def provision_task_disk(state_dir: str, vm_name: str, rootfs: str, disk_mode: str, runner: Callable[..., object] | None = None) -> str: """每任务磁盘路径:copy 模式复制到 state_dir//disk.raw;reuse 模式直接返回基础镜像路径; overlay 模式(feat-chv-opt-landing,阶段 1)在 state_dir//disk.qcow2 建空 qcow2 overlay,backing file 指向只读 base(base 本身一字节都不改、不拷)。 feat-chv-fix:已存在磁盘须校验——大小与源不一致即 重拷(脏磁盘不复用);源不可读即 ChvFailure,绝不 静默返回旧磁盘。overlay 模式已存在 overlay 时校验 backing file 是否仍指向同一 base:是即复用,否即删 了重建(旧 overlay 的 base 已换,复用等于挂错盘)。""" if disk_mode == "reuse": return rootfs if disk_mode == "overlay": task_dir = os.path.join(state_dir, vm_name) os.makedirs(task_dir, exist_ok=True) return create_overlay_disk( rootfs, overlay_disk_path(state_dir, vm_name), runner=runner) task_dir = os.path.join(state_dir, vm_name) os.makedirs(task_dir, exist_ok=True) task_disk = os.path.join(task_dir, "disk.raw") # feat-chv-secfix(L1):目标是符号链接时先删链接本身, # 绝不跟随链接写入目标文件(copyfile 跟随链接会截断并 # 写进链接目标)。删链接与重建之间的 TOCTOU 窗口已在 # 文档里明写:state_dir 须 0700 且仅 worker 用户可写, # 宿主本地攻击者本就写不进该目录,L1 修的是“链接已 # 预置”情形下的最后一道门。 if os.path.islink(task_disk): os.remove(task_disk) if os.path.exists(task_disk): try: same = os.path.getsize(task_disk) == os.path.getsize(rootfs) except OSError: raise ChvFailure( "disk_quota_unverified", "任务磁盘或源镜像不可读,无法校验复用:%s" % task_disk) if same: return task_disk os.remove(task_disk) try: shutil.copyfile(rootfs, task_disk) except OSError as exc: raise ChvFailure( "disk_quota_unverified", "任务磁盘拷贝失败:%s" % exc) return task_disk def task_disk_path(state_dir: str, vm_name: str) -> str: """该 VM 的任务磁盘路径:state_dir//disk.raw (copy 模式;overlay 模式见 overlay_disk_path)。""" return os.path.join(state_dir, vm_name, "disk.raw") def task_disk_paths(state_dir: str, vm_name: str) -> list: """该 VM 现存的任务磁盘文件(copy 的 disk.raw 与/或 overlay 的 disk.qcow2):GC 与清理按此清单查,不猜 文件名;数据盘 data.raw 不在内——用户数据盘永不 进任务盘 GC,见 delete_data_disk。""" out = [] for name in ("disk.raw", OVERLAY_DISK_NAME): path = os.path.join(state_dir, vm_name, name) if os.path.islink(path) or os.path.exists(path): out.append(path) return out def delete_task_disk(state_dir: str, vm_name: str) -> bool: """删任务磁盘(feat-chv-secfix,H3):stop/recycle/ 清理路径都调它。返回 True 表示确实删掉了一个文件, False 表示本来就没有;删失败抛 OSError(调用方记审计, 绝不静默——残留盘由 gc_stale_disks 下次再清)。 符号链接只删链接本身,不跟随。 feat-chv-opt-landing:disk.raw 与 disk.qcow2 都删 (任一存在即删);数据盘 data.raw 绝不在此删—— 它是用户数据,删它须走 delete_data_disk(显式回收)。""" removed = False for path in task_disk_paths(state_dir, vm_name): os.remove(path) removed = True return removed # -- overlay 分层与数据盘(feat-chv-opt-landing,阶段 1)------ # 黄金镜像只读 base(qcow2,基线 773 MiB)绝不被写:每台 # 用户 VM 只有一个空 overlay(disk.qcow2,backing file 指 # 向 base);数据盘(data.raw,工作区 + 浏览器 profile # 的载体)是第二块独立盘——快照登记与 GC 只认任务盘, # 数据盘不进快照、不进 GC、不随 stop 删(删它须显式回收)。 # 客内挂载见镜像 99-chv-sync.cfg runcmd 的 chv-data-mount #(按 LABEL=CHVDATA 找盘,不依赖 /dev/vd? 固定名)。 OVERLAY_DISK_NAME = "disk.qcow2" DATA_DISK_NAME = "data.raw" DATA_DISK_GB = 10.0 DATA_DISK_LABEL = "CHVDATA" TENANT_FILE_NAME = "tenant.json" PERSISTENT_DIR_NAME = "persistent" def overlay_disk_path(state_dir: str, vm_name: str) -> str: """该 VM 的 overlay 磁盘路径:state_dir//disk.qcow2。""" return os.path.join(state_dir, vm_name, OVERLAY_DISK_NAME) def data_disk_path(state_dir: str, vm_name: str) -> str: """该 VM 的数据盘路径:state_dir//data.raw。""" return os.path.join(state_dir, vm_name, DATA_DISK_NAME) def _default_qemu_runner(cmd, **kwargs): """qemu-img 的默认执行器:subprocess 直调(宿主命令, 与执行器的 SSH/ch-remote runner 分开,不混用)。""" return subprocess.run(cmd, capture_output=True, text=True, timeout=kwargs.get("timeout", 120)) def _qemu_img_cmd(): """qemu-img 可执行路径:PATH 里找不到即报 disk_overlay_unsupported(调用方 fail-fast,不静默 回落 copy——回落等于悄悄拷 773 MiB base,违背分层)。""" import shutil as _shutil path = _shutil.which("qemu-img") if not path: raise ChvFailure( "disk_overlay_unsupported", "qemu-img 不可用,overlay 模式无法建盘") return path def _base_format(base_path: str) -> str: """backing file 的 -F 口径:按 base 后缀定(.qcow2 / .raw);其它后缀即 disk_overlay_unsupported,不猜 (格式猜错会建出读不了 base 的 overlay,启动才炸)。""" if base_path.endswith(".qcow2"): return "qcow2" if base_path.endswith(".raw"): return "raw" raise ChvFailure( "disk_overlay_unsupported", "overlay 的 base 须是 .qcow2 或 .raw:%s" % base_path) def _overlay_backing_matches(overlay_path: str, base_path: str, runner, qemu: str = "qemu-img") -> bool: """已存在 overlay 的 backing file 是否仍指向同一 base: qemu-img info --output=json 的 backing-filename 与 base 绝对路径比对;查不到/解析失败即 False(不复用, 调用方删了重建,不猜)。""" try: result = runner([qemu, "info", "--output=json", overlay_path]) except Exception: return False if getattr(result, "returncode", 1) != 0: return False try: import json as _json info = _json.loads(getattr(result, "stdout", "") or "") except ValueError: return False if not isinstance(info, dict): return False backing = info.get("backing-filename") return isinstance(backing, str) \ and os.path.abspath(backing) == os.path.abspath(base_path) def create_overlay_disk(base_path: str, overlay_path: str, runner: Callable[..., object] | None = None) -> str: """建空 overlay(qemu-img create -f qcow2 -b -F ):base 只读,增量只写 overlay。 已存在且 backing 仍指向同一 base 即复用;backing 不符 即删了重建;overlay 是符号链接时先删链接本身(L1 同款, 绝不跟随链接写目标)。建完以 qemu-img info 核对 backing, 对不上即 ChvFailure disk_overlay_invalid(建了坏盘不 交给执行器去启动才炸)。runner 可注入(单测 fake); 不给即 subprocess 直调。""" if not os.path.isfile(base_path) \ or not os.access(base_path, os.R_OK): raise ChvFailure( "disk_overlay_unsupported", "overlay 的 base 镜像不可读:%s" % base_path) # runner 注入时(单测 fake)不查本机 qemu-img 是否存在, # 命令名只作 fake 的分发标记;默认 runner 才查真实二进制。 run = runner if runner is not None else _default_qemu_runner qemu = "qemu-img" if runner is not None else _qemu_img_cmd() if os.path.islink(overlay_path): os.remove(overlay_path) if os.path.exists(overlay_path): if _overlay_backing_matches(overlay_path, base_path, run, qemu=qemu): return overlay_path os.remove(overlay_path) result = run([qemu, "create", "-f", "qcow2", "-b", os.path.abspath(base_path), "-F", _base_format(base_path), overlay_path]) if getattr(result, "returncode", 1) != 0: raise ChvFailure( "disk_overlay_invalid", "overlay 建盘失败:%s:%s" % (overlay_path, getattr(result, "stderr", "") or "")) if not os.path.exists(overlay_path): raise ChvFailure( "disk_overlay_invalid", "overlay 建盘后文件不存在:%s" % overlay_path) if not _overlay_backing_matches(overlay_path, base_path, run, qemu=qemu): raise ChvFailure( "disk_overlay_invalid", "overlay 建盘后 backing file 与 base 不符:%s" % overlay_path) return overlay_path def provision_data_disk(state_dir: str, vm_name: str, size_gb: float = DATA_DISK_GB) -> str: """建数据盘(空 raw 稀疏盘,虚大小默认 10 GB,实际占用 随写入增长):已存在即复用(不 resize、不重建,旧盘里 可能已有用户数据);不存在即 os.truncate 建稀疏文件。 raw 而不用 qcow2(2026-11-07):数据盘只是空白可写卷, qcow2 在此无收益;raw 零外部依赖——copy 模式本来就不 需要 qemu-img,数据盘绝不能悄悄给 copy 加上这个依赖 (Muse VM 无 qemu-img 时 copy 派发会全灭;CHV v54 又 拒绝带 backing 的 qcow2,数据盘走 qcow2 毫无必要)。 失败即 ChvFailure disk_overlay_invalid(数据盘建不出 来时拒绝启动,不让任务在无数据盘的 VM 里把工作区写进 系统盘——静默回落等于丢“独立持久”的保证)。""" path = data_disk_path(state_dir, vm_name) os.makedirs(os.path.dirname(path), exist_ok=True) if os.path.islink(path): os.remove(path) if os.path.exists(path): return path try: with open(path, "wb") as fh: fh.truncate(int(size_gb * 1024 ** 3)) except OSError as exc: raise ChvFailure( "disk_overlay_invalid", "数据盘建盘失败:%s:%s" % (path, exc)) if not os.path.exists(path): raise ChvFailure( "disk_overlay_invalid", "数据盘建盘后文件不存在:%s" % path) return path def delete_data_disk(state_dir: str, vm_name: str) -> bool: """显式删数据盘(仅回收路径调):返回 True 表示确实 删掉了一个文件。任务盘 GC、stop、hibernate 一律不调 它——用户数据不自动删,删须是显式回收决定。""" path = data_disk_path(state_dir, vm_name) if os.path.islink(path) or os.path.exists(path): os.remove(path) return True return False # -- tenant 归属标记(feat-chv-opt-landing,阶段 1)------------ # 三处标记,缺一不可互相印证: # ① 每 VM tenant.json:overlay/数据盘的归属(供给时写); # ② 快照登记条目 tenant 字段(record_snapshot 写); # ③ 持久 VM 登记 persistent/.json(预热/暂停时写)。 # 归属校验只认“有标记且一致”;无标记是阶段 1 之前的旧 # 登记(legacy),恢复时放行但发审计警告,不静默当作已验。 def _tenant_file_path(state_dir, vm_name): return os.path.join(state_dir, vm_name, TENANT_FILE_NAME) def write_vm_tenant(state_dir: str, vm_name: str, tenant_id: str | None, disk: str | None = None) -> str: """写该 VM 的 tenant 归属标记(atomic 写 + fsync, 与 snapshots.json 同款);tenant_id 为空即不写(无 归属可标时不伪造标记,调用方按 legacy 处理)。""" import time if not tenant_id: return "" from cloud.chv.atomic_file import atomic_write_text import json path = _tenant_file_path(state_dir, vm_name) os.makedirs(os.path.dirname(path), exist_ok=True) atomic_write_text(path, json.dumps({ "tenant_id": tenant_id, "vm": vm_name, "disk": disk, "created_at": time.time()})) return path def read_vm_tenant(state_dir: str, vm_name: str) -> str | None: """读该 VM 的 tenant 归属标记:无文件/损坏即 None (legacy,未标记;调用方不得把 None 当成“某租户”)。""" import json try: with open(_tenant_file_path(state_dir, vm_name), "r") as fh: data = json.load(fh) except (OSError, ValueError): return None if not isinstance(data, dict): return None tenant = data.get("tenant_id") return tenant if tenant else None def find_snapshot_entry(state_dir: str, snapshot_url: str) -> tuple[str | None, dict | None]: """按 URL 在全部 VM 的快照登记里找条目:返回 (vm_name, entry);找不到即 (None, None)(未登记的 快照:归属无从核对,调用方按 legacy/未登记处理, 不在此处捏造归属)。URL 精确比对,不做前缀猜测。""" try: entries = os.listdir(state_dir) except OSError: return None, None for entry in entries: if not entry.startswith("chv-"): continue for item in _load_snapshots(state_dir, entry): if isinstance(item, dict) \ and item.get("url") == snapshot_url: return entry, item return None, None def _persistent_file_path(state_dir, tenant_id): import re safe = re.sub(r"[^A-Za-z0-9_.-]", "-", str(tenant_id or "default")) return os.path.join(state_dir, PERSISTENT_DIR_NAME, safe + ".json") def write_persistent_vm(state_dir: str, tenant_id: str | None, record: dict) -> str: """写持久 VM 登记(atomic 写):record 至少含 {vm, tenant_id, state: paused|running, origin: prewarm|task, gitea_synced};调用方负责字段完整。""" import json from cloud.chv.atomic_file import atomic_write_text path = _persistent_file_path(state_dir, tenant_id) os.makedirs(os.path.dirname(path), exist_ok=True) atomic_write_text(path, json.dumps(record)) return path def read_persistent_vm(state_dir: str, tenant_id: str | None) -> dict | None: """读持久 VM 登记:无文件/损坏/字段不全即 None (无可用持久 VM,调用方回落冷启,不猜)。""" import json try: with open(_persistent_file_path(state_dir, tenant_id), "r") as fh: data = json.load(fh) except (OSError, ValueError): return None if not isinstance(data, dict) or not data.get("vm") \ or data.get("tenant_id") != tenant_id: return None return data def clear_persistent_vm(state_dir: str, tenant_id: str | None) -> bool: """清持久 VM 登记(VM 已死/已回收时调):返回 True 表示确实清掉了一个登记。""" path = _persistent_file_path(state_dir, tenant_id) if os.path.exists(path): os.remove(path) return True return False # -- 快照登记与上限(feat-chv-secfix,M3)------------------------- # 登记文件:state_dir//snapshots.json,内容为 # [{url, path(file:// 才有), size, created_at}]。登记是 # 执行器在宿主侧的账本,不是 VMM 的快照元数据;VMM 不认 # 它,删登记不等于删快照文件(反之亦然),两者在 # prune/record 里成对处理并审计。 def _snapshots_path(state_dir, vm_name): return os.path.join(state_dir, vm_name, "snapshots.json") def _snapshot_path_size(path): """快照产物大小:v54 起快照是目录(含 config.json/ state.json/memory-ranges),取目录内文件总和;文件 目标(旧登记)仍取文件大小;读不出时返回 None(调用 方按未知处理,不谎报 0)。""" if os.path.isdir(path) and not os.path.islink(path): return _dir_total_bytes(path) try: return os.path.getsize(path) except OSError: return None def _remove_snapshot_path(path): """删快照产物:目录整树删,文件/链接只删本身。""" if os.path.isdir(path) and not os.path.islink(path): shutil.rmtree(path) elif os.path.islink(path) or os.path.exists(path): os.remove(path) def has_recorded_snapshots(state_dir: str, vm_name: str) -> bool: """该 VM 是否还有登记在册的快照(hibernate 的盘保护 依据):有登记快照时任务盘是 restore 的输入,残留盘 GC 不得删;登记被剪干净后盘即成孤儿,可正常 GC。""" return bool(_load_snapshots(state_dir, vm_name)) def _load_snapshots(state_dir, vm_name): import json path = _snapshots_path(state_dir, vm_name) try: with open(path, "r") as fh: data = json.load(fh) except (OSError, ValueError): return [] return data if isinstance(data, list) else [] def _save_snapshots(state_dir, vm_name, entries): import json from cloud.chv.atomic_file import atomic_write_text path = _snapshots_path(state_dir, vm_name) os.makedirs(os.path.dirname(path), exist_ok=True) # feat-chv-closeup(P2-18):随机 tmp 名 + 双 fsync。 atomic_write_text(path, json.dumps(entries)) def prune_snapshots(state_dir: str, vm_name: str, retention_days: float | None, now: float | None = None, delete_files: bool = True) -> tuple[list, list]: """按保留期剪枝:删过期条目;delete_files 时同时删 对应的 file:// 快照文件(只删登记里记的 path,不猜 文件名)。返回(保留条目,剪掉条目)。""" import time now = time.time() if now is None else now kept, pruned = [], [] for entry in _load_snapshots(state_dir, vm_name): created = entry.get("created_at") or 0 if retention_days is not None \ and now - created > retention_days * 86400: pruned.append(entry) if delete_files: _prune_snapshot_file(entry) else: kept.append(entry) if pruned: _save_snapshots(state_dir, vm_name, kept) return kept, pruned def _prune_snapshot_file(entry): """剪枝时删一条登记对应的快照产物:目录整树删, 文件只删本身;删失败静默(登记已剪,残留产物由 下次 GC/人工清理,不因删产物失败挡住剪枝本身)。""" path = entry.get("path") if not path: return try: _remove_snapshot_path(path) except OSError: pass def check_snapshot_limits(state_dir: str, vm_name: str, max_count: int, max_total_gb: float, retention_days: float | None) -> list: """快照上限预检(snapshot_vm 调 ch-remote 之前调): 先按保留期剪枝,再查数量(已有数 + 本次 > 上限即 snapshot_quota_exceeded)与总量(已有总量已达上限即 拒绝;本次大小事前未知,总量在 record 时二次收紧, 见 record_snapshot)。返回剪枝后条目。""" kept, _ = prune_snapshots(state_dir, vm_name, retention_days) if len(kept) + 1 > max_count: raise ChvFailure( "snapshot_quota_exceeded", "快照数量已达上限 %d(VM %s)" % (max_count, vm_name)) total: float = 0.0 for entry in kept: size = entry.get("size") if isinstance(size, (int, float)): total += size elif entry.get("path") and os.path.exists(entry["path"]): _sized = _snapshot_path_size(entry["path"]) if _sized is not None: total += _sized if total >= max_total_gb * 1024 ** 3: raise ChvFailure( "snapshot_quota_exceeded", "快照总量 %d 字节已达上限 %.1f GB(VM %s)" % (total, max_total_gb, vm_name)) return kept def record_snapshot(state_dir: str, vm_name: str, snapshot_url: str, max_total_gb: float, tenant_id: str | None = None) -> tuple[list, list]: """快照成功后登记;file:// 目标记实际大小。登记后若 总量超上限,按最旧优先删登记外的旧快照文件(总量硬 帽,删动作由调用方审计——本函数返回被删的旧条目)。 返回(全部条目,被总量帽删掉的旧条目)。 feat-chv-opt-landing:tenant_id 给出时条目带 tenant 字段(归属标记);不给时不写该字段——缺字段即 legacy 未标记,恢复校验时按 legacy 处理,绝不事后补一个 猜出来的 tenant。""" import time path = None size = None if snapshot_url.startswith("file://"): path = snapshot_url[len("file://"):] size = _snapshot_path_size(path) entries = _load_snapshots(state_dir, vm_name) entry = {"url": snapshot_url, "path": path, "size": size, "created_at": time.time()} if tenant_id: entry["tenant"] = tenant_id entries.append(entry) _save_snapshots(state_dir, vm_name, entries) removed = [] if max_total_gb is not None: cap = max_total_gb * 1024 ** 3 total = sum(e.get("size") or 0 for e in entries) while len(entries) > 1 and total > cap: oldest = entries.pop(0) _prune_snapshot_file(oldest) total -= oldest.get("size") or 0 removed.append(oldest) if removed: _save_snapshots(state_dir, vm_name, entries) return entries, removed