"""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)