Files
codex-app-server/cloud/chv/hybrid_events.py
T
Muse 39c51b7bf2 feat(chv-hybrid-c): autoscaler 池目标数 + 回收同步门 + 审计事件目录
- autoscaler: desired_workers += pool_target(W=2 台/宿主,
  pool_target_for_hosts);Autoscaler/CLI 支持 --pool-target /
  --online-hosts;L1 其余语义(30s 轮、scale up fast/down slow、
  fail-closed、veto、LIFO)原样复用,L2 不自动加主机
- executor: recycle_vm/recycle_persistent_vm 前加 Gitea 同步门
  (chv-sync push 失败即 state/status=sync_failed、VM 不回收、
  记 vm_recycle_sync_failed 审计);未配同步/VM 已死走旧路径
- 新增 cloud/chv/hybrid_events.py:9 个设计事件名一字不差,
  全部必填 host_id、密钥字段名拒绝入目录
- orphans facade 已存在且复用 registry/orphan 语义,不动
  experimental_pool(wired_to_dispatch 恒 False)
- 测试:cloud/tests/test_chv_hybrid_c.py 32 项;全量 4611 项
  0 失败 0 错误 15 skipped
2026-11-08 21:24:46 +00:00

147 lines
5.9 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 混合模式审计事件目录(C 轨,feat-chv-hybrid-c)。
唯一依据:~/workspace/chv-multi-host/DESIGN-HYBRID.md
§7 与 §9.1 第 13 项。事件名字符串与设计原文一字不差,
供 A/B 两轨的发射点 import(A/B 工作树里还没有本文件,
他们先按设计原文的事件名字符串直接发射,协调员集成时
统一收到本模块名下)。
规则(设计 §7):
- 全部事件带 host_id(宿主名,默认 socket.gethostname())。
- 绝不带密钥值:字段名命中密钥名(见 SECRET_FIELD_NAMES)
即拒绝,错误信息只含字段名、不含值;调用方仍须遵守
executor._emit 的既有契约(detail 只含 VM 名、路径、
URL、kind,不含任何 key 值)。
- 本模块只定名 + 必填字段 + 校验/格式化,不发事件、
不读库、不碰 VM;发射仍走各轨现有的 _emit / 日志路径。
另有一枚 C 轨事件 vm_recycle_sync_failed:不在 §9.1-13
的名单里,但 §3.3 要求“同步失败 → 任务标同步失败
(审计)”,回收同步失败的审计须有个名字,故在此单列
并注明来源,不混进设计名单。
"""
import socket
# -- 设计原文事件名(§9.1 第 13 项 / §7,一字不差) ------------
WARM_POOL_ACQUIRE = "warm_pool_acquire"
WARM_POOL_MISS = "warm_pool_miss"
WARM_POOL_RELEASE = "warm_pool_release"
VM_RESIDENT_SUSPENDED = "vm_resident_suspended"
VM_RESIDENT_DEMOTED = "vm_resident_demoted"
VM_RESIDENT_PROMOTED = "vm_resident_promoted"
RESIDENT_UPGRADE_HINT = "resident_upgrade_hint"
SNAPSHOT_TENANT_MISMATCH = "snapshot_tenant_mismatch"
VM_RESTORE_DENIED = "vm_restore_denied"
# 设计 §9.1-13 名单一共 9 个事件(acquire/miss/release
# 是 §7“warm_pool_acquire/miss/release”的三枚展开,
# miss 的全名 warm_pool_miss 见 §7“须记 warm_pool_miss
# 审计”)。
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,
)
# -- C 轨回收同步审计(§3.3,非 §9.1-13 名单) ---------------
VM_RECYCLE_SYNC_FAILED = "vm_recycle_sync_failed"
ALL_KNOWN_EVENTS = HYBRID_EVENTS + (VM_RECYCLE_SYNC_FAILED,)
# -- 必填字段:全部事件都须带 host_id -------------------------
# 其余字段按事件语义:vm = VM 名,tenant_id = 租户,
# reason = 失败原因短文本(已截断,绝不含密钥值)。
REQUIRED_FIELDS = {
WARM_POOL_ACQUIRE: ("host_id", "vm"),
WARM_POOL_MISS: ("host_id",),
WARM_POOL_RELEASE: ("host_id", "vm"),
VM_RESIDENT_SUSPENDED: ("host_id", "tenant_id", "vm"),
VM_RESIDENT_DEMOTED: ("host_id", "tenant_id", "vm"),
VM_RESIDENT_PROMOTED: ("host_id", "tenant_id", "vm"),
RESIDENT_UPGRADE_HINT: ("host_id", "tenant_id"),
SNAPSHOT_TENANT_MISMATCH: ("host_id", "tenant_id", "vm"),
VM_RESTORE_DENIED: ("host_id", "tenant_id", "vm"),
VM_RECYCLE_SYNC_FAILED: ("host_id", "vm"),
}
# 密钥字段名:命中即拒绝。exact 匹配 + token/secret/
# password 子串匹配(host_key 等公开指纹名不在此列,
# 不可误拦)。
SECRET_FIELD_NAMES = frozenset({
"gitea_token", "token", "secret", "password", "api_key",
"apikey", "private_key", "access_key", "session_key",
"credential", "credentials", "seed", "auth",
})
_SECRET_SUBSTRINGS = ("token", "secret", "password")
def _is_secret_field(name: str) -> bool:
lowered = str(name).lower()
if lowered in SECRET_FIELD_NAMES:
return True
return any(part in lowered for part in _SECRET_SUBSTRINGS)
def default_host_id() -> str:
"""宿主名:审计事件的 host_id 缺省值。"""
return socket.gethostname()
def validate_event_fields(event: str, fields: dict) -> dict:
"""校验一个事件的字段,返回清洗后的副本(去 None 值)。
未知事件名、缺必填字段、host_id 为空、字段名命中密钥
名时一律 ValueError;错误信息只含事件名/字段名,
绝不含字段值。"""
if event not in ALL_KNOWN_EVENTS:
raise ValueError("未知审计事件:%s" % event)
cleaned = {k: v for k, v in dict(fields or {}).items()
if v is not None}
secret = sorted(k for k in cleaned if _is_secret_field(k))
if secret:
raise ValueError(
"审计事件 %s 不得带密钥字段:%s" % (event, ",".join(secret)))
missing = [k for k in REQUIRED_FIELDS[event]
if k not in cleaned
or (isinstance(cleaned[k], str)
and not cleaned[k].strip())]
if missing:
raise ValueError(
"审计事件 %s 缺必填字段:%s" % (event, ",".join(missing)))
return cleaned
def build_event(event: str, fields: dict | None = None,
host_id: str | None = None) -> dict:
"""组装一个事件的字段 dict:host_id 未给时取本机宿主名,
再过 validate_event_fields。返回的 dict 可直接用于
format_detail 或交审计回调。"""
merged = dict(fields or {})
if host_id is not None:
merged["host_id"] = host_id
elif not merged.get("host_id"):
merged["host_id"] = default_host_id()
return validate_event_fields(event, merged)
def format_detail(event: str, fields: dict | None = None,
host_id: str | None = None) -> str:
"""把事件字段格式化成 executor._emit 的 detail 字符串
("k=v k=v",键排序,值截断 200 字符、去换行)。
先过 build_event 校验,所以带 host_id、不带密钥字段。"""
cleaned = build_event(event, fields, host_id=host_id)
parts = []
for key in sorted(cleaned):
value = str(cleaned[key]).replace("\n", " ").replace("\r", " ")
if len(value) > 200:
value = value[:200]
parts.append("%s=%s" % (key, value))
return " ".join(parts)