merge(chv-hybrid): 合入 C 轨——autoscaler 池目标数 + 回收同步门 + 审计事件目录

合入 feat-chv-hybrid-c (39c51b7)。executor.py 自动合入,无冲突;
autoscaler.py / hybrid_events.py / test_chv_hybrid_c.py 直接合入。

集成统一:A/B 两轨按事件名字符串直接发射的审计点收到 C 的
hybrid_events.py 模块名下——executor.py 顶层 import 9 个设计事件
常量(WARM_POOL_ACQUIRE/MISS/RELEASE、VM_RESIDENT_SUSPENDED/
DEMOTED/PROMOTED、RESIDENT_UPGRADE_HINT、SNAPSHOT_TENANT_MISMATCH、
VM_RESTORE_DENIED),13 处字面量(_emit 事件名、审计 kind、
ChvFailure kind)全部改走常量;发射路径 _emit 与 detail 字符串
原样不动(hybrid_events 只定名 + 校验,不发事件)。C 的回收
同步失败事件仍走其原有函数内 import + format_detail,不动。
experimental_pool.py 的两处同名字符串不动——那是实验位自身的
事件,不属混合生产路径(§9.1-10)。

C 轨未决项 1 定夺(_run_with_vm 的 finally 仍是无条件 shutdown,
未接回收同步门):本轮不接,列为跟进项。理由:设计 §3.3 的同步
门是回收路径(recycle_vm / recycle_persistent_vm),冷任务每任务
生命周期不在其内;finally 里接同步门会改全部任务的关机语义
(一次 push 失败即留 VM 占槽),须另立重试/留存上限/告警的
设计,不是本轮一行的事。

测试:test_chv_hybrid_a/b/c 共 96 项全部通过;全量待跑。
This commit is contained in:
host committed 2026-11-08 22:01:19 +00:00
commit 04a208a98b
4 files changed
+977 -24

No files matched your search

+573
View File
@@ -0,0 +1,573 @@
"""CHV 混合模式 C 轨测试(feat-chv-hybrid-c)。
只覆盖本轨的三个改动点(DESIGN-HYBRID.md §9.1 第 10–13 项):
- autoscaler desired_workers 作用域扩展(§1.2,W=2);
- 回收同步失败即任务标同步失败、VM 不回收(§3.3);
- 审计事件目录(§7,事件名与设计原文一致、带 host_id、
不带密钥值)与 orphans facade 核实(§1.3/ADR-5/ADR-12)。
只用标准库与 mock runner/launcher/api_client,绝不启动
真实 VM、不联网、不碰生产库。真机验证(WSL 3 台排布、
热恢复 ≤2 秒等)不在本文件声称已验证。
"""
import json
import os
import socket
import subprocess
import sys
import tempfile
import time
import unittest
from pathlib import Path
from unittest import mock
try:
from cloud.autoscaler import (
CHV_WARM_POOL_PER_HOST,
Autoscaler,
desired_workers,
pool_target_for_hosts,
)
from cloud.chv import hybrid_events
from cloud.chv.executor import CloudHypervisorExecutor
from cloud.chv.orphans import reconcile_orphans_for_executor
from cloud.server import init_db
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.autoscaler import (
CHV_WARM_POOL_PER_HOST,
Autoscaler,
desired_workers,
pool_target_for_hosts,
)
from cloud.chv import hybrid_events
from cloud.chv.executor import CloudHypervisorExecutor
from cloud.chv.orphans import reconcile_orphans_for_executor
from cloud.server import init_db
class FakeResult:
def __init__(self, returncode=0, stdout="", stderr=""):
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
class FakeProc:
def __init__(self, alive=True):
self._alive = alive
self.terminated = False
self.killed = False
def poll(self):
return None if self._alive else 1
def terminate(self):
self.terminated = True
self._alive = False
def wait(self, timeout=None):
return 0
def kill(self):
self.killed = True
self._alive = False
def _write(path, content=b"x"):
with open(path, "wb") as fh:
fh.write(content)
os.chmod(path, 0o755)
return path
# -- 11. desired_workers + 池目标数 --------------------------------------
class DesiredWorkersPoolTestCase(unittest.TestCase):
def test_pool_target_zero_unchanged(self):
# pool_target=0(缺省)时与旧公式一字不差。
self.assertEqual(desired_workers(0, 2, 1, 2), 1)
self.assertEqual(desired_workers(0, 2, 0, 2), 0)
self.assertEqual(desired_workers(1, 2, 1, 2), 1)
self.assertEqual(desired_workers(3, 2, 1, 2), 2)
self.assertEqual(desired_workers(10, 2, 1, 2), 2)
self.assertEqual(desired_workers(5, 1, 1, 3), 3)
self.assertEqual(desired_workers(5, 1, 1, 3, pool_target=0), 3)
def test_pool_target_additive(self):
# 期望值 = fleet 需要量 + 池目标数(§1.2)。
self.assertEqual(desired_workers(0, 2, 1, 20, pool_target=12), 13)
self.assertEqual(desired_workers(4, 2, 1, 20, pool_target=12), 14)
self.assertEqual(desired_workers(3, 2, 1, 20, pool_target=2), 4)
# depth=0 且 min_workers=0 时池目标仍维持(预热不因
# 队列空而归零)。
self.assertEqual(desired_workers(0, 2, 0, 20, pool_target=12), 12)
def test_pool_target_capped_by_max_workers(self):
# 池目标数计入 max_workers 上限:配不下即截断,不静默多扩。
self.assertEqual(desired_workers(0, 2, 1, 2, pool_target=12), 2)
self.assertEqual(desired_workers(10, 2, 1, 3, pool_target=12), 3)
def test_pool_target_negative_or_none_is_zero(self):
self.assertEqual(desired_workers(0, 2, 1, 2, pool_target=-5), 1)
self.assertEqual(desired_workers(0, 2, 1, 2, pool_target=None), 1)
class PoolTargetForHostsTestCase(unittest.TestCase):
def test_w_is_two_per_host(self):
self.assertEqual(CHV_WARM_POOL_PER_HOST, 2)
def test_pool_target_is_w_times_online_hosts(self):
self.assertEqual(pool_target_for_hosts(6), 12)
self.assertEqual(pool_target_for_hosts(22), 44)
self.assertEqual(pool_target_for_hosts(1), 2)
self.assertEqual(pool_target_for_hosts(0), 0)
def test_invalid_hosts_is_zero(self):
self.assertEqual(pool_target_for_hosts(-1), 0)
self.assertEqual(pool_target_for_hosts(None), 0)
self.assertEqual(pool_target_for_hosts("not-a-number"), 0)
def _insert_task(db_path, task_id):
now = time.time()
import sqlite3
conn = sqlite3.connect(db_path)
try:
conn.execute(
"INSERT INTO tasks(id, project_id, prompt, status,"
" idempotency_key, depends_on, priority, created_by,"
" created_at, updated_at, last_heartbeat)"
" VALUES(?, 'p1', 'x', 'queued', NULL, '[]', 0,"
" 'tester', ?, ?, NULL)",
(task_id, now, now))
conn.commit()
finally:
conn.close()
class AutoscalerPoolReconcileTestCase(unittest.TestCase):
"""L1 其余语义不变:30 秒一轮(interval 缺省)、scale up
fast / scale down slow(scale_down_after 冷却)原样复用,
只验证期望值多了池目标数。"""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
init_db(self.db_path)
self.state_file = os.path.join(self.tmp.name, "state.json")
self.fake_cmd = [sys.executable, "-c",
"import time; time.sleep(120)"]
def tearDown(self):
if hasattr(self, "scaler"):
for wid in list(self.scaler.workers):
try:
self.scaler._stop_worker(wid)
except Exception:
pass
self.tmp.cleanup()
def _scaler(self, **kwargs):
kwargs.setdefault("min_workers", 1)
kwargs.setdefault("max_workers", 5)
kwargs.setdefault("per_worker_capacity", 2)
kwargs.setdefault("scale_down_after", 1)
kwargs.setdefault("drain_timeout", 5)
kwargs.setdefault("state_file", self.state_file)
self.scaler = Autoscaler(
self.db_path, [], worker_cmd=self.fake_cmd, **kwargs)
return self.scaler
def test_empty_queue_still_holds_pool(self):
scaler = self._scaler(pool_target=2)
depth, desired, actual = scaler.reconcile()
self.assertEqual((depth, desired, actual), (0, 3, 3))
def test_depth_plus_pool_and_scale_down_slow(self):
scaler = self._scaler(pool_target=2, scale_down_after=2)
for tid in ("A", "B", "C", "D"):
_insert_task(self.db_path, tid)
depth, desired, actual = scaler.reconcile()
self.assertEqual((depth, desired, actual), (4, 4, 4))
# 任务全完成:深度归零但池目标仍在,期望值回落到 3
# 而不是 1;缩容仍须等满 2 轮冷却(scale down slow)。
import sqlite3
conn = sqlite3.connect(self.db_path)
try:
conn.execute("UPDATE tasks SET status='completed'")
conn.commit()
finally:
conn.close()
scaler.reconcile()
self.assertEqual(len(scaler.workers), 4)
self.assertEqual(scaler.last_action, "scale_down_pending_1/2")
depth, desired, actual = scaler.reconcile()
self.assertEqual((depth, desired, actual), (0, 3, 3))
def test_online_hosts_derives_pool_target(self):
scaler = self._scaler(online_hosts=6, max_workers=20)
self.assertEqual(scaler.pool_target, 12)
depth, desired, actual = scaler.reconcile()
self.assertEqual((depth, desired, actual), (0, 13, 13))
def test_explicit_pool_target_wins_over_hosts(self):
scaler = self._scaler(pool_target=2, online_hosts=6)
self.assertEqual(scaler.pool_target, 2)
def test_state_file_records_pool_target(self):
scaler = self._scaler(pool_target=2)
scaler.reconcile()
with open(self.state_file) as fh:
state = json.load(fh)
self.assertEqual(state["pool_target"], 2)
self.assertEqual(state["desired"], 3)
def test_main_passes_pool_flags(self):
import cloud.autoscaler as autoscaler_mod
with mock.patch.object(
autoscaler_mod, "Autoscaler") as mock_cls, \
mock.patch("signal.signal"):
mock_cls.return_value.run_loop = mock.Mock()
autoscaler_mod.main([
"--db", self.db_path, "--pool-target", "12",
"--", "--interval", "10"])
kwargs = mock_cls.call_args.kwargs
self.assertEqual(kwargs["pool_target"], 12)
with mock.patch.object(
autoscaler_mod, "Autoscaler") as mock_cls, \
mock.patch("signal.signal"):
mock_cls.return_value.run_loop = mock.Mock()
autoscaler_mod.main([
"--db", self.db_path, "--online-hosts", "6",
"--", "--interval", "10"])
kwargs = mock_cls.call_args.kwargs
self.assertEqual(kwargs["online_hosts"], 6)
self.assertEqual(kwargs["warm_pool_per_host"], 2)
# -- 12. 回收同步 ---------------------------------------------------------
class RecycleSyncCase(unittest.TestCase):
"""mock runner/launcher/api_client,不启动真实 VM。
push_rc 控制客内 `chv-sync push` 的返回值。"""
TOKEN = "SECRET-TOKEN-XYZ-DO-NOT-LEAK"
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.dir = self.tmp.name
self.binary = _write(os.path.join(self.dir, "cloud-hypervisor"))
self.ch_remote = _write(os.path.join(self.dir, "ch-remote"))
self.kernel = _write(os.path.join(self.dir, "vmlinux"))
self.rootfs = _write(os.path.join(self.dir, "disk.raw"), b"R" * 100)
self.ssh_key = _write(os.path.join(self.dir, "id_chv"))
self.kvm = _write(os.path.join(self.dir, "kvm"))
self.cpuinfo = os.path.join(self.dir, "cpuinfo")
with open(self.cpuinfo, "w") as fh:
fh.write("flags : fpu vmx sse\n")
self.state_dir = os.path.join(self.dir, "state")
self.calls = []
self.audit = []
self.proc = FakeProc(alive=True)
self.push_rc = 0
self.push_stdout = "已 push"
self.push_stderr = ""
self.raise_on_push = False
def tearDown(self):
self.tmp.cleanup()
def _runner(self, cmd, timeout=None):
self.calls.append(list(cmd))
joined = " ".join(str(c) for c in cmd)
if "chv-sync" in joined:
if self.raise_on_push:
raise RuntimeError("ssh broken")
return FakeResult(self.push_rc, self.push_stdout,
self.push_stderr)
return FakeResult(0, "", "")
def _make_exec(self, sync_configured=True):
env = {"CHV_EGRESS_PROXY": "http://10.0.0.1:8888",
"CHV_HOST_KEY_MODE": "keyscan",
"CHV_SSH_FORWARD": "1"}
if sync_configured:
env.update({
"CHV_GITEA_URL": "https://gitea.tose.top",
"CHV_GITEA_USER": "tester",
"CHV_GITEA_TOKEN": self.TOKEN,
"CHV_SYNC_REPO": "tester/repo",
})
ex = CloudHypervisorExecutor(
binary=self.binary, ch_remote=self.ch_remote,
kernel=self.kernel, rootfs=self.rootfs,
ssh_key=self.ssh_key, kvm_path=self.kvm,
cpuinfo_path=self.cpuinfo, state_dir=self.state_dir,
runner=self._runner,
launcher=lambda cmd: self.proc,
api_client=lambda *a, **k: (200, {}),
sleeper=lambda s: None, env=env)
ex.audit_callback = lambda e, t, d, k=None: self.audit.append(
(e, t.get("id") if isinstance(t, dict) else t, d, k))
return ex
def _task(self):
return {"id": "abc123", "prompt": "x", "project_id": "proj1"}
def _mark_vm_running(self, ex, vm_name="chv-abc123"):
os.makedirs(self.state_dir, exist_ok=True)
with open(os.path.join(self.state_dir, vm_name + ".sock"),
"w") as fh:
fh.write("")
ex._vms[vm_name] = self.proc
def _audit_events(self):
return [e for e, _t, _d, _k in self.audit]
def _audit_details(self):
return [d for _e, _t, d, _k in self.audit]
def test_no_sync_configured_recycles_as_before(self):
# 未配 Gitea 同步:无物可推,走旧回收路径(回归)。
ex = self._make_exec(sync_configured=False)
self._mark_vm_running(ex)
out = ex.recycle_vm(self._task())
self.assertEqual(out["state"], "recycled")
self.assertTrue(out["sync_ok"])
self.assertFalse(out["vm_retained"])
self.assertTrue(self.proc.terminated)
self.assertIn("vm_recycled", self._audit_events())
self.assertFalse(any("chv-sync" in " ".join(c)
for c in self.calls))
def test_sync_push_ok_then_recycle(self):
ex = self._make_exec()
self._mark_vm_running(ex)
self.push_rc = 0
out = ex.recycle_vm(self._task())
self.assertEqual(out["state"], "recycled")
self.assertTrue(out["sync_ok"])
self.assertFalse(out["vm_retained"])
self.assertTrue(self.proc.terminated)
self.assertIn("vm_recycled", self._audit_events())
self.assertTrue(any("chv-sync" in " ".join(c)
for c in self.calls))
self.assertNotIn("vm_recycle_sync_failed",
self._audit_events())
def test_sync_push_failed_marks_sync_failed_and_retains_vm(self):
# §3.3 新增语义:同步失败即任务标同步失败、VM 不回收。
ex = self._make_exec()
self._mark_vm_running(ex)
self.push_rc = 1
self.push_stderr = "push 被拒绝(远端已有新提交)"
out = ex.recycle_vm(self._task())
self.assertEqual(out["state"], "sync_failed")
self.assertEqual(out["status"], "sync_failed")
self.assertFalse(out["sync_ok"])
self.assertTrue(out["vm_retained"])
self.assertIn("push", out["sync_error"])
# VM 保留:进程未停、socket 未删、未发 recycled/shutdown。
self.assertFalse(self.proc.terminated)
self.assertTrue(os.path.exists(os.path.join(
self.state_dir, "chv-abc123.sock")))
self.assertNotIn("vm_recycled", self._audit_events())
self.assertNotIn("vm_shutdown", self._audit_events())
self.assertNotIn("vm_destroy", self._audit_events())
# 记审计:事件名正确、带 host_id、不带密钥值。
self.assertIn("vm_recycle_sync_failed", self._audit_events())
details = " ".join(self._audit_details())
self.assertIn("host_id=", details)
self.assertNotIn(self.TOKEN, details)
self.assertNotIn(self.TOKEN, json.dumps(self.audit))
def test_sync_ssh_error_also_retains_vm(self):
ex = self._make_exec()
self._mark_vm_running(ex)
self.raise_on_push = True
out = ex.recycle_vm(self._task())
self.assertEqual(out["state"], "sync_failed")
self.assertTrue(out["vm_retained"])
self.assertFalse(self.proc.terminated)
self.assertIn("vm_recycle_sync_failed", self._audit_events())
def test_sync_failed_then_retry_after_push_ok(self):
# 人工/重试路径:push 修好后再次回收即成功,不卡死。
ex = self._make_exec()
self._mark_vm_running(ex)
self.push_rc = 1
out = ex.recycle_vm(self._task())
self.assertEqual(out["state"], "sync_failed")
self.push_rc = 0
out = ex.recycle_vm(self._task())
self.assertEqual(out["state"], "recycled")
self.assertTrue(self.proc.terminated)
def test_sync_skip_flag_recycles_despite_push_failure(self):
ex = self._make_exec()
self._mark_vm_running(ex)
self.push_rc = 1
out = ex.recycle_vm(self._task(), sync=False)
self.assertEqual(out["state"], "recycled")
self.assertTrue(self.proc.terminated)
def test_vm_not_running_does_not_block_cleanup(self):
# VM 已死:没有活工作树可推,不拿死 VM 卡清理。
ex = self._make_exec()
out = ex.recycle_vm(self._task())
self.assertEqual(out["state"], "recycled")
def test_recycle_persistent_vm_sync_failed_keeps_disks(self):
ex = self._make_exec()
self._mark_vm_running(ex, vm_name="chv-persist-proj1")
self.push_rc = 1
out = ex.recycle_persistent_vm({"id": "chv-persist-proj1"})
self.assertEqual(out["state"], "sync_failed")
self.assertEqual(out["status"], "sync_failed")
self.assertTrue(out["vm_retained"])
self.assertEqual(out["deleted_disks"], [])
self.assertFalse(self.proc.terminated)
self.assertNotIn("vm_persistent_recycled",
self._audit_events())
self.assertIn("vm_recycle_sync_failed", self._audit_events())
def test_recycle_vm_missing_id_still_raises(self):
ex = self._make_exec()
with self.assertRaises(ValueError):
ex.recycle_vm({})
# -- 13. 审计事件目录 ------------------------------------------------------
class HybridEventsTestCase(unittest.TestCase):
def test_design_event_names_verbatim(self):
# 事件名与 DESIGN-HYBRID.md §9.1-13 原文一字不差。
self.assertEqual(hybrid_events.HYBRID_EVENTS, (
"warm_pool_acquire",
"warm_pool_miss",
"warm_pool_release",
"vm_resident_suspended",
"vm_resident_demoted",
"vm_resident_promoted",
"resident_upgrade_hint",
"snapshot_tenant_mismatch",
"vm_restore_denied",
))
def test_every_event_requires_host_id(self):
for event in hybrid_events.HYBRID_EVENTS:
self.assertIn("host_id",
hybrid_events.REQUIRED_FIELDS[event],
event)
self.assertIn(
"host_id",
hybrid_events.REQUIRED_FIELDS[
hybrid_events.VM_RECYCLE_SYNC_FAILED])
def test_build_event_defaults_host_id(self):
fields = hybrid_events.build_event(
"warm_pool_miss", {"tenant_id": "t1"})
self.assertEqual(fields["host_id"], socket.gethostname())
self.assertEqual(fields["tenant_id"], "t1")
def test_build_event_rejects_missing_required(self):
with self.assertRaises(ValueError):
hybrid_events.build_event("warm_pool_acquire", {})
with self.assertRaises(ValueError):
hybrid_events.build_event(
"vm_resident_suspended",
{"host_id": "h1", "tenant_id": "t1"})
with self.assertRaises(ValueError):
hybrid_events.build_event("no_such_event",
{"host_id": "h1"})
def test_secret_field_rejected_without_leaking_value(self):
sentinel = "SECRET-TOKEN-XYZ-DO-NOT-LEAK"
with self.assertRaises(ValueError) as ctx:
hybrid_events.build_event(
"warm_pool_acquire",
{"vm": "chv-x", "GITEA_TOKEN": sentinel})
self.assertNotIn(sentinel, str(ctx.exception))
self.assertIn("GITEA_TOKEN", str(ctx.exception))
def test_format_detail_has_host_id_and_no_secret(self):
detail = hybrid_events.format_detail(
"vm_resident_promoted",
{"tenant_id": "t1", "vm": "chv-persist-t1"})
self.assertIn("host_id=%s" % socket.gethostname(), detail)
self.assertIn("vm=chv-persist-t1", detail)
self.assertIn("tenant_id=t1", detail)
# -- 10. orphans facade 核实 ----------------------------------------------
class OrphansFacadeTestCase(unittest.TestCase):
def test_facade_exists_and_cleans_only_orphans(self):
# facade 已存在:读 registry → 只清理 warm/assigned
# 条目(只清理、不分配),非法条目丢弃不连坐。
with tempfile.TemporaryDirectory() as tmp:
registry = {
"warm-1": {"state": "warm", "vm": "chv-a",
"task_id": "t1"},
"asg-1": {"state": "assigned", "vm": "chv-b",
"task_id": "t2"},
"bad-1": {"state": "warm"},
"dead-1": {"state": "recycled", "vm": "chv-c"},
}
with open(os.path.join(tmp, "warm-pool.json"),
"w") as fh:
json.dump(registry, fh)
class Exec:
state_dir = tmp
cleaned = []
def shutdown_vm(self, vm):
self.cleaned.append(vm)
return True
def _emit(self, event, task, detail, kind=None):
pass
ex = Exec()
cleaned = reconcile_orphans_for_executor(ex)
self.assertEqual(cleaned, ["chv-a", "chv-b"])
self.assertEqual(ex.cleaned, ["chv-a", "chv-b"])
with open(os.path.join(tmp, "warm-pool.json")) as fh:
self.assertEqual(json.load(fh), {})
def test_facade_quarantines_corrupt_registry(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "warm-pool.json")
with open(path, "w") as fh:
fh.write("{not json")
class Exec:
state_dir = tmp
def shutdown_vm(self, vm):
raise AssertionError("must not be called")
self.assertEqual(reconcile_orphans_for_executor(Exec()), [])
self.assertTrue(os.path.exists(path + ".corrupt"))
def test_warm_pool_still_not_wired_to_dispatch(self):
from cloud.chv.experimental_pool import WarmPool
self.assertIs(WarmPool.wired_to_dispatch, False)
def test_orphans_module_does_not_import_experimental_pool(self):
# 生产 facade 不 import 实验模块(ADR-12):全新解释器
# 里只 import orphans,experimental_pool 不得被加载。
code = (
"import cloud.chv.orphans, sys;"
" assert 'cloud.chv.experimental_pool' not in sys.modules;"
" print('orphans-clean')"
)
proc = subprocess.run(
[sys.executable, "-c", code],
capture_output=True, text=True, cwd=str(
Path(__file__).resolve().parents[2]))
self.assertEqual(proc.returncode, 0, proc.stderr)
self.assertIn("orphans-clean", proc.stdout)
if __name__ == "__main__":
unittest.main()