Files
codex-app-server/cloud/chv/disk.py
T
host 65504ae8cb fix(chv): V-3/F1/F2 真机缺陷修复 + V-1/V-4 附带修复
V-3 快照/restore:
- snapshot 改传裸 URL(source_url= 是 restore 的键,服务端
  Could not extract path from URL);快照产物是目录,校验
  改为目录非空,登记计量/剪枝同步支持目录
- restore 起 VMM 前补 _restore_prepare:杀旧 VMM(登记的 +
  未登记跨实例扫描)并清旧 socket,与 _wake_prepare 对等
- hibernate 评估结论:快照 config.json 引用原盘,旧 stop 删
  盘致 restore 必失败;hibernate 的 stop 改 keep_disk=True,
  GC 不删有登记快照的 VM 的盘,restore 前预检快照引用的盘
  (snapshot_disk_missing fail-fast)

F1:re-wake 删旧盘——H5 删私钥致 re-wake 必然生成新 key 对,
旧盘复用时 cloud-init 不重应用新 seed,sshd 仍呈旧 key 而
known_hosts 钉新 key;wake 本就是冷启动,旧盘不是要保留的状态

F2:stop_vm 跨实例按 socket 进程扫描杀未登记 VMM
(/proc cmdline 精确 socket + VMM 二进制名 + 跳过自身/pid 1,
绝不误杀 ch-remote 与他人 VMM)

V-4:remote_command source 失败改 || exit 126 fail-fast;
guest-env 条目 owner 改归 SSH 用户(0600 不变),root 仍 root:root

V-1 附带:build.sh 预建 /app/work(1777 粘滞位 + stat 断言);
host.py default_codex_bin() 改 shutil.which,裸名不再让
health 报 ok:false

测试:新 test_chv_v3_fixes.py 23 项;旧快照测试按目录产物改写;
test_chv* 458 全过,全量 cloud 套件 1930 全过(skipped=8)
2026-11-08 09:45:00 +00:00

286 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 对已存在
磁盘校验大小:与源不一致即重拷;源不可读即报失败,
绝不复用脏磁盘。
"""
import os
import shutil
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) -> None:
"""磁盘配额门(feat-chv-fix 口径):
- disk_gb 未设时不检查;
- 镜像不可读时报 disk_quota_unverified,不静默跳过;
- 镜像大小超过 disk_gb 即 disk_quota_exceeded;
- state_dir 已给时:已用总量 + 本次拷贝超过 disk_gb
即 disk_quota_exceeded;宿主剩余空间不足以容纳本次
拷贝即 disk_quota_exceeded(宿主余量校验)。
"""
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))
if state_dir and os.path.isdir(state_dir):
used = _dir_total_bytes(state_dir)
if used + size > disk_gb * 1024 ** 3:
raise ChvFailure(
"disk_quota_exceeded",
"磁盘总量 %d 字节 + 本次 %d 字节超过配额 %.1f GB"
% (used, size, disk_gb))
try:
free = shutil.disk_usage(state_dir).free
except OSError:
free = None
if free is not None and free < size:
raise ChvFailure(
"disk_quota_exceeded",
"宿主剩余空间 %d 字节不足以容纳本次磁盘拷贝 %d 字节"
% (free, size))
def provision_task_disk(state_dir: str, vm_name: str, rootfs: str, disk_mode: str) -> str:
"""每任务磁盘路径:copy 模式复制到
state_dir/<vm>/disk.raw;reuse 模式直接返回基础镜像路径。
feat-chv-fix:已存在磁盘须校验——大小与源不一致即
重拷(脏磁盘不复用);源不可读即 ChvFailure,绝不
静默返回旧磁盘。"""
if disk_mode == "reuse":
return rootfs
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/<vm>/disk.raw。"""
return os.path.join(state_dir, vm_name, "disk.raw")
def delete_task_disk(state_dir: str, vm_name: str) -> bool:
"""删任务磁盘(feat-chv-secfix,H3):stop/recycle/
清理路径都调它。返回 True 表示确实删掉了一个文件,
False 表示本来就没有;删失败抛 OSError(调用方记审计,
绝不静默——残留盘由 gc_stale_disks 下次再清)。
符号链接只删链接本身,不跟随。"""
path = task_disk_path(state_dir, vm_name)
if os.path.islink(path) or os.path.exists(path):
os.remove(path)
return True
return False
# -- 快照登记与上限(feat-chv-secfix,M3)-------------------------
# 登记文件:state_dir/<vm>/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) -> tuple[list, list]:
"""快照成功后登记;file:// 目标记实际大小。登记后若
总量超上限,按最旧优先删登记外的旧快照文件(总量硬
帽,删动作由调用方审计——本函数返回被删的旧条目)。
返回(全部条目,被总量帽删掉的旧条目)。"""
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)
entries.append({"url": snapshot_url, "path": path,
"size": size, "created_at": time.time()})
_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