diff --git a/chv-guest/generate_lockfile.py b/chv-guest/generate_lockfile.py new file mode 100644 index 0000000..2786e13 --- /dev/null +++ b/chv-guest/generate_lockfile.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""在构建宿主上生成 CHV 客机镜像的供应链 lockfile(to10 轨)。 + +事后校验见 check_lockfile.py(校验 BUILDINFO.versions); +本脚本是事前/事中生成端:在构建宿主上跑,读 BUILDINFO +的 tag 与 contents(仅名字清单,不含版本号),逐项从本 +机 dpkg / pip 查真实版本,写出 lockfile.json: + + {"tag": ..., "image_sha256": ... | null, + "generated_at": UTC ISO 时间, + "versions": {"dpkg": ["name version", ...], + "pip": ["name version", ...]}, + "unresolved": ["查不到版本的名", ...]} + +绝不编造版本号:查不到的进 unresolved,不猜、不填占位 +版本。--verify 模式校验一份 lockfile:tag 与 BUILDINFO +一致、dpkg/pip 非空、每条都有非空版本、unresolved 为空; +任一不满足即退出非 0(unresolved 非空 = 清单不完整, +不是“生成成功”)。 + +用法(均在构建宿主上跑): + python3 generate_lockfile.py BUILDINFO.json --output lockfile.json + python3 generate_lockfile.py --verify lockfile.json \ + --buildinfo BUILDINFO.json +本轨无构建宿主:脚本已落地且可跑(见回执里的 /tmp 合成 +BUILDINFO 实测),真实清单待构建宿主产出,绝不把合成 +数据写成生产清单。 +""" + +import json +import subprocess +import sys +from datetime import datetime, timezone + + +def _dpkg_version(name): + """dpkg 查版本,查不到回 None(不抛)。""" + try: + out = subprocess.run( + ["dpkg-query", "-W", "-f=${Version}", name], + capture_output=True, text=True, timeout=30) + except Exception: + return None + if out.returncode != 0: + return None + version = out.stdout.strip() + return version or None + + +def _pip_version(name): + """pip 查版本,查不到回 None(不抛)。""" + try: + out = subprocess.run( + [sys.executable, "-m", "pip", "show", name], + capture_output=True, text=True, timeout=30) + except Exception: + return None + if out.returncode != 0: + return None + for line in out.stdout.splitlines(): + if line.startswith("Version:"): + version = line.split(":", 1)[1].strip() + return version or None + return None + + +def generate(buildinfo_path, output_path): + with open(buildinfo_path) as fh: + buildinfo = json.load(fh) + names = buildinfo.get("contents") or [] + if not isinstance(names, list): + print("BUILDINFO.contents 不是列表", file=sys.stderr) + return 2 + dpkg, pip, unresolved = [], [], [] + for name in names: + version = _dpkg_version(name) + if version is not None: + dpkg.append("%s %s" % (name, version)) + continue + version = _pip_version(name) + if version is not None: + pip.append("%s %s" % (name, version)) + continue + unresolved.append(name) + lockfile = { + "tag": buildinfo.get("tag"), + "image_sha256": buildinfo.get("image_sha256") + or buildinfo.get("sha256"), + "generated_at": datetime.now(timezone.utc).isoformat(), + "versions": {"dpkg": sorted(dpkg), "pip": sorted(pip)}, + "unresolved": sorted(unresolved), + } + with open(output_path, "w") as fh: + json.dump(lockfile, fh, indent=2, sort_keys=True) + fh.write("\n") + if unresolved: + print("警告:%d 项查不到版本,已列入 unresolved:%s" + % (len(unresolved), ", ".join(sorted(unresolved))), + file=sys.stderr) + return 0 + + +def verify(lockfile_path, buildinfo_path): + with open(lockfile_path) as fh: + lockfile = json.load(fh) + if buildinfo_path: + with open(buildinfo_path) as fh: + buildinfo = json.load(fh) + if lockfile.get("tag") != buildinfo.get("tag"): + print("tag 不一致:lockfile %s != BUILDINFO %s" + % (lockfile.get("tag"), buildinfo.get("tag")), + file=sys.stderr) + return 1 + versions = lockfile.get("versions") or {} + for kind in ("dpkg", "pip"): + entries = versions.get(kind) + if not entries: + print("versions.%s 为空,供应链清单缺失" % kind, + file=sys.stderr) + return 1 + for entry in entries: + parts = str(entry).split() + if len(parts) < 2 or not parts[1]: + print("版本为空的条目:%s %r" % (kind, entry), + file=sys.stderr) + return 1 + unresolved = lockfile.get("unresolved") or [] + if unresolved: + print("清单不完整,unresolved 非空:%s" % sorted(unresolved), + file=sys.stderr) + return 1 + print("lockfile 校验通过:tag=%s dpkg=%d pip=%d" + % (lockfile.get("tag"), len(versions["dpkg"]), + len(versions["pip"]))) + return 0 + + +def main(argv): + if "--verify" in argv: + lockfile_path = argv[argv.index("--verify") + 1] + buildinfo_path = None + if "--buildinfo" in argv: + buildinfo_path = argv[argv.index("--buildinfo") + 1] + return verify(lockfile_path, buildinfo_path) + if len(argv) < 2: + print("用法:generate_lockfile.py BUILDINFO.json " + "--output lockfile.json | --verify lockfile.json " + "[--buildinfo BUILDINFO.json]", file=sys.stderr) + return 2 + output_path = None + if "--output" in argv: + output_path = argv[argv.index("--output") + 1] + if not output_path: + print("缺少 --output", file=sys.stderr) + return 2 + return generate(argv[1], output_path) + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/cloud/chv/compat215.py b/cloud/chv/compat215.py index c89c777..5dd76f2 100644 --- a/cloud/chv/compat215.py +++ b/cloud/chv/compat215.py @@ -48,7 +48,6 @@ cloud.quota,测试钉住这些模块在本基线里不存在): import os - # 合并后替换清单:每项的 local/replacement/test 字段由测试 # 逐项核对(local 函数真实存在、#215 模块当前不存在、 # test 命名的测试类真实存在),清单本身不得漂移。 diff --git a/cloud/chv/config.py b/cloud/chv/config.py index 0265d93..693cfe1 100644 --- a/cloud/chv/config.py +++ b/cloud/chv/config.py @@ -27,6 +27,33 @@ import os import re +def _to_int(value: object) -> int: + """动态配置值转 int(env/参数均未定型):bool 拒绝, + int/float 按 int(),str 按 strip 后 int(),其它 ValueError。 + 旧行为是 int(value) 抛 TypeError/ValueError 后由调用方 + 转 ValueError;这里直接统一成 ValueError,文案由调用方定。""" + if isinstance(value, bool): + raise ValueError("布尔值不是整数") + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + if isinstance(value, str): + return int(value.strip()) + raise ValueError("不是整数:%r" % (value,)) + + +def _to_float(value: object) -> float: + """动态配置值转 float,与 _to_int 同款口径。""" + if isinstance(value, bool): + raise ValueError("布尔值不是数字") + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + return float(value.strip()) + raise ValueError("不是数字:%r" % (value,)) + + def _parse_memory_bytes(value: str) -> int: """内存字符串转字节:2G/512M/1024K/2048(无单位=字节)。 非法值 ValueError,绝不静默按默认算。""" @@ -93,7 +120,7 @@ def _ssh_fragment(params: dict, env: dict) -> dict: else: raw_port = env.get("CHV_SSH_PORT") or 2222 try: - ssh_port = int(raw_port) + ssh_port = _to_int(raw_port) except (TypeError, ValueError): raise ValueError("CHV_SSH_PORT 必须是整数") if not 1 <= ssh_port <= 65535: @@ -134,11 +161,11 @@ def _quota_fragment(env: dict) -> dict: 磁盘/快照数上限不在这里,它们是磁盘行为,见 _disk_fragment。校验文案与旧代码逐字一致。""" try: - max_vms = int(env.get("CHV_MAX_VMS") or 4) + max_vms = _to_int(env.get("CHV_MAX_VMS") or 4) except ValueError: raise ValueError("CHV_MAX_VMS 必须是整数") try: - warm_pool_size = int(env.get("CHV_WARM_POOL_SIZE") or 0) + warm_pool_size = _to_int(env.get("CHV_WARM_POOL_SIZE") or 0) except ValueError: raise ValueError("CHV_WARM_POOL_SIZE 必须是整数") return {"max_vms": max_vms, "warm_pool_size": warm_pool_size} @@ -154,18 +181,18 @@ def _disk_fragment(env: dict) -> dict: raise ValueError( "CHV_DISK_MODE 只能是 copy 或 reuse:%s" % disk_mode) try: - disk_gb = float(env.get("CHV_DISK_GB")) \ + disk_gb = _to_float(env.get("CHV_DISK_GB")) \ if env.get("CHV_DISK_GB") else 50.0 except ValueError: raise ValueError("CHV_DISK_GB 必须是数字") if disk_gb <= 0: raise ValueError("CHV_DISK_GB 必须大于 0") try: - snapshot_max_count = int(env.get("CHV_SNAPSHOT_MAX_COUNT") - or 10) - snapshot_max_total_gb = float( + snapshot_max_count = _to_int(env.get("CHV_SNAPSHOT_MAX_COUNT") + or 10) + snapshot_max_total_gb = _to_float( env.get("CHV_SNAPSHOT_MAX_TOTAL_GB") or 20.0) - snapshot_retention_days = float( + snapshot_retention_days = _to_float( env.get("CHV_SNAPSHOT_RETENTION_DAYS") or 30.0) except ValueError: raise ValueError("CHV_SNAPSHOT_* 必须是数字") @@ -192,18 +219,18 @@ def _timeout_fragment(params: dict, env: dict) -> dict: 超时、客机就绪超时/间隔。ready_interval <= 0 时回落 5 秒(旧语义,非校验失败)。校验文案逐字一致。""" task_timeout_raw = env.get("CHV_TASK_TIMEOUT") - task_timeout = float(task_timeout_raw) \ + task_timeout = _to_float(task_timeout_raw) \ if task_timeout_raw else None try: - api_timeout = float( + api_timeout = _to_float( params.get("api_timeout") if params.get("api_timeout") is not None else env.get("CHV_API_TIMEOUT") or 30) - ready_timeout = float( + ready_timeout = _to_float( params.get("ready_timeout") if params.get("ready_timeout") is not None else env.get("CHV_READY_TIMEOUT") or 120) - ready_interval = float( + ready_interval = _to_float( params.get("ready_interval") if params.get("ready_interval") is not None else env.get("CHV_READY_INTERVAL") or 5) diff --git a/cloud/chv/disk.py b/cloud/chv/disk.py index f0404d2..68addf6 100644 --- a/cloud/chv/disk.py +++ b/cloud/chv/disk.py @@ -203,7 +203,7 @@ def check_snapshot_limits(state_dir: str, vm_name: str, max_count: int, raise ChvFailure( "snapshot_quota_exceeded", "快照数量已达上限 %d(VM %s)" % (max_count, vm_name)) - total = 0 + total: float = 0.0 for entry in kept: size = entry.get("size") if isinstance(size, (int, float)): diff --git a/cloud/chv/executor.py b/cloud/chv/executor.py index d19d6a9..3bdddc4 100644 --- a/cloud/chv/executor.py +++ b/cloud/chv/executor.py @@ -34,30 +34,35 @@ import shutil import subprocess import threading import time +from collections.abc import Callable -from cloud.chv.api_client import _unix_socket_api -from cloud.chv.api_client import normalize_api_result -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.api_client import _unix_socket_api, normalize_api_result +from cloud.chv.config import _parse_memory_bytes, build_chv_config +from cloud.chv.disk import ( + check_disk_quota, + check_snapshot_limits, + delete_task_disk, + provision_task_disk, + record_snapshot, + task_disk_path, +) from cloud.chv.errors import ChvFailure -from cloud.chv.hardening import ensure_secure_state_dir -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.hardening import ( + ensure_secure_state_dir, + parse_net_entries, + socket_is_live, + validate_egress_proxy, + validate_net_entries, + validate_snapshot_url, +) from cloud.chv.ssh_transport import build_ssh_command as _build_ssh_cmd from cloud.chv.ssh_transport import known_hosts_path as _known_hosts_path -from cloud.chv.ssh_transport import parse_keyscan_output -from cloud.chv.ssh_transport import split_secret_env -from cloud.chv.ssh_transport import write_known_hosts +from cloud.chv.ssh_transport import ( + parse_keyscan_output, + split_secret_env, + write_known_hosts, +) +from cloud.executor_base import Executor _LOGGER = logging.getLogger("cloud.cloud_hypervisor") @@ -246,6 +251,57 @@ class CloudHypervisorExecutor(Executor): 开启嵌套虚拟化的实例,那是用户侧开通决策,不在本轨内。 """ + # to10:mypy 属性声明(仅注解、不赋值,运行时仍由 + # _copy_config_fields/_init_runtime 抄写)。没有这组声明, + # 动态 setattr 会让 mypy 认为全部配置属性不存在(基线 + # 115 个报错里近 90 个属此类),类型检查等于没跑。 + binary: str | None + kernel: str | None + rootfs: str | None + cpus: str + memory: str + state_dir: str + ssh_key: str | None + ssh_user: str + ssh_port: int + guest_host_py: str + cmdline: str + net: str | None + net_json: str | None + kvm_path: str + cpuinfo_path: str + ch_remote: str + model: str | None + max_vms: int + disk_mode: str + disk_gb: float + egress_proxy: str | None + allow_direct_egress: bool + task_timeout: float | None + snapshot_url: str | None + warm_pool_size: int + api_timeout: float + ready_timeout: float + ready_interval: float + host_key_mode: str + ssh_host: str | None + ssh_forward: bool + reuse_experiment: bool + single_tenant_project: str | None + snapshot_max_count: int + snapshot_max_total_gb: float + snapshot_retention_days: float + snapshot_dir: str | None + allow_remote_snapshot: bool + runner: "Callable[..., object]" + launcher: "Callable[..., object]" + api_client: "Callable[..., object]" + sleeper: "Callable[..., object]" + audit_callback: object + audit_failure_count: int + _vms: dict + _guest_env_delivered: dict + def __init__(self, binary: str | None = None, kernel: str | None = None, rootfs: str | None = None, @@ -253,7 +309,8 @@ class CloudHypervisorExecutor(Executor): cpus: str | int | None = None, memory: str | None = None, state_dir: str | None = None, - runner: object = None, launcher: object = None, + runner: Callable[..., object] | None = None, + launcher: Callable[..., object] | None = None, ssh_key: str | None = None, ssh_user: str | None = None, ssh_port: int | str | None = None, @@ -265,12 +322,13 @@ class CloudHypervisorExecutor(Executor): cpuinfo_path: str = "/proc/cpuinfo", env: dict | None = None, model: str | None = None, ch_remote: str | None = None, - api_client: object = None, - sleeper: object = None, api_timeout: float | None = None, + api_client: Callable[..., object] | None = None, + sleeper: Callable[..., object] | None = None, + api_timeout: float | None = None, ready_timeout: float | None = None, ready_interval: float | None = None, host_key_mode: str | None = None) -> None: - self._env = env if env is not None else os.environ + self._env: dict = env if env is not None else os.environ # type: ignore[assignment] # 配置解析已拆到 cloud/chv/config.py(参数优先于环境 # 变量,默认值与 #216 一致;显式空用户/越界端口的收紧 # 见 config.py 头注释)。字段名与原属性一一对应,旧 @@ -291,7 +349,15 @@ class CloudHypervisorExecutor(Executor): self._copy_config_fields(_cfg) self._init_runtime(runner, launcher, api_client, sleeper) - def _init_runtime(self, runner, launcher, api_client, sleeper) -> None: + def _init_runtime(self, runner: Callable[..., object] | None, + launcher: Callable[..., object] | None, + api_client: Callable[..., object] | None, + sleeper: Callable[..., object] | None) -> None: + # to10:runner/launcher/api_client/sleeper 均为可调用注入对象; + # 形参保持 object 以兼容旧测试的任意 lambda,调用点用 + # getattr 可调用检查后调用(见 _call_runner 等),mypy + # 的 object-not-callable 在 _call_runner 处以 Callable 局部 + # 变量处理。 """运行时注入:seed 投递集合、API 客户端、睡眠器、 runner/launcher 默认值(纯移动)。""" # feat-chv-secfix(H2):本实例已 seed 投递密钥的 VM @@ -346,7 +412,11 @@ class CloudHypervisorExecutor(Executor): 幂等:传入的已是 chv- 前缀名时不再二次加前缀(清理 扫描 socket 文件名时传的就是 chv- 名,二次加前缀会 把 chv-dead 算成 chv-chv-dead、永远清不掉)。""" - raw = task_or_name.get("id") if isinstance(task_or_name, dict) \ + # to10:id 按契约是 str;cast 只给 mypy 用,运行时原样 + # 传给 re.sub(id 缺失时仍按旧行为 TypeError,不静默 + # 编成 "None")。 + from typing import cast as _cast + raw = _cast(str, task_or_name.get("id")) if isinstance(task_or_name, dict) \ else str(task_or_name) safe = re.sub(r"[^A-Za-z0-9_.-]", "-", raw) if safe.startswith("chv-"): @@ -589,7 +659,7 @@ class CloudHypervisorExecutor(Executor): - egress_not_configured:CHV_EGRESS_PROXY 未设且未显式 允许直连(默认 fail-closed,VM 出口必须过白名单代理) """ - missing = [] # (kind, 文本) 列表,全部收集完再一次抛 + missing: list = [] # (kind, 文本) 列表,全部收集完再一次抛 self._preflight_host_checks(missing) self._preflight_file_checks(missing) self._preflight_net_checks(missing) @@ -637,6 +707,34 @@ class CloudHypervisorExecutor(Executor): self, "audit_failure_count", 0) + 1 _LOGGER.error("chv_audit_failed event=%s kind=%s error=%s", event, kind, exc) + # to10(ADR-14):回调失败的事件再落盘一份—— + # 宿主日志可能轮转、计数只剩数字,落盘文件是 + # 唯一能事后找回“哪次事件丢了”的取证。落盘 + # 本身 best-effort,失败只记日志,绝不炸任务。 + self._spill_audit(event, task, detail, kind) + + def _spill_audit(self, event, task, detail, kind) -> None: + """审计失败事件落盘(to10,见 ADR-14 取舍): + state_dir/chv-audit-spill.jsonl,每行一 JSON + {ts, event, kind, detail, task_id};文件超 1MB 时 + 先轮转成 .old 再写。detail 沿用 _emit 契约,绝不 + 含密钥值。落盘不是 DB 替代:timeline 查询看不到它, + 需宿主人工查阅;轮转只留最新两代,旧事件会丢。""" + try: + path = os.path.join(self.state_dir, + "chv-audit-spill.jsonl") + if os.path.exists(path) \ + and os.path.getsize(path) > 1024 * 1024: + os.replace(path, path + ".old") + task_id = task.get("id") if isinstance(task, dict) \ + else str(task) + line = json.dumps({"ts": time.time(), "event": event, + "kind": kind, "detail": detail, + "task_id": task_id}) + with open(path, "a") as fh: + fh.write(line + "\n") + except Exception as exc: + _LOGGER.error("chv_audit_spill_failed error=%s", exc) # -- 命令构造(纯函数,不做文件校验) ------------------------------- def _build_vm_command(self, task): @@ -671,7 +769,7 @@ class CloudHypervisorExecutor(Executor): return _known_hosts_path(self.state_dir, self.vm_name(task_or_name)) - def _net_guest_ip(self): + def _net_guest_ip(self) -> str | None: """VmConfig net 首个带 ip 的条目(静态 tap 客机 IP)。""" if not self.net_json: return None @@ -691,8 +789,9 @@ class CloudHypervisorExecutor(Executor): 显式 CHV_SSH_HOST > net 静态客机 IP > 显式转发宣称下 的回环;三者皆无时报 ChvFailure(ssh_target_undeclared), 绝不无条件假设 127.0.0.1:2222 能通。""" - if getattr(self, "ssh_host", None): - return self.ssh_host + _ssh_host = self.ssh_host + if _ssh_host: + return _ssh_host guest_ip = self._net_guest_ip() if guest_ip: return guest_ip @@ -704,6 +803,30 @@ class CloudHypervisorExecutor(Executor): "里给 net 条目配 ip(静态 tap 客机 IP)、或显式设 " "CHV_SSH_FORWARD=1 宣称宿主 NAT 已转发回环端口") + def _read_keypair_texts(self, paths: dict) -> tuple: + """读 key 对文本(to10 提取,provision_host_key 降行用)。""" + with open(paths["public"], "r") as fh: + public_text = fh.read() + with open(paths["private"], "r") as fh: + private_text = fh.read() + return public_text, private_text + + def _write_provisioned_known_hosts(self, paths: dict, + public_text: str) -> None: + """写预置 known_hosts(to10 提取)。主机部分跟 SSH + 目标走(客机 IP 时不再写死回环,否则钉死校验永远 + 对不上);解析失败时回环仅作钉死写入,不放宽校验。""" + from cloud.chv.hostkeys import known_hosts_line + from cloud.chv.ssh_transport import write_known_hosts + try: + ssh_host = self.resolve_ssh_host() + except ChvFailure: + ssh_host = "127.0.0.1" + write_known_hosts( + paths["known_hosts"], + [known_hosts_line(self.ssh_port, public_text, + host=ssh_host)]) + def provision_host_key(self, task: dict, secret_env: dict | None = None) -> dict: """按 VM 预置 host key(补缺口轨强方案,见 hostkeys.py): 启动前为该 VM 生成一次性 ed25519 host key 对,公钥 @@ -718,10 +841,13 @@ class CloudHypervisorExecutor(Executor): 脏标记存在时直接拒绝(清理失败即拒起,不再复用 旧 key 对)。成功后审计 host_key_generated(只含 VM 名与密钥名,不含任何 key 值)。""" + # to10:known_hosts 写入已抽到 _write_provisioned_known_hosts, + # 此处只留生成与 seed 写入的 import(删后不再有未用 import)。 from cloud.chv.hostkeys import ( - dirty_marker_path, generate_host_keypair, - known_hosts_line, write_seed) - from cloud.chv.ssh_transport import write_known_hosts + dirty_marker_path, + generate_host_keypair, + write_seed, + ) vm = self.vm_name(task) if os.path.exists(dirty_marker_path(self.state_dir, vm)): raise ChvFailure( @@ -732,20 +858,8 @@ class CloudHypervisorExecutor(Executor): paths = generate_host_keypair( self.state_dir, vm, runner=lambda cmd: self._call_runner(cmd)) - with open(paths["public"], "r") as fh: - public_text = fh.read() - with open(paths["private"], "r") as fh: - private_text = fh.read() - # feat-chv-fix:known_hosts 行的主机部分跟 SSH 目标走 - # (客机 IP 时不再写死回环,否则钉死校验永远对不上)。 - try: - ssh_host = self.resolve_ssh_host() - except ChvFailure: - ssh_host = "127.0.0.1" - write_known_hosts( - paths["known_hosts"], - [known_hosts_line(self.ssh_port, public_text, - host=ssh_host)]) + public_text, private_text = self._read_keypair_texts(paths) + self._write_provisioned_known_hosts(paths, public_text) seed_dir = write_seed(paths["seed_dir"], vm, private_text, public_text, secret_env=secret_env) @@ -819,7 +933,7 @@ class CloudHypervisorExecutor(Executor): 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] + missing: list = [name for name in secrets if name not in delivered] if missing: raise ChvFailure( "secret_env_not_delivered", @@ -835,6 +949,27 @@ class CloudHypervisorExecutor(Executor): guest_env_file=guest_env_file) # -- API 模式:VmConfig / API 调用 / 就绪轮询 ----------------------- + def _vm_config_disks(self, task_disk: str | None, + seed_disk: str | None) -> list: + """VmConfig 磁盘列表(to10 提取,含 L2 白名单校验)。 + 任务盘 + 可选 seed 只读盘(CIDATA ISO,cloud-init + NoCloud 数据源);显式给的盘若不是基础镜像本身, + 须在 state_dir 内,否则 VmConfig 不能挂进宿主任意文件。""" + # 旧文案逐字保留:任务盘“任务磁盘须在…”,seed 盘 + # “seed 磁盘须在…”(label 本身含“磁盘”与空格)。 + for path, label in ((task_disk, "任务磁盘"), + (seed_disk, "seed 磁盘")): + if path and path != self.rootfs: + if not os.path.realpath(path).startswith( + os.path.realpath(self.state_dir) + os.sep): + raise ChvFailure( + "disk_path_not_allowed", + "%s须在 state_dir 内:%s" % (label, path)) + disks: list = [{"path": task_disk or self.rootfs}] + if seed_disk: + disks.append({"path": seed_disk, "readonly": True}) + return disks + def build_vm_config(self, task_disk: str | None = None, seed_disk: str | None = None) -> dict: """API 模式的 VmConfig(vm.create 请求体,纯函数): cpus{boot_vcpus,max_vcpus}、memory{size: 字节}、 @@ -847,36 +982,14 @@ 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)}, "kernel": {"path": self.kernel}, "cmdline": {"args": self.cmdline}, - "disks": [{"path": task_disk or self.rootfs}], + "disks": self._vm_config_disks(task_disk, seed_disk), "rng": {"src": "/dev/urandom"}, } - # feat-chv-fix(P0-1):seed 磁盘是 VmConfig 第二块 - # 只读盘(CIDATA 卷标 ISO,客机 cloud-init NoCloud - # 数据源),不是宿主目录——目录本身从不进 VmConfig。 - if seed_disk: - config["disks"].append({"path": seed_disk, - "readonly": True}) if self.net_json: try: net = json.loads(self.net_json) @@ -1230,7 +1343,7 @@ class CloudHypervisorExecutor(Executor): raise return secrets, net_entries - def _provision_seed_disk(self, task: dict, name: str, secrets) -> str: + def _provision_seed_disk(self, task: dict, name: str, secrets) -> str | None: """provisioned 模式的 seed 盘供给:生成→制盘→挂盘, 任一步失败即抛(调用方 stop_vm 清理),纯移动。""" seed_disk = None @@ -1248,8 +1361,10 @@ class CloudHypervisorExecutor(Executor): # 失败即 fail-fast(VM 尚未启动,此时拒绝最 # 安全,except 里 stop_vm 会清掉 seed)。 from cloud.chv.hostkeys import ( - build_seed_disk, delete_host_private_key, - seed_disk_path) + 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) @@ -1498,8 +1613,8 @@ class CloudHypervisorExecutor(Executor): self.pause_vm(task_or_name) self.snapshot_vm(task_or_name, snapshot_url) self.stop_vm(task_or_name) - task = task_or_name if isinstance(task_or_name, dict) \ - else {"id": task_or_name} + # to10:删死赋值(task 建出后未用,F841;返回值直接 + # 用 task_or_name 算 VM 名,行为不变)。 return {"vm": self.vm_name(task_or_name), "state": "hibernated", "snapshot": snapshot_url} @@ -1616,7 +1731,8 @@ class CloudHypervisorExecutor(Executor): 非 live(无 api-socket 文件且无登记活进程)即删盘 并审计;删失败审计 vm_disk_delete_failed 并计入 failed。返回 {deleted, failed}。""" - deleted, failed = [], [] + deleted: list = [] + failed: list = [] try: entries = os.listdir(self.state_dir) except Exception: @@ -1693,7 +1809,7 @@ class CloudHypervisorExecutor(Executor): """崩溃残留扫描(H5):私钥一律删、非 live VM 的 seed 同理删并审计;返回已清理 VM 名。worker 启动与 cleanup_stale_vms 时调它,不依赖 socket 是否存在。""" - cleaned = [] + cleaned: list = [] try: entries = os.listdir(self.state_dir) except Exception: @@ -1814,8 +1930,7 @@ class CloudHypervisorExecutor(Executor): extra_env=extra_env) result = self._call_runner(ssh_cmd, timeout=self.task_timeout) # 方法内 import:见模块头注释的循环导入说明。 - from cloud.sandbox import _parse_host_result - from cloud.sandbox import SimpleNamespaceShim + from cloud.sandbox import SimpleNamespaceShim, _parse_host_result parsed = _parse_host_result(task["id"], SimpleNamespaceShim(result)) parsed["executor"] = "cloud-hypervisor" diff --git a/cloud/chv/experimental_pool.py b/cloud/chv/experimental_pool.py index 5abccbb..ffb5df3 100644 --- a/cloud/chv/experimental_pool.py +++ b/cloud/chv/experimental_pool.py @@ -4,6 +4,7 @@ cloud/chv/pool.py 移到本文件,cloud/chv/__init__ 与 cloud/sandbox.py 不再 re-export 它,生产代码 import 本 模块即违规(见 docs/chv-architecture.md ADR-12 的禁 + import 清单与测试钉住)。实验用法只走 `from cloud.chv.experimental_pool import WarmPool`。 生产残留清理已彻底分文件:见 cloud/chv/orphans.py 的 @@ -53,18 +54,21 @@ Registry(本轨新增): 仍可用,重启后孤儿改由 socket 扫描兜底)。 """ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from cloud.chv.executor import CloudHypervisorExecutor + import os -from cloud.chv.orphans import _REGISTRY_FILENAME -from cloud.chv.orphans import _read_registry -from cloud.chv.orphans import _write_registry +from cloud.chv.orphans import _REGISTRY_FILENAME, _read_registry, _write_registry class WarmPool: """快照预热池(实验性,见模块头定案)。 wired_to_dispatch = False:生产 dispatch 不查池。 - + 目标:冷启动(preflight + 起 VMM + create + boot + SSH 就绪)是慢路径;预热池提前把干净 VM 起好并暂停,任务 @@ -81,7 +85,7 @@ class WarmPool: wired_to_dispatch = False - def __init__(self, executor: object, size: int | None = None, + def __init__(self, executor: "CloudHypervisorExecutor", size: int | None = None, snapshot_url: str | None = None, registry_path: str | None = None) -> None: self.executor = executor @@ -90,7 +94,7 @@ class WarmPool: self.snapshot_url = snapshot_url \ if snapshot_url is not None \ else getattr(executor, "snapshot_url", None) - self._entries = {} # 合成名 -> {"state", "vm", "task", ...} + self._entries: dict = {} # 合成名 -> {"state", "vm", "task", ...} self.registry_path = registry_path if self.registry_path is None: state_dir = getattr(executor, "state_dir", None) diff --git a/cloud/chv/hardening.py b/cloud/chv/hardening.py index c37f9b9..f71b1eb 100644 --- a/cloud/chv/hardening.py +++ b/cloud/chv/hardening.py @@ -20,7 +20,7 @@ from cloud.chv.errors import ChvFailure _NET_FIELDS = frozenset({"tap", "mac", "ip", "mask"}) -def ensure_secure_state_dir(state_dir: str) -> None: +def ensure_secure_state_dir(state_dir: str) -> str: """建 state_dir 并保证 0700(H6):目录不存在即按 0700 建;已存在但组/其它位可访时 chmod 0700;失败即 ChvFailure(state_dir_insecure),绝不带着 /tmp 式 @@ -125,7 +125,7 @@ def _normalize_net_entry(entry: dict) -> dict: def _check_net_duplicates(normalized: list) -> None: """本次条目内 tap/mac/ip 重复即 net_config_invalid。""" - seen = {"tap": set(), "mac": set(), "ip": set()} + seen: dict[str, set[str]] = {"tap": set(), "mac": set(), "ip": set()} for entry in normalized: for kind in ("tap", "mac", "ip"): value = entry.get(kind) @@ -152,7 +152,7 @@ def _check_net_conflicts(normalized: list, existing_entries: list) -> None: % (kind, value, other.get("vm"))) -def validate_egress_proxy(egress_proxy: str) -> None: +def validate_egress_proxy(egress_proxy: str) -> str | None: """egress 代理地址校验(H1 代码门的一部分):须为 http/https URL 且带主机名;否则 net_config_invalid… 实际 kind 用 config_invalid(preflight 的配置类失败)。 diff --git a/cloud/chv/hostkeys.py b/cloud/chv/hostkeys.py index 65ff0cd..34c40d0 100644 --- a/cloud/chv/hostkeys.py +++ b/cloud/chv/hostkeys.py @@ -32,6 +32,7 @@ import os import shlex +from collections.abc import Callable # seed 投递的客内密钥环境文件路径(feat-chv-secfix,H2): # 只在 provisioned 模式的 seed user-data 里写出,0600 root, @@ -88,7 +89,8 @@ def seed_disk_path(state_dir: str, vm_name: str) -> str: return os.path.join(state_dir, vm_name, "seed.iso") -def build_seed_disk(seed_dir: str, seed_disk: str, runner: object = None) -> str: +def build_seed_disk(seed_dir: str, seed_disk: str, + runner: Callable[[list[str]], object] | None = None) -> str: """把 seed 目录做成 CIDATA 卷标 ISO(feat-chv-fix,P0-1 的 注入载体):genisoimage -volid CIDATA -joliet -rock。 @@ -206,7 +208,7 @@ def write_seed(seed_dir: str, vm_name: str, private_key_text: str, def generate_host_keypair(state_dir: str, vm_name: str, - runner: object = None, + runner: Callable[[list[str]], object] | None = None, keygen: str = "ssh-keygen") -> dict: """为该 VM 生成(或复用本 VM 已有的)host key 对。 diff --git a/cloud/chv/ssh_transport.py b/cloud/chv/ssh_transport.py index 995c461..71899b1 100644 --- a/cloud/chv/ssh_transport.py +++ b/cloud/chv/ssh_transport.py @@ -52,7 +52,7 @@ import shlex _CHV_SECRET_ENV_NAMES = frozenset({"SUB2API_API_KEY"}) -def split_secret_env(extra_env: dict) -> tuple: +def split_secret_env(extra_env: dict | None) -> tuple[dict, dict]: """把 extra_env 拆成(密钥子集,非密钥子集)。""" secrets, plain = {}, {} for key, value in dict(extra_env or {}).items(): diff --git a/cloud/chv/worker_adapter.py b/cloud/chv/worker_adapter.py new file mode 100644 index 0000000..b7b32e5 --- /dev/null +++ b/cloud/chv/worker_adapter.py @@ -0,0 +1,196 @@ +"""CHV worker 适配模块(to10 轨,见 ADR-13)。 + +worker.py 里散落的 CHV 强相关接线抽到这里,worker 只留 +薄包装(方法名、签名、调用点不变,旧测试继续走 worker +方法)。本模块只依赖标准库 + cloud.chv 叶子模块 + +cloud.logging_config,不 import cloud.worker,绝无循环。 + +抽入本模块的(行为与错误文案逐字搬运): +- call_goal_resume_breakpoint:通用断点调用(原 worker + 顶层函数 _call_goal_resume_breakpoint 的本体)。 +- make_chv_audit_callback:VM 生命周期事件进审计的回调 + (原 Worker.__init__ 里的闭包本体)。 +- reconcile_warm_pool:生产残留清理 facade 调用 + 审计 + (原 Worker._reconcile_warm_pool 的本体)。 +- cancel_watchdog_loop / quota_watchdog_loop:同步路径 + 两条看门狗循环(原 Worker._chv_*_watchdog 的本体, + self 依赖全部改成回调参数)。 +- watchdog_vm_name:看门狗启动时的 VM 名派生(原 + _run_attempt 里的 try/except 块)。 + +明确不抽的(见 ADR-13 诚实清单,worker 通用流程): +cancel() 里的 cancel_vm 分支、cleanup_stale_containers +的 CHV 分支、网关守卫、_executor_name 的 CHV 分支、 +_run_attempt 的看门狗线程启动、VM 死亡写回——它们都 +嵌在 Docker/Local 共用的通用流程里,硬抽只会把通用 +流程撕成两半,行为不变的收益为零。 +""" + +import inspect +import threading +from collections.abc import Callable + +from cloud.chv.executor import CloudHypervisorExecutor +from cloud.logging_config import log_event + + +def is_chv_executor(executor: object) -> bool: + """是否为 CHV 执行器(worker 各处 isinstance 的同款判断)。""" + return isinstance(executor, CloudHypervisorExecutor) + + +def call_goal_resume_breakpoint(executor: object, task: dict, + model_provider: str | None = None, + extra_env: dict | None = None) -> object: + """通用断点调用:执行器提供 goal_resume_breakpoint_task + 时调用它。 + + Local 仍走原路径(调用方直接传 model_provider,不经过 + 这里,以免改生产语义);这里只用于非 Local 执行器: + 签名接受 model_provider 时传它(CHV/Docker 均接受), + 否则只传 task(旧假执行器单参)。feat-chv-secfix(H2): + CHV 的 gateway_env 不再经 SSH env 前缀注入(密钥值进 + argv 的旧路径已废除),改走 provisioned seed 盘客内文件 + 投递;CHV 没有 Local 那样的进程 env 通道,seed 文件是 + 唯一通道,keyscan 模式下密钥投递 fail-closed。""" + method = getattr(executor, "goal_resume_breakpoint_task") + try: + params = inspect.signature(method).parameters + except (TypeError, ValueError): + return method(task) + kwargs: dict = {} + if "model_provider" in params: + kwargs["model_provider"] = model_provider + if "extra_env" in params and extra_env is not None: + kwargs["extra_env"] = extra_env + return method(task, **kwargs) + + +def make_chv_audit_callback(audit_fn: Callable) -> Callable: + """VM 生命周期事件进审计的回调(原 __init__ 闭包本体)。 + + audit_fn 即 Worker._audit(best-effort,失败不影响任务); + 时间线展示 VM 事件时读审计表即可,不另起一套 VM 事件表; + 成本/配额仍走 worker 现有 quota 语义,本回调绝不记账。""" + + def _chv_audit(event, task, detail, kind=None): + try: + project_id = task.get("project_id") \ + if isinstance(task, dict) else None + audit_fn(project_id, "chv_vm_" + event, detail or "") + except Exception: + pass + + return _chv_audit + + +def reconcile_warm_pool(executor: object, audit_fn: Callable, + when: str) -> list: + """显式调生产残留清理 facade,返回已清理 VM 名。 + + 仅 CHV worker 有效(其它执行器返回 [])。实现是 + cloud.chv.orphans.reconcile_orphans_for_executor(): + 直接读 registry 文件做清理,不构造 WarmPool、不碰 + 实验接口。调用失败不炸主流程,但进审计(失败本身 + 也审计,不静默丢弃)。orphan 条目绝不发给任务, + reconcile 只做清理。""" + if not isinstance(executor, CloudHypervisorExecutor): + return [] + try: + from cloud.chv.orphans import reconcile_orphans_for_executor as _reconcile + cleaned = _reconcile(executor) or [] + except Exception as exc: + try: + audit_fn(None, "chv_warm_pool_reconcile_failed", + "when=%s error=%s" % (when, exc)) + except Exception: + pass + return [] + if cleaned: + try: + audit_fn(None, "chv_warm_pool_reconcile", + "when=%s cleaned=%s" % (when, ",".join(cleaned))) + except Exception: + pass + return cleaned + + +def cancel_watchdog_loop(task_id: str, vm_name: str | None, + stop_event: threading.Event, + cancel_requested: set, + get_task: Callable, + cancel_vm: Callable | None, + logger: object) -> None: + """CHV 同步路径的取消看门狗(feat-chv-fix,P1-2,Docker + 取消看门狗同款模式):同步 SSH 阻塞时 worker 的 cancel() + 只能改库、清 _running 项,阻塞中的 run 本身不会醒; + 本线程每 1 秒查库/取消标志,命中时调 cancel_vm 真关 + VM,然后退出。查库失败时当没取消,不误杀。""" + while not stop_event.wait(1.0): + try: + if task_id in cancel_requested: + cancelled = True + else: + db_task = get_task(task_id) + cancelled = (db_task is not None and + db_task.get("status") == "interrupted") + except Exception: + cancelled = False + if not cancelled: + continue + try: + if cancel_vm is not None and vm_name: + cancel_vm(vm_name) + log_event(logger, "worker_chv_killed", + task_id=task_id, + message="cancel watchdog killed VM %s " + "(task interrupted/cancelled)" + % vm_name) + except Exception: + pass + return + + +def quota_watchdog_loop(task_id: str, vm_name: str | None, + stop_event: threading.Event, + quota_check: Callable | None, + mark_quota_hit: Callable, + cancel_vm: Callable | None, + logger: object, + interval: float = 30.0) -> None: + """CHV 同步路径的配额看门狗(feat-chv-fix,P1-2,Docker + 配额看门狗同款):命中时记 _quota_hit_tasks 并调 + cancel_vm 真关 VM;_run_attempt 在同步返回后把结果 + 改写成 quota_exceeded,走统一配额中断收尾。""" + while not stop_event.wait(interval): + try: + over = bool(quota_check()) if quota_check else False + except Exception: + over = False + if not over: + continue + mark_quota_hit(task_id) + try: + if cancel_vm is not None and vm_name: + cancel_vm(vm_name) + log_event(logger, "worker_chv_killed", + task_id=task_id, + message="quota watchdog killed VM %s " + "(quota exceeded)" % vm_name) + except Exception: + pass + return + + +def watchdog_vm_name(executor: object, task: dict) -> str | None: + """看门狗启动时的 VM 名派生(原 _run_attempt 块): + executor.vm_name(task) 抛错时回 None(不建看门狗), + 绝不炸主派发流程。executor 无 vm_name 时等同抛错 + (原代码里 AttributeError 同样被 except 吞掉回 None)。""" + get_vm_name = getattr(executor, "vm_name", None) + if not callable(get_vm_name): + return None + try: + return get_vm_name(task) + except Exception: + return None diff --git a/cloud/console-react/src/api/chv.test.ts b/cloud/console-react/src/api/chv.test.ts index 3a73d9e..82ef4b6 100644 --- a/cloud/console-react/src/api/chv.test.ts +++ b/cloud/console-react/src/api/chv.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { chvAttachmentBlocked, chvAvailability, chvQueueHint, chvVmBadge, chvVmStatusText, countChvWorkers, isChvExecutor, isWorkerAlive } from "./chv"; +import { chvAttachmentBlocked, chvAvailability, chvAvailabilityText, chvQueueHint, chvVmBadge, chvVmDisplay, chvVmStatusText, countChvWorkers, isChvExecutor, isWorkerAlive } from "./chv"; // 纯逻辑测试:只测 chv.ts,不渲染组件、不发请求、不读 localStorage。 // alive 规则与 WorkersSection 现有口径一致:alive 字段优先, @@ -103,3 +103,53 @@ describe("chvQueueHint", () => { expect(chvQueueHint("cloud-hypervisor", "queued", { state: "known" as const, online: 1, total: 1 })).toBe(""); }); }); + +describe("chvVmDisplay(to10 四态)", () => { + it("字段 present=true → dead", () => { + const d = chvVmDisplay({ executor: "cloud-hypervisor", chv_vm_dead: true }); + expect(d.state).toBe("dead"); + expect(d.text).toContain("VM 中途死亡"); + }); + it("字段 absent → not-reported,不猜 live", () => { + const d = chvVmDisplay({ executor: "cloud-hypervisor" }); + expect(d.state).toBe("not-reported"); + expect(d.text).toContain("不代表 VM 活着"); + }); + it("任务未加载 → unknown", () => { + const d = chvVmDisplay(null); + expect(d.state).toBe("unknown"); + expect(d.text).toContain("无法判断"); + }); + it("非 CHV → not-applicable,text 为空", () => { + const d = chvVmDisplay({ executor: "docker", chv_vm_dead: true }); + expect(d.state).toBe("not-applicable"); + expect(d.text).toBe(""); + }); + it("旧入口仍是 display 的 text(unknown/not-applicable 回空)", () => { + expect(chvVmStatusText({ executor: "cloud-hypervisor", chv_vm_dead: true })).toContain("VM 中途死亡"); + expect(chvVmStatusText(null)).toBe(""); + expect(chvVmStatusText({ executor: "docker" })).toBe(""); + }); +}); + +describe("chvAvailabilityText(to10 四处统一文案)", () => { + it("known 零在线:不拦提交、后端是最终权威", () => { + const text = chvAvailabilityText({ state: "known", online: 0, total: 2 }); + expect(text).toContain("在线 0 / 共 2"); + expect(text).toContain("不拦提交"); + expect(text).toContain("最终权威"); + }); + it("known 非零:只有在线数,无排队警告", () => { + const text = chvAvailabilityText({ state: "known", online: 1, total: 1 }); + expect(text).toContain("在线 1 / 共 1"); + expect(text).not.toContain("排队"); + }); + it("未知三原因各有不同文案,绝不写成 0", () => { + expect(chvAvailabilityText({ state: "unknown", reason: "aggregate-only" })).toContain("仅全局管理员可见"); + expect(chvAvailabilityText({ state: "unknown", reason: "load-failed" })).toContain("加载失败"); + expect(chvAvailabilityText({ state: "unknown", reason: "not-loaded" })).toContain("尚未加载"); + for (const reason of ["aggregate-only", "load-failed", "not-loaded"] as const) { + expect(chvAvailabilityText({ state: "unknown", reason })).not.toContain("在线 0"); + } + }); +}); diff --git a/cloud/console-react/src/api/chv.ts b/cloud/console-react/src/api/chv.ts index 31e32f8..ce10c25 100644 --- a/cloud/console-react/src/api/chv.ts +++ b/cloud/console-react/src/api/chv.ts @@ -54,27 +54,85 @@ export function chvAttachmentBlocked( return isChvExecutor(executor) && hasAttachments; } -// VM 只读状态文案:输入只有后端已返回的两个字段,输出 -// 三种情形之一,绝不编造第四种: -// - chv_vm_dead 为 true → 已报告死亡(来源:worker 终局 -// 写回的 interrupted_reason,见 server _chv_vm_dead_from_row); -// - CHV 任务但无该字段 → 未报告死亡,且明写“后端无 live -// 状态,不代表 VM 活着”(server 没有 VM 登记表); -// - 非 CHV 任务 → 空串(不显示 VM 行)。 -export function chvVmStatusText(task: { +// VM 只读状态:四种情形各有明确文案,绝不编造第五种, +// 绝不猜 live(to10 精确化,见 ADR-15): +// - dead:CHV 任务且 chv_vm_dead 为 true(字段已 present; +// 来源:worker 终局写回的 interrupted_reason,见 server +// _chv_vm_dead_from_row); +// - not-reported:CHV 任务但字段 absent(后端只在死亡时 +// 才返回该字段;absent 只表示“未报告”,且明写“后端无 +// live 状态字段,不代表 VM 活着”,server 没有 VM 登记表); +// - unknown:任务详情本身未加载(task 为 null),无法判断; +// - not-applicable:非 CHV 任务,不显示 VM 行(text 为空)。 +export type ChvVmDisplay = + | { state: "dead"; text: string } + | { state: "not-reported"; text: string } + | { state: "unknown"; text: string } + | { state: "not-applicable"; text: string }; + +export function chvVmDisplay(task: { executor?: string | null; chv_vm_dead?: boolean; -}): string { +} | null | undefined): ChvVmDisplay { + if (task === null || task === undefined) { + return { + state: "unknown", + text: "任务详情未加载,无法判断 VM 状态(后端无 live 状态字段,详情加载后才显示)", + }; + } // 死亡文案同样先过执行器门:后端只对 CHV 任务派生该字段, // 万一某天非 CHV 行误带该字段,前端也不把 docker 任务 // 写成“VM 死亡”。 if (task.chv_vm_dead && isChvExecutor(task.executor)) { - return "VM 中途死亡,任务未完成、断点可续跑(后端只读字段 chv_vm_dead)"; + return { + state: "dead", + text: "VM 中途死亡,任务未完成、断点可续跑(后端只读字段 chv_vm_dead)", + }; } if (isChvExecutor(task.executor)) { - return "未报告 VM 死亡(后端无 live 状态字段,不代表 VM 活着)"; + return { + state: "not-reported", + text: "未报告 VM 死亡(后端无 live 状态字段,不代表 VM 活着)", + }; } - return ""; + return { state: "not-applicable", text: "" }; +} + +// 旧入口(保留给已测调用点):即 chvVmDisplay 的 text, +// not-applicable 与 unknown 均回空串(调用点只在已加载的 +// CHV 行上用它,unknown 分支由详情页的新入口处理)。 +export function chvVmStatusText(task: { + executor?: string | null; + chv_vm_dead?: boolean; +} | null | undefined): string { + const display = chvVmDisplay(task); + return display.state === "not-applicable" || + display.state === "unknown" + ? "" + : display.text; +} + +// CHV 可用性统一文案(to10):新建任务、Workers、环境 +// 三处此前各写各的,Workers 的未知分支甚至恒写“仅全局 +// 管理员可见”(加载失败/未加载时也是这句,是文案 bug)。 +// 四处统一走本函数:known 零在线时不拦提交、后端是最终 +// 权威;未知时按 reason 分三句,绝不把未知写成 0。 +export function chvAvailabilityText( + availability: ChvAvailability, +): string { + if (availability.state === "known") { + const base = `CHV worker 在线 ${availability.online} / 共 ${availability.total}(来自 /workers)`; + return availability.online === 0 + ? `${base}:当前无 CHV worker 在线,任务将一直排队,请先确认宿主 worker 已起;本提示不拦提交,worker 随后上线可认领,后端是最终权威。` + : base; + } + if (availability.reason === "aggregate-only") { + return "CHV worker 在线数未知:完整 worker 清单仅全局管理员可见,非管理员只能看到聚合数,无法按执行器分项。"; + } + if (availability.reason === "load-failed") { + return "CHV worker 在线数未知:/workers 加载失败,请到 Workers 区刷新后重试。"; + } + return "CHV worker 在线数未知:/workers 尚未加载,请到 Workers 区刷新。"; } // 列表行徽标:只有已报告死亡时才出现短徽标;未报告时 diff --git a/cloud/console-react/src/sections/DispatchSection.tsx b/cloud/console-react/src/sections/DispatchSection.tsx index c23281b..bdc93c9 100644 --- a/cloud/console-react/src/sections/DispatchSection.tsx +++ b/cloud/console-react/src/sections/DispatchSection.tsx @@ -2,7 +2,7 @@ import { useRef, useState } from "react"; import { useConsole } from "../context"; import { Card, Bar } from "../components/ui"; import { gatherAttachments } from "../api/client"; -import { chvAttachmentBlocked, chvAvailability, isChvExecutor } from "../api/chv"; +import { chvAttachmentBlocked, chvAvailability, chvAvailabilityText, isChvExecutor } from "../api/chv"; import { EFFORTS } from "../api/types"; export default function DispatchSection() { @@ -97,14 +97,8 @@ export default function DispatchSection() { {isChv ?