feat(chv): 阶段 1 持久 VM、overlay 分层与预热快照

- disk.py:overlay 模式(qcow2 backing file 指只读 base,
  建盘/复用/重建校验 + qemu-img fail-closed)、数据盘
  data.qcow2 独立供给(不进快照/GC/stop)、配额增量口径
  (overlay 本次增量 0)、快照登记 tenant 字段、每 VM
  tenant.json、持久登记 persistent/<tenant>.json、快照
  登记按 URL 查找。
- config.py:CHV_DISK_MODE 白名单加 overlay(默认仍 copy;
  错误文案保留旧子串,防回归测试钉死)。
- executor.py:VmConfig image_type 按后缀(.qcow2→Qcow2,
  其余 Raw)+ 数据盘第三块盘;overlay 供给写 tenant 标记;
  snapshot_vm 登记带 tenant,_validate_restore 归属校验
  (跨用户 snapshot_tenant_mismatch 拒绝 + 审计,legacy
  未标记/未登记放行并警告);stop/GC 在 overlay 模式下
  保护持久登记/快照认领的盘;持久 VM(prewarm/acquire/
  suspend/dispatch_task_persistent/recycle_persistent_vm,
  CHV_WARM_POOL_SIZE>=1 启用,=0 退化冷启旧语义),
  resume 只认 vm.info=Paused + socket 活性 + 归属一致。
- tests:cloud/tests/test_chv_opt_stage1.py 26 项(overlay、
  tenant、持久池、跨用户拒绝、两连任务、显式回收)。
回归:discover test_chv_*.py 与 test_cloud_hypervisor.py
全过(结构契约:函数 ≤50 行、公开 API 全注解、文案钉死)。
不推、不合、不部署;只本地分支。
This commit is contained in:
host committed 2026-11-08 09:45:00 +00:00
1 parent c904ae9358
commit 7a6fae7993
4 files changed
+1643 -65

No files matched your search

+353 -15
View File
@@ -14,10 +14,19 @@ check_disk_quota 改为三项:state_dir 已用总量 + 本次
是直接调用者的第二道门)。provision_task_disk 对已存在
磁盘校验大小:与源不一致即重拷;源不可读即报失败,
绝不复用脏磁盘。
feat-chv-opt-landing(阶段 1):黄金镜像只读 base +
qcow2 overlay 分层(disk_mode="overlay")、数据盘独立
持久(data.qcow2,绝不进快照/GC)、快照与 overlay 的
tenant 归属标记(登记条目 tenant 字段 + 每 VM tenant.json
+ 持久 VM 登记 persistent/<tenant>.json)。overlay 与
数据盘只在 overlay 模式下供给;copy/reuse 语义不变。
"""
import os
import shutil
import subprocess
from collections.abc import Callable
from cloud.chv.errors import ChvFailure
@@ -33,7 +42,8 @@ def _dir_total_bytes(path):
return total
def check_disk_quota(rootfs: str, disk_gb: float, state_dir: str | None = None) -> None:
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 未设时不检查;
@@ -42,6 +52,11 @@ def check_disk_quota(rootfs: str, disk_gb: float, state_dir: str | None = None)
- 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
@@ -55,33 +70,46 @@ def check_disk_quota(rootfs: str, disk_gb: float, state_dir: str | None = None)
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 + size > disk_gb * 1024 ** 3:
if used + increment > disk_gb * 1024 ** 3:
raise ChvFailure(
"disk_quota_exceeded",
"磁盘总量 %d 字节 + 本次 %d 字节超过配额 %.1f GB"
% (used, size, disk_gb))
% (used, increment, disk_gb))
try:
free = shutil.disk_usage(state_dir).free
except OSError:
free = None
if free is not None and free < size:
if free is not None and free < increment:
raise ChvFailure(
"disk_quota_exceeded",
"宿主剩余空间 %d 字节不足以容纳本次磁盘拷贝 %d 字节"
% (free, size))
% (free, increment))
def provision_task_disk(state_dir: str, vm_name: str, rootfs: str, disk_mode: str) -> str:
def provision_task_disk(state_dir: str, vm_name: str, rootfs: str, disk_mode: str,
runner: Callable[..., object] | None = None) -> str:
"""每任务磁盘路径:copy 模式复制到
state_dir/<vm>/disk.raw;reuse 模式直接返回基础镜像路径。
state_dir/<vm>/disk.raw;reuse 模式直接返回基础镜像路径;
overlay 模式(feat-chv-opt-landing,阶段 1)在
state_dir/<vm>/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")
@@ -113,23 +141,325 @@ def provision_task_disk(state_dir: str, vm_name: str, rootfs: str, disk_mode: st
def task_disk_path(state_dir: str, vm_name: str) -> str:
"""该 VM 的任务磁盘路径:state_dir/<vm>/disk.raw。"""
"""该 VM 的任务磁盘路径:state_dir/<vm>/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.qcow2 不在内——用户数据盘永不
进任务盘 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 下次再清)。
符号链接只删链接本身,不跟随。"""
path = task_disk_path(state_dir, vm_name)
符号链接只删链接本身,不跟随。
feat-chv-opt-landing:disk.raw 与 disk.qcow2 都删
(任一存在即删);数据盘 data.qcow2 绝不在此删——
它是用户数据,删它须走 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.qcow2,工作区 + 浏览器 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.qcow2"
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/<vm>/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/<vm>/data.qcow2。"""
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 <base>
-F <base 格式> <overlay>):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,
runner: Callable[..., object] | None = None) -> str:
"""建数据盘(空 qcow2 稀疏盘,虚大小默认 10 GB,实际
占用随写入增长):已存在即复用(不 resize、不重建,
旧盘里可能已有用户数据);不存在即 qemu-img create。
失败即 ChvFailure disk_overlay_invalid(数据盘建不出
来时 overlay 模式拒绝启动,不让任务在无数据盘的
VM 里把工作区写进系统盘——静默回落等于丢“独立持久”
的保证)。"""
run = runner if runner is not None else _default_qemu_runner
qemu = "qemu-img" if runner is not None else _qemu_img_cmd()
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
result = run([qemu, "create", "-f", "qcow2",
path, "%gG" % size_gb])
if getattr(result, "returncode", 1) != 0:
raise ChvFailure(
"disk_overlay_invalid",
"数据盘建盘失败:%s:%s"
% (path, getattr(result, "stderr", "") or ""))
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/<tenant>.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/<vm>/snapshots.json,内容为
# [{url, path(file:// 才有), size, created_at}]。登记是
@@ -256,11 +586,16 @@ def check_snapshot_limits(state_dir: str, vm_name: str, max_count: int,
return kept
def record_snapshot(state_dir: str, vm_name: str, snapshot_url: str, max_total_gb: float) -> tuple[list, list]:
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
@@ -268,8 +603,11 @@ def record_snapshot(state_dir: str, vm_name: str, snapshot_url: str, max_total_g
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()})
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: