Files
codex-app-server/cloud/chv/disk.py
T
host 7a6fae7993 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 全注解、文案钉死)。
不推、不合、不部署;只本地分支。
2026-11-08 09:45:00 +00:00

624 lines
26 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 对已存在
磁盘校验大小:与源不一致即重拷;源不可读即报失败,
绝不复用脏磁盘。
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