- 登记扩展 tier/state/last_active_at/lease_expires_at;认领三重核对 原样保留,新增 tier 校验(无 tier 行 = 无常驻资格,fail-closed; 与 P11 用量门不对称已写进注释) - 租约计时器:闲置 30 分钟挂起、再 24 小时降级;阈值经环境变量注入, 生产默认值不变;worker 心跳轮询驱动 sweep_resident_leases; 降级同步失败即中止、VM 保留 - 排队:300 秒超时后 host_capacity_exceeded 明确拒绝;不插队、 不抢占常驻槽位、不升降档;常驻任务仍等常驻槽位;队列深度供 L1 读 - 升级提示点 resident_upgrade_hint:30 天 ≥100 次或连续 7 天每天 ≥3 次, 一周内同一租户最多一次;频率只用于提示,绝不改路由 - 网关分流:常驻 VM 可接网关、池 VM 以 gateway_pool_denied 拒绝 (worker 侧守卫自 P1-1 起已放行 CHV,分流 enforcement 落执行器)
4323 lines
211 KiB
Python
4323 lines
211 KiB
Python
"""Cloud Hypervisor 执行器(从 cloud/sandbox.py 巨类拆出)。
|
||
|
||
模块分工(cloud/chv/):
|
||
- errors.py:ChvFailure 分类失败
|
||
- config.py:配置解析(参数/env/默认值)与内存字符串解析
|
||
- api_client.py:Unix socket 上的 VMM REST API 传输与归一化
|
||
- ssh_transport.py:SSH 命令构造、每 VM known_hosts 钉死、
|
||
ssh-keyscan 首次捕获(TOFU 边界见该模块)
|
||
- disk.py:每任务磁盘供给与磁盘配额门
|
||
- orphans.py:生产残留清理唯一入口(registry 文件直读清理)
|
||
- experimental_pool.py:WarmPool 预热池(纯实验,生产禁 import)
|
||
- executor.py(本模块):执行器类本体——生命周期方法
|
||
(wake/pause/resume/snapshot/restore/hibernate/stop/
|
||
cleanup)与任务执行方法(dispatch/run/goal/execute)。
|
||
类体仍较长是故意的:生命周期与执行入口共享同一份
|
||
_vms 登记与审计状态,再按方法拆 mixin 只会把状态机
|
||
藏进多文件继承链;真正的可拆部分(配置/传输/磁盘/
|
||
池)已经拆出去了。
|
||
|
||
循环导入说明(必读):cloud/sandbox.py 顶层 re-export
|
||
本模块(保证 `from cloud.sandbox import
|
||
CloudHypervisorExecutor` 旧路径可用),所以本模块顶层
|
||
绝不能 import cloud.sandbox;凡是原来 sandbox.py 里的
|
||
共享 helper(goal 解析、host 命令选项、runner 超时、
|
||
_parse_host_result),一律在方法内按需 import——调用时
|
||
sandbox 早已加载完成,不会撞上半初始化的模块。
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
import shutil
|
||
import socket
|
||
import subprocess
|
||
import threading
|
||
import time
|
||
from collections.abc import Callable
|
||
|
||
from cloud.chv.api_client import _unix_socket_api, normalize_api_result
|
||
from cloud.chv.atomic_file import atomic_write_text
|
||
from cloud.chv.config import _parse_memory_bytes, build_chv_config
|
||
from cloud.chv.disk import (
|
||
check_disk_quota,
|
||
check_snapshot_limits,
|
||
clear_persistent_vm,
|
||
data_disk_path,
|
||
delete_data_disk,
|
||
delete_task_disk,
|
||
find_snapshot_entry,
|
||
has_recorded_snapshots,
|
||
overlay_disk_path,
|
||
provision_data_disk,
|
||
provision_task_disk,
|
||
read_persistent_vm,
|
||
read_vm_tenant,
|
||
record_snapshot,
|
||
task_disk_path,
|
||
task_disk_paths,
|
||
write_persistent_vm,
|
||
write_vm_tenant,
|
||
)
|
||
from cloud.chv.errors import ChvFailure
|
||
from cloud.chv.hardening import (
|
||
ensure_secure_state_dir,
|
||
parse_net_entries,
|
||
socket_is_live,
|
||
validate_egress_proxy,
|
||
validate_net_entries,
|
||
validate_snapshot_url,
|
||
)
|
||
from cloud.chv.ssh_transport import build_ssh_command as _build_ssh_cmd
|
||
from cloud.chv.ssh_transport import known_hosts_path as _known_hosts_path
|
||
from cloud.chv.ssh_transport import (
|
||
parse_keyscan_output,
|
||
split_guest_env,
|
||
split_secret_env,
|
||
write_known_hosts,
|
||
)
|
||
from cloud.executor_base import Executor
|
||
|
||
_LOGGER = logging.getLogger("cloud.cloud_hypervisor")
|
||
|
||
|
||
# -- 混合模式 B 轨(feat-chv-hybrid-b,设计 DESIGN-HYBRID.md §9.1
|
||
# 第 5–9 项;已定值直接用,不许改回提案口径)-------------------
|
||
# 常驻资格档位(§5):只有 Paid/Heavy 有常驻资格,Free 无论多
|
||
# 高频都没有。
|
||
RESIDENT_TIERS = ("Paid", "Heavy")
|
||
# 常驻租约状态(§4.1):登记 state 字段取值。legacy 的
|
||
# paused/running 是阶段 1 持久 VM 的旧状态,不是常驻态。
|
||
RESIDENT_STATES = ("resident-active", "resident-paused",
|
||
"hibernated", "demoted")
|
||
# 常驻有效态(占槽、续期、认领、网关放行都只认这三个):
|
||
# demoted 已收回租约,不占槽、不续期、不复活。
|
||
RESIDENT_ACTIVE_STATES = ("resident-active", "resident-paused",
|
||
"hibernated")
|
||
# 认领时可 resume 的登记状态:legacy paused + 常驻态里 VM 已
|
||
# 暂停的两种(resident-active 的 VM 在任务间隙同样被
|
||
# suspend_after_task 暂停;hibernated 的 VM 已停机,须走
|
||
# restore,不进 resume 认领;demoted 已收回租约,不认领)。
|
||
RESIDENT_CLAIMABLE_STATES = ("paused", "resident-active",
|
||
"resident-paused")
|
||
# 租约阈值(已定值,§4.2):闲置 30 分钟挂起,挂起后再闲置
|
||
# 24 小时降级(累计 24.5 小时无活动)。生产默认值不变;
|
||
# 测试用环境变量注入短阈值(见 _resident_thresholds)。
|
||
RESIDENT_IDLE_SUSPEND_SECONDS = 30 * 60
|
||
RESIDENT_IDLE_DEMOTE_SECONDS = 24 * 3600
|
||
# 每宿主常驻上限(已定值,§4.1):宿主 VM 槽位的 50%。
|
||
RESIDENT_MAX_FRACTION = 0.5
|
||
# 排队超时(已定值,§3.4):无可用宿主时等 300 秒再明确拒绝。
|
||
QUEUE_TIMEOUT_SECONDS = 300
|
||
QUEUE_POLL_SECONDS = 5
|
||
# 升级提示点(已定值,§5):30 天内池任务 ≥100 次,或连续 7 天
|
||
# 每天 ≥3 次;一周内同一租户最多记一次。
|
||
UPGRADE_HINT_WINDOW_DAYS = 30
|
||
UPGRADE_HINT_MIN_TASKS = 100
|
||
UPGRADE_HINT_STREAK_DAYS = 7
|
||
UPGRADE_HINT_MIN_PER_DAY = 3
|
||
UPGRADE_HINT_DEDUP_DAYS = 7
|
||
# 网关 provider(§6):目前唯一的长连接网关是 sub2api。
|
||
GATEWAY_PROVIDERS = ("sub2api",)
|
||
|
||
|
||
def _pid_gone(pid):
|
||
"""进程是否已不在(/proc 消失,或已成僵尸:父进程未收,
|
||
但内存/tap 等资源已释放,等同已死)。"""
|
||
try:
|
||
with open("/proc/%d/stat" % pid, "rb") as fh:
|
||
stat = fh.read()
|
||
except OSError:
|
||
return True
|
||
try:
|
||
state = stat.rsplit(b")", 1)[1].split()[0]
|
||
except (IndexError, ValueError):
|
||
return False
|
||
return state == b"Z"
|
||
|
||
|
||
def _terminate_pid(pid, sigterm_timeout=2.0):
|
||
"""SIGTERM→等→SIGKILL 杀一个进程:best-effort 不抛异常。
|
||
僵尸态即算已死(资源已释放),不等父进程收尸。"""
|
||
import signal
|
||
try:
|
||
os.kill(pid, signal.SIGTERM)
|
||
except OSError:
|
||
return
|
||
deadline = time.time() + sigterm_timeout
|
||
while time.time() < deadline:
|
||
if _pid_gone(pid):
|
||
return
|
||
time.sleep(0.05)
|
||
try:
|
||
os.kill(pid, signal.SIGKILL)
|
||
except OSError:
|
||
return
|
||
deadline = time.time() + sigterm_timeout
|
||
while time.time() < deadline:
|
||
if _pid_gone(pid):
|
||
return
|
||
time.sleep(0.05)
|
||
|
||
|
||
def _find_vmm_pids_by_socket(socket_path, binary_basename):
|
||
"""扫描 /proc,找命令行以 --api-socket 带该 socket 路径、
|
||
且 argv[0] 是给定 VMM 二进制的进程 pid 列表。三道门:
|
||
精确 socket 路径(每 VM 唯一)、VMM 二进制名(ch-remote
|
||
等客户端绝不认)、跳过自身与 pid 1。读不到 cmdline 的
|
||
进程跳过,不抛异常。"""
|
||
if not socket_path or not binary_basename:
|
||
return []
|
||
try:
|
||
entries = os.listdir("/proc")
|
||
except OSError:
|
||
return []
|
||
me = os.getpid()
|
||
found = []
|
||
for entry in entries:
|
||
if not entry.isdigit():
|
||
continue
|
||
pid = int(entry)
|
||
if pid in (1, me):
|
||
continue
|
||
try:
|
||
with open("/proc/%d/cmdline" % pid, "rb") as fh:
|
||
raw = fh.read()
|
||
except OSError:
|
||
continue
|
||
if not raw:
|
||
continue
|
||
tokens = [t.decode("utf-8", "surrogateescape")
|
||
for t in raw.split(b"\0") if t]
|
||
if not tokens:
|
||
continue
|
||
if os.path.basename(tokens[0]) != binary_basename:
|
||
continue
|
||
if socket_path not in tokens:
|
||
continue
|
||
if "--api-socket" not in tokens \
|
||
and not any(t.startswith("--api-socket=")
|
||
for t in tokens):
|
||
continue
|
||
found.append(pid)
|
||
return found
|
||
|
||
|
||
def _assert_snapshot_dir_persisted(snapshot_url):
|
||
"""file:// 快照的落盘校验(v54 起产物是目录):目标须是
|
||
已生成的非空目录,否则 ChvFailure snapshot_not_persisted。
|
||
非 file:// 目标无法从宿主验证,只认 ch-remote returncode。"""
|
||
if not snapshot_url.startswith("file://"):
|
||
return
|
||
snap_path = snapshot_url[len("file://"):]
|
||
if not os.path.isdir(snap_path) or not os.listdir(snap_path):
|
||
raise ChvFailure(
|
||
"snapshot_not_persisted",
|
||
"快照未落盘或为空目录:%s" % snapshot_url)
|
||
|
||
|
||
# SSH 连接建立阶段瞬时 reset 的 stderr 标记(V-7/F1):
|
||
# kex 阶段被 reset 时 OpenSSH 报 kex_exchange_identification
|
||
# + Connection reset,远端命令根本没开始执行。只认这组
|
||
# 标记:host key 拒绝(同为 rc=255)与远端命令失败不在此列。
|
||
_SSH_TRANSIENT_RESET_MARKERS = (
|
||
"kex_exchange_identification",
|
||
"Connection reset",
|
||
"Connection closed by",
|
||
)
|
||
|
||
|
||
def _is_transient_ssh_reset(result) -> bool:
|
||
"""单次 SSH 是否撞上连接建立阶段的瞬时 reset:returncode
|
||
255 + stderr 含 kex/reset 标记。stderr 可能是 bytes,
|
||
按文本解码后比对;无 stderr 即不认,不猜。"""
|
||
if getattr(result, "returncode", 0) != 255:
|
||
return False
|
||
stderr = getattr(result, "stderr", "") or ""
|
||
if isinstance(stderr, bytes):
|
||
stderr = stderr.decode("utf-8", "replace")
|
||
return any(marker in stderr
|
||
for marker in _SSH_TRANSIENT_RESET_MARKERS)
|
||
|
||
|
||
def _ensure_snapshot_dir(snapshot_url, snapshot_path) -> None:
|
||
"""file:// 快照目标目录建好(V-7/D1):服务端要求目标是
|
||
已存在的目录,fresh URL 直接 snapshot 会 rc=1
|
||
(Destination is not a directory),mkdir 后同一 URL 即
|
||
通。非 file:// 目标(snapshot_path 为 None)跳过。目标
|
||
已存在且不是目录、或建目录失败时报 snapshot_not_persisted
|
||
(ch-remote 之前 fail-fast,不调 ch-remote)。"""
|
||
if snapshot_path is None:
|
||
return
|
||
if os.path.exists(snapshot_path) \
|
||
and not os.path.isdir(snapshot_path):
|
||
raise ChvFailure(
|
||
"snapshot_not_persisted",
|
||
"快照目标已存在且不是目录:%s" % snapshot_url)
|
||
try:
|
||
os.makedirs(snapshot_path, exist_ok=True)
|
||
except OSError as exc:
|
||
raise ChvFailure(
|
||
"snapshot_not_persisted",
|
||
"快照目录无法创建:%s:%s" % (snapshot_url, exc))
|
||
|
||
|
||
def _disk_image_type(path: str) -> str:
|
||
"""VmConfig 磁盘 image_type 枚举原名(feat-chv-opt-landing):
|
||
.qcow2 后缀即 Qcow2,其余即 Raw(raw 无魔数、JSON 里写
|
||
小写 raw 会报 unknown variant,见 _vm_config_disks)。"""
|
||
return "Qcow2" if str(path).endswith(".qcow2") else "Raw"
|
||
|
||
|
||
def _default_popen_launcher(cmd):
|
||
"""默认 VMM 启动器:独立会话子进程,输出丢弃(纯移动)。"""
|
||
return subprocess.Popen(
|
||
cmd, stdout=subprocess.DEVNULL,
|
||
stderr=subprocess.DEVNULL, start_new_session=True)
|
||
|
||
|
||
class CloudHypervisorExecutor(Executor):
|
||
"""Cloud Hypervisor 执行器 v1(命名:cloud-hypervisor,绝不用
|
||
cloud/ch 缩写——cloud 已是通用 HTTP runner 占位,撞名会让
|
||
认领过滤与白名单全部错乱)。
|
||
|
||
调度层选择,不是任务内工具(补充口径,必读):
|
||
- 任务内的 LLM 绝不能发现、调用本执行器:执行器是在任务
|
||
创建前由 server 白名单(POST /tasks 的 executor 字段)、
|
||
环境默认值(CLOUD_DEFAULT_EXECUTOR)或 worker CLI
|
||
--executor 认领决定的。任务内 agent 若能自选沙箱,等
|
||
于能从隔离强的执行器降级逃逸到隔离弱的执行器。
|
||
- 本模块不提供任何 MCP/工具/skill 给任务内 agent;需要可
|
||
发现的是创建任务那一层:POST /tasks 的 executor 可选值、
|
||
cloud/README.md 说明、控制台选项(只改文档与白名单,
|
||
不新增任务内调用入口)。
|
||
|
||
客内通道:只选 SSH,不做 vsock 第二套。
|
||
- 选 SSH 的理由:客内 host.py 的调用就是一条普通命令,
|
||
SSH 直接复用 OpenSSH 的密钥认证与端口转发,云镜像自带
|
||
sshd,无需在客内再写一个 vsock agent、也无需在宿主再写
|
||
一个 vsock 代理去翻译 host.py 协议。
|
||
- 放弃 vsock 的代价:需要运维提供从宿主到客机的 SSH 通路
|
||
(tap/桥接 + 端口转发,见下文“VM 生命周期”)。
|
||
- Host key 钉死(本轨 #216 后的加固,默认行为变化):
|
||
不再用 StrictHostKeyChecking=no + UserKnownHostsFile=
|
||
/dev/null。每 VM 在 state_dir/<vm>/known_hosts 有独立
|
||
文件;首次启动时 wait_guest_ready 经 ssh-keyscan 在本
|
||
机回环端口上捕获当时呈现的 host key 写入该文件(首次
|
||
信任 TOFU:只防首次之后的替换,不验证首次本身,边界
|
||
见 cloud/chv/ssh_transport.py),此后同一 VM 的每次
|
||
SSH 都用 StrictHostKeyChecking=yes + 该文件校验,换
|
||
key 直接拒绝。客机用户与端口是显式配置:CHV_SSH_USER
|
||
(默认 root,显式空字符串直接 ValueError,绝不静默
|
||
fallback 回 root)、CHV_SSH_PORT(默认 2222,必须在
|
||
1-65535 内);非 root 用户要求客机镜像里该用户已配
|
||
sshd 与 authorized_keys(运维责任)。
|
||
- 密钥红线:SSH 只传 -i 密钥文件路径,绝不把任何 key 值、
|
||
模型 key 值放进 argv、日志或返回结果;客内 host.py 的
|
||
模型 key 由客机镜像/cloud-init 预置(运维责任),v1 不
|
||
在 SSH 命令里注入任务级 key。feat-chv-secfix 起网关
|
||
密钥改走 seed 盘客内文件投递(见 wake_vm 与
|
||
hostkeys.write_seed),keyscan 模式下密钥投递
|
||
fail-closed;非密钥 extra_env 仍走 env 前缀。
|
||
|
||
VM 唤醒流程(写进代码,不是只留文档;API 模式为准):
|
||
- 主唤醒路径是 API 模式,不是“一条直接启动命令就完事”:
|
||
wake_vm() 先 preflight,再经 launcher 以 --api-socket
|
||
只启动 VMM 进程(CLI 上不带内核/磁盘配置),等
|
||
vmm.ping 通,再 PUT vm.create(VmConfig)+ PUT vm.boot。
|
||
_build_vm_command() 保留的直接启动命令只是文档里的手动
|
||
等价写法(运维手工起 VM 时用),执行器代码里不走它。
|
||
- 就绪判定只选 SSH,不做 vsock 第二套:boot 后轮询 SSH
|
||
“true”命令,直到 CHV_READY_TIMEOUT(默认 120 秒,
|
||
轮询间隔 CHV_READY_INTERVAL,默认 5 秒)内成功才跑
|
||
host.py/任务;超时则关 VM 并报失败,绝不在未就绪时
|
||
就派任务。vsock 放弃理由同客内通道一节(需客内 agent
|
||
+ 宿主代理两套新代码,v1 不拼半吊子)。
|
||
- 暂停与快照唤醒是两条独立代码分支:
|
||
pause_vm()/resume_vm() 走 ch-remote pause/resume;
|
||
restore_vm() 先 ch-remote restore(source_url=...),
|
||
再显式 resume_vm()——恢复后的 VM 默认是 paused,不
|
||
resume 就不算醒,绝不能只 restore 就返回成功。
|
||
- 关闭与清理:任务结束/失败/取消时走 shutdown_vm()/
|
||
stop_vm():ch-remote shutdown + 终止登记进程 + 删
|
||
socket。dispatch/run/goal 均走“wake → 跑任务 →
|
||
finally shutdown”的每任务 VM 生命周期;execute() 是
|
||
只读 status,仍走 SSH 直连(要求 VM 已醒,调用方先
|
||
wake_vm),不为一次 status 就起一台 VM。本轨把这条
|
||
从“文档约定”收成“代码门禁”:execute() 先查 VM 是否
|
||
在运行(本实例登记了活进程,或 api-socket 文件存在
|
||
视为带外已醒的 VM),未运行直接报 ChvFailure
|
||
(kind=vm_not_running),绝不向一个不存在的端口发
|
||
SSH 再拿连接失败冒充任务状态(execute 语义二选一
|
||
的结论:选“只读已运行 VM + 未运行报分类失败”,
|
||
不选“execute 先唤醒”——唤醒是 dispatch/run/goal
|
||
的职责,status 不应有起 VM 的副作用)。
|
||
- 清理要能处理三种情况:已死进程(poll() 非 None)、
|
||
已关 VM(无登记进程、无 socket,stop 返回 False)、
|
||
socket 残留(有 socket、无登记进程,照常删 socket 并
|
||
计入清理)。cleanup_stale_vms() 只清 chv- 前缀的
|
||
socket,绝不按前缀全删其它执行器的东西。
|
||
|
||
生产级口径(对齐公开沙箱保障口径,不声称复刻某家内部架构):
|
||
- 单任务单 VM:wake → 跑 → finally 关,同一任务不共享 VM;
|
||
每任务磁盘 copy 模式独立,租户无共享路径、无东西向互访;
|
||
VM 内 root 只能影响本 VM。
|
||
- 快速启动:持久 VM + 预热池(feat-chv-opt-landing,
|
||
阶段 1,生产路径,见 pause/resume 与
|
||
dispatch_task_persistent/prewarm_persistent_vm):
|
||
同一租户的任务共用一台持久 VM(overlay 系统盘 +
|
||
独立数据盘),任务间隙 pause、到达时 resume;
|
||
CHV_WARM_POOL_SIZE>=1 时控制面常备 1 台已开机、已
|
||
完成 Gitea 首次同步的 paused 持久 VM,任务到达先
|
||
领它、结束暂停回池,池空回落冷启不报错;为 0(默认)
|
||
时全部退化为冷启旧语义。WarmPool(experimental_pool.py)
|
||
仍是纯实验(ADR-5/ADR-12),生产 dispatch 绝不查它,
|
||
阶段 1 的池不经过它;池状态另落
|
||
state_dir/persistent/<tenant>.json 登记,worker
|
||
重启后载入的条目须经 vm.info=Paused + socket 活性
|
||
+ tenant 归属三重核对才认领,否则只当孤儿清理,
|
||
绝不直接发给任务(见 orphans.py 与 acquire_for_task)。
|
||
- 长任务中途死亡:_run_with_vm 在客内命令抛传输异常、
|
||
或非 goal 结果已 failed 且 VM 登记进程已死时,改判
|
||
interrupted(断点可续跑)而不是笼统 failed;VM 还活
|
||
着时的异常/失败原样处理,不误判。中途心跳未做:评估
|
||
结论是 host.py 单次 SSH 长回合没有客内进度信号可采,
|
||
另起一路 SSH 轮询只能证明“VM 活着”不能证明“任务
|
||
在进展”,徒增连接 churn,不做(见 PR 描述)。
|
||
- 与 #215 收敛模块的关系:#215 未合并,base 里没有
|
||
timeouts/gates/quota/sandbox_policy 模块,本轨不复制
|
||
它们;#215 合并后应再复用的点清单见 cloud/chv/
|
||
config.py 头注释与 PR 描述。
|
||
- 资源与成本:CPU/内存/磁盘配额门、宿主容量门(CHV_MAX_VMS)、
|
||
SSH 超时、goal 100 轮上限、执行器内部不重试;成本与配额
|
||
复用 worker 现有 quota 语义,不另起计费。
|
||
- 网络与治理:出口默认 fail-closed(必须过 CHV_EGRESS_PROXY
|
||
白名单代理);审批仍走任务 approval 与 server merge_gate,
|
||
VM 内 agent 不能自批。
|
||
- 可观测:VM 生命周期事件经 audit_callback 发出(worker 接线
|
||
后转 AuthStore 审计),失败带 ChvFailure.kind 分类;断点
|
||
与 task_checkpoints/task_attempts 语义对齐(recycle 保留
|
||
两者,丢失临时文件)。
|
||
- 默认关闭:server 默认仍 docker、worker 默认仍 local,CHV
|
||
只在显式选择且有 CHV worker 时才跑;无 CHV_* 配置时
|
||
preflight 直接失败。完整清单见
|
||
docs/chv-production-readiness.md。
|
||
- worker 层现状:通用路径已能走 dispatch_task/goal_task;
|
||
断点续跑(goal-resume-breakpoint)已由补缺口轨接线:
|
||
worker 通用分支对任何提供该方法的执行器调用它,
|
||
Local 仍走原路径;网关任务的 worker 侧守卫(P1-1 起)
|
||
已放行 CHV,网关分流改由本执行器按 VM 类 enforcement
|
||
(feat-chv-hybrid-b,见 gateway_allowed_for_task):
|
||
常驻 VM 可接网关、池 VM 不接(ChvFailure
|
||
gateway_pool_denied),改之前网关不上 CHV 的前置
|
||
依赖即由此落地。
|
||
- 网关现状:worker 通用路径在 sub2api 网关下对非 Local
|
||
执行器直接 loud fail(与 CloudExecutor 同款),v1 不
|
||
改这条门,所以 CHV + sub2api 组合目前在 worker 层就
|
||
明确失败,不宣称生产可用。——本段是 v1 旧口径,已被
|
||
feat-chv-hybrid-b 取代:worker 守卫自 P1-1 起已放行
|
||
CHV,网关分流现由本执行器的 gateway_allowed_for_task
|
||
按常驻/池分流(常驻可接、池不接);旧文案留此作
|
||
演变记录,执行以 gateway_allowed_for_task 为准。
|
||
|
||
配置(只走构造参数或环境变量,不写文件;参数优先):
|
||
- CHV_BINARY:cloud-hypervisor 二进制(默认同名 PATH 查找)
|
||
- CHV_KERNEL:客机内核路径(必填,文件必须存在)
|
||
- CHV_ROOTFS:客机根文件系统镜像路径(必填,文件必须
|
||
存在;CHV_IMAGE 是同义别名,仅当 CHV_ROOTFS 未设时用)
|
||
- CHV_CPU:vCPU 数(默认 2,命令里写 boot=N)
|
||
- CHV_MEM:内存(默认 2G,命令里写 size=2G)
|
||
- CHV_STATE_DIR:api-socket 目录(默认
|
||
~/.local/share/codex-app-server/chv-vms,0700;
|
||
feat-chv-secfix 起不再默认 /tmp/chv-vms)
|
||
- CHV_SSH_KEY:SSH 私钥文件路径(必填,文件必须存在)
|
||
- CHV_SSH_USER:SSH 用户(默认 root)
|
||
- CHV_SSH_PORT:宿主侧转发到客机的 SSH 端口(默认 2222)
|
||
- CHV_CMDLINE:客机内核命令行(默认
|
||
"console=hvc0 root=/dev/vda1 rw")
|
||
- CHV_NET:直接启动命令里的 --net 原样透传值(例如
|
||
tap=,mac=...,运维按实际 tap 配置给;不给则命令里不带
|
||
--net)。API 模式不用它,API 的网络配置走 CHV_NET_JSON。
|
||
- CHV_NET_JSON:API 模式 VmConfig 的 net 列表 JSON(例如
|
||
'[{"tap":"","mac":"12:34:56:78:90:01"}]',运维按实际
|
||
tap 给;不给则 VmConfig 不带 net,客机无网卡、SSH 就
|
||
绪必然超时失败——这是 fail-fast 前置条件,不是静默成功)
|
||
- CHV_REMOTE:ch-remote 二进制(默认同名 PATH 查找;
|
||
preflight 必查,缺失即 fail-fast;stop/cleanup 里调用
|
||
失败时尽力而为不炸)
|
||
- CHV_API_TIMEOUT:等 vmm.ping 通的上限秒数(默认 30)
|
||
- CHV_READY_TIMEOUT:等 guest SSH 就绪的上限秒数(默认 120)
|
||
- CHV_READY_INTERVAL:SSH 就绪轮询间隔秒数(默认 5)
|
||
|
||
前置校验(preflight,执行入口必调,缺一即 fail-fast):
|
||
收集全部缺失项一次报出具体缺哪一项,绝不笼统报错:
|
||
cloud-hypervisor 与 ch-remote 二进制存在、/dev/kvm 存在
|
||
且可读写、CPU 有 vmx/svm 标志、内核与磁盘镜像路径存在
|
||
且可读、SSH key 存在且可读。任一项缺失都 raise
|
||
RuntimeError,绝不静默回落 Docker/Local。_validate()
|
||
即 preflight(旧名保留,供旧调用点用)。
|
||
命令构造(_build_vm_command/build_ssh_command/
|
||
build_vm_config)是纯函数,不做文件存在校验,单测可
|
||
直接断言命令与 VmConfig 形状。
|
||
|
||
真实验证边界(写进 PR 描述,不可删):
|
||
未做真实 Cloud Hypervisor 启动验证。当前环境没有可跑真
|
||
VM 的宿主:生产主机 124.222.165.170 本身就是 CVM
|
||
(systemd-detect-virt=kvm,DMI product=CVM),/dev/kvm
|
||
不存在、无 vmx/svm 标志、无 kvm 模块、未装
|
||
cloud-hypervisor,无嵌套虚拟化透传;本 VM 容器内同样
|
||
无 /dev/kvm。v1 验证只能是 mock runner + 干跑命令构造
|
||
+ 单元测试。真 VM 验证需另备带 /dev/kvm 的裸金属或
|
||
开启嵌套虚拟化的实例,那是用户侧开通决策,不在本轨内。
|
||
"""
|
||
|
||
# to10:mypy 属性声明(仅注解、不赋值,运行时仍由
|
||
# _copy_config_fields/_init_runtime 抄写)。没有这组声明,
|
||
# 动态 setattr 会让 mypy 认为全部配置属性不存在(基线
|
||
# 115 个报错里近 90 个属此类),类型检查等于没跑。
|
||
binary: str | None
|
||
kernel: str | None
|
||
rootfs: str | None
|
||
cpus: str
|
||
memory: str
|
||
state_dir: str
|
||
ssh_key: str | None
|
||
ssh_user: str
|
||
ssh_port: int
|
||
guest_host_py: str
|
||
cmdline: str
|
||
net: str | None
|
||
net_json: str | None
|
||
kvm_path: str
|
||
cpuinfo_path: str
|
||
ch_remote: str
|
||
model: str | None
|
||
max_vms: int
|
||
disk_mode: str
|
||
disk_gb: float
|
||
egress_proxy: str | None
|
||
allow_direct_egress: bool
|
||
task_timeout: float | None
|
||
snapshot_url: str | None
|
||
warm_pool_size: int
|
||
api_timeout: float
|
||
ready_timeout: float
|
||
ready_interval: float
|
||
host_key_mode: str
|
||
ssh_host: str | None
|
||
ssh_forward: bool
|
||
reuse_experiment: bool
|
||
single_tenant_project: str | None
|
||
snapshot_max_count: int
|
||
snapshot_max_total_gb: float
|
||
snapshot_retention_days: float
|
||
snapshot_dir: str | None
|
||
allow_remote_snapshot: bool
|
||
runner: "Callable[..., object]"
|
||
launcher: "Callable[..., object]"
|
||
api_client: "Callable[..., object]"
|
||
sleeper: "Callable[..., object]"
|
||
audit_callback: object
|
||
audit_failure_count: int
|
||
_vms: dict
|
||
_guest_env_delivered: dict
|
||
|
||
def __init__(self, binary: str | None = None,
|
||
kernel: str | None = None,
|
||
rootfs: str | None = None,
|
||
image: str | None = None,
|
||
cpus: str | int | None = None,
|
||
memory: str | None = None,
|
||
state_dir: str | None = None,
|
||
runner: Callable[..., object] | None = None,
|
||
launcher: Callable[..., object] | None = None,
|
||
ssh_key: str | None = None,
|
||
ssh_user: str | None = None,
|
||
ssh_port: int | str | None = None,
|
||
guest_host_py: str = "/app/host.py",
|
||
cmdline: str | None = None,
|
||
net: str | None = None, net_json: str | None = None,
|
||
ssh_host: str | None = None,
|
||
kvm_path: str = "/dev/kvm",
|
||
cpuinfo_path: str = "/proc/cpuinfo",
|
||
env: dict | None = None,
|
||
model: str | None = None, ch_remote: str | None = None,
|
||
api_client: Callable[..., object] | None = None,
|
||
sleeper: Callable[..., object] | None = None,
|
||
api_timeout: float | None = None,
|
||
ready_timeout: float | None = None,
|
||
ready_interval: float | None = None,
|
||
host_key_mode: str | None = None) -> None:
|
||
self._env: dict = env if env is not None else os.environ # type: ignore[assignment]
|
||
# 配置解析已拆到 cloud/chv/config.py(参数优先于环境
|
||
# 变量,默认值与 #216 一致;显式空用户/越界端口的收紧
|
||
# 见 config.py 头注释)。字段名与原属性一一对应,旧
|
||
# 测试读属性的方式全部不变。
|
||
_cfg = build_chv_config({
|
||
"binary": binary, "kernel": kernel, "rootfs": rootfs,
|
||
"image": image, "cpus": cpus, "memory": memory,
|
||
"state_dir": state_dir, "ssh_key": ssh_key,
|
||
"ssh_user": ssh_user, "ssh_port": ssh_port,
|
||
"guest_host_py": guest_host_py, "cmdline": cmdline,
|
||
"net": net, "net_json": net_json, "ssh_host": ssh_host,
|
||
"kvm_path": kvm_path,
|
||
"cpuinfo_path": cpuinfo_path, "ch_remote": ch_remote,
|
||
"model": model, "api_timeout": api_timeout,
|
||
"ready_timeout": ready_timeout,
|
||
"ready_interval": ready_interval,
|
||
"host_key_mode": host_key_mode}, self._env)
|
||
self._copy_config_fields(_cfg)
|
||
self._init_runtime(runner, launcher, api_client, sleeper)
|
||
|
||
def _init_runtime(self, runner: Callable[..., object] | None,
|
||
launcher: Callable[..., object] | None,
|
||
api_client: Callable[..., object] | None,
|
||
sleeper: Callable[..., object] | None) -> None:
|
||
# to10:runner/launcher/api_client/sleeper 均为可调用注入对象;
|
||
# 形参保持 object 以兼容旧测试的任意 lambda,调用点用
|
||
# getattr 可调用检查后调用(见 _call_runner 等),mypy
|
||
# 的 object-not-callable 在 _call_runner 处以 Callable 局部
|
||
# 变量处理。
|
||
"""运行时注入:seed 投递集合、API 客户端、睡眠器、
|
||
runner/launcher 默认值(纯移动)。"""
|
||
# feat-chv-secfix(H2):本实例已 seed 投递密钥的 VM
|
||
# 名 → 已投递密钥名集合。_run_guest 只对集合内的密钥
|
||
# 名 source 客内文件,未投递的密钥名一律拒绝(防止
|
||
# “wake 没投递、执行时却按名 source 一个旧文件”)。
|
||
self._guest_env_delivered = {}
|
||
# feat-chv-closeup(P2-20):_guest_env_delivered 是
|
||
# 跨 wake/build_ssh_command/stop 的共享字典,wake 与
|
||
# 执行可并发在不同线程——此前无锁读写,set/get/pop
|
||
# 交错会丢投递记录(密钥被误判未投递而拒跑,或已清
|
||
# 的记录被误读为已投递)。专用锁护住全部访问(与
|
||
# _vms_lock 分开:投递记录与进程登记是两把不同的锁,
|
||
# 合一把会在 stop 路径上不必要地串行);所有访问点
|
||
# 必须走下面三个小方法,禁止直接读写字典(新方法
|
||
# 是唯一入口,回归测试钉死它)。
|
||
self._guest_env_lock = threading.Lock()
|
||
self.api_client = api_client if api_client is not None \
|
||
else _unix_socket_api
|
||
self.sleeper = sleeper if sleeper is not None else time.sleep
|
||
self.audit_callback = None
|
||
self.audit_failure_count = 0
|
||
self._init_hybrid_b_runtime()
|
||
self._vms_lock = threading.Lock()
|
||
if runner is not None:
|
||
self.runner = runner
|
||
else:
|
||
# 方法内 import:sandbox 顶层 re-export 本模块,
|
||
# 顶层 import 会循环;调用时 sandbox 早已加载完成。
|
||
from cloud.sandbox import _default_runner
|
||
self.runner = _default_runner
|
||
if launcher is not None:
|
||
self.launcher = launcher
|
||
else:
|
||
self.launcher = _default_popen_launcher
|
||
self._vms = {}
|
||
|
||
def _init_hybrid_b_runtime(self):
|
||
"""B 轨运行期状态(_init_runtime 的一段,拆出只为
|
||
满足 50 行函数上限,语义不变):排队等待计数
|
||
(_wait_for_capacity 在等待期间 +1,退出时 -1;
|
||
queue_depth() 供 L1 扩缩读队列深度)与租约降级前
|
||
的同步钩子(见 _sync_resident_before_demote;
|
||
None 即默认口径)。"""
|
||
self._queue_waiters = 0
|
||
self._queue_lock = threading.Lock()
|
||
self.resident_sync_hook = None
|
||
|
||
def _delivered_secrets(self, name: str) -> set:
|
||
"""P2-20 唯一读入口:该 VM 已 seed 投递的密钥名集合。"""
|
||
with self._guest_env_lock:
|
||
return set(self._guest_env_delivered.get(name, set()))
|
||
|
||
def _mark_guest_env_delivered(self, name: str, secrets) -> None:
|
||
"""P2-20 唯一写入口:记下该 VM 已投递的密钥名集合
|
||
(secrets 为空即清记录,与旧语义一致)。"""
|
||
with self._guest_env_lock:
|
||
if secrets:
|
||
self._guest_env_delivered[name] = set(secrets)
|
||
else:
|
||
self._guest_env_delivered.pop(name, None)
|
||
|
||
def _clear_guest_env_delivered(self, name: str) -> None:
|
||
"""P2-20 唯一清除入口:stop 时清掉该 VM 的投递记录。"""
|
||
with self._guest_env_lock:
|
||
self._guest_env_delivered.pop(name, None)
|
||
|
||
def _copy_config_fields(self, _cfg) -> None:
|
||
"""把 build_chv_config 的字段抄到 self(纯移动,字段集不变)。"""
|
||
for _field in (
|
||
"binary", "kernel", "rootfs", "cpus", "memory",
|
||
"state_dir", "ssh_key", "ssh_user", "ssh_port",
|
||
"guest_host_py", "cmdline", "net", "net_json",
|
||
"kvm_path", "cpuinfo_path", "ch_remote", "model",
|
||
"max_vms", "disk_mode", "disk_gb", "egress_proxy",
|
||
"allow_direct_egress", "task_timeout", "snapshot_url",
|
||
"warm_pool_size", "api_timeout", "ready_timeout",
|
||
"ready_interval", "host_key_mode", "ssh_host",
|
||
"ssh_forward", "reuse_experiment",
|
||
"single_tenant_project", "snapshot_max_count",
|
||
"snapshot_max_total_gb", "snapshot_retention_days",
|
||
"snapshot_dir", "allow_remote_snapshot",
|
||
"gitea_sync_env", "seed_user_data_extra_file",
|
||
"seed_network_config_file"):
|
||
setattr(self, _field, getattr(_cfg, _field))
|
||
|
||
# -- 名字 / 路径 ---------------------------------------------------
|
||
def _host_name(self, task):
|
||
return re.sub(r"[^A-Za-z0-9_.-]", "-",
|
||
str(task.get("id")))
|
||
|
||
def vm_name(self, task_or_name: dict | str) -> str:
|
||
"""VM 名:chv-<安全字符任务名>(与 Docker 的 codex- 同款
|
||
派生,同一任务每次算出同一名字,清理才能按名找)。
|
||
幂等:传入的已是 chv- 前缀名时不再二次加前缀(清理
|
||
扫描 socket 文件名时传的就是 chv- 名,二次加前缀会
|
||
把 chv-dead 算成 chv-chv-dead、永远清不掉)。"""
|
||
# to10:id 按契约是 str;cast 只给 mypy 用,运行时原样
|
||
# 传给 re.sub(id 缺失时仍按旧行为 TypeError,不静默
|
||
# 编成 "None")。
|
||
from typing import cast as _cast
|
||
raw = _cast(str, task_or_name.get("id")) if isinstance(task_or_name, dict) \
|
||
else str(task_or_name)
|
||
safe = re.sub(r"[^A-Za-z0-9_.-]", "-", raw)
|
||
if safe.startswith("chv-"):
|
||
return safe
|
||
return "chv-" + safe
|
||
|
||
def api_socket(self, task_or_name: dict | str) -> str:
|
||
"""该 VM 的 api-socket 路径:state_dir/<vm 名>.sock。"""
|
||
return os.path.join(self.state_dir,
|
||
self.vm_name(task_or_name) + ".sock")
|
||
|
||
def _tenant_id(self, task) -> str | None:
|
||
"""归属租户 id(feat-chv-opt-landing):任务 dict 的
|
||
tenant_id > project_id > 单租户声明
|
||
CHV_SINGLE_TENANT_PROJECT;三者皆无即 None(无归属
|
||
可标,快照/overlay 按 legacy 处理,不伪造)。"""
|
||
if isinstance(task, dict):
|
||
tenant = task.get("tenant_id") or task.get("project_id")
|
||
if tenant:
|
||
return str(tenant)
|
||
return getattr(self, "single_tenant_project", None)
|
||
|
||
def persistent_vm_name(self, tenant_id: str | None) -> str:
|
||
"""该租户的持久 VM 名:chv-persist-<安全字符租户>
|
||
(阶段 1:同一租户的连续任务共用这一台 VM,任务间
|
||
暂停不断电、不删盘;租户为空即 default 单租户)。"""
|
||
safe = re.sub(r"[^A-Za-z0-9_.-]", "-",
|
||
str(tenant_id or "default"))
|
||
return "chv-persist-" + safe
|
||
|
||
# -- 前置校验 ------------------------------------------------------
|
||
@staticmethod
|
||
def _binary_ok(path):
|
||
if "/" in path:
|
||
return os.path.isfile(path) and os.access(path, os.X_OK)
|
||
return shutil.which(path) is not None
|
||
|
||
def _preflight_config_checks(self, missing: list) -> None:
|
||
"""preflight 的配置校验块(feat-chv-fix,P2-1 小提取:
|
||
仅把 reuse 拦截与 CPU/内存校验从 preflight() 里抽出,
|
||
语义逐行不变;其余 preflight 检查项与 config.py 的
|
||
字段解析深耦合,本轨如实停、不硬拆)。"""
|
||
# P0-3:reuse 生产拦截。一行配置再也不能击穿租户隔离:
|
||
# 只有显式实验二次确认 CHV_REUSE_SINGLE_TENANT_
|
||
# EXPERIMENT=1 时才放行,且放行本身在 wake_vm 里审计;
|
||
# 否则 preflight 直接拒绝(kind=disk_mode_reuse_blocked)。
|
||
if getattr(self, "disk_mode", "copy") == "reuse" \
|
||
and not getattr(self, "reuse_experiment", False):
|
||
missing.append(("disk_mode_reuse_blocked",
|
||
"CHV_DISK_MODE=reuse 未经显式实验确认:"
|
||
"须同时设 CHV_REUSE_SINGLE_TENANT_"
|
||
"EXPERIMENT=1(仅单租户实验)"))
|
||
# feat-chv-secfix(M6):实验放行还须声明单租户项目。
|
||
# 代码无法从环境变量验证数据库里是否真的只有一个
|
||
# 租户——声明 + 审计就是本项的机制,文档明写该边界,
|
||
# 不假装已校验多租户状态。
|
||
if getattr(self, "disk_mode", "copy") == "reuse" \
|
||
and getattr(self, "reuse_experiment", False) \
|
||
and not getattr(self, "single_tenant_project", None):
|
||
missing.append(("reuse_tenant_undeclared",
|
||
"CHV_DISK_MODE=reuse 实验放行须同时声明"
|
||
" CHV_SINGLE_TENANT_PROJECT(单租户"
|
||
"项目 id,仅部署声明 + 审计,不验证"
|
||
"数据库多租户状态)"))
|
||
# P2-6:配置校验时机统一到 preflight。CPU 非整数、内存
|
||
# 字符串非法过去只在 build_vm_config 才炸,现在 preflight
|
||
# 一次报出(kind=config_invalid)。
|
||
try:
|
||
int(self.cpus)
|
||
except (TypeError, ValueError):
|
||
missing.append(("config_invalid",
|
||
"CHV_CPU 必须是整数:%r" % (self.cpus,)))
|
||
try:
|
||
_parse_memory_bytes(self.memory)
|
||
except ValueError:
|
||
missing.append(("config_invalid",
|
||
"CHV_MEM 格式非法:%r" % (self.memory,)))
|
||
self._preflight_numeric_checks(missing)
|
||
def _preflight_numeric_checks(self, missing: list) -> None:
|
||
"""preflight 数值面:端口/配额/超时/快照统一校验,纯移动。
|
||
构造时已校验正常值;这里兜底属性事后被改写的情形。"""
|
||
# feat-chv-arch95(配置统一校验):构造时 build_chv_config
|
||
# 已校验过这些字段的正常值;这里再以 config_invalid
|
||
# 统一兜底属性被事后改写/注入非法值的情形,preflight
|
||
# 是生产路径最后一道统一校验,不再等到 build_vm_config
|
||
# 或 wake 时才在不同位置以不同文案报错。
|
||
try:
|
||
_port = int(getattr(self, "ssh_port", 2222))
|
||
if not 1 <= _port <= 65535:
|
||
raise ValueError("ssh_port")
|
||
except (TypeError, ValueError):
|
||
missing.append(("config_invalid",
|
||
"CHV_SSH_PORT 必须在 1-65535 之间"))
|
||
try:
|
||
_max_vms = int(getattr(self, "max_vms", 4))
|
||
if _max_vms < 0:
|
||
raise ValueError("max_vms")
|
||
except (TypeError, ValueError):
|
||
missing.append(("config_invalid",
|
||
"CHV_MAX_VMS 必须是非负整数"))
|
||
try:
|
||
_disk_gb = float(getattr(self, "disk_gb", 50.0))
|
||
if _disk_gb <= 0:
|
||
raise ValueError("disk_gb")
|
||
except (TypeError, ValueError):
|
||
missing.append(("config_invalid",
|
||
"CHV_DISK_GB 必须是大于 0 的数字"))
|
||
try:
|
||
if float(getattr(self, "api_timeout", 30)) <= 0 \
|
||
or float(getattr(self, "ready_timeout", 120)) <= 0 \
|
||
or float(getattr(self, "ready_interval", 5)) <= 0:
|
||
raise ValueError("timeout")
|
||
except (TypeError, ValueError):
|
||
missing.append(("config_invalid",
|
||
"CHV_*_TIMEOUT/INTERVAL 必须是正数"))
|
||
try:
|
||
if int(getattr(self, "warm_pool_size", 0)) < 0:
|
||
raise ValueError("warm_pool_size")
|
||
except (TypeError, ValueError):
|
||
missing.append(("config_invalid",
|
||
"CHV_WARM_POOL_SIZE 必须是非负整数"))
|
||
self._preflight_snapshot_checks(missing)
|
||
def _preflight_snapshot_checks(self, missing: list) -> None:
|
||
"""preflight 快照/host-key 面:统一校验,纯移动。"""
|
||
try:
|
||
if int(getattr(self, "snapshot_max_count", 10)) < 0 \
|
||
or float(getattr(self, "snapshot_max_total_gb", 20.0)) < 0 \
|
||
or float(getattr(self, "snapshot_retention_days", 30.0)) < 0:
|
||
raise ValueError("snapshot")
|
||
except (TypeError, ValueError):
|
||
missing.append(("config_invalid",
|
||
"CHV_SNAPSHOT_* 不能为负数"))
|
||
if getattr(self, "host_key_mode", "provisioned") \
|
||
not in ("provisioned", "keyscan"):
|
||
missing.append(("config_invalid",
|
||
"CHV_HOST_KEY_MODE 只能是 provisioned 或 keyscan"))
|
||
|
||
|
||
|
||
def _preflight_host_checks(self, missing: list) -> None:
|
||
"""preflight 主机面:二进制/KVM/CPU 标志,纯移动。"""
|
||
if not self._binary_ok(self.binary):
|
||
missing.append(("binary_missing",
|
||
"cloud-hypervisor 二进制缺失或不可执行:%s"
|
||
% self.binary))
|
||
if not self._binary_ok(self.ch_remote):
|
||
missing.append(("binary_missing",
|
||
"ch-remote 二进制缺失或不可执行:%s"
|
||
% self.ch_remote))
|
||
if not os.path.exists(self.kvm_path):
|
||
missing.append(("kvm_missing",
|
||
"/dev/kvm 不存在:%s,宿主无 KVM"
|
||
% self.kvm_path))
|
||
else:
|
||
if not os.access(self.kvm_path, os.R_OK):
|
||
missing.append(("kvm_missing",
|
||
"/dev/kvm 不可读:%s" % self.kvm_path))
|
||
if not os.access(self.kvm_path, os.W_OK):
|
||
missing.append(("kvm_missing",
|
||
"/dev/kvm 不可写:%s" % self.kvm_path))
|
||
cpuinfo_ok = False
|
||
try:
|
||
with open(self.cpuinfo_path, "r") as fh:
|
||
for line in fh:
|
||
if line.startswith("flags"):
|
||
flags = line.split(":", 1)[1].split()
|
||
if "vmx" in flags or "svm" in flags:
|
||
cpuinfo_ok = True
|
||
break
|
||
except OSError:
|
||
missing.append(("cpu_flag_missing",
|
||
"CPU 信息文件缺失:%s" % self.cpuinfo_path))
|
||
else:
|
||
if not cpuinfo_ok:
|
||
missing.append(("cpu_flag_missing",
|
||
"CPU 无虚拟化标志(无 vmx/svm):%s"
|
||
% self.cpuinfo_path))
|
||
|
||
def _preflight_file_checks(self, missing: list) -> None:
|
||
"""preflight 文件面:内核/镜像/SSH key,纯移动。"""
|
||
if not self.kernel:
|
||
missing.append(("kernel_missing", "CHV_KERNEL 未设"))
|
||
elif not os.path.isfile(self.kernel):
|
||
missing.append(("kernel_missing",
|
||
"内核文件不存在:%s" % self.kernel))
|
||
elif not os.access(self.kernel, os.R_OK):
|
||
missing.append(("kernel_missing",
|
||
"内核文件不可读:%s" % self.kernel))
|
||
if not self.rootfs:
|
||
missing.append(("image_missing", "CHV_ROOTFS 未设"))
|
||
elif not os.path.isfile(self.rootfs):
|
||
missing.append(("image_missing",
|
||
"磁盘镜像文件不存在:%s" % self.rootfs))
|
||
elif not os.access(self.rootfs, os.R_OK):
|
||
missing.append(("image_missing",
|
||
"磁盘镜像文件不可读:%s" % self.rootfs))
|
||
if not self.ssh_key:
|
||
missing.append(("ssh_key_missing", "CHV_SSH_KEY 未设"))
|
||
elif not os.path.isfile(self.ssh_key):
|
||
missing.append(("ssh_key_missing",
|
||
"SSH key 文件不存在:%s" % self.ssh_key))
|
||
elif not os.access(self.ssh_key, os.R_OK):
|
||
missing.append(("ssh_key_missing",
|
||
"SSH key 文件不可读:%s" % self.ssh_key))
|
||
|
||
def _preflight_net_checks(self, missing: list) -> None:
|
||
"""preflight 网络/目录面:egress/安全目录/net,纯移动。"""
|
||
if not self.egress_proxy and not self.allow_direct_egress:
|
||
missing.append(("egress_not_configured",
|
||
"CHV_EGRESS_PROXY 未设且未显式允许直连:"
|
||
"VM 出口必须过白名单代理,默认不给直连外网"))
|
||
if self.egress_proxy:
|
||
try:
|
||
validate_egress_proxy(self.egress_proxy)
|
||
except ChvFailure as exc:
|
||
missing.append((exc.kind, str(exc)))
|
||
# feat-chv-secfix(H6):state_dir 安全目录门。
|
||
# 默认值已出 /tmp(见 config.py);这里再校验一次:
|
||
# 目录须存在且 0700,否则 preflight 直接失败。
|
||
try:
|
||
ensure_secure_state_dir(self.state_dir)
|
||
except ChvFailure as exc:
|
||
missing.append((exc.kind, str(exc)))
|
||
# 补缺口轨 (4):tap/网络配置校验前置到 preflight。
|
||
# CHV_NET_JSON 非法 JSON 或非 list/dict 时直接报
|
||
# net_config_invalid,不等到 vm.create 才炸;直连
|
||
# 只在显式 CHV_ALLOW_DIRECT_EGRESS=1 时放行,且
|
||
# wake_vm 里发 egress_direct_allowed 审计。tap 桥接
|
||
# 本身是否真只走代理仍需宿主实测,代码不声称已验证。
|
||
# feat-chv-secfix(L4):再加字段白名单与条目内重复
|
||
# 校验;CHV_NET 与 CHV_NET_JSON 双设时只发警告审计
|
||
# (net_config_dual),不硬拒——CHV_NET 是手动直接
|
||
# 启动命令的透传值,双设不一定是误配,硬拒会断掉
|
||
# 手动运维路径;误配的代价是“API 模式忽略 CHV_NET”,
|
||
# 警告 + 文档已写清。
|
||
if self.net and self.net_json:
|
||
self._emit("net_config_dual", None,
|
||
"CHV_NET 与 CHV_NET_JSON 双设:API 模式只用"
|
||
" CHV_NET_JSON,CHV_NET 仅手动直接启动命令用")
|
||
if self.net_json:
|
||
try:
|
||
_entries = parse_net_entries(self.net_json)
|
||
validate_net_entries(_entries, [])
|
||
except ChvFailure as exc:
|
||
missing.append((exc.kind, str(exc)))
|
||
def preflight(self) -> bool:
|
||
"""预检(缺一不可则 fail-fast):收集全部缺失项一次报出
|
||
具体缺哪一项,绝不笼统报错;失败为 ChvFailure(带 kind,
|
||
供审计与失败分类用),绝不回落 Docker/Local。
|
||
检查项与 kind:
|
||
- binary_missing:cloud-hypervisor 二进制不存在/不可执行
|
||
- binary_missing:ch-remote 二进制不存在/不可执行
|
||
- kvm_missing:/dev/kvm 不存在
|
||
- kvm_missing:/dev/kvm 不可读/不可写(分开报)
|
||
- cpu_flag_missing:/proc/cpuinfo 无 vmx/svm 标志
|
||
- kernel_missing:CHV_KERNEL 未设/文件不存在/不可读
|
||
- image_missing:CHV_ROOTFS 未设/文件不存在/不可读
|
||
- ssh_key_missing:CHV_SSH_KEY 未设/文件不存在/不可读
|
||
- egress_not_configured:CHV_EGRESS_PROXY 未设且未显式
|
||
允许直连(默认 fail-closed,VM 出口必须过白名单代理)
|
||
"""
|
||
missing: list = [] # (kind, 文本) 列表,全部收集完再一次抛
|
||
self._preflight_host_checks(missing)
|
||
self._preflight_file_checks(missing)
|
||
self._preflight_net_checks(missing)
|
||
self._preflight_config_checks(missing)
|
||
if missing:
|
||
kind = missing[0][0]
|
||
detail = "; ".join(text for _, text in missing)
|
||
# feat-chv-secfix(M5/L3):preflight 失败本身也
|
||
# 审计(此前 try 块之前失败一律无审计事件)。
|
||
self._emit("preflight_failed", None,
|
||
"preflight 失败:%s" % detail, kind)
|
||
raise ChvFailure(
|
||
kind, "cloud-hypervisor preflight 失败:%s" % detail)
|
||
return True
|
||
|
||
def _validate(self):
|
||
"""旧名保留:即 preflight。"""
|
||
return self.preflight()
|
||
|
||
# -- 审计 ----------------------------------------------------------
|
||
def _emit(self, event, task, detail="", kind=None):
|
||
"""VM 生命周期审计事件:有 audit_callback 时调它
|
||
(worker 接线后转 AuthStore 审计,即任务 timeline 的
|
||
审计来源),无 callback 时只记日志。
|
||
feat-chv-secfix(M5):审计回调失败不再双重静默
|
||
pass——记宿主 error 日志(审计完整性本身没有保证,
|
||
但失败至少可见;任务仍不因此炸,审计失败不改变
|
||
任务状态是 worker 层的既定语义)。feat-chv-arch95plus
|
||
取舍(见 ADR-9):不改成“审计失败即中断任务”——
|
||
审计是可观测性不是门禁,DB 短暂不可用时硬中断会把
|
||
一次审计失败放大成任务失败,且与 worker 全局
|
||
best-effort 语义冲突;改为“失败记日志 + 计数 + 不
|
||
中断”,计数见 self.audit_failure_count(宿主可观测,
|
||
非任务状态)。审计 detail 里绝不含密钥值(调用方
|
||
负责:本类所有 _emit 的 detail 只含 VM 名、路径、
|
||
URL、kind,不含任何 key 值)。"""
|
||
try:
|
||
if self.audit_callback is not None:
|
||
self.audit_callback(event, task, detail, kind)
|
||
else:
|
||
_LOGGER.info("chv_audit event=%s kind=%s %s",
|
||
event, kind, detail)
|
||
except Exception as exc:
|
||
self.audit_failure_count = getattr(
|
||
self, "audit_failure_count", 0) + 1
|
||
_LOGGER.error("chv_audit_failed event=%s kind=%s error=%s",
|
||
event, kind, exc)
|
||
# to10(ADR-14):回调失败的事件再落盘一份——
|
||
# 宿主日志可能轮转、计数只剩数字,落盘文件是
|
||
# 唯一能事后找回“哪次事件丢了”的取证。落盘
|
||
# 本身 best-effort,失败只记日志,绝不炸任务。
|
||
self._spill_audit(event, task, detail, kind)
|
||
|
||
def _spill_audit(self, event, task, detail, kind) -> None:
|
||
"""审计失败事件落盘(to10,见 ADR-14 取舍):
|
||
state_dir/chv-audit-spill.jsonl,每行一 JSON
|
||
{ts, event, kind, detail, task_id};文件超 1MB 时
|
||
先轮转成 .old 再写。detail 沿用 _emit 契约,绝不
|
||
含密钥值。落盘不是 DB 替代:timeline 查询看不到它,
|
||
需宿主人工查阅;轮转只留最新两代,旧事件会丢。"""
|
||
try:
|
||
path = os.path.join(self.state_dir,
|
||
"chv-audit-spill.jsonl")
|
||
if os.path.exists(path) \
|
||
and os.path.getsize(path) > 1024 * 1024:
|
||
os.replace(path, path + ".old")
|
||
task_id = task.get("id") if isinstance(task, dict) \
|
||
else str(task)
|
||
line = json.dumps({"ts": time.time(), "event": event,
|
||
"kind": kind, "detail": detail,
|
||
"task_id": task_id})
|
||
with open(path, "a") as fh:
|
||
fh.write(line + "\n")
|
||
# feat-chv-closeup(P2-18):spill 是审计丢事
|
||
# 件后的唯一取证,写完即 fsync,否则宿主崩溃
|
||
# 后 spill 本身也可能丢。失败只记日志,不炸任务。
|
||
fh.flush()
|
||
os.fsync(fh.fileno())
|
||
except Exception as exc:
|
||
_LOGGER.error("chv_audit_spill_failed error=%s", exc)
|
||
|
||
# -- 命令构造(纯函数,不做文件校验) -------------------------------
|
||
def _build_vm_command(self, task):
|
||
"""构造 cloud-hypervisor 启动命令(干跑可测,仅私有)。
|
||
|
||
feat-chv-fix(P2-2):原公开 build_vm_command() 降级为
|
||
私有——执行器唯一启动路径是 API 模式(wake_vm),公开
|
||
的第二启动路径容易被后来人误当成可走的启动入口;手动
|
||
等价写法保留在本方法与类 docstring 里,运维手工起 VM
|
||
时仍可按此形状拼命令,不再经公开方法调用。
|
||
|
||
顺序固定:<二进制> --api-socket <socket> --cpus boot=N
|
||
--memory size=M --kernel <内核> --cmdline <命令行>
|
||
--disk path=<镜像> [--net <运维透传值>]。
|
||
--net 不给时不带:tap/桥接是运维侧配置,本类不编造
|
||
一个不存在的 tap 名。
|
||
"""
|
||
cmd = [self.binary,
|
||
"--api-socket", self.api_socket(task),
|
||
"--cpus", "boot=%s" % self.cpus,
|
||
"--memory", "size=%s" % self.memory,
|
||
"--kernel", self.kernel,
|
||
"--cmdline", self.cmdline,
|
||
"--disk", "path=%s" % self.rootfs]
|
||
if self.net:
|
||
cmd += ["--net", self.net]
|
||
return cmd
|
||
|
||
def known_hosts_path(self, task_or_name: dict | str) -> str:
|
||
"""该 VM 的 known_hosts 文件:state_dir/<vm>/known_hosts
|
||
(与每任务磁盘同目录,见 ssh_transport.py 的钉死模型)。"""
|
||
return _known_hosts_path(self.state_dir,
|
||
self.vm_name(task_or_name))
|
||
|
||
def _net_guest_ip(self) -> str | None:
|
||
"""VmConfig net 首个带 ip 的条目(静态 tap 客机 IP)。"""
|
||
if not self.net_json:
|
||
return None
|
||
try:
|
||
net = json.loads(self.net_json)
|
||
except ValueError:
|
||
return None
|
||
entries = net if isinstance(net, list) else [net]
|
||
for entry in entries:
|
||
if isinstance(entry, dict) and entry.get("ip"):
|
||
return str(entry["ip"])
|
||
return None
|
||
|
||
def resolve_ssh_host(self) -> str:
|
||
"""SSH 目标解析(feat-chv-fix,P0-4 选定方案:客机 IP):
|
||
|
||
显式 CHV_SSH_HOST > net 静态客机 IP > 显式转发宣称下
|
||
的回环;三者皆无时报 ChvFailure(ssh_target_undeclared),
|
||
绝不无条件假设 127.0.0.1:2222 能通。"""
|
||
_ssh_host = self.ssh_host
|
||
if _ssh_host:
|
||
return _ssh_host
|
||
guest_ip = self._net_guest_ip()
|
||
if guest_ip:
|
||
return guest_ip
|
||
if getattr(self, "ssh_forward", False):
|
||
return "127.0.0.1"
|
||
raise ChvFailure(
|
||
"ssh_target_undeclared",
|
||
"SSH 目标未声明:须设 CHV_SSH_HOST、或在 CHV_NET_JSON "
|
||
"里给 net 条目配 ip(静态 tap 客机 IP)、或显式设 "
|
||
"CHV_SSH_FORWARD=1 宣称宿主 NAT 已转发回环端口")
|
||
|
||
def _read_keypair_texts(self, paths: dict) -> tuple:
|
||
"""读 key 对文本(to10 提取,provision_host_key 降行用)。"""
|
||
with open(paths["public"], "r") as fh:
|
||
public_text = fh.read()
|
||
with open(paths["private"], "r") as fh:
|
||
private_text = fh.read()
|
||
return public_text, private_text
|
||
|
||
def _write_provisioned_known_hosts(self, paths: dict,
|
||
public_text: str) -> None:
|
||
"""写预置 known_hosts(to10 提取)。主机部分跟 SSH
|
||
目标走(客机 IP 时不再写死回环,否则钉死校验永远
|
||
对不上);解析失败时回环仅作钉死写入,不放宽校验。"""
|
||
from cloud.chv.hostkeys import known_hosts_line
|
||
from cloud.chv.ssh_transport import write_known_hosts
|
||
try:
|
||
ssh_host = self.resolve_ssh_host()
|
||
except ChvFailure:
|
||
ssh_host = "127.0.0.1"
|
||
write_known_hosts(
|
||
paths["known_hosts"],
|
||
[known_hosts_line(self.ssh_port, public_text,
|
||
host=ssh_host)])
|
||
|
||
def provision_host_key(self, task: dict, secret_env: dict | None = None,
|
||
guest_env: dict | None = None,
|
||
user_data_extra: str | None = None,
|
||
network_config: str | None = None) -> dict:
|
||
"""按 VM 预置 host key(补缺口轨强方案,见 hostkeys.py):
|
||
启动前为该 VM 生成一次性 ed25519 host key 对,公钥
|
||
立即写进该 VM 的 known_hosts(钉死),私钥只写进
|
||
per-VM seed 目录的 cloud-init user-data,随 VM 销毁
|
||
而删。返回 {paths, seed_dir, known_hosts}。keygen
|
||
失败即 raise,provisioned 模式下绝不回落 keyscan;
|
||
不同 VM 的 key 对不同(state_dir/<vm> 隔离),同一
|
||
VM 重启复用已有对(否则钉死立即失效)。
|
||
feat-chv-secfix(H2/M2):secret_env 给定时写进
|
||
seed 的客内密钥文件(0600);guest_env 给定时与
|
||
secret_env 合并写同一客内文件(boot 时要用的非密钥
|
||
同步变量,见 gitea_sync);上次清理失败留下的
|
||
脏标记存在时直接拒绝(清理失败即拒起,不再复用
|
||
旧 key 对)。成功后审计 host_key_generated(只含
|
||
VM 名与密钥名,不含任何 key 值)。"""
|
||
# to10:known_hosts 写入已抽到 _write_provisioned_known_hosts,
|
||
# 此处只留生成与 seed 写入的 import(删后不再有未用 import)。
|
||
from cloud.chv.hostkeys import (
|
||
dirty_marker_path,
|
||
generate_host_keypair,
|
||
write_seed,
|
||
)
|
||
vm = self.vm_name(task)
|
||
if os.path.exists(dirty_marker_path(self.state_dir, vm)):
|
||
raise ChvFailure(
|
||
"vm_dirty_refused",
|
||
"VM %s 上次清理失败(脏标记存在),拒绝复用旧 "
|
||
"host key 启动:请人工清理 state_dir 后删脏标记"
|
||
% vm)
|
||
paths = generate_host_keypair(
|
||
self.state_dir, vm,
|
||
runner=lambda cmd: self._call_runner(cmd))
|
||
public_text, private_text = self._read_keypair_texts(paths)
|
||
self._write_provisioned_known_hosts(paths, public_text)
|
||
seed_dir = write_seed(paths["seed_dir"], vm,
|
||
private_text, public_text,
|
||
secret_env=secret_env,
|
||
guest_env=guest_env,
|
||
user_data_extra=user_data_extra,
|
||
network_config=network_config,
|
||
ssh_user=getattr(self, "ssh_user", None))
|
||
self._emit("host_key_generated", task,
|
||
"vm=%s secret_names=%s"
|
||
% (vm, ",".join(sorted(secret_env or {}))))
|
||
return {"paths": paths, "seed_dir": seed_dir,
|
||
"known_hosts": paths["known_hosts"]}
|
||
|
||
def capture_host_key(self, task: dict) -> bool:
|
||
"""首次捕获客机 host key(TOFU,弱回落,边界见
|
||
ssh_transport.py 与 hostkeys.py):ssh-keyscan 经本机
|
||
回环端口抓 key 行,写入该 VM 的 known_hosts 文件
|
||
(0600)。生产部署必须用 provisioned 预置模式,
|
||
本方法只在 CHV_HOST_KEY_MODE=keyscan 时作回落,
|
||
它只能防首次之后的替换,不能验证首次本身。
|
||
抓不到可用 key 行时返回 False(不写文件、不报错),
|
||
由 wait_guest_ready 下一轮再试;sshd 还没起来时
|
||
这就是常态,不是失败。"""
|
||
try:
|
||
ssh_host = self.resolve_ssh_host()
|
||
except ChvFailure:
|
||
return False
|
||
try:
|
||
result = self._call_runner(
|
||
["ssh-keyscan", "-p", str(self.ssh_port),
|
||
ssh_host], timeout=10)
|
||
stdout = getattr(result, "stdout", "") or ""
|
||
except Exception:
|
||
return False
|
||
key_lines = parse_keyscan_output(stdout, host=ssh_host)
|
||
if not key_lines:
|
||
return False
|
||
try:
|
||
write_known_hosts(self.known_hosts_path(task), key_lines)
|
||
except OSError:
|
||
return False
|
||
# feat-chv-secfix(M5):捕获成功独立审计(只含 VM
|
||
# 名与主机,不含 key 行本身)。
|
||
self._emit("host_key_captured", task,
|
||
"vm=%s host=%s" % (self.vm_name(task), ssh_host))
|
||
return True
|
||
|
||
def build_ssh_command(self, task: dict, inner_cmd: list, extra_env: dict | None = None) -> list:
|
||
"""构造 SSH 命令:ssh -p <端口> -i <key 文件> + 钉死选项
|
||
+ user@<SSH 目标> + 客内命令。argv 里只有 key 文件路径,
|
||
绝无任何 key 值。egress_proxy 已设时客内命令前加 env
|
||
前缀(HTTPS_PROXY/HTTP_PROXY/NO_PROXY,代理地址本身
|
||
不是密钥;客机出口只走该白名单代理,见类 docstring)。
|
||
Host key 选项是钉死式(StrictHostKeyChecking=yes +
|
||
每 VM known_hosts 文件),#216 的 no + /dev/null 已
|
||
在本轨废除,见类 docstring 与 ssh_transport.py。
|
||
feat-chv-secfix(H2):extra_env 先拆密钥子集与非
|
||
密钥子集。密钥子集须已在本实例的 wake 里 seed 投递
|
||
(_guest_env_delivered 有记录),否则直接 ChvFailure
|
||
(secret_env_not_delivered),绝不回落 argv;已投递
|
||
时远程命令 source 客内密钥文件。keyscan 模式下
|
||
wake 根本不建 seed,密钥子集到这里必然未投递——
|
||
即 CHV + 网关密钥现在要求 provisioned 模式(已是
|
||
默认),keyscan 模式下的网关任务 fail-closed,不再
|
||
把密钥值拼进 argv 将就跑。"""
|
||
try:
|
||
ssh_host = self.resolve_ssh_host()
|
||
except ChvFailure:
|
||
# 纯命令构造(旧测试/干跑)不炸:回环缺省仅作
|
||
# 构造兼容,wake/wait 路径仍严格解析。
|
||
ssh_host = "127.0.0.1"
|
||
secrets, plain = split_secret_env(extra_env)
|
||
guest_env_file = None
|
||
if secrets:
|
||
from cloud.chv.hostkeys import GUEST_ENV_PATH
|
||
delivered = self._delivered_secrets(self.vm_name(task))
|
||
missing: list = [name for name in secrets if name not in delivered]
|
||
if missing:
|
||
raise ChvFailure(
|
||
"secret_env_not_delivered",
|
||
"密钥环境变量 %s 未经 seed 投递,拒绝进 ssh "
|
||
"argv:provisioned 模式下 wake 时投递,"
|
||
"keyscan 模式不支持密钥投递"
|
||
% ",".join(sorted(missing)))
|
||
guest_env_file = GUEST_ENV_PATH
|
||
return _build_ssh_cmd(
|
||
self.ssh_port, self.ssh_key, self.ssh_user,
|
||
self.egress_proxy, self.known_hosts_path(task),
|
||
inner_cmd, ssh_host=ssh_host, extra_env=plain or None,
|
||
guest_env_file=guest_env_file)
|
||
|
||
# -- API 模式:VmConfig / API 调用 / 就绪轮询 -----------------------
|
||
def _vm_config_disks(self, task_disk: str | None,
|
||
seed_disk: str | None,
|
||
data_disk: str | None = None) -> list:
|
||
"""VmConfig 磁盘列表(to10 提取,含 L2 白名单校验)。
|
||
任务盘 + 可选 seed 只读盘(CIDATA ISO,cloud-init
|
||
NoCloud 数据源)+ 可选数据盘(overlay 模式第二块盘,
|
||
工作区/浏览器 profile 载体,客内按 LABEL=CHVDATA 挂
|
||
载到 /home/chv/workspace);显式给的盘若不是基础
|
||
镜像本身,须在 state_dir 内,否则 VmConfig 不能挂
|
||
进宿主任意文件。
|
||
image_type 必须显式给:CHV 54 的磁盘类型靠文件
|
||
魔数探测,raw 盘无魔数、探测失败即 vm.boot 400
|
||
"Image type required for disk"(2026-11-07 WSL 真机
|
||
CHV 54.0.0 实测);值须是枚举原名 Raw(CLI 的小写
|
||
raw 是另一层映射,JSON 里写 raw 会报 unknown variant);
|
||
CLI 各轮一直传 image_type=raw(seed ISO 亦然,客内
|
||
仍以 /dev/vdb 读到 CIDATA)。
|
||
feat-chv-opt-landing:.qcow2 后缀的盘(overlay 与
|
||
数据盘)image_type 为 Qcow2(qcow2 有魔数可探测,
|
||
但与 raw 同款必须显式给枚举原名,不依赖探测)。"""
|
||
# 旧文案逐字保留:任务盘“任务磁盘须在…”,seed 盘
|
||
# “seed 磁盘须在…”(label 本身含“磁盘”与空格)。
|
||
for path, label in ((task_disk, "任务磁盘"),
|
||
(seed_disk, "seed 磁盘"),
|
||
(data_disk, "数据磁盘")):
|
||
if path and path != self.rootfs:
|
||
if not os.path.realpath(path).startswith(
|
||
os.path.realpath(self.state_dir) + os.sep):
|
||
raise ChvFailure(
|
||
"disk_path_not_allowed",
|
||
"%s须在 state_dir 内:%s" % (label, path))
|
||
disks: list = [{"path": task_disk or self.rootfs,
|
||
"image_type": _disk_image_type(
|
||
task_disk or self.rootfs)}]
|
||
if seed_disk:
|
||
disks.append({"path": seed_disk, "readonly": True,
|
||
"image_type": "Raw"})
|
||
if data_disk:
|
||
disks.append({"path": data_disk,
|
||
"image_type": _disk_image_type(data_disk)})
|
||
return disks
|
||
|
||
def build_vm_config(self, task_disk: str | None = None, seed_disk: str | None = None,
|
||
data_disk: str | None = None) -> dict:
|
||
"""API 模式的 VmConfig(vm.create 请求体,纯函数):
|
||
cpus{boot_vcpus,max_vcpus}、memory{size: 字节}、
|
||
payload{kernel, cmdline}、disks[{path: 每任务磁盘}]、
|
||
rng{/dev/urandom},以及 CHV_NET_JSON 给出的 net 列表
|
||
(不给则不带 net——客机无网卡时 SSH 就绪会超时失败,
|
||
这是 fail-fast 前置条件,见类 docstring)。
|
||
task_disk 不给时用基础镜像路径(仅 lab/reuse 模式)。
|
||
payload 形态(CHV 54 API):内核与命令行须放在
|
||
payload 对象里(kernel/cmdline 均为直接字符串),
|
||
旧版顶层 kernel{path}/cmdline{args} 会被服务端忽略,
|
||
vm.create 照常 204、vm.boot 报 400 "No bootitem
|
||
provided: neither firmware nor kernel"(2026-11-07
|
||
WSL 真机 CHV 54.0.0 实测)。"""
|
||
try:
|
||
cpus = int(self.cpus)
|
||
except ValueError:
|
||
raise ValueError("CHV_CPU 必须是整数:%r" % (self.cpus,))
|
||
payload: dict = {}
|
||
if self.kernel:
|
||
payload["kernel"] = self.kernel
|
||
if self.cmdline:
|
||
payload["cmdline"] = self.cmdline
|
||
config = {
|
||
"cpus": {"boot_vcpus": cpus, "max_vcpus": cpus},
|
||
"memory": {"size": _parse_memory_bytes(self.memory)},
|
||
"payload": payload,
|
||
"disks": self._vm_config_disks(task_disk, seed_disk,
|
||
data_disk),
|
||
"rng": {"src": "/dev/urandom"},
|
||
}
|
||
if self.net_json:
|
||
try:
|
||
net = json.loads(self.net_json)
|
||
except ValueError:
|
||
raise ValueError("CHV_NET_JSON 不是合法 JSON")
|
||
config["net"] = net if isinstance(net, list) else [net]
|
||
return config
|
||
|
||
def _api_call(self, socket_path, method, path, payload=None):
|
||
"""API 调用归一化(实现在 cloud/chv/api_client.py)。"""
|
||
return normalize_api_result(
|
||
self.api_client(socket_path, method, path, payload))
|
||
|
||
def _call_runner(self, cmd, timeout=None):
|
||
"""runner 调用(共享 helper 在 cloud/sandbox.py):
|
||
timeout 给定时优先传 timeout,假 runner 只接受 cmd 时
|
||
TypeError 回退到只传 cmd(超时护栏丢了,但绝不因此静默
|
||
换执行器;worker 层 task_timeout 仍是外层硬护栏)。"""
|
||
from cloud.sandbox import _call_runner_with_timeout
|
||
return _call_runner_with_timeout(self.runner, cmd, timeout)
|
||
|
||
def _drop_registered_proc(self, name):
|
||
"""取走并杀掉本实例登记的旧 VMM 进程(wake 与 restore
|
||
的共用准备步):返回旧进程或 None。"""
|
||
old = None
|
||
with self._vms_lock:
|
||
if name in self._vms:
|
||
old = self._vms.pop(name, None)
|
||
if old is not None:
|
||
self._kill_proc(old)
|
||
return old
|
||
|
||
def _kill_vmm_by_socket(self, socket_path):
|
||
"""按 api-socket 杀未登记的 VMM 进程(F2 跨实例 stop
|
||
与 V-3 restore 的共用机制):扫描 /proc 找命令行里
|
||
以 --api-socket 带该 socket 路径、且 argv[0] 是本执行
|
||
器 VMM 二进制的进程,SIGTERM→等→SIGKILL。
|
||
|
||
绝不误杀的三道门(见 _find_vmm_pids_by_socket):
|
||
精确 socket 路径、VMM 二进制名、跳过自身与 pid 1。
|
||
扫描/杀失败一律 best-effort 不抛异常——stop_vm
|
||
“尽力而为、不抛异常”的契约不变。返回杀掉的 pid 列表
|
||
(调用方审计用,测试断言用)。"""
|
||
try:
|
||
own_binary = os.path.basename(self.binary or "")
|
||
except Exception:
|
||
return []
|
||
killed = []
|
||
for pid in _find_vmm_pids_by_socket(socket_path, own_binary):
|
||
_terminate_pid(pid)
|
||
killed.append(pid)
|
||
return killed
|
||
|
||
def _kill_proc(self, proc):
|
||
"""终止登记进程:terminate → 等 5 秒 → kill,全部
|
||
best-effort,绝不抛异常。"""
|
||
if proc is None:
|
||
return
|
||
try:
|
||
proc.terminate()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
proc.wait(timeout=5)
|
||
except Exception:
|
||
try:
|
||
proc.kill()
|
||
except Exception:
|
||
pass
|
||
|
||
def _active_count(self):
|
||
with self._vms_lock:
|
||
return len(self._vms)
|
||
|
||
def _persistent_vm_count(self, exclude=None):
|
||
"""跨重启容量计数(feat-chv-fix,P1-4 + feat-chv-secfix
|
||
M7):本实例登记的活进程名 ∪ state_dir 下 chv-*.sock
|
||
对应的 VM 名。worker 重启后 _vms 为空,但旧 VMM 的
|
||
socket 仍在,仍计入容量——重启不再绕过 CHV_MAX_VMS。
|
||
M7 修正:残留 socket 不再无条件计入——只有 socket
|
||
真能连通(有监听者)才计数;空文件/死 socket 不计,
|
||
否则 CHV_MAX_VMS 会被崩溃残留占满、新任务全部被
|
||
拒(可用性 DoS)。exclude:调用方自己的 VM 名(同
|
||
任务重复 wake 时先关旧 VM,不应被自己的残留 socket
|
||
误拒)。"""
|
||
names = set()
|
||
with self._vms_lock:
|
||
for name, proc in self._vms.items():
|
||
try:
|
||
alive = proc.poll() is None
|
||
except Exception:
|
||
alive = False
|
||
if alive:
|
||
names.add(name)
|
||
try:
|
||
entries = os.listdir(self.state_dir)
|
||
except Exception:
|
||
entries = []
|
||
for entry in entries:
|
||
if entry.endswith(".sock") and entry[:-5].startswith("chv-"):
|
||
sock_path = os.path.join(self.state_dir, entry)
|
||
if socket_is_live(sock_path):
|
||
names.add(entry[:-5])
|
||
if exclude:
|
||
names.discard(exclude)
|
||
return len(names)
|
||
|
||
def _check_capacity(self, exclude=None):
|
||
"""宿主容量门:持久化计数(见 _persistent_vm_count)
|
||
达到 max_vms 即明确拒绝(kind=host_capacity_exceeded,
|
||
调用方应排队/拒绝,绝不降级 Docker/Local)。"""
|
||
count = self._persistent_vm_count(exclude=exclude)
|
||
if count >= self.max_vms:
|
||
raise ChvFailure(
|
||
"host_capacity_exceeded",
|
||
"宿主 CHV 容量已满:已计数 %d 台,上限 %d,"
|
||
"请排队或拒绝本任务" % (count, self.max_vms))
|
||
|
||
def _check_disk_quota(self):
|
||
"""磁盘配额门(实现在 cloud/chv/disk.py,feat-chv-fix
|
||
口径:总量 + 本次 + 宿主余量,镜像不可读不静默跳过)。
|
||
feat-chv-opt-landing:overlay 模式本次增量为 0(只
|
||
建空 overlay,不拷 base),配额仍按 base 大小硬帽。"""
|
||
increment = 0 if getattr(self, "disk_mode", "copy") \
|
||
== "overlay" else None
|
||
check_disk_quota(self.rootfs, self.disk_gb,
|
||
state_dir=self.state_dir,
|
||
increment_bytes=increment)
|
||
|
||
def _provision_task_disk(self, task):
|
||
"""每任务磁盘供给(实现在 cloud/chv/disk.py):
|
||
- copy(默认):把基础镜像复制到
|
||
state_dir/<vm>/disk.raw,VmConfig 用该路径;复制
|
||
慢但两个任务绝不写同一文件,测试用小文件可直接验证。
|
||
- reuse(仅单租户实验):直接返回基础镜像路径,磁盘
|
||
不独立;preflight 拦截(须显式实验二次确认并审计
|
||
放行,见 preflight,feat-chv-fix)。
|
||
- overlay(feat-chv-opt-landing,阶段 1):在
|
||
state_dir/<vm>/disk.qcow2 建空 overlay,backing
|
||
file 指向只读 base。
|
||
数据盘与 tenant 标记与 overlay 解耦(2026-11-07 真机
|
||
实测:CHV v54 的 vm.boot 对任何带 backing file 的
|
||
qcow2 都报 Maximum disk nesting depth exceeded,
|
||
overlay 在本 VMM 上不可用——见阶段 1 报告 §overlay;
|
||
但数据盘(工作区/浏览器 profile 载体,不进快照)与
|
||
tenant.json(持久 VM 归属)是独立持久/归属机制,
|
||
不依赖 backing file):copy 与 overlay 都供给独立
|
||
数据盘 state_dir/<vm>/data.raw 并写 tenant 归属
|
||
标记 tenant.json;只有 reuse 不写(盘就是共享 base,
|
||
标给某租户是错的)。
|
||
返回每任务磁盘路径。"""
|
||
disk = provision_task_disk(
|
||
self.state_dir, self.vm_name(task), self.rootfs,
|
||
self.disk_mode)
|
||
if getattr(self, "disk_mode", "copy") in ("overlay", "copy"):
|
||
provision_data_disk(self.state_dir, self.vm_name(task))
|
||
write_vm_tenant(self.state_dir, self.vm_name(task),
|
||
self._tenant_id(task), disk=disk)
|
||
return disk
|
||
|
||
def _provision_data_disk_path(self, task):
|
||
"""数据盘路径(VmConfig 第三块盘):copy 与 overlay 都
|
||
挂(见 _provision_task_disk 的解耦说明);只有 reuse
|
||
返回 None(共享 base,不挂私有数据盘)。"""
|
||
if getattr(self, "disk_mode", "copy") == "reuse":
|
||
return None
|
||
return data_disk_path(self.state_dir, self.vm_name(task))
|
||
|
||
def wait_api_ready(self, task: dict) -> bool:
|
||
"""等 vmm.ping 通:按次数轮询(api_timeout/1 秒取整,
|
||
至少 1 次),不用墙钟死循环——fake sleeper 不推进墙钟
|
||
时墙钟循环会空转,次数循环在单测里是确定性的。
|
||
超时 raise ChvFailure(kind=api_timeout)。"""
|
||
socket_path = self.api_socket(task)
|
||
attempts = max(1, int(self.api_timeout))
|
||
for attempt in range(attempts):
|
||
try:
|
||
status, _ = self._api_call(
|
||
socket_path, "GET", "/vmm.ping")
|
||
if status == 200:
|
||
return True
|
||
except Exception:
|
||
pass
|
||
if attempt + 1 < attempts:
|
||
self.sleeper(1)
|
||
raise ChvFailure(
|
||
"api_timeout",
|
||
"vmm.ping 超时:%d 次内 API 未通,socket=%s"
|
||
% (attempts, socket_path))
|
||
|
||
def _provisioned_key_guard(self, task):
|
||
"""provisioned 下缺 known_hosts 即 fail-fast,绝不 keyscan。
|
||
|
||
返回 True 表示已拦截(调用方须先关 VM 再抛
|
||
host_key_not_pinned);False 表示可继续轮询。"""
|
||
if getattr(self, "host_key_mode", "provisioned") == "provisioned" \
|
||
and not os.path.exists(self.known_hosts_path(task)):
|
||
return True
|
||
return False
|
||
|
||
def _check_host_key_mismatch(self, task, result):
|
||
"""SSH stderr 含钉死拒绝标记时审计并返回 True(须 fail-fast)。
|
||
|
||
MITM 尝试是独立安全事件,不轮询成普通超时;审计
|
||
detail 只含 VM 名与标记,不含 key 值。"""
|
||
stderr = getattr(result, "stderr", "") or ""
|
||
if "Host key verification failed" in stderr \
|
||
or "REMOTE HOST IDENTIFICATION" in stderr:
|
||
self._emit("host_key_mismatch", task,
|
||
"vm=%s ssh host key 被拒绝(钉死不匹配)"
|
||
% self.vm_name(task), "host_key_mismatch")
|
||
return True
|
||
return False
|
||
|
||
def _call_ssh_with_transient_retry(self, task, ssh_cmd,
|
||
timeout=None):
|
||
"""任务 SSH 的连接建立阶段瞬时 reset 重试(V-7/F1,
|
||
执行器内部唯一允许的重试——§5“执行器内部不重试”
|
||
的例外,见 _run_with_vm 的注):
|
||
|
||
崩溃后重 wake 的首条任务 SSH 曾撞 1 次 kex 阶段
|
||
Connection reset(rc=255),下一条即通;与防火墙
|
||
冒烟第一轮 cloud-init 收尾重启 sshd 的瞬态故障同款
|
||
签名。wait_guest_ready 的轮询本就容忍这类抖动,但
|
||
首条任务 SSH 是单次调用:不重试就把一次秒级抖动
|
||
放大成 worker 级重试 = 整台 VM 冷启动重跑(分钟级
|
||
+ 重复计费)。故只对“连接未建立即 reset”(rc=255
|
||
+ kex/reset 标记,见 _is_transient_ssh_reset)重试
|
||
恰好 1 次,重试前 sleeper(1);第二次仍失败即原样
|
||
返回,不再追。host key 拒绝、认证失败、远端命令
|
||
失败一律不重试;误重试的最坏后果也被 host.py 兜住:
|
||
dispatch 同名任务已存在即 loud fail(task 已存在),
|
||
不会静默重跑一轮 turn。重试发 vm_ssh_transient_retry
|
||
审计。"""
|
||
result = self._call_runner(ssh_cmd, timeout=timeout)
|
||
if _is_transient_ssh_reset(result):
|
||
self._emit("vm_ssh_transient_retry", task,
|
||
"vm=%s ssh 瞬时 reset(kex 阶段),1 秒后重试 1 次"
|
||
% self.vm_name(task),
|
||
"ssh_transient_retry")
|
||
self.sleeper(1)
|
||
result = self._call_runner(ssh_cmd, timeout=timeout)
|
||
return result
|
||
|
||
def wait_guest_ready(self, task: dict) -> bool:
|
||
"""等 guest SSH 就绪:轮询 ssh "true",returncode 0 即就绪;
|
||
每轮先尽力捕获 host key(keyscan 模式),超时先关 VM
|
||
再 raise guest_not_ready,绝不在未就绪时派任务。"""
|
||
# P0-2:provisioned 下缺 known_hosts 即 fail-fast,绝不
|
||
# 调用 capture_host_key 回落 TOFU(keyscan 才走捕获分支)。
|
||
if self._provisioned_key_guard(task):
|
||
try:
|
||
self.stop_vm(task)
|
||
except Exception:
|
||
pass
|
||
raise ChvFailure(
|
||
"host_key_not_pinned",
|
||
"provisioned 模式下 known_hosts 缺失,拒绝 keyscan "
|
||
"回落:VM %s 未钉死 host key" % self.vm_name(task))
|
||
attempts = max(1, int(self.ready_timeout / self.ready_interval))
|
||
for attempt in range(attempts):
|
||
try:
|
||
if not os.path.exists(self.known_hosts_path(task)):
|
||
self.capture_host_key(task)
|
||
result = self._call_runner(
|
||
self.build_ssh_command(task, ["true"]))
|
||
if getattr(result, "returncode", 1) == 0:
|
||
return True
|
||
# M5:host key 不匹配是独立安全事件,见
|
||
# _check_host_key_mismatch(审计 + fail-fast)。
|
||
if self._check_host_key_mismatch(task, result):
|
||
try:
|
||
self.stop_vm(task)
|
||
except Exception:
|
||
pass
|
||
raise ChvFailure(
|
||
"host_key_mismatch",
|
||
"SSH host key 不匹配(钉死拒绝):VM %s,"
|
||
"可能被替换或中间人,已关 VM"
|
||
% self.vm_name(task))
|
||
except ChvFailure:
|
||
raise
|
||
except Exception:
|
||
pass
|
||
if attempt + 1 < attempts:
|
||
self.sleeper(self.ready_interval)
|
||
try:
|
||
self.stop_vm(task)
|
||
except Exception:
|
||
pass
|
||
raise ChvFailure(
|
||
"guest_not_ready",
|
||
"guest SSH 就绪超时:%d 次轮询内 sshd 未通,已关 VM"
|
||
% attempts)
|
||
|
||
def _wait_guest_env_usable(self, task: dict) -> None:
|
||
"""密钥已投递时,等客内 guest-env 对 SSH 用户可读再返回:
|
||
write_files 以 root:root 写文件(见 hostkeys.py——
|
||
write_files 在 users-groups 之前跑,owner 只能写 root),
|
||
客内 final 阶段的 99-chv-sync.cfg runcmd 才 chown 给
|
||
SSH 用户;SSH 就绪(vm_ready)比 final 阶段早约 30 秒
|
||
(V-9 真机:runcmd 脚本在 vm_ready 后约 31 秒才执行),
|
||
派发若在这窗口内 source 文件会 Permission denied。
|
||
判定用 `test -r`(SSH 用户本人可读与否,正是派发
|
||
source 的前置条件),不比对 owner 名。无密钥投递时
|
||
不等待、不轮询。超时先关 VM 再抛 guest_env_not_ready,
|
||
绝不带着读不到的密钥去跑任务。"""
|
||
if not self._delivered_secrets(self.vm_name(task)):
|
||
return
|
||
from cloud.chv.hostkeys import GUEST_ENV_PATH
|
||
attempts = max(1, int(self.ready_timeout / self.ready_interval))
|
||
for attempt in range(attempts):
|
||
try:
|
||
result = self._call_runner(self.build_ssh_command(
|
||
task, ["test", "-r", GUEST_ENV_PATH]))
|
||
except Exception:
|
||
result = None
|
||
if (result is not None
|
||
and getattr(result, "returncode", 1) == 0):
|
||
return
|
||
if attempt + 1 < attempts:
|
||
self.sleeper(self.ready_interval)
|
||
try:
|
||
self.stop_vm(task)
|
||
except Exception:
|
||
pass
|
||
raise ChvFailure(
|
||
"guest_env_not_ready",
|
||
"guest-env 在 %d 次轮询内仍对 SSH 用户不可读,已关 VM:"
|
||
"runcmd chown 未生效或 cloud-init 未完成"
|
||
% attempts)
|
||
|
||
def _guest_host_command(self, *args, model=None, effort=None,
|
||
approval=None, sandbox=None,
|
||
model_provider=None):
|
||
"""客内 host.py 命令:python3 /app/host.py [顶层全局参数]
|
||
[子命令...],顶层参数顺序与 Local/Docker 同款(必须在
|
||
子命令之前,共享 helper 在 cloud/sandbox.py)。
|
||
guest_host_py 默认 /app/host.py:客机镜像须把 host.py
|
||
放在该路径(与 Docker 的 /app:ro 同款约定,只是载体
|
||
从容器换成 VM 镜像)。"""
|
||
from cloud.sandbox import _host_command_options
|
||
cmd = ["python3", self.guest_host_py]
|
||
cmd += _host_command_options(
|
||
model=model, effective_model=self.model,
|
||
model_provider=model_provider, effort=effort,
|
||
approval=approval, sandbox=sandbox)
|
||
cmd += list(args)
|
||
return cmd
|
||
|
||
# -- 生命周期:API 唤醒 / 暂停恢复 / 快照 / 关闭 --------------------
|
||
def _launch_api_only(self, task):
|
||
"""只启动 VMM 进程(API 模式第一步):CLI 上只带
|
||
--api-socket,不带内核/磁盘配置;VM 配置随后走
|
||
vm.create 请求体。返回登记的进程。"""
|
||
socket_path = self.api_socket(task)
|
||
cmd = [self.binary, "--api-socket", socket_path]
|
||
proc = self.launcher(cmd)
|
||
with self._vms_lock:
|
||
self._vms[self.vm_name(task)] = proc
|
||
return proc
|
||
|
||
def _ch_remote(self, task_or_name, *args):
|
||
"""ch-remote 调用:[ch-remote --api-socket=<socket> args...]。
|
||
returncode 非 0 即 raise RuntimeError(fail-fast),
|
||
runner 本身抛异常时原样传播。返回 runner 结果。"""
|
||
name = self.vm_name(task_or_name)
|
||
socket_path = os.path.join(self.state_dir, name + ".sock")
|
||
result = self.runner([self.ch_remote,
|
||
"--api-socket=%s" % socket_path]
|
||
+ list(args))
|
||
if getattr(result, "returncode", 0) not in (0, None):
|
||
# feat-chv-opt-landing(阶段 2):stderr 尾巴进报错,
|
||
# 否则 tap-busy(Resource busy / os error 16)这类
|
||
# 原因只剩 returncode,调用方无法区分瞬时竞态与
|
||
# 真失败、重试 wrapper 无从下手。截断 300 字符。
|
||
tail = (getattr(result, "stderr", "") or "").strip()
|
||
tail = tail.splitlines()[-1] if tail else ""
|
||
raise RuntimeError(
|
||
"ch-remote %s 失败:returncode=%s%s"
|
||
% (" ".join(args), getattr(result, "returncode"),
|
||
(" stderr=%s" % tail[:300]) if tail else ""))
|
||
return result
|
||
|
||
# -- tap 空闲轮询与退避重试(阶段 2,§6.1/§6.2 遗留) ------------
|
||
def _tap_wait_seconds(self):
|
||
try:
|
||
return max(0.0, float(
|
||
self._env.get("CHV_TAP_WAIT_TIMEOUT", 30)))
|
||
except (TypeError, ValueError):
|
||
return 30.0
|
||
|
||
def _tap_wait_interval(self):
|
||
try:
|
||
return max(0.5, float(
|
||
self._env.get("CHV_TAP_WAIT_INTERVAL", 2)))
|
||
except (TypeError, ValueError):
|
||
return 2.0
|
||
|
||
@staticmethod
|
||
def _is_tap_busy_error(exc):
|
||
text = str(exc)
|
||
lowered = text.lower()
|
||
return ("resource busy" in lowered
|
||
or "os error 16" in lowered
|
||
or ("tap" in lowered and "busy" in lowered))
|
||
|
||
def _tap_names_for_task(self):
|
||
"""该任务 net 条目的 tap 名(去重去空)。"""
|
||
if not self.net_json:
|
||
return []
|
||
try:
|
||
entries = parse_net_entries(self.net_json)
|
||
except Exception:
|
||
return []
|
||
taps = []
|
||
for entry in entries:
|
||
tap = (entry.get("tap") or "").strip()
|
||
if tap and tap not in taps:
|
||
taps.append(tap)
|
||
return taps
|
||
|
||
def _wait_tap_idle(self, taps, timeout=None):
|
||
"""等 tap 空闲:`ip link show <tap>` 不存在即空闲;
|
||
state DOWN / NO-CARRIER 即空闲;否则轮询到超时。
|
||
无 tap 配置、ip 不可用、探查抛错、输出为空时返回 True
|
||
(按旧行为直接尝试,重试 wrapper 仍会接住 busy 再
|
||
等一次)。诊断一律不用 pgrep -x cloud-hypervisor
|
||
(15 字符上限永远匹配不到),只看 tap 设备状态,
|
||
或 ps -eo pid,cmd / 直接读 /proc。"""
|
||
if not taps:
|
||
return True
|
||
if timeout is None:
|
||
timeout = self._tap_wait_seconds()
|
||
interval = self._tap_wait_interval()
|
||
deadline = time.monotonic() + max(0.0, timeout)
|
||
while True:
|
||
idle = True
|
||
for tap in taps:
|
||
try:
|
||
result = self._call_runner(
|
||
["ip", "link", "show", tap], timeout=10)
|
||
except Exception:
|
||
continue
|
||
if getattr(result, "returncode", 0) != 0:
|
||
continue # tap 不存在 = 空闲
|
||
out = getattr(result, "stdout", "") or ""
|
||
if not out.strip():
|
||
continue # 无输出 = 探查无果,按空闲处理
|
||
if "state DOWN" in out or "NO-CARRIER" in out:
|
||
continue
|
||
idle = False
|
||
if idle:
|
||
return True
|
||
if time.monotonic() >= deadline:
|
||
return False
|
||
self.sleeper(interval)
|
||
|
||
def _retry_on_tap_busy(self, task, fn, what):
|
||
"""tap-busy 退避重试:fn 抛含 Resource busy 的错时,
|
||
等 tap 空闲(CHV_TAP_WAIT_TIMEOUT,默认 30 秒)后
|
||
重试一次;第二次仍 busy 即原样抛(调用方失败路径
|
||
处理,restore 失败路径现已保留 seed.iso 可再试)。"""
|
||
try:
|
||
return fn()
|
||
except Exception as exc:
|
||
if not self._is_tap_busy_error(exc):
|
||
raise
|
||
taps = self._tap_names_for_task()
|
||
self._emit("vm_tap_busy_retry", task,
|
||
"vm=%s %s tap busy,等 tap 空闲后重试一次"
|
||
% (self.vm_name(task), what))
|
||
self._wait_tap_idle(taps)
|
||
return fn()
|
||
|
||
def _registered_net_entries(self, exclude_vm=None):
|
||
"""已登记 VM 的 net 条目(H7):扫描 state_dir/*/net.json
|
||
(wake 成功后写,stop 时删)。文件损坏/缺失即跳过该
|
||
VM——登记缺失只会漏判冲突,不会误拒;漏判的兜底是
|
||
宿主防火墙部署清单里的“每 VM 独立 tap”检查项。"""
|
||
registered = []
|
||
try:
|
||
entries = os.listdir(self.state_dir)
|
||
except Exception:
|
||
return registered
|
||
for entry in entries:
|
||
if entry == exclude_vm:
|
||
continue
|
||
path = os.path.join(self.state_dir, entry, "net.json")
|
||
try:
|
||
with open(path, "r") as fh:
|
||
data = json.load(fh)
|
||
except Exception:
|
||
continue
|
||
if not isinstance(data, list):
|
||
continue
|
||
for item in data:
|
||
if isinstance(item, dict):
|
||
row = dict(item)
|
||
row["vm"] = entry
|
||
registered.append(row)
|
||
return registered
|
||
|
||
def _record_net_entries(self, vm, entries):
|
||
"""wake 成功后登记本 VM 的 net 条目(P2-19 收尾)。
|
||
|
||
返回 True=登记成功,False=登记失败。失败时三件事
|
||
同时做:warning 日志(旧行为保留)+ 独立审计事件
|
||
net_registry_write_failed(调用方与运维都能看见,
|
||
不再只有一条日志)+ 返回 False 让调用方感知。
|
||
调用方 _boot_vm 见 False 即拒绝启动(ChvFailure,
|
||
wake 随后 stop_vm 清掉半醒 VM):登记缺失的后果
|
||
是后来的 wake 漏判 tap/mac/ip 冲突(H7 的第二道
|
||
防线没了),继续启动等于带着缺口跑,不如不起;
|
||
state_dir 可写本就是 preflight 的 0700 门,走到
|
||
这里才写失败是真故障,不是预期内的可容忍情形。
|
||
写路径走 atomic_file(P2-18):随机 tmp 名 + 双 fsync。
|
||
"""
|
||
try:
|
||
vm_dir = os.path.join(self.state_dir, vm)
|
||
os.makedirs(vm_dir, exist_ok=True)
|
||
atomic_write_text(
|
||
os.path.join(vm_dir, "net.json"),
|
||
json.dumps(entries))
|
||
return True
|
||
except OSError as exc:
|
||
_LOGGER.warning("chv_net_registry_write_failed vm=%s "
|
||
"error=%s", vm, exc)
|
||
self._emit("net_registry_write_failed", None,
|
||
"vm=%s net 登记写失败:%s" % (vm, exc),
|
||
"net_registry_write_failed")
|
||
return False
|
||
|
||
def _check_api_socket_permissions(self, socket_path):
|
||
"""VMM api-socket 权限门(H6):socket 文件存在时,
|
||
组/其它可写(mode & 0o022)即 fail-fast——Unix socket
|
||
的 connect 需要写权限,可写即任何同组/其它用户都
|
||
能调 VMM API。仅禁写位,不禁读/执行位:VMM 默认
|
||
umask 下的 0755 类权限不误杀(VMM 默认值代码控制
|
||
不了,真机上的实际权限仍须按部署清单核对)。
|
||
socket 文件不存在时(mock/带外)跳过,不假装已验。
|
||
普通文件(mock 占位)也跳过——只对真正的 socket
|
||
文件校验权限。"""
|
||
if not os.path.exists(socket_path):
|
||
return
|
||
try:
|
||
import stat as _stat
|
||
st = os.stat(socket_path)
|
||
if not _stat.S_ISSOCK(st.st_mode):
|
||
return
|
||
mode = st.st_mode & 0o777
|
||
except OSError:
|
||
return
|
||
if mode & 0o022:
|
||
raise ChvFailure(
|
||
"socket_permissions_insecure",
|
||
"VMM api-socket 组/其它可写(%o),拒绝启动:%s"
|
||
% (mode, socket_path))
|
||
|
||
def _seed_extras(self) -> tuple:
|
||
"""读 seed 附加项文件(CHV_SEED_* 配置的路径):
|
||
返回 (user_data_extra, network_config),皆空即
|
||
(None, None)。路径已配但文件缺失/读不出时
|
||
ChvFailure(seed_extra_missing),wake 在起 VMM
|
||
之前拒绝——缺 users 块的 VM 起了也登不进去,缺
|
||
network-config 的 VM 在无 DHCP 的 tap 上拿不到 IP,
|
||
不如不起。错误只含路径,不含文件内容。"""
|
||
out = []
|
||
for attr in ("seed_user_data_extra_file",
|
||
"seed_network_config_file"):
|
||
path = getattr(self, attr, None)
|
||
if not path:
|
||
out.append(None)
|
||
continue
|
||
try:
|
||
with open(path) as fh:
|
||
out.append(fh.read())
|
||
except OSError as exc:
|
||
raise ChvFailure(
|
||
"seed_extra_missing",
|
||
"seed 附加文件读不出:%s:%s" % (path, exc))
|
||
return out[0], out[1]
|
||
|
||
def _effective_extra_env(self, task: dict,
|
||
extra_env: dict | None) -> dict | None:
|
||
"""wake 生效的 extra_env:把 Gitea 同步变量并进来。
|
||
|
||
优先级(低→高):执行器宿主配置 CHV_GITEA_*(构造时
|
||
已映射成 gitea_sync_env)< 任务 dict 的 gitea_sync <
|
||
显式 extra_env 同名键(见 gitea_sync.extract_...)。
|
||
同步已请求(任一同步键出现)但必填键不全时,在起
|
||
VMM 之前就 ChvFailure(gitea_sync_incomplete,
|
||
fail-closed;错误只含缺失键名,不含值)——半份同步
|
||
配置绝不能让 VM 带着“以为同步了”的状态启动。无同步
|
||
变量时原样返回(本次启动不同步)。
|
||
必须在 _wake_prechecks 之前调:任务/配置来源的
|
||
GITEA_TOKEN 也要受 keyscan 模式 fail-closed 检查。"""
|
||
from cloud.chv.gitea_sync import (
|
||
extract_gitea_sync_env,
|
||
validate_gitea_sync_env,
|
||
)
|
||
sync = extract_gitea_sync_env(
|
||
task=task, extra_env=extra_env,
|
||
config_sync=getattr(self, "gitea_sync_env", None))
|
||
try:
|
||
validate_gitea_sync_env(sync)
|
||
except ValueError as exc:
|
||
raise ChvFailure("gitea_sync_incomplete", str(exc))
|
||
if not sync:
|
||
return extra_env
|
||
merged = dict(extra_env or {})
|
||
merged.update(sync)
|
||
return merged
|
||
|
||
def _wake_prechecks(self, task: dict, name: str, extra_env) -> tuple:
|
||
"""wake 前置:preflight/容量/配额/net/密钥模式,纯移动。
|
||
失败时发 vm_failed 审计并原样抛 ChvFailure。"""
|
||
try:
|
||
self.preflight()
|
||
# feat-chv-fix:SSH 目标与 reuse 放行在启动前就定性。
|
||
# resolve 失败即 fail-fast(不先起 VMM 再超时);
|
||
# reuse 实验放行必须审计,留痕给运维复核。
|
||
self.resolve_ssh_host()
|
||
self._check_capacity(exclude=name)
|
||
self._check_disk_quota()
|
||
# feat-chv-secfix(H7):跨 VM tap/mac/ip 冲突在
|
||
# 起 VMM 前拒绝(preflight 只做了条目内校验)。
|
||
net_entries = []
|
||
if self.net_json:
|
||
net_entries = validate_net_entries(
|
||
parse_net_entries(self.net_json),
|
||
self._registered_net_entries(exclude_vm=name))
|
||
# feat-chv-secfix(H2):密钥子集的投递能力在起
|
||
# VM 前就定性——keyscan 模式无 seed 可投递,有
|
||
# 密钥即 fail-closed,不等到执行时才在 argv 前
|
||
# 被拦(那时 VM 已起,失败语义更脏)。
|
||
secrets, _plain = split_secret_env(extra_env)
|
||
if secrets and getattr(self, "host_key_mode",
|
||
"provisioned") != "provisioned":
|
||
raise ChvFailure(
|
||
"secret_env_requires_provisioned",
|
||
"密钥环境变量 %s 须走 seed 文件投递,仅 "
|
||
"provisioned 模式支持(当前 %s)"
|
||
% (",".join(sorted(secrets)),
|
||
getattr(self, "host_key_mode", "provisioned")))
|
||
except ChvFailure as exc:
|
||
self._emit("vm_failed", task,
|
||
"vm=%s wake 前置失败 kind=%s" % (name, exc.kind),
|
||
exc.kind)
|
||
raise
|
||
return secrets, net_entries
|
||
|
||
def _provision_seed_disk(self, task: dict, name: str, secrets,
|
||
guest_env: dict | None = None,
|
||
seed_extras: tuple | None = None) -> str | None:
|
||
"""provisioned 模式的 seed 盘供给:生成→制盘→挂盘,
|
||
任一步失败即抛(调用方 stop_vm 清理),纯移动。"""
|
||
seed_disk = None
|
||
if getattr(self, "host_key_mode", "provisioned") == "provisioned":
|
||
# 强方案:启动前预置每 VM host key 并钉死,
|
||
# 再把 seed 目录做成 CIDATA seed 磁盘并挂进
|
||
# VmConfig(P0-1 闭环:生成→制盘→挂盘三步缺
|
||
# 一即 fail-fast,绝不回落 keyscan、绝不只
|
||
# 生成不注入)。
|
||
# feat-chv-secfix(H2/H5):secret_env 随 seed
|
||
# 写进客内密钥文件;seed.iso 建好(0600,见
|
||
# build_seed_disk)后立即删宿主侧私钥——此后
|
||
# 宿主上只剩公钥 + known_hosts,私钥只活在客
|
||
# 机与 seed.iso(随 VM 销毁而删)里。删私钥
|
||
# 失败即 fail-fast(VM 尚未启动,此时拒绝最
|
||
# 安全,except 里 stop_vm 会清掉 seed)。
|
||
from cloud.chv.hostkeys import (
|
||
build_seed_disk,
|
||
delete_host_private_key,
|
||
seed_disk_path,
|
||
)
|
||
extra, netcfg = seed_extras or (None, None)
|
||
provisioned = self.provision_host_key(
|
||
task, secret_env=secrets or None,
|
||
guest_env=guest_env or None,
|
||
user_data_extra=extra, network_config=netcfg)
|
||
seed_disk = seed_disk_path(self.state_dir, name)
|
||
build_seed_disk(
|
||
provisioned["seed_dir"], seed_disk,
|
||
runner=lambda cmd: self._call_runner(cmd))
|
||
delete_host_private_key(self.state_dir, name)
|
||
self._mark_guest_env_delivered(name, secrets)
|
||
return seed_disk
|
||
|
||
def _boot_vm(self, task, name, task_disk, seed_disk,
|
||
net_entries) -> None:
|
||
"""VMM 启动→vm.create→vm.boot→SSH 就绪→密钥已投递时
|
||
等 guest-env 对 SSH 用户可读(V-9,见
|
||
_wait_guest_env_usable),纯移动。"""
|
||
socket_path = self.api_socket(task)
|
||
self._launch_api_only(task)
|
||
self.wait_api_ready(task)
|
||
self._check_api_socket_permissions(socket_path)
|
||
status, body = self._api_call(
|
||
socket_path, "PUT", "/vm.create",
|
||
self.build_vm_config(
|
||
task_disk, seed_disk=seed_disk,
|
||
data_disk=self._provision_data_disk_path(task)))
|
||
if status not in (200, 204):
|
||
raise ChvFailure(
|
||
"vm_create_failed",
|
||
"vm.create 失败:status=%s" % status)
|
||
self._emit("vm_create", task,
|
||
"vm=%s disk=%s" % (name, task_disk))
|
||
if net_entries:
|
||
# feat-chv-closeup(P2-19):登记失败即拒起,
|
||
# 见 _record_net_entries(审计 + 返回 False)。
|
||
if not self._record_net_entries(name, net_entries):
|
||
raise ChvFailure(
|
||
"net_registry_write_failed",
|
||
"vm=%s net 登记写失败,拒绝启动(缺登记会"
|
||
"漏判 tap/mac/ip 冲突)" % name)
|
||
# feat-chv-opt-landing(阶段 2):vm.boot 触 tap,停机
|
||
# →再启窗口的 tap 释放竞态在此处报 Resource busy;
|
||
# 响应体进报错(否则只剩 status,分不清瞬时竞态),
|
||
# busy 时等 tap 空闲后重试一次,不立即失败。
|
||
def _do_boot():
|
||
_status, _body = self._api_call(
|
||
socket_path, "PUT", "/vm.boot")
|
||
if _status not in (200, 204):
|
||
raise ChvFailure(
|
||
"vm_boot_failed",
|
||
"vm.boot 失败:status=%s body=%s"
|
||
% (_status, _body))
|
||
return _status
|
||
|
||
self._retry_on_tap_busy(task, _do_boot, "vm.boot")
|
||
self._emit("vm_boot", task, "vm=%s" % name)
|
||
self.wait_guest_ready(task)
|
||
# V-9:密钥已投递时等 guest-env 对 SSH 用户可读再报
|
||
# 就绪(runcmd chown 比 SSH 就绪晚约 30 秒)。
|
||
self._wait_guest_env_usable(task)
|
||
|
||
def _wake_prepare(self, task: dict, name: str) -> tuple:
|
||
"""wake 准备:reuse 审计、旧 VM 清理、任务盘供给、
|
||
直连 egress 审计,纯移动。返回 (task_disk, socket_path)。
|
||
feat-chv-v3(F1):旧 VM 在时先删旧任务盘再供给新盘。
|
||
根因:H5 在 seed 建好后删宿主私钥,re-wake 必然重新
|
||
生成 key 对;旧盘若复用,cloud-init 以 instance-id
|
||
(=VM 名)已应用为由不再重应用新 seed,sshd 仍呈旧
|
||
key,known_hosts 却钉了新 key → host_key_mismatch。
|
||
wake 按设计是冷启动(单任务单 VM,重复 wake 即重启),
|
||
旧盘从来不是要保留的状态(restore 另走 restore_vm,
|
||
暂停唤醒另走 resume);删盘失败即 ChvFailure fail-fast,
|
||
绝不带着旧盘 + 新 key 对去启动。reuse 模式不删(盘
|
||
就是基础镜像本身,删不得)。"""
|
||
if getattr(self, "disk_mode", "copy") == "reuse":
|
||
self._emit("disk_reuse_experiment_allowed", task,
|
||
"vm=%s CHV_REUSE_SINGLE_TENANT_EXPERIMENT=1 "
|
||
"explicit single_tenant_project=%s"
|
||
% (name, getattr(self, "single_tenant_project",
|
||
None)))
|
||
self._drop_registered_proc(name)
|
||
# 旧盘不论旧 VMM 是否登记在册(活着重 wake / 崩溃后
|
||
# 重 wake)一律先删:delete_task_disk 无盘时返回 False,
|
||
# 不 extra 判断。
|
||
if getattr(self, "disk_mode", "copy") != "reuse":
|
||
try:
|
||
delete_task_disk(self.state_dir, name)
|
||
except OSError as exc:
|
||
raise ChvFailure(
|
||
"stale_disk_cleanup_failed",
|
||
"旧任务盘删不掉,拒绝复用旧盘启动:VM %s:%s"
|
||
% (name, exc))
|
||
task_disk = self._provision_task_disk(task)
|
||
socket_path = self.api_socket(task)
|
||
if self.allow_direct_egress:
|
||
self._emit("egress_direct_allowed", task,
|
||
"vm=%s CHV_ALLOW_DIRECT_EGRESS=1 explicit" % name)
|
||
return task_disk, socket_path
|
||
|
||
|
||
def wake_vm(self, task: dict, extra_env: dict | None = None) -> dict:
|
||
"""主唤醒路径(API 模式,dispatch/run/goal 的前置步骤):
|
||
preflight → 容量门 → 磁盘配额门 → 每任务磁盘供给 →
|
||
launcher 只起 VMM(--api-socket)→ 等 vmm.ping 通 →
|
||
PUT vm.create(VmConfig)→ PUT vm.boot → 等 guest
|
||
SSH 就绪 → 密钥已投递时再等 guest-env 对 SSH 用户可读
|
||
(见 _wait_guest_env_usable,V-9)。任一步失败都
|
||
关 VM(stop_vm)再抛 ChvFailure,绝不带着半醒的 VM
|
||
去跑任务。
|
||
成功返回 {vm, api_socket, state: "running", ready: True,
|
||
mode: "cold"},并审计 vm_create/vm_boot/vm_ready。
|
||
单任务单 VM:同一任务重复 wake 时先关旧 VM 再起新
|
||
的,绝不两个 VM 共用一个任务名。
|
||
feat-chv-secfix:extra_env(_run_with_vm 透传)里的
|
||
密钥子集在 provisioned 模式下写进 seed 客内文件,
|
||
keyscan 模式下有密钥子集即 fail-closed;起 VM 前的
|
||
preflight/容量/配额失败也发 vm_failed 审计(L3);
|
||
net 隔离在起 VMM 前校验(H7),成功后登记 net.json。"""
|
||
if not isinstance(task, dict) or task.get("id") is None:
|
||
raise ValueError("任务缺少 id")
|
||
name = self.vm_name(task)
|
||
try:
|
||
extra_env = self._effective_extra_env(task, extra_env)
|
||
seed_extras = self._seed_extras()
|
||
except ChvFailure as exc:
|
||
self._emit("vm_failed", task,
|
||
"vm=%s wake 失败 kind=%s" % (name, exc.kind),
|
||
exc.kind)
|
||
raise
|
||
secrets, net_entries = self._wake_prechecks(task, name, extra_env)
|
||
guest_env, _plain = split_guest_env(extra_env)
|
||
task_disk, socket_path = self._wake_prepare(task, name)
|
||
try:
|
||
seed_disk = self._provision_seed_disk(
|
||
task, name, secrets, guest_env=guest_env or None,
|
||
seed_extras=seed_extras)
|
||
self._boot_vm(task, name, task_disk, seed_disk, net_entries)
|
||
except Exception:
|
||
try:
|
||
self.stop_vm(task)
|
||
except Exception:
|
||
pass
|
||
if isinstance(task, dict):
|
||
self._emit("vm_failed", task,
|
||
"vm=%s wake 失败" % name)
|
||
raise
|
||
self._emit("vm_ready", task, "vm=%s" % name)
|
||
return {"vm": name, "api_socket": socket_path,
|
||
"state": "running", "ready": True, "mode": "cold"}
|
||
|
||
def start_vm(self, task: dict) -> dict:
|
||
"""旧名保留:即 wake_vm(API 模式)。直接启动命令
|
||
_build_vm_command() 只作手动等价写法(私有),不再是执行器
|
||
的启动路径——两条启动路径并存是上一版的半吊子,
|
||
本版只认 API 模式。"""
|
||
return self.wake_vm(task)
|
||
|
||
def pause_vm(self, task_or_name: dict | str) -> bool:
|
||
"""暂停 VM:ch-remote pause。成功审计 vm_pause。"""
|
||
self._ch_remote(task_or_name, "pause")
|
||
task = task_or_name if isinstance(task_or_name, dict) \
|
||
else {"id": task_or_name}
|
||
self._emit("vm_pause", task,
|
||
"vm=%s" % self.vm_name(task_or_name))
|
||
return True
|
||
|
||
def resume_vm(self, task_or_name: dict | str) -> bool:
|
||
"""唤醒已暂停的 VM:ch-remote resume。成功审计 vm_resume。
|
||
注意:这是暂停唤醒的唯一路径,恢复后的 VM 也走它
|
||
(见 restore_vm),绝不能只 restore 就算醒。"""
|
||
self._ch_remote(task_or_name, "resume")
|
||
task = task_or_name if isinstance(task_or_name, dict) \
|
||
else {"id": task_or_name}
|
||
self._emit("vm_resume", task,
|
||
"vm=%s" % self.vm_name(task_or_name))
|
||
return True
|
||
|
||
# -- 持久 VM 与预热池(feat-chv-opt-landing,阶段 1)-----------
|
||
# 模型(单租户灰度,CHV_WARM_POOL_SIZE=1 时启用):
|
||
# 同一租户的任务共用一台持久 VM(chv-persist-<tenant>,
|
||
# overlay 系统盘 + 独立数据盘);任务间隙 pause 存盘,
|
||
# 下个任务 resume,不走完整开机。预热池 = 常备 1 台已
|
||
# 开机、已完成 Gitea 首次同步、处于 paused 的持久 VM:
|
||
# 任务到达时先领它顶上,任务结束暂停回池(即补建回池);
|
||
# 池空(无 paused 登记)自动回落冷启,不报错。
|
||
# 与 experimental_pool.WarmPool 的区别(ADR-5 的缺口
|
||
# 在此补上):预热 VM 的盘从生下来就是该租户的持久盘
|
||
#(overlay + 数据盘 + tenant 标记),任务直接跑在它
|
||
# 自己的盘上,不存在“干净快照盘当任务盘用”的隔离击穿,
|
||
# 故 acquire 不需要热换盘。WarmPool 本身仍是实验模块,
|
||
# 生产路径不 import 它(ADR-12);本节是生产路径。
|
||
# 开关:CHV_WARM_POOL_SIZE(默认 0)——为 0 时本节全部
|
||
# 入口退化为冷启旧语义(dispatch_task_persistent 直接
|
||
# 调 dispatch_task;prewarm/refill 为 no-op),绝不硬
|
||
# 编码改默认值偷换全局行为;阶段 1 验证用
|
||
# CHV_WARM_POOL_SIZE=1。
|
||
|
||
def _vm_info_state(self, task_or_name) -> str | None:
|
||
"""GET /vm.info 取 VM 状态(Running/Paused/...):
|
||
取不到/无 state 字段即 None(调用方按未知处理,绝
|
||
不猜它是 paused 还是 running)。"""
|
||
try:
|
||
status, body = self._api_call(
|
||
self.api_socket(task_or_name), "GET", "/vm.info")
|
||
except Exception:
|
||
return None
|
||
if status != 200 or not isinstance(body, dict):
|
||
return None
|
||
state = body.get("state")
|
||
return state if isinstance(state, str) else None
|
||
|
||
def _persistent_task(self, tenant_id):
|
||
"""持久 VM 的合成任务 dict:id 固定为 persist-<租户>,
|
||
故 vm 名恒为 chv-persist-<租户>;gitea_sync 等任务
|
||
级同步变量由调用方经 extra_env 给,不进这里。"""
|
||
return {"id": "persist-%s" % (tenant_id or "default"),
|
||
"project_id": tenant_id}
|
||
|
||
def _wait_sync_workspace(self, vm_task, sync_workspace) -> bool:
|
||
"""等 Gitea 首次同步完成:轮询客内 test -d
|
||
<sync_workspace>/.git(clone 落盘的硬证据);每轮
|
||
间隔 ready_interval、上限 ready_timeout;超时即
|
||
False(调用方 fail-closed,不把未同步的 VM 当预热
|
||
机发给任务)。"""
|
||
attempts = max(
|
||
1, int(self.ready_timeout / self.ready_interval))
|
||
for attempt in range(attempts):
|
||
try:
|
||
result = self._call_runner(self.build_ssh_command(
|
||
vm_task, ["test", "-d",
|
||
sync_workspace + "/.git"]))
|
||
if getattr(result, "returncode", 1) == 0:
|
||
return True
|
||
except Exception:
|
||
pass
|
||
if attempt + 1 < attempts:
|
||
self.sleeper(self.ready_interval)
|
||
return False
|
||
|
||
def _prewarm_build_new(self, tenant_id, extra_env, sync_workspace):
|
||
"""预热新建段(prewarm_persistent_vm 的后半段):冷启
|
||
一台持久 VM;sync_workspace 给出时等首次同步完成,
|
||
等不到即关 VM 报 warm_pool_sync_incomplete(绝不把
|
||
未同步的 VM 记成预热机)。返回 (vm_task, gitea_synced)。"""
|
||
vm_task = self._persistent_task(tenant_id)
|
||
self.wake_vm(vm_task, extra_env=extra_env)
|
||
synced = False
|
||
if sync_workspace:
|
||
synced = self._wait_sync_workspace(
|
||
vm_task, sync_workspace)
|
||
if not synced:
|
||
try:
|
||
self.stop_vm(vm_task)
|
||
except Exception:
|
||
pass
|
||
raise ChvFailure(
|
||
"warm_pool_sync_incomplete",
|
||
"预热 VM %s 在 %s 秒内未完成 Gitea 首次同步"
|
||
"(%s/.git 未出现),已关 VM,不进池"
|
||
% (self.vm_name(vm_task), self.ready_timeout,
|
||
sync_workspace))
|
||
else:
|
||
synced = bool(getattr(self, "gitea_sync_env", None))
|
||
return vm_task, synced
|
||
|
||
def prewarm_persistent_vm(self, tenant_id: str | None,
|
||
extra_env: dict | None = None,
|
||
sync_workspace: str | None = None) -> dict:
|
||
"""预热池进池(幂等):CHV_WARM_POOL_SIZE>=1 才生效,
|
||
为 0 即 {pooled: False, reason: warm_pool_disabled}。
|
||
已有 paused 登记且 socket 活着即返回它(already-warm,
|
||
不重建);否则冷启一台持久 VM(overlay 盘 + Gitea
|
||
同步 env),sync_workspace 给出时等首次同步完成
|
||
(等不到即关 VM 报 warm_pool_sync_incomplete,绝不
|
||
把未同步的 VM 记成预热机),再 pause、写登记。
|
||
返回 {pooled, vm, mode, gitea_synced}。"""
|
||
if int(getattr(self, "warm_pool_size", 0)) <= 0:
|
||
return {"pooled": False, "vm": None,
|
||
"reason": "warm_pool_disabled"}
|
||
vm = self.persistent_vm_name(tenant_id)
|
||
record = read_persistent_vm(self.state_dir, tenant_id)
|
||
if record is not None and record.get("state") == "paused" \
|
||
and record.get("vm") == vm \
|
||
and os.path.exists(self.api_socket({"id": vm})):
|
||
return {"pooled": True, "vm": vm,
|
||
"mode": "already-warm",
|
||
"gitea_synced": bool(
|
||
record.get("gitea_synced"))}
|
||
vm_task, synced = self._prewarm_build_new(
|
||
tenant_id, extra_env, sync_workspace)
|
||
self.pause_vm(vm_task)
|
||
import time as _time
|
||
# feat-chv-hybrid-b:合并写(tier/last_active_at 等
|
||
# 不再被字面量覆盖丢掉);已有常驻态登记时保持其
|
||
# 状态(预热不降级常驻租约),否则记 legacy paused。
|
||
_prior = read_persistent_vm(self.state_dir, tenant_id) or {}
|
||
_state = _prior.get("state") \
|
||
if _prior.get("state") in RESIDENT_STATES else "paused"
|
||
self._write_resident_record(tenant_id, {
|
||
"vm": vm, "tenant_id": tenant_id,
|
||
"state": _state, "origin": "prewarm",
|
||
"gitea_synced": synced}, now=_time.time())
|
||
self._emit("warm_pool_prewarm", vm_task,
|
||
"vm=%s gitea_synced=%s" % (vm, synced))
|
||
return {"pooled": True, "vm": vm, "mode": "prewarmed",
|
||
"gitea_synced": synced}
|
||
|
||
def refill_warm_pool(self, tenant_id: str | None,
|
||
extra_env: dict | None = None,
|
||
sync_workspace: str | None = None) -> dict:
|
||
"""后台补建回池(调用方自行起线程跑,本方法本身
|
||
同步):池里已有 paused 登记即 no-op;池空即
|
||
prewarm 一台。warm_pool_size==0 时 no-op。"""
|
||
record = read_persistent_vm(self.state_dir, tenant_id)
|
||
if record is not None and record.get("state") == "paused" \
|
||
and os.path.exists(self.api_socket(
|
||
{"id": record.get("vm") or ""})):
|
||
return {"pooled": True, "vm": record.get("vm"),
|
||
"mode": "already-warm",
|
||
"gitea_synced": bool(record.get("gitea_synced"))}
|
||
return self.prewarm_persistent_vm(
|
||
tenant_id, extra_env=extra_env,
|
||
sync_workspace=sync_workspace)
|
||
|
||
def _acquire_paused_candidate(self, tenant):
|
||
"""认领的登记核对(acquire_for_task 的前半段):登记
|
||
是 paused、socket 存在、tenant.json 归属一致才返回
|
||
(vm, record);任一不符即清登记、返回 (None, None)
|
||
(调用方回落冷启)。归属不符与 socket 消失是登记
|
||
本身已坏,清掉;状态未知(vm.info 取不到)不在此
|
||
判——那是认领时的第二道门,见 acquire_for_task。
|
||
feat-chv-hybrid-b:可认领态扩展为 legacy paused +
|
||
常驻态里 VM 已暂停的两种(resident-active /
|
||
resident-paused,见 RESIDENT_CLAIMABLE_STATES);
|
||
常驻态登记新增 tier 校验——tier 不是 Paid/Heavy
|
||
即不认领(登记本身是坏的/被降档的,留给清理,不
|
||
静默按旧语义 resume;legacy paused 登记无 tier
|
||
字段,走旧语义,不因此拒认)。"""
|
||
record = read_persistent_vm(self.state_dir, tenant)
|
||
if record is None \
|
||
or record.get("state") not in RESIDENT_CLAIMABLE_STATES:
|
||
return None, None
|
||
vm = record.get("vm")
|
||
if not vm:
|
||
return None, None
|
||
if record.get("state") in RESIDENT_ACTIVE_STATES \
|
||
and not self.resident_tier_eligible(
|
||
tenant, record.get("tier")):
|
||
# 常驻态却无常驻资格:不认领、不 resume;登记留给
|
||
# 租约计时器/清理处理(不静默清掉——删租约是降级
|
||
# 流程的职责,不是认领的职责)。
|
||
self._emit("resident_tier_denied",
|
||
{"id": "persist-%s" % tenant,
|
||
"project_id": tenant},
|
||
"host_id=%s tenant=%s vm=%s tier=%s 常驻态登记"
|
||
"无常驻资格,不认领、回落冷启"
|
||
% (self._host_id(), tenant, vm,
|
||
record.get("tier")),
|
||
"tier_denied")
|
||
return None, None
|
||
owner = read_vm_tenant(self.state_dir, vm)
|
||
if owner is not None and owner != tenant:
|
||
# 盘的归属与请求方不一致:这台不是你的 VM,绝不
|
||
# resume,清登记回落冷启(legacy 未标记盘 owner
|
||
# 为 None 时放行,见 _check_snapshot_tenant 同款)。
|
||
clear_persistent_vm(self.state_dir, tenant)
|
||
return None, None
|
||
if not os.path.exists(self.api_socket({"id": vm})):
|
||
clear_persistent_vm(self.state_dir, tenant)
|
||
return None, None
|
||
return vm, record
|
||
|
||
def acquire_for_task(self, task: dict,
|
||
extra_env: dict | None = None) -> dict:
|
||
"""取本租户的可用 VM(任务到达时的认领):
|
||
- 持久登记是 paused、socket 活着、vm.info 确认
|
||
Paused、tenant.json 归属一致 → resume + 等 SSH
|
||
就绪,返回 {mode: resumed, vm, resume_seconds,
|
||
warm_hit}(warm_hit 表示这台来自 prewarm,
|
||
不是上个任务暂停留下的)。
|
||
- 其它情形(无登记 / VM 已死 / 状态未知 /
|
||
resume 或 SSH 就绪失败)→ 丢弃该登记(若有),
|
||
返回 {mode: cold, vm: None},调用方回落冷启,
|
||
不报错。warm_pool_size==0 时直接 cold。
|
||
resume_seconds 只计 resume+SSH 就绪,不含任务
|
||
自身启动。"""
|
||
if int(getattr(self, "warm_pool_size", 0)) <= 0:
|
||
return {"mode": "cold", "vm": None}
|
||
tenant = self._tenant_id(task)
|
||
vm, record = self._acquire_paused_candidate(tenant)
|
||
if vm is None:
|
||
return {"mode": "cold", "vm": None}
|
||
state = self._vm_info_state({"id": vm})
|
||
if state != "Paused":
|
||
# 状态未知或非 paused 时不猜:不是 paused 就不
|
||
# 认领(running 的 VM resume 会错、死了的 VM
|
||
# resume 也会错),回落冷启,登记留给清理。
|
||
return {"mode": "cold", "vm": None}
|
||
return self._acquire_resume_recorded(task, tenant, vm, record)
|
||
|
||
def _acquire_resume_recorded(self, task, tenant, vm, record):
|
||
"""认领的 resume 段(acquire_for_task 的后半段):
|
||
resume + 等 SSH 就绪,计时写回登记(state=running)
|
||
并返回 resumed;resume/就绪失败即清登记、回落 cold。
|
||
resume_seconds 只计 resume+SSH 就绪。"""
|
||
import time as _time
|
||
t0 = _time.time()
|
||
try:
|
||
self.resume_vm({"id": vm})
|
||
self.wait_guest_ready({"id": vm})
|
||
except Exception as exc:
|
||
self._emit("warm_pool_acquire_failed", task,
|
||
"vm=%s resume/就绪失败,回落冷启:%s"
|
||
% (vm, exc), "warm_pool_acquire_failed")
|
||
clear_persistent_vm(self.state_dir, tenant)
|
||
return {"mode": "cold", "vm": None}
|
||
resume_seconds = _time.time() - t0
|
||
# feat-chv-hybrid-b:合并写——常驻态登记 resume 后仍
|
||
# 保持常驻态(不写回 legacy running),只续期;legacy
|
||
# 登记走旧语义(state=running)。tier 等字段不再被
|
||
# 字面量覆盖丢掉。
|
||
if record.get("state") in RESIDENT_ACTIVE_STATES:
|
||
self._write_resident_record(
|
||
tenant, {"last_active_at": _time.time()}, now=_time.time())
|
||
else:
|
||
self._write_resident_record(tenant, {
|
||
"vm": vm, "tenant_id": tenant,
|
||
"state": "running",
|
||
"origin": record.get("origin") or "task",
|
||
"gitea_synced": bool(record.get("gitea_synced"))},
|
||
now=_time.time())
|
||
self._emit("warm_pool_acquire", task,
|
||
"vm=%s mode=resumed resume_seconds=%.2f"
|
||
" warm_hit=%s"
|
||
% (vm, resume_seconds,
|
||
record.get("origin") == "prewarm"))
|
||
return {"mode": "resumed", "vm": vm,
|
||
"resume_seconds": resume_seconds,
|
||
"warm_hit": record.get("origin") == "prewarm"}
|
||
|
||
def suspend_after_task(self, task_or_name: dict | str,
|
||
tenant_id: str | None = None) -> bool:
|
||
"""任务间隙暂停:pause + 写持久登记(state=paused,
|
||
origin=task)。pause 失败或 VM 已死即清登记并返回
|
||
False(调用方不得把死 VM 记成可用预热机)。"""
|
||
vm = self.vm_name(task_or_name)
|
||
tenant = tenant_id or self._tenant_id(
|
||
task_or_name if isinstance(task_or_name, dict)
|
||
else {"id": task_or_name})
|
||
try:
|
||
self.pause_vm(task_or_name)
|
||
except Exception as exc:
|
||
self._emit("vm_suspend_failed",
|
||
task_or_name if isinstance(task_or_name, dict)
|
||
else {"id": task_or_name},
|
||
"vm=%s 暂停失败,清持久登记:%s" % (vm, exc),
|
||
"vm_suspend_failed")
|
||
if tenant:
|
||
clear_persistent_vm(self.state_dir, tenant)
|
||
return False
|
||
def suspend_after_task(self, task_or_name: dict | str,
|
||
tenant_id: str | None = None) -> bool:
|
||
"""任务间隙暂停:pause + 写持久登记(state=paused,
|
||
origin=task)。pause 失败或 VM 已死即清登记并返回
|
||
False(调用方不得把死 VM 记成可用预热机)。
|
||
feat-chv-hybrid-b:常驻租户的任务结束只暂停 VM、
|
||
只续期(last_active_at=now),不改租约状态——
|
||
resident-active → resident-paused 只由租约计时器
|
||
在闲置满阈值时转(见 sweep_resident_leases),任务
|
||
结束不是降级信号;登记写改走 _write_resident_record
|
||
合并写,tier/last_active_at 等字段不再被字面量覆盖丢掉。"""
|
||
vm = self.vm_name(task_or_name)
|
||
tenant = tenant_id or self._tenant_id(
|
||
task_or_name if isinstance(task_or_name, dict)
|
||
else {"id": task_or_name})
|
||
try:
|
||
self.pause_vm(task_or_name)
|
||
except Exception as exc:
|
||
self._emit("vm_suspend_failed",
|
||
task_or_name if isinstance(task_or_name, dict)
|
||
else {"id": task_or_name},
|
||
"vm=%s 暂停失败,清持久登记:%s" % (vm, exc),
|
||
"vm_suspend_failed")
|
||
if tenant:
|
||
clear_persistent_vm(self.state_dir, tenant)
|
||
return False
|
||
if tenant:
|
||
import time as _time
|
||
now = _time.time()
|
||
record = read_persistent_vm(self.state_dir, tenant) or {}
|
||
if record.get("state") in RESIDENT_STATES:
|
||
self._write_resident_record(
|
||
tenant, {"last_active_at": now}, now=now)
|
||
else:
|
||
self._write_resident_record(tenant, {
|
||
"vm": vm, "tenant_id": tenant,
|
||
"state": "paused", "origin": "task",
|
||
"gitea_synced": bool(record.get("gitea_synced"))},
|
||
now=now)
|
||
return True
|
||
|
||
# -- 混合模式 B 轨(feat-chv-hybrid-b,DESIGN-HYBRID.md §9.1
|
||
# 第 5–9 项)-------------------------------------------------
|
||
# 计时器驱动方式(实施时定,§4.2 二选一):worker 心跳轮询。
|
||
# executor 暴露 sweep_resident_leases() 做一轮扫描,worker
|
||
# 心跳 tick 调一次(接线点见汇报;本轨不改 worker.py,由
|
||
# 协调员在集成时接一行调用)。不复用 P8 cron 语义:P8 是
|
||
# 任务级定时调度(每项目每 tick 上限、指数退避重试),租约
|
||
# 扫描是宿主级状态机轮询、无任务可调度,套 P8 只会把简单
|
||
# 的扫描复杂化;心跳轮询与 _reconcile_warm_pool 的调用形态
|
||
# 同款(worker 启动/心跳时调执行器方法)。
|
||
# 审计:状态转换记 vm_resident_suspended / vm_resident_demoted /
|
||
# vm_resident_promoted、升级提示记 resident_upgrade_hint,
|
||
# 全部带 host_id + tenant_id,绝不带密钥值。
|
||
|
||
def _hybrid_env_float(self, name, default):
|
||
"""读 B 轨阈值类环境变量(测试用短阈值注入):
|
||
非法/缺失/<=0 即回生产默认值,绝不因配错而停表。"""
|
||
try:
|
||
value = float(self._env.get(name) or default)
|
||
except (TypeError, ValueError):
|
||
return float(default)
|
||
return value if value > 0 else float(default)
|
||
|
||
def _host_id(self):
|
||
"""审计里的 host_id:CHV_HOST_ID 环境变量优先,
|
||
未设即本机 hostname。"""
|
||
try:
|
||
host_id = self._env.get("CHV_HOST_ID")
|
||
except Exception:
|
||
host_id = None
|
||
return host_id or socket.gethostname()
|
||
|
||
def _resident_thresholds(self):
|
||
"""租约阈值(秒):(挂起阈值, 降级阈值)。生产默认值
|
||
30 分钟 / 24 小时;测试经 CHV_RESIDENT_IDLE_SUSPEND_SECONDS /
|
||
CHV_RESIDENT_IDLE_DEMOTE_SECONDS 注入短阈值(构造时
|
||
env 或事后改 self._env 均可,调用时现读)。"""
|
||
return (
|
||
self._hybrid_env_float(
|
||
"CHV_RESIDENT_IDLE_SUSPEND_SECONDS",
|
||
RESIDENT_IDLE_SUSPEND_SECONDS),
|
||
self._hybrid_env_float(
|
||
"CHV_RESIDENT_IDLE_DEMOTE_SECONDS",
|
||
RESIDENT_IDLE_DEMOTE_SECONDS))
|
||
|
||
def resident_tier_eligible(self, tenant_id: str | None,
|
||
tier: str | None = None) -> bool:
|
||
"""常驻资格(权益门,fail-closed,设计 §5):
|
||
只有 Paid/Heavy 有常驻资格;Free 无论多高频都没有;
|
||
无 tier 行 = 无常驻资格(拒绝常驻,走池)。
|
||
不对称说明(有意为之,设计 §5):P11 的既定语义是
|
||
“无 tier 行的已有用户不受 tier 门约束(只受原有 USD
|
||
配额约束)”——那是用量门。常驻是权益门,语义相反:
|
||
无 tier 行 = 不授予新权益。两者不冲突,因为一个是
|
||
“不限制已有用量”,一个是“不授予新权益”;实施时不许
|
||
把 P11 的豁免复用到常驻门上。
|
||
tier 参数优先,否则读登记的 tier 字段;两者皆无即
|
||
不合格。"""
|
||
if tier is None:
|
||
record = read_persistent_vm(self.state_dir, tenant_id) \
|
||
if tenant_id else None
|
||
tier = record.get("tier") if record else None
|
||
return tier in RESIDENT_TIERS
|
||
|
||
def _write_resident_record(self, tenant, updates, now=None):
|
||
"""合并写持久登记:保留旧字段,只更新给定字段 +
|
||
updated_at;新登记补 tenant_id/vm 默认值。阶段 1 的
|
||
三处字面量写登记(prewarm/acquire/suspend)至此都
|
||
经本方法,tier/state/last_active_at/lease_expires_at
|
||
不再被字面量覆盖丢掉。"""
|
||
record = read_persistent_vm(self.state_dir, tenant) or {}
|
||
record.update(updates)
|
||
record.setdefault("tenant_id", tenant)
|
||
record.setdefault("vm", self.persistent_vm_name(tenant))
|
||
record["updated_at"] = time.time() if now is None else now
|
||
write_persistent_vm(self.state_dir, tenant, record)
|
||
return record
|
||
|
||
def touch_resident_activity(self, tenant_id: str | None,
|
||
now: float | None = None) -> bool:
|
||
"""租约续期(设计 §4.1):每次任务/消息/网关活动即续期
|
||
(last_active_at=now)。仅当登记存在且为常驻有效态
|
||
(resident-active/resident-paused/hibernated)时写;
|
||
无登记不凭空造租约,demoted 不静默复活(复活走
|
||
promote_to_resident)。lease_expires_at 是闲置规则
|
||
的派生值(last_active_at + 挂起阈值 + 降级阈值,即
|
||
再无活动时的降级点),不是日历到期日,续期即重算。
|
||
返回是否续期。"""
|
||
if not tenant_id:
|
||
return False
|
||
record = read_persistent_vm(self.state_dir, tenant_id)
|
||
if record is None \
|
||
or record.get("state") not in RESIDENT_ACTIVE_STATES:
|
||
return False
|
||
now = time.time() if now is None else now
|
||
suspend, demote = self._resident_thresholds()
|
||
self._write_resident_record(tenant_id, {
|
||
"last_active_at": now,
|
||
"lease_expires_at": now + suspend + demote}, now=now)
|
||
return True
|
||
|
||
def _resident_records(self):
|
||
"""扫描 state_dir/persistent/*.json,返回常驻有效态
|
||
登记 [(tenant, record)](demoted 已收回租约、不占槽,
|
||
不在内;损坏/非 dict 的登记跳过)。"""
|
||
out = []
|
||
pdir = os.path.join(self.state_dir, "persistent")
|
||
try:
|
||
entries = os.listdir(pdir)
|
||
except OSError:
|
||
return out
|
||
for entry in entries:
|
||
if not entry.endswith(".json"):
|
||
continue
|
||
try:
|
||
with open(os.path.join(pdir, entry), "r") as fh:
|
||
record = json.load(fh)
|
||
except (OSError, ValueError):
|
||
continue
|
||
if isinstance(record, dict) \
|
||
and record.get("state") in RESIDENT_ACTIVE_STATES:
|
||
tenant = record.get("tenant_id") \
|
||
or entry[:-len(".json")]
|
||
out.append((tenant, record))
|
||
return out
|
||
|
||
def _resident_count(self):
|
||
"""当前常驻有效租约数(占槽数)。"""
|
||
return len(self._resident_records())
|
||
|
||
def resident_slot_cap(self) -> int:
|
||
"""每宿主常驻上限(已定值,§4.1):VM 槽位的 50%
|
||
(CHV_MAX_VMS × 50%,向下取整)。"""
|
||
return max(0, int(self.max_vms * RESIDENT_MAX_FRACTION))
|
||
|
||
def promote_to_resident(self, tenant_id: str,
|
||
tier: str | None = None) -> dict:
|
||
"""新建/恢复常驻租约(§4.1/§4.4 执行层语义):
|
||
- tier 校验 fail-closed:tier 须显式为 Paid/Heavy;
|
||
缺失/不符即 ChvFailure tier_denied(平台层路由时
|
||
已拦一次,这里是执行层 preflight 第二道)。
|
||
- 常驻上限:已常驻数达上限时不抢占已有常驻,返回
|
||
{promoted: False, queued: True}(新常驻申请排队)。
|
||
- 成功:登记 tier/state=resident-active/last_active_at/
|
||
lease_expires_at,审计 vm_resident_promoted。
|
||
已是常驻有效态时幂等续期(不重复占槽)。
|
||
返回 {promoted, queued, vm, tier, state, reason?}。"""
|
||
if not tenant_id:
|
||
raise ValueError("tenant_id 不能为空")
|
||
if tier is None:
|
||
record = read_persistent_vm(self.state_dir, tenant_id)
|
||
tier = record.get("tier") if record else None
|
||
if tier not in RESIDENT_TIERS:
|
||
raise ChvFailure(
|
||
"tier_denied",
|
||
"常驻资格须为 Paid/Heavy 档位:租户 %s tier=%s,"
|
||
"无常驻资格(Free 无论多高频都没有;无 tier 行"
|
||
"即无资格,走池)" % (tenant_id, tier))
|
||
now = time.time()
|
||
record = read_persistent_vm(self.state_dir, tenant_id)
|
||
if record is not None \
|
||
and record.get("state") in RESIDENT_ACTIVE_STATES:
|
||
self.touch_resident_activity(tenant_id, now=now)
|
||
return {"promoted": True, "queued": False,
|
||
"vm": record.get("vm"), "tier": tier,
|
||
"state": record.get("state")}
|
||
if self._resident_count() >= self.resident_slot_cap():
|
||
return {"promoted": False, "queued": True,
|
||
"reason": "resident_capacity_exceeded",
|
||
"tier": tier}
|
||
suspend, demote = self._resident_thresholds()
|
||
record = self._write_resident_record(tenant_id, {
|
||
"tier": tier, "state": "resident-active",
|
||
"last_active_at": now,
|
||
"lease_expires_at": now + suspend + demote}, now=now)
|
||
self._emit("vm_resident_promoted",
|
||
{"id": "persist-%s" % tenant_id,
|
||
"project_id": tenant_id},
|
||
"host_id=%s tenant=%s vm=%s tier=%s"
|
||
% (self._host_id(), tenant_id,
|
||
record.get("vm"), tier))
|
||
return {"promoted": True, "queued": False,
|
||
"vm": record.get("vm"), "tier": tier,
|
||
"state": "resident-active"}
|
||
|
||
def _suspend_resident_vm(self, tenant, record):
|
||
"""租约挂起的 VM 动作(sweep 的一步):VM 已是 Paused
|
||
(任务间隙 suspend_after_task 已暂停)即直接转态,
|
||
不调 pause;Running 即 pause_vm;状态未知且 socket
|
||
已不在时视为 VM 已不在、无须 pause,直接转态;其余
|
||
未知时试调 pause_vm,失败即本租户中止(返回 False,
|
||
租约状态不动)。"""
|
||
vm = record.get("vm") or self.persistent_vm_name(tenant)
|
||
state = self._vm_info_state({"id": vm})
|
||
if state == "Paused":
|
||
return True
|
||
if state is None and not os.path.exists(
|
||
self.api_socket({"id": vm})):
|
||
return True
|
||
try:
|
||
self.pause_vm({"id": vm})
|
||
return True
|
||
except Exception as exc:
|
||
self._emit("vm_suspend_failed",
|
||
{"id": vm, "project_id": tenant},
|
||
"vm=%s 租约挂起 pause 失败:%s" % (vm, exc),
|
||
"vm_suspend_failed")
|
||
return False
|
||
|
||
def sweep_resident_leases(self, now: float | None = None) -> dict:
|
||
"""租约计时器一轮扫描(设计 §4.2,worker 心跳轮询驱动):
|
||
- resident-active 且闲置 >= 挂起阈值 → pause_vm(已
|
||
Paused 的直接转态)→ state=resident-paused,审计
|
||
vm_resident_suspended(带 host_id + tenant_id)。
|
||
- resident-paused 且闲置 >= 挂起阈值 + 降级阈值(即
|
||
累计 24.5 小时无活动)→ demote_resident,审计
|
||
vm_resident_demoted。
|
||
单个租户失败(pause 失败/降级中止)不中断整轮,记入
|
||
aborted。返回 {suspended, demoted, aborted}(租户 id
|
||
列表)。"""
|
||
now = time.time() if now is None else now
|
||
suspend_s, demote_s = self._resident_thresholds()
|
||
out = {"suspended": [], "demoted": [], "aborted": []}
|
||
for tenant, record in self._resident_records():
|
||
try:
|
||
last = record.get("last_active_at") \
|
||
or record.get("updated_at") or now
|
||
idle = now - float(last)
|
||
state = record.get("state")
|
||
if state == "resident-active" and idle >= suspend_s:
|
||
if self._suspend_resident_vm(tenant, record):
|
||
self._write_resident_record(
|
||
tenant, {"state": "resident-paused"},
|
||
now=now)
|
||
self._emit(
|
||
"vm_resident_suspended",
|
||
{"id": "persist-%s" % tenant,
|
||
"project_id": tenant},
|
||
"host_id=%s tenant=%s vm=%s idle_seconds=%.0f"
|
||
% (self._host_id(), tenant,
|
||
record.get("vm"), idle))
|
||
out["suspended"].append(tenant)
|
||
else:
|
||
out["aborted"].append(tenant)
|
||
elif state == "resident-paused" \
|
||
and idle >= suspend_s + demote_s:
|
||
result = self.demote_resident(tenant, now=now)
|
||
if result.get("demoted"):
|
||
out["demoted"].append(tenant)
|
||
else:
|
||
out["aborted"].append(tenant)
|
||
except Exception as exc:
|
||
_LOGGER.error(
|
||
"chv_resident_sweep_failed tenant=%s error=%s",
|
||
tenant, exc)
|
||
out["aborted"].append(tenant)
|
||
return out
|
||
|
||
def _sync_resident_before_demote(self, tenant_id, vm):
|
||
"""降级前的同步步(设计 §4.3 第 1 步):
|
||
权威同步点在任务结束时(§3.3:任务完成即 Gitea push +
|
||
记忆/技能落盘,之后 VM 才暂停),降级时盘上已是同步后
|
||
状态;因此默认本步直接通过。若部署配了
|
||
resident_sync_hook(例如先 resume 再跑一次 chv-sync
|
||
push 的回调),则以钩子返回值为准:False 即同步失败,
|
||
降级中止。demote_resident 的 sync_fn 参数优先于钩子。"""
|
||
hook = getattr(self, "resident_sync_hook", None)
|
||
if hook is None:
|
||
return True
|
||
return bool(hook(tenant_id, vm))
|
||
|
||
# 降级流程细则(设计 §4.3,注释放方法外、不计入 50 行上限):
|
||
# 同步 → 快照 → 清 seed → 卸盘 → 销毁 → 登记 demoted。
|
||
# - 同步失败即降级中止、VM 保留、记审计
|
||
# (vm_resident_demote_aborted),绝不丢改动后降级;
|
||
# 快照失败同样中止。
|
||
# - 清 seed:stop_vm 删 seed 目录 + 宿主私钥 + 已投递密钥
|
||
# 记录;seed.iso 随 keep_disk=True 保留——它是快照
|
||
# config.json 引用的 restore 输入(hibernate 的 V-7/D2
|
||
# 同款),删了快照即不可恢复;“清 seed”指清 live seed,
|
||
# 不指毁快照输入。
|
||
# - 卸盘/销毁:stop_vm(keep_disk=True) 关 VM;任务盘与
|
||
# seed.iso 留作快照输入,数据盘 data.raw 是用户数据、
|
||
# 永不随降级删(只走显式回收)。
|
||
# - 登记:state=demoted,保留 last_active_at 与快照 URL
|
||
# (供下次唤醒时从 Gitea 重建或快照恢复),审计
|
||
# vm_resident_demoted(带 host_id + tenant_id)。
|
||
# - VM 已不在时跳过快照(无物可拍),只做登记。
|
||
def demote_resident(self, tenant_id: str | None,
|
||
snapshot_url: str | None = None,
|
||
sync_fn: Callable[..., bool] | None = None,
|
||
now: float | None = None) -> dict:
|
||
"""降级流程(设计 §4.3):同步 → 快照 → 清 seed →
|
||
卸盘 → 销毁 → 登记 demoted。同步/快照失败即中止、VM
|
||
保留;仅 resident-paused/hibernated 可降级,其余返回
|
||
{demoted: False, reason: not_demotable}。
|
||
返回 {demoted, vm, snapshot, reason?}。"""
|
||
now = time.time() if now is None else now
|
||
record = read_persistent_vm(self.state_dir, tenant_id)
|
||
if record is None or record.get("state") not in (
|
||
"resident-paused", "hibernated"):
|
||
return {"demoted": False, "reason": "not_demotable",
|
||
"state": record.get("state") if record else None}
|
||
vm = record.get("vm") or self.persistent_vm_name(tenant_id)
|
||
task = {"id": vm, "project_id": tenant_id}
|
||
if not self._demote_sync_ok(task, tenant_id, vm, sync_fn):
|
||
return {"demoted": False, "reason": "sync_failed",
|
||
"vm": vm}
|
||
snapshot, failed = self._demote_snapshot(
|
||
task, vm, snapshot_url)
|
||
if failed:
|
||
return {"demoted": False, "reason": "snapshot_failed",
|
||
"vm": vm}
|
||
try:
|
||
self.stop_vm(task, keep_disk=True)
|
||
except Exception:
|
||
pass
|
||
updates = {"state": "demoted"}
|
||
if snapshot:
|
||
updates["snapshot_url"] = snapshot
|
||
self._write_resident_record(tenant_id, updates, now=now)
|
||
self._emit("vm_resident_demoted", task,
|
||
"host_id=%s tenant=%s vm=%s snapshot=%s"
|
||
% (self._host_id(), tenant_id, vm, snapshot))
|
||
return {"demoted": True, "vm": vm, "snapshot": snapshot}
|
||
|
||
def _demote_sync_ok(self, task, tenant_id, vm, sync_fn):
|
||
"""降级同步步(demote_resident 的一步):失败记
|
||
vm_resident_demote_aborted 审计并返回 False(VM 保留,
|
||
绝不丢改动后降级);成功返回 True。"""
|
||
sync_callable = sync_fn if sync_fn is not None \
|
||
else self._sync_resident_before_demote
|
||
try:
|
||
synced = bool(sync_callable(tenant_id, vm))
|
||
except Exception:
|
||
synced = False
|
||
if not synced:
|
||
self._emit("vm_resident_demote_aborted", task,
|
||
"host_id=%s tenant=%s vm=%s 同步失败,降级中止、"
|
||
"VM 保留" % (self._host_id(), tenant_id, vm),
|
||
"resident_demote_sync_failed")
|
||
return synced
|
||
|
||
def _demote_snapshot(self, task, vm, snapshot_url):
|
||
"""降级快照步(demote_resident 的一步):VM 活着即
|
||
snapshot_vm(失败记中止审计);VM 已不在时跳过快照
|
||
(无物可拍)。返回 (snapshot_url_or_None, failed)。"""
|
||
if not (os.path.exists(self.api_socket({"id": vm}))
|
||
or self._vm_info_state({"id": vm}) is not None):
|
||
return None, False
|
||
if snapshot_url is None:
|
||
snapshot_url = "file://%s" % os.path.join(
|
||
self.state_dir, vm, "snapshot-demote")
|
||
try:
|
||
self.snapshot_vm(task, snapshot_url)
|
||
except Exception as exc:
|
||
self._emit("vm_resident_demote_aborted", task,
|
||
"host_id=%s tenant=%s vm=%s 快照失败,降级"
|
||
"中止、VM 保留:%s"
|
||
% (self._host_id(), task.get("project_id"),
|
||
vm, exc),
|
||
"resident_demote_snapshot_failed")
|
||
return None, True
|
||
return snapshot_url, False
|
||
|
||
# -- 排队(设计 §3.4)--------------------------------------------
|
||
def queue_depth(self) -> int:
|
||
"""当前排队等待的任务数(L1 扩缩输入;等待期间计数,
|
||
退出时减回)。"""
|
||
with self._queue_lock:
|
||
return self._queue_waiters
|
||
|
||
def _queue_attempts(self):
|
||
"""排队轮次(次数制,与 wait_api_ready 同款口径:fake
|
||
sleeper 不推进墙钟时墙钟循环会空转,次数循环在单测里
|
||
是确定性的;真实 sleeper 下总时长约等于超时)。
|
||
超时经 CHV_QUEUE_TIMEOUT_SECONDS 注入(默认 300 秒),
|
||
轮询间隔经 CHV_QUEUE_POLL_SECONDS(默认 5 秒)。"""
|
||
timeout = self._hybrid_env_float(
|
||
"CHV_QUEUE_TIMEOUT_SECONDS", QUEUE_TIMEOUT_SECONDS)
|
||
poll = self._hybrid_env_float(
|
||
"CHV_QUEUE_POLL_SECONDS", QUEUE_POLL_SECONDS)
|
||
return max(1, int(timeout / poll)), poll
|
||
|
||
def _wait_for_capacity(self, task, exclude=None):
|
||
"""排队等宿主容量(设计 §3.4):容量有空即直接过;
|
||
满时按到达顺序等(本方法是调用方线程内顺序等待,无
|
||
优先队列、不插队)、不抢占(只等同一容量门放行,不踢
|
||
别的 VM)、不升/降档位(只等槽位,不改路由)。
|
||
超时(默认 300 秒)后 ChvFailure host_capacity_exceeded
|
||
明确拒绝,不无限积压。等待期间 queue_depth() +1。"""
|
||
try:
|
||
self._check_capacity(exclude=exclude)
|
||
return
|
||
except ChvFailure:
|
||
pass
|
||
attempts, poll = self._queue_attempts()
|
||
with self._queue_lock:
|
||
self._queue_waiters += 1
|
||
try:
|
||
for _ in range(attempts):
|
||
self.sleeper(poll)
|
||
try:
|
||
self._check_capacity(exclude=exclude)
|
||
return
|
||
except ChvFailure:
|
||
continue
|
||
raise ChvFailure(
|
||
"host_capacity_exceeded",
|
||
"排队超时:%d 秒内无可用宿主(容量仍满),明确拒绝,"
|
||
"不无限积压" % int(attempts * poll))
|
||
finally:
|
||
with self._queue_lock:
|
||
self._queue_waiters -= 1
|
||
|
||
def _wait_for_resident_slot(self, tenant_id):
|
||
"""常驻租户的任务排队时仍等常驻槽位(设计 §3.4):
|
||
只等常驻计数 < 上限,不挪去池里跑(挪了等于改隔离
|
||
与计费口径),不抢占已有常驻。超时同样
|
||
host_capacity_exceeded。"""
|
||
if self._resident_count() < self.resident_slot_cap():
|
||
return
|
||
attempts, poll = self._queue_attempts()
|
||
with self._queue_lock:
|
||
self._queue_waiters += 1
|
||
try:
|
||
for _ in range(attempts):
|
||
self.sleeper(poll)
|
||
if self._resident_count() < self.resident_slot_cap():
|
||
return
|
||
raise ChvFailure(
|
||
"host_capacity_exceeded",
|
||
"排队超时:常驻槽位仍满(%d/%d),明确拒绝"
|
||
% (self._resident_count(),
|
||
self.resident_slot_cap()))
|
||
finally:
|
||
with self._queue_lock:
|
||
self._queue_waiters -= 1
|
||
|
||
# -- 升级提示点(设计 §5)----------------------------------------
|
||
def _pool_task_file(self, tenant_id):
|
||
"""池任务时间戳文件:state_dir/pool-tasks/<tenant>.json
|
||
(执行层任务登记,不是平台层账本;只存时间戳与上次
|
||
提示时间,不存任务内容、不存密钥)。"""
|
||
safe = re.sub(r"[^A-Za-z0-9_.-]", "-",
|
||
str(tenant_id or "default"))
|
||
return os.path.join(self.state_dir, "pool-tasks",
|
||
safe + ".json")
|
||
|
||
def _load_pool_tasks(self, tenant_id):
|
||
"""读池任务登记:{tasks: [ts...], last_hint_at: ts|None};
|
||
无文件/损坏即空登记(不猜)。"""
|
||
try:
|
||
with open(self._pool_task_file(tenant_id), "r") as fh:
|
||
data = json.load(fh)
|
||
except (OSError, ValueError):
|
||
return {"tasks": [], "last_hint_at": None}
|
||
if not isinstance(data, dict):
|
||
return {"tasks": [], "last_hint_at": None}
|
||
tasks = data.get("tasks") or []
|
||
if not isinstance(tasks, list):
|
||
tasks = []
|
||
return {"tasks": [t for t in tasks
|
||
if isinstance(t, (int, float))],
|
||
"last_hint_at": data.get("last_hint_at")}
|
||
|
||
def _save_pool_tasks(self, tenant_id, data):
|
||
"""写池任务登记(atomic 写,与持久登记同款)。"""
|
||
path = self._pool_task_file(tenant_id)
|
||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||
atomic_write_text(path, json.dumps(data))
|
||
|
||
def _has_7day_streak(self, tasks, now):
|
||
"""连续 7 天每天 ≥3 次(UTC 日桶;含今天往前 7 天)。"""
|
||
today = int(now // 86400)
|
||
days = {}
|
||
for ts in tasks:
|
||
day = int(ts // 86400)
|
||
days[day] = days.get(day, 0) + 1
|
||
for offset in range(UPGRADE_HINT_STREAK_DAYS):
|
||
if days.get(today - offset, 0) < UPGRADE_HINT_MIN_PER_DAY:
|
||
return False
|
||
return True
|
||
|
||
def _upgrade_hint_metric(self, tasks, now):
|
||
"""本次提示的频率度量(进审计 detail,供平台层消费)。"""
|
||
if len(tasks) >= UPGRADE_HINT_MIN_TASKS:
|
||
return "30d_tasks=%d" % len(tasks)
|
||
return "7d_streak_%d_per_day" % UPGRADE_HINT_MIN_PER_DAY
|
||
|
||
def _upgrade_hint_due(self, tasks, last_hint_at, now):
|
||
"""是否该记升级提示:先去重(一周内同一租户最多一次),
|
||
再判高频定义(30 天 ≥100 次,或连续 7 天每天 ≥3 次)。"""
|
||
if last_hint_at is not None \
|
||
and now - float(last_hint_at) \
|
||
< UPGRADE_HINT_DEDUP_DAYS * 86400:
|
||
return False
|
||
if len(tasks) >= UPGRADE_HINT_MIN_TASKS:
|
||
return True
|
||
return self._has_7day_streak(tasks, now)
|
||
|
||
def record_pool_task(self, tenant_id: str | None,
|
||
tier: str | None = None,
|
||
now: float | None = None) -> dict:
|
||
"""记一次池任务并在达高频定义时发升级提示点(设计 §5):
|
||
只对 Free 租户记(Paid/Heavy 已有常驻资格,无须提示;
|
||
tier 未知时按 Free 记——提示只是事件,平台层消费时
|
||
再核档位);频率只用于产生提示点,绝不用于改路由
|
||
(本方法不返回路由决策、不改任何登记的状态字段)。
|
||
频率度量从本文件里的任务时间戳算,不另建账本。
|
||
返回 {recorded, hinted, metric}。"""
|
||
now = time.time() if now is None else now
|
||
if tier is None:
|
||
record = read_persistent_vm(self.state_dir, tenant_id) \
|
||
if tenant_id else None
|
||
tier = record.get("tier") if record else None
|
||
if tier is not None and tier != "Free":
|
||
return {"recorded": False, "hinted": False,
|
||
"reason": "not_free_tier"}
|
||
data = self._load_pool_tasks(tenant_id)
|
||
cutoff = now - UPGRADE_HINT_WINDOW_DAYS * 86400
|
||
tasks = [t for t in data["tasks"] if t >= cutoff]
|
||
tasks.append(now)
|
||
hinted = False
|
||
metric = None
|
||
if self._upgrade_hint_due(tasks, data.get("last_hint_at"),
|
||
now):
|
||
metric = self._upgrade_hint_metric(tasks, now)
|
||
data["last_hint_at"] = now
|
||
self._emit(
|
||
"resident_upgrade_hint",
|
||
{"id": "pool-%s" % tenant_id,
|
||
"project_id": tenant_id},
|
||
"host_id=%s tenant=%s tier=Free metric=%s "
|
||
"window_days=%d"
|
||
% (self._host_id(), tenant_id, metric,
|
||
UPGRADE_HINT_WINDOW_DAYS))
|
||
hinted = True
|
||
data["tasks"] = tasks
|
||
self._save_pool_tasks(tenant_id, data)
|
||
return {"recorded": True, "hinted": hinted, "metric": metric}
|
||
|
||
# -- 网关分流(设计 §6)------------------------------------------
|
||
def gateway_allowed_for_task(self, task: dict,
|
||
model_provider: str | None = None) -> bool:
|
||
"""网关分流(设计 §6;worker 侧守卫自 P1-1 起已放行 CHV,
|
||
分流 enforcement 落在本执行器——改之前网关不上 CHV 的
|
||
前置依赖即由此落地):
|
||
- 非网关任务(model_provider 不是网关 provider)→
|
||
True(不拦;本方法不作常驻判定用)。
|
||
- 网关任务:常驻 VM 可接(登记为常驻有效态且 tier
|
||
校验通过)、池 VM 不接。常驻有效态含 hibernated
|
||
(可 restore + resume 续跑);demoted、无登记、
|
||
legacy 态一律按池算。"""
|
||
provider = model_provider
|
||
if provider is None and isinstance(task, dict):
|
||
provider = task.get("_gateway_provider")
|
||
if provider not in GATEWAY_PROVIDERS:
|
||
return True
|
||
tenant = self._tenant_id(task) if isinstance(task, dict) \
|
||
else None
|
||
if not tenant:
|
||
return False
|
||
record = read_persistent_vm(self.state_dir, tenant)
|
||
if record is None \
|
||
or record.get("state") not in RESIDENT_ACTIVE_STATES:
|
||
return False
|
||
return self.resident_tier_eligible(tenant,
|
||
record.get("tier"))
|
||
|
||
def _check_gateway_vm_class(self, task, model_provider=None):
|
||
"""网关任务的 VM 类门:池 VM 以 gateway_pool_denied
|
||
明确拒绝(ChvFailure,worker 现有 except 路径照常
|
||
写回 failed);常驻 VM 放行。"""
|
||
if not self.gateway_allowed_for_task(task, model_provider):
|
||
tenant = self._tenant_id(task) \
|
||
if isinstance(task, dict) else None
|
||
raise ChvFailure(
|
||
"gateway_pool_denied",
|
||
"网关任务只接常驻 VM:租户 %s 当前走池(无常驻"
|
||
"租约或档位无常驻资格),拒绝上 CHV;池 VM 用完"
|
||
"即销毁,长连接在上面活不过一个任务周期"
|
||
% tenant)
|
||
|
||
def _hybrid_pool_preamble(self, task, model_provider=None):
|
||
"""B 轨池路径前置(_run_with_vm 的开头,拆出只为满足
|
||
50 行函数上限):任务到达即活动(续期租约)、网关 VM
|
||
类门(常驻可接、池不接)、排队等容量(超时
|
||
host_capacity_exceeded)、池任务记一次(Free 高频
|
||
提示点原料)。续期与记数失败不挡任务;网关门与排队
|
||
拒绝直接抛。"""
|
||
try:
|
||
_tenant = self._tenant_id(task)
|
||
if _tenant:
|
||
self.touch_resident_activity(_tenant)
|
||
except Exception:
|
||
pass
|
||
self._check_gateway_vm_class(task, model_provider)
|
||
self._wait_for_capacity(task, exclude=self.vm_name(task))
|
||
try:
|
||
_tenant = self._tenant_id(task)
|
||
if _tenant:
|
||
self.record_pool_task(
|
||
_tenant,
|
||
task.get("tier") if isinstance(task, dict) else None)
|
||
except Exception:
|
||
pass
|
||
|
||
def _hybrid_persistent_preamble(self, task, model_provider=None):
|
||
"""B 轨持久路径前置(_run_with_persistent_vm 的开头,
|
||
拆出只为满足 50 行函数上限):任务到达即续期租约
|
||
(仅常驻登记写,失败不挡任务);网关任务先过 VM 类门
|
||
(常驻可接、池不接)。"""
|
||
try:
|
||
_tenant = self._tenant_id(task)
|
||
if _tenant:
|
||
self.touch_resident_activity(_tenant)
|
||
except Exception:
|
||
pass
|
||
self._check_gateway_vm_class(task, model_provider)
|
||
|
||
def _wait_for_persistent_slot(self, task, tenant, vm):
|
||
"""持久路径冷启需新占槽位时先排队(_run_with_persistent_vm
|
||
的一段,拆出只为满足 50 行函数上限):常驻租户等常驻
|
||
槽位(不挪去池里跑),其余等通用宿主容量;超时
|
||
host_capacity_exceeded 明确拒绝。"""
|
||
_record = read_persistent_vm(self.state_dir, tenant) \
|
||
if tenant else None
|
||
if _record is not None and _record.get("state") \
|
||
in RESIDENT_ACTIVE_STATES:
|
||
self._wait_for_resident_slot(tenant)
|
||
else:
|
||
self._wait_for_capacity(task, exclude=vm)
|
||
|
||
def _run_guest_on_vm(self, task, vm, inner_args, goal,
|
||
extra_env=None, **kwargs):
|
||
"""在指定 VM 上跑客内命令并做中途死亡分类(
|
||
_run_with_persistent_vm 的执行段):登记进程已死即
|
||
interrupted(断点可续跑),VM 活着时的异常/失败原
|
||
样处理,不误判。返回结果 dict(未附 vm_mode,调用
|
||
方贴)。"""
|
||
try:
|
||
if goal:
|
||
result = self._run_guest_goal(
|
||
task, inner_args, extra_env=extra_env,
|
||
vm_name=vm, **kwargs)
|
||
else:
|
||
result = self._run_guest(
|
||
task, inner_args, extra_env=extra_env,
|
||
vm_name=vm, **kwargs)
|
||
except Exception as exc:
|
||
if self._vm_proc_dead({"id": vm}):
|
||
return self._vm_death_result(
|
||
task, goal, str(exc)[:500])
|
||
raise
|
||
if not goal and result.get("status") == "failed" \
|
||
and self._vm_proc_dead({"id": vm}):
|
||
result = dict(result)
|
||
result["status"] = "interrupted"
|
||
result["vm_dead"] = True
|
||
result["interrupted_reason"] = (
|
||
"VM 中途死亡,任务未完成、断点可续跑:"
|
||
"客内命令已 failed 且登记的 VMM 进程已死")
|
||
return result
|
||
|
||
def _run_with_persistent_vm(self, task, inner_args, goal=False,
|
||
extra_env=None, **kwargs):
|
||
"""持久 VM 任务生命周期(阶段 1,与 _run_with_vm 对
|
||
照):acquire(resumed)或冷启持久 VM → 跑客内命令
|
||
→ finally 暂停回池(suspend_after_task),不关机、
|
||
不删盘。warm_pool_size==0 时本方法不被调(调用方
|
||
直接走 _run_with_vm)。冷启路径:wake 持久 VM
|
||
(合成任务 persist-<租户>),任务本身的 host 任务
|
||
名仍按真实任务 id 派生(_run_guest 的 vm_name 参数
|
||
分离 VM 身份与任务身份)。中途死亡分类见
|
||
_run_guest_on_vm(登记进程已死即 interrupted)。"""
|
||
tenant = self._tenant_id(task)
|
||
vm_task = self._persistent_task(tenant)
|
||
self._hybrid_persistent_preamble(
|
||
task, kwargs.get("model_provider"))
|
||
acquired = self.acquire_for_task(task, extra_env=extra_env)
|
||
vm = acquired.get("vm") or self.vm_name(vm_task)
|
||
mode = acquired["mode"]
|
||
resume_seconds = acquired.get("resume_seconds")
|
||
if mode == "cold":
|
||
self._wait_for_persistent_slot(vm_task, tenant, vm)
|
||
self.wake_vm(vm_task, extra_env=extra_env)
|
||
try:
|
||
result = self._run_guest_on_vm(
|
||
task, vm, inner_args, goal,
|
||
extra_env=extra_env, **kwargs)
|
||
if isinstance(result, dict):
|
||
result = dict(result)
|
||
result["vm_mode"] = mode
|
||
if resume_seconds is not None:
|
||
result["resume_seconds"] = resume_seconds
|
||
return result
|
||
finally:
|
||
try:
|
||
if self._vm_proc_dead({"id": vm}):
|
||
# VM 已死:暂停无意义,清登记,免得下次
|
||
# acquire 把死 VM 当可用预热机。
|
||
if tenant:
|
||
clear_persistent_vm(self.state_dir, tenant)
|
||
else:
|
||
self.suspend_after_task({"id": vm},
|
||
tenant_id=tenant)
|
||
except Exception:
|
||
pass
|
||
|
||
def dispatch_task_persistent(self, task: dict,
|
||
extra_env: dict | None = None,
|
||
model_provider: str | None = None) -> dict:
|
||
"""持久 VM 任务入口(阶段 1):warm_pool_size>=1 时
|
||
走 _run_with_persistent_vm(resume/冷启持久 VM →
|
||
跑 → 暂停回池);==0 时退化为 dispatch_task(冷启
|
||
→ 跑 → 关机),与旧行为一致。结果 dict 附 vm_mode
|
||
(resumed/cold)与 resume_seconds(resumed 时)。"""
|
||
if not isinstance(task, dict) or task.get("id") is None:
|
||
raise ValueError("任务缺少 id")
|
||
if not task.get("prompt"):
|
||
raise ValueError("任务缺少 prompt")
|
||
if int(getattr(self, "warm_pool_size", 0)) <= 0:
|
||
return self.dispatch_task(
|
||
task, extra_env=extra_env,
|
||
model_provider=model_provider)
|
||
args = ["dispatch", "--name", self._host_name(task),
|
||
"--task", task["prompt"]]
|
||
repo = task.get("repo")
|
||
if repo:
|
||
args += ["--repo", repo]
|
||
return self._run_with_persistent_vm(
|
||
task, args, extra_env=extra_env, model=task.get("model"),
|
||
effort=task.get("effort"), approval=task.get("approval"),
|
||
model_provider=model_provider,
|
||
sandbox=("read-only" if task.get("plan") else None))
|
||
|
||
def recycle_persistent_vm(self, task_or_name: dict | str,
|
||
tenant_id: str | None = None) -> dict:
|
||
"""显式回收持久 VM(唯一删用户盘的入口):先 shutdown
|
||
(stop_vm,keep_disk=False),再删 overlay 与数据盘、
|
||
清 tenant 标记与持久登记。返回 {vm, state: recycled,
|
||
deleted_disks}。与 recycle_vm 的区别:后者只关 VM
|
||
不删持久盘(持久盘须显式回收才删,用户数据不静默丢)。"""
|
||
vm = self.vm_name(task_or_name)
|
||
tenant = tenant_id or self._tenant_id(
|
||
task_or_name if isinstance(task_or_name, dict)
|
||
else {"id": task_or_name})
|
||
self.shutdown_vm(task_or_name)
|
||
deleted = []
|
||
if delete_task_disk(self.state_dir, vm):
|
||
deleted.append("task")
|
||
if delete_data_disk(self.state_dir, vm):
|
||
deleted.append("data")
|
||
_tenant_path = os.path.join(self.state_dir, vm,
|
||
"tenant.json")
|
||
if os.path.exists(_tenant_path):
|
||
os.remove(_tenant_path)
|
||
if tenant:
|
||
clear_persistent_vm(self.state_dir, tenant)
|
||
self._emit("vm_persistent_recycled",
|
||
task_or_name if isinstance(task_or_name, dict)
|
||
else {"id": task_or_name},
|
||
"vm=%s deleted_disks=%s" % (vm, deleted))
|
||
return {"vm": vm, "state": "recycled",
|
||
"deleted_disks": deleted}
|
||
|
||
def snapshot_vm(self, task_or_name: dict | str, snapshot_url: str) -> str:
|
||
"""给(已暂停的)VM 做快照:ch-remote snapshot <裸 URL>。
|
||
注意:snapshot 的参数是裸 URL,不是 source_url=...
|
||
(那是 restore 的键);传 source_url=... 会被服务端
|
||
当成 URL 本身,报 Could not extract path from URL
|
||
(2026-11-07 WSL 真机 V-3 实测)。snapshot_url 为空
|
||
即 ValueError;成功审计 vm_snapshot。快照本身不改变
|
||
VM 状态(仍 paused,需 resume 才醒)。
|
||
v54 起快照产物是目录(含 config.json/state.json/
|
||
memory-ranges),不是文件:file:// 目标须是已生成
|
||
的非空目录,文件/空目录/缺失一律报
|
||
snapshot_not_persisted(旧 isfile 校验会把真快照
|
||
误判为未落盘)。
|
||
feat-chv-secfix(L2/M3):ch-remote 之前先过 URL
|
||
白名单(file:// 须在 state_dir/CHV_SNAPSHOT_DIR 内,
|
||
远端须显式放行)与数量/总量/保留期上限;成功后登
|
||
记并在总量超帽时按最旧优先剪枝(删动作审计)。
|
||
feat-chv-v3-repair(V-7/D1):file:// 目标目录在
|
||
配额预检后、ch-remote 前建好(见 _ensure_snapshot_dir)。"""
|
||
if not snapshot_url:
|
||
raise ValueError("snapshot_url 不能为空")
|
||
snapshot_path = validate_snapshot_url(
|
||
snapshot_url, self.state_dir,
|
||
snapshot_dir=getattr(self, "snapshot_dir", None),
|
||
allow_remote=getattr(self, "allow_remote_snapshot", False))
|
||
vm_name = self.vm_name(task_or_name)
|
||
check_snapshot_limits(
|
||
self.state_dir, vm_name,
|
||
getattr(self, "snapshot_max_count", 10),
|
||
getattr(self, "snapshot_max_total_gb", 20.0),
|
||
getattr(self, "snapshot_retention_days", 30.0))
|
||
# feat-chv-v3-repair(V-7/D1):建目录放在配额预检
|
||
# 之后——配额拒绝先于任何文件改动,且目标是文件时
|
||
# 也不抢占配额错误的 kind;只在 snapshot_vm 这一处
|
||
# 建,hibernate_vm 经此调用,不重不漏。
|
||
_ensure_snapshot_dir(snapshot_url, snapshot_path)
|
||
self._ch_remote(task_or_name, "snapshot", snapshot_url)
|
||
# feat-chv-fix(P1-7)/feat-chv-v3:只查 returncode
|
||
# 不够,见 _assert_snapshot_dir_persisted——file://
|
||
# 快照须验证目标目录已生成且非空,否则报
|
||
# snapshot_not_persisted,调用方(hibernate_vm)据此
|
||
# 不 shutdown、不销毁原 VM;非 file:// 目标无法从宿
|
||
# 主验证落盘,仍只认 returncode,但文档明写该边界。
|
||
_assert_snapshot_dir_persisted(snapshot_url)
|
||
return self._record_snapshot_and_emit(
|
||
task_or_name, vm_name, snapshot_url)
|
||
|
||
def _record_snapshot_and_emit(self, task_or_name, vm_name,
|
||
snapshot_url):
|
||
"""快照成功后登记 + 总量超帽时最旧优先剪枝(删动作
|
||
审计)+ vm_snapshot 审计,返回 snapshot_url。
|
||
feat-chv-opt-landing:登记条目带 tenant 字段(归属
|
||
标记,见 disk.record_snapshot);审计 detail 同样
|
||
带 tenant,便于事后核对谁的快照。"""
|
||
task = task_or_name if isinstance(task_or_name, dict) \
|
||
else {"id": task_or_name}
|
||
tenant = self._tenant_id(task)
|
||
_entries, _removed = record_snapshot(
|
||
self.state_dir, vm_name, snapshot_url,
|
||
getattr(self, "snapshot_max_total_gb", 20.0),
|
||
tenant_id=tenant)
|
||
for _old in _removed:
|
||
self._emit("vm_snapshot_pruned", task,
|
||
"vm=%s pruned snapshot=%s(总量超帽,最旧优先)"
|
||
% (vm_name, _old.get("url")))
|
||
self._emit("vm_snapshot", task,
|
||
"vm=%s snapshot=%s tenant=%s"
|
||
% (self.vm_name(task_or_name), snapshot_url, tenant))
|
||
return snapshot_url
|
||
|
||
def _check_snapshot_tenant(self, task, snapshot_url) -> None:
|
||
"""restore 的归属校验(feat-chv-opt-landing,阶段 1
|
||
必须补的代码项):按 URL 在全部 VM 的快照登记里找
|
||
条目;条目带 tenant 且与请求方 tenant 不一致时,
|
||
直接拒绝(ChvFailure snapshot_tenant_mismatch)并
|
||
发 vm_restore_tenant_denied 审计——A 的快照绝不能
|
||
恢复到 B 名下。条目无 tenant 是阶段 1 之前的 legacy
|
||
登记:放行但发 vm_restore_tenant_unmarked 审计警告
|
||
(单租户灰度下的旧快照,不静默当作已验);条目根本
|
||
未登记:同样放行 + 警告(未登记快照无从核对归属,
|
||
登记才是归属的唯一账本——生产须要求快照必登记,
|
||
见报告残留)。请求方无 tenant(None)而条目有
|
||
tenant 时同样拒绝(无法证明归属即 fail-closed)。"""
|
||
_owner_vm, entry = find_snapshot_entry(
|
||
self.state_dir, snapshot_url)
|
||
if entry is None:
|
||
self._emit("vm_restore_tenant_unmarked", task,
|
||
"vm=%s snapshot=%s 未在登记中找到,归属"
|
||
"无法核对(未登记快照,放行并警告)"
|
||
% (self.vm_name(task), snapshot_url),
|
||
"snapshot_tenant_unmarked")
|
||
return
|
||
marked = entry.get("tenant")
|
||
if marked is None:
|
||
self._emit("vm_restore_tenant_unmarked", task,
|
||
"vm=%s snapshot=%s 登记无 tenant 字段"
|
||
"(legacy 旧快照,放行并警告)"
|
||
% (self.vm_name(task), snapshot_url),
|
||
"snapshot_tenant_unmarked")
|
||
return
|
||
requester = self._tenant_id(task)
|
||
if requester != marked:
|
||
self._emit("vm_restore_tenant_denied", task,
|
||
"vm=%s snapshot=%s 归属 tenant=%s,请求方"
|
||
" tenant=%s,拒绝跨用户恢复"
|
||
% (self.vm_name(task), snapshot_url,
|
||
marked, requester),
|
||
"snapshot_tenant_mismatch")
|
||
raise ChvFailure(
|
||
"snapshot_tenant_mismatch",
|
||
"快照归属 tenant=%s,与请求方 tenant=%s 不一致,"
|
||
"拒绝跨用户恢复:%s" % (marked, requester,
|
||
snapshot_url))
|
||
|
||
def _validate_restore(self, task, snapshot_url):
|
||
"""restore 前置校验:返回(归一化后的 snapshot_url,
|
||
归一化后的 net 条目)。
|
||
|
||
restore 不走 vm.create、没有 VmConfig 可挂 seed:
|
||
快照内已固化原 VM host key,重预置反而破坏钉死,
|
||
故 provisioned 下复用已钉死的 known_hosts、缺失
|
||
fail-fast、绝不 keyscan、绝不重生成。restore 时不
|
||
重复验快照文件存在(远端/对象存储源合法,ch-remote
|
||
本身会在源缺失时失败)。
|
||
feat-chv-cover100:restore 也走 H7 net 隔离——此前
|
||
restore 路径既不校验冲突、也不登记 net.json:已登
|
||
记 VM 的 tap/mac/ip 会被恢复出的 VM 静默复用,且
|
||
恢复出的 VM 自身不登记、后来的 wake 也看不到它,
|
||
两边都漏判。与 wake 的 _wake_prechecks 同款:冲突
|
||
在起 VMM 前拒绝(net_tap_conflict),登记失败见
|
||
_restore_boot(P2-19 语义)。"""
|
||
if not isinstance(task, dict) or task.get("id") is None:
|
||
raise ValueError("任务缺少 id")
|
||
if not snapshot_url:
|
||
raise ValueError("snapshot_url 不能为空")
|
||
if "://" not in snapshot_url:
|
||
snapshot_url = "file://%s" % snapshot_url
|
||
validate_snapshot_url(
|
||
snapshot_url, self.state_dir,
|
||
snapshot_dir=getattr(self, "snapshot_dir", None),
|
||
allow_remote=getattr(self, "allow_remote_snapshot", False))
|
||
self._check_snapshot_tenant(task, snapshot_url)
|
||
self._check_snapshot_disk(snapshot_url)
|
||
self.preflight()
|
||
self.resolve_ssh_host()
|
||
if self._provisioned_key_guard(task):
|
||
raise ChvFailure(
|
||
"host_key_not_pinned",
|
||
"provisioned 模式下 restore 缺 known_hosts,拒绝 "
|
||
"keyscan 回落:VM %s" % self.vm_name(task))
|
||
return snapshot_url, self._validate_restore_net(task)
|
||
|
||
def _validate_restore_net(self, task):
|
||
"""restore 的 net 隔离段(_validate_restore 的尾段):
|
||
与 wake 的 _wake_prechecks 同款,冲突在起 VMM 前
|
||
拒绝(net_tap_conflict);net 未配即空列表。"""
|
||
if not self.net_json:
|
||
return []
|
||
try:
|
||
return validate_net_entries(
|
||
parse_net_entries(self.net_json),
|
||
self._registered_net_entries(
|
||
exclude_vm=self.vm_name(task)))
|
||
except ChvFailure as exc:
|
||
self._emit("vm_failed", task,
|
||
"vm=%s restore 前置失败 kind=%s"
|
||
% (self.vm_name(task), exc.kind),
|
||
exc.kind)
|
||
raise
|
||
|
||
def _check_snapshot_disk(self, snapshot_url):
|
||
"""restore 前的快照磁盘预检(feat-chv-v3,hibernate
|
||
评估结论):快照 config.json 引用原任务盘路径,
|
||
盘缺失时 restore 必在 ch-remote 侧失败;起 VMM 前
|
||
先 fail-fast(snapshot_disk_missing),不让调用方
|
||
拿一个含糊的 ch-remote 失败去猜。只对本地目录快照
|
||
且 config.json 可读可解析时校验:远端源、旧格式
|
||
无 config.json、解析失败时跳过(ch-remote 本身会
|
||
在源缺失时失败,不在这里多一层误拒)。"""
|
||
if not snapshot_url.startswith("file://"):
|
||
return
|
||
snap_dir = snapshot_url[len("file://"):]
|
||
if not os.path.isdir(snap_dir):
|
||
return
|
||
config_path = os.path.join(snap_dir, "config.json")
|
||
try:
|
||
with open(config_path, "r") as fh:
|
||
config = json.load(fh)
|
||
except (OSError, ValueError):
|
||
return
|
||
if not isinstance(config, dict):
|
||
return
|
||
disks = config.get("disks")
|
||
if not isinstance(disks, list):
|
||
return
|
||
missing = []
|
||
for disk in disks:
|
||
if not isinstance(disk, dict):
|
||
continue
|
||
path = disk.get("path")
|
||
if isinstance(path, str) and path \
|
||
and not os.path.exists(path):
|
||
missing.append(path)
|
||
if missing:
|
||
raise ChvFailure(
|
||
"snapshot_disk_missing",
|
||
"快照引用的任务盘缺失,restore 无法进行:%s"
|
||
% ", ".join(missing))
|
||
|
||
def _restore_prepare(self, task, name):
|
||
"""restore 准备(feat-chv-v3,与 _wake_prepare 对等):
|
||
杀旧 VMM(登记的 + 未登记跨实例的),清旧 socket。
|
||
restore 须落到全新 VMM 上:旧 VMM 还活着时新 VMM
|
||
抢不到 tap/socket,ch-remote restore 会被旧 VM 拒
|
||
掉(2026-11-07 WSL 真机 V-3 实测)。"""
|
||
socket_path = self.api_socket(task)
|
||
self._drop_registered_proc(name)
|
||
self._kill_vmm_by_socket(socket_path)
|
||
try:
|
||
if os.path.exists(socket_path):
|
||
os.remove(socket_path)
|
||
except OSError:
|
||
pass
|
||
|
||
def _restore_boot(self, task, snapshot_url, net_entries):
|
||
"""restore 的启动段:起 VMM → 等 ping → restore →
|
||
显式 resume → 等 SSH 就绪(恢复后默认 paused,不
|
||
resume 就不算醒)。"""
|
||
name = self.vm_name(task)
|
||
self._restore_prepare(task, name)
|
||
self._launch_api_only(task)
|
||
self.wait_api_ready(task)
|
||
self._check_api_socket_permissions(self.api_socket(task))
|
||
# feat-chv-opt-landing(阶段 2):restore 触 tap,停机
|
||
# →恢复窗口的 tap 释放竞态在此处报 Resource busy;
|
||
# 等 tap 空闲后重试一次,不立即失败(见 _retry_on_tap_busy)。
|
||
self._retry_on_tap_busy(
|
||
task,
|
||
lambda: self._ch_remote(task, "restore",
|
||
"source_url=%s" % snapshot_url),
|
||
"restore")
|
||
# feat-chv-cover100:与 _boot_vm 同款——恢复成功后登记
|
||
# net.json,登记失败即拒恢复(P2-19 语义:审计 +
|
||
# 返回 False + 调用方 ChvFailure);restore_vm 的
|
||
# except 会 stop_vm 清掉半恢复 VM。
|
||
if net_entries and not self._record_net_entries(
|
||
name, net_entries):
|
||
raise ChvFailure(
|
||
"net_registry_write_failed",
|
||
"vm=%s net 登记写失败,拒绝恢复(缺登记会"
|
||
"漏判 tap/mac/ip 冲突)" % name)
|
||
self._emit("vm_restore", task,
|
||
"vm=%s snapshot=%s" % (name, snapshot_url))
|
||
self.resume_vm(task)
|
||
self.wait_guest_ready(task)
|
||
|
||
def restore_vm(self, task: dict, snapshot_url: str) -> dict:
|
||
"""快照恢复唤醒:校验 → 只起 VMM → restore → 显式
|
||
resume → 等 SSH 就绪。恢复后默认 paused,不 resume
|
||
就不算醒。任一步失败都关 VM 再抛;成功返回
|
||
{vm, api_socket, state: "running", ready: True,
|
||
mode: "restored"}。恢复前校验 net 冲突、恢复成功后
|
||
登记 net.json(与 wake 的 H7/P2-19 同款,见
|
||
_validate_restore/_restore_boot)。"""
|
||
snapshot_url, net_entries = self._validate_restore(
|
||
task, snapshot_url)
|
||
name = self.vm_name(task)
|
||
self._check_capacity(exclude=name)
|
||
try:
|
||
self._restore_boot(task, snapshot_url, net_entries)
|
||
except Exception:
|
||
try:
|
||
# feat-chv-opt-landing(阶段 2,§6.2 遗留缺陷):
|
||
# 失败路径原先 stop_vm(task)(keep_disk=False)
|
||
# 会删 seed.iso,而快照 config.json 的 disks
|
||
# 同时引用 disk.raw 与 seed.iso,删了之后任何
|
||
# 重试 restore 都报 seed.iso ENOENT——失败清理
|
||
# 毁掉了恢复输入。现改 keep_disk=True:任务盘
|
||
# 与 seed.iso 都留,重试成为可能;残留盘由
|
||
# GC(有登记快照的受保护、无登记的按孤儿清)
|
||
# 与显式回收接手,不长期滞留。
|
||
self.stop_vm(task, keep_disk=True)
|
||
except Exception:
|
||
pass
|
||
raise
|
||
return {"vm": name, "api_socket": self.api_socket(task),
|
||
"state": "running", "ready": True, "mode": "restored"}
|
||
|
||
def hibernate_vm(self, task_or_name: dict | str, snapshot_url: str) -> dict:
|
||
"""休眠(CHV 无原生 hibernate 端点,等价实现):
|
||
pause + snapshot + shutdown。休眠后文件与 worktree
|
||
保留在快照里(快照是暂停时刻的完整状态),临时文件
|
||
不在恢复保证内(见 recycle_vm 的说明)。
|
||
成功返回 {vm, state: "hibernated", snapshot: ...}。
|
||
本方法明写“等价实现”,绝不声称接了原生 hibernate。
|
||
feat-chv-v3(V-3 附带评估,结论:必须留盘):
|
||
快照 config.json 引用原任务盘路径(state_dir/<vm>/
|
||
disk.raw),stop_vm 默认会删盘——修前 hibernate 的
|
||
stop 直接删掉了自己快照的输入,restore 时盘已不在、
|
||
必失败。所以休眠的 stop 用 keep_disk=True 留盘;
|
||
留下的盘由 gc_stale_disks 的快照登记保护(有登记
|
||
快照的 VM 不 GC 盘),快照被剪干净后盘才成孤儿可删。
|
||
restore 成功后 VM 再走正常 stop,盘照常删,不留
|
||
长期残留。
|
||
feat-chv-v3-repair(V-7/D2):keep_disk=True 同样留
|
||
seed.iso——config.json 的 disks 同时引用 disk.raw 与
|
||
seed.iso(readonly),只留盘不留 seed.iso 时 restore
|
||
仍在服务端失败(Cannot open disk path …/seed.iso),
|
||
预检 snapshot_disk_missing 先拒掉不是过严;有登记
|
||
快照的 VM 的 seed.iso 也不被 cleanup_stale_secrets
|
||
当孤儿清掉(见 _clean_one_vm_secrets)。"""
|
||
self.pause_vm(task_or_name)
|
||
self.snapshot_vm(task_or_name, snapshot_url)
|
||
self.stop_vm(task_or_name, keep_disk=True)
|
||
# to10:删死赋值(task 建出后未用,F841;返回值直接
|
||
# 用 task_or_name 算 VM 名,行为不变)。
|
||
return {"vm": self.vm_name(task_or_name),
|
||
"state": "hibernated", "snapshot": snapshot_url}
|
||
|
||
def wake_from_hibernate(self, task: dict, snapshot_url: str) -> dict:
|
||
"""从休眠唤醒:即 restore_vm(restore + 显式 resume
|
||
+ SSH 就绪),语义与 restore_vm 完全一致,只是名字
|
||
说明来源是休眠快照。"""
|
||
return self.restore_vm(task, snapshot_url)
|
||
|
||
def _stop_registered_proc(self, name, proc):
|
||
"""清登记进程:已死直接清,活着先杀再清(纯移动)。"""
|
||
if proc is None:
|
||
return
|
||
with self._vms_lock:
|
||
self._vms.pop(name, None)
|
||
try:
|
||
alive = proc.poll() is None
|
||
except Exception:
|
||
alive = False
|
||
if alive:
|
||
self._kill_proc(proc)
|
||
|
||
def _remove_vm_seed_files(self, name, keep_disk):
|
||
"""删 seed 目录 + 宿主私钥 + seed.iso(stop 的第一块
|
||
清理,见 _cleanup_vm_files):keep_disk=True 时 seed.iso
|
||
留——快照 config.json 的 disks 含 seed.iso(readonly),
|
||
删了 restore 必在服务端失败(Cannot open disk path
|
||
…/seed.iso / No such file or directory),预检
|
||
snapshot_disk_missing 不是过严(V-7/D2)。seed 目录
|
||
与宿主私钥仍删,config.json 不引用它们。"""
|
||
seed_dir = os.path.join(self.state_dir, name, "seed")
|
||
if os.path.isdir(seed_dir):
|
||
shutil.rmtree(seed_dir)
|
||
for _suffix in ("ssh_host_ed25519_key",
|
||
"ssh_host_ed25519_key.pub",
|
||
"seed.iso"):
|
||
if keep_disk and _suffix == "seed.iso":
|
||
continue
|
||
_p = os.path.join(self.state_dir, name, _suffix)
|
||
if os.path.exists(_p):
|
||
os.remove(_p)
|
||
|
||
def _persistent_record_for_vm(self, name):
|
||
"""该 VM 是否被某租户的持久登记认领:读 tenant.json
|
||
得租户,再读该租户的持久登记、核对 vm 名一致。
|
||
返回登记 dict 或 None(未认领:冷路径 VM 或旧 VM,
|
||
stop/GC 按旧语义处理)。"""
|
||
tenant = read_vm_tenant(self.state_dir, name)
|
||
if not tenant:
|
||
return None
|
||
record = read_persistent_vm(self.state_dir, tenant)
|
||
if record is not None and record.get("vm") == name:
|
||
return record
|
||
return None
|
||
|
||
def _overlay_disk_protected(self, name) -> bool:
|
||
"""该 VM 的任务盘是否受保护(stop/GC 不删):
|
||
有持久登记认领,或有登记快照(hibernate 的 restore
|
||
输入)。不分模式(2026-11-07:copy 模式也要能跑持
|
||
久 VM;无登记/无快照的盘仍按旧语义删除)。
|
||
数据盘 data.raw 永远不受 GC/stop 删除(另有显式
|
||
回收入口),本方法不覆盖它。"""
|
||
if self._persistent_record_for_vm(name) is not None:
|
||
return True
|
||
return has_recorded_snapshots(self.state_dir, name)
|
||
|
||
def _cleanup_vm_task_disk(self, name, task, keep_disk) -> bool:
|
||
"""stop 的任务盘删除块(_cleanup_vm_files 的中段):
|
||
keep_disk 或受保护盘(持久登记认领/有登记快照)即
|
||
跳过;删掉返回 False(无失败),删失败记审计并返回
|
||
True(脏标记由调用方写)。保护不分模式(2026-11-07:
|
||
copy 模式也要能跑持久 VM;无登记/无快照的 copy 盘
|
||
仍按旧语义删除,只有被认领/被快照引用的盘才留)。"""
|
||
_protected = self._overlay_disk_protected(name)
|
||
try:
|
||
if not keep_disk and not _protected \
|
||
and delete_task_disk(self.state_dir, name):
|
||
self._emit("vm_disk_deleted", task,
|
||
"vm=%s disk deleted on stop" % name)
|
||
except Exception as _disk_exc:
|
||
self._emit("vm_disk_delete_failed", task,
|
||
"vm=%s error=%s" % (name, _disk_exc))
|
||
return True
|
||
return False
|
||
|
||
def _cleanup_vm_files(self, name, task, keep_disk=False):
|
||
"""stop 的三块文件清理:seed/私钥、任务磁盘、net 登记。
|
||
|
||
任一块失败记对应审计并返回 True(脏标记由调用方写);
|
||
seed/私钥与磁盘删除的审计名与文案逐字不变。
|
||
keep_disk=True 时跳过任务盘删除,并跳过 seed.iso
|
||
删除(hibernate 专用:快照 config.json 引用原盘路径
|
||
与 seed.iso,任一删了 restore 即失败——见
|
||
hibernate_vm 的评估注释与 V-7/D2;seed 目录与宿主
|
||
私钥仍删,config.json 不引用它们,私钥明文已嵌进
|
||
seed.iso、宿主侧不留)。
|
||
feat-chv-opt-landing:overlay 模式下,任务盘是持久
|
||
用户盘——该 VM 有持久登记认领或有登记快照时,stop
|
||
不删 overlay(删了等于销毁用户 VM / 毁掉快照输入);
|
||
删盘只走显式回收(recycle_persistent_vm)。无认领
|
||
又无快照的 overlay VM(冷路径残留)仍按旧语义删。"""
|
||
from cloud.chv.hostkeys import dirty_marker_path # noqa: F401
|
||
failed = False
|
||
try:
|
||
self._remove_vm_seed_files(name, keep_disk)
|
||
except Exception as _cleanup_exc:
|
||
failed = True
|
||
self._emit("vm_key_cleanup_failed", task,
|
||
"vm=%s error=%s" % (name, _cleanup_exc))
|
||
# feat-chv-opt-landing:overlay 模式下受保护的盘(持久
|
||
# 登记认领或有登记快照)stop 不删;copy 模式一律旧语义。
|
||
# 见 _cleanup_vm_task_disk。
|
||
if self._cleanup_vm_task_disk(name, task, keep_disk):
|
||
failed = True
|
||
try:
|
||
_net_path = os.path.join(self.state_dir, name, "net.json")
|
||
if os.path.exists(_net_path):
|
||
os.remove(_net_path)
|
||
except Exception as _net_exc:
|
||
# feat-chv-closeup(P2-17):net.json 删失败
|
||
# 此前只记 failed=True、无独立审计——删不掉的
|
||
# 登记会让后来的 wake 误判该 tap/mac/ip 仍被
|
||
# 占用(误拒)或与脏标记混在一起查不出;现发
|
||
# 独立审计 vm_net_registry_delete_failed。
|
||
failed = True
|
||
self._emit("vm_net_registry_delete_failed", task,
|
||
"vm=%s error=%s" % (name, _net_exc))
|
||
self._clear_guest_env_delivered(name)
|
||
return failed
|
||
|
||
def _write_dirty_marker(self, name, failed):
|
||
"""M2 脏标记:清理失败写标记、成功清标记(P2-17 收尾)。
|
||
|
||
返回 True=标记状态已如愿落盘(失败时标记已写、
|
||
成功时标记已清/本来就没有),False=写/删标记
|
||
本身失败。失败时发独立审计 dirty_marker_write_failed
|
||
+ error 日志,不再全静默——标记写不下去时拒起链
|
||
本身就不存在(provision/wake 读不到标记),部署
|
||
必须保证 state_dir 可写(preflight 的 0700 门)
|
||
并靠审计告警发现;stop 路径仍不抛错(stop_vm
|
||
“尽力而为、不抛异常”的契约不变,False 只作
|
||
调用方可观测的信号)。写路径走 atomic_file
|
||
(P2-18):随机 tmp 名 + 双 fsync。
|
||
"""
|
||
from cloud.chv.hostkeys import dirty_marker_path
|
||
try:
|
||
_marker = dirty_marker_path(self.state_dir, name)
|
||
if failed:
|
||
os.makedirs(os.path.dirname(_marker), exist_ok=True)
|
||
atomic_write_text(_marker, "cleanup failed\n")
|
||
elif os.path.exists(_marker):
|
||
os.remove(_marker)
|
||
return True
|
||
except Exception as exc:
|
||
_LOGGER.error("chv_dirty_marker_write_failed vm=%s "
|
||
"failed=%s error=%s", name, failed, exc)
|
||
self._emit("dirty_marker_write_failed", None,
|
||
"vm=%s 脏标记写失败(failed=%s):%s"
|
||
% (name, failed, exc),
|
||
"dirty_marker_write_failed")
|
||
return False
|
||
|
||
def stop_vm(self, task_or_name: dict | str,
|
||
keep_disk: bool = False) -> bool:
|
||
"""停止 VM(尽力而为,不抛异常):已死进程直接清登记,
|
||
已关 VM 仍尽力调一次 ch-remote(返回 False),socket
|
||
残留删 socket(返回 True)。ch-remote shutdown 失败
|
||
不炸,但命令本身仍会发出(测试据此断言关闭路径)。
|
||
feat-chv-v3(F2):新实例的 _vms 里无登记时,旧 VMM
|
||
也要按 socket 进程扫描清掉(_kill_vmm_by_socket),
|
||
否则 socket/磁盘已删、VMM 空壳进程仍活着占 tap。
|
||
keep_disk=True 时不删任务盘与 seed.iso(hibernate 专用,
|
||
见 hibernate_vm:快照 config.json 的 disks 同时引用
|
||
两者)。"""
|
||
name = self.vm_name(task_or_name)
|
||
socket_path = os.path.join(self.state_dir, name + ".sock")
|
||
with self._vms_lock:
|
||
had_proc = name in self._vms
|
||
proc = self._vms.get(name)
|
||
had = had_proc or os.path.exists(socket_path)
|
||
try:
|
||
self.runner([self.ch_remote,
|
||
"--api-socket=%s" % socket_path, "shutdown"])
|
||
except Exception:
|
||
pass
|
||
self._stop_registered_proc(name, proc)
|
||
self._kill_vmm_by_socket(socket_path)
|
||
try:
|
||
if os.path.exists(socket_path):
|
||
os.remove(socket_path)
|
||
except Exception:
|
||
pass
|
||
# per-VM seed 目录与 host 私钥随 VM 关闭而删,绝不留给
|
||
# 下一个 VM 复用;known_hosts 公钥保留供事后审计。
|
||
# 三块清理独立记账 + 脏标记(H3/M2,见 _cleanup_vm_files)。
|
||
_task = task_or_name if isinstance(task_or_name, dict) \
|
||
else {"id": task_or_name}
|
||
_cleanup_failed = self._cleanup_vm_files(name, _task,
|
||
keep_disk=keep_disk)
|
||
self._write_dirty_marker(name, _cleanup_failed)
|
||
task = task_or_name if isinstance(task_or_name, dict) \
|
||
else {"id": task_or_name}
|
||
if had:
|
||
self._emit("vm_shutdown", task, "vm=%s" % name)
|
||
self._emit("vm_destroy", task, "vm=%s" % name)
|
||
_LOGGER.info("chv_vm_stop vm=%s had=%s", name, had)
|
||
return had
|
||
|
||
def gc_stale_disks(self) -> dict:
|
||
"""残留盘 GC(H3):扫描 state_dir/*/disk.raw 与
|
||
disk.qcow2,VM 非 live(无 api-socket 文件且无登记
|
||
活进程)即删盘并审计;删失败审计 vm_disk_delete_failed
|
||
并计入 failed。返回 {deleted, failed}。
|
||
feat-chv-v3:有登记快照的 VM 不 GC 盘——那是休眠
|
||
VM 的盘,restore 还要读它(见 hibernate_vm)。
|
||
feat-chv-opt-landing:有持久登记认领的 VM 同样不
|
||
GC(那是用户的持久盘);数据盘 data.raw 永不进
|
||
GC(用户数据,只走显式回收 recycle_persistent_vm)。"""
|
||
deleted: list = []
|
||
failed: list = []
|
||
try:
|
||
entries = os.listdir(self.state_dir)
|
||
except Exception:
|
||
return {"deleted": deleted, "failed": failed}
|
||
for entry in entries:
|
||
if not entry.startswith("chv-"):
|
||
continue
|
||
if not task_disk_paths(self.state_dir, entry):
|
||
continue
|
||
if has_recorded_snapshots(self.state_dir, entry):
|
||
continue
|
||
if self._persistent_record_for_vm(entry) is not None:
|
||
continue
|
||
live = os.path.exists(
|
||
os.path.join(self.state_dir, entry + ".sock"))
|
||
with self._vms_lock:
|
||
proc = self._vms.get(entry)
|
||
if proc is not None:
|
||
try:
|
||
live = live or proc.poll() is None
|
||
except Exception:
|
||
pass
|
||
if live:
|
||
continue
|
||
try:
|
||
delete_task_disk(self.state_dir, entry)
|
||
deleted.append(entry)
|
||
self._emit("vm_disk_gc", {"id": entry},
|
||
"vm=%s stale disk deleted" % entry)
|
||
except Exception as exc:
|
||
failed.append(entry)
|
||
self._emit("vm_disk_delete_failed", {"id": entry},
|
||
"vm=%s gc error=%s" % (entry, exc))
|
||
return {"deleted": deleted, "failed": failed}
|
||
|
||
def _is_live_vm(self, entry):
|
||
"""VM 是否活着:socket 存在或登记进程未死即活。"""
|
||
if os.path.exists(os.path.join(self.state_dir, entry + ".sock")):
|
||
return True
|
||
with self._vms_lock:
|
||
proc = self._vms.get(entry)
|
||
if proc is not None:
|
||
try:
|
||
return proc.poll() is None
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
def _clean_one_vm_secrets(self, entry, vm_dir):
|
||
"""单 VM 残留清理:私钥一律删(live VM 私钥在 seed 盘
|
||
建好后即删,残留只可能是崩溃遗留);非 live VM 的
|
||
seed.iso/seed 目录同理删,但有登记快照的 VM 除外
|
||
(feat-chv-v3-repair,V-7/D2:那是休眠 VM 的 restore
|
||
输入,seed.iso 被快照 config.json 的 disks 引用,与
|
||
gc_stale_disks 的盘保护同款逻辑;私钥仍删,restore
|
||
不读宿主私钥文件)。返回 (删私钥, 删 seed)。"""
|
||
removed_priv = False
|
||
priv = os.path.join(vm_dir, "ssh_host_ed25519_key")
|
||
if os.path.exists(priv):
|
||
try:
|
||
os.remove(priv)
|
||
removed_priv = True
|
||
except Exception:
|
||
pass
|
||
removed_seed = False
|
||
if not self._is_live_vm(entry) \
|
||
and not has_recorded_snapshots(self.state_dir, entry):
|
||
for _suffix in ("seed.iso", "seed"):
|
||
_p = os.path.join(vm_dir, _suffix)
|
||
try:
|
||
if os.path.isdir(_p):
|
||
shutil.rmtree(_p)
|
||
removed_seed = True
|
||
elif os.path.exists(_p):
|
||
os.remove(_p)
|
||
removed_seed = True
|
||
except Exception:
|
||
pass
|
||
return removed_priv, removed_seed
|
||
|
||
def cleanup_stale_secrets(self) -> list:
|
||
"""崩溃残留扫描(H5):私钥一律删、非 live VM 的 seed
|
||
同理删并审计;有登记快照的 VM 的 seed 不删(V-7/D2,
|
||
休眠 VM 的 restore 输入,见 _clean_one_vm_secrets);
|
||
返回已清理 VM 名。worker 启动与
|
||
cleanup_stale_vms 时调它,不依赖 socket 是否存在。"""
|
||
cleaned: list = []
|
||
try:
|
||
entries = os.listdir(self.state_dir)
|
||
except Exception:
|
||
return cleaned
|
||
for entry in entries:
|
||
if not entry.startswith("chv-"):
|
||
continue
|
||
vm_dir = os.path.join(self.state_dir, entry)
|
||
if not os.path.isdir(vm_dir):
|
||
continue
|
||
removed_priv, removed_seed = \
|
||
self._clean_one_vm_secrets(entry, vm_dir)
|
||
if removed_priv or removed_seed:
|
||
cleaned.append(entry)
|
||
self._emit("host_secret_residue_cleaned",
|
||
{"id": entry},
|
||
"vm=%s private_key=%s seed=%s"
|
||
% (entry, removed_priv, removed_seed))
|
||
return cleaned
|
||
|
||
def shutdown_vm(self, task_or_name: dict | str) -> bool:
|
||
"""关闭入口的正式名:即 stop_vm(ch-remote shutdown)。
|
||
任务结束/失败/取消时调它。"""
|
||
return self.stop_vm(task_or_name)
|
||
|
||
def cancel_vm(self, task_or_name: dict | str) -> bool:
|
||
"""取消入口:即 shutdown_vm。worker 的取消路径对
|
||
Docker 调 kill_container,对 CHV 调本方法——取消绝不
|
||
只改数据库状态,必须真关 VM。"""
|
||
return self.shutdown_vm(task_or_name)
|
||
|
||
def recycle_vm(self, task: dict) -> dict:
|
||
"""回收 VM(任务结束后的销毁语义):
|
||
先 shutdown,再返回保留/丢失清单。保留的只有宿主侧
|
||
的 task_checkpoints / task_attempts(数据库里的断点与
|
||
尝试账本,VM 销毁不影响它们,goal 断点续跑据此接续);
|
||
丢失的是客机内的临时文件与未拷出的 worktree 临时物
|
||
——recycle 绝不声称“恢复临时文件”。需要拷出的工件
|
||
必须在 shutdown 之前由调用方经 SSH 拷出,本方法不
|
||
假装替调用方拷了。"""
|
||
if not isinstance(task, dict) or task.get("id") is None:
|
||
raise ValueError("任务缺少 id")
|
||
self.shutdown_vm(task)
|
||
self._emit("vm_recycled", task,
|
||
"vm=%s" % self.vm_name(task))
|
||
return {
|
||
"vm": self.vm_name(task),
|
||
"state": "recycled",
|
||
"preserved": ["task_checkpoints", "task_attempts"],
|
||
"lost": ["guest /tmp", "guest worktree temp files"],
|
||
}
|
||
|
||
def _stop_dead_socket_vms(self, entries):
|
||
"""socket 扫描:登记进程仍活的跳过,其余走 stop_vm。
|
||
|
||
返回已清理 VM 名;非 chv- 前缀 socket 一律跳过。"""
|
||
cleaned = []
|
||
for entry in entries:
|
||
if not entry.endswith(".sock"):
|
||
continue
|
||
name = entry[:-len(".sock")]
|
||
if not name.startswith("chv-"):
|
||
continue
|
||
proc = self._vms.get(name)
|
||
if proc is not None:
|
||
try:
|
||
alive = proc.poll() is None
|
||
except Exception:
|
||
alive = False
|
||
if alive:
|
||
continue
|
||
if self.stop_vm(name):
|
||
cleaned.append(name)
|
||
return cleaned
|
||
|
||
def _purge_dead_registry(self):
|
||
"""登记进程已死但 socket 已被删的登记项顺手清掉
|
||
(没有 socket 可删,不计入清理数)。"""
|
||
for name in list(self._vms.keys()):
|
||
proc = self._vms.get(name)
|
||
try:
|
||
dead = proc is not None and proc.poll() is not None
|
||
except Exception:
|
||
dead = True
|
||
if dead:
|
||
self._vms.pop(name, None)
|
||
|
||
def cleanup_stale_vms(self) -> list:
|
||
"""清理残留 VM:先跑残留盘 GC 与残留密钥扫描,再走
|
||
socket 扫描与登记清理。只清 socket 残留与已死进程
|
||
对应的 VM;state_dir 不存在返回 [],绝不抛异常。"""
|
||
try:
|
||
self.cleanup_stale_secrets()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
self.gc_stale_disks()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
entries = os.listdir(self.state_dir)
|
||
except Exception:
|
||
return []
|
||
cleaned = self._stop_dead_socket_vms(entries)
|
||
self._purge_dead_registry()
|
||
return cleaned
|
||
|
||
# -- 客内执行 ------------------------------------------------------
|
||
def _run_guest(self, task, inner_args, extra_env=None, vm_name=None, **kwargs):
|
||
"""SSH 调客内 host.py,返回 Local/Docker 同款结果 dict
|
||
(executor 字段改写为 cloud-hypervisor,并附 vm 名与
|
||
api-socket)。runner 异常即原样传播,由 worker 侧统一
|
||
写回 failed——本类不在这里把异常吞成假成功。
|
||
extra_env(P1-1):经 SSH env 前缀进客机环境。
|
||
feat-chv-opt-landing:vm_name 给出时 SSH 目标是该
|
||
VM(持久 VM 路径:任务 id ≠ VM 名,host 任务名仍按
|
||
任务 id 派生);不给时目标即 vm_name(task),与旧
|
||
行为一致。"""
|
||
self._validate()
|
||
vm_task = {"id": vm_name} if vm_name else task
|
||
ssh_cmd = self.build_ssh_command(
|
||
vm_task, self._guest_host_command(*inner_args, **kwargs),
|
||
extra_env=extra_env)
|
||
result = self._call_ssh_with_transient_retry(
|
||
task, ssh_cmd, timeout=self.task_timeout)
|
||
# 方法内 import:见模块头注释的循环导入说明。
|
||
from cloud.sandbox import SimpleNamespaceShim, _parse_host_result
|
||
parsed = _parse_host_result(task["id"],
|
||
SimpleNamespaceShim(result))
|
||
parsed["executor"] = "cloud-hypervisor"
|
||
parsed["vm"] = self.vm_name(vm_task)
|
||
parsed["api_socket"] = self.api_socket(vm_task)
|
||
return parsed
|
||
|
||
def _goal_result(self, task_id, result):
|
||
"""客内 host.py goal 输出解析:直接委托给三执行器共享
|
||
的 parse_goal_result(cloud/sandbox.py,Local 的
|
||
_goal_result_from_runner 同款正典语义),executor
|
||
字段为 cloud-hypervisor。
|
||
|
||
本轨对齐说明(#216 的 CHV 与正典有两处分叉,本轨
|
||
收敛,PR 描述里明写):① 非 0 退出但已打印 met 结论
|
||
时,正典判 failed(结论不可信),CHV 旧代码判
|
||
completed;② 输出含“已存在”撞名时,正典判 failed
|
||
并提示“用继续而非重试”,CHV 旧代码判 interrupted。
|
||
其余分支(met/unmet、interrupted 标记、永久错误、
|
||
无结论异常退出)本来就一致,不变。"""
|
||
from cloud.sandbox import parse_goal_result
|
||
return parse_goal_result(task_id, result,
|
||
"cloud-hypervisor")
|
||
|
||
def _run_guest_goal(self, task, inner_args, extra_env=None,
|
||
vm_name=None, **kwargs):
|
||
self._validate()
|
||
vm_task = {"id": vm_name} if vm_name else task
|
||
ssh_cmd = self.build_ssh_command(
|
||
vm_task, self._guest_host_command(*inner_args, **kwargs),
|
||
extra_env=extra_env)
|
||
return self._goal_result(
|
||
task["id"],
|
||
self._call_ssh_with_transient_retry(
|
||
task, ssh_cmd, timeout=self.task_timeout))
|
||
|
||
def _vm_proc_dead(self, task_or_name):
|
||
"""该 VM 的登记进程是否已死:只认本实例登记的进程
|
||
(poll() 非 None 即死);未登记时返回 False(不认
|
||
socket 残留——崩掉的 VMM 也会留下 socket 文件,单凭
|
||
socket 在不能断定 VM 活着还是死了,中途死亡判定只
|
||
用登记进程这个硬证据)。"""
|
||
name = self.vm_name(task_or_name)
|
||
with self._vms_lock:
|
||
proc = self._vms.get(name)
|
||
if proc is None:
|
||
return False
|
||
try:
|
||
return proc.poll() is not None
|
||
except Exception:
|
||
return False
|
||
|
||
def _vm_is_running(self, task_or_name):
|
||
"""execute() 的“VM 在运行”判定:本实例登记了活进程,
|
||
或 api-socket 文件存在(带外已醒的 VM,例如另一进
|
||
程 restore 的,登记不在本实例里)。两者皆无即未运行。"""
|
||
name = self.vm_name(task_or_name)
|
||
with self._vms_lock:
|
||
proc = self._vms.get(name)
|
||
if proc is not None:
|
||
try:
|
||
if proc.poll() is None:
|
||
return True
|
||
except Exception:
|
||
pass
|
||
return os.path.exists(self.api_socket(task_or_name))
|
||
|
||
def _vm_death_result(self, task, goal, evidence):
|
||
"""VM 中途死亡的 interrupted 结果:goal 形态带
|
||
goal_met/goal_error(断点可续跑),非 goal 形态带
|
||
vm_dead 标记与原因,status 一律 interrupted,绝不
|
||
笼统 failed。"""
|
||
reason = "VM 中途死亡,任务未完成、断点可续跑:%s" \
|
||
% (evidence or "客机连接在任务进行中丢失")
|
||
if goal:
|
||
return {
|
||
"executor": "cloud-hypervisor",
|
||
"task_id": task["id"],
|
||
"status": "interrupted",
|
||
"stdout": "",
|
||
"stderr": evidence or "",
|
||
"returncode": None,
|
||
"host_status": None,
|
||
"goal_met": None,
|
||
"goal_error": reason,
|
||
"usage": None,
|
||
"vm": self.vm_name(task),
|
||
"vm_dead": True,
|
||
}
|
||
return {
|
||
"executor": "cloud-hypervisor",
|
||
"task_id": task["id"],
|
||
"status": "interrupted",
|
||
"stdout": "",
|
||
"stderr": evidence or "",
|
||
"returncode": None,
|
||
"host_status": None,
|
||
"vm": self.vm_name(task),
|
||
"vm_dead": True,
|
||
"interrupted_reason": reason,
|
||
}
|
||
|
||
def _run_with_vm(self, task, inner_args, goal=False, extra_env=None,
|
||
**kwargs):
|
||
"""每任务 VM 生命周期(冷启动正式路径,feat-chv-arch95
|
||
定案):wake_vm(API 模式 + SSH 就绪)→ 跑客内命令 →
|
||
finally shutdown_vm。本方法绝不查 WarmPool:预热池是
|
||
实验性的(见 experimental_pool.py 头定案与 ADR-5),dispatch 永远
|
||
冷启动,warm_pool_wired_to_dispatch 恒 False。
|
||
wake 失败时不跑
|
||
任务、直接抛分类失败。本方法不做内部重试:重试是
|
||
worker 的 max_retries 语义,执行器内部重试会把一次
|
||
失败放大成多次计费与多次 VM 启动。唯一例外:首条任务 SSH
|
||
的连接建立阶段瞬时 reset 由 _run_guest 经
|
||
_call_ssh_with_transient_retry 重试 1 次(传输抖动,非任务重试)。
|
||
|
||
中途死亡分类(本轨新增):客内命令抛传输异常、或
|
||
非 goal 结果已 failed 时,若登记的 VMM 进程已死,
|
||
判 interrupted(断点可续跑)而不是笼统 failed 或
|
||
原样抛异常;VM 还活着时的异常/失败原样处理,不
|
||
误判。goal 结果本身已有 interrupted 分类(见
|
||
parse_goal_result),这里不再二次改写 goal 结论。
|
||
feat-chv-secfix(H2):extra_env 透传给 wake_vm(密钥
|
||
子集在 provisioned 模式下随 seed 投递),再透传给
|
||
_run_guest(远程命令 source 客内密钥文件)。
|
||
feat-chv-hybrid-b:wake 之前先走 _hybrid_pool_preamble
|
||
(续期 + 网关门 + 排队 + 池任务记数)。"""
|
||
self._hybrid_pool_preamble(task, kwargs.get("model_provider"))
|
||
self.wake_vm(task, extra_env=extra_env)
|
||
try:
|
||
return self._run_guest_with_death_classification(
|
||
task, inner_args, goal, extra_env, kwargs)
|
||
finally:
|
||
try:
|
||
self.shutdown_vm(task)
|
||
except Exception:
|
||
pass
|
||
|
||
def _run_guest_with_death_classification(self, task, inner_args,
|
||
goal, extra_env, kwargs):
|
||
"""客内执行 + 中途死亡分类(_run_with_vm 的一段,拆出
|
||
只为满足 50 行函数上限):客内命令抛传输异常、或非
|
||
goal 结果已 failed 时,若登记的 VMM 进程已死,判
|
||
interrupted(断点可续跑);VM 还活着时的异常/失败
|
||
原样处理,不误判。"""
|
||
try:
|
||
if goal:
|
||
return self._run_guest_goal(
|
||
task, inner_args, extra_env=extra_env, **kwargs)
|
||
result = self._run_guest(task, inner_args,
|
||
extra_env=extra_env, **kwargs)
|
||
except Exception as exc:
|
||
if self._vm_proc_dead(task):
|
||
return self._vm_death_result(
|
||
task, goal, str(exc)[:500])
|
||
raise
|
||
if not goal and result.get("status") == "failed" \
|
||
and self._vm_proc_dead(task):
|
||
result = dict(result)
|
||
result["status"] = "interrupted"
|
||
result["vm_dead"] = True
|
||
result["interrupted_reason"] = (
|
||
"VM 中途死亡,任务未完成、断点可续跑:"
|
||
"客内命令已 failed 且登记的 VMM 进程已死")
|
||
return result
|
||
|
||
def execute(self, task: dict) -> dict:
|
||
"""只读执行:客内 host.py status,不直接跑模型回合
|
||
(与 Local/Docker 的 execute 同款语义)。
|
||
|
||
execute 语义二选一结论(本轨落代码):只用于已运行
|
||
VM 的只读 status。VM 未运行时报分类失败
|
||
ChvFailure(kind=vm_not_running),绝不为一次 status
|
||
起一台 VM,也绝不向不存在的端口发 SSH 再拿连接
|
||
失败冒充任务状态。需要跑任务时调 dispatch/run/goal,
|
||
它们自带 wake_vm。"""
|
||
if not isinstance(task, dict) or task.get("id") is None:
|
||
raise ValueError("任务缺少 id")
|
||
if not self._vm_is_running(task):
|
||
raise ChvFailure(
|
||
"vm_not_running",
|
||
"execute() 只读已运行 VM 的 status:VM %s 未运行,"
|
||
"请先 wake_vm() 再调 execute(),不要用 execute "
|
||
"唤醒 VM" % self.vm_name(task))
|
||
try:
|
||
return self._run_guest(
|
||
task, ("status", "--task", self._host_name(task)))
|
||
except Exception as exc:
|
||
# feat-chv-fix(P1-8):socket 残留但 VMM 已死时,
|
||
# _vm_is_running 会因 socket 存在而放行,随后的
|
||
# SSH 失败不得以原始传输异常抛出——登记进程已死
|
||
# 或 socket 已消失即统一归类 vm_not_running;VM
|
||
# 仍活着时的 SSH 失败原样传播(那是客内 status
|
||
# 失败,不是 VM 未运行)。
|
||
if self._vm_proc_dead(task) \
|
||
or not os.path.exists(self.api_socket(task)):
|
||
raise ChvFailure(
|
||
"vm_not_running",
|
||
"execute() 时 VM 已死(socket 残留/进程已死),"
|
||
"SSH 失败归类为未运行:%s" % exc)
|
||
raise
|
||
|
||
def dispatch_task(self, task: dict, extra_env: dict | None = None, model_provider: str | None = None) -> dict:
|
||
"""客内 host.py dispatch:建 host 任务并跑一轮 turn。"""
|
||
if not isinstance(task, dict) or task.get("id") is None:
|
||
raise ValueError("任务缺少 id")
|
||
if not task.get("prompt"):
|
||
raise ValueError("任务缺少 prompt")
|
||
args = ["dispatch", "--name", self._host_name(task),
|
||
"--task", task["prompt"]]
|
||
repo = task.get("repo")
|
||
if repo:
|
||
args += ["--repo", repo]
|
||
return self._run_with_vm(
|
||
task, args, extra_env=extra_env, model=task.get("model"),
|
||
effort=task.get("effort"), approval=task.get("approval"),
|
||
model_provider=model_provider,
|
||
sandbox=("read-only" if task.get("plan") else None))
|
||
|
||
def run_task(self, task: dict, extra_env: dict | None = None, model_provider: str | None = None) -> dict:
|
||
"""客内 host.py run:在已有 host 任务上再跑一轮 turn。"""
|
||
if not isinstance(task, dict) or task.get("id") is None:
|
||
raise ValueError("任务缺少 id")
|
||
if not task.get("prompt"):
|
||
raise ValueError("任务缺少 prompt")
|
||
return self._run_with_vm(
|
||
task, ("run", "--task", self._host_name(task),
|
||
"--prompt", task["prompt"]),
|
||
extra_env=extra_env, model=task.get("model"),
|
||
effort=task.get("effort"), approval=task.get("approval"),
|
||
model_provider=model_provider)
|
||
|
||
def goal_task(self, task: dict, extra_env: dict | None = None, model_provider: str | None = None) -> dict:
|
||
"""客内 host.py goal:目标循环,轮次规则与 Local 同款
|
||
(缺省 3 轮,>100 轮 loud fail,旧验收形态 0 轮禁用)。"""
|
||
if not isinstance(task, dict) or task.get("id") is None:
|
||
raise ValueError("任务缺少 id")
|
||
goal_text = task.get("goal") or task.get("prompt")
|
||
if not goal_text:
|
||
raise ValueError("任务缺少 prompt")
|
||
verify = task.get("verify_command")
|
||
rounds = task.get("goal_max_rounds")
|
||
if rounds is None:
|
||
rounds = 3
|
||
if rounds is not None and rounds > 100:
|
||
raise ValueError("goal_max_rounds 超过上限 100 轮")
|
||
if rounds == 0 and verify and not task.get("goal_mode"):
|
||
raise ValueError(
|
||
"旧验收形态 goal_max_rounds=0 表示不限轮次,已禁用")
|
||
args = ["goal", "--name", self._host_name(task),
|
||
"--task", goal_text]
|
||
if verify:
|
||
args += ["--verify", verify]
|
||
args += ["--max-rounds", str(rounds)]
|
||
repo = task.get("repo")
|
||
if repo:
|
||
args += ["--repo", repo]
|
||
return self._run_with_vm(
|
||
task, args, goal=True, extra_env=extra_env,
|
||
model=task.get("model"),
|
||
effort=task.get("effort"), approval=task.get("approval"),
|
||
model_provider=model_provider)
|
||
|
||
def goal_resume_task(self, task: dict, goal_text: str | None = None,
|
||
max_rounds: int | None = None,
|
||
extra_env: dict | None = None,
|
||
model_provider: str | None = None) -> dict:
|
||
"""客内 host.py goal-resume:goal_text=None 为断点续跑
|
||
(不带 --goal-text,沿用原 goal+verify),给 goal_text
|
||
为开新目标(带 --goal-text 与 --max-rounds,默认 0)。"""
|
||
if not isinstance(task, dict) or task.get("id") is None:
|
||
raise ValueError("任务缺少 id")
|
||
args = ["goal-resume", "--task", self._host_name(task)]
|
||
if goal_text:
|
||
args += ["--goal-text", goal_text, "--max-rounds",
|
||
str(max_rounds if max_rounds is not None else 0)]
|
||
elif max_rounds is not None:
|
||
args += ["--max-rounds", str(max_rounds)]
|
||
return self._run_with_vm(
|
||
task, args, goal=True, extra_env=extra_env,
|
||
model=task.get("model"),
|
||
effort=task.get("effort"), approval=task.get("approval"),
|
||
model_provider=model_provider)
|
||
|
||
def goal_resume_breakpoint_task(self, task: dict,
|
||
model_provider: str | None = None,
|
||
extra_env: dict | None = None) -> dict:
|
||
"""客内 host.py goal-resume 断点续跑:无 --goal-text、
|
||
无 --max-rounds,host 沿用原任务从断点轮次续跑。
|
||
worker 层已接线(补缺口轨):非网关的 CHV 断点任务
|
||
经 worker 的通用断点分支调到这里,model_provider
|
||
按名透传给客内 host.py(与 Local/Docker 同款口径,
|
||
首轮 thread 的 provider 必须一致);网关任务的分流
|
||
由 _run_with_vm 开头的 _check_gateway_vm_class 按 VM
|
||
类 enforcement(常驻 VM 可接、池 VM 以
|
||
gateway_pool_denied 拒绝)。模型 key
|
||
本身绝不经本方法传值,只按环境变量名注入。"""
|
||
if not isinstance(task, dict) or task.get("id") is None:
|
||
raise ValueError("任务缺少 id")
|
||
return self._run_with_vm(
|
||
task, ("goal-resume", "--task", self._host_name(task)),
|
||
goal=True, extra_env=extra_env, model=task.get("model"),
|
||
effort=task.get("effort"), approval=task.get("approval"),
|
||
model_provider=model_provider)
|
||
|
||
|