ForcePilot/backend/package/yuxi/channel/secrets/atomic.py
Kris 3ce713eb9e feat(secrets): 新增完整的密钥管理工具链
新增了包括密钥加解密、配置健康检查、原子写入、审计、解析器以及 scrubber 在内的完整 secrets 模块,实现了明文密钥检测替换、密钥引用解析和配置安全校验能力
2026-05-21 10:32:14 +08:00

138 lines
4.7 KiB
Python

from __future__ import annotations
import hashlib
import json
import logging
from collections.abc import Callable
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Any
logger = logging.getLogger(__name__)
class AtomicWritePhase(StrEnum):
PLAN = "plan"
PROJECT = "project"
VALIDATE = "validate"
APPLY = "apply"
@dataclass
class ConfigFileSnapshot:
path: str
raw: str
config_hash: str
@classmethod
def capture(cls, path: str, raw: str) -> ConfigFileSnapshot:
config_hash = hashlib.sha256(raw.encode()).hexdigest()[:12]
return cls(path=path, raw=raw, config_hash=config_hash)
@dataclass
class AtomicWritePlan:
phase: AtomicWritePhase = AtomicWritePhase.PLAN
target_state: dict[str, Any] = field(default_factory=dict)
changes: list[dict[str, Any]] = field(default_factory=list)
issues: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
snapshots: dict[str, ConfigFileSnapshot] = field(default_factory=dict)
dry_run: bool = True
@property
def ok(self) -> bool:
return len(self.issues) == 0
@dataclass
class AtomicWriteResult:
success: bool
phase: AtomicWritePhase
issues: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
applied_changes: int = 0
@classmethod
def ok(cls, phase: AtomicWritePhase, applied: int = 0, warnings: list[str] | None = None):
return cls(success=True, phase=phase, applied_changes=applied, warnings=warnings or [])
@classmethod
def failed(cls, phase: AtomicWritePhase, issues: list[str], warnings: list[str] | None = None):
return cls(success=False, phase=phase, issues=issues, warnings=warnings or [])
async def atomic_write_config(
plan: AtomicWritePlan,
apply_fn: Callable[[dict[str, Any]], Any],
validate_fn: Callable[[dict[str, Any]], list[str]] | None = None,
capture_fn: Callable[[], dict[str, ConfigFileSnapshot]] | None = None,
restore_fn: Callable[[dict[str, ConfigFileSnapshot]], Any] | None = None,
) -> AtomicWriteResult:
"""Plan → Project → Validate → Apply with snapshot/rollback."""
# Phase 1: Plan — generate target state
plan.phase = AtomicWritePhase.PLAN
if not plan.target_state:
return AtomicWriteResult.failed(AtomicWritePhase.PLAN, ["No target state provided"])
# Phase 2: Project — simulate changes, capture snapshots
plan.phase = AtomicWritePhase.PROJECT
if capture_fn:
plan.snapshots = capture_fn()
# Phase 3: Validate — pre-check target state
plan.phase = AtomicWritePhase.VALIDATE
if validate_fn:
validation_issues = validate_fn(plan.target_state)
if validation_issues:
plan.issues.extend(validation_issues)
return AtomicWriteResult.failed(
AtomicWritePhase.VALIDATE,
validation_issues,
plan.warnings,
)
if plan.dry_run:
logger.info(
"AtomicWrite: dry-run complete, planned %d changes, %d issues, %d warnings",
len(plan.changes),
len(plan.issues),
len(plan.warnings),
)
return AtomicWriteResult.ok(AtomicWritePhase.VALIDATE, warnings=plan.warnings)
# Phase 4: Apply — execute with rollback on failure
plan.phase = AtomicWritePhase.APPLY
try:
await _maybe_await(apply_fn, plan.target_state)
applied = len(plan.changes)
logger.info("AtomicWrite: applied %d changes successfully", applied)
return AtomicWriteResult.ok(AtomicWritePhase.APPLY, applied=applied, warnings=plan.warnings)
except Exception as e:
logger.exception("AtomicWrite: apply failed, rolling back")
apply_issues: list[str] = [f"Apply failed: {e}"]
if restore_fn and plan.snapshots:
try:
await _maybe_await(restore_fn, plan.snapshots)
logger.info("AtomicWrite: rollback successful")
except Exception as rollback_err:
logger.exception("AtomicWrite: rollback failed: %s", rollback_err)
apply_issues.insert(0, f"Rollback failed: {rollback_err}")
return AtomicWriteResult.failed(AtomicWritePhase.APPLY, apply_issues, plan.warnings)
async def _maybe_await(fn: Callable, *args, **kwargs) -> Any:
import inspect
result = fn(*args, **kwargs)
if inspect.isawaitable(result):
return await result
return result
def capture_db_snapshot(config: dict[str, Any]) -> ConfigFileSnapshot:
raw = json.dumps(config, sort_keys=True, default=str, ensure_ascii=False)
config_hash = hashlib.sha256(raw.encode()).hexdigest()[:12]
return ConfigFileSnapshot(path="db:channel_config", raw=raw, config_hash=config_hash)