feat(chv-muse-p1): 常驻 VM + WarmPool 扶正 + 气球 + vsock,对齐 Muse
每用户一台常驻 VM(暂停态,resume 到 SSH 5 秒内为待验证目标), 池空/失败回落冷启动;CHV 配置加 virtio-balloon + free-page-reporting 与 vsock notify 最小实现(SSH 仍是命令通道与就绪判定)。 新模块 resident_pool.py / vsock.py + 客内 vsock_notify.py; _config 增加 CHV_RESIDENT_IDLE_MINUTES / CHV_BALLOON / CHV_VSOCK; worker_adapter 接常驻孤儿清理 facade;ADR-17 差距表见 docs/chv-muse-p1-gap.md。 恢复收尾(分离后回归,见 gap.md §4):VmDeathTest 死亡模拟改杀 实际跑命令的 VM;两处冷路径旧测试显式关常驻;_balloon_vsock_payload 与 listen_notify 拆到 50 行内;resident_pool.py:208 mypy 修掉。 executor.py out 未注解与 marker_tools.py:142 是基线旧错,未动。 测试:全量 cloud 套件 1934 OK(skipped=8);mypy 在改动文件上 干净(仅基线旧错 4 处)。真机验证待第三棒让出 WSL 测试机。
This commit is contained in:
1 parent
06c3e172fe
commit
78098c5594
13 files changed
+2527
-36
No files matched your search
+61
-5
@@ -259,6 +259,60 @@ def _gitea_fragment(env: dict) -> dict:
|
||||
return {"gitea_sync_env": config_gitea_sync_env(env)}
|
||||
|
||||
|
||||
def _to_switch(raw: object, name: str) -> bool | None:
|
||||
"""CHV_* 开关解析(P1 气球/vsock 用):未设 → None(调用方
|
||||
再定默认值);"1" 开、"0" 关;其它值一律 ValueError,绝不
|
||||
静默当关(与 CHV_SSH_FORWARD 的 `== "1"` 旧口径不同,本轨
|
||||
新开关按差距表要求非法值即报错)。"""
|
||||
if raw is None:
|
||||
return None
|
||||
if raw == "1":
|
||||
return True
|
||||
if raw == "0":
|
||||
return False
|
||||
raise ValueError("%s 只能是 0 或 1:%r" % (name, raw))
|
||||
|
||||
|
||||
def _balloon_vsock_fragment(env: dict) -> dict:
|
||||
"""气球/vsock 片段(P1,feat-chv-muse-p1):virtio-balloon
|
||||
+ free-page-reporting 开关、vsock notify 通道开关与客 CID。
|
||||
|
||||
- CHV_BALLOON:默认 1(开)。只认 "1"/"0",其它值
|
||||
ValueError。
|
||||
- CHV_VSOCK:默认 1(开)。只认 "1"/"0",其它值
|
||||
ValueError。
|
||||
- CHV_VSOCK_CID:客机 vsock CID,可选,默认 3。必须是
|
||||
整数且在 3-4294967295 之间:0/1/2 是 vsock 保留 CID
|
||||
(Hypervisor/Loopback/Host,CHV 的 VmConfig 校验会
|
||||
以 VsockSpecialCid 拒绝),-1(随机 CID)CHV 的
|
||||
vm.create 同样 400 拒绝(上游 discussion #7531),
|
||||
所以这里不接受保留值/负值;显式设了非法 CID 即
|
||||
ValueError,哪怕 CHV_VSOCK=0(配错早炸,不留到起 VM
|
||||
才暴露)。CID 由运维指定、不是 CHV 分配的:同一宿主
|
||||
上各 VM 的 vsock socket 文件不同,CID 相同(默认 3)
|
||||
并不冲突。"""
|
||||
balloon = _to_switch(env.get("CHV_BALLOON"), "CHV_BALLOON")
|
||||
if balloon is None:
|
||||
balloon = True
|
||||
vsock = _to_switch(env.get("CHV_VSOCK"), "CHV_VSOCK")
|
||||
if vsock is None:
|
||||
vsock = True
|
||||
raw_cid = env.get("CHV_VSOCK_CID")
|
||||
if raw_cid is None:
|
||||
vsock_cid = 3
|
||||
else:
|
||||
try:
|
||||
vsock_cid = _to_int(raw_cid)
|
||||
except ValueError:
|
||||
raise ValueError("CHV_VSOCK_CID 必须是整数")
|
||||
if not 3 <= vsock_cid <= 0xFFFFFFFF:
|
||||
raise ValueError(
|
||||
"CHV_VSOCK_CID 必须在 3-4294967295 之间:%s"
|
||||
% vsock_cid)
|
||||
return {"balloon": balloon, "vsock": vsock,
|
||||
"vsock_cid": vsock_cid}
|
||||
|
||||
|
||||
def _seed_fragment(params: dict, env: dict) -> dict:
|
||||
"""seed 附加项片段:只存宿主文件路径,不读文件内容
|
||||
(读文件在 wake 时 fail-closed,见执行器 _seed_extras;
|
||||
@@ -295,17 +349,19 @@ def build_chv_config(params: dict, env: dict) -> "ChvConfig":
|
||||
"""把显式构造参数与环境变量解析成 ChvConfig。
|
||||
|
||||
片段调用顺序即旧校验顺序(SSH → 配额 → 磁盘 →
|
||||
超时),只有一处可观测的顺序变化:warm_pool_size
|
||||
从旧超时片段移到配额片段,因此“CHV_WARM_POOL_SIZE
|
||||
非法 + 磁盘非法同时出现”时先报前者;单项非法时
|
||||
报错与旧代码逐字一致。字段集合与取值与旧代码一致,
|
||||
由测试的字段集断言钉住。"""
|
||||
气球/vsock → 超时),只有两处可观测的顺序变化:
|
||||
warm_pool_size 从旧超时片段移到配额片段,因此
|
||||
“CHV_WARM_POOL_SIZE 非法 + 磁盘非法同时出现”时先报
|
||||
前者;单项非法时报错与旧代码逐字一致。字段集合 =
|
||||
旧代码字段 + P1 气球/vsock 三字段(balloon/vsock/
|
||||
vsock_cid),由测试的字段集断言钉住。"""
|
||||
env = env if env is not None else {}
|
||||
return _assemble_chv_config([
|
||||
_base_fragment(params, env),
|
||||
_ssh_fragment(params, env),
|
||||
_quota_fragment(env),
|
||||
_disk_fragment(env),
|
||||
_balloon_vsock_fragment(env),
|
||||
_network_fragment(params, env),
|
||||
_timeout_fragment(params, env),
|
||||
_gitea_fragment(env),
|
||||
|
||||
+227
-25
@@ -97,6 +97,10 @@ class CloudHypervisorExecutor(Executor):
|
||||
SSH 直接复用 OpenSSH 的密钥认证与端口转发,云镜像自带
|
||||
sshd,无需在客内再写一个 vsock agent、也无需在宿主再写
|
||||
一个 vsock 代理去翻译 host.py 协议。
|
||||
- P1(feat-chv-muse-p1)补充:vsock 只做最小 notify
|
||||
(客机发 ready/心跳,见 cloud/chv/vsock.py 与
|
||||
cloud/chv/guest/vsock_notify.py),与 SSH 并存;SSH
|
||||
仍是唯一命令通道,host key 钉死不动。
|
||||
- 放弃 vsock 的代价:需要运维提供从宿主到客机的 SSH 通路
|
||||
(tap/桥接 + 端口转发,见下文“VM 生命周期”)。
|
||||
- Host key 钉死(本轨 #216 后的加固,默认行为变化):
|
||||
@@ -131,8 +135,8 @@ class CloudHypervisorExecutor(Executor):
|
||||
“true”命令,直到 CHV_READY_TIMEOUT(默认 120 秒,
|
||||
轮询间隔 CHV_READY_INTERVAL,默认 5 秒)内成功才跑
|
||||
host.py/任务;超时则关 VM 并报失败,绝不在未就绪时
|
||||
就派任务。vsock 放弃理由同客内通道一节(需客内 agent
|
||||
+ 宿主代理两套新代码,v1 不拼半吊子)。
|
||||
就派任务。P1 的 vsock ready 只做就绪辅助信号
|
||||
(cloud/chv/vsock.py),绝不取代 SSH 就绪判定。
|
||||
- 暂停与快照唤醒是两条独立代码分支:
|
||||
pause_vm()/resume_vm() 走 ch-remote pause/resume;
|
||||
restore_vm() 先 ch-remote restore(source_url=...),
|
||||
@@ -230,6 +234,12 @@ class CloudHypervisorExecutor(Executor):
|
||||
- CHV_API_TIMEOUT:等 vmm.ping 通的上限秒数(默认 30)
|
||||
- CHV_READY_TIMEOUT:等 guest SSH 就绪的上限秒数(默认 120)
|
||||
- CHV_READY_INTERVAL:SSH 就绪轮询间隔秒数(默认 5)
|
||||
- CHV_BALLOON:virtio-balloon + free-page-reporting 开关
|
||||
(默认 1 开;只认 0/1,非法值 ValueError)
|
||||
- CHV_VSOCK:vsock notify 通道开关(默认 1 开;只认
|
||||
0/1,非法值 ValueError)
|
||||
- CHV_VSOCK_CID:客机 vsock CID(默认 3;须在
|
||||
3-4294967295 内,0/1/2 为保留 CID)
|
||||
|
||||
前置校验(preflight,执行入口必调,缺一即 fail-fast):
|
||||
收集全部缺失项一次报出具体缺哪一项,绝不笼统报错:
|
||||
@@ -282,6 +292,9 @@ class CloudHypervisorExecutor(Executor):
|
||||
task_timeout: float | None
|
||||
snapshot_url: str | None
|
||||
warm_pool_size: int
|
||||
balloon: bool
|
||||
vsock: bool
|
||||
vsock_cid: int
|
||||
api_timeout: float
|
||||
ready_timeout: float
|
||||
ready_interval: float
|
||||
@@ -431,7 +444,8 @@ class CloudHypervisorExecutor(Executor):
|
||||
"snapshot_max_total_gb", "snapshot_retention_days",
|
||||
"snapshot_dir", "allow_remote_snapshot",
|
||||
"gitea_sync_env", "seed_user_data_extra_file",
|
||||
"seed_network_config_file"):
|
||||
"seed_network_config_file", "balloon", "vsock",
|
||||
"vsock_cid"):
|
||||
setattr(self, _field, getattr(_cfg, _field))
|
||||
|
||||
# -- 名字 / 路径 ---------------------------------------------------
|
||||
@@ -1024,7 +1038,8 @@ class CloudHypervisorExecutor(Executor):
|
||||
"image_type": "Raw"})
|
||||
return disks
|
||||
|
||||
def build_vm_config(self, task_disk: str | None = None, seed_disk: str | None = None) -> dict:
|
||||
def build_vm_config(self, task_disk: str | None = None, seed_disk: str | None = None,
|
||||
vsock_socket: str | None = None) -> dict:
|
||||
"""API 模式的 VmConfig(vm.create 请求体,纯函数):
|
||||
cpus{boot_vcpus,max_vcpus}、memory{size: 字节}、
|
||||
payload{kernel, cmdline}、disks[{path: 每任务磁盘}]、
|
||||
@@ -1037,7 +1052,11 @@ class CloudHypervisorExecutor(Executor):
|
||||
旧版顶层 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 实测)。"""
|
||||
WSL 真机 CHV 54.0.0 实测)。
|
||||
P1(feat-chv-muse-p1):默认带 balloon/vsock 设备
|
||||
(见 _balloon_vsock_payload;CHV_BALLOON/CHV_VSOCK
|
||||
可关)。vsock_socket 显式给时用它,否则按任务盘
|
||||
位置派生每 VM 唯一路径(_boot_vm 未传时即走派生)。"""
|
||||
try:
|
||||
cpus = int(self.cpus)
|
||||
except ValueError:
|
||||
@@ -1054,6 +1073,8 @@ class CloudHypervisorExecutor(Executor):
|
||||
"disks": self._vm_config_disks(task_disk, seed_disk),
|
||||
"rng": {"src": "/dev/urandom"},
|
||||
}
|
||||
config.update(self._balloon_vsock_payload(
|
||||
vsock_socket=vsock_socket, task_disk=task_disk))
|
||||
if self.net_json:
|
||||
try:
|
||||
net = json.loads(self.net_json)
|
||||
@@ -1062,6 +1083,67 @@ class CloudHypervisorExecutor(Executor):
|
||||
config["net"] = net if isinstance(net, list) else [net]
|
||||
return config
|
||||
|
||||
def _balloon_vsock_payload(self, vsock_socket=None,
|
||||
task_disk=None) -> dict:
|
||||
"""P1(feat-chv-muse-p1):VmConfig 的 balloon/vsock
|
||||
设备(CHV 54 API,VmConfig 顶层键;字段名以 CHV
|
||||
OpenAPI 生成的客户端为准:BalloonConfig{size,
|
||||
deflate_on_oom, free_page_reporting}、VsockConfig{
|
||||
cid, socket};本机无 /dev/kvm,尚未真机验证,见下)。
|
||||
|
||||
balloon(默认开):{"size": 0, "deflate_on_oom": true,
|
||||
"free_page_reporting": true}。size 是目标气球尺寸
|
||||
(字节):0 = 启动时不预扣客机内存,只靠
|
||||
free_page_reporting 让客机持续上报空闲页、宿主
|
||||
MADV_DONTNEED 回收——被动回收,不主动 inflate。
|
||||
deflate_on_oom 是安全网:日后若经 vm.resize 充气
|
||||
后客机承压,气球自动放气而不 OOM。待真机验证:
|
||||
size=0 是否被 CHV 54 接受(若拒绝,改 size 为 VM
|
||||
内存字节数并重新验证,见汇报);气球实际省内存量。
|
||||
vsock(默认开):{"cid": <CHV_VSOCK_CID,默认 3>,
|
||||
"socket": <宿主侧 Unix socket 路径>}。cid 是客机
|
||||
CID(0/1/2 保留,宿主永远是 CID 2,客机连宿主用
|
||||
CID 2;CHV 不分配 CID,由配置指定)。socket 路径
|
||||
派生:显式给 vsock_socket 时用它;否则任务盘在
|
||||
state_dir 内时落任务盘同目录(state_dir/<vm>/
|
||||
vsock.sock,每 VM 唯一);reuse/lab(任务盘即基础
|
||||
镜像或未给)时落 state_dir/vsock.sock——单 VM 可
|
||||
用,多 VM 并发会在 vm.create 撞路径(大声失败,
|
||||
不静默),_boot_vm 后续接线应显式传每 VM 路径。
|
||||
宿主侧监听在 <socket>_<port> 上(port 见
|
||||
cloud/chv/vsock.py 的 VSOCK_NOTIFY_PORT),客机
|
||||
连 CID 2 发 ready/心跳(cloud/chv/guest/
|
||||
vsock_notify.py)。开关关时对应键整个不出现
|
||||
(不是发 false),旧行为(无设备)原样保留。"""
|
||||
extra: dict = {}
|
||||
if getattr(self, "balloon", True):
|
||||
extra["balloon"] = {
|
||||
"size": 0, "deflate_on_oom": True,
|
||||
"free_page_reporting": True,
|
||||
}
|
||||
if getattr(self, "vsock", True):
|
||||
extra["vsock"] = {
|
||||
"cid": getattr(self, "vsock_cid", 3),
|
||||
"socket": self._resolve_vsock_socket(
|
||||
vsock_socket, task_disk),
|
||||
}
|
||||
return extra
|
||||
|
||||
def _resolve_vsock_socket(self, vsock_socket, task_disk):
|
||||
"""vsock socket 路径派生(_balloon_vsock_payload 的
|
||||
子步,单拆只为 50 行规矩,行为不动):显式给定时
|
||||
用它;否则任务盘在 state_dir 内时落任务盘同目录
|
||||
(每 VM 唯一),reuse/lab 时落 state_dir/vsock.sock
|
||||
(多 VM 并发会撞路径,大声失败)。"""
|
||||
if vsock_socket is not None:
|
||||
return vsock_socket
|
||||
if task_disk and os.path.realpath(
|
||||
task_disk).startswith(
|
||||
os.path.realpath(self.state_dir) + os.sep):
|
||||
return os.path.join(
|
||||
os.path.dirname(task_disk), "vsock.sock")
|
||||
return os.path.join(self.state_dir, "vsock.sock")
|
||||
|
||||
def _api_call(self, socket_path, method, path, payload=None):
|
||||
"""API 调用归一化(实现在 cloud/chv/api_client.py)。"""
|
||||
return normalize_api_result(
|
||||
@@ -2245,27 +2327,118 @@ class CloudHypervisorExecutor(Executor):
|
||||
"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 启动。
|
||||
def _resident_enabled(self):
|
||||
"""常驻默认开;CHV_RESIDENT_ENABLED=0/false/no/off 关。
|
||||
优先认执行器上已有的 resident_enabled 属性(P1-B 若
|
||||
经 config 落属性,直接复用),否则惰性读 env;__init__
|
||||
不动,避免与 P1-B 的 __init__ 改动撞车。"""
|
||||
flag = getattr(self, "resident_enabled", None)
|
||||
if flag is None:
|
||||
env = getattr(self, "_env", None)
|
||||
if env is None:
|
||||
env = os.environ
|
||||
flag = env.get("CHV_RESIDENT_ENABLED", "1")
|
||||
if isinstance(flag, str):
|
||||
return flag.strip().lower() not in (
|
||||
"0", "false", "no", "off")
|
||||
return bool(flag)
|
||||
|
||||
中途死亡分类(本轨新增):客内命令抛传输异常、或
|
||||
非 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 客内密钥文件)。"""
|
||||
def _resident_pool_instance(self):
|
||||
"""惰性建唯一常驻池并缓存(方法内 import:执行器顶层
|
||||
绝不 import 池/orphans,见 ADR-12 的 AST 钉子)。
|
||||
同一 state_dir 只活一个池实例:registry 只在构造时
|
||||
读一次,重启后条目一律 orphan(见 resident_pool)。"""
|
||||
pool = getattr(self, "_resident_pool", None)
|
||||
if pool is not None:
|
||||
return pool
|
||||
with self._vms_lock:
|
||||
pool = getattr(self, "_resident_pool", None)
|
||||
if pool is None:
|
||||
from cloud.chv.resident_pool import ResidentPool
|
||||
pool = ResidentPool(self)
|
||||
self._resident_pool = pool
|
||||
return pool
|
||||
|
||||
def _resident_acquire(self, task, extra_env=None):
|
||||
"""常驻获取(best-effort,一律不抛,失败回 None):
|
||||
未启用/带密钥 env → None(走冷启动;密钥须随 seed
|
||||
投递,常驻 VM 的 seed 是建池时定的,带密钥任务只能
|
||||
冷启动)。有 paused 条目 → resume + SSH 就绪;池空/
|
||||
失败 → 惰性 provision 一次再取,仍无 → None。返回
|
||||
{"task": 常驻伪任务, "pool": 池, "user_id": 用户}。"""
|
||||
try:
|
||||
if not self._resident_enabled():
|
||||
return None
|
||||
secrets, _plain = split_secret_env(extra_env)
|
||||
if secrets:
|
||||
return None
|
||||
pool = self._resident_pool_instance()
|
||||
user_id = task.get("user_id") \
|
||||
if isinstance(task, dict) else None
|
||||
acquired = pool.acquire(user_id)
|
||||
if acquired.get("mode") == "resident":
|
||||
return {"task": acquired["task"], "pool": pool,
|
||||
"user_id": user_id}
|
||||
provisioned = pool.provision(user_id)
|
||||
if provisioned.get("mode") != "resident":
|
||||
return None
|
||||
acquired = pool.acquire(user_id)
|
||||
if acquired.get("mode") != "resident":
|
||||
return None
|
||||
return {"task": acquired["task"], "pool": pool,
|
||||
"user_id": user_id}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _run_on_resident(self, task, resident_task, inner_args,
|
||||
goal=False, extra_env=None, **kwargs):
|
||||
"""常驻路径执行(_run_with_vm 的常驻分支本体):
|
||||
_run_guest/_run_guest_goal 一律走常驻伪任务传参(SSH
|
||||
known_hosts、api-socket、密钥投递记录、VMM 登记都按
|
||||
常驻 VM 名算),返回后把 task_id 改回真实任务 id;
|
||||
VM 中途死亡判据同样按常驻伪任务查登记进程,死亡
|
||||
结论里的任务 id 用真实任务。finally 的 pause 在调
|
||||
用方(_run_with_vm)做,这里只跑命令。"""
|
||||
try:
|
||||
if goal:
|
||||
result = self._run_guest_goal(
|
||||
resident_task, inner_args, extra_env=extra_env,
|
||||
**kwargs)
|
||||
else:
|
||||
result = self._run_guest(
|
||||
resident_task, inner_args, extra_env=extra_env,
|
||||
**kwargs)
|
||||
except Exception as exc:
|
||||
if self._vm_proc_dead(resident_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(resident_task):
|
||||
result = dict(result)
|
||||
result["status"] = "interrupted"
|
||||
result["vm_dead"] = True
|
||||
result["interrupted_reason"] = (
|
||||
"VM 中途死亡,任务未完成、断点可续跑:"
|
||||
"客内命令已 failed 且登记的 VMM 进程已死")
|
||||
if isinstance(result, dict):
|
||||
result = dict(result)
|
||||
result["task_id"] = task["id"]
|
||||
return result
|
||||
|
||||
def _run_cold(self, task, inner_args, goal=False, extra_env=None,
|
||||
**kwargs):
|
||||
"""冷启动路径(原 _run_with_vm 本体,逐字搬运):
|
||||
wake_vm(API 模式 + SSH 就绪)→ 跑客内命令 →
|
||||
finally shutdown_vm。wake 失败时不跑任务、直接抛
|
||||
分类失败;本方法不做内部重试(重试是 worker 的
|
||||
max_retries 语义)。中途死亡分类:客内命令抛传输
|
||||
异常、或非 goal 结果已 failed 时,若登记的 VMM 进程
|
||||
已死,判 interrupted(断点可续跑);VM 还活着时的
|
||||
异常/失败原样处理。feat-chv-secfix(H2):extra_env
|
||||
透传给 wake_vm(密钥子集在 provisioned 模式下随 seed
|
||||
投递),再透传给 _run_guest(远程命令 source 客内
|
||||
密钥文件)。"""
|
||||
self.wake_vm(task, extra_env=extra_env)
|
||||
try:
|
||||
try:
|
||||
@@ -2294,6 +2467,35 @@ class CloudHypervisorExecutor(Executor):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _run_with_vm(self, task, inner_args, goal=False, extra_env=None,
|
||||
**kwargs):
|
||||
"""任务 VM 生命周期(feat-chv-muse-p1,见 ADR-17):
|
||||
常驻启用时先 _resident_acquire——paused 常驻 VM 经
|
||||
resume + SSH 就绪即跑,跑完 finally 里 release(pause,
|
||||
不 shutdown);池空/失败/未启用/带密钥 env 时走冷启动
|
||||
(_run_cold:wake_vm → 跑客内命令 → finally shutdown)。
|
||||
常驻失败一律回落冷启动,绝不因池坏而丢任务;WarmPool
|
||||
仍绝不查(实验模块,wired_to_dispatch 恒 False)。
|
||||
wake 失败时不跑任务、直接抛分类失败;本方法不做内部
|
||||
重试(重试是 worker 的 max_retries 语义)。中途死亡
|
||||
判 interrupted(断点可续跑),见 _run_cold 与
|
||||
_run_on_resident。dispatch/run/goal/goal_resume/断点
|
||||
续跑都走本方法,签名不动。"""
|
||||
resident = self._resident_acquire(task, extra_env=extra_env)
|
||||
if resident is not None:
|
||||
pool = resident["pool"]
|
||||
try:
|
||||
return self._run_on_resident(
|
||||
task, resident["task"], inner_args, goal=goal,
|
||||
extra_env=extra_env, **kwargs)
|
||||
finally:
|
||||
try:
|
||||
pool.release(resident["user_id"])
|
||||
except Exception:
|
||||
pass
|
||||
return self._run_cold(task, inner_args, goal=goal,
|
||||
extra_env=extra_env, **kwargs)
|
||||
|
||||
def execute(self, task: dict) -> dict:
|
||||
"""只读执行:客内 host.py status,不直接跑模型回合
|
||||
(与 Local/Docker 的 execute 同款语义)。
|
||||
|
||||
Executable
+136
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""客内 vsock notify 脚本(P1,feat-chv-muse-p1)。
|
||||
|
||||
启动后连宿主 CID 2 的 1024 端口发 ready,之后每 30 秒发一次
|
||||
heartbeat。消息是 JSON 行:
|
||||
|
||||
{"type": "ready"|"heartbeat", "vm": "<vm 名>", "ts": <unix 秒>}
|
||||
|
||||
宿主在 <vsock socket>_1024 这个 Unix socket 上监听接收
|
||||
(cloud/chv/vsock.py 的 wait_ready/listen_notify)。
|
||||
|
||||
失败一律静默退出(exit 0,不打日志、不留 traceback):vsock
|
||||
只是就绪辅助信号,SSH 仍是唯一命令通道与就绪判定——本脚本
|
||||
绝不能因为连不上、发失败而影响 sshd 或任务。调试时设
|
||||
VSOCK_NOTIFY_DEBUG=1 可在 stderr 看到异常。
|
||||
|
||||
================ P2 镜像集成接入说明(本轨只写脚本,不改构建) ================
|
||||
本文件不由 chv-guest/build.sh 安装,镜像集成是 P2 的范围。
|
||||
P2 接入时做这三件:
|
||||
|
||||
1. 把本文件拷进镜像,例如 /app/vsock_notify.py(须 python3,
|
||||
客机镜像已有 Python;若最小镜像无 python3,P2 须先补);
|
||||
2. 加 systemd 单元,例如 /etc/systemd/system/vsock-notify.service:
|
||||
|
||||
[Unit]
|
||||
Description=vsock notify to host (ready/heartbeat)
|
||||
After=multi-user.target
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/python3 /app/vsock_notify.py %H
|
||||
Restart=no
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
(%H 是客机 hostname;也可直接写 VM 名,或由 cloud-init
|
||||
写进环境变量 CHV_VM_NAME。Restart=no:本脚本失败即静默
|
||||
退出,不由 systemd 反复拉起打扰宿主监听。)
|
||||
3. 前置条件(缺一则本脚本静默退出,任务仍走 SSH,不报错):
|
||||
- 客机内核须含 CONFIG_VIRTIO_VSOCKETS;
|
||||
- 起 VM 时 CHV_VSOCK=1(默认开),VmConfig 带 vsock 设备
|
||||
(cloud/chv/executor.py 的 _balloon_vsock_payload);
|
||||
- 宿主侧须在 VM boot 前后监听 <socket>_1024(否则客机
|
||||
首次 connect 失败,本脚本在 30 秒内重试,仍失败即退出)。
|
||||
|
||||
端口 1024 与宿主侧 cloud/chv/vsock.py 的 VSOCK_NOTIFY_PORT
|
||||
须一致;改一处必须改另一处。
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
|
||||
# 宿主 CID(vsock 众所周知值,CHV 官方 docs/vsock.md)。
|
||||
HOST_CID = 2
|
||||
# notify 端口(与宿主侧 VSOCK_NOTIFY_PORT 一致)。
|
||||
NOTIFY_PORT = 1024
|
||||
# 首次 connect 重试窗口(秒):宿主监听可能比客机启动晚到。
|
||||
CONNECT_RETRY_SECONDS = 30
|
||||
# 心跳间隔(秒)。
|
||||
HEARTBEAT_INTERVAL = 30
|
||||
|
||||
|
||||
def _vm_name(argv) -> str:
|
||||
"""VM 名:argv[1] > CHV_VM_NAME > hostname。"""
|
||||
if len(argv) > 1 and argv[1]:
|
||||
return argv[1]
|
||||
env_name = os.environ.get("CHV_VM_NAME") or os.environ.get(
|
||||
"VSOCK_VM_NAME")
|
||||
if env_name:
|
||||
return env_name
|
||||
try:
|
||||
return socket.gethostname()
|
||||
except OSError:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _send(sock, message_type: str, vm: str) -> None:
|
||||
line = json.dumps({"type": message_type, "vm": vm,
|
||||
"ts": time.time()},
|
||||
separators=(",", ":"))
|
||||
sock.sendall((line + "\n").encode("utf-8"))
|
||||
|
||||
|
||||
def _connect(deadline: float):
|
||||
"""在 deadline 前反复连 (HOST_CID, NOTIFY_PORT),成功返回
|
||||
socket,超时返回 None。"""
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return None
|
||||
sock = socket.socket(socket.AF_VSOCK, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.settimeout(min(remaining, 5))
|
||||
sock.connect((HOST_CID, NOTIFY_PORT))
|
||||
sock.settimeout(None)
|
||||
return sock
|
||||
except OSError as exc:
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
if os.environ.get("VSOCK_NOTIFY_DEBUG") == "1":
|
||||
print("vsock_notify connect 失败:%r" % (exc,),
|
||||
file=sys.stderr, flush=True)
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def main(argv) -> int:
|
||||
vm = _vm_name(argv)
|
||||
deadline = time.monotonic() + CONNECT_RETRY_SECONDS
|
||||
sock = _connect(deadline)
|
||||
if sock is None:
|
||||
return 0
|
||||
try:
|
||||
_send(sock, "ready", vm)
|
||||
while True:
|
||||
time.sleep(HEARTBEAT_INTERVAL)
|
||||
_send(sock, "heartbeat", vm)
|
||||
except (OSError, ValueError):
|
||||
# 任何发送失败都静默退出,不影响 sshd/任务。
|
||||
return 0
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
finally:
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -0,0 +1,515 @@
|
||||
"""每用户常驻 VM 池(生产模块,P1 对齐 Muse,见 ADR-17)。
|
||||
|
||||
每用户一台常驻 VM:任务到时 resume 已暂停的 VM,跑完
|
||||
再 pause,不销毁;池空或任何一步失败都回落冷启动,
|
||||
绝不因池坏而丢任务。常驻 VM 的盘是每用户持久工作区盘
|
||||
(state_dir/chv-resident-<用户>/disk.raw),不是每任务
|
||||
新盘——隔离单元在常驻路径上从“每任务”变成“每用户”
|
||||
(单租户可接受,多租户重审前不得把常驻当租户隔离)。
|
||||
|
||||
状态机:cold → provisioning → paused → running → paused。
|
||||
acquire(user):有 paused 条目时 resume_vm + 等 SSH 就绪
|
||||
(复用执行器 wait_guest_ready,不另写判定),返回
|
||||
mode=resident;无可用/失败时返回 mode=cold。release(user):
|
||||
pause_vm,不销毁;pause 失败时条目留在 running(不删
|
||||
不销毁,worker 关闭时 reconcile 清理)。provision(user):
|
||||
wake_vm + pause_vm 建 paused 条目(惰性建池,失败回落)。
|
||||
reclaim_idle():paused 超 CHV_RESIDENT_IDLE_MINUTES(默认
|
||||
0=不自动关机)时 hibernate(pause + snapshot + shutdown)
|
||||
并删条目,快照文件留作恢复物,restore 是后续工作。
|
||||
|
||||
Registry(state_dir/resident-pool.json):文件 IO 与损坏
|
||||
语义复用 cloud.chv.orphans(原子写、损坏 quarantine、
|
||||
单条目非法只丢该条);重启后读到的条目一律 orphan,不
|
||||
复用,只能 reconcile 清理。条目按用户键隔离:release
|
||||
绝不把用过的 VM 放回给另一用户;同一用户同一时间只跑
|
||||
一个常驻任务,并发第二个任务回落冷启动。
|
||||
|
||||
诚实边界:resume 失败时 wait_guest_ready 的失败路径会
|
||||
stop_vm(含删工作区盘),条目随之丢弃——该路径的工作区
|
||||
以 Gitea 同步为准;带密钥 extra_env 的任务不走常驻
|
||||
(seed 是建池时定的,密钥只能冷启动随 seed 投递)。
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
|
||||
from cloud.chv.orphans import _read_registry, _write_registry
|
||||
|
||||
_LOGGER = logging.getLogger("cloud.cloud_hypervisor")
|
||||
|
||||
_REGISTRY_FILENAME = "resident-pool.json"
|
||||
_RESIDENT_STATES = ("paused", "running", "provisioning")
|
||||
_DEFAULT_USER = "default"
|
||||
|
||||
|
||||
def resident_user_key(user_id: str | None) -> str:
|
||||
"""用户键:非空即原样消毒(非法字符变 -),空/缺省
|
||||
即 "default"(当前单租户:任务 dict 无 user_id 时全
|
||||
部落到同一常驻 VM;多租户重审前不得当租户边界用)。"""
|
||||
raw = _DEFAULT_USER if user_id is None else str(user_id)
|
||||
key = re.sub(r"[^A-Za-z0-9_.-]", "-", raw).strip(".-")
|
||||
return key or _DEFAULT_USER
|
||||
|
||||
|
||||
def resident_task_for_user(user_id: str | None) -> dict:
|
||||
"""常驻伪任务:执行器按它算 VM 名/known_hosts/socket。
|
||||
vm_name({"id": "resident-alice"}) = "chv-resident-alice"。"""
|
||||
return {"id": "resident-" + resident_user_key(user_id)}
|
||||
|
||||
|
||||
def resident_registry_path_for_executor(executor: object) -> str | None:
|
||||
"""常驻 registry 路径:state_dir/resident-pool.json。
|
||||
无 state_dir 时返回 None(调用方按“无残留”处理)。"""
|
||||
state_dir = getattr(executor, "state_dir", None)
|
||||
if not state_dir:
|
||||
return None
|
||||
return os.path.join(state_dir, _REGISTRY_FILENAME)
|
||||
|
||||
|
||||
def reconcile_resident_orphans_for_executor(executor: object) -> list[str]:
|
||||
"""生产路径的常驻残留清理入口:返回已清理的 VM 名。
|
||||
只做清理:读 resident-pool.json → 每个上一世条目尽力
|
||||
shutdown_vm → 落盘为空 → best-effort 审计。绝不构造
|
||||
ResidentPool、绝不把任何条目发给任务。无 registry 路
|
||||
径时返回 []。文件 IO 与损坏语义复用 orphans。"""
|
||||
path = resident_registry_path_for_executor(executor)
|
||||
if not path:
|
||||
return []
|
||||
data, _corrupt = _read_registry(path)
|
||||
if data is None:
|
||||
return []
|
||||
cleaned: list[str] = []
|
||||
for entry in data.values():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if not entry.get("vm"):
|
||||
continue
|
||||
if entry.get("state") not in _RESIDENT_STATES:
|
||||
continue
|
||||
vm = str(entry["vm"])
|
||||
try:
|
||||
executor.shutdown_vm(vm) # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
cleaned.append(vm)
|
||||
if cleaned:
|
||||
_write_registry(path, {})
|
||||
try:
|
||||
executor._emit( # type: ignore[attr-defined]
|
||||
"resident_pool_reconcile", {"id": None},
|
||||
"cleaned=%s" % ",".join(cleaned))
|
||||
except Exception:
|
||||
pass
|
||||
return cleaned
|
||||
|
||||
|
||||
class ResidentPool:
|
||||
"""每用户常驻 VM 池(生产路径,见模块 docstring)。
|
||||
|
||||
同一 state_dir 只活一个池实例:registry 只在构造时
|
||||
读一次,重启后条目一律 orphan(见 _load_registry)。
|
||||
公共方法全部加锁:并发 acquire/release/provision 只
|
||||
放行一路,其余回 cold/False,不把同一 VM 发给两个任务。
|
||||
"""
|
||||
|
||||
def __init__(self, executor: object, idle_minutes: int | None = None,
|
||||
registry_path: str | None = None) -> None:
|
||||
"""建池并读 registry:读到的条目一律 orphan(见模块
|
||||
docstring)。idle_minutes 未给时读执行器属性或
|
||||
CHV_RESIDENT_IDLE_MINUTES,非法/负数即 0(不自动关机)。"""
|
||||
self.executor = executor
|
||||
self._lock = threading.Lock()
|
||||
self._busy: set[str] = set()
|
||||
self._entries: dict = {}
|
||||
self.registry_path = registry_path
|
||||
if self.registry_path is None:
|
||||
self.registry_path = \
|
||||
resident_registry_path_for_executor(executor)
|
||||
if idle_minutes is None:
|
||||
idle_minutes = self._idle_minutes_from_env()
|
||||
self.idle_minutes = idle_minutes if idle_minutes > 0 else 0
|
||||
self.registry_corrupt = False
|
||||
self.registry_corrupt_entries = 0
|
||||
self._load_registry()
|
||||
|
||||
# -- registry ------------------------------------------------------
|
||||
def _load_registry(self) -> None:
|
||||
"""载入 registry:读到的条目一律标 orphan(绝不直接
|
||||
当 paused 发给任务,旧 VMM 进程已随旧 worker 消失)。
|
||||
无文件时为空池;损坏时 quarantine 并从空开始;单条
|
||||
目非法只丢该条并计数,不连坐其它条目。"""
|
||||
if not self.registry_path:
|
||||
return
|
||||
data, corrupt = _read_registry(self.registry_path)
|
||||
if corrupt:
|
||||
self.registry_corrupt = True
|
||||
return
|
||||
if data is None:
|
||||
return
|
||||
for key, entry in data.items():
|
||||
if not isinstance(entry, dict) \
|
||||
or not entry.get("vm") \
|
||||
or entry.get("state") not in _RESIDENT_STATES:
|
||||
self.registry_corrupt_entries += 1
|
||||
continue
|
||||
self._entries[key] = {
|
||||
"state": "orphan",
|
||||
"vm": entry["vm"],
|
||||
"user_id": entry.get("user_id") or key,
|
||||
"paused_at": None,
|
||||
"last_used": None,
|
||||
}
|
||||
|
||||
def _save_registry(self) -> None:
|
||||
"""落盘当前条目(只写 paused/running/provisioning;
|
||||
orphan 是待清理的上一世条目,reconcile 后即删,不写
|
||||
回)。调用方须已持锁;写失败只记日志,不炸任务流。"""
|
||||
if not self.registry_path:
|
||||
return
|
||||
data = {}
|
||||
for key, entry in self._entries.items():
|
||||
if entry["state"] not in _RESIDENT_STATES:
|
||||
continue
|
||||
data[key] = {
|
||||
"state": entry["state"],
|
||||
"vm": entry.get("vm"),
|
||||
"user_id": entry.get("user_id"),
|
||||
"paused_at": entry.get("paused_at"),
|
||||
"last_used": entry.get("last_used"),
|
||||
}
|
||||
_write_registry(self.registry_path, data)
|
||||
|
||||
def _emit(self, event: str, task: dict, detail: str) -> None:
|
||||
"""best-effort 审计:执行器无 _emit 或抛错都不炸
|
||||
池操作(与 WarmPool 同款)。"""
|
||||
emit = getattr(self.executor, "_emit", None)
|
||||
if not callable(emit):
|
||||
return
|
||||
try:
|
||||
emit(event, task, detail)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _idle_minutes_from_env(self) -> int:
|
||||
"""闲置阈值:执行器属性优先(P1-B 若经 config 落
|
||||
属性,直接复用),否则读 CHV_RESIDENT_IDLE_MINUTES;
|
||||
非数字/负数一律 0(不自动关机,不炸建池)。"""
|
||||
raw = getattr(self.executor, "resident_idle_minutes", None)
|
||||
if raw is None:
|
||||
env = getattr(self.executor, "_env", None)
|
||||
if env is None:
|
||||
env = os.environ
|
||||
raw = env.get("CHV_RESIDENT_IDLE_MINUTES", 0)
|
||||
if raw is None:
|
||||
return 0
|
||||
try:
|
||||
minutes = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
return minutes if minutes > 0 else 0
|
||||
|
||||
def _vm_name(self, resident_task: dict) -> str:
|
||||
"""常驻 VM 名:优先执行器 vm_name(与冷启动同款派
|
||||
生),鸭子型执行器无该方法时退回 chv- 前缀拼法。"""
|
||||
vm_name = getattr(self.executor, "vm_name", None)
|
||||
if callable(vm_name):
|
||||
try:
|
||||
return str(vm_name(resident_task))
|
||||
except Exception:
|
||||
pass
|
||||
return "chv-" + str(resident_task.get("id"))
|
||||
|
||||
# -- 计数 ----------------------------------------------------------
|
||||
def paused_count(self) -> int:
|
||||
"""当前 paused(可分配)的常驻条目数。"""
|
||||
with self._lock:
|
||||
return sum(1 for e in self._entries.values()
|
||||
if e.get("state") == "paused")
|
||||
|
||||
def orphan_count(self) -> int:
|
||||
"""上一世残留条目数(待 reconcile,不可分配)。"""
|
||||
with self._lock:
|
||||
return sum(1 for e in self._entries.values()
|
||||
if e.get("state") == "orphan")
|
||||
|
||||
# -- acquire / release ----------------------------------------------
|
||||
def _claim_paused(self, user_key: str) -> dict | None:
|
||||
"""占一条 paused 条目:加忙标记并返回其快照;无条目/
|
||||
非 paused/正忙时返回 None(调用方回落冷启动)。"""
|
||||
with self._lock:
|
||||
entry = self._entries.get(user_key)
|
||||
if entry is None or entry.get("state") != "paused":
|
||||
return None
|
||||
if user_key in self._busy:
|
||||
return None
|
||||
self._busy.add(user_key)
|
||||
return dict(entry)
|
||||
|
||||
def _resume_and_wait(self, resident_task: dict) -> bool:
|
||||
"""resume + 等 SSH 就绪(复用执行器判定)。
|
||||
resume 缺失的鸭子型执行器跳过 resume(与 WarmPool
|
||||
同款宽容);SSH 未就绪即 False(wait_guest_ready
|
||||
的失败路径已自行 stop_vm,调用方只须删条目)。"""
|
||||
resume = getattr(self.executor, "resume_vm", None)
|
||||
if callable(resume):
|
||||
try:
|
||||
resume(resident_task)
|
||||
except Exception:
|
||||
return False
|
||||
wait = getattr(self.executor, "wait_guest_ready", None)
|
||||
if not callable(wait):
|
||||
return True
|
||||
try:
|
||||
return bool(wait(resident_task))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _settle_acquire(self, user_key: str, ok: bool) -> None:
|
||||
"""acquire 收尾:成功标 running,失败删条目;都清
|
||||
忙标记并落盘。"""
|
||||
with self._lock:
|
||||
self._busy.discard(user_key)
|
||||
entry = self._entries.get(user_key)
|
||||
if entry is None:
|
||||
return
|
||||
if ok:
|
||||
entry["state"] = "running"
|
||||
entry["last_used"] = time.time()
|
||||
else:
|
||||
self._entries.pop(user_key, None)
|
||||
self._save_registry()
|
||||
|
||||
def acquire(self, user_id: str | None) -> dict:
|
||||
"""取常驻 VM:paused 条目 → resume + SSH 就绪 →
|
||||
mode=resident;无条目/正忙/失败 → mode=cold(调用
|
||||
方回落冷启动)。失败条目直接丢弃(VM 本身已被
|
||||
wait_guest_ready 的失败路径关掉,或 resume 已拒)。"""
|
||||
user_key = resident_user_key(user_id)
|
||||
entry = self._claim_paused(user_key)
|
||||
if entry is None:
|
||||
return {"mode": "cold", "vm": None, "task": None}
|
||||
resident_task = resident_task_for_user(user_id)
|
||||
ok = self._resume_and_wait(resident_task)
|
||||
self._settle_acquire(user_key, ok)
|
||||
if not ok:
|
||||
return {"mode": "cold", "vm": None, "task": None}
|
||||
self._emit("resident_pool_acquire", resident_task,
|
||||
"vm=%s user=%s" % (entry.get("vm"), user_key))
|
||||
return {"mode": "resident", "vm": entry.get("vm"),
|
||||
"task": resident_task}
|
||||
|
||||
def _pause(self, resident_task: dict) -> bool:
|
||||
"""pause_vm 尽力而为:失败返回 False,不抛。"""
|
||||
pause = getattr(self.executor, "pause_vm", None)
|
||||
if not callable(pause):
|
||||
return False
|
||||
try:
|
||||
pause(resident_task)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def release(self, user_id: str | None) -> bool:
|
||||
"""还常驻 VM:running 条目 → pause_vm,不销毁。
|
||||
无条目/非 running/正忙 → False;pause 失败时条目
|
||||
留在 running(不删不销毁,worker 关闭时 reconcile
|
||||
清理),返回 False。只动本用户的条目。"""
|
||||
user_key = resident_user_key(user_id)
|
||||
resident_task = resident_task_for_user(user_id)
|
||||
with self._lock:
|
||||
entry = self._entries.get(user_key)
|
||||
if entry is None or entry.get("state") != "running":
|
||||
return False
|
||||
if user_key in self._busy:
|
||||
return False
|
||||
self._busy.add(user_key)
|
||||
paused = self._pause(resident_task)
|
||||
with self._lock:
|
||||
self._busy.discard(user_key)
|
||||
entry = self._entries.get(user_key)
|
||||
if entry is None or not paused:
|
||||
return False
|
||||
entry["state"] = "paused"
|
||||
entry["paused_at"] = time.time()
|
||||
self._save_registry()
|
||||
self._emit("resident_pool_release", resident_task,
|
||||
"vm=%s user=%s" % (entry.get("vm"), user_key))
|
||||
return True
|
||||
|
||||
# -- provision -------------------------------------------------------
|
||||
def _wake_and_pause(self, resident_task: dict) -> bool:
|
||||
"""建池:wake_vm(冷启动全套,含 SSH 就绪)再
|
||||
pause_vm。任一步失败都尽力 shutdown,不留半醒的
|
||||
VM;调用方随后删条目回落冷启动。"""
|
||||
try:
|
||||
wake = getattr(self.executor, "wake_vm", None)
|
||||
pause = getattr(self.executor, "pause_vm", None)
|
||||
if not callable(wake) or not callable(pause):
|
||||
return False
|
||||
wake(resident_task)
|
||||
pause(resident_task)
|
||||
return True
|
||||
except Exception:
|
||||
shutdown = getattr(self.executor, "shutdown_vm", None)
|
||||
if callable(shutdown):
|
||||
try:
|
||||
shutdown(resident_task)
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
def _finish_provision(self, user_key: str, ok: bool) -> str | None:
|
||||
"""建池收尾:成功标 paused 并记时间,失败删条目;
|
||||
都清忙标记并落盘。返回 VM 名或 None。"""
|
||||
with self._lock:
|
||||
self._busy.discard(user_key)
|
||||
entry = self._entries.get(user_key)
|
||||
if entry is None:
|
||||
return None
|
||||
if not ok:
|
||||
self._entries.pop(user_key, None)
|
||||
self._save_registry()
|
||||
return None
|
||||
entry["state"] = "paused"
|
||||
entry["paused_at"] = time.time()
|
||||
self._save_registry()
|
||||
return str(entry.get("vm"))
|
||||
|
||||
def provision(self, user_id: str | None) -> dict:
|
||||
"""建常驻 VM:已有 paused/running 条目直接返回(不
|
||||
重 wake);否则 wake + pause,成功落 paused 条目,
|
||||
失败删条目回 cold。并发建池只放行一路,其余回 cold。"""
|
||||
user_key = resident_user_key(user_id)
|
||||
resident_task = resident_task_for_user(user_id)
|
||||
with self._lock:
|
||||
entry = self._entries.get(user_key)
|
||||
if entry is not None and entry.get("state") in (
|
||||
"paused", "running"):
|
||||
return {"mode": "resident", "vm": entry.get("vm"),
|
||||
"task": resident_task}
|
||||
if user_key in self._busy:
|
||||
return {"mode": "cold", "vm": None, "task": None}
|
||||
self._busy.add(user_key)
|
||||
self._entries[user_key] = {
|
||||
"state": "provisioning",
|
||||
"vm": self._vm_name(resident_task),
|
||||
"user_id": user_id if user_id is not None
|
||||
else _DEFAULT_USER,
|
||||
"paused_at": None, "last_used": None}
|
||||
self._save_registry()
|
||||
ok = self._wake_and_pause(resident_task)
|
||||
vm = self._finish_provision(user_key, ok)
|
||||
if vm is None:
|
||||
return {"mode": "cold", "vm": None, "task": None}
|
||||
self._emit("resident_pool_provision", resident_task,
|
||||
"vm=%s user=%s" % (vm, user_key))
|
||||
return {"mode": "resident", "vm": vm, "task": resident_task}
|
||||
|
||||
# -- 空闲回收 --------------------------------------------------------
|
||||
def _hibernate_one(self, user_key: str) -> str | None:
|
||||
"""单条目休眠:hibernate_vm(快照到 state_dir 内
|
||||
resident.snapshot,成功后删条目)。失败留条目下次
|
||||
再试,返回 None;无 state_dir 或无 hibernate 入口
|
||||
同样留条目。"""
|
||||
with self._lock:
|
||||
entry = self._entries.get(user_key)
|
||||
if entry is None or entry.get("state") != "paused":
|
||||
return None
|
||||
if user_key in self._busy:
|
||||
return None
|
||||
self._busy.add(user_key)
|
||||
resident_task = resident_task_for_user(
|
||||
entry.get("user_id"))
|
||||
vm = str(entry.get("vm"))
|
||||
state_dir = getattr(self.executor, "state_dir", None)
|
||||
hibernate = getattr(self.executor, "hibernate_vm", None)
|
||||
if not state_dir or not callable(hibernate):
|
||||
with self._lock:
|
||||
self._busy.discard(user_key)
|
||||
return None
|
||||
snapshot_url = "file://" + os.path.join(
|
||||
state_dir, vm, "resident.snapshot")
|
||||
try:
|
||||
hibernate(resident_task, snapshot_url)
|
||||
except Exception:
|
||||
with self._lock:
|
||||
self._busy.discard(user_key)
|
||||
return None
|
||||
with self._lock:
|
||||
self._busy.discard(user_key)
|
||||
self._entries.pop(user_key, None)
|
||||
self._save_registry()
|
||||
self._emit("resident_pool_reclaim", resident_task,
|
||||
"vm=%s snapshot=%s" % (vm, snapshot_url))
|
||||
return vm
|
||||
|
||||
def reclaim_idle(self, now: float | None = None) -> list[str]:
|
||||
"""空闲回收:paused 超阈值的条目 hibernate(pause +
|
||||
snapshot + shutdown)并删条目。阈值 0 即关闭回收;
|
||||
未到阈值/正忙/无 paused_at 的条目不动。返回回收的
|
||||
VM 名。"""
|
||||
if self.idle_minutes <= 0:
|
||||
return []
|
||||
if now is None:
|
||||
now = time.time()
|
||||
due: list[str] = []
|
||||
with self._lock:
|
||||
for key, entry in self._entries.items():
|
||||
if entry.get("state") != "paused":
|
||||
continue
|
||||
if key in self._busy:
|
||||
continue
|
||||
paused_at = entry.get("paused_at")
|
||||
if paused_at is None:
|
||||
continue
|
||||
if now - paused_at > self.idle_minutes * 60:
|
||||
due.append(key)
|
||||
reclaimed: list[str] = []
|
||||
for key in due:
|
||||
vm = self._hibernate_one(key)
|
||||
if vm is not None:
|
||||
reclaimed.append(vm)
|
||||
return reclaimed
|
||||
|
||||
# -- 孤儿清理 --------------------------------------------------------
|
||||
def _shutdown_one(self, user_key: str) -> str | None:
|
||||
"""单条目尽力 shutdown 并从内存删掉(失败仍计入
|
||||
已清理,与 orphans 旧语义一致:不因单条卡住整批)。
|
||||
返回 VM 名或 None(条目已不在)。"""
|
||||
with self._lock:
|
||||
entry = self._entries.get(user_key)
|
||||
if entry is None:
|
||||
return None
|
||||
vm = str(entry.get("vm"))
|
||||
shutdown = getattr(self.executor, "shutdown_vm", None)
|
||||
if callable(shutdown):
|
||||
try:
|
||||
shutdown(vm)
|
||||
except Exception:
|
||||
pass
|
||||
with self._lock:
|
||||
self._entries.pop(user_key, None)
|
||||
return vm
|
||||
|
||||
def reconcile_orphans(self) -> list[str]:
|
||||
"""清理本池内存里的 orphan 条目(重启后读到的上一
|
||||
世条目):每个尽力 shutdown_vm,条目清掉并落盘。
|
||||
生产启动/关闭不走本方法,走模块级 facade。"""
|
||||
with self._lock:
|
||||
orphans = [k for k, e in self._entries.items()
|
||||
if e.get("state") == "orphan"]
|
||||
cleaned: list[str] = []
|
||||
for key in orphans:
|
||||
vm = self._shutdown_one(key)
|
||||
if vm is not None:
|
||||
cleaned.append(vm)
|
||||
if cleaned:
|
||||
with self._lock:
|
||||
self._save_registry()
|
||||
self._emit("resident_pool_reconcile", {"id": None},
|
||||
"cleaned=%s" % ",".join(cleaned))
|
||||
return cleaned
|
||||
@@ -0,0 +1,223 @@
|
||||
"""宿主侧 vsock notify 最小实现(P1,feat-chv-muse-p1)。
|
||||
|
||||
方向只一个:客机 → 宿主。客机连宿主 CID 2 的
|
||||
VSOCK_NOTIFY_PORT 端口发 ready/心跳(客内脚本
|
||||
cloud/chv/guest/vsock_notify.py);宿主在
|
||||
``<vsock socket>_<port>`` 这个 Unix socket 上监听接收。
|
||||
|
||||
形态依据(CHV 官方 docs/vsock.md,upstream 原文):
|
||||
- ``--vsock cid=3,socket=/tmp/ch.vsock``:cid 是客机 CID,
|
||||
socket 是宿主侧 Unix socket 路径;
|
||||
- 客 → 宿主:宿主须先在 ``<socket>_<port>`` 上监听
|
||||
(如 ``/tmp/ch.vsock_1234``),客机连众所周知的 CID 2
|
||||
(Host);
|
||||
- 只支持 stream socket。
|
||||
|
||||
只做就绪辅助:wait_ready() 收到 ready 即返回,供
|
||||
_run_with_vm/常驻路径在 SSH 轮询之外多一个早期信号;
|
||||
SSH 仍是唯一命令通道与就绪判定——vsock 超时、无消息、
|
||||
监听失败一律返回 None,绝不因此判任务失败、绝不抛到
|
||||
调用方。消息是 JSON 行:
|
||||
|
||||
{"type": "ready"|"heartbeat", "vm": "<vm 名>", "ts": <unix 秒>}
|
||||
|
||||
不做宿主 → 客机方向(客机不监听,host.py 协议仍走 SSH)。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
|
||||
_LOGGER = logging.getLogger("cloud.cloud_hypervisor")
|
||||
|
||||
# 宿主 CID(vsock 众所周知值,CHV 官方 docs/vsock.md)。
|
||||
HOST_CID = 2
|
||||
# 客机 CID 默认值(与 CHV_VSOCK_CID 默认值一致;0/1/2 保留)。
|
||||
DEFAULT_GUEST_CID = 3
|
||||
# notify 端口:客机连 (CID 2, 本端口) 发 ready/心跳;
|
||||
# 宿主监听 <socket>_<本端口>。与客内脚本的端口须一致。
|
||||
VSOCK_NOTIFY_PORT = 1024
|
||||
# 单行上限:客机发来的行超长即丢弃该连接(防脏数据撑内存)。
|
||||
MAX_LINE_BYTES = 65536
|
||||
# 合法消息类型。
|
||||
VALID_TYPES = ("ready", "heartbeat")
|
||||
|
||||
|
||||
def vsock_socket_path(state_dir: str, vm_name: str) -> str:
|
||||
"""该 VM 的 vsock socket 路径(放进 VmConfig 的值)。
|
||||
|
||||
与 build_vm_config 的派生一致:任务盘同目录的
|
||||
``state_dir/<vm>/vsock.sock``。vm_name 须与
|
||||
api_socket/任务盘用的名字相同(executor.vm_name(task)
|
||||
的返回值,含 chv- 前缀),这里不二次加前缀。"""
|
||||
return os.path.join(state_dir, vm_name, "vsock.sock")
|
||||
|
||||
|
||||
def notify_socket_path(socket_path: str,
|
||||
port: int = VSOCK_NOTIFY_PORT) -> str:
|
||||
"""宿主侧监听路径:``<socket>_<port>``(CHV 官方约定)。"""
|
||||
return "%s_%d" % (socket_path, port)
|
||||
|
||||
|
||||
def encode_notify(message_type: str, vm: str,
|
||||
ts: float | None = None) -> bytes:
|
||||
"""编码一条 notify 消息为 JSON 行(含换行)。
|
||||
|
||||
type 非法或 vm 为空即 ValueError(调用方是可信的客内
|
||||
脚本/宿主代码,编码错误是编程错误,早炸)。"""
|
||||
if message_type not in VALID_TYPES:
|
||||
raise ValueError("vsock notify 类型非法:%r" % (message_type,))
|
||||
if not vm:
|
||||
raise ValueError("vsock notify vm 不能为空")
|
||||
if ts is None:
|
||||
ts = time.time()
|
||||
line = json.dumps({"type": message_type, "vm": vm, "ts": ts},
|
||||
separators=(",", ":"))
|
||||
return (line + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def decode_notify(line: bytes | str) -> dict | None:
|
||||
"""解码一行 notify 消息:合法返回 dict,否则返回 None。
|
||||
|
||||
客机字节不可信,任何畸形(非 JSON、非对象、类型不在
|
||||
ready/heartbeat 内、vm 空/非字符串、ts 非数字)一律
|
||||
None,绝不抛异常。"""
|
||||
try:
|
||||
if isinstance(line, bytes):
|
||||
line = line.decode("utf-8")
|
||||
obj = json.loads(line)
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return None
|
||||
if not isinstance(obj, dict):
|
||||
return None
|
||||
if obj.get("type") not in VALID_TYPES:
|
||||
return None
|
||||
if not isinstance(obj.get("vm"), str) or not obj.get("vm"):
|
||||
return None
|
||||
if not isinstance(obj.get("ts"), (int, float)) \
|
||||
or isinstance(obj.get("ts"), bool):
|
||||
return None
|
||||
return obj
|
||||
|
||||
|
||||
def _bind_notify_listener(listener, notify_path: str) -> bool:
|
||||
"""bind notify socket:残留文件先删一次再重试一次;
|
||||
两次都失败即 False(调用方记 warning 后返回 None,
|
||||
与原来直抛到外层 except 的行为一致)。"""
|
||||
try:
|
||||
listener.bind(notify_path)
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.unlink(notify_path)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
listener.bind(notify_path)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def listen_notify(notify_path: str, timeout: float,
|
||||
want_type: str | None = None) -> dict | None:
|
||||
"""在 notify_path(Unix socket)上监听,等一条 notify。
|
||||
|
||||
收到首条合法消息即返回(want_type 给定时跳过其它类型
|
||||
继续等,直到超时);超时、客机断开未发合法消息、监听
|
||||
失败一律返回 None。监听用的 socket 文件用完即删(尽力
|
||||
而为);bind 前已存在的同名残留文件先删一次再 bind。
|
||||
timeout <= 0 即不等,直接返回 None。"""
|
||||
if timeout is None or timeout <= 0:
|
||||
return None
|
||||
deadline = time.monotonic() + timeout
|
||||
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
try:
|
||||
if not _bind_notify_listener(listener, notify_path):
|
||||
_LOGGER.warning("vsock notify 监听失败:%s", notify_path)
|
||||
return None
|
||||
listener.listen(1)
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return None
|
||||
listener.settimeout(remaining)
|
||||
try:
|
||||
conn, _ = listener.accept()
|
||||
except socket.timeout:
|
||||
return None
|
||||
except OSError:
|
||||
_LOGGER.warning("vsock notify accept 失败:%s",
|
||||
notify_path)
|
||||
return None
|
||||
try:
|
||||
return _read_notify(conn, deadline, want_type)
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
_LOGGER.warning("vsock notify 监听失败:%s", notify_path)
|
||||
return None
|
||||
finally:
|
||||
try:
|
||||
listener.close()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.unlink(notify_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _read_notify(conn, deadline: float,
|
||||
want_type: str | None) -> dict | None:
|
||||
"""从一条已接受的连接上读 JSON 行,直到合法消息或超时/
|
||||
断开。want_type 给定时跳过其它合法类型继续读。"""
|
||||
try:
|
||||
stream = conn.makefile("rb")
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return None
|
||||
try:
|
||||
conn.settimeout(remaining)
|
||||
line = stream.readline(MAX_LINE_BYTES + 1)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if not line:
|
||||
return None
|
||||
if len(line) > MAX_LINE_BYTES:
|
||||
return None
|
||||
msg = decode_notify(line)
|
||||
if msg is None:
|
||||
continue
|
||||
if want_type is not None and msg["type"] != want_type:
|
||||
continue
|
||||
return msg
|
||||
finally:
|
||||
try:
|
||||
stream.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def wait_ready(state_dir: str, vm_name: str,
|
||||
timeout: float = 30,
|
||||
port: int = VSOCK_NOTIFY_PORT) -> dict | None:
|
||||
"""等一台 VM 的 vsock ready(就绪辅助,非 SSH 判定)。
|
||||
|
||||
在 ``state_dir/<vm>/vsock.sock_<port>`` 上监听,直到
|
||||
收到 {"type": "ready"} 或超时。返回 ready 消息 dict,
|
||||
超时/失败返回 None。调用方(_run_with_vm/常驻路径)
|
||||
拿到 None 时继续走 SSH 就绪轮询,绝不因此失败。"""
|
||||
path = notify_socket_path(
|
||||
vsock_socket_path(state_dir, vm_name), port)
|
||||
return listen_notify(path, timeout, want_type="ready")
|
||||
@@ -12,6 +12,9 @@ cloud.logging_config,不 import cloud.worker,绝无循环。
|
||||
(原 Worker.__init__ 里的闭包本体)。
|
||||
- reconcile_warm_pool:生产残留清理 facade 调用 + 审计
|
||||
(原 Worker._reconcile_warm_pool 的本体)。
|
||||
- reconcile_resident_pool:常驻池残留清理 facade 调用 +
|
||||
审计(新,feat-chv-muse-p1;调 resident_pool 的模块级
|
||||
facade,不构造 ResidentPool、不碰实验接口)。
|
||||
- cancel_watchdog_loop / quota_watchdog_loop:同步路径
|
||||
两条看门狗循环(原 Worker._chv_*_watchdog 的本体,
|
||||
self 依赖全部改成回调参数)。
|
||||
@@ -115,6 +118,38 @@ def reconcile_warm_pool(executor: object, audit_fn: Callable,
|
||||
return cleaned
|
||||
|
||||
|
||||
def reconcile_resident_pool(executor: object, audit_fn: Callable,
|
||||
when: str) -> list:
|
||||
"""显式调常驻残留清理 facade,返回已清理 VM 名。
|
||||
|
||||
仅 CHV worker 有效(其它执行器返回 [])。实现是
|
||||
cloud.chv.resident_pool.reconcile_resident_orphans_
|
||||
for_executor():直接读 resident-pool.json 做清理,不
|
||||
构造 ResidentPool、不碰实验接口。调用失败不炸主流程,
|
||||
但进审计(失败本身也审计,不静默丢弃)。orphan 条目
|
||||
绝不发给任务,reconcile 只做清理。"""
|
||||
if not isinstance(executor, CloudHypervisorExecutor):
|
||||
return []
|
||||
try:
|
||||
from cloud.chv.resident_pool import (
|
||||
reconcile_resident_orphans_for_executor as _reconcile)
|
||||
cleaned = _reconcile(executor) or []
|
||||
except Exception as exc:
|
||||
try:
|
||||
audit_fn(None, "chv_resident_pool_reconcile_failed",
|
||||
"when=%s error=%s" % (when, exc))
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
if cleaned:
|
||||
try:
|
||||
audit_fn(None, "chv_resident_pool_reconcile",
|
||||
"when=%s cleaned=%s" % (when, ",".join(cleaned)))
|
||||
except Exception:
|
||||
pass
|
||||
return cleaned
|
||||
|
||||
|
||||
def cancel_watchdog_loop(task_id: str, vm_name: str | None,
|
||||
stop_event: threading.Event,
|
||||
cancel_requested: set,
|
||||
|
||||
Reference in new issue
Block a user