Files
codex-app-server/cloud/tests/test_chv_p1fix.py
T

731 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""CHV 生产级审核 P1 修复轨(feat-chv-p1fix,堆叠于 #270 之上)测试。
逐项对应审核报告 §5 的 P1-01~P1-10(P1-10 是文档项,
无代码测试;文档数字见 docs/chv-production-readiness.md
§15 与 docs/chv-architecture.md 头范围):
- P1-01:生产 reconcile 失败项不进 cleaned、不清库、
落盘保留失败项原始条目、可重试;实验池同款语义。
- P1-02:registry 损坏与非法条目发审计并计数,损坏不
静默返回空。
- P1-03:残留私钥删失败发审计、写脏标记,调用方可区分
成功与失败(failed VM 不进 cleaned)。
- P1-04:build_ssh_command 删除回环回落,构造失败即抛;
显式回环(CHV_SSH_HOST / CHV_SSH_FORWARD=1)仍可用。
- P1-05:state_dir flock(locking.StateLock)——线程内
互斥、跨进程互斥、wake 全程串行化、purge 快照遍历。
- P1-06:单条快照超总量帽时拒绝记录,且不删旧快照、
不落盘新条目。
- P1-07:CHV_CPU / CHV_MEM / CHV_TASK_TIMEOUT 的 >0
校验(config 构造层 + preflight 事后改写兜底)。
- P1-08:快照保留期剪枝删失败发审计;snapshot_vm 失败
路径发 vm_snapshot_failed 审计。
- P1-09:4 处三元 else 分支——str 形态 pause/resume/
snapshot + 非 dict spill task。
全部用标准库与 mock runner/launcher/api_client,绝不启动
真实 VM(本机无 /dev/kvm,真机验证仍是未验证项)。
"""
import json
import os
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
from unittest import mock
try:
from cloud.chv import (
ChvFailure,
CloudHypervisorExecutor,
reconcile_orphans_for_executor,
)
from cloud.chv.config import build_chv_config
from cloud.chv.disk import (
prune_snapshots,
record_snapshot,
)
from cloud.chv.locking import StateLock, lock_path_for_state_dir
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.chv import (
ChvFailure,
CloudHypervisorExecutor,
reconcile_orphans_for_executor,
)
from cloud.chv.config import build_chv_config
from cloud.chv.disk import (
prune_snapshots,
record_snapshot,
)
from cloud.chv.locking import StateLock, lock_path_for_state_dir
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
def poll(self):
return None if self._alive else 1
def terminate(self):
self._alive = False
def wait(self, timeout=None):
return 0
def kill(self):
self._alive = False
def _write(path, content=b"x"):
with open(path, "wb") as fh:
fh.write(content)
os.chmod(path, 0o755)
return path
class P1FixCase(unittest.TestCase):
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 = []
def tearDown(self):
self.tmp.cleanup()
def make_exec(self, runner=None, api_client=None, launcher=None,
env_extra=None, **overrides):
env = {"CHV_EGRESS_PROXY": "http://10.0.0.1:8888",
"CHV_SSH_FORWARD": "1",
"CHV_HOST_KEY_MODE": "keyscan"}
if env_extra:
env.update(env_extra)
if runner is None:
runner = self._default_runner
if api_client is None:
api_client = self._default_api
if launcher is None:
launcher = self._default_launcher
kwargs = dict(
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=runner, launcher=launcher, api_client=api_client,
sleeper=lambda s: None, env=env)
kwargs.update(overrides)
ex = CloudHypervisorExecutor(**kwargs)
ex.audit_callback = lambda *a: self.audit.append(a)
return ex
def _default_runner(self, cmd, timeout=None):
self.calls.append(list(cmd))
return FakeResult(0, "", "")
def _default_api(self, socket_path, method, path, payload=None):
return (200, {})
def _default_launcher(self, cmd):
return FakeProc(alive=True)
def _task(self, task_id="abc123"):
return {"id": task_id, "prompt": "x", "project_id": "proj1"}
def _audit_events(self):
return [a[0] for a in self.audit]
# -- P1-01 / P1-02:生产 reconcile --------------------------------------
class FakeProdExecutor:
def __init__(self, state_dir, fail_vms=()):
self.state_dir = state_dir
self.fail_vms = set(fail_vms)
self.shutdowns = []
self.emits = []
def shutdown_vm(self, vm):
self.shutdowns.append(vm)
if vm in self.fail_vms:
raise RuntimeError("shutdown down")
def _emit(self, event, task, detail):
self.emits.append(event)
def _write_registry(state_dir, data):
path = os.path.join(state_dir, "warm-pool.json")
os.makedirs(state_dir, exist_ok=True)
if isinstance(data, str):
with open(path, "w") as fh:
fh.write(data)
else:
with open(path, "w") as fh:
json.dump(data, fh)
return path
class ReconcileP1FixTest(unittest.TestCase):
def test_failure_not_cleaned_registry_retained_and_retryable(self):
with tempfile.TemporaryDirectory() as d:
path = _write_registry(d, {
"a": {"state": "warm", "vm": "chv-good"},
"b": {"state": "warm", "vm": "chv-bad"},
})
ex = FakeProdExecutor(d, fail_vms={"chv-bad"})
self.assertEqual(reconcile_orphans_for_executor(ex),
["chv-good"])
self.assertIn("warm_pool_reconcile_failed", ex.emits)
with open(path) as fh:
remaining = json.load(fh)
self.assertEqual(
remaining,
{"b": {"state": "warm", "vm": "chv-bad"}})
# 修复后重试:失败项可被清理,库最终为空。
ex.fail_vms = set()
self.assertEqual(reconcile_orphans_for_executor(ex),
["chv-bad"])
with open(path) as fh:
self.assertEqual(json.load(fh), {})
def test_corrupt_registry_audited_not_silent(self):
with tempfile.TemporaryDirectory() as d:
path = _write_registry(d, "{not json")
ex = FakeProdExecutor(d)
self.assertEqual(reconcile_orphans_for_executor(ex), [])
self.assertIn("warm_pool_registry_corrupt", ex.emits)
self.assertTrue(os.path.exists(path + ".corrupt"))
def test_invalid_entries_audited_and_counted(self):
with tempfile.TemporaryDirectory() as d:
path = _write_registry(d, {
"a": {"state": "warm", "vm": "chv-a"},
"c": {"state": "warm"},
"e": {"state": "running", "vm": "chv-e"},
})
ex = FakeProdExecutor(d)
self.assertEqual(reconcile_orphans_for_executor(ex),
["chv-a"])
self.assertIn("warm_pool_registry_invalid_entries",
ex.emits)
with open(path) as fh:
self.assertEqual(json.load(fh), {})
# -- P1-03:残留私钥删失败 ----------------------------------------------
class StaleSecretsP1FixTest(P1FixCase):
def test_delete_failure_not_cleaned_audited_and_dirty_marked(self):
from cloud.chv.hostkeys import dirty_marker_path
ex = self.make_exec()
vm_dir = os.path.join(self.state_dir, "chv-bad")
os.makedirs(vm_dir, exist_ok=True)
priv = os.path.join(vm_dir, "ssh_host_ed25519_key")
with open(priv, "w") as fh:
fh.write("PRIVATE")
real_remove = os.remove
def remove(path):
if path == priv:
raise OSError("删不掉")
return real_remove(path)
with mock.patch.object(os, "remove", side_effect=remove):
cleaned = ex.cleanup_stale_secrets()
self.assertNotIn("chv-bad", cleaned)
self.assertIn("host_secret_residue_delete_failed",
self._audit_events())
self.assertTrue(os.path.exists(
dirty_marker_path(self.state_dir, "chv-bad")))
self.assertTrue(os.path.exists(priv))
def test_delete_success_cleaned(self):
ex = self.make_exec()
vm_dir = os.path.join(self.state_dir, "chv-ok")
os.makedirs(vm_dir, exist_ok=True)
with open(os.path.join(vm_dir, "ssh_host_ed25519_key"),
"w") as fh:
fh.write("PRIVATE")
cleaned = ex.cleanup_stale_secrets()
self.assertIn("chv-ok", cleaned)
self.assertIn("host_secret_residue_cleaned",
self._audit_events())
# -- P1-04:SSH 回落删除 -------------------------------------------------
class SshFallbackP1FixTest(P1FixCase):
def test_undeclared_build_raises_not_loopback(self):
ex = self.make_exec(env_extra={"CHV_SSH_FORWARD": "0"})
with self.assertRaises(ChvFailure) as ctx:
ex.build_ssh_command(self._task(), ["true"])
self.assertEqual(ctx.exception.kind, "ssh_target_undeclared")
def test_explicit_loopback_still_allowed(self):
ex = self.make_exec(env_extra={"CHV_SSH_HOST": "127.0.0.1"})
cmd = ex.build_ssh_command(self._task(), ["true"])
self.assertIn("root@127.0.0.1", " ".join(cmd))
ex2 = self.make_exec(env_extra={"CHV_SSH_FORWARD": "1"})
cmd2 = ex2.build_ssh_command(self._task(), ["true"])
self.assertIn("root@127.0.0.1", " ".join(cmd2))
# -- P1-05:flock ---------------------------------------------------------
class StateLockP1FixTest(unittest.TestCase):
def test_thread_mutual_exclusion(self):
with tempfile.TemporaryDirectory() as d:
state_dir = os.path.join(d, "state")
lock_a = StateLock(state_dir)
lock_b = StateLock(state_dir)
acquired = []
lock_a.acquire()
try:
def _take():
# RLock 须由加锁线程释放,故获取与释放同线程。
lock_b.acquire()
acquired.append(True)
lock_b.release()
t = threading.Thread(target=_take)
t.start()
time.sleep(0.3)
self.assertEqual(acquired, [])
self.assertTrue(t.is_alive())
finally:
lock_a.release()
t.join(timeout=5)
self.assertEqual(acquired, [True])
self.assertTrue(os.path.exists(
lock_path_for_state_dir(state_dir)))
def test_cross_process_mutual_exclusion(self):
with tempfile.TemporaryDirectory() as d:
state_dir = os.path.join(d, "state")
os.makedirs(state_dir, exist_ok=True)
root = str(Path(__file__).resolve().parents[2])
ready = os.path.join(state_dir, "child-locked")
script = (
"import sys, time; sys.path.insert(0, %r); "
"from cloud.chv.locking import StateLock; "
"lock = StateLock(%r); lock.acquire(); "
"open(%r, 'w').write('locked'); "
"time.sleep(10)" % (root, state_dir, ready))
proc = subprocess.Popen(
[sys.executable, "-c", script],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
deadline = time.time() + 10
while not os.path.exists(ready) and time.time() < deadline:
time.sleep(0.05)
self.assertTrue(os.path.exists(ready))
self.assertIsNone(proc.poll())
lock = StateLock(state_dir)
acquired = []
def _take():
lock.acquire()
acquired.append(True)
lock.release()
t = threading.Thread(target=_take)
t.start()
time.sleep(0.5)
# 子进程持锁期间,本进程的加锁必须阻塞。
self.assertEqual(acquired, [])
proc.terminate()
proc.wait(timeout=5)
t.join(timeout=5)
self.assertEqual(acquired, [True])
finally:
if proc.poll() is None:
proc.kill()
proc.wait(timeout=5)
def test_purge_snapshot_traversal_under_concurrent_mutation(self):
# 旧无锁 pop + 持锁遍历并发可致字典迭代异常;新实现
# 快照键列表后逐项持锁删,并发变更加锁路径下不得抛错。
with tempfile.TemporaryDirectory():
case = P1FixCase()
case.setUp()
try:
ex = case.make_exec()
ex.state_dir = case.state_dir
stop = threading.Event()
def mutate():
i = 0
while not stop.is_set():
name = "chv-m%d" % (i % 5)
with ex._vms_lock:
ex._vms[name] = FakeProc(alive=False)
i += 1
t = threading.Thread(target=mutate)
t.start()
try:
for _ in range(50):
ex._purge_dead_registry()
finally:
stop.set()
t.join(timeout=5)
finally:
case.tearDown()
class WakeSerializationP1FixTest(P1FixCase):
def test_concurrent_wakes_serialized_by_state_lock(self):
# 两路并发 wake:每路 api 往返睡 0.2 秒;串行则总耗时
# 至少 0.35 秒,并行则约 0.2 秒。阈值留足余量防抖动。
def sleeping_api(socket_path, method, path, payload=None):
if path == "/vmm.ping":
time.sleep(0.2)
return (200, {})
ex1 = self.make_exec(api_client=sleeping_api)
ex2 = self.make_exec(api_client=sleeping_api)
results = []
start = time.time()
def wake(ex, task_id):
try:
results.append(ex.wake_vm({"id": task_id})["state"])
except Exception as exc: # noqa: BLE001 - 失败即测试失败
results.append("error:%s" % exc)
t1 = threading.Thread(target=wake, args=(ex1, "s1"))
t2 = threading.Thread(target=wake, args=(ex2, "s2"))
t1.start()
t2.start()
t1.join(timeout=30)
t2.join(timeout=30)
elapsed = time.time() - start
self.assertEqual(sorted(results), ["running", "running"])
self.assertGreaterEqual(elapsed, 0.35)
self.assertTrue(os.path.exists(
lock_path_for_state_dir(self.state_dir)))
# -- P1-06:单条快照超帽 ---------------------------------------------------
class SingleSnapshotCapP1FixTest(unittest.TestCase):
def test_single_oversize_snapshot_rejected_without_side_effects(self):
with tempfile.TemporaryDirectory() as tmp:
old = os.path.join(tmp, "vm1", "old.snap")
os.makedirs(os.path.join(tmp, "vm1"), exist_ok=True)
with open(old, "wb") as fh:
fh.write(b"O" * 100)
from cloud.chv.disk import _save_snapshots
_save_snapshots(tmp, "vm1", [{
"url": "file://" + old, "path": old, "size": 100,
"created_at": 0}])
new = os.path.join(tmp, "vm1", "new.snap")
with open(new, "wb") as fh:
fh.write(b"N" * 1000)
# 总量帽 500 字节:新单条 1000 字节本身已超帽。
with self.assertRaises(ChvFailure) as ctx:
record_snapshot(tmp, "vm1", "file://" + new,
500 / (1024 ** 3))
self.assertEqual(ctx.exception.kind,
"snapshot_quota_exceeded")
# 拒绝无副作用:旧快照文件仍在、登记仍只有旧条目。
self.assertTrue(os.path.exists(old))
from cloud.chv.disk import _load_snapshots
entries = _load_snapshots(tmp, "vm1")
self.assertEqual(len(entries), 1)
self.assertEqual(entries[0]["path"], old)
# -- P1-07:>0 校验 ---------------------------------------------------------
class PositiveConfigP1FixTest(P1FixCase):
def test_config_layer_rejects_non_positive(self):
base_env = {}
for env_extra, field in (
({"CHV_CPU": "0"}, "cpu"),
({"CHV_CPU": "-2"}, "cpu"),
({"CHV_MEM": "0"}, "mem"),
({"CHV_MEM": "0G"}, "mem"),
({"CHV_TASK_TIMEOUT": "0"}, "timeout"),
({"CHV_TASK_TIMEOUT": "-5"}, "timeout")):
env = dict(base_env)
env.update(env_extra)
with self.assertRaises(ValueError, msg=field):
build_chv_config({}, env)
# 合法值与 None(不限)不受影响。
cfg = build_chv_config({}, {"CHV_TASK_TIMEOUT": "30"})
self.assertEqual(cfg.task_timeout, 30.0)
cfg = build_chv_config({}, {})
self.assertIsNone(cfg.task_timeout)
def test_preflight_rejects_tampered_attributes(self):
for attr, value in (("cpus", "0"), ("cpus", "-1"),
("memory", "0"), ("task_timeout", 0),
("task_timeout", -3)):
ex = self.make_exec()
setattr(ex, attr, value)
with self.assertRaises(ChvFailure) as ctx:
ex.preflight()
self.assertEqual(ctx.exception.kind, "config_invalid")
# -- P1-08:剪枝审计与快照失败审计 -----------------------------------------
class SnapshotAuditP1FixTest(P1FixCase):
def test_retention_prune_delete_failure_audited(self):
ex = self.make_exec()
vm_dir = os.path.join(self.state_dir, "chv-abc123")
os.makedirs(vm_dir, exist_ok=True)
old = os.path.join(self.state_dir, "old.snap")
with open(old, "wb") as fh:
fh.write(b"O" * 10)
with open(os.path.join(vm_dir, "snapshots.json"), "w") as fh:
json.dump([{"url": "file://" + old, "path": old,
"size": 10, "created_at": 0}], fh)
new = os.path.join(self.state_dir, "new.snap")
with open(new, "wb") as fh:
fh.write(b"N" * 10)
real_remove = os.remove
def remove(path):
if path == old:
raise OSError("删不掉")
return real_remove(path)
with mock.patch.object(os, "remove", side_effect=remove):
out = ex.snapshot_vm(self._task(), "file://" + new)
self.assertEqual(out, "file://" + new)
self.assertIn("vm_snapshot_prune_failed",
self._audit_events())
self.assertTrue(os.path.exists(old))
def test_snapshot_failure_audited(self):
def failing_runner(cmd, timeout=None):
if cmd and cmd[0] == "ch-remote":
raise RuntimeError("ch-remote 挂了")
return FakeResult(0, "", "")
ex = self.make_exec(runner=failing_runner)
# 目标须在 state_dir 内才过白名单(ch-remote 失败在后)。
target = "file://" + os.path.join(self.state_dir, "x.snap")
with self.assertRaises(RuntimeError):
ex.snapshot_vm(self._task(), target)
self.assertIn("vm_snapshot_failed", self._audit_events())
# -- P1-09:4 处三元 else 分支 ----------------------------------------------
class BranchCoverP1FixTest(P1FixCase):
def test_pause_resume_snapshot_str_forms(self):
ex = self.make_exec()
os.makedirs(self.state_dir, exist_ok=True)
self.assertTrue(ex.pause_vm("chv-abc"))
self.assertTrue(ex.resume_vm("chv-abc"))
snap = os.path.join(self.state_dir, "snap.raw")
with open(snap, "wb") as fh:
fh.write(b"S" * 10)
out = ex.snapshot_vm("chv-abc", "file://" + snap)
self.assertEqual(out, "file://" + snap)
self.assertIn("vm_pause", self._audit_events())
self.assertIn("vm_resume", self._audit_events())
self.assertIn("vm_snapshot", self._audit_events())
def test_spill_audit_non_dict_task(self):
ex = self.make_exec()
os.makedirs(self.state_dir, exist_ok=True)
ex._spill_audit("ev", "plain-task-id", "detail", None)
path = os.path.join(self.state_dir, "chv-audit-spill.jsonl")
with open(path) as fh:
row = json.loads(fh.readline())
self.assertEqual(row["task_id"], "plain-task-id")
self.assertEqual(row["event"], "ev")
# -- 覆盖补齐:新分支的行覆盖(诚实口径,不凑数改代码) --------
class CoverageCompletionP1FixTest(P1FixCase):
def test_prune_events_no_file_and_no_delete(self):
from cloud.chv.disk import _save_snapshots
with tempfile.TemporaryDirectory() as tmp:
_save_snapshots(tmp, "vm1", [
{"url": "file:///gone.snap", "path": "/gone.snap",
"size": 5, "created_at": 0},
{"url": "https://example.invalid/x", "path": None,
"size": 5, "created_at": 0}])
events = []
kept, pruned = prune_snapshots(
tmp, "vm1", retention_days=30, now=10 ** 9,
events=events)
self.assertEqual(kept, [])
self.assertEqual(len(pruned), 2)
kinds = [e[0] for e in events]
self.assertEqual(kinds, ["pruned_no_file", "pruned_no_file"])
events2 = []
_save_snapshots(tmp, "vm1", [
{"url": "file:///gone2.snap", "path": "/gone2.snap",
"size": 5, "created_at": 0}])
kept2, _ = prune_snapshots(
tmp, "vm1", retention_days=30, now=10 ** 9,
delete_files=False, events=events2)
self.assertEqual(kept2, [])
self.assertEqual([e[0] for e in events2],
["pruned_no_file"])
def test_record_without_cap_and_tighten_delete_failure_event(self):
with tempfile.TemporaryDirectory() as tmp:
entries, removed = record_snapshot(
tmp, "vm1", "https://example.invalid/new", None)
self.assertEqual(len(entries), 1)
self.assertEqual(removed, [])
old = os.path.join(tmp, "vm1", "old.snap")
os.makedirs(os.path.join(tmp, "vm1"), exist_ok=True)
with open(old, "wb") as fh:
fh.write(b"O" * 100)
from cloud.chv.disk import _save_snapshots
_save_snapshots(tmp, "vm1", [
{"url": "file://" + old, "path": old, "size": 100,
"created_at": 0}])
real_remove = os.remove
def remove(path):
if path == old:
raise OSError("删不掉")
return real_remove(path)
events = []
with mock.patch.object(os, "remove", side_effect=remove):
entries2, removed2 = record_snapshot(
tmp, "vm1", "https://example.invalid/new2",
0.0, events=events)
self.assertEqual(len(removed2), 1)
self.assertEqual([e[0] for e in events], ["delete_failed"])
self.assertTrue(os.path.exists(old))
def test_tighten_no_file_entry_event(self):
from cloud.chv.disk import _save_snapshots
with tempfile.TemporaryDirectory() as tmp:
_save_snapshots(tmp, "vm1", [
{"url": "https://example.invalid/old", "path": None,
"size": 100, "created_at": 0}])
events = []
entries, removed = record_snapshot(
tmp, "vm1", "https://example.invalid/new",
0.0, events=events)
self.assertEqual(len(removed), 1)
self.assertEqual([e[0] for e in events],
["pruned_no_file"])
def test_state_lock_ctx_refreshes_on_state_dir_change(self):
ex = self.make_exec()
old_lock = ex._state_lock
new_dir = os.path.join(self.dir, "state2")
ex.state_dir = new_dir
self.assertIsNot(ex._state_lock_ctx(), old_lock)
self.assertEqual(ex._state_lock.state_dir, new_dir)
def test_gc_and_secrets_listdir_failure_returns_empty(self):
ex = self.make_exec()
ex.state_dir = os.path.join(self.dir, "missing-dir")
self.assertEqual(ex.gc_stale_disks(),
{"deleted": [], "failed": []})
self.assertEqual(ex.cleanup_stale_secrets(), [])
def test_cleanup_listdir_failure_returns_empty(self):
ex = self.make_exec()
os.makedirs(self.state_dir, exist_ok=True)
real_listdir = os.listdir
calls = {"n": 0}
def listdir(path):
calls["n"] += 1
if calls["n"] >= 3:
raise OSError("listdir 挂了")
return real_listdir(path)
with mock.patch.object(os, "listdir", side_effect=listdir):
self.assertEqual(ex.cleanup_stale_vms(), [])
def test_orphan_entries_compat_wrapper_and_no_emit_executor(self):
from cloud.chv.orphans import _orphan_entries
data = {"a": {"state": "warm", "vm": "chv-a",
"task_id": "a", "snapshot_url": None},
"b": {"state": "running", "vm": "chv-b"}}
self.assertEqual(_orphan_entries(data),
[{"vm": "chv-a", "task_id": "a",
"snapshot_url": None}])
class NoEmit:
def __init__(self, state_dir):
self.state_dir = state_dir
self.shutdowns = []
def shutdown_vm(self, vm):
self.shutdowns.append(vm)
with tempfile.TemporaryDirectory() as d:
_write_registry(d, {
"a": {"state": "warm", "vm": "chv-a"}})
ex = NoEmit(d)
self.assertEqual(reconcile_orphans_for_executor(ex),
["chv-a"])
def test_state_lock_flock_failure_closes_and_releases(self):
import fcntl
with tempfile.TemporaryDirectory() as d:
state_dir = os.path.join(d, "state")
lock = StateLock(state_dir)
with mock.patch.object(fcntl, "flock",
side_effect=OSError("flock 挂了")):
with self.assertRaises(OSError):
lock.acquire()
self.assertIsNone(lock._fd)
# 失败后锁仍可用(RLock 已释放、fd 已关闭)。
lock.acquire()
lock.release()
def test_pool_reconcile_failed_emit_failure_swallowed(self):
from cloud.chv.experimental_pool import WarmPool
stub = _PoolStubForP1Fix()
pool = WarmPool(stub, registry_path=None)
pool._entries["old-1"] = {"state": "orphan", "vm": "vm-old",
"task": {"id": "old-1"}}
self.assertEqual(pool.reconcile_orphans(), [])
self.assertIn("old-1", pool._entries)
class _PoolStubForP1Fix:
"""实验池失败审计吞错测试用假执行器。"""
def shutdown_vm(self, task_or_name):
raise RuntimeError("shutdown 失败")
def _emit(self, event, task, detail="", kind=None):
raise RuntimeError("audit 失败")
if __name__ == "__main__":
unittest.main()