Files

264 lines
10 KiB
Python
Raw Permalink 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.
# -*- coding: utf-8 -*-
"""⑤.7 配置台 ↔ 内核配置推送契约 —— issue #67 / PRD ⑤.7。
发布(#66)之后的下一步是**把配置真正送进内核**让 edge-gateway / rag-kb /
model-framework 生效。配置台与内核是两个独立部署单元,二者通过**配置推送契约**
解耦:配置台把一份已发布版本打包为内核可消费的 **JSON manifest**(带校验和),
内核侧拉取/接收后先验完整性再加载。本模块实现这个契约的"配置台侧":
- ``PushManifest``:推送给内核的清单(版本 / 快照 / 校验和 / 生成时间 / 来源);
- ``PushChannel``:推送通道。
- ``build_manifest(release)``:把 Release 打包成 manifest,计算 SHA256 校验和
(对快照做规范 JSON 序列化后哈希,确保内核侧可复算验证);
- ``push(release)``:模拟推送——把 manifest 写到内核预期的接收目录
(``<inbox>/manifest-<version>.json``),并记录推送日志(幂等:同版本不重复推送);
- ``pushed_versions()``:已成功推送的版本清单;
- ``verify(manifest)``:校验 manifest 的校验和是否一致(内核侧或配置台侧复用)。
幂等性:同一版本重复 push 返回已推送的旧记录(不覆盖、不重复写文件),避免内核
重复加载;要重推需先 ``retract``(撤回)该版本。
零运行时依赖:仅用 json / hashlib / dataclass / 标准库。
"""
from __future__ import annotations
import hashlib
import json
import os
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from .release import Release
MANIFEST_SCHEMA_VERSION = 1
MANIFEST_FILENAME_FMT = "manifest-{version}.json"
PUSH_LOG_FILENAME = "push_log.json"
PUSH_LOG_SCHEMA_VERSION = 1
def _now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _canonical_json(obj: Any) -> str:
"""规范 JSON 序列化(排序键、无空白),用于稳定哈希。"""
return json.dumps(obj, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
def checksum(snapshot: Dict[str, Any]) -> str:
"""计算配置快照的 SHA256 校验和(规范序列化后哈希)。
内核侧收到 manifest 后,对 ``snapshot`` 用同样算法复算,比对 ``checksum``
即可确认传输无损/未篡改。
"""
return hashlib.sha256(_canonical_json(snapshot).encode("utf-8")).hexdigest()
# ---------------------------------------------------------------------------
# 推送清单
# ---------------------------------------------------------------------------
@dataclass
class PushManifest:
"""推送给内核的配置清单(自描述:版本/快照/校验和/来源)。"""
schema_version: int = MANIFEST_SCHEMA_VERSION
version: str = "" # 对应 Release 的 semver
snapshot: Dict[str, Any] = field(default_factory=dict)
checksum: str = "" # snapshot 的 SHA256
generated_at: str = "" # manifest 生成时间
source: str = "template-console" # 来源标识(内核侧据此识别推送方)
description: str = "" # 推送说明(可解释)
def to_dict(self) -> dict:
return asdict(self)
def to_json(self) -> str:
"""manifest 序列化为 JSON 文本(推送载荷)。"""
return json.dumps(self.to_dict(), ensure_ascii=False, indent=2)
@classmethod
def from_dict(cls, raw: dict) -> "PushManifest":
return cls(
schema_version=raw.get("schema_version", MANIFEST_SCHEMA_VERSION),
version=raw["version"],
snapshot=raw.get("snapshot", {}),
checksum=raw.get("checksum", ""),
generated_at=raw.get("generated_at", ""),
source=raw.get("source", "template-console"),
description=raw.get("description", ""),
)
# ---------------------------------------------------------------------------
# 推送通道
# ---------------------------------------------------------------------------
@dataclass
class PushRecord:
"""一次推送的记录(幂等判定与审计依据)。"""
version: str
checksum: str
pushed_at: str
pushed_by: str
manifest_path: str
status: str = "pushed" # pushed / retracted
reason: str = ""
class PushChannel:
"""配置台 → 内核的配置推送通道(基于文件系统的模拟推送)。
用法:
rm = ReleaseManager(store)
rel = rm.publish("1.0.0", ...)
ch = PushChannel(inbox="/path/to/kernel/inbox")
manifest = ch.push(rel, pushed_by="admin")
# 内核侧:读 manifest,复算 checksum 比对,加载 snapshot
"""
def __init__(self, inbox: str, push_log_path: Optional[str] = None) -> None:
"""``inbox`` 是内核侧接收目录(模拟推送就是把 manifest 写到此处)。
``push_log_path`` 推送日志路径(默认与 inbox 同目录的 push_log.json),
记录每个版本的推送状态,支撑幂等与撤回。
"""
self.inbox = inbox
os.makedirs(inbox, exist_ok=True)
self.push_log_path = push_log_path or os.path.join(inbox, PUSH_LOG_FILENAME)
# -- manifest 构建 --
def build_manifest(
self, release: Release, description: str = "",
) -> PushManifest:
"""把 Release 打包为 PushManifest(含校验和)。"""
snap = release.snapshot
return PushManifest(
schema_version=MANIFEST_SCHEMA_VERSION,
version=release.version,
snapshot=snap,
checksum=checksum(snap),
generated_at=_now_iso(),
source="template-console",
description=description or f"推送版本 {release.version}",
)
# -- 推送日志 --
def _read_log(self) -> List[PushRecord]:
if not os.path.isfile(self.push_log_path):
return []
with open(self.push_log_path, "r", encoding="utf-8") as fh:
blob = json.load(fh)
return [PushRecord(**r) for r in blob.get("records", [])]
def _write_log(self, records: List[PushRecord]) -> None:
blob = {
"schema_version": PUSH_LOG_SCHEMA_VERSION,
"records": [asdict(r) for r in records],
}
tmp = self.push_log_path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(blob, fh, ensure_ascii=False, indent=2)
os.replace(tmp, self.push_log_path)
def _find_record(self, version: str) -> Optional[PushRecord]:
for r in self._read_log():
if r.version == version:
return r
return None
# -- 推送 / 撤回 --
def push(
self, release: Release, pushed_by: str = "system",
description: str = "", force: bool = False,
) -> PushManifest:
"""推送一个已发布版本到内核接收目录(幂等:同版本不重复推送)。
幂等性:若该版本已成功推送且未撤回,直接返回原 manifest(不重复写文件、
不重复触发内核加载)。要强制重推,先 ``retract`` 或传 ``force=True``。
Args:
release: 已发布的版本(含快照);
pushed_by: 推送人(对接 RBAC);
description: 推送说明;
force: 强制重推(覆盖既有 manifest)。
Returns:
推送的 PushManifest。
"""
existing = self._find_record(release.version)
if existing and existing.status == "pushed" and not force:
# 幂等:返回已推送的 manifest(从 inbox 读回)
if os.path.isfile(existing.manifest_path):
with open(existing.manifest_path, "r", encoding="utf-8") as fh:
return PushManifest.from_dict(json.load(fh))
manifest = self.build_manifest(release, description=description)
manifest_path = os.path.join(
self.inbox, MANIFEST_FILENAME_FMT.format(version=release.version))
tmp = manifest_path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
fh.write(manifest.to_json())
os.replace(tmp, manifest_path)
# 更新推送日志(覆盖同版本旧记录)
records = [r for r in self._read_log() if r.version != release.version]
records.append(PushRecord(
version=release.version, checksum=manifest.checksum,
pushed_at=_now_iso(), pushed_by=pushed_by,
manifest_path=manifest_path, status="pushed",
reason=description or f"推送 {release.version}",
))
self._write_log(records)
return manifest
def retract(self, version: str, by: str = "system", reason: str = "") -> bool:
"""撤回一个已推送版本(标记为 retracted,不删 manifest 文件,可追溯)。
撤回后该版本可重新 push(幂等解除)。返回是否实际撤回。
"""
rec = self._find_record(version)
if rec is None or rec.status != "pushed":
return False
records = self._read_log()
for i, r in enumerate(records):
if r.version == version:
records[i] = PushRecord(
version=r.version, checksum=r.checksum,
pushed_at=r.pushed_at, pushed_by=r.pushed_by,
manifest_path=r.manifest_path, status="retracted",
reason=f"撤回 by {by}:{reason or '未说明'}",
)
self._write_log(records)
return True
# -- 查询 / 校验 --
def pushed_versions(self) -> List[Dict[str, Any]]:
"""已推送版本摘要(配置台推送状态列表用)。"""
return [
{"version": r.version, "checksum": r.checksum,
"pushed_at": r.pushed_at, "pushed_by": r.pushed_by,
"status": r.status}
for r in self._read_log()
]
@staticmethod
def verify(manifest: PushManifest) -> bool:
"""校验 manifest 的 checksum 与其 snapshot 是否一致。
内核侧收到 manifest 后调用此方法,确认传输无损;配置台侧也可在推送前自检。
"""
return manifest.checksum == checksum(manifest.snapshot)
@staticmethod
def verify_payload(payload: Dict[str, Any]) -> bool:
"""从原始 payload(dict)校验:用同算法复算 checksum 比对。"""
try:
manifest = PushManifest.from_dict(payload)
except (KeyError, TypeError):
return False
return PushChannel.verify(manifest)