feat(chv): 架构与代码质量 9.5+ 提升——长函数清零、WarmPool facade、#215 对齐、审计计数、只读 VM 字段、ADR-8~11
This commit is contained in:
1 parent
57cf397fe7
commit
56a6241bbe
15 files changed
+830
-335
No files matched your search
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""校验已构建镜像的 BUILDINFO 供应链清单(事后 lockfile 检查)。
|
||||||
|
|
||||||
|
事前 apt/pip 精确 lockfile 无法在本轨生成:apt 版本在构建
|
||||||
|
宿主上解析、pip 版本在客机里解析,没有构建宿主就编不出
|
||||||
|
版本号——本脚本绝不编造。构建时 build.sh 已从客机读回
|
||||||
|
dpkg + pip freeze 写进 BUILDINFO.versions(事后 lockfile),
|
||||||
|
本脚本校验它:versions 非空、tag 与预期一致、可选的冻结
|
||||||
|
清单(--frozen JSON,形如 {"dpkg": ["pkg version", ...]})
|
||||||
|
逐项比对,漂移即退出非 0。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python3 check_lockfile.py out/BUILDINFO.json [--tag TAG]
|
||||||
|
[--frozen frozen.json]
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
if len(argv) < 2:
|
||||||
|
print("用法:check_lockfile.py BUILDINFO.json [--tag TAG] "
|
||||||
|
"[--frozen frozen.json]", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
path = argv[1]
|
||||||
|
tag = None
|
||||||
|
frozen_path = None
|
||||||
|
i = 2
|
||||||
|
while i < len(argv):
|
||||||
|
if argv[i] == "--tag" and i + 1 < len(argv):
|
||||||
|
tag = argv[i + 1]
|
||||||
|
i += 2
|
||||||
|
elif argv[i] == "--frozen" and i + 1 < len(argv):
|
||||||
|
frozen_path = argv[i + 1]
|
||||||
|
i += 2
|
||||||
|
else:
|
||||||
|
print("未知参数:%s" % argv[i], file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
with open(path) as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
if tag is not None and data.get("tag") != tag:
|
||||||
|
print("tag 不一致:%s != %s" % (data.get("tag"), tag),
|
||||||
|
file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
versions = data.get("versions") or {}
|
||||||
|
if not versions.get("dpkg") or not versions.get("pip"):
|
||||||
|
print("BUILDINFO.versions.dpkg/pip 为空,供应链清单缺失",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if frozen_path:
|
||||||
|
with open(frozen_path) as fh:
|
||||||
|
frozen = json.load(fh)
|
||||||
|
for kind in ("dpkg", "pip"):
|
||||||
|
want = set(frozen.get(kind) or [])
|
||||||
|
got = set(versions.get(kind) or [])
|
||||||
|
missing = want - got
|
||||||
|
if missing:
|
||||||
|
print("%s 漂移:冻结清单里有、构建产物里无:%s"
|
||||||
|
% (kind, sorted(missing)), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print("lockfile 校验通过:tag=%s dpkg=%d pip=%d"
|
||||||
|
% (data.get("tag"), len(versions["dpkg"]),
|
||||||
|
len(versions["pip"])))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv))
|
||||||
+93
-72
@@ -9,12 +9,100 @@ import os
|
|||||||
import socket
|
import socket
|
||||||
|
|
||||||
|
|
||||||
|
def _read_header(sock):
|
||||||
|
"""读满响应头:返回 (header_text, 已收体部前缀)。
|
||||||
|
|
||||||
|
超时或提前关流即 RuntimeError,绝不把半截头当结束
|
||||||
|
(feat-chv-fix P1-6 的语义,纯移动)。"""
|
||||||
|
buf = b""
|
||||||
|
header_end = None
|
||||||
|
timed_out = False
|
||||||
|
while header_end is None:
|
||||||
|
try:
|
||||||
|
data = sock.recv(65536)
|
||||||
|
except socket.timeout:
|
||||||
|
timed_out = True
|
||||||
|
break
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
buf += data
|
||||||
|
header_end = buf.find(b"\r\n\r\n")
|
||||||
|
if timed_out or header_end is None:
|
||||||
|
raise RuntimeError("CHV API 响应头不完整(超时或提前关流)")
|
||||||
|
header = buf[:header_end].decode("utf-8", errors="replace")
|
||||||
|
return header, buf[header_end + 4:]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_content_length(header):
|
||||||
|
"""从响应头取 Content-Length:chunked 或无长度即拒绝。
|
||||||
|
|
||||||
|
chunked 明确拒绝(CHV REST 不应发 chunked);无长度则
|
||||||
|
无法证完整性,同样拒绝。"""
|
||||||
|
content_length = None
|
||||||
|
is_chunked = False
|
||||||
|
for line in header.split("\r\n")[1:]:
|
||||||
|
if ":" not in line:
|
||||||
|
continue
|
||||||
|
name, value = line.split(":", 1)
|
||||||
|
name = name.strip().lower()
|
||||||
|
value = value.strip()
|
||||||
|
if name == "content-length":
|
||||||
|
try:
|
||||||
|
content_length = int(value)
|
||||||
|
except ValueError:
|
||||||
|
raise RuntimeError("CHV API Content-Length 非法")
|
||||||
|
elif name == "transfer-encoding" \
|
||||||
|
and "chunked" in value.lower():
|
||||||
|
is_chunked = True
|
||||||
|
if is_chunked:
|
||||||
|
raise RuntimeError("CHV API 响应为 chunked,拒绝解析")
|
||||||
|
if content_length is None:
|
||||||
|
raise RuntimeError("CHV API 响应无 Content-Length,拒绝解析")
|
||||||
|
return content_length
|
||||||
|
|
||||||
|
|
||||||
|
def _read_body(sock, body, content_length):
|
||||||
|
"""按 Content-Length 读满体部:超时或提前关流即截断错误。"""
|
||||||
|
while len(body) < content_length:
|
||||||
|
try:
|
||||||
|
data = sock.recv(65536)
|
||||||
|
except socket.timeout:
|
||||||
|
raise RuntimeError(
|
||||||
|
"CHV API 响应体截断:已收 %d / 需 %d 字节"
|
||||||
|
% (len(body), content_length))
|
||||||
|
if not data:
|
||||||
|
raise RuntimeError(
|
||||||
|
"CHV API 响应体截断:已收 %d / 需 %d 字节"
|
||||||
|
% (len(body), content_length))
|
||||||
|
body += data
|
||||||
|
return body[:content_length]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_response(header, body):
|
||||||
|
"""从已读满的头与体部解析 (status, body):能解析 JSON
|
||||||
|
时为 dict,否则为原文本;状态行解析失败时 status 为 0。"""
|
||||||
|
text = header + "\r\n\r\n" + body.decode("utf-8", errors="replace")
|
||||||
|
status = 0
|
||||||
|
if text.startswith("HTTP/"):
|
||||||
|
try:
|
||||||
|
status = int(text.split()[1])
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
status = 0
|
||||||
|
body_text = body.decode("utf-8", errors="replace")
|
||||||
|
try:
|
||||||
|
parsed = json.loads(body_text) if body_text.strip() else {}
|
||||||
|
except ValueError:
|
||||||
|
parsed = body_text
|
||||||
|
return status, parsed
|
||||||
|
|
||||||
|
|
||||||
def _unix_socket_api(socket_path, method, path, payload=None):
|
def _unix_socket_api(socket_path, method, path, payload=None):
|
||||||
"""Cloud Hypervisor REST API 的默认传输:标准库 raw HTTP
|
"""Cloud Hypervisor REST API 的默认传输:标准库 raw HTTP
|
||||||
over Unix socket(无第三方依赖)。返回 (status, body),
|
over Unix socket(无第三方依赖)。返回 (status, body),
|
||||||
body 能解析 JSON 时为 dict,否则为原文本。socket 不存在
|
body 能解析 JSON 时为 dict,否则为原文本。socket 不存在
|
||||||
或连接失败时 raise RuntimeError(调用方据此判 api 超时
|
或连接失败时 raise RuntimeError(调用方据此判 api 超时
|
||||||
/失败,不编造 200)。"""
|
/失败,不编造 200)。读满语义见 _read_header /
|
||||||
|
_parse_content_length / _read_body(P1-6,纯移动)。"""
|
||||||
if not os.path.exists(socket_path):
|
if not os.path.exists(socket_path):
|
||||||
raise RuntimeError("api socket 不存在:%s" % socket_path)
|
raise RuntimeError("api socket 不存在:%s" % socket_path)
|
||||||
body = b""
|
body = b""
|
||||||
@@ -32,79 +120,12 @@ def _unix_socket_api(socket_path, method, path, payload=None):
|
|||||||
try:
|
try:
|
||||||
sock.connect(socket_path)
|
sock.connect(socket_path)
|
||||||
sock.sendall(raw)
|
sock.sendall(raw)
|
||||||
chunks = []
|
header, body = _read_header(sock)
|
||||||
# feat-chv-fix(P1-6):读超时不再当“响应结束”。
|
content_length = _parse_content_length(header)
|
||||||
# 先读满响应头,再按 Content-Length 读满体部;超时、
|
body = _read_body(sock, body, content_length)
|
||||||
# 提前关流、体部短于 Content-Length 一律 RuntimeError
|
|
||||||
# (调用方判 api_timeout / 传输失败),绝不解析半截
|
|
||||||
# 响应。chunked 与无 Content-Length 响应明确拒绝
|
|
||||||
# (Cloud Hypervisor REST 不应发 chunked;无长度则
|
|
||||||
# 无法证完整性)。
|
|
||||||
buf = b""
|
|
||||||
header_end = None
|
|
||||||
timed_out = False
|
|
||||||
while header_end is None:
|
|
||||||
try:
|
|
||||||
data = sock.recv(65536)
|
|
||||||
except socket.timeout:
|
|
||||||
timed_out = True
|
|
||||||
break
|
|
||||||
if not data:
|
|
||||||
break
|
|
||||||
buf += data
|
|
||||||
header_end = buf.find(b"\r\n\r\n")
|
|
||||||
if timed_out or header_end is None:
|
|
||||||
raise RuntimeError("CHV API 响应头不完整(超时或提前关流)")
|
|
||||||
header = buf[:header_end].decode("utf-8", errors="replace")
|
|
||||||
body = buf[header_end + 4:]
|
|
||||||
content_length = None
|
|
||||||
is_chunked = False
|
|
||||||
for line in header.split("\r\n")[1:]:
|
|
||||||
if ":" not in line:
|
|
||||||
continue
|
|
||||||
name, value = line.split(":", 1)
|
|
||||||
name = name.strip().lower()
|
|
||||||
value = value.strip()
|
|
||||||
if name == "content-length":
|
|
||||||
try:
|
|
||||||
content_length = int(value)
|
|
||||||
except ValueError:
|
|
||||||
raise RuntimeError("CHV API Content-Length 非法")
|
|
||||||
elif name == "transfer-encoding" \
|
|
||||||
and "chunked" in value.lower():
|
|
||||||
is_chunked = True
|
|
||||||
if is_chunked:
|
|
||||||
raise RuntimeError("CHV API 响应为 chunked,拒绝解析")
|
|
||||||
if content_length is None:
|
|
||||||
raise RuntimeError("CHV API 响应无 Content-Length,拒绝解析")
|
|
||||||
while len(body) < content_length:
|
|
||||||
try:
|
|
||||||
data = sock.recv(65536)
|
|
||||||
except socket.timeout:
|
|
||||||
raise RuntimeError(
|
|
||||||
"CHV API 响应体截断:已收 %d / 需 %d 字节"
|
|
||||||
% (len(body), content_length))
|
|
||||||
if not data:
|
|
||||||
raise RuntimeError(
|
|
||||||
"CHV API 响应体截断:已收 %d / 需 %d 字节"
|
|
||||||
% (len(body), content_length))
|
|
||||||
body += data
|
|
||||||
body = body[:content_length]
|
|
||||||
finally:
|
finally:
|
||||||
sock.close()
|
sock.close()
|
||||||
text = header + "\r\n\r\n" + body.decode("utf-8", errors="replace")
|
return _parse_response(header, body)
|
||||||
status = 0
|
|
||||||
if text.startswith("HTTP/"):
|
|
||||||
try:
|
|
||||||
status = int(text.split()[1])
|
|
||||||
except (IndexError, ValueError):
|
|
||||||
status = 0
|
|
||||||
body_text = body.decode("utf-8", errors="replace")
|
|
||||||
try:
|
|
||||||
parsed = json.loads(body_text) if body_text.strip() else {}
|
|
||||||
except ValueError:
|
|
||||||
parsed = body_text
|
|
||||||
return status, parsed
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_api_result(result) -> tuple:
|
def normalize_api_result(result) -> tuple:
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""#215(feat-arch-converge)接口对齐层,CHV 本地实现。
|
||||||
|
|
||||||
|
背景:#215 未合并,本基线里没有 cloud/timeouts.py、
|
||||||
|
cloud/gates.py、cloud/quota.py、cloud/sandbox_policy.py。
|
||||||
|
本模块绝不复制 #215 的文件,只把 CHV 真正能映射的
|
||||||
|
接口写成与 #215 相同的签名,#215 合并后替换为 import
|
||||||
|
即是机械的(见 docs/chv-architecture.md ADR-8)。
|
||||||
|
|
||||||
|
映射表(逐个核对过 #215 远程分支的真实函数,不是凭空编的):
|
||||||
|
- gates.resolve_inside(base, rel):目录门,CHV 快照/磁盘
|
||||||
|
路径白名单正好需要它——本模块实现同语义并已在
|
||||||
|
hardening.validate_snapshot_url 里使用,#215 合并后
|
||||||
|
改为 from cloud.gates import resolve_inside 即可。
|
||||||
|
- sandbox_policy.tmpfs_isolated(executor_kind, path):CHV
|
||||||
|
客机 /tmp 落在客机磁盘上,不是宿主 tmpfs,故对
|
||||||
|
cloud-hypervisor 一律返回 (False, ...)——fail-closed,
|
||||||
|
绝不宣称隔离。未知执行器同样 fail-closed。
|
||||||
|
- timeouts.resolve_module_timeout / quota.decide:无映射。
|
||||||
|
前者是审批/队列超时(None=不过期、0=不过期),CHV
|
||||||
|
的 api/ready 超时是秒数且 0 无效,语义不同,硬套会
|
||||||
|
改行为;后者是按月美元配额,CHV 的配额是 VM 数/磁盘
|
||||||
|
GB/快照数,单位不同。两者在本模块里 deliberately
|
||||||
|
不提供,测试钉住“CHV 不 import 它们”。
|
||||||
|
|
||||||
|
本模块只依赖标准库,是叶子模块,不 import cloud.sandbox。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_inside(base, rel):
|
||||||
|
"""目录门:base 内的相对路径安全解析,越界回 None。
|
||||||
|
|
||||||
|
空串回 base 本身;绝对路径、.. 逃逸、符号链接逃逸、
|
||||||
|
commonpath 跨盘符 ValueError 一律 None。签名与 #215
|
||||||
|
的 gates.resolve_inside 一致,合并后可直接替换为 import。"""
|
||||||
|
if not rel:
|
||||||
|
return base
|
||||||
|
if os.path.isabs(rel):
|
||||||
|
return None
|
||||||
|
real_base = os.path.realpath(base)
|
||||||
|
target = os.path.realpath(os.path.join(real_base, rel))
|
||||||
|
try:
|
||||||
|
if os.path.commonpath([real_base, target]) != real_base:
|
||||||
|
return None
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def tmpfs_isolated(executor_kind, path):
|
||||||
|
"""某执行器下某路径是否走 tmpfs 隔离:(isolated, reason)。
|
||||||
|
|
||||||
|
cloud-hypervisor:客机 /tmp 落在客机磁盘上,不走宿主
|
||||||
|
tmpfs,一律 (False, ...);未知执行器 fail-closed 同样
|
||||||
|
False。签名与 #215 的 sandbox_policy.tmpfs_isolated 一致。"""
|
||||||
|
if executor_kind == "cloud-hypervisor":
|
||||||
|
return False, "CHV 客机 /tmp 落在客机磁盘上,不走宿主 tmpfs"
|
||||||
|
return False, "未知执行器,按不隔离处理(fail-closed 口径)"
|
||||||
+40
-35
@@ -170,45 +170,21 @@ def _timeout_fragment(params: dict, env: dict) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_chv_config(params: dict, env: dict):
|
def _assemble_chv_config(base: dict, ssh: dict, quota: dict,
|
||||||
"""把显式构造参数(params,None=未给)与环境变量(env,
|
timeouts: dict):
|
||||||
默认为 os.environ 的调用方传入 dict)解析成 ChvConfig。
|
"""把基础字段 + SSH/配额/超时三个片段组装成 ChvConfig。
|
||||||
|
|
||||||
显式参数只有 __init__ 本来就有的那些;max_vms、磁盘模式、
|
纯移动:字段名与取值逐字不变,build_chv_config 只解析。"""
|
||||||
磁盘配额、egress、快照、预热池目标数本来就没有显式参数,
|
|
||||||
仍只走环境变量(与 #216 一致,不悄悄加新参数面)。
|
|
||||||
片段化(feat-chv-arch95):SSH 面、配额面、超时面分别
|
|
||||||
由 _ssh_fragment/_quota_fragment/_timeout_fragment 解析,
|
|
||||||
错误文案与校验顺序逐字一致;build_chv_config 只组装。"""
|
|
||||||
env = env if env is not None else {}
|
|
||||||
binary = params.get("binary") or env.get("CHV_BINARY") \
|
|
||||||
or "cloud-hypervisor"
|
|
||||||
kernel = params.get("kernel") or env.get("CHV_KERNEL")
|
|
||||||
rootfs = params.get("rootfs") or params.get("image") \
|
|
||||||
or env.get("CHV_ROOTFS") or env.get("CHV_IMAGE")
|
|
||||||
cpus = str(params.get("cpus") or env.get("CHV_CPU") or "2")
|
|
||||||
memory = params.get("memory") or env.get("CHV_MEM") or "2G"
|
|
||||||
state_dir = params.get("state_dir") or env.get("CHV_STATE_DIR") \
|
|
||||||
or os.path.join(os.path.expanduser("~"),
|
|
||||||
".local", "share", "codex-app-server", "chv-vms")
|
|
||||||
kvm_path = params.get("kvm_path") \
|
|
||||||
if params.get("kvm_path") is not None else "/dev/kvm"
|
|
||||||
cpuinfo_path = params.get("cpuinfo_path") \
|
|
||||||
if params.get("cpuinfo_path") is not None else "/proc/cpuinfo"
|
|
||||||
ch_remote = params.get("ch_remote") or env.get("CHV_REMOTE") \
|
|
||||||
or "ch-remote"
|
|
||||||
model = params.get("model")
|
|
||||||
ssh = _ssh_fragment(params, env)
|
|
||||||
quota = _quota_fragment(env)
|
|
||||||
timeouts = _timeout_fragment(params, env)
|
|
||||||
return ChvConfig(
|
return ChvConfig(
|
||||||
binary=binary, kernel=kernel, rootfs=rootfs, cpus=cpus,
|
binary=base["binary"], kernel=base["kernel"],
|
||||||
memory=memory, state_dir=state_dir, ssh_key=ssh["ssh_key"],
|
rootfs=base["rootfs"], cpus=base["cpus"],
|
||||||
ssh_user=ssh["ssh_user"], ssh_port=ssh["ssh_port"],
|
memory=base["memory"], state_dir=base["state_dir"],
|
||||||
|
ssh_key=ssh["ssh_key"], ssh_user=ssh["ssh_user"],
|
||||||
|
ssh_port=ssh["ssh_port"],
|
||||||
guest_host_py=ssh["guest_host_py"], cmdline=ssh["cmdline"],
|
guest_host_py=ssh["guest_host_py"], cmdline=ssh["cmdline"],
|
||||||
net=ssh["net"], net_json=ssh["net_json"],
|
net=ssh["net"], net_json=ssh["net_json"],
|
||||||
kvm_path=kvm_path, cpuinfo_path=cpuinfo_path,
|
kvm_path=base["kvm_path"], cpuinfo_path=base["cpuinfo_path"],
|
||||||
ch_remote=ch_remote, model=model,
|
ch_remote=base["ch_remote"], model=base["model"],
|
||||||
max_vms=quota["max_vms"], disk_mode=quota["disk_mode"],
|
max_vms=quota["max_vms"], disk_mode=quota["disk_mode"],
|
||||||
disk_gb=quota["disk_gb"], ssh_host=ssh["ssh_host"],
|
disk_gb=quota["disk_gb"], ssh_host=ssh["ssh_host"],
|
||||||
ssh_forward=ssh["ssh_forward"],
|
ssh_forward=ssh["ssh_forward"],
|
||||||
@@ -228,3 +204,32 @@ def build_chv_config(params: dict, env: dict):
|
|||||||
snapshot_retention_days=quota["snapshot_retention_days"],
|
snapshot_retention_days=quota["snapshot_retention_days"],
|
||||||
snapshot_dir=quota["snapshot_dir"],
|
snapshot_dir=quota["snapshot_dir"],
|
||||||
allow_remote_snapshot=quota["allow_remote_snapshot"])
|
allow_remote_snapshot=quota["allow_remote_snapshot"])
|
||||||
|
|
||||||
|
|
||||||
|
def build_chv_config(params: dict, env: dict):
|
||||||
|
"""把显式构造参数与环境变量解析成 ChvConfig(见
|
||||||
|
_assemble_chv_config 的组装段;片段化与校验顺序不变)。"""
|
||||||
|
env = env if env is not None else {}
|
||||||
|
base = {
|
||||||
|
"binary": params.get("binary") or env.get("CHV_BINARY")
|
||||||
|
or "cloud-hypervisor",
|
||||||
|
"kernel": params.get("kernel") or env.get("CHV_KERNEL"),
|
||||||
|
"rootfs": params.get("rootfs") or params.get("image")
|
||||||
|
or env.get("CHV_ROOTFS") or env.get("CHV_IMAGE"),
|
||||||
|
"cpus": str(params.get("cpus") or env.get("CHV_CPU") or "2"),
|
||||||
|
"memory": params.get("memory") or env.get("CHV_MEM") or "2G",
|
||||||
|
"state_dir": params.get("state_dir")
|
||||||
|
or env.get("CHV_STATE_DIR")
|
||||||
|
or os.path.join(os.path.expanduser("~"),
|
||||||
|
".local", "share", "codex-app-server", "chv-vms"),
|
||||||
|
"kvm_path": params.get("kvm_path")
|
||||||
|
if params.get("kvm_path") is not None else "/dev/kvm",
|
||||||
|
"cpuinfo_path": params.get("cpuinfo_path")
|
||||||
|
if params.get("cpuinfo_path") is not None else "/proc/cpuinfo",
|
||||||
|
"ch_remote": params.get("ch_remote") or env.get("CHV_REMOTE")
|
||||||
|
or "ch-remote",
|
||||||
|
"model": params.get("model"),
|
||||||
|
}
|
||||||
|
return _assemble_chv_config(
|
||||||
|
base, _ssh_fragment(params, env), _quota_fragment(env),
|
||||||
|
_timeout_fragment(params, env))
|
||||||
+215
-195
@@ -289,6 +289,7 @@ class CloudHypervisorExecutor(Executor):
|
|||||||
else _unix_socket_api
|
else _unix_socket_api
|
||||||
self.sleeper = sleeper if sleeper is not None else time.sleep
|
self.sleeper = sleeper if sleeper is not None else time.sleep
|
||||||
self.audit_callback = None
|
self.audit_callback = None
|
||||||
|
self.audit_failure_count = 0
|
||||||
self._vms_lock = threading.Lock()
|
self._vms_lock = threading.Lock()
|
||||||
if runner is not None:
|
if runner is not None:
|
||||||
self.runner = runner
|
self.runner = runner
|
||||||
@@ -602,9 +603,15 @@ class CloudHypervisorExecutor(Executor):
|
|||||||
feat-chv-secfix(M5):审计回调失败不再双重静默
|
feat-chv-secfix(M5):审计回调失败不再双重静默
|
||||||
pass——记宿主 error 日志(审计完整性本身没有保证,
|
pass——记宿主 error 日志(审计完整性本身没有保证,
|
||||||
但失败至少可见;任务仍不因此炸,审计失败不改变
|
但失败至少可见;任务仍不因此炸,审计失败不改变
|
||||||
任务状态是 worker 层的既定语义)。审计 detail 里
|
任务状态是 worker 层的既定语义)。feat-chv-arch95plus
|
||||||
绝不含密钥值(调用方负责:本类所有 _emit 的 detail
|
取舍(见 ADR-9):不改成“审计失败即中断任务”——
|
||||||
只含 VM 名、路径、URL、kind,不含任何 key 值)。"""
|
审计是可观测性不是门禁,DB 短暂不可用时硬中断会把
|
||||||
|
一次审计失败放大成任务失败,且与 worker 全局
|
||||||
|
best-effort 语义冲突;改为“失败记日志 + 计数 + 不
|
||||||
|
中断”,计数见 self.audit_failure_count(宿主可观测,
|
||||||
|
非任务状态)。审计 detail 里绝不含密钥值(调用方
|
||||||
|
负责:本类所有 _emit 的 detail 只含 VM 名、路径、
|
||||||
|
URL、kind,不含任何 key 值)。"""
|
||||||
try:
|
try:
|
||||||
if self.audit_callback is not None:
|
if self.audit_callback is not None:
|
||||||
self.audit_callback(event, task, detail, kind)
|
self.audit_callback(event, task, detail, kind)
|
||||||
@@ -612,6 +619,8 @@ class CloudHypervisorExecutor(Executor):
|
|||||||
_LOGGER.info("chv_audit event=%s kind=%s %s",
|
_LOGGER.info("chv_audit event=%s kind=%s %s",
|
||||||
event, kind, detail)
|
event, kind, detail)
|
||||||
except Exception as exc:
|
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",
|
_LOGGER.error("chv_audit_failed event=%s kind=%s error=%s",
|
||||||
event, kind, exc)
|
event, kind, exc)
|
||||||
|
|
||||||
@@ -981,21 +990,37 @@ class CloudHypervisorExecutor(Executor):
|
|||||||
"vmm.ping 超时:%d 次内 API 未通,socket=%s"
|
"vmm.ping 超时:%d 次内 API 未通,socket=%s"
|
||||||
% (attempts, socket_path))
|
% (attempts, socket_path))
|
||||||
|
|
||||||
def wait_guest_ready(self, task):
|
def _provisioned_key_guard(self, task):
|
||||||
"""等 guest SSH 就绪(只选 SSH,见类 docstring):
|
"""provisioned 下缺 known_hosts 即 fail-fast,绝不 keyscan。
|
||||||
轮询 ssh … "true",returncode 0 即就绪;次数 =
|
|
||||||
ready_timeout/ready_interval 取整,至少 1 次。
|
返回 True 表示已拦截(调用方须先关 VM 再抛
|
||||||
每轮先尽力捕获 host key(known_hosts 文件不存在时;
|
host_key_not_pinned);False 表示可继续轮询。"""
|
||||||
sshd 未起时抓不到是常态,下轮再试),再发 ssh
|
|
||||||
"true"——钉死式 SSH 在文件缺失时会直接拒绝未知主机,
|
|
||||||
不先捕获就永远等不到就绪。超时时先关 VM 再 raise
|
|
||||||
ChvFailure(kind=guest_not_ready),绝不在未就绪时
|
|
||||||
派任务。"""
|
|
||||||
# feat-chv-fix(P0-2):provisioned 模式下缺 known_hosts
|
|
||||||
# 时直接 fail-fast,绝不调用 capture_host_key 回落 TOFU。
|
|
||||||
# (keyscan 模式才走下面的捕获分支。)
|
|
||||||
if getattr(self, "host_key_mode", "provisioned") == "provisioned" \
|
if getattr(self, "host_key_mode", "provisioned") == "provisioned" \
|
||||||
and not os.path.exists(self.known_hosts_path(task)):
|
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):
|
||||||
|
"""等 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:
|
try:
|
||||||
self.stop_vm(task)
|
self.stop_vm(task)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -1013,17 +1038,9 @@ class CloudHypervisorExecutor(Executor):
|
|||||||
self.build_ssh_command(task, ["true"]))
|
self.build_ssh_command(task, ["true"]))
|
||||||
if getattr(result, "returncode", 1) == 0:
|
if getattr(result, "returncode", 1) == 0:
|
||||||
return True
|
return True
|
||||||
# feat-chv-secfix(M5):host key 不匹配是独立
|
# M5:host key 不匹配是独立安全事件,见
|
||||||
# 安全事件,不是“sshd 没起”。SSH stderr 含
|
# _check_host_key_mismatch(审计 + fail-fast)。
|
||||||
# 钉死拒绝标记时立即审计 host_key_mismatch 并
|
if self._check_host_key_mismatch(task, result):
|
||||||
# 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")
|
|
||||||
try:
|
try:
|
||||||
self.stop_vm(task)
|
self.stop_vm(task)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -1394,58 +1411,60 @@ class CloudHypervisorExecutor(Executor):
|
|||||||
% (self.vm_name(task_or_name), snapshot_url))
|
% (self.vm_name(task_or_name), snapshot_url))
|
||||||
return snapshot_url
|
return snapshot_url
|
||||||
|
|
||||||
def restore_vm(self, task, snapshot_url):
|
def _validate_restore(self, task, snapshot_url):
|
||||||
"""快照恢复唤醒:preflight → 只起 VMM(--api-socket)
|
"""restore 前置校验:返回归一化后的 snapshot_url。
|
||||||
→ 等 vmm.ping → ch-remote restore → 显式 resume →
|
|
||||||
等 guest SSH 就绪。恢复后的 VM 默认是 paused,不
|
restore 不走 vm.create、没有 VmConfig 可挂 seed:
|
||||||
resume 就不算醒——本方法绝不省略 resume 那一步。
|
快照内已固化原 VM host key,重预置反而破坏钉死,
|
||||||
任一步失败都关 VM 再抛。成功返回 {vm, api_socket,
|
故 provisioned 下复用已钉死的 known_hosts、缺失
|
||||||
state: "running", ready: True, mode: "restored"}。"""
|
fail-fast、绝不 keyscan、绝不重生成。restore 时不
|
||||||
|
重复验快照文件存在(远端/对象存储源合法,ch-remote
|
||||||
|
本身会在源缺失时失败)。"""
|
||||||
if not isinstance(task, dict) or task.get("id") is None:
|
if not isinstance(task, dict) or task.get("id") is None:
|
||||||
raise ValueError("任务缺少 id")
|
raise ValueError("任务缺少 id")
|
||||||
if not snapshot_url:
|
if not snapshot_url:
|
||||||
raise ValueError("snapshot_url 不能为空")
|
raise ValueError("snapshot_url 不能为空")
|
||||||
if "://" not in snapshot_url:
|
if "://" not in snapshot_url:
|
||||||
snapshot_url = "file://%s" % snapshot_url
|
snapshot_url = "file://%s" % snapshot_url
|
||||||
# feat-chv-secfix(L2):恢复源同样过 URL 白名单
|
|
||||||
# (file:// 须在白名单目录内,远端须显式放行),
|
|
||||||
# 在 preflight/起 VMM 之前就拒绝。
|
|
||||||
validate_snapshot_url(
|
validate_snapshot_url(
|
||||||
snapshot_url, self.state_dir,
|
snapshot_url, self.state_dir,
|
||||||
snapshot_dir=getattr(self, "snapshot_dir", None),
|
snapshot_dir=getattr(self, "snapshot_dir", None),
|
||||||
allow_remote=getattr(self, "allow_remote_snapshot", False))
|
allow_remote=getattr(self, "allow_remote_snapshot", False))
|
||||||
self.preflight()
|
self.preflight()
|
||||||
self.resolve_ssh_host()
|
self.resolve_ssh_host()
|
||||||
# feat-chv-fix(P0-2 修正版):restore 不走 vm.create,
|
if self._provisioned_key_guard(task):
|
||||||
# 没有 VmConfig 可挂 seed——审计报告“restore 须把 seed
|
|
||||||
# 接入 VmConfig”的说法在此不适用,纠正为:快照内已
|
|
||||||
# 固化原 VM 的 host key,重新预置新 key 反而会破坏
|
|
||||||
# 钉死。正确不变量是:provisioned 下复用已钉死的
|
|
||||||
# known_hosts(stop 时公钥保留、私钥已删但快照内仍有
|
|
||||||
# 原私钥),缺失时 fail-fast、绝不 keyscan、绝不重
|
|
||||||
# 生成。file:// 快照须先验存在且非空,否则不启动。
|
|
||||||
# 注意:restore 时不重复验快照文件存在——快照落盘
|
|
||||||
# 校验是 snapshot_vm/hibernate_vm 创建时的门(P1-7),
|
|
||||||
# restore 的 ch-remote 本身会在源缺失时失败;在这里
|
|
||||||
# 再验一次会把“源在远端/对象存储”的合法路径也挡掉。
|
|
||||||
if getattr(self, "host_key_mode", "provisioned") == "provisioned" \
|
|
||||||
and not os.path.exists(self.known_hosts_path(task)):
|
|
||||||
raise ChvFailure(
|
raise ChvFailure(
|
||||||
"host_key_not_pinned",
|
"host_key_not_pinned",
|
||||||
"provisioned 模式下 restore 缺 known_hosts,拒绝 "
|
"provisioned 模式下 restore 缺 known_hosts,拒绝 "
|
||||||
"keyscan 回落:VM %s" % self.vm_name(task))
|
"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, snapshot_url):
|
||||||
|
"""快照恢复唤醒:校验 → 只起 VMM → restore → 显式
|
||||||
|
resume → 等 SSH 就绪。恢复后默认 paused,不 resume
|
||||||
|
就不算醒。任一步失败都关 VM 再抛;成功返回
|
||||||
|
{vm, api_socket, state: "running", ready: True,
|
||||||
|
mode: "restored"}。"""
|
||||||
|
snapshot_url = self._validate_restore(task, snapshot_url)
|
||||||
name = self.vm_name(task)
|
name = self.vm_name(task)
|
||||||
self._check_capacity(exclude=name)
|
self._check_capacity(exclude=name)
|
||||||
try:
|
try:
|
||||||
self._launch_api_only(task)
|
self._restore_boot(task, snapshot_url)
|
||||||
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)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
try:
|
||||||
self.stop_vm(task)
|
self.stop_vm(task)
|
||||||
@@ -1476,58 +1495,26 @@ class CloudHypervisorExecutor(Executor):
|
|||||||
说明来源是休眠快照。"""
|
说明来源是休眠快照。"""
|
||||||
return self.restore_vm(task, snapshot_url)
|
return self.restore_vm(task, snapshot_url)
|
||||||
|
|
||||||
def stop_vm(self, task_or_name):
|
def _stop_registered_proc(self, name, proc):
|
||||||
"""停止 VM(尽力而为,不抛异常;三种情况):
|
"""清登记进程:已死直接清,活着先杀再清(纯移动)。"""
|
||||||
- 已死进程:登记进程 poll() 非 None,跳过 terminate
|
if proc is None:
|
||||||
等待,直接删 socket、清登记,返回 True。
|
return
|
||||||
- 已关 VM:无登记进程、无 socket,ch-remote 仍尽力
|
|
||||||
调过一次,返回 False(本来就没有什么可清)。
|
|
||||||
- socket 残留:有 socket、无登记进程,删 socket 并
|
|
||||||
返回 True。
|
|
||||||
ch-remote shutdown 失败不炸(VM 可能已死),但
|
|
||||||
ch-remote 命令本身仍会发出——测试据此断言关闭路径
|
|
||||||
确实用了 ch-remote shutdown,而不是只杀进程。"""
|
|
||||||
name = self.vm_name(task_or_name)
|
|
||||||
socket_path = os.path.join(self.state_dir, name + ".sock")
|
|
||||||
with self._vms_lock:
|
with self._vms_lock:
|
||||||
had_proc = name in self._vms
|
self._vms.pop(name, None)
|
||||||
proc = self._vms.get(name)
|
|
||||||
had = had_proc or os.path.exists(socket_path)
|
|
||||||
try:
|
try:
|
||||||
self.runner([self.ch_remote,
|
alive = proc.poll() is None
|
||||||
"--api-socket=%s" % socket_path, "shutdown"])
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
alive = False
|
||||||
if proc is not None:
|
if alive:
|
||||||
with self._vms_lock:
|
self._kill_proc(proc)
|
||||||
self._vms.pop(name, None)
|
|
||||||
try:
|
def _cleanup_vm_files(self, name, task):
|
||||||
alive = proc.poll() is None
|
"""stop 的三块文件清理:seed/私钥、任务磁盘、net 登记。
|
||||||
except Exception:
|
|
||||||
alive = False
|
任一块失败记对应审计并返回 True(脏标记由调用方写);
|
||||||
if alive:
|
seed/私钥与磁盘删除的审计名与文案逐字不变。"""
|
||||||
self._kill_proc(proc)
|
from cloud.chv.hostkeys import dirty_marker_path # noqa: F401
|
||||||
try:
|
failed = False
|
||||||
if os.path.exists(socket_path):
|
|
||||||
os.remove(socket_path)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# 补缺口轨:per-VM seed 目录与 host 私钥随 VM
|
|
||||||
# 关闭而删,绝不留给下一个 VM 复用;known_hosts
|
|
||||||
# 公钥文件保留供事后审计,删失败不炸关闭。下次
|
|
||||||
# 同名 VM 预置时若私钥已删即重新生成并覆盖
|
|
||||||
# known_hosts,不会用旧钉死去校验新 VM。
|
|
||||||
# feat-chv-secfix(H3/M2):清理分三块独立记账——
|
|
||||||
# seed/私钥块、任务磁盘块、net 登记块,任一块失败
|
|
||||||
# 都记对应审计 + 写脏标记(清理失败即拒起),绝不
|
|
||||||
# 再以“一块失败、整段吞掉”收场;任务磁盘块是 H3
|
|
||||||
# 的主修复:stop 即删 disk.raw(保留期 0;需取证
|
|
||||||
# 须在 shutdown 前经 SSH 拷出,见 recycle_vm 文档)。
|
|
||||||
# net.json 是 live 预约(非审计 artifact),随 VM
|
|
||||||
# 关闭而删,tap/mac/ip 预约随即释放。
|
|
||||||
_task = task_or_name if isinstance(task_or_name, dict) \
|
|
||||||
else {"id": task_or_name}
|
|
||||||
_cleanup_failed = False
|
|
||||||
try:
|
try:
|
||||||
seed_dir = os.path.join(self.state_dir, name, "seed")
|
seed_dir = os.path.join(self.state_dir, name, "seed")
|
||||||
if os.path.isdir(seed_dir):
|
if os.path.isdir(seed_dir):
|
||||||
@@ -1539,45 +1526,69 @@ class CloudHypervisorExecutor(Executor):
|
|||||||
if os.path.exists(_p):
|
if os.path.exists(_p):
|
||||||
os.remove(_p)
|
os.remove(_p)
|
||||||
except Exception as _cleanup_exc:
|
except Exception as _cleanup_exc:
|
||||||
_cleanup_failed = True
|
failed = True
|
||||||
self._emit("vm_key_cleanup_failed", _task,
|
self._emit("vm_key_cleanup_failed", task,
|
||||||
"vm=%s error=%s" % (name, _cleanup_exc))
|
"vm=%s error=%s" % (name, _cleanup_exc))
|
||||||
try:
|
try:
|
||||||
if delete_task_disk(self.state_dir, name):
|
if delete_task_disk(self.state_dir, name):
|
||||||
self._emit("vm_disk_deleted", _task,
|
self._emit("vm_disk_deleted", task,
|
||||||
"vm=%s disk deleted on stop" % name)
|
"vm=%s disk deleted on stop" % name)
|
||||||
except Exception as _disk_exc:
|
except Exception as _disk_exc:
|
||||||
_cleanup_failed = True
|
failed = True
|
||||||
self._emit("vm_disk_delete_failed", _task,
|
self._emit("vm_disk_delete_failed", task,
|
||||||
"vm=%s error=%s" % (name, _disk_exc))
|
"vm=%s error=%s" % (name, _disk_exc))
|
||||||
try:
|
try:
|
||||||
_net_path = os.path.join(self.state_dir, name, "net.json")
|
_net_path = os.path.join(self.state_dir, name, "net.json")
|
||||||
if os.path.exists(_net_path):
|
if os.path.exists(_net_path):
|
||||||
os.remove(_net_path)
|
os.remove(_net_path)
|
||||||
except Exception:
|
except Exception:
|
||||||
_cleanup_failed = True
|
failed = True
|
||||||
self._guest_env_delivered.pop(name, None)
|
self._guest_env_delivered.pop(name, None)
|
||||||
if _cleanup_failed:
|
return failed
|
||||||
# M2 根治:脏标记。标记本身写失败也不炸关闭,
|
|
||||||
# 但下次 provision_host_key 仍会因残留文件复用
|
def _write_dirty_marker(self, name, failed):
|
||||||
# 旧 key——该残留情形由 cleanup_stale_secrets 的
|
"""M2 脏标记:清理失败写标记、成功清标记;写失败不炸关闭。"""
|
||||||
# 扫描兜底(见 cleanup_stale_vms),文档明写。
|
from cloud.chv.hostkeys import dirty_marker_path
|
||||||
try:
|
try:
|
||||||
from cloud.chv.hostkeys import dirty_marker_path
|
_marker = dirty_marker_path(self.state_dir, name)
|
||||||
_marker = dirty_marker_path(self.state_dir, name)
|
if failed:
|
||||||
os.makedirs(os.path.dirname(_marker), exist_ok=True)
|
os.makedirs(os.path.dirname(_marker), exist_ok=True)
|
||||||
with open(_marker, "w") as fh:
|
with open(_marker, "w") as fh:
|
||||||
fh.write("cleanup failed\n")
|
fh.write("cleanup failed\n")
|
||||||
except Exception:
|
elif os.path.exists(_marker):
|
||||||
pass
|
os.remove(_marker)
|
||||||
else:
|
except Exception:
|
||||||
try:
|
pass
|
||||||
from cloud.chv.hostkeys import dirty_marker_path
|
|
||||||
_marker = dirty_marker_path(self.state_dir, name)
|
def stop_vm(self, task_or_name):
|
||||||
if os.path.exists(_marker):
|
"""停止 VM(尽力而为,不抛异常):已死进程直接清登记,
|
||||||
os.remove(_marker)
|
已关 VM 仍尽力调一次 ch-remote(返回 False),socket
|
||||||
except Exception:
|
残留删 socket(返回 True)。ch-remote shutdown 失败
|
||||||
pass
|
不炸,但命令本身仍会发出(测试据此断言关闭路径)。"""
|
||||||
|
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) \
|
task = task_or_name if isinstance(task_or_name, dict) \
|
||||||
else {"id": task_or_name}
|
else {"id": task_or_name}
|
||||||
if had:
|
if had:
|
||||||
@@ -1624,12 +1635,49 @@ class CloudHypervisorExecutor(Executor):
|
|||||||
"vm=%s gc error=%s" % (entry, exc))
|
"vm=%s gc error=%s" % (entry, exc))
|
||||||
return {"deleted": deleted, "failed": failed}
|
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)。"""
|
||||||
|
removed_priv = False
|
||||||
|
priv = os.path.join(vm_dir, "ssh_host_ed25519_key")
|
||||||
|
if os.path.exists(priv):
|
||||||
|
try:
|
||||||
|
os.remove(priv)
|
||||||
|
removed_priv = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
removed_seed = False
|
||||||
|
if not self._is_live_vm(entry):
|
||||||
|
for _suffix in ("seed.iso", "seed"):
|
||||||
|
_p = os.path.join(vm_dir, _suffix)
|
||||||
|
try:
|
||||||
|
if os.path.isdir(_p):
|
||||||
|
shutil.rmtree(_p)
|
||||||
|
removed_seed = True
|
||||||
|
elif os.path.exists(_p):
|
||||||
|
os.remove(_p)
|
||||||
|
removed_seed = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return removed_priv, removed_seed
|
||||||
|
|
||||||
def cleanup_stale_secrets(self):
|
def cleanup_stale_secrets(self):
|
||||||
"""崩溃残留扫描(H5):state_dir 下的 host 私钥文件
|
"""崩溃残留扫描(H5):私钥一律删、非 live VM 的 seed
|
||||||
在本轨不变量下只可能是残留——live VM 的私钥在 seed
|
同理删并审计;返回已清理 VM 名。worker 启动与
|
||||||
盘建好后即删(见 wake_vm),所以任何残留私钥文件
|
|
||||||
一律删除并审计;非 live VM 的 seed.iso/seed 目录同
|
|
||||||
理。返回已清理的 VM 名列表。worker 启动与
|
|
||||||
cleanup_stale_vms 时调它,不依赖 socket 是否存在。"""
|
cleanup_stale_vms 时调它,不依赖 socket 是否存在。"""
|
||||||
cleaned = []
|
cleaned = []
|
||||||
try:
|
try:
|
||||||
@@ -1642,36 +1690,8 @@ class CloudHypervisorExecutor(Executor):
|
|||||||
vm_dir = os.path.join(self.state_dir, entry)
|
vm_dir = os.path.join(self.state_dir, entry)
|
||||||
if not os.path.isdir(vm_dir):
|
if not os.path.isdir(vm_dir):
|
||||||
continue
|
continue
|
||||||
priv = os.path.join(vm_dir, "ssh_host_ed25519_key")
|
removed_priv, removed_seed = \
|
||||||
removed_priv = False
|
self._clean_one_vm_secrets(entry, vm_dir)
|
||||||
if os.path.exists(priv):
|
|
||||||
try:
|
|
||||||
os.remove(priv)
|
|
||||||
removed_priv = True
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
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
|
|
||||||
removed_seed = False
|
|
||||||
if not live:
|
|
||||||
for _suffix in ("seed.iso", "seed"):
|
|
||||||
_p = os.path.join(vm_dir, _suffix)
|
|
||||||
try:
|
|
||||||
if os.path.isdir(_p):
|
|
||||||
shutil.rmtree(_p)
|
|
||||||
removed_seed = True
|
|
||||||
elif os.path.exists(_p):
|
|
||||||
os.remove(_p)
|
|
||||||
removed_seed = True
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if removed_priv or removed_seed:
|
if removed_priv or removed_seed:
|
||||||
cleaned.append(entry)
|
cleaned.append(entry)
|
||||||
self._emit("host_secret_residue_cleaned",
|
self._emit("host_secret_residue_cleaned",
|
||||||
@@ -1712,32 +1732,11 @@ class CloudHypervisorExecutor(Executor):
|
|||||||
"lost": ["guest /tmp", "guest worktree temp files"],
|
"lost": ["guest /tmp", "guest worktree temp files"],
|
||||||
}
|
}
|
||||||
|
|
||||||
def cleanup_stale_vms(self):
|
def _stop_dead_socket_vms(self, entries):
|
||||||
"""清理残留 VM:只清 socket 残留与已死进程对应的 VM。
|
"""socket 扫描:登记进程仍活的跳过,其余走 stop_vm。
|
||||||
|
|
||||||
feat-chv-secfix(H3/H5):先跑残留盘 GC 与残留
|
返回已清理 VM 名;非 chv- 前缀 socket 一律跳过。"""
|
||||||
密钥扫描(不依赖 socket 是否存在,覆盖 worker 崩溃
|
|
||||||
后 socket 已删、文件仍在的情形),再走原 socket 扫描。
|
|
||||||
扫描 state_dir 下 *.sock:登记进程仍活(poll() 为 None)
|
|
||||||
的跳过;其余一律走 stop_vm。返回已清理的 VM 名列表;
|
|
||||||
state_dir 不存在时返回 [],绝不抛异常。基础设施或
|
|
||||||
其它执行器的 socket 不在 state_dir 里,不会被误删;
|
|
||||||
同目录下非 chv- 前缀的 socket 也跳过(只认本执行器
|
|
||||||
的 chv- 命名)。
|
|
||||||
"""
|
|
||||||
cleaned = []
|
cleaned = []
|
||||||
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 []
|
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
if not entry.endswith(".sock"):
|
if not entry.endswith(".sock"):
|
||||||
continue
|
continue
|
||||||
@@ -1754,9 +1753,11 @@ class CloudHypervisorExecutor(Executor):
|
|||||||
continue
|
continue
|
||||||
if self.stop_vm(name):
|
if self.stop_vm(name):
|
||||||
cleaned.append(name)
|
cleaned.append(name)
|
||||||
# 登记进程已死但 socket 文件已被删的情况:登记项本身
|
return cleaned
|
||||||
# 也是残留,顺手清掉登记,不计入 cleaned(没有 socket
|
|
||||||
# 可删,不算一次清理)。
|
def _purge_dead_registry(self):
|
||||||
|
"""登记进程已死但 socket 已被删的登记项顺手清掉
|
||||||
|
(没有 socket 可删,不计入清理数)。"""
|
||||||
for name in list(self._vms.keys()):
|
for name in list(self._vms.keys()):
|
||||||
proc = self._vms.get(name)
|
proc = self._vms.get(name)
|
||||||
try:
|
try:
|
||||||
@@ -1765,6 +1766,25 @@ class CloudHypervisorExecutor(Executor):
|
|||||||
dead = True
|
dead = True
|
||||||
if dead:
|
if dead:
|
||||||
self._vms.pop(name, None)
|
self._vms.pop(name, None)
|
||||||
|
|
||||||
|
def cleanup_stale_vms(self):
|
||||||
|
"""清理残留 VM:先跑残留盘 GC 与残留密钥扫描,再走
|
||||||
|
socket 扫描与登记清理。只清 socket 残留与已死进程
|
||||||
|
对应的 VM;state_dir 不存在返回 [],绝不抛异常。"""
|
||||||
|
try:
|
||||||
|
self.cleanup_stale_secrets()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
self.gc_stale_disks()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
entries = os.listdir(self.state_dir)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
cleaned = self._stop_dead_socket_vms(entries)
|
||||||
|
self._purge_dead_registry()
|
||||||
return cleaned
|
return cleaned
|
||||||
|
|
||||||
# -- 客内执行 ------------------------------------------------------
|
# -- 客内执行 ------------------------------------------------------
|
||||||
|
|||||||
+42
-26
@@ -99,33 +99,47 @@ def validate_net_entries(entries: list, existing_entries: list) -> list:
|
|||||||
vm})的 tap/mac/ip 任一重复即 net_tap_conflict,
|
vm})的 tap/mac/ip 任一重复即 net_tap_conflict,
|
||||||
拒绝启动并由调用方审计。
|
拒绝启动并由调用方审计。
|
||||||
返回归一化后的条目 list。"""
|
返回归一化后的条目 list。"""
|
||||||
seen_taps, seen_macs, seen_ips = set(), set(), set()
|
normalized = [_normalize_net_entry(e) for e in entries]
|
||||||
normalized = []
|
_check_net_duplicates(normalized)
|
||||||
for entry in entries:
|
_check_net_conflicts(normalized, existing_entries)
|
||||||
unknown = set(entry) - _NET_FIELDS
|
return normalized
|
||||||
if unknown:
|
|
||||||
raise ChvFailure(
|
|
||||||
"net_config_invalid",
|
def _normalize_net_entry(entry: dict) -> dict:
|
||||||
"CHV_NET_JSON 条目含未知字段:%s(白名单:%s)"
|
"""单条 net 条目归一化:字段白名单 + tap/mac/ip 归一化。
|
||||||
% (",".join(sorted(unknown)),
|
|
||||||
",".join(sorted(_NET_FIELDS))))
|
空 tap 视为未指定,不参与 tap 冲突比较(见
|
||||||
tap = str(entry.get("tap") or "").strip()
|
validate_net_entries 的说明,纯移动)。"""
|
||||||
mac = str(entry.get("mac") or "").strip().lower()
|
unknown = set(entry) - _NET_FIELDS
|
||||||
ip = str(entry.get("ip") or "").strip()
|
if unknown:
|
||||||
for kind, value, seen in (("tap", tap, seen_taps),
|
raise ChvFailure(
|
||||||
("mac", mac, seen_macs),
|
"net_config_invalid",
|
||||||
("ip", ip, seen_ips)):
|
"CHV_NET_JSON 条目含未知字段:%s(白名单:%s)"
|
||||||
|
% (",".join(sorted(unknown)),
|
||||||
|
",".join(sorted(_NET_FIELDS))))
|
||||||
|
return {"tap": str(entry.get("tap") or "").strip(),
|
||||||
|
"mac": str(entry.get("mac") or "").strip().lower(),
|
||||||
|
"ip": str(entry.get("ip") or "").strip(),
|
||||||
|
"mask": str(entry.get("mask") or "")}
|
||||||
|
|
||||||
|
|
||||||
|
def _check_net_duplicates(normalized: list) -> None:
|
||||||
|
"""本次条目内 tap/mac/ip 重复即 net_config_invalid。"""
|
||||||
|
seen = {"tap": set(), "mac": set(), "ip": set()}
|
||||||
|
for entry in normalized:
|
||||||
|
for kind in ("tap", "mac", "ip"):
|
||||||
|
value = entry.get(kind)
|
||||||
if not value:
|
if not value:
|
||||||
continue
|
continue
|
||||||
if kind == "tap" and not tap:
|
if value in seen[kind]:
|
||||||
continue
|
|
||||||
if value in seen:
|
|
||||||
raise ChvFailure(
|
raise ChvFailure(
|
||||||
"net_config_invalid",
|
"net_config_invalid",
|
||||||
"CHV_NET_JSON 内 %s 重复:%s" % (kind, value))
|
"CHV_NET_JSON 内 %s 重复:%s" % (kind, value))
|
||||||
seen.add(value)
|
seen[kind].add(value)
|
||||||
normalized.append({"tap": tap, "mac": mac, "ip": ip,
|
|
||||||
"mask": str(entry.get("mask") or "")})
|
|
||||||
|
def _check_net_conflicts(normalized: list, existing_entries: list) -> None:
|
||||||
|
"""与已登记 VM 的 tap/mac/ip 任一重复即 net_tap_conflict。"""
|
||||||
for entry in normalized:
|
for entry in normalized:
|
||||||
for other in existing_entries:
|
for other in existing_entries:
|
||||||
for kind in ("tap", "mac", "ip"):
|
for kind in ("tap", "mac", "ip"):
|
||||||
@@ -136,7 +150,6 @@ def validate_net_entries(entries: list, existing_entries: list) -> list:
|
|||||||
"net %s %s 已被 VM %s 使用,拒绝共享"
|
"net %s %s 已被 VM %s 使用,拒绝共享"
|
||||||
" tap/mac/ip:每 VM 必须独立"
|
" tap/mac/ip:每 VM 必须独立"
|
||||||
% (kind, value, other.get("vm")))
|
% (kind, value, other.get("vm")))
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def validate_egress_proxy(egress_proxy: str) -> None:
|
def validate_egress_proxy(egress_proxy: str) -> None:
|
||||||
@@ -162,9 +175,12 @@ def validate_egress_proxy(egress_proxy: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _is_within(directory, path):
|
def _is_within(directory, path):
|
||||||
directory = os.path.realpath(directory)
|
"""目录内判定:经 compat215.resolve_inside(#215 目录门
|
||||||
path = os.path.realpath(path)
|
同签名),越界回 None 即不在内。绝对路径先转相对路径
|
||||||
return path == directory or path.startswith(directory + os.sep)
|
再过门,.. 逃逸与符号链接逃逸一律挡。"""
|
||||||
|
from cloud.chv.compat215 import resolve_inside
|
||||||
|
rel = os.path.relpath(path, directory)
|
||||||
|
return resolve_inside(directory, rel) is not None
|
||||||
|
|
||||||
|
|
||||||
def validate_snapshot_url(snapshot_url: str, state_dir: str, snapshot_dir=None,
|
def validate_snapshot_url(snapshot_url: str, state_dir: str, snapshot_dir=None,
|
||||||
|
|||||||
@@ -304,3 +304,13 @@ class WarmPool:
|
|||||||
"warm_pool_release", task,
|
"warm_pool_release", task,
|
||||||
"vm=%s destroyed=%s" % (pool_vm, destroyed))
|
"vm=%s destroyed=%s" % (pool_vm, destroyed))
|
||||||
return destroyed
|
return destroyed
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_orphans_for_executor(executor) -> list:
|
||||||
|
"""生产路径唯一的 WarmPool 入口:只做孤儿清理。
|
||||||
|
|
||||||
|
worker 启动/关闭调它,不构造 WarmPool 类、不调
|
||||||
|
prewarm/acquire/release——实验接口与生产路径彻底分离:
|
||||||
|
生产代码 import 本函数即可,acquire/prewarm 只存在于
|
||||||
|
实验用的 WarmPool 类与测试里。"""
|
||||||
|
return WarmPool(executor).reconcile_orphans()
|
||||||
@@ -24,6 +24,10 @@ export interface Task {
|
|||||||
kind?: "main" | "side";
|
kind?: "main" | "side";
|
||||||
no_time_limit?: number | boolean;
|
no_time_limit?: number | boolean;
|
||||||
environment_id?: string; attachments?: string[]; model?: string; effort?: string;
|
environment_id?: string; attachments?: string[]; model?: string; effort?: string;
|
||||||
|
// feat-chv-arch95plus 只读字段:仅 CHV 任务且 worker 已把
|
||||||
|
// VM 死亡写进 interrupted_reason 时 server 才返回 true,
|
||||||
|
// 其它情形字段不存在(绝不把“未报告死亡”当成“VM 活着”)。
|
||||||
|
chv_vm_dead?: boolean;
|
||||||
child_model?: string; child_effort?: string; approval?: string;
|
child_model?: string; child_effort?: string; approval?: string;
|
||||||
result_text?: string; error_text?: string;
|
result_text?: string; error_text?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ export default function TaskDetailPanel() {
|
|||||||
<div><dt>turn</dt><dd>{shortId(t.turn_id)}</dd></div>
|
<div><dt>turn</dt><dd>{shortId(t.turn_id)}</dd></div>
|
||||||
<div><dt>root turn</dt><dd>{shortId(t.root_turn_id)}</dd></div>
|
<div><dt>root turn</dt><dd>{shortId(t.root_turn_id)}</dd></div>
|
||||||
<div><dt>executor</dt><dd>{t.executor || ""}{t.executor === "cloud-hypervisor" ? "(实验执行器,默认关闭;仅展示,无 VM 控制按钮)" : ""}</dd></div>
|
<div><dt>executor</dt><dd>{t.executor || ""}{t.executor === "cloud-hypervisor" ? "(实验执行器,默认关闭;仅展示,无 VM 控制按钮)" : ""}</dd></div>
|
||||||
|
{t.chv_vm_dead ? <div><dt>VM 状态</dt><dd>VM 中途死亡,任务未完成、断点可续跑(后端只读字段 chv_vm_dead;无该字段时不代表 VM 活着)</dd></div> : null}
|
||||||
<div><dt>主机</dt><dd>{t.worktree_host || ""}</dd></div>
|
<div><dt>主机</dt><dd>{t.worktree_host || ""}</dd></div>
|
||||||
<div><dt>模型</dt><dd>{t.model || "默认"}</dd></div>
|
<div><dt>模型</dt><dd>{t.model || "默认"}</dd></div>
|
||||||
<div><dt>强度</dt><dd>{t.effort || "默认"}</dd></div>
|
<div><dt>强度</dt><dd>{t.effort || "默认"}</dd></div>
|
||||||
|
|||||||
+1
-1
@@ -74,7 +74,7 @@ Error generating stack: `+a.message+`
|
|||||||
`);for(;mt>=0;){const Ct=yt.slice(0,mt);yt=yt.slice(mt+2);for(const V of Ct.split(`
|
`);for(;mt>=0;){const Ct=yt.slice(0,mt);yt=yt.slice(mt+2);for(const V of Ct.split(`
|
||||||
`))if(V.startsWith("data: "))try{const Y=JSON.parse(V.slice(6));Y.waiting?b(Y.task_status==="interrupted"?"任务已中断,等待续跑…":Y.task_status==="paused"?"任务已暂停…":"等待会话…"):Y.image&&(f("data:image/jpeg;base64,"+Y.image),b("直播中"));const J={};Y.holder&&(J.holder=Y.holder),Y.url&&(J.url=Y.url),Y.title!==void 0&&(J.title=Y.title),Object.keys(J).length>0&&A(dt=>({...dt||{},...J}))}catch{}mt=yt.indexOf(`
|
`))if(V.startsWith("data: "))try{const Y=JSON.parse(V.slice(6));Y.waiting?b(Y.task_status==="interrupted"?"任务已中断,等待续跑…":Y.task_status==="paused"?"任务已暂停…":"等待会话…"):Y.image&&(f("data:image/jpeg;base64,"+Y.image),b("直播中"));const J={};Y.holder&&(J.holder=Y.holder),Y.url&&(J.url=Y.url),Y.title!==void 0&&(J.title=Y.title),Object.keys(J).length>0&&A(dt=>({...dt||{},...J}))}catch{}mt=yt.indexOf(`
|
||||||
|
|
||||||
`)}}b("流已断开")}catch(at){if(at.name==="AbortError")return;M(at instanceof Error?at.message:String(at)),b("未开始")}finally{p(!1)}},[r]),ft=d.useCallback(()=>{var z;(z=H.current)==null||z.abort(),p(!1),b("已停止")},[]),Et=d.useCallback(async z=>{if(r)try{const L=await I($.taskBrowserControl(r.id),"POST",{holder:z});A(L),M("")}catch(L){M(L instanceof Error?L.message:String(L))}},[r]),st=d.useCallback(async z=>{if(!r||(g==null?void 0:g.holder)!=="user"||!Z.current)return;const L=Z.current.getBoundingClientRect(),at=Math.round((z.clientX-L.left)/L.width*1280),jt=Math.round((z.clientY-L.top)/L.height*800);try{await I($.taskBrowserInput(r.id),"POST",{type:"mouse_click",x:at,y:jt})}catch(Mt){M(Mt instanceof Error?Mt.message:String(Mt))}},[r,g]),Ht=d.useCallback(async z=>{if(!(!r||(g==null?void 0:g.holder)!=="user")){z.preventDefault();try{z.key.length===1?await I($.taskBrowserInput(r.id),"POST",{type:"type",text:z.key}):await I($.taskBrowserInput(r.id),"POST",{type:"key",key:z.key})}catch(L){M(L instanceof Error?L.message:String(L))}}},[r,g]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情"});const Q=(g==null?void 0:g.holder)||"—",ct=g!==null;return c.jsxs("div",{children:[c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",alignItems:"center"},children:[c.jsxs("span",{children:["状态 ",c.jsx("b",{children:O})]}),c.jsxs("span",{className:"muted",children:["会话 ",ct?"活跃":"未激活(轮次结束即消失)"]}),c.jsxs("span",{className:"muted",children:["控制权 ",Q]}),g!=null&&g.url?c.jsx("span",{className:"muted",title:g.url,children:g.url.slice(0,80)}):null]}),c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",marginTop:8},children:[q?c.jsx("button",{type:"button",onClick:ft,children:"停止观看"}):c.jsx("button",{type:"button",onClick:()=>void it(),children:"开始观看"}),c.jsx("button",{type:"button",onClick:()=>void F(),children:"刷新状态"}),Q!=="user"?c.jsx("button",{type:"button",onClick:()=>void Et("user"),disabled:!ct,children:"接管"}):c.jsx("button",{type:"button",onClick:()=>void Et("agent"),children:"交还给 Agent"})]}),E?c.jsx("div",{className:"muted",style:{marginTop:8},children:E}):null,Q==="user"?c.jsx("div",{className:"muted",style:{marginTop:8},children:"你已接管:点画面即点击,键盘输入直达页面。交还后 Agent 续跑前会先快照确认你的改动。"}):null,c.jsx("div",{style:{marginTop:8},children:h?c.jsx("img",{ref:Z,src:h,alt:"浏览器实时画面",tabIndex:0,onClick:z=>void st(z),onKeyDown:z=>void Ht(z),style:{width:"100%",maxWidth:1280,cursor:Q==="user"?"crosshair":"default",border:"1px solid var(--border, #ccc)"}}):c.jsx("div",{className:"muted",children:ct?"点“开始观看”接入画面流(接管后点画面即点击)":"浏览器会话未激活:任务本轮未开浏览器,或轮次已结束。"})})]})}function Ng(){const i=Gt(),[r,h]=d.useState("uncommittedChanges"),[f,g]=d.useState(""),[A,O]=d.useState(""),[b,E]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("h3",{children:"评审队列"}),c.jsxs("select",{"aria-label":"评审目标",value:r,onChange:M=>h(M.target.value),children:[c.jsx("option",{value:"uncommittedChanges",children:"未提交改动(工作区)"}),c.jsx("option",{value:"baseBranch",children:"与分支的差异"}),c.jsx("option",{value:"commit",children:"指定提交"}),c.jsx("option",{value:"custom",children:"自定义评审指令"})]}),c.jsx("input",{className:"stacked",placeholder:"与分支的差异用:分支名(如 main)",style:{width:"100%"},value:f,onChange:M=>g(M.target.value)}),c.jsx("input",{className:"stacked",placeholder:"指定提交用:提交 sha",style:{width:"100%"},value:A,onChange:M=>O(M.target.value)}),c.jsx("textarea",{placeholder:"自定义评审指令用:评审指令正文",value:b,onChange:M=>E(M.target.value)}),c.jsxs(zt,{children:[c.jsx("button",{className:"primary",onClick:()=>void i.postReview({target_type:r,branch:f.trim()||void 0,sha:A.trim()||void 0,instructions:b.trim()||void 0}),children:"登记评审需求"}),c.jsx("button",{onClick:i.refreshReviews,children:"刷新队列"}),c.jsx("span",{className:"muted",children:i.reviewMsg})]}),c.jsx("div",{className:"muted",children:"本基线只登记评审需求(requested),不自动跑评审;评审是否执行、结果如何,以评审队列里的记录为准。"}),i.reviewError?c.jsx("div",{className:"muted",children:i.reviewError}):i.reviews.length===0?c.jsx("div",{className:"muted",children:"点任务行查看详情后,点“刷新队列”查看评审记录"}):c.jsx(Sn,{children:i.reviews.map(M=>{let q=M.target_type||"";return M.branch&&(q+=":"+M.branch),M.sha&&(q+=":"+String(M.sha).slice(0,12)),M.instructions&&(q+=":"+String(M.instructions).slice(0,80)),c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:"评审"})," ",se(M.id)," | ",q," | ",c.jsx("span",{className:"st-"+(M.status||""),children:M.Line truncated
|
`)}}b("流已断开")}catch(at){if(at.name==="AbortError")return;M(at instanceof Error?at.message:String(at)),b("未开始")}finally{p(!1)}},[r]),ft=d.useCallback(()=>{var z;(z=H.current)==null||z.abort(),p(!1),b("已停止")},[]),Et=d.useCallback(async z=>{if(r)try{const L=await I($.taskBrowserControl(r.id),"POST",{holder:z});A(L),M("")}catch(L){M(L instanceof Error?L.message:String(L))}},[r]),st=d.useCallback(async z=>{if(!r||(g==null?void 0:g.holder)!=="user"||!Z.current)return;const L=Z.current.getBoundingClientRect(),at=Math.round((z.clientX-L.left)/L.width*1280),jt=Math.round((z.clientY-L.top)/L.height*800);try{await I($.taskBrowserInput(r.id),"POST",{type:"mouse_click",x:at,y:jt})}catch(Mt){M(Mt instanceof Error?Mt.message:String(Mt))}},[r,g]),Ht=d.useCallback(async z=>{if(!(!r||(g==null?void 0:g.holder)!=="user")){z.preventDefault();try{z.key.length===1?await I($.taskBrowserInput(r.id),"POST",{type:"type",text:z.key}):await I($.taskBrowserInput(r.id),"POST",{type:"key",key:z.key})}catch(L){M(L instanceof Error?L.message:String(L))}}},[r,g]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情"});const Q=(g==null?void 0:g.holder)||"—",ct=g!==null;return c.jsxs("div",{children:[c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",alignItems:"center"},children:[c.jsxs("span",{children:["状态 ",c.jsx("b",{children:O})]}),c.jsxs("span",{className:"muted",children:["会话 ",ct?"活跃":"未激活(轮次结束即消失)"]}),c.jsxs("span",{className:"muted",children:["控制权 ",Q]}),g!=null&&g.url?c.jsx("span",{className:"muted",title:g.url,children:g.url.slice(0,80)}):null]}),c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",marginTop:8},children:[q?c.jsx("button",{type:"button",onClick:ft,children:"停止观看"}):c.jsx("button",{type:"button",onClick:()=>void it(),children:"开始观看"}),c.jsx("button",{type:"button",onClick:()=>void F(),children:"刷新状态"}),Q!=="user"?c.jsx("button",{type:"button",onClick:()=>void Et("user"),disabled:!ct,children:"接管"}):c.jsx("button",{type:"button",onClick:()=>void Et("agent"),children:"交还给 Agent"})]}),E?c.jsx("div",{className:"muted",style:{marginTop:8},children:E}):null,Q==="user"?c.jsx("div",{className:"muted",style:{marginTop:8},children:"你已接管:点画面即点击,键盘输入直达页面。交还后 Agent 续跑前会先快照确认你的改动。"}):null,c.jsx("div",{style:{marginTop:8},children:h?c.jsx("img",{ref:Z,src:h,alt:"浏览器实时画面",tabIndex:0,onClick:z=>void st(z),onKeyDown:z=>void Ht(z),style:{width:"100%",maxWidth:1280,cursor:Q==="user"?"crosshair":"default",border:"1px solid var(--border, #ccc)"}}):c.jsx("div",{className:"muted",children:ct?"点“开始观看”接入画面流(接管后点画面即点击)":"浏览器会话未激活:任务本轮未开浏览器,或轮次已结束。"})})]})}function Ng(){const i=Gt(),[r,h]=d.useState("uncommittedChanges"),[f,g]=d.useState(""),[A,O]=d.useState(""),[b,E]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("h3",{children:"评审队列"}),c.jsxs("select",{"aria-label":"评审目标",value:r,onChange:M=>h(M.target.value),children:[c.jsx("option",{value:"uncommittedChanges",children:"未提交改动(工作区)"}),c.jsx("option",{value:"baseBranch",children:"与分支的差异"}),c.jsx("option",{value:"commit",children:"指定提交"}),c.jsx("option",{value:"custom",children:"自定义评审指令"})]}),c.jsx("input",{className:"stacked",placeholder:"与分支的差异用:分支名(如 main)",style:{width:"100%"},value:f,onChange:M=>g(M.target.value)}),c.jsx("input",{className:"stacked",placeholder:"指定提交用:提交 sha",style:{width:"100%"},value:A,onChange:M=>O(M.target.value)}),c.jsx("textarea",{placeholder:"自定义评审指令用:评审指令正文",value:b,onChange:M=>E(M.target.value)}),c.jsxs(zt,{children:[c.jsx("button",{className:"primary",onClick:()=>void i.postReview({target_type:r,branch:f.trim()||void 0,sha:A.trim()||void 0,instructions:b.trim()||void 0}),children:"登记评审需求"}),c.jsx("button",{onClick:i.refreshReviews,children:"刷新队列"}),c.jsx("span",{className:"muted",children:i.reviewMsg})]}),c.jsx("div",{className:"muted",children:"本基线只登记评审需求(requested),不自动跑评审;评审是否执行、结果如何,以评审队列里的记录为准。"}),i.reviewError?c.jsx("div",{className:"muted",children:i.reviewError}):i.reviews.length===0?c.jsx("div",{className:"muted",children:"点任务行查看详情后,点“刷新队列”查看评审记录"}):c.jsx(Sn,{children:i.reviews.map(M=>{let q=M.target_type||"";return M.branch&&(q+=":"+M.branch),M.sha&&(q+=":"+String(M.sha).slice(0,12)),M.instructions&&(q+=":"+String(M.instructions).slice(0,80)),c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:"评审"})," ",se(M.id)," | ",q," | ",c.jsx("span",{className:"st-"+(M.status||""),children:M.Line truncated
|
||||||
<style rel="stylesheet" crossorigin>:root{--text:#1f2328;--muted:#656d76;--subtle:#6e7781;--line:#d0d7de;--line-muted:#d8dee4;--paper:#ffffff;--card:#ffffff;--subtle-bg:#f6f8fa;--inset-bg:#f6f8fa;--accent:#0969da;--accent-emphasis:#0969da;--green:#2da44e;--green-hover:#2c974b;--success:#1a7f37;--danger:#d1242f;--danger-bg:#ffebe9;--attention:#9a6700;--done:#8250df;--selected-bg:#ddf4ff;--radius:6px;--shadow:0 1px 0 rgba(27,31,36,.04);--sans:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif;--mono:ui-monospace,SFMono-Regular,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;--ctl:32px}*{box-sizing:border-box}html,body,#root{height:100%}body{font-family:var(--sans);margin:0;color:var(--text);background:#fff;line-height:1.5;font-size:14px}a{color:var(--accent);text-underline-offset:3px}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}.muted{color:var(--muted);font-size:12px;line-height:1.5}.err{color:var(--danger)}.ok{color:var(--success)}button{cursor:pointer;font-weight:500;border-radius:var(--radius);border:1px solid var(--line);background:#fff;color:var(--text);box-shadow:var(--shadow)}button:hover{background:var(--subtle-bg)}button:disabled{opacity:.45;cursor:not-allowed;box-shadow:none}button.primary,button.seal{background:var(--green);border-color:#1b1f2426;color:#fff}button.primary:hover,button.seal:hover{background:var(--green-hover)}button.danger{color:var(--danger);border-color:var(--line);background:#fff}button.danger:hover{background:var(--danger-bg);color:var(--danger)}.icon-btn{width:32px;height:32px;padding:0;display:grid;place-items:center;border:1px solid var(--line);border-radius:var(--radius);background:#fff;color:var(--text);font-size:16px;line-height:1}.icon-btn:hover{background:var(--subtle-bg)}.dash-topbar{position:sticky;top:0;z-index:10;background:#24292f;color:#fff;border-bottom:1px solid rgba(255,255,255,.15);padding:10px 20px 8px}.dash-topbar .muted{color:#ffffffbf}.dash-topbar-row{display:flex;gap:14px;align-items:center;flex-wrap:wrap}.dash-brand{font-weight:600;font-size:16px;color:#fff}.dash-status{font-size:13px;font-weight:600;color:#ffffffbf}.dash-status.ok{color:#2da44e}.dash-stamp{margin-left:auto}.dash-nav{display:flex;gap:4px;flex-wrap:wrap;margin-top:8px}.dash-nav a{color:#fff;text-decoration:none;font-size:13px;font-weight:600;padding:6px 12px;border-radius:var(--radius)}.dash-nav a:hover{background:#ffffff1f;text-decoration:none}.tab-nav button{background:transparent;border:0;box-shadow:none;color:#fff;font-size:13px;font-weight:600;padding:6px 12px;border-radius:var(--radius)}.tab-nav button:hover{background:#ffffff1f}.tab-nav button.active{background:#ffffff2e}.dash-page-tabs{display:flex}.dash-page-tabs>.tab-content{grid-column:1 / -1;min-width:0;display:flex;flex-direction:column;gap:20px}.tab-workbench{display:grid;grid-template-columns:320px minmax(0,1fr);gap:20px;align-items:start}.tab-workbench>*{min-width:0}@media(max-width:899px){.tab-workbench{grid-template-columns:1fr}.tab-workbench #sec-dispatch{order:1}.tab-workbench #sec-tasks{order:2}.tab-workbench #sec-conv{order:3}}.dash-page{max-width:1180px;margin:0 auto;padding:20px;display:flex;flex-direction:column;gap:20px}.dash-page>*{min-width:0}.dash-section>*{min-width:0}.dash-section{scroll-margin-top:130px;display:flex;flex-direction:column;gap:16px}.dash-section.card{padding:16px}.dash-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.dash-card{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:16px;min-width:0}.dash-card-label{color:var(--muted);font-size:12px;font-weight:600}.dash-card-value{font-size:24px;font-weight:600;line-height:1.3;overflow-wrap:anywhere}.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:none;padding:16px}.card h2{font-size:14px;margin:0 0 12px;font-weight:600}.card h3{font-size:14px;margin:24px 0 12px;padding-top:16px;border-top:1px solid var(--line);font-weight:600}.card h3:first-of-type{border-top:0;padding-top:0}.composer h2{font-size:16px}.composer textarea.prompt{min-height:148px;font-size:14px;padding:12px;border-radius:var(--radius)}.composer textarea.coord{font-family:var(--mono);font-size:13px}.bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:12px 0}.bar>button,.bar>label{display:inline-flex;align-items:center;height:var(--ctl);min-height:var(--ctl);padding:0 16px;box-sizing:border-box;white-space:nowrap}.bar>input,.bar>select{height:var(--ctl);min-height:var(--ctl)}input,select,textarea,button{font:inherit;font-size:14px;padding:5px 12px;border-radius:var(--radius);border:1px solid var(--line);background:#fff;color:var(--text)}input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px #0969da4d}select{-moz-appearance:none;appearance:none;-webkit-appearance:none;cursLine truncated
|
<style rel="stylesheet" crossorigin>:root{--text:#1f2328;--muted:#656d76;--subtle:#6e7781;--line:#d0d7de;--line-muted:#d8dee4;--paper:#ffffff;--card:#ffffff;--subtle-bg:#f6f8fa;--inset-bg:#f6f8fa;--accent:#0969da;--accent-emphasis:#0969da;--green:#2da44e;--green-hover:#2c974b;--success:#1a7f37;--danger:#d1242f;--danger-bg:#ffebe9;--attention:#9a6700;--done:#8250df;--selected-bg:#ddf4ff;--radius:6px;--shadow:0 1px 0 rgba(27,31,36,.04);--sans:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif;--mono:ui-monospace,SFMono-Regular,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;--ctl:32px}*{box-sizing:border-box}html,body,#root{height:100%}body{font-family:var(--sans);margin:0;color:var(--text);background:#fff;line-height:1.5;font-size:14px}a{color:var(--accent);text-underline-offset:3px}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}.muted{color:var(--muted);font-size:12px;line-height:1.5}.err{color:var(--danger)}.ok{color:var(--success)}button{cursor:pointer;font-weight:500;border-radius:var(--radius);border:1px solid var(--line);background:#fff;color:var(--text);box-shadow:var(--shadow)}button:hover{background:var(--subtle-bg)}button:disabled{opacity:.45;cursor:not-allowed;box-shadow:none}button.primary,button.seal{background:var(--green);border-color:#1b1f2426;color:#fff}button.primary:hover,button.seal:hover{background:var(--green-hover)}button.danger{color:var(--danger);border-color:var(--line);background:#fff}button.danger:hover{background:var(--danger-bg);color:var(--danger)}.icon-btn{width:32px;height:32px;padding:0;display:grid;place-items:center;border:1px solid var(--line);border-radius:var(--radius);background:#fff;color:var(--text);font-size:16px;line-height:1}.icon-btn:hover{background:var(--subtle-bg)}.dash-topbar{position:sticky;top:0;z-index:10;background:#24292f;color:#fff;border-bottom:1px solid rgba(255,255,255,.15);padding:10px 20px 8px}.dash-topbar .muted{color:#ffffffbf}.dash-topbar-row{display:flex;gap:14px;align-items:center;flex-wrap:wrap}.dash-brand{font-weight:600;font-size:16px;color:#fff}.dash-status{font-size:13px;font-weight:600;color:#ffffffbf}.dash-status.ok{color:#2da44e}.dash-stamp{margin-left:auto}.dash-nav{display:flex;gap:4px;flex-wrap:wrap;margin-top:8px}.dash-nav a{color:#fff;text-decoration:none;font-size:13px;font-weight:600;padding:6px 12px;border-radius:var(--radius)}.dash-nav a:hover{background:#ffffff1f;text-decoration:none}.tab-nav button{background:transparent;border:0;box-shadow:none;color:#fff;font-size:13px;font-weight:600;padding:6px 12px;border-radius:var(--radius)}.tab-nav button:hover{background:#ffffff1f}.tab-nav button.active{background:#ffffff2e}.dash-page-tabs{display:flex}.dash-page-tabs>.tab-content{grid-column:1 / -1;min-width:0;display:flex;flex-direction:column;gap:20px}.tab-workbench{display:grid;grid-template-columns:320px minmax(0,1fr);gap:20px;align-items:start}.tab-workbench>*{min-width:0}@media(max-width:899px){.tab-workbench{grid-template-columns:1fr}.tab-workbench #sec-dispatch{order:1}.tab-workbench #sec-tasks{order:2}.tab-workbench #sec-conv{order:3}}.dash-page{max-width:1180px;margin:0 auto;padding:20px;display:flex;flex-direction:column;gap:20px}.dash-page>*{min-width:0}.dash-section>*{min-width:0}.dash-section{scroll-margin-top:130px;display:flex;flex-direction:column;gap:16px}.dash-section.card{padding:16px}.dash-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.dash-card{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:16px;min-width:0}.dash-card-label{color:var(--muted);font-size:12px;font-weight:600}.dash-card-value{font-size:24px;font-weight:600;line-height:1.3;overflow-wrap:anywhere}.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:none;padding:16px}.card h2{font-size:14px;margin:0 0 12px;font-weight:600}.card h3{font-size:14px;margin:24px 0 12px;padding-top:16px;border-top:1px solid var(--line);font-weight:600}.card h3:first-of-type{border-top:0;padding-top:0}.composer h2{font-size:16px}.composer textarea.prompt{min-height:148px;font-size:14px;padding:12px;border-radius:var(--radius)}.composer textarea.coord{font-family:var(--mono);font-size:13px}.bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:12px 0}.bar>button,.bar>label{display:inline-flex;align-items:center;height:var(--ctl);min-height:var(--ctl);padding:0 16px;box-sizing:border-box;white-space:nowrap}.bar>input,.bar>select{height:var(--ctl);min-height:var(--ctl)}input,select,textarea,button{font:inherit;font-size:14px;padding:5px 12px;border-radius:var(--radius);border:1px solid var(--line);background:#fff;color:var(--text)}input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px #0969da4d}select{-moz-appearance:none;appearance:none;-webkit-appearance:none;cursLine truncated
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -226,6 +226,16 @@ def _task_to_dict(row, attachments=None, total_tokens=None):
|
|||||||
# 列表接口由 _task_tokens_map 一次聚合传入;其它调用方
|
# 列表接口由 _task_tokens_map 一次聚合传入;其它调用方
|
||||||
# 不传时为 None(前端显示占位),绝不把“未算”写成 0。
|
# 不传时为 None(前端显示占位),绝不把“未算”写成 0。
|
||||||
"total_tokens": total_tokens,
|
"total_tokens": total_tokens,
|
||||||
|
# feat-chv-arch95plus 只读字段:仅当 CHV 任务且 worker
|
||||||
|
# 已把 VM 死亡写进 interrupted_reason 时才出现,值为
|
||||||
|
# True;其它情形一律不返回该字段(绝不把“未报告死亡”
|
||||||
|
# 写成“VM 活着”,live VM 状态不在 server 进程里,
|
||||||
|
# server 没有 VM 登记表,编不出来也不编)。
|
||||||
|
**({"chv_vm_dead": True}
|
||||||
|
if _row_get(row, "executor") == "cloud-hypervisor"
|
||||||
|
and _row_get(row, "interrupted_reason")
|
||||||
|
and "VM 中途死亡" in str(_row_get(row, "interrupted_reason"))
|
||||||
|
else {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"""CHV 架构 9.5+ 提升轨测试(feat-chv-arch95plus,堆叠于 #252)。
|
||||||
|
|
||||||
|
只用标准库与 mock,绝不启动真实 VM;测试里只用 FAKE
|
||||||
|
占位密钥,绝无真实密钥。覆盖:长函数提取后的行为不变、
|
||||||
|
WarmPool facade 不调实验接口、#215 对齐接口形状、审计
|
||||||
|
失败计数不中断、只读字段 chv_vm_dead 的派生规则、
|
||||||
|
lockfile 校验脚本的通过/漂移/空清单。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from cloud.chv.compat215 import resolve_inside, tmpfs_isolated
|
||||||
|
from cloud.chv.hardening import validate_snapshot_url
|
||||||
|
from cloud.chv.pool import reconcile_orphans_for_executor
|
||||||
|
from cloud.sandbox import CloudHypervisorExecutor
|
||||||
|
from cloud.server import _task_to_dict
|
||||||
|
except ImportError:
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||||
|
from cloud.chv.compat215 import resolve_inside, tmpfs_isolated
|
||||||
|
from cloud.chv.hardening import validate_snapshot_url
|
||||||
|
from cloud.chv.pool import reconcile_orphans_for_executor
|
||||||
|
from cloud.sandbox import CloudHypervisorExecutor
|
||||||
|
from cloud.server import _task_to_dict
|
||||||
|
|
||||||
|
|
||||||
|
def _row(**kw):
|
||||||
|
base = {
|
||||||
|
"id": "t1", "project_id": "p1", "prompt": "x",
|
||||||
|
"status": "interrupted", "idempotency_key": None,
|
||||||
|
"depends_on": "[]", "priority": 0, "created_by": "u",
|
||||||
|
"created_at": 0, "updated_at": 0, "last_heartbeat": 0,
|
||||||
|
}
|
||||||
|
base.update(kw)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
class FakeExecutor:
|
||||||
|
def __init__(self):
|
||||||
|
self.acquire_called = False
|
||||||
|
self.prewarm_called = False
|
||||||
|
|
||||||
|
def shutdown_vm(self, vm):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _emit(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Compat215Test(unittest.TestCase):
|
||||||
|
def test_resolve_inside_blocks_escape_and_symlink(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
inside = os.path.join(d, "a.txt")
|
||||||
|
with open(inside, "w") as fh:
|
||||||
|
fh.write("x")
|
||||||
|
self.assertEqual(resolve_inside(d, "a.txt"), inside)
|
||||||
|
self.assertIsNone(resolve_inside(d, "../outside"))
|
||||||
|
self.assertIsNone(resolve_inside(d, "/etc/passwd"))
|
||||||
|
self.assertEqual(resolve_inside(d, ""), d)
|
||||||
|
|
||||||
|
def test_tmpfs_isolated_chv_and_unknown_fail_closed(self):
|
||||||
|
isolated, _ = tmpfs_isolated("cloud-hypervisor", "/tmp")
|
||||||
|
self.assertFalse(isolated)
|
||||||
|
isolated, _ = tmpfs_isolated("unknown-kind", "/tmp")
|
||||||
|
self.assertFalse(isolated)
|
||||||
|
|
||||||
|
def test_snapshot_url_uses_resolve_inside(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
state = os.path.join(d, "state")
|
||||||
|
os.makedirs(state)
|
||||||
|
path = os.path.join(state, "s.raw")
|
||||||
|
with open(path, "w") as fh:
|
||||||
|
fh.write("x")
|
||||||
|
self.assertEqual(
|
||||||
|
validate_snapshot_url("file://" + path, state), path)
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
validate_snapshot_url(
|
||||||
|
"file:///etc/passwd", state)
|
||||||
|
|
||||||
|
def test_no_timeout_or_quota_map(self):
|
||||||
|
import cloud.chv.compat215 as m
|
||||||
|
self.assertFalse(hasattr(m, "resolve_module_timeout"))
|
||||||
|
self.assertFalse(hasattr(m, "decide"))
|
||||||
|
|
||||||
|
|
||||||
|
class PoolFacadeTest(unittest.TestCase):
|
||||||
|
def test_facade_only_reconciles_never_acquires(self):
|
||||||
|
# facade 内部构造 WarmPool:用假执行器 + 空 registry,
|
||||||
|
# 只要它不调 acquire/prewarm 就返回 [] 且不炸。
|
||||||
|
ex = FakeExecutor()
|
||||||
|
# WarmPool 构造需要 state_dir 属性;没有则 registry 为空
|
||||||
|
ex.state_dir = tempfile.mkdtemp()
|
||||||
|
ex.warm_pool_size = 0
|
||||||
|
ex.snapshot_url = None
|
||||||
|
result = reconcile_orphans_for_executor(ex)
|
||||||
|
self.assertEqual(result, [])
|
||||||
|
self.assertFalse(ex.acquire_called)
|
||||||
|
self.assertFalse(ex.prewarm_called)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditCounterTest(unittest.TestCase):
|
||||||
|
def test_emit_failure_counts_and_does_not_raise(self):
|
||||||
|
ex = CloudHypervisorExecutor.__new__(CloudHypervisorExecutor)
|
||||||
|
ex.audit_callback = lambda *a, **k: (_ for _ in ()).throw(
|
||||||
|
RuntimeError("db down"))
|
||||||
|
ex.audit_failure_count = 0
|
||||||
|
# _emit 不抛异常(任务不因此中断),计数 +1
|
||||||
|
ex._emit("vm_boot", {"id": "t1"}, "vm=chv-t1")
|
||||||
|
self.assertEqual(ex.audit_failure_count, 1)
|
||||||
|
ex._emit("vm_boot", {"id": "t1"}, "vm=chv-t1")
|
||||||
|
self.assertEqual(ex.audit_failure_count, 2)
|
||||||
|
|
||||||
|
|
||||||
|
class ChvVmDeadFieldTest(unittest.TestCase):
|
||||||
|
def test_only_chv_with_vm_death_reason_returns_true(self):
|
||||||
|
row = _row(executor="cloud-hypervisor",
|
||||||
|
interrupted_reason="VM 中途死亡,任务未完成")
|
||||||
|
d = _task_to_dict(row)
|
||||||
|
self.assertTrue(d.get("chv_vm_dead"))
|
||||||
|
row2 = _row(executor="cloud-hypervisor",
|
||||||
|
interrupted_reason="quota")
|
||||||
|
self.assertNotIn("chv_vm_dead", _task_to_dict(row2))
|
||||||
|
row3 = _row(executor="docker",
|
||||||
|
interrupted_reason="VM 中途死亡,任务未完成")
|
||||||
|
self.assertNotIn("chv_vm_dead", _task_to_dict(row3))
|
||||||
|
row4 = _row(executor="cloud-hypervisor",
|
||||||
|
interrupted_reason=None)
|
||||||
|
self.assertNotIn("chv_vm_dead", _task_to_dict(row4))
|
||||||
|
|
||||||
|
|
||||||
|
class LockfileScriptTest(unittest.TestCase):
|
||||||
|
def _run(self, data, extra=None):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
p = os.path.join(d, "BUILDINFO.json")
|
||||||
|
with open(p, "w") as fh:
|
||||||
|
json.dump(data, fh)
|
||||||
|
script = str(Path(__file__).resolve().parents[2]
|
||||||
|
/ "chv-guest" / "check_lockfile.py")
|
||||||
|
cmd = [sys.executable, script, p] + (extra or [])
|
||||||
|
return subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
|
||||||
|
def test_pass_and_empty_and_drift(self):
|
||||||
|
good = {"tag": "t", "versions": {"dpkg": ["a 1"],
|
||||||
|
"pip": ["b==2"]}}
|
||||||
|
self.assertEqual(self._run(good, ["--tag", "t"]).returncode, 0)
|
||||||
|
bad = {"tag": "t", "versions": {"dpkg": [], "pip": []}}
|
||||||
|
self.assertNotEqual(self._run(bad).returncode, 0)
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
frozen = os.path.join(d, "frozen.json")
|
||||||
|
with open(frozen, "w") as fh:
|
||||||
|
json.dump({"dpkg": ["missing 9"], "pip": []}, fh)
|
||||||
|
self.assertNotEqual(
|
||||||
|
self._run(good, ["--frozen", frozen]).returncode, 0)
|
||||||
|
|
||||||
|
|
||||||
|
class LongFunctionTest(unittest.TestCase):
|
||||||
|
def test_no_chv_function_over_50_lines(self):
|
||||||
|
import ast
|
||||||
|
base = Path(__file__).resolve().parents[2] / "cloud" / "chv"
|
||||||
|
over = []
|
||||||
|
for f in base.glob("*.py"):
|
||||||
|
tree = ast.parse(f.read_text())
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.FunctionDef):
|
||||||
|
n = node.end_lineno - node.lineno + 1
|
||||||
|
if n > 50:
|
||||||
|
over.append("%s:%s:%d" % (f.name, node.name, n))
|
||||||
|
self.assertEqual(over, [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -9348,6 +9348,17 @@ class Worker:
|
|||||||
final_written = self._set_status_if_running(task_id, final)
|
final_written = self._set_status_if_running(task_id, final)
|
||||||
if quota_stop and final_written and final == "interrupted":
|
if quota_stop and final_written and final == "interrupted":
|
||||||
self._mark_interrupted_reason(task_id, "quota")
|
self._mark_interrupted_reason(task_id, "quota")
|
||||||
|
# feat-chv-arch95plus:VM 死亡写回——执行器 _run_with_vm
|
||||||
|
# 已把 vm_dead/interrupted_reason 放进 result,本处把它
|
||||||
|
# 写进 tasks.interrupted_reason 列,server 的只读字段
|
||||||
|
# chv_vm_dead 才能据此触发;否则该字段在生产里永远
|
||||||
|
# 不出现,前端 VM 死亡横幅就是死代码。只写原因文本,
|
||||||
|
# 不改状态本身(_mark 只在 status='interrupted' 时写)。
|
||||||
|
if final_written and final == "interrupted" \
|
||||||
|
and (result or {}).get("vm_dead"):
|
||||||
|
self._mark_interrupted_reason(
|
||||||
|
task_id, (result or {}).get("interrupted_reason")
|
||||||
|
or "VM 中途死亡")
|
||||||
finished_at = time.time()
|
finished_at = time.time()
|
||||||
# P0 安全批后:CAS 没改到行(任务已被取消/回收)时
|
# P0 安全批后:CAS 没改到行(任务已被取消/回收)时
|
||||||
# 绝不写回结果,否则迟到的旧进程会覆盖新一轮的结果
|
# 绝不写回结果,否则迟到的旧进程会覆盖新一轮的结果
|
||||||
|
|||||||
@@ -72,9 +72,67 @@
|
|||||||
|
|
||||||
- 现状:#215(timeouts/gates/quota/sandbox_policy)未合并,
|
- 现状:#215(timeouts/gates/quota/sandbox_policy)未合并,
|
||||||
本链 base 里无其模块,本轨不复制其代码。
|
本链 base 里无其模块,本轨不复制其代码。
|
||||||
- 接口对齐点(#215 合并后机械替换,非重写):超时类
|
- 纠正(feat-chv-arch95plus,核对过 #215 远程分支真实
|
||||||
api/ready/task 解析 → timeouts;门禁类 preflight/容量/
|
函数后):上一轨 ADR-7 的“超时→timeouts、配额→quota”
|
||||||
磁盘配额/egress fail-closed → gates;配额类 max_vms/disk_gb
|
泛化映射是错的。#215 的 timeouts 收敛的是审批/队列
|
||||||
(任务成本仍走 worker quota)→ quota;隔离类单任务单 VM/
|
超时(None/0=不过期),quota 是按月美元配额,与 CHV
|
||||||
磁盘 copy/egress 代理/SSH 钉死/池一次性与孤儿清理 →
|
的 api/ready 秒数(0 无效)和 VM 数/磁盘 GB/快照数
|
||||||
sandbox_policy。替换时须重跑本链 163 项测试,不得改语义。
|
单位与语义都不同,硬套会改行为,故这两项无映射。
|
||||||
|
- 真实映射(已落代码,见 cloud/chv/compat215.py):
|
||||||
|
gates.resolve_inside(base, rel) 目录门 → CHV 快照/磁盘
|
||||||
|
路径白名单已在 hardening 里使用,合并后改为 import;
|
||||||
|
sandbox_policy.tmpfs_isolated → CHV 客机 /tmp 落客机
|
||||||
|
磁盘、一律不隔离(fail-closed),合并后改为 import。
|
||||||
|
其余 #215 函数(审批、控制门、合并状态门、worker 寻址)
|
||||||
|
与 CHV 无交集,不映射。替换时须重跑本链测试,不得改语义。
|
||||||
|
|
||||||
|
## ADR-8 WarmPool 生产隔离:facade 而非类导入
|
||||||
|
|
||||||
|
- 决策:生产路径(worker 启动/关闭)只调
|
||||||
|
cloud/chv/pool.py 的 reconcile_orphans_for_executor()
|
||||||
|
facade,该函数内部才构造 WarmPool 并只调
|
||||||
|
reconcile_orphans();生产 dispatch 绝不调 prewarm/
|
||||||
|
acquire/release,executor.py 也不 import pool。
|
||||||
|
WarmPool 类与 sandbox re-export 保留仅为兼容(旧测试
|
||||||
|
import 路径),新代码不得经类入口进实验接口。
|
||||||
|
- 测试钉住:facade 测试断言 acquire/prewarm 绝不被调;
|
||||||
|
executor 不 import pool 由模块依赖检查覆盖。
|
||||||
|
|
||||||
|
## ADR-9 审计失败取舍:记日志 + 计数,不中断任务
|
||||||
|
|
||||||
|
- 选项 A(失败即中断任务):审计完整性最强,但 DB 短暂
|
||||||
|
不可用会把一次审计失败放大成任务失败,且与 worker
|
||||||
|
全局 best-effort 语义冲突,CHV 单独硬中断是不一致的。
|
||||||
|
- 选项 B(失败记日志 + 计数 + 不中断任务):已选。
|
||||||
|
executor._emit 失败时记宿主 error 日志并累加
|
||||||
|
audit_failure_count(宿主可观测,非任务状态),任务
|
||||||
|
继续;审计完整性本身没有保证,文档与就绪清单明写。
|
||||||
|
- 边界:本决策只针对 VM 生命周期审计;VM 启动/关机等
|
||||||
|
门禁失败仍按原有分类失败处理,不受本条影响。
|
||||||
|
|
||||||
|
## ADR-10 长函数抽不动的耦合点(本轨后已无 >50 行函数)
|
||||||
|
|
||||||
|
- 本轨后 cloud/chv/ 全部函数均 ≤50 行,上一轨的 7 个
|
||||||
|
长函数全部拆完:stop_vm(进程清理/文件清理/脏标记三
|
||||||
|
helper)、wait_guest_ready(provisioned 守卫/不匹配检查)、
|
||||||
|
restore_vm(校验/启动段)、cleanup_stale_secrets(单 VM
|
||||||
|
清理/活 VM 判定)、cleanup_stale_vms(死 socket 扫描/
|
||||||
|
登记清理)、_unix_socket_api(读头/解析长度/读体/解析
|
||||||
|
响应)、validate_net_entries(归一化/去重/冲突检查)、
|
||||||
|
build_chv_config(组装段纯移动)。
|
||||||
|
- 残留耦合点(已拆但仍需注意):stop_vm 的三块清理共享
|
||||||
|
同一脏标记,任一块失败即整 VM 标记脏(有意,不拆成
|
||||||
|
三个标记);_unix_socket_api 的读满语义仍由四个 helper
|
||||||
|
共同保证,改任一 helper 须重跑截断/超时测试。
|
||||||
|
|
||||||
|
## ADR-11 只读 VM 字段边界:chv_vm_dead 不是 live 状态
|
||||||
|
|
||||||
|
- 决策:server 任务序列化只在 CHV 任务且 worker 已把
|
||||||
|
VM 死亡写进 interrupted_reason 列时返回 chv_vm_dead=True,
|
||||||
|
其它情形字段不存在;worker 终局时把执行器的 vm_dead
|
||||||
|
原因写回该列(见 worker.py 终局块),否则该字段在
|
||||||
|
生产里永远不出现。
|
||||||
|
- 边界:该字段是“已报告死亡”,不是“VM 活着/死了”的
|
||||||
|
live 状态——server 进程没有 VM 登记表,live 状态编不
|
||||||
|
出来;前端无该字段时不显示 VM 状态,不猜测。VM 控制
|
||||||
|
按钮仍不做(后端无控制端点且安全审计 No-Go)。
|
||||||
@@ -426,3 +426,36 @@
|
|||||||
- 真机动态渗透、压测(无 KVM,做不了,只列清单);
|
- 真机动态渗透、压测(无 KVM,做不了,只列清单);
|
||||||
- WarmPool dispatch 前查池接线(已定案不做,见 ADR-5;重启须先
|
- WarmPool dispatch 前查池接线(已定案不做,见 ADR-5;重启须先
|
||||||
实现任务盘重供给并补不变量测试)。
|
实现任务盘重供给并补不变量测试)。
|
||||||
|
|
||||||
|
## 12. 架构 9.5+ 提升轨 feat-chv-arch95plus(堆叠于 #252 之上)
|
||||||
|
|
||||||
|
> 验证方式标注(本轨新增,逐条不混淆):静态 = 代码 + mock
|
||||||
|
> 测试已覆盖;真机 = 须 KVM 宿主验证;压测 = 须并发负载验证。
|
||||||
|
> 凡写“代码已做”而未标真机/压测的,一律只代表静态通过。
|
||||||
|
|
||||||
|
- 长函数:cloud/chv/ 全部函数 ≤50 行(静态,mock 测试覆盖
|
||||||
|
提取后的 stop/wait/restore/清理/接收/校验/组装段)。
|
||||||
|
- WarmPool 生产隔离:生产路径只调 reconcile facade(静态);
|
||||||
|
预热收益仍是实验性,真机收益未验证(真机)。
|
||||||
|
- #215 对齐:目录门与 tmpfs 口径已落代码(静态);超时与
|
||||||
|
美元配额无映射,#215 合并后替换为 import 仍须重跑测试
|
||||||
|
(合并后,静态)。
|
||||||
|
- 审计失败:记日志 + 计数 + 不中断(静态);审计完整性在
|
||||||
|
DB 故障下的实际表现仍须真机/故障注入验证(真机)。
|
||||||
|
- 供应链:BUILDINFO 事后 lockfile 校验脚本已落(静态);
|
||||||
|
事前 apt/pip 精确 lockfile 仍缺(须构建宿主,外部)。
|
||||||
|
- 只读 VM 字段:chv_vm_dead 派生自 worker 已写回的
|
||||||
|
interrupted_reason(静态,mock 测试覆盖);live VM 状态
|
||||||
|
仍无字段,VM 控制按钮仍不做(后端无控制端点 + 安全 No-Go)。
|
||||||
|
- 合并前 diff 复核清单(合并时逐项打勾,不在本轨内做):
|
||||||
|
server 白名单、worker 断点分支、sandbox re-export、
|
||||||
|
console.html 构建产物与 src 一致、#215 合并后的 import
|
||||||
|
替换点。
|
||||||
|
- 真机验证清单(沿用 §11.5,另加本轨项):VM 死亡横幅在
|
||||||
|
真任务中断后是否出现、审计失败计数在 DB 故障时是否
|
||||||
|
累加且任务不中断、快照白名单目录门在符号链接逃逸时
|
||||||
|
是否拒绝。
|
||||||
|
- 灰度启用清单(启用时逐项打勾):CI 变绿 → 按 #216→#252→
|
||||||
|
本 PR 顺序合并 → KVM 宿主 + 防火墙清单 → 镜像构建发布
|
||||||
|
→ §11.5 + 本节真机清单全过 → 默认关闭下单租户/受信项目
|
||||||
|
灰度 → 全量多租户(须另行决策,不在本轨内默认开启)。
|
||||||
Reference in new issue
Block a user