2367 lines
115 KiB
Python
2367 lines
115 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 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.config import _parse_memory_bytes, build_chv_config
|
||
from cloud.chv.disk import (
|
||
check_disk_quota,
|
||
check_snapshot_limits,
|
||
delete_task_disk,
|
||
provision_task_disk,
|
||
record_snapshot,
|
||
task_disk_path,
|
||
)
|
||
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.locking import StateLock
|
||
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_secret_env,
|
||
write_known_hosts,
|
||
)
|
||
from cloud.executor_base import Executor
|
||
|
||
_LOGGER = logging.getLogger("cloud.cloud_hypervisor")
|
||
|
||
|
||
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。
|
||
- 快速启动:WarmPool(cloud/chv/experimental_pool.py)预热干净快照 VM,
|
||
acquire 消耗、release 销毁,池空回落冷启动,绝不降级;
|
||
池状态另落 state_dir/warm-pool.json registry,worker
|
||
重启后载入的条目一律当孤儿、只能 reconcile_orphans()
|
||
清理,绝不重新发给任务(见 orphans.py)。
|
||
- 长任务中途死亡:_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 侧 loud fail
|
||
守卫拦住,不放宽网关规则。
|
||
- 网关现状:worker 通用路径在 sub2api 网关下对非 Local
|
||
执行器直接 loud fail(与 CloudExecutor 同款),v1 不
|
||
改这条门,所以 CHV + sub2api 组合目前在 worker 层就
|
||
明确失败,不宣称生产可用。
|
||
|
||
配置(只走构造参数或环境变量,不写文件;参数优先):
|
||
- 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
|
||
_state_lock: StateLock
|
||
_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-p1fix(P1-05):容量/net/磁盘 check→act
|
||
# 与 purge/cleanup 的跨进程锁(flock,见 locking.py)。
|
||
# 只在 wake/restore/cleanup 时加锁,不在构造时建锁
|
||
# 文件(构造本身不应有 state_dir 写副作用)。
|
||
self._state_lock = StateLock(self.state_dir)
|
||
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._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 _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"):
|
||
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")
|
||
|
||
# -- 前置校验 ------------------------------------------------------
|
||
@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)。
|
||
# feat-chv-p1fix(P1-07):构造时校验拦不住“属性事后
|
||
# 改写/注入”的非法值,preflight 这里再验 >0——CPU
|
||
# 0/负、内存 0、任务超时 0/负一律干净 preflight 失败,
|
||
# 不进 VmConfig、不等 VMM 侧才炸。
|
||
try:
|
||
if int(self.cpus) <= 0:
|
||
raise ValueError("CHV_CPU")
|
||
except (TypeError, ValueError):
|
||
missing.append(("config_invalid",
|
||
"CHV_CPU 必须是大于 0 的整数:%r"
|
||
% (self.cpus,)))
|
||
try:
|
||
if _parse_memory_bytes(self.memory) <= 0:
|
||
raise ValueError("CHV_MEM")
|
||
except ValueError:
|
||
missing.append(("config_invalid",
|
||
"CHV_MEM 格式非法或为 0:%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 必须是正数"))
|
||
self._preflight_task_timeout_check(missing)
|
||
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_task_timeout_check(self, missing: list) -> None:
|
||
"""preflight 任务超时面(feat-chv-p1fix,P1-07 提取:
|
||
保持 _preflight_numeric_checks 在 50 行硬门禁内)。
|
||
None = 不限(旧语义);显式 0/负数即 preflight 失败。"""
|
||
try:
|
||
_task_timeout = getattr(self, "task_timeout", None)
|
||
if _task_timeout is not None \
|
||
and float(_task_timeout) <= 0:
|
||
raise ValueError("task_timeout")
|
||
except (TypeError, ValueError):
|
||
missing.append(("config_invalid",
|
||
"CHV_TASK_TIMEOUT 必须是大于 0 的数字"))
|
||
|
||
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")
|
||
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 时不再写死回环,否则钉死校验永远
|
||
对不上)。feat-chv-p1fix(P1-04 同款):解析失败即
|
||
抛 ChvFailure,绝不回落回环写钉死——回环钉死会让
|
||
SSH 校验钉在一个“解析失败后猜出来的目标”上;
|
||
wake 路径在 provision 之前已在 _wake_prechecks 里
|
||
严格解析过,此处失败只发生在直接调用时,抛错是
|
||
正确语义。"""
|
||
from cloud.chv.hostkeys import known_hosts_line
|
||
from cloud.chv.ssh_transport import write_known_hosts
|
||
ssh_host = self.resolve_ssh_host()
|
||
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) -> 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);上次清理失败留下的
|
||
脏标记存在时直接拒绝(清理失败即拒起,不再复用
|
||
旧 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)
|
||
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 将就跑。"""
|
||
# feat-chv-p1fix(P1-04):删除旧回环回落——构造失败
|
||
# 即抛 ssh_target_undeclared,调用方无法再把“显式
|
||
# 回环”与“解析失败后回落”混为一谈。显式回环仍可用:
|
||
# 设 CHV_SSH_HOST=127.0.0.1,或显式宣称
|
||
# CHV_SSH_FORWARD=1;wake/wait 路径本来就严格解析,
|
||
# 本改动只收紧公开构造方法,不放宽任何严格路径,
|
||
# 也没有新增 dry-run 例外参数(干跑同样不许猜目标)。
|
||
ssh_host = self.resolve_ssh_host()
|
||
secrets, plain = split_secret_env(extra_env)
|
||
guest_env_file = None
|
||
if secrets:
|
||
from cloud.chv.hostkeys import GUEST_ENV_PATH
|
||
delivered = self._guest_env_delivered.get(
|
||
self.vm_name(task), set())
|
||
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) -> list:
|
||
"""VmConfig 磁盘列表(to10 提取,含 L2 白名单校验)。
|
||
任务盘 + 可选 seed 只读盘(CIDATA ISO,cloud-init
|
||
NoCloud 数据源);显式给的盘若不是基础镜像本身,
|
||
须在 state_dir 内,否则 VmConfig 不能挂进宿主任意文件。"""
|
||
# 旧文案逐字保留:任务盘“任务磁盘须在…”,seed 盘
|
||
# “seed 磁盘须在…”(label 本身含“磁盘”与空格)。
|
||
for path, label in ((task_disk, "任务磁盘"),
|
||
(seed_disk, "seed 磁盘")):
|
||
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}]
|
||
if seed_disk:
|
||
disks.append({"path": seed_disk, "readonly": True})
|
||
return disks
|
||
|
||
def build_vm_config(self, task_disk: str | None = None, seed_disk: str | None = None) -> dict:
|
||
"""API 模式的 VmConfig(vm.create 请求体,纯函数):
|
||
cpus{boot_vcpus,max_vcpus}、memory{size: 字节}、
|
||
kernel{path}、cmdline{args}、disks[{path: 每任务磁盘}]、
|
||
rng{/dev/urandom},以及 CHV_NET_JSON 给出的 net 列表
|
||
(不给则不带 net——客机无网卡时 SSH 就绪会超时失败,
|
||
这是 fail-fast 前置条件,见类 docstring)。
|
||
task_disk 不给时用基础镜像路径(仅 lab/reuse 模式)。"""
|
||
try:
|
||
cpus = int(self.cpus)
|
||
except ValueError:
|
||
raise ValueError("CHV_CPU 必须是整数:%r" % (self.cpus,))
|
||
config = {
|
||
"cpus": {"boot_vcpus": cpus, "max_vcpus": cpus},
|
||
"memory": {"size": _parse_memory_bytes(self.memory)},
|
||
"kernel": {"path": self.kernel},
|
||
"cmdline": {"args": self.cmdline},
|
||
"disks": self._vm_config_disks(task_disk, seed_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 _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 _state_lock_ctx(self) -> StateLock:
|
||
"""当前 state_dir 的 StateLock(state_dir 若被事后改写,
|
||
换用新路径的锁实例,避免锁在旧目录上)。"""
|
||
if self._state_lock.state_dir != self.state_dir:
|
||
self._state_lock = StateLock(self.state_dir)
|
||
return self._state_lock
|
||
|
||
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
|
||
口径:总量 + 本次 + 宿主余量,镜像不可读不静默跳过)。"""
|
||
check_disk_quota(self.rootfs, self.disk_gb,
|
||
state_dir=self.state_dir)
|
||
|
||
def _provision_task_disk(self, task):
|
||
"""每任务磁盘供给(实现在 cloud/chv/disk.py):
|
||
- copy(默认):把基础镜像复制到
|
||
state_dir/<vm>/disk.raw,VmConfig 用该路径;复制
|
||
慢但两个任务绝不写同一文件,测试用小文件可直接验证。
|
||
- reuse(仅单租户实验):直接返回基础镜像路径,磁盘
|
||
不独立;preflight 拦截(须显式实验二次确认并审计
|
||
放行,见 preflight,feat-chv-fix)。
|
||
返回每任务磁盘路径。"""
|
||
return provision_task_disk(
|
||
self.state_dir, self.vm_name(task), self.rootfs,
|
||
self.disk_mode)
|
||
|
||
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 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 _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):
|
||
raise RuntimeError(
|
||
"ch-remote %s 失败:returncode=%s"
|
||
% (" ".join(args), getattr(result, "returncode")))
|
||
return result
|
||
|
||
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 条目(best-effort 写,
|
||
失败只记日志——登记缺失的后果是漏判冲突,见上文)。"""
|
||
try:
|
||
vm_dir = os.path.join(self.state_dir, vm)
|
||
os.makedirs(vm_dir, exist_ok=True)
|
||
tmp_path = os.path.join(vm_dir, "net.json.tmp")
|
||
with open(tmp_path, "w") as fh:
|
||
json.dump(entries, fh)
|
||
os.replace(tmp_path, os.path.join(vm_dir, "net.json"))
|
||
except OSError as exc:
|
||
_LOGGER.warning("chv_net_registry_write_failed vm=%s "
|
||
"error=%s", vm, exc)
|
||
|
||
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 _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) -> 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,
|
||
)
|
||
provisioned = self.provision_host_key(
|
||
task, secret_env=secrets or None)
|
||
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)
|
||
if secrets:
|
||
self._guest_env_delivered[name] = set(secrets)
|
||
else:
|
||
self._guest_env_delivered.pop(name, None)
|
||
return seed_disk
|
||
|
||
def _boot_vm(self, task, name, task_disk, seed_disk,
|
||
net_entries) -> None:
|
||
"""VMM 启动→vm.create→vm.boot→SSH 就绪,纯移动。"""
|
||
socket_path = self.api_socket(task)
|
||
self._launch_api_only(task)
|
||
self.wait_api_ready(task)
|
||
self._check_api_socket_permissions(socket_path)
|
||
status, _ = self._api_call(
|
||
socket_path, "PUT", "/vm.create",
|
||
self.build_vm_config(task_disk, seed_disk=seed_disk))
|
||
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:
|
||
self._record_net_entries(name, net_entries)
|
||
status, _ = self._api_call(socket_path, "PUT", "/vm.boot")
|
||
if status not in (200, 204):
|
||
raise ChvFailure(
|
||
"vm_boot_failed",
|
||
"vm.boot 失败:status=%s" % status)
|
||
self._emit("vm_boot", task, "vm=%s" % name)
|
||
self.wait_guest_ready(task)
|
||
|
||
def _wake_prepare(self, task: dict, name: str) -> tuple:
|
||
"""wake 准备:reuse 审计、旧 VM 清理、任务盘供给、
|
||
直连 egress 审计,纯移动。返回 (task_disk, socket_path)。"""
|
||
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)))
|
||
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)
|
||
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 就绪。任一步失败都关 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。
|
||
feat-chv-p1fix(P1-05):全程持 state_dir flock——
|
||
容量/net/磁盘的 check→act 只有在“查完立即占位、
|
||
占位前不放行”时才成立;锁提前释放会在 socket 尚
|
||
未绑定前留出计数窗口,仍可超限。串行化代价只落在
|
||
wake/restore/cleanup 之间,已运行 VM 的任务执行
|
||
(_run_guest)不持此锁。"""
|
||
with self._state_lock_ctx():
|
||
return self._wake_vm_locked(task, extra_env)
|
||
|
||
def _wake_vm_locked(self, task: dict, extra_env: dict | None = None) -> dict:
|
||
"""持 state 锁的 wake 本体(见 wake_vm)。"""
|
||
if not isinstance(task, dict) or task.get("id") is None:
|
||
raise ValueError("任务缺少 id")
|
||
name = self.vm_name(task)
|
||
secrets, net_entries = self._wake_prechecks(task, name, extra_env)
|
||
task_disk, socket_path = self._wake_prepare(task, name)
|
||
try:
|
||
seed_disk = self._provision_seed_disk(task, name, secrets)
|
||
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
|
||
|
||
def snapshot_vm(self, task_or_name: dict | str, snapshot_url: str) -> str:
|
||
"""给(已暂停的)VM 做快照:ch-remote snapshot
|
||
<source_url>。snapshot_url 为空即 ValueError;成功
|
||
审计 vm_snapshot。快照本身不改变 VM 状态(仍 paused,
|
||
需 resume 才醒)。
|
||
feat-chv-secfix(L2/M3):ch-remote 之前先过 URL
|
||
白名单(file:// 须在 state_dir/CHV_SNAPSHOT_DIR 内,
|
||
远端须显式放行)与数量/总量/保留期上限;成功后登
|
||
记并在总量超帽时按最旧优先剪枝(删动作审计)。
|
||
feat-chv-p1fix(P1-08):保留期剪枝与总量帽剪枝的
|
||
删文件成功/失败均发审计;失败路径统一发
|
||
vm_snapshot_failed 审计再抛(此前失败路径无任何
|
||
快照失败审计,只有 wake 前置失败才有 vm_failed)。
|
||
本体拆到 _snapshot_create 以守 50 行硬门禁。"""
|
||
if not snapshot_url:
|
||
raise ValueError("snapshot_url 不能为空")
|
||
task = task_or_name if isinstance(task_or_name, dict) \
|
||
else {"id": task_or_name}
|
||
vm_name = self.vm_name(task_or_name)
|
||
try:
|
||
self._snapshot_create(task_or_name, task, vm_name,
|
||
snapshot_url)
|
||
except Exception as exc:
|
||
kind = getattr(exc, "kind", None) or type(exc).__name__
|
||
self._emit("vm_snapshot_failed", task,
|
||
"vm=%s snapshot=%s kind=%s error=%s"
|
||
% (vm_name, snapshot_url, kind, exc), kind)
|
||
raise
|
||
self._emit("vm_snapshot", task,
|
||
"vm=%s snapshot=%s"
|
||
% (self.vm_name(task_or_name), snapshot_url))
|
||
return snapshot_url
|
||
|
||
def _snapshot_create(self, task_or_name, task, vm_name,
|
||
snapshot_url) -> None:
|
||
"""快照创建段:白名单→上限/保留期剪枝→ch-remote→
|
||
落盘校验→登记/总量剪枝(见 snapshot_vm)。"""
|
||
validate_snapshot_url(
|
||
snapshot_url, self.state_dir,
|
||
snapshot_dir=getattr(self, "snapshot_dir", None),
|
||
allow_remote=getattr(
|
||
self, "allow_remote_snapshot", False))
|
||
prune_events: list = []
|
||
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),
|
||
events=prune_events)
|
||
self._emit_prune_events(task, vm_name, prune_events, "保留期")
|
||
self._ch_remote(task_or_name, "snapshot",
|
||
"source_url=%s" % snapshot_url)
|
||
# feat-chv-fix(P1-7):只查 returncode 不够。file://
|
||
# 快照须验证目标文件存在且非空,否则报
|
||
# snapshot_not_persisted,调用方(hibernate_vm)据此
|
||
# 不 shutdown、不销毁原 VM;非 file:// 目标无法从宿
|
||
# 主验证落盘,仍只认 returncode,但文档明写该边界。
|
||
if snapshot_url.startswith("file://"):
|
||
_snap_path = snapshot_url[len("file://"):]
|
||
if not os.path.isfile(_snap_path) \
|
||
or os.path.getsize(_snap_path) == 0:
|
||
raise ChvFailure(
|
||
"snapshot_not_persisted",
|
||
"快照未落盘或为空:%s" % snapshot_url)
|
||
record_events: list = []
|
||
record_snapshot(
|
||
self.state_dir, vm_name, snapshot_url,
|
||
getattr(self, "snapshot_max_total_gb", 20.0),
|
||
events=record_events)
|
||
self._emit_prune_events(task, vm_name, record_events,
|
||
"总量超帽")
|
||
|
||
def _emit_prune_events(self, task, vm_name, events, reason) -> None:
|
||
"""剪枝事件转审计(feat-chv-p1fix,P1-08):成功删
|
||
文件与登记剪枝发 vm_snapshot_pruned,删失败发
|
||
vm_snapshot_prune_failed;无文件可删的登记剪枝只
|
||
发 pruned(删动作本身不存在,无失败可言)。"""
|
||
deleted = 0
|
||
failed = 0
|
||
for status, entry, error in events:
|
||
url = entry.get("url") if isinstance(entry, dict) else None
|
||
if status == "delete_failed":
|
||
failed += 1
|
||
self._emit("vm_snapshot_prune_failed", task,
|
||
"vm=%s pruned snapshot=%s reason=%s "
|
||
"error=%s" % (vm_name, url, reason, error))
|
||
else:
|
||
deleted += 1
|
||
self._emit("vm_snapshot_pruned", task,
|
||
"vm=%s pruned snapshot=%s reason=%s "
|
||
"deleted=%s" % (
|
||
vm_name, url, reason,
|
||
status == "deleted"))
|
||
if failed:
|
||
_LOGGER.warning("chv_snapshot_prune_failed vm=%s "
|
||
"reason=%s failed=%d", vm_name, reason,
|
||
failed)
|
||
|
||
def _validate_restore(self, task, snapshot_url):
|
||
"""restore 前置校验:返回归一化后的 snapshot_url。
|
||
|
||
restore 不走 vm.create、没有 VmConfig 可挂 seed:
|
||
快照内已固化原 VM host key,重预置反而破坏钉死,
|
||
故 provisioned 下复用已钉死的 known_hosts、缺失
|
||
fail-fast、绝不 keyscan、绝不重生成。restore 时不
|
||
重复验快照文件存在(远端/对象存储源合法,ch-remote
|
||
本身会在源缺失时失败)。"""
|
||
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.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
|
||
|
||
def _restore_boot(self, task, snapshot_url):
|
||
"""restore 的启动段:起 VMM → 等 ping → restore →
|
||
显式 resume → 等 SSH 就绪(恢复后默认 paused,不
|
||
resume 就不算醒)。"""
|
||
name = self.vm_name(task)
|
||
self._launch_api_only(task)
|
||
self.wait_api_ready(task)
|
||
self._check_api_socket_permissions(self.api_socket(task))
|
||
self._ch_remote(task, "restore",
|
||
"source_url=%s" % snapshot_url)
|
||
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"}。feat-chv-p1fix(P1-05):与 wake
|
||
同款,全程持 state 锁——restore 同样查容量、建 VM,
|
||
不持锁会与并发 wake 互相超限。"""
|
||
with self._state_lock_ctx():
|
||
return self._restore_vm_locked(task, snapshot_url)
|
||
|
||
def _restore_vm_locked(self, task: dict, snapshot_url: str) -> dict:
|
||
"""持 state 锁的 restore 本体(见 restore_vm)。"""
|
||
snapshot_url = self._validate_restore(task, snapshot_url)
|
||
name = self.vm_name(task)
|
||
self._check_capacity(exclude=name)
|
||
try:
|
||
self._restore_boot(task, snapshot_url)
|
||
except Exception:
|
||
try:
|
||
self.stop_vm(task)
|
||
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。"""
|
||
self.pause_vm(task_or_name)
|
||
self.snapshot_vm(task_or_name, snapshot_url)
|
||
self.stop_vm(task_or_name)
|
||
# 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 _cleanup_vm_files(self, name, task):
|
||
"""stop 的三块文件清理:seed/私钥、任务磁盘、net 登记。
|
||
|
||
任一块失败记对应审计并返回 True(脏标记由调用方写);
|
||
seed/私钥与磁盘删除的审计名与文案逐字不变。"""
|
||
from cloud.chv.hostkeys import dirty_marker_path # noqa: F401
|
||
failed = False
|
||
try:
|
||
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"):
|
||
_p = os.path.join(self.state_dir, name, _suffix)
|
||
if os.path.exists(_p):
|
||
os.remove(_p)
|
||
except Exception as _cleanup_exc:
|
||
failed = True
|
||
self._emit("vm_key_cleanup_failed", task,
|
||
"vm=%s error=%s" % (name, _cleanup_exc))
|
||
try:
|
||
if 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:
|
||
failed = True
|
||
self._emit("vm_disk_delete_failed", task,
|
||
"vm=%s error=%s" % (name, _disk_exc))
|
||
try:
|
||
_net_path = os.path.join(self.state_dir, name, "net.json")
|
||
if os.path.exists(_net_path):
|
||
os.remove(_net_path)
|
||
except Exception:
|
||
failed = True
|
||
self._guest_env_delivered.pop(name, None)
|
||
return failed
|
||
|
||
def _write_dirty_marker(self, name, failed):
|
||
"""M2 脏标记:清理失败写标记、成功清标记;写失败不炸关闭。"""
|
||
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)
|
||
with open(_marker, "w") as fh:
|
||
fh.write("cleanup failed\n")
|
||
elif os.path.exists(_marker):
|
||
os.remove(_marker)
|
||
except Exception:
|
||
pass
|
||
|
||
def stop_vm(self, task_or_name: dict | str) -> bool:
|
||
"""停止 VM(尽力而为,不抛异常):已死进程直接清登记,
|
||
已关 VM 仍尽力调一次 ch-remote(返回 False),socket
|
||
残留删 socket(返回 True)。ch-remote shutdown 失败
|
||
不炸,但命令本身仍会发出(测试据此断言关闭路径)。"""
|
||
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)
|
||
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)
|
||
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,VM
|
||
非 live(无 api-socket 文件且无登记活进程)即删盘
|
||
并审计;删失败审计 vm_disk_delete_failed 并计入
|
||
failed。返回 {deleted, failed}。"""
|
||
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
|
||
disk = task_disk_path(self.state_dir, entry)
|
||
if not (os.path.islink(disk) or os.path.exists(disk)):
|
||
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 目录同理删。返回 (删私钥, 删 seed,
|
||
删失败)。
|
||
|
||
feat-chv-p1fix(P1-03):删失败不再静默 pass——
|
||
第三元组就是失败标志,调用方据此发审计、写脏标记,
|
||
删失败的 VM 绝不进 cleaned(调用方可区分成功与失败)。
|
||
"""
|
||
removed_priv = False
|
||
failed = 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:
|
||
failed = True
|
||
removed_seed = False
|
||
if not self._is_live_vm(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:
|
||
failed = True
|
||
return removed_priv, removed_seed, failed
|
||
|
||
def cleanup_stale_secrets(self) -> list:
|
||
"""崩溃残留扫描(H5):私钥一律删、非 live VM 的 seed
|
||
同理删并审计;返回已清理 VM 名。worker 启动与
|
||
cleanup_stale_vms 时调它,不依赖 socket 是否存在。
|
||
feat-chv-p1fix(P1-03):删失败的 VM 不进 cleaned,
|
||
另发 host_secret_residue_delete_failed 审计并写脏
|
||
标记(下次 provision_host_key 见标记即拒起,见
|
||
vm_dirty_refused),直至人工清理——最敏感物残留
|
||
不再“重试前不可观测”。"""
|
||
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, failed = \
|
||
self._clean_one_vm_secrets(entry, vm_dir)
|
||
if failed:
|
||
self._emit("host_secret_residue_delete_failed",
|
||
{"id": entry},
|
||
"vm=%s private_key_removed=%s "
|
||
"seed_removed=%s" % (
|
||
entry, removed_priv, removed_seed))
|
||
self._write_dirty_marker(entry, True)
|
||
# 删失败时即使部分删掉了,也不进 cleaned:残留仍在,
|
||
# “已清理”名单里绝不出现未清干净的 VM。
|
||
if not failed and (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 一律跳过。
|
||
feat-chv-p1fix(P1-05):登记进程的读持 _vms_lock,
|
||
不再无锁读 dict(与 purge/注册并发会读到中间态)。"""
|
||
cleaned = []
|
||
for entry in entries:
|
||
if not entry.endswith(".sock"):
|
||
continue
|
||
name = entry[:-len(".sock")]
|
||
if not name.startswith("chv-"):
|
||
continue
|
||
with self._vms_lock:
|
||
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 可删,不计入清理数)。
|
||
feat-chv-p1fix(P1-05):先在锁内快照键列表,再
|
||
逐项在锁内读/删——旧“无锁 pop 与持锁遍历并发”
|
||
会致 dictionary changed size during iteration,
|
||
且 pop 本身无锁。"""
|
||
with self._vms_lock:
|
||
names = list(self._vms.keys())
|
||
for name in names:
|
||
with self._vms_lock:
|
||
proc = self._vms.get(name)
|
||
try:
|
||
dead = proc is not None and proc.poll() is not None
|
||
except Exception:
|
||
dead = True
|
||
if dead:
|
||
with self._vms_lock:
|
||
self._vms.pop(name, None)
|
||
|
||
def cleanup_stale_vms(self) -> list:
|
||
"""清理残留 VM:先跑残留盘 GC 与残留密钥扫描,再走
|
||
socket 扫描与登记清理。只清 socket 残留与已死进程
|
||
对应的 VM;state_dir 不存在返回 [],绝不抛异常。
|
||
feat-chv-p1fix(P1-05):全程持 state 锁,与 wake/
|
||
restore 互斥——清理删盘/删 socket 时不许另一路
|
||
wake 同时为同名 VM 供盘、建 VM。"""
|
||
with self._state_lock_ctx():
|
||
return self._cleanup_stale_vms_locked()
|
||
|
||
def _cleanup_stale_vms_locked(self) -> list:
|
||
"""持 state 锁的 cleanup 本体(见 cleanup_stale_vms)。"""
|
||
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, **kwargs):
|
||
"""SSH 调客内 host.py,返回 Local/Docker 同款结果 dict
|
||
(executor 字段改写为 cloud-hypervisor,并附 vm 名与
|
||
api-socket)。runner 异常即原样传播,由 worker 侧统一
|
||
写回 failed——本类不在这里把异常吞成假成功。
|
||
extra_env(P1-1):经 SSH env 前缀进客机环境。"""
|
||
self._validate()
|
||
ssh_cmd = self.build_ssh_command(
|
||
task, self._guest_host_command(*inner_args, **kwargs),
|
||
extra_env=extra_env)
|
||
result = self._call_runner(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(task)
|
||
parsed["api_socket"] = self.api_socket(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,
|
||
**kwargs):
|
||
self._validate()
|
||
ssh_cmd = self.build_ssh_command(
|
||
task, self._guest_host_command(*inner_args, **kwargs),
|
||
extra_env=extra_env)
|
||
return self._goal_result(
|
||
task["id"],
|
||
self._call_runner(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 启动。
|
||
|
||
中途死亡分类(本轨新增):客内命令抛传输异常、或
|
||
非 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 客内密钥文件)。"""
|
||
self.wake_vm(task, extra_env=extra_env)
|
||
try:
|
||
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
|
||
finally:
|
||
try:
|
||
self.shutdown_vm(task)
|
||
except Exception:
|
||
pass
|
||
|
||
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 必须一致);网关任务在 worker
|
||
侧仍被 loud fail 守卫拦住,不到这里。模型 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)
|
||
|
||
|