- 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 全注解、文案钉死)。 不推、不合、不部署;只本地分支。
624 lines
26 KiB
Python
624 lines
26 KiB
Python
"""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.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
|
||
|
||
|
||
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/<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")
|
||
# 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
|
||
(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 下次再清)。
|
||
符号链接只删链接本身,不跟随。
|
||
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}]。登记是
|
||
# 执行器在宿主侧的账本,不是 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
|