feat(chv): 安全审计修复 High/Medium/Low(堆叠于 #239)
审计报告 ~/workspace/chv-security-audit/REPORT.md(No-Go, Critical 0 / High 7 / Medium 8 / Low 5)代码层修复,mock 测试覆盖,真机动态验证一律未做、只列清单(见 docs/chv-production-readiness.md §11)。 High: - H1 egress 代码门:代理地址校验、直连显式允许+审计、 每 VM 独立 tap/mac/ip 校验、net 字段白名单;宿主防火墙 列为部署清单,代码门+防火墙缺一不可。 - H2 网关 key 移出 argv:provisioned seed 盘客内文件 /etc/chv/guest-env(0600)投递,传输层密钥进 argv 直接 ValueError;keyscan 模式密钥投递 fail-closed。 - H3 任务盘删除:stop/recycle/cleanup 删 disk.raw(保留期 0)+ 残留盘 GC,删失败审计+脏标记。 - H4 默认值改 provisioned:keyscan 只作显式 opt-in。 - H5 seed.iso 生成即 0600;宿主私钥 seed 盘建好后即删; 崩溃残留扫描 cleanup_stale_secrets。 - H6 state_dir 默认出 /tmp(~/.local/share/.../chv-vms, preflight 保 0700);api-socket 组/其它可写即 fail-fast。 - H7 tap 隔离校验:net.json 登记+stop 释放,共享即拒绝审计。 Medium:M1 shlex 远程转义;M2 清理失败即拒起(脏标记); M3 配额默认 50GB + 快照数/总量/保留期上限;M4 SHA256 必填 + BUILDINFO versions 实测记录(事前 lockfile 列清单); M5 host key/ preflight/失败审计补齐;M6 reuse 须声明单租户 项目;M7 容量计数只认连通 socket;M8 sshd 加固进 build.sh 断言。Low:L1 链接不跟随;L2 路径白名单;L3 失败审计; L4 双变量警告+字段白名单;L5 预热盘/任务盘对应关系定义。 旧测试:实验室默认值与生产默认值区分标注;行为已变的旧 测试按新语义更新(容量死 socket、快照白名单、磁盘路径 白名单、网关 argv、reuse 租户声明)。 测试:新 test_chv_secfix 39 项;旧 117 项 + 2 项新增断言 = 158 项;worker 相关 129 项;前端 vitest 9 项;cloud 全量 1588 项(skipped 4)。
This commit is contained in:
1 parent
003ea357f9
commit
baf26beb78
16 files changed
+2106
-132
No files matched your search
+504
-61
@@ -40,11 +40,22 @@ from cloud.executor_base import Executor
|
||||
from cloud.chv.config import _parse_memory_bytes
|
||||
from cloud.chv.config import build_chv_config
|
||||
from cloud.chv.disk import check_disk_quota
|
||||
from cloud.chv.disk import check_snapshot_limits
|
||||
from cloud.chv.disk import delete_task_disk
|
||||
from cloud.chv.disk import provision_task_disk
|
||||
from cloud.chv.disk import record_snapshot
|
||||
from cloud.chv.disk import task_disk_path
|
||||
from cloud.chv.errors import ChvFailure
|
||||
from cloud.chv.hardening import ensure_secure_state_dir
|
||||
from cloud.chv.hardening import parse_net_entries
|
||||
from cloud.chv.hardening import socket_is_live
|
||||
from cloud.chv.hardening import validate_egress_proxy
|
||||
from cloud.chv.hardening import validate_net_entries
|
||||
from cloud.chv.hardening import validate_snapshot_url
|
||||
from cloud.chv.ssh_transport import build_ssh_command as _build_ssh_cmd
|
||||
from cloud.chv.ssh_transport import known_hosts_path as _known_hosts_path
|
||||
from cloud.chv.ssh_transport import parse_keyscan_output
|
||||
from cloud.chv.ssh_transport import split_secret_env
|
||||
from cloud.chv.ssh_transport import write_known_hosts
|
||||
|
||||
_LOGGER = logging.getLogger("cloud.cloud_hypervisor")
|
||||
@@ -89,7 +100,10 @@ class CloudHypervisorExecutor(Executor):
|
||||
- 密钥红线:SSH 只传 -i 密钥文件路径,绝不把任何 key 值、
|
||||
模型 key 值放进 argv、日志或返回结果;客内 host.py 的
|
||||
模型 key 由客机镜像/cloud-init 预置(运维责任),v1 不
|
||||
在 SSH 命令里注入任务级 key。
|
||||
在 SSH 命令里注入任务级 key。feat-chv-secfix 起网关
|
||||
密钥改走 seed 盘客内文件投递(见 wake_vm 与
|
||||
hostkeys.write_seed),keyscan 模式下密钥投递
|
||||
fail-closed;非密钥 extra_env 仍走 env 前缀。
|
||||
|
||||
VM 唤醒流程(写进代码,不是只留文档;API 模式为准):
|
||||
- 主唤醒路径是 API 模式,不是“一条直接启动命令就完事”:
|
||||
@@ -180,7 +194,9 @@ class CloudHypervisorExecutor(Executor):
|
||||
存在;CHV_IMAGE 是同义别名,仅当 CHV_ROOTFS 未设时用)
|
||||
- CHV_CPU:vCPU 数(默认 2,命令里写 boot=N)
|
||||
- CHV_MEM:内存(默认 2G,命令里写 size=2G)
|
||||
- CHV_STATE_DIR:api-socket 目录(默认 /tmp/chv-vms)
|
||||
- CHV_STATE_DIR:api-socket 目录(默认
|
||||
~/.local/share/codex-app-server/chv-vms,0700;
|
||||
feat-chv-secfix 起不再默认 /tmp/chv-vms)
|
||||
- CHV_SSH_KEY:SSH 私钥文件路径(必填,文件必须存在)
|
||||
- CHV_SSH_USER:SSH 用户(默认 root)
|
||||
- CHV_SSH_PORT:宿主侧转发到客机的 SSH 端口(默认 2222)
|
||||
@@ -260,8 +276,16 @@ class CloudHypervisorExecutor(Executor):
|
||||
"allow_direct_egress", "task_timeout", "snapshot_url",
|
||||
"warm_pool_size", "api_timeout", "ready_timeout",
|
||||
"ready_interval", "host_key_mode", "ssh_host",
|
||||
"ssh_forward", "reuse_experiment"):
|
||||
"ssh_forward", "reuse_experiment",
|
||||
"single_tenant_project", "snapshot_max_count",
|
||||
"snapshot_max_total_gb", "snapshot_retention_days",
|
||||
"snapshot_dir", "allow_remote_snapshot"):
|
||||
setattr(self, _field, getattr(_cfg, _field))
|
||||
# feat-chv-secfix(H2):本实例已 seed 投递密钥的 VM
|
||||
# 名 → 已投递密钥名集合。_run_guest 只对集合内的密钥
|
||||
# 名 source 客内文件,未投递的密钥名一律拒绝(防止
|
||||
# “wake 没投递、执行时却按名 source 一个旧文件”)。
|
||||
self._guest_env_delivered = {}
|
||||
self.api_client = api_client if api_client is not None \
|
||||
else _unix_socket_api
|
||||
self.sleeper = sleeper if sleeper is not None else time.sleep
|
||||
@@ -329,6 +353,18 @@ class CloudHypervisorExecutor(Executor):
|
||||
"CHV_DISK_MODE=reuse 未经显式实验确认:"
|
||||
"须同时设 CHV_REUSE_SINGLE_TENANT_"
|
||||
"EXPERIMENT=1(仅单租户实验)"))
|
||||
# feat-chv-secfix(M6):实验放行还须声明单租户项目。
|
||||
# 代码无法从环境变量验证数据库里是否真的只有一个
|
||||
# 租户——声明 + 审计就是本项的机制,文档明写该边界,
|
||||
# 不假装已校验多租户状态。
|
||||
if getattr(self, "disk_mode", "copy") == "reuse" \
|
||||
and getattr(self, "reuse_experiment", False) \
|
||||
and not getattr(self, "single_tenant_project", None):
|
||||
missing.append(("reuse_tenant_undeclared",
|
||||
"CHV_DISK_MODE=reuse 实验放行须同时声明"
|
||||
" CHV_SINGLE_TENANT_PROJECT(单租户"
|
||||
"项目 id,仅部署声明 + 审计,不验证"
|
||||
"数据库多租户状态)"))
|
||||
# P2-6:配置校验时机统一到 preflight。CPU 非整数、内存
|
||||
# 字符串非法过去只在 build_vm_config 才炸,现在 preflight
|
||||
# 一次报出(kind=config_invalid)。
|
||||
@@ -424,26 +460,48 @@ class CloudHypervisorExecutor(Executor):
|
||||
missing.append(("egress_not_configured",
|
||||
"CHV_EGRESS_PROXY 未设且未显式允许直连:"
|
||||
"VM 出口必须过白名单代理,默认不给直连外网"))
|
||||
if self.egress_proxy:
|
||||
try:
|
||||
validate_egress_proxy(self.egress_proxy)
|
||||
except ChvFailure as exc:
|
||||
missing.append((exc.kind, str(exc)))
|
||||
# feat-chv-secfix(H6):state_dir 安全目录门。
|
||||
# 默认值已出 /tmp(见 config.py);这里再校验一次:
|
||||
# 目录须存在且 0700,否则 preflight 直接失败。
|
||||
try:
|
||||
ensure_secure_state_dir(self.state_dir)
|
||||
except ChvFailure as exc:
|
||||
missing.append((exc.kind, str(exc)))
|
||||
# 补缺口轨 (4):tap/网络配置校验前置到 preflight。
|
||||
# CHV_NET_JSON 非法 JSON 或非 list/dict 时直接报
|
||||
# net_config_invalid,不等到 vm.create 才炸;直连
|
||||
# 只在显式 CHV_ALLOW_DIRECT_EGRESS=1 时放行,且
|
||||
# wake_vm 里发 egress_direct_allowed 审计。tap 桥接
|
||||
# 本身是否真只走代理仍需宿主实测,代码不声称已验证。
|
||||
# feat-chv-secfix(L4):再加字段白名单与条目内重复
|
||||
# 校验;CHV_NET 与 CHV_NET_JSON 双设时只发警告审计
|
||||
# (net_config_dual),不硬拒——CHV_NET 是手动直接
|
||||
# 启动命令的透传值,双设不一定是误配,硬拒会断掉
|
||||
# 手动运维路径;误配的代价是“API 模式忽略 CHV_NET”,
|
||||
# 警告 + 文档已写清。
|
||||
if self.net and self.net_json:
|
||||
self._emit("net_config_dual", None,
|
||||
"CHV_NET 与 CHV_NET_JSON 双设:API 模式只用"
|
||||
" CHV_NET_JSON,CHV_NET 仅手动直接启动命令用")
|
||||
if self.net_json:
|
||||
try:
|
||||
_net = json.loads(self.net_json)
|
||||
except ValueError:
|
||||
missing.append(("net_config_invalid",
|
||||
"CHV_NET_JSON 不是合法 JSON"))
|
||||
else:
|
||||
if not isinstance(_net, (list, dict)):
|
||||
missing.append(("net_config_invalid",
|
||||
"CHV_NET_JSON 须为 JSON list 或 dict"))
|
||||
_entries = parse_net_entries(self.net_json)
|
||||
validate_net_entries(_entries, [])
|
||||
except ChvFailure as exc:
|
||||
missing.append((exc.kind, str(exc)))
|
||||
self._preflight_config_checks(missing)
|
||||
if missing:
|
||||
kind = missing[0][0]
|
||||
detail = "; ".join(text for _, text in missing)
|
||||
# feat-chv-secfix(M5/L3):preflight 失败本身也
|
||||
# 审计(此前 try 块之前失败一律无审计事件)。
|
||||
self._emit("preflight_failed", None,
|
||||
"preflight 失败:%s" % detail, kind)
|
||||
raise ChvFailure(
|
||||
kind, "cloud-hypervisor preflight 失败:%s" % detail)
|
||||
return True
|
||||
@@ -456,18 +514,22 @@ class CloudHypervisorExecutor(Executor):
|
||||
def _emit(self, event, task, detail="", kind=None):
|
||||
"""VM 生命周期审计事件:有 audit_callback 时调它
|
||||
(worker 接线后转 AuthStore 审计,即任务 timeline 的
|
||||
审计来源),无 callback 时只记日志,绝不因审计失败炸
|
||||
任务。event 取值:vm_create/vm_boot/vm_ready/vm_pause/
|
||||
vm_resume/vm_snapshot/vm_restore/vm_shutdown/vm_destroy/
|
||||
vm_failed/vm_recycled/warm_pool_acquire/warm_pool_release。"""
|
||||
审计来源),无 callback 时只记日志。
|
||||
feat-chv-secfix(M5):审计回调失败不再双重静默
|
||||
pass——记宿主 error 日志(审计完整性本身没有保证,
|
||||
但失败至少可见;任务仍不因此炸,审计失败不改变
|
||||
任务状态是 worker 层的既定语义)。审计 detail 里
|
||||
绝不含密钥值(调用方负责:本类所有 _emit 的 detail
|
||||
只含 VM 名、路径、URL、kind,不含任何 key 值)。"""
|
||||
try:
|
||||
if self.audit_callback is not None:
|
||||
self.audit_callback(event, task, detail, kind)
|
||||
else:
|
||||
_LOGGER.info("chv_audit event=%s kind=%s %s",
|
||||
event, kind, detail)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
_LOGGER.error("chv_audit_failed event=%s kind=%s error=%s",
|
||||
event, kind, exc)
|
||||
|
||||
# -- 命令构造(纯函数,不做文件校验) -------------------------------
|
||||
def _build_vm_command(self, task):
|
||||
@@ -535,7 +597,7 @@ class CloudHypervisorExecutor(Executor):
|
||||
"里给 net 条目配 ip(静态 tap 客机 IP)、或显式设 "
|
||||
"CHV_SSH_FORWARD=1 宣称宿主 NAT 已转发回环端口")
|
||||
|
||||
def provision_host_key(self, task):
|
||||
def provision_host_key(self, task, secret_env=None):
|
||||
"""按 VM 预置 host key(补缺口轨强方案,见 hostkeys.py):
|
||||
启动前为该 VM 生成一次性 ed25519 host key 对,公钥
|
||||
立即写进该 VM 的 known_hosts(钉死),私钥只写进
|
||||
@@ -543,12 +605,23 @@ class CloudHypervisorExecutor(Executor):
|
||||
而删。返回 {paths, seed_dir, known_hosts}。keygen
|
||||
失败即 raise,provisioned 模式下绝不回落 keyscan;
|
||||
不同 VM 的 key 对不同(state_dir/<vm> 隔离),同一
|
||||
VM 重启复用已有对(否则钉死立即失效)。"""
|
||||
VM 重启复用已有对(否则钉死立即失效)。
|
||||
feat-chv-secfix(H2/M2):secret_env 给定时写进
|
||||
seed 的客内密钥文件(0600);上次清理失败留下的
|
||||
脏标记存在时直接拒绝(清理失败即拒起,不再复用
|
||||
旧 key 对)。成功后审计 host_key_generated(只含
|
||||
VM 名与密钥名,不含任何 key 值)。"""
|
||||
from cloud.chv.hostkeys import (
|
||||
generate_host_keypair, host_key_paths, known_hosts_line,
|
||||
write_seed)
|
||||
dirty_marker_path, generate_host_keypair, host_key_paths,
|
||||
known_hosts_line, write_seed)
|
||||
from cloud.chv.ssh_transport import write_known_hosts
|
||||
vm = self.vm_name(task)
|
||||
if os.path.exists(dirty_marker_path(self.state_dir, vm)):
|
||||
raise ChvFailure(
|
||||
"vm_dirty_refused",
|
||||
"VM %s 上次清理失败(脏标记存在),拒绝复用旧 "
|
||||
"host key 启动:请人工清理 state_dir 后删脏标记"
|
||||
% vm)
|
||||
paths = generate_host_keypair(
|
||||
self.state_dir, vm,
|
||||
runner=lambda cmd: self._call_runner(cmd))
|
||||
@@ -567,7 +640,11 @@ class CloudHypervisorExecutor(Executor):
|
||||
[known_hosts_line(self.ssh_port, public_text,
|
||||
host=ssh_host)])
|
||||
seed_dir = write_seed(paths["seed_dir"], vm,
|
||||
private_text, public_text)
|
||||
private_text, public_text,
|
||||
secret_env=secret_env)
|
||||
self._emit("host_key_generated", task,
|
||||
"vm=%s secret_names=%s"
|
||||
% (vm, ",".join(sorted(secret_env or {}))))
|
||||
return {"paths": paths, "seed_dir": seed_dir,
|
||||
"known_hosts": paths["known_hosts"]}
|
||||
|
||||
@@ -599,27 +676,56 @@ class CloudHypervisorExecutor(Executor):
|
||||
write_known_hosts(self.known_hosts_path(task), key_lines)
|
||||
except OSError:
|
||||
return False
|
||||
# feat-chv-secfix(M5):捕获成功独立审计(只含 VM
|
||||
# 名与主机,不含 key 行本身)。
|
||||
self._emit("host_key_captured", task,
|
||||
"vm=%s host=%s" % (self.vm_name(task), ssh_host))
|
||||
return True
|
||||
|
||||
def build_ssh_command(self, task, inner_cmd, extra_env=None):
|
||||
"""构造 SSH 命令:ssh -p <端口> -i <key 文件> + 钉死选项
|
||||
+ user@127.0.0.1 + 客内命令。argv 里只有 key 文件路径,
|
||||
+ user@<SSH 目标> + 客内命令。argv 里只有 key 文件路径,
|
||||
绝无任何 key 值。egress_proxy 已设时客内命令前加 env
|
||||
前缀(HTTPS_PROXY/HTTP_PROXY/NO_PROXY,代理地址本身
|
||||
不是密钥;客机出口只走该白名单代理,见类 docstring)。
|
||||
Host key 选项是钉死式(StrictHostKeyChecking=yes +
|
||||
每 VM known_hosts 文件),#216 的 no + /dev/null 已
|
||||
在本轨废除,见类 docstring 与 ssh_transport.py。"""
|
||||
在本轨废除,见类 docstring 与 ssh_transport.py。
|
||||
feat-chv-secfix(H2):extra_env 先拆密钥子集与非
|
||||
密钥子集。密钥子集须已在本实例的 wake 里 seed 投递
|
||||
(_guest_env_delivered 有记录),否则直接 ChvFailure
|
||||
(secret_env_not_delivered),绝不回落 argv;已投递
|
||||
时远程命令 source 客内密钥文件。keyscan 模式下
|
||||
wake 根本不建 seed,密钥子集到这里必然未投递——
|
||||
即 CHV + 网关密钥现在要求 provisioned 模式(已是
|
||||
默认),keyscan 模式下的网关任务 fail-closed,不再
|
||||
把密钥值拼进 argv 将就跑。"""
|
||||
try:
|
||||
ssh_host = self.resolve_ssh_host()
|
||||
except ChvFailure:
|
||||
# 纯命令构造(旧测试/干跑)不炸:回环缺省仅作
|
||||
# 构造兼容,wake/wait 路径仍严格解析。
|
||||
ssh_host = "127.0.0.1"
|
||||
secrets, plain = split_secret_env(extra_env)
|
||||
guest_env_file = None
|
||||
if secrets:
|
||||
from cloud.chv.hostkeys import GUEST_ENV_PATH
|
||||
delivered = self._guest_env_delivered.get(
|
||||
self.vm_name(task), set())
|
||||
missing = [name for name in secrets if name not in delivered]
|
||||
if missing:
|
||||
raise ChvFailure(
|
||||
"secret_env_not_delivered",
|
||||
"密钥环境变量 %s 未经 seed 投递,拒绝进 ssh "
|
||||
"argv:provisioned 模式下 wake 时投递,"
|
||||
"keyscan 模式不支持密钥投递"
|
||||
% ",".join(sorted(missing)))
|
||||
guest_env_file = GUEST_ENV_PATH
|
||||
return _build_ssh_cmd(
|
||||
self.ssh_port, self.ssh_key, self.ssh_user,
|
||||
self.egress_proxy, self.known_hosts_path(task),
|
||||
inner_cmd, ssh_host=ssh_host, extra_env=extra_env)
|
||||
inner_cmd, ssh_host=ssh_host, extra_env=plain or None,
|
||||
guest_env_file=guest_env_file)
|
||||
|
||||
# -- API 模式:VmConfig / API 调用 / 就绪轮询 -----------------------
|
||||
def build_vm_config(self, task_disk=None, seed_disk=None):
|
||||
@@ -634,6 +740,22 @@ class CloudHypervisorExecutor(Executor):
|
||||
cpus = int(self.cpus)
|
||||
except ValueError:
|
||||
raise ValueError("CHV_CPU 必须是整数:%r" % (self.cpus,))
|
||||
# feat-chv-secfix(L2):磁盘路径白名单。显式给的
|
||||
# task_disk 若不是基础镜像本身(reuse 模式的合法
|
||||
# 情形),须在 state_dir 内;seed 盘须在 state_dir
|
||||
# 内——VmConfig 再也不能挂进宿主任意文件。
|
||||
if task_disk and task_disk != self.rootfs:
|
||||
if not os.path.realpath(task_disk).startswith(
|
||||
os.path.realpath(self.state_dir) + os.sep):
|
||||
raise ChvFailure(
|
||||
"disk_path_not_allowed",
|
||||
"任务磁盘须在 state_dir 内:%s" % task_disk)
|
||||
if seed_disk:
|
||||
if not os.path.realpath(seed_disk).startswith(
|
||||
os.path.realpath(self.state_dir) + os.sep):
|
||||
raise ChvFailure(
|
||||
"disk_path_not_allowed",
|
||||
"seed 磁盘须在 state_dir 内:%s" % seed_disk)
|
||||
config = {
|
||||
"cpus": {"boot_vcpus": cpus, "max_vcpus": cpus},
|
||||
"memory": {"size": _parse_memory_bytes(self.memory)},
|
||||
@@ -691,14 +813,16 @@ class CloudHypervisorExecutor(Executor):
|
||||
return len(self._vms)
|
||||
|
||||
def _persistent_vm_count(self, exclude=None):
|
||||
"""跨重启容量计数(feat-chv-fix,P1-4):本实例登记
|
||||
的活进程名 ∪ state_dir 下 chv-*.sock 对应的 VM 名。
|
||||
worker 重启后 _vms 为空,但旧 VMM 的 socket 仍在,
|
||||
仍计入容量——重启不再绕过 CHV_MAX_VMS。注意这是
|
||||
保守计数:已死 VMM 的残留 socket 在清理前也计入,
|
||||
直到 cleanup_stale_vms / worker reconcile 清掉它。
|
||||
exclude:调用方自己的 VM 名(同任务重复 wake 时先
|
||||
关旧 VM,不应被自己的残留 socket 误拒)。"""
|
||||
"""跨重启容量计数(feat-chv-fix,P1-4 + feat-chv-secfix
|
||||
M7):本实例登记的活进程名 ∪ state_dir 下 chv-*.sock
|
||||
对应的 VM 名。worker 重启后 _vms 为空,但旧 VMM 的
|
||||
socket 仍在,仍计入容量——重启不再绕过 CHV_MAX_VMS。
|
||||
M7 修正:残留 socket 不再无条件计入——只有 socket
|
||||
真能连通(有监听者)才计数;空文件/死 socket 不计,
|
||||
否则 CHV_MAX_VMS 会被崩溃残留占满、新任务全部被
|
||||
拒(可用性 DoS)。exclude:调用方自己的 VM 名(同
|
||||
任务重复 wake 时先关旧 VM,不应被自己的残留 socket
|
||||
误拒)。"""
|
||||
names = set()
|
||||
with self._vms_lock:
|
||||
for name, proc in self._vms.items():
|
||||
@@ -714,7 +838,9 @@ class CloudHypervisorExecutor(Executor):
|
||||
entries = []
|
||||
for entry in entries:
|
||||
if entry.endswith(".sock") and entry[:-5].startswith("chv-"):
|
||||
names.add(entry[:-5])
|
||||
sock_path = os.path.join(self.state_dir, entry)
|
||||
if socket_is_live(sock_path):
|
||||
names.add(entry[:-5])
|
||||
if exclude:
|
||||
names.discard(exclude)
|
||||
return len(names)
|
||||
@@ -784,7 +910,7 @@ class CloudHypervisorExecutor(Executor):
|
||||
# feat-chv-fix(P0-2):provisioned 模式下缺 known_hosts
|
||||
# 时直接 fail-fast,绝不调用 capture_host_key 回落 TOFU。
|
||||
# (keyscan 模式才走下面的捕获分支。)
|
||||
if getattr(self, "host_key_mode", "keyscan") == "provisioned" \
|
||||
if getattr(self, "host_key_mode", "provisioned") == "provisioned" \
|
||||
and not os.path.exists(self.known_hosts_path(task)):
|
||||
try:
|
||||
self.stop_vm(task)
|
||||
@@ -803,6 +929,28 @@ class CloudHypervisorExecutor(Executor):
|
||||
self.build_ssh_command(task, ["true"]))
|
||||
if getattr(result, "returncode", 1) == 0:
|
||||
return True
|
||||
# feat-chv-secfix(M5):host key 不匹配是独立
|
||||
# 安全事件,不是“sshd 没起”。SSH stderr 含
|
||||
# 钉死拒绝标记时立即审计 host_key_mismatch 并
|
||||
# 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:
|
||||
self.stop_vm(task)
|
||||
except Exception:
|
||||
pass
|
||||
raise ChvFailure(
|
||||
"host_key_mismatch",
|
||||
"SSH host key 不匹配(钉死拒绝):VM %s,"
|
||||
"可能被替换或中间人,已关 VM"
|
||||
% self.vm_name(task))
|
||||
except ChvFailure:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
if attempt + 1 < attempts:
|
||||
@@ -861,7 +1009,75 @@ class CloudHypervisorExecutor(Executor):
|
||||
% (" ".join(args), getattr(result, "returncode")))
|
||||
return result
|
||||
|
||||
def wake_vm(self, task):
|
||||
def _registered_net_entries(self, exclude_vm=None):
|
||||
"""已登记 VM 的 net 条目(H7):扫描 state_dir/*/net.json
|
||||
(wake 成功后写,stop 时删)。文件损坏/缺失即跳过该
|
||||
VM——登记缺失只会漏判冲突,不会误拒;漏判的兜底是
|
||||
宿主防火墙部署清单里的“每 VM 独立 tap”检查项。"""
|
||||
registered = []
|
||||
try:
|
||||
entries = os.listdir(self.state_dir)
|
||||
except Exception:
|
||||
return registered
|
||||
for entry in entries:
|
||||
if entry == exclude_vm:
|
||||
continue
|
||||
path = os.path.join(self.state_dir, entry, "net.json")
|
||||
try:
|
||||
with open(path, "r") as fh:
|
||||
data = json.load(fh)
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(data, list):
|
||||
continue
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
row = dict(item)
|
||||
row["vm"] = entry
|
||||
registered.append(row)
|
||||
return registered
|
||||
|
||||
def _record_net_entries(self, vm, entries):
|
||||
"""wake 成功后登记本 VM 的 net 条目(best-effort 写,
|
||||
失败只记日志——登记缺失的后果是漏判冲突,见上文)。"""
|
||||
try:
|
||||
vm_dir = os.path.join(self.state_dir, vm)
|
||||
os.makedirs(vm_dir, exist_ok=True)
|
||||
tmp_path = os.path.join(vm_dir, "net.json.tmp")
|
||||
with open(tmp_path, "w") as fh:
|
||||
json.dump(entries, fh)
|
||||
os.replace(tmp_path, os.path.join(vm_dir, "net.json"))
|
||||
except OSError as exc:
|
||||
_LOGGER.warning("chv_net_registry_write_failed vm=%s "
|
||||
"error=%s", vm, exc)
|
||||
|
||||
def _check_api_socket_permissions(self, socket_path):
|
||||
"""VMM api-socket 权限门(H6):socket 文件存在时,
|
||||
组/其它可写(mode & 0o022)即 fail-fast——Unix socket
|
||||
的 connect 需要写权限,可写即任何同组/其它用户都
|
||||
能调 VMM API。仅禁写位,不禁读/执行位:VMM 默认
|
||||
umask 下的 0755 类权限不误杀(VMM 默认值代码控制
|
||||
不了,真机上的实际权限仍须按部署清单核对)。
|
||||
socket 文件不存在时(mock/带外)跳过,不假装已验。
|
||||
普通文件(mock 占位)也跳过——只对真正的 socket
|
||||
文件校验权限。"""
|
||||
if not os.path.exists(socket_path):
|
||||
return
|
||||
try:
|
||||
import stat as _stat
|
||||
st = os.stat(socket_path)
|
||||
if not _stat.S_ISSOCK(st.st_mode):
|
||||
return
|
||||
mode = st.st_mode & 0o777
|
||||
except OSError:
|
||||
return
|
||||
if mode & 0o022:
|
||||
raise ChvFailure(
|
||||
"socket_permissions_insecure",
|
||||
"VMM api-socket 组/其它可写(%o),拒绝启动:%s"
|
||||
% (mode, socket_path))
|
||||
|
||||
def wake_vm(self, task, extra_env=None):
|
||||
"""主唤醒路径(API 模式,dispatch/run/goal 的前置步骤):
|
||||
preflight → 容量门 → 磁盘配额门 → 每任务磁盘供给 →
|
||||
launcher 只起 VMM(--api-socket)→ 等 vmm.ping 通 →
|
||||
@@ -871,21 +1087,54 @@ class CloudHypervisorExecutor(Executor):
|
||||
成功返回 {vm, api_socket, state: "running", ready: True,
|
||||
mode: "cold"},并审计 vm_create/vm_boot/vm_ready。
|
||||
单任务单 VM:同一任务重复 wake 时先关旧 VM 再起新
|
||||
的,绝不两个 VM 共用一个任务名。"""
|
||||
的,绝不两个 VM 共用一个任务名。
|
||||
feat-chv-secfix:extra_env(_run_with_vm 透传)里的
|
||||
密钥子集在 provisioned 模式下写进 seed 客内文件,
|
||||
keyscan 模式下有密钥子集即 fail-closed;起 VM 前的
|
||||
preflight/容量/配额失败也发 vm_failed 审计(L3);
|
||||
net 隔离在起 VMM 前校验(H7),成功后登记 net.json。"""
|
||||
if not isinstance(task, dict) or task.get("id") is None:
|
||||
raise ValueError("任务缺少 id")
|
||||
self.preflight()
|
||||
# feat-chv-fix:SSH 目标与 reuse 放行在启动前就定性。
|
||||
# resolve 失败即 fail-fast(不先起 VMM 再超时);
|
||||
# reuse 实验放行必须审计,留痕给运维复核。
|
||||
self.resolve_ssh_host()
|
||||
name = self.vm_name(task)
|
||||
self._check_capacity(exclude=name)
|
||||
self._check_disk_quota()
|
||||
try:
|
||||
self.preflight()
|
||||
# feat-chv-fix:SSH 目标与 reuse 放行在启动前就定性。
|
||||
# resolve 失败即 fail-fast(不先起 VMM 再超时);
|
||||
# reuse 实验放行必须审计,留痕给运维复核。
|
||||
self.resolve_ssh_host()
|
||||
self._check_capacity(exclude=name)
|
||||
self._check_disk_quota()
|
||||
# feat-chv-secfix(H7):跨 VM tap/mac/ip 冲突在
|
||||
# 起 VMM 前拒绝(preflight 只做了条目内校验)。
|
||||
net_entries = []
|
||||
if self.net_json:
|
||||
net_entries = validate_net_entries(
|
||||
parse_net_entries(self.net_json),
|
||||
self._registered_net_entries(exclude_vm=name))
|
||||
# feat-chv-secfix(H2):密钥子集的投递能力在起
|
||||
# VM 前就定性——keyscan 模式无 seed 可投递,有
|
||||
# 密钥即 fail-closed,不等到执行时才在 argv 前
|
||||
# 被拦(那时 VM 已起,失败语义更脏)。
|
||||
secrets, _plain = split_secret_env(extra_env)
|
||||
if secrets and getattr(self, "host_key_mode",
|
||||
"provisioned") != "provisioned":
|
||||
raise ChvFailure(
|
||||
"secret_env_requires_provisioned",
|
||||
"密钥环境变量 %s 须走 seed 文件投递,仅 "
|
||||
"provisioned 模式支持(当前 %s)"
|
||||
% (",".join(sorted(secrets)),
|
||||
getattr(self, "host_key_mode", "provisioned")))
|
||||
except ChvFailure as exc:
|
||||
self._emit("vm_failed", task,
|
||||
"vm=%s wake 前置失败 kind=%s" % (name, exc.kind),
|
||||
exc.kind)
|
||||
raise
|
||||
if getattr(self, "disk_mode", "copy") == "reuse":
|
||||
self._emit("disk_reuse_experiment_allowed", task,
|
||||
"vm=%s CHV_REUSE_SINGLE_TENANT_EXPERIMENT=1 "
|
||||
"explicit" % name)
|
||||
"explicit single_tenant_project=%s"
|
||||
% (name, getattr(self, "single_tenant_project",
|
||||
None)))
|
||||
old = None
|
||||
with self._vms_lock:
|
||||
if name in self._vms:
|
||||
@@ -903,21 +1152,36 @@ class CloudHypervisorExecutor(Executor):
|
||||
"vm=%s CHV_ALLOW_DIRECT_EGRESS=1 explicit" % name)
|
||||
try:
|
||||
seed_disk = None
|
||||
if getattr(self, "host_key_mode", "keyscan") == "provisioned":
|
||||
if getattr(self, "host_key_mode", "provisioned") == "provisioned":
|
||||
# 强方案:启动前预置每 VM host key 并钉死,
|
||||
# 再把 seed 目录做成 CIDATA seed 磁盘并挂进
|
||||
# VmConfig(P0-1 闭环:生成→制盘→挂盘三步缺
|
||||
# 一即 fail-fast,绝不回落 keyscan、绝不只
|
||||
# 生成不注入)。
|
||||
# feat-chv-secfix(H2/H5):secret_env 随 seed
|
||||
# 写进客内密钥文件;seed.iso 建好(0600,见
|
||||
# build_seed_disk)后立即删宿主侧私钥——此后
|
||||
# 宿主上只剩公钥 + known_hosts,私钥只活在客
|
||||
# 机与 seed.iso(随 VM 销毁而删)里。删私钥
|
||||
# 失败即 fail-fast(VM 尚未启动,此时拒绝最
|
||||
# 安全,except 里 stop_vm 会清掉 seed)。
|
||||
from cloud.chv.hostkeys import (
|
||||
build_seed_disk, seed_disk_path)
|
||||
provisioned = self.provision_host_key(task)
|
||||
build_seed_disk, delete_host_private_key,
|
||||
seed_disk_path)
|
||||
provisioned = self.provision_host_key(
|
||||
task, secret_env=secrets or None)
|
||||
seed_disk = seed_disk_path(self.state_dir, name)
|
||||
build_seed_disk(
|
||||
provisioned["seed_dir"], seed_disk,
|
||||
runner=lambda cmd: self._call_runner(cmd))
|
||||
delete_host_private_key(self.state_dir, name)
|
||||
if secrets:
|
||||
self._guest_env_delivered[name] = set(secrets)
|
||||
else:
|
||||
self._guest_env_delivered.pop(name, None)
|
||||
self._launch_api_only(task)
|
||||
self.wait_api_ready(task)
|
||||
self._check_api_socket_permissions(socket_path)
|
||||
status, _ = self._api_call(
|
||||
socket_path, "PUT", "/vm.create",
|
||||
self.build_vm_config(task_disk, seed_disk=seed_disk))
|
||||
@@ -927,6 +1191,8 @@ class CloudHypervisorExecutor(Executor):
|
||||
"vm.create 失败:status=%s" % status)
|
||||
self._emit("vm_create", task,
|
||||
"vm=%s disk=%s" % (name, task_disk))
|
||||
if net_entries:
|
||||
self._record_net_entries(name, net_entries)
|
||||
status, _ = self._api_call(socket_path, "PUT", "/vm.boot")
|
||||
if status not in (200, 204):
|
||||
raise ChvFailure(
|
||||
@@ -978,9 +1244,23 @@ class CloudHypervisorExecutor(Executor):
|
||||
"""给(已暂停的)VM 做快照:ch-remote snapshot
|
||||
<source_url>。snapshot_url 为空即 ValueError;成功
|
||||
审计 vm_snapshot。快照本身不改变 VM 状态(仍 paused,
|
||||
需 resume 才醒)。"""
|
||||
需 resume 才醒)。
|
||||
feat-chv-secfix(L2/M3):ch-remote 之前先过 URL
|
||||
白名单(file:// 须在 state_dir/CHV_SNAPSHOT_DIR 内,
|
||||
远端须显式放行)与数量/总量/保留期上限;成功后登
|
||||
记并在总量超帽时按最旧优先剪枝(删动作审计)。"""
|
||||
if not snapshot_url:
|
||||
raise ValueError("snapshot_url 不能为空")
|
||||
validate_snapshot_url(
|
||||
snapshot_url, self.state_dir,
|
||||
snapshot_dir=getattr(self, "snapshot_dir", None),
|
||||
allow_remote=getattr(self, "allow_remote_snapshot", False))
|
||||
vm_name = self.vm_name(task_or_name)
|
||||
check_snapshot_limits(
|
||||
self.state_dir, vm_name,
|
||||
getattr(self, "snapshot_max_count", 10),
|
||||
getattr(self, "snapshot_max_total_gb", 20.0),
|
||||
getattr(self, "snapshot_retention_days", 30.0))
|
||||
self._ch_remote(task_or_name, "snapshot",
|
||||
"source_url=%s" % snapshot_url)
|
||||
# feat-chv-fix(P1-7):只查 returncode 不够。file://
|
||||
@@ -997,6 +1277,13 @@ class CloudHypervisorExecutor(Executor):
|
||||
"快照未落盘或为空:%s" % snapshot_url)
|
||||
task = task_or_name if isinstance(task_or_name, dict) \
|
||||
else {"id": task_or_name}
|
||||
_entries, _removed = record_snapshot(
|
||||
self.state_dir, vm_name, snapshot_url,
|
||||
getattr(self, "snapshot_max_total_gb", 20.0))
|
||||
for _old in _removed:
|
||||
self._emit("vm_snapshot_pruned", task,
|
||||
"vm=%s pruned snapshot=%s(总量超帽,最旧优先)"
|
||||
% (vm_name, _old.get("url")))
|
||||
self._emit("vm_snapshot", task,
|
||||
"vm=%s snapshot=%s"
|
||||
% (self.vm_name(task_or_name), snapshot_url))
|
||||
@@ -1015,6 +1302,13 @@ class CloudHypervisorExecutor(Executor):
|
||||
raise ValueError("snapshot_url 不能为空")
|
||||
if "://" not in snapshot_url:
|
||||
snapshot_url = "file://%s" % snapshot_url
|
||||
# feat-chv-secfix(L2):恢复源同样过 URL 白名单
|
||||
# (file:// 须在白名单目录内,远端须显式放行),
|
||||
# 在 preflight/起 VMM 之前就拒绝。
|
||||
validate_snapshot_url(
|
||||
snapshot_url, self.state_dir,
|
||||
snapshot_dir=getattr(self, "snapshot_dir", None),
|
||||
allow_remote=getattr(self, "allow_remote_snapshot", False))
|
||||
self.preflight()
|
||||
self.resolve_ssh_host()
|
||||
# feat-chv-fix(P0-2 修正版):restore 不走 vm.create,
|
||||
@@ -1029,7 +1323,7 @@ class CloudHypervisorExecutor(Executor):
|
||||
# 校验是 snapshot_vm/hibernate_vm 创建时的门(P1-7),
|
||||
# restore 的 ch-remote 本身会在源缺失时失败;在这里
|
||||
# 再验一次会把“源在远端/对象存储”的合法路径也挡掉。
|
||||
if getattr(self, "host_key_mode", "keyscan") == "provisioned" \
|
||||
if getattr(self, "host_key_mode", "provisioned") == "provisioned" \
|
||||
and not os.path.exists(self.known_hosts_path(task)):
|
||||
raise ChvFailure(
|
||||
"host_key_not_pinned",
|
||||
@@ -1040,6 +1334,7 @@ class CloudHypervisorExecutor(Executor):
|
||||
try:
|
||||
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,
|
||||
@@ -1117,6 +1412,17 @@ class CloudHypervisorExecutor(Executor):
|
||||
# 公钥文件保留供事后审计,删失败不炸关闭。下次
|
||||
# 同名 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:
|
||||
seed_dir = os.path.join(self.state_dir, name, "seed")
|
||||
if os.path.isdir(seed_dir):
|
||||
@@ -1128,16 +1434,45 @@ class CloudHypervisorExecutor(Executor):
|
||||
if os.path.exists(_p):
|
||||
os.remove(_p)
|
||||
except Exception as _cleanup_exc:
|
||||
# feat-chv-fix(P2-5):seed/私钥删除失败不再静默
|
||||
# 吞掉——记 vm_key_cleanup_failed 审计;且下次同名
|
||||
# VM 预置时若私钥残留,generate_host_keypair 的
|
||||
# “复用已有对”语义不变,但审计痕迹让“一性”破
|
||||
# 坏可被发现(彻底根治须另做“清理失败即拒起”,
|
||||
# 见 PR 描述的遗留项,不在本轨假装已根治)。
|
||||
task = task_or_name if isinstance(task_or_name, dict) \
|
||||
else {"id": task_or_name}
|
||||
self._emit("vm_key_cleanup_failed", task,
|
||||
_cleanup_failed = True
|
||||
self._emit("vm_key_cleanup_failed", _task,
|
||||
"vm=%s error=%s" % (name, _cleanup_exc))
|
||||
try:
|
||||
if delete_task_disk(self.state_dir, name):
|
||||
self._emit("vm_disk_deleted", _task,
|
||||
"vm=%s disk deleted on stop" % name)
|
||||
except Exception as _disk_exc:
|
||||
_cleanup_failed = True
|
||||
self._emit("vm_disk_delete_failed", _task,
|
||||
"vm=%s error=%s" % (name, _disk_exc))
|
||||
try:
|
||||
_net_path = os.path.join(self.state_dir, name, "net.json")
|
||||
if os.path.exists(_net_path):
|
||||
os.remove(_net_path)
|
||||
except Exception:
|
||||
_cleanup_failed = True
|
||||
self._guest_env_delivered.pop(name, None)
|
||||
if _cleanup_failed:
|
||||
# M2 根治:脏标记。标记本身写失败也不炸关闭,
|
||||
# 但下次 provision_host_key 仍会因残留文件复用
|
||||
# 旧 key——该残留情形由 cleanup_stale_secrets 的
|
||||
# 扫描兜底(见 cleanup_stale_vms),文档明写。
|
||||
try:
|
||||
from cloud.chv.hostkeys import dirty_marker_path
|
||||
_marker = dirty_marker_path(self.state_dir, name)
|
||||
os.makedirs(os.path.dirname(_marker), exist_ok=True)
|
||||
with open(_marker, "w") as fh:
|
||||
fh.write("cleanup failed\n")
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
from cloud.chv.hostkeys import dirty_marker_path
|
||||
_marker = dirty_marker_path(self.state_dir, name)
|
||||
if os.path.exists(_marker):
|
||||
os.remove(_marker)
|
||||
except Exception:
|
||||
pass
|
||||
task = task_or_name if isinstance(task_or_name, dict) \
|
||||
else {"id": task_or_name}
|
||||
if had:
|
||||
@@ -1146,6 +1481,100 @@ class CloudHypervisorExecutor(Executor):
|
||||
_LOGGER.info("chv_vm_stop vm=%s had=%s", name, had)
|
||||
return had
|
||||
|
||||
def gc_stale_disks(self):
|
||||
"""残留盘 GC(H3):扫描 state_dir/*/disk.raw,VM
|
||||
非 live(无 api-socket 文件且无登记活进程)即删盘
|
||||
并审计;删失败审计 vm_disk_delete_failed 并计入
|
||||
failed。返回 {deleted, failed}。"""
|
||||
deleted, failed = [], []
|
||||
try:
|
||||
entries = os.listdir(self.state_dir)
|
||||
except Exception:
|
||||
return {"deleted": deleted, "failed": failed}
|
||||
for entry in entries:
|
||||
if not entry.startswith("chv-"):
|
||||
continue
|
||||
disk = task_disk_path(self.state_dir, entry)
|
||||
if not (os.path.islink(disk) or os.path.exists(disk)):
|
||||
continue
|
||||
live = os.path.exists(
|
||||
os.path.join(self.state_dir, entry + ".sock"))
|
||||
with self._vms_lock:
|
||||
proc = self._vms.get(entry)
|
||||
if proc is not None:
|
||||
try:
|
||||
live = live or proc.poll() is None
|
||||
except Exception:
|
||||
pass
|
||||
if live:
|
||||
continue
|
||||
try:
|
||||
delete_task_disk(self.state_dir, entry)
|
||||
deleted.append(entry)
|
||||
self._emit("vm_disk_gc", {"id": entry},
|
||||
"vm=%s stale disk deleted" % entry)
|
||||
except Exception as exc:
|
||||
failed.append(entry)
|
||||
self._emit("vm_disk_delete_failed", {"id": entry},
|
||||
"vm=%s gc error=%s" % (entry, exc))
|
||||
return {"deleted": deleted, "failed": failed}
|
||||
|
||||
def cleanup_stale_secrets(self):
|
||||
"""崩溃残留扫描(H5):state_dir 下的 host 私钥文件
|
||||
在本轨不变量下只可能是残留——live VM 的私钥在 seed
|
||||
盘建好后即删(见 wake_vm),所以任何残留私钥文件
|
||||
一律删除并审计;非 live VM 的 seed.iso/seed 目录同
|
||||
理。返回已清理的 VM 名列表。worker 启动与
|
||||
cleanup_stale_vms 时调它,不依赖 socket 是否存在。"""
|
||||
cleaned = []
|
||||
try:
|
||||
entries = os.listdir(self.state_dir)
|
||||
except Exception:
|
||||
return cleaned
|
||||
for entry in entries:
|
||||
if not entry.startswith("chv-"):
|
||||
continue
|
||||
vm_dir = os.path.join(self.state_dir, entry)
|
||||
if not os.path.isdir(vm_dir):
|
||||
continue
|
||||
priv = os.path.join(vm_dir, "ssh_host_ed25519_key")
|
||||
removed_priv = False
|
||||
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:
|
||||
cleaned.append(entry)
|
||||
self._emit("host_secret_residue_cleaned",
|
||||
{"id": entry},
|
||||
"vm=%s private_key=%s seed=%s"
|
||||
% (entry, removed_priv, removed_seed))
|
||||
return cleaned
|
||||
|
||||
def shutdown_vm(self, task_or_name):
|
||||
"""关闭入口的正式名:即 stop_vm(ch-remote shutdown)。
|
||||
任务结束/失败/取消时调它。"""
|
||||
@@ -1181,6 +1610,9 @@ class CloudHypervisorExecutor(Executor):
|
||||
def cleanup_stale_vms(self):
|
||||
"""清理残留 VM:只清 socket 残留与已死进程对应的 VM。
|
||||
|
||||
feat-chv-secfix(H3/H5):先跑残留盘 GC 与残留
|
||||
密钥扫描(不依赖 socket 是否存在,覆盖 worker 崩溃
|
||||
后 socket 已删、文件仍在的情形),再走原 socket 扫描。
|
||||
扫描 state_dir 下 *.sock:登记进程仍活(poll() 为 None)
|
||||
的跳过;其余一律走 stop_vm。返回已清理的 VM 名列表;
|
||||
state_dir 不存在时返回 [],绝不抛异常。基础设施或
|
||||
@@ -1189,6 +1621,14 @@ class CloudHypervisorExecutor(Executor):
|
||||
的 chv- 命名)。
|
||||
"""
|
||||
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:
|
||||
@@ -1350,8 +1790,11 @@ class CloudHypervisorExecutor(Executor):
|
||||
判 interrupted(断点可续跑)而不是笼统 failed 或
|
||||
原样抛异常;VM 还活着时的异常/失败原样处理,不
|
||||
误判。goal 结果本身已有 interrupted 分类(见
|
||||
parse_goal_result),这里不再二次改写 goal 结论。"""
|
||||
self.wake_vm(task)
|
||||
parse_goal_result),这里不再二次改写 goal 结论。
|
||||
feat-chv-secfix(H2):extra_env 透传给 wake_vm(密钥
|
||||
子集在 provisioned 模式下随 seed 投递),再透传给
|
||||
_run_guest(远程命令 source 客内密钥文件)。"""
|
||||
self.wake_vm(task, extra_env=extra_env)
|
||||
try:
|
||||
try:
|
||||
if goal:
|
||||
|
||||
Reference in new issue
Block a user