# -*- coding: utf-8 -*- """⑤.7 版本发布 + 回滚点 —— issue #66 / PRD ⑤.7。 配置台的每次发布都应是一个**可回滚的版本**:实施工程师改了配置 → 预览确认 → 管理员发布;发布即固化当时全量配置快照为一个带 semver 的 Release;若线上出问题, 一键回滚到上一个版本,把 ``ConfigStore`` 恢复成那份快照。这样配置变更"可追溯、 可逆转",对齐 PRD「版本化发布与回滚点」。 本模块提供: - ``Release`` 数据类(semver 版本号 / 时间戳 / 快照 / 发布人 / 变更说明); - ``ReleaseManager``:list / publish / rollback; - ``publish``:固化 ConfigStore 快照为新版本,semver 单调递增校验 (新版本必须严格大于当前最新版),拒绝重复发布空快照; - ``rollback``:把 ConfigStore 恢复为指定历史版本的快照,并记一条"回滚事件" (不删除任何历史版本——回滚本身也是一次可追溯的变更); - semver 校验(``MAJOR.MINOR.PATCH``,单调递增)。 发布记录持久化为 ``releases.json``(与 ConfigStore 同根目录),人可读、可备份。 零运行时依赖:仅用 json / dataclass / 标准库。 """ from __future__ import annotations import json import os import re from dataclasses import asdict, dataclass, field from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Tuple from .config_store import ConfigStore # --------------------------------------------------------------------------- # semver # --------------------------------------------------------------------------- _SEMVER_RE = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") RELEASES_FILENAME = "releases.json" RELEASES_SCHEMA_VERSION = 1 def is_valid_semver(version: str) -> bool: """是否合法 semver(MAJOR.MINOR.PATCH,无预发布后缀)。""" return bool(_SEMVER_RE.match(version)) def semver_tuple(version: str) -> Tuple[int, int, int]: """semver → (major, minor, patch) 元组(用于比较)。""" m = _SEMVER_RE.match(version) if not m: raise ValueError(f"非法 semver:{version}") return tuple(int(x) for x in m.groups()) # type: ignore[return-value] def semver_gt(a: str, b: str) -> bool: """a 是否严格大于 b。""" return semver_tuple(a) > semver_tuple(b) def bump_patch(version: str) -> str: """patch 位 +1(默认递增策略,发布时若用户未指定版本号则用此)。""" major, minor, patch = semver_tuple(version) return f"{major}.{minor}.{patch + 1}" # --------------------------------------------------------------------------- # Release 数据模型 # --------------------------------------------------------------------------- @dataclass class Release: """一次发布版本(可解释:含发布人、变更说明、来源)。""" version: str # semver,如 1.2.0 created_at: str # ISO8601 发布时间 snapshot: Dict[str, Any] # 全量配置快照(ConfigStore.snapshot()) released_by: str = "system" # 发布人(对接 RBAC 用户名) changelog: str = "" # 变更说明(本次发布改了什么、为什么) reason: str = "" # 发布理由(可解释可溯源) def to_dict(self) -> dict: return asdict(self) @classmethod def from_dict(cls, raw: dict) -> "Release": return cls( version=raw["version"], created_at=raw.get("created_at", ""), snapshot=raw.get("snapshot", {}), released_by=raw.get("released_by", "system"), changelog=raw.get("changelog", ""), reason=raw.get("reason", ""), ) def _now_iso() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") # --------------------------------------------------------------------------- # 发布管理器 # --------------------------------------------------------------------------- class ReleaseManager: """版本发布 + 回滚管理器。 用法: store = ConfigStore("/path/to/store") rm = ReleaseManager(store) # releases 落在 store 同目录 rel = rm.publish("1.0.0", released_by="admin", changelog="首次发布") rm.rollback("0.9.9", released_by="admin") # 回滚到 0.9.9 的快照 """ def __init__(self, store: ConfigStore, releases_path: Optional[str] = None) -> None: self.store = store self.releases_path = releases_path or os.path.join(store.root, RELEASES_FILENAME) # -- 持久化 -- def _read_all(self) -> List[Release]: if not os.path.isfile(self.releases_path): return [] with open(self.releases_path, "r", encoding="utf-8") as fh: blob = json.load(fh) return [Release.from_dict(r) for r in blob.get("releases", [])] def _write_all(self, releases: List[Release]) -> None: blob = { "schema_version": RELEASES_SCHEMA_VERSION, "releases": [r.to_dict() for r in releases], } tmp = self.releases_path + ".tmp" with open(tmp, "w", encoding="utf-8") as fh: json.dump(blob, fh, ensure_ascii=False, indent=2) os.replace(tmp, self.releases_path) # -- 查询 -- def list(self) -> List[Release]: """全部发布版本(按版本号升序)。""" rels = self._read_all() return sorted(rels, key=lambda r: semver_tuple(r.version)) def latest(self) -> Optional[Release]: """最新发布版本(无则 None)。""" rels = self.list() return rels[-1] if rels else None def get(self, version: str) -> Optional[Release]: """取指定版本(不存在返回 None)。""" for r in self._read_all(): if r.version == version: return r return None # -- 发布 -- def publish( self, version: str, released_by: str = "system", changelog: str = "", reason: str = "", ) -> Release: """发布新版本(固化当前 ConfigStore 快照)。 Raises: ValueError: semver 非法 / 版本号非单调递增 / 版本号已存在 / 快照为空(无任何配置不允许发布)。 """ if not is_valid_semver(version): raise ValueError(f"版本号 '{version}' 非法(须为 MAJOR.MINOR.PATCH)") releases = self._read_all() existing = {r.version for r in releases} if version in existing: raise ValueError(f"版本号 '{version}' 已存在,不可重复发布") # 单调递增:新版本必须严格大于当前最新 if releases: current_latest = max((r.version for r in releases), key=semver_tuple) if not semver_gt(version, current_latest): raise ValueError( f"新版本 '{version}' 必须大于当前最新 '{current_latest}'(单调递增)") snapshot = self.store.snapshot() total_items = sum(len(v) for v in snapshot.get("kinds", {}).values()) if total_items == 0: raise ValueError("配置快照为空,不允许发布(先在配置台录入配置)") release = Release( version=version, created_at=_now_iso(), snapshot=snapshot, released_by=released_by, changelog=changelog, reason=reason, ) releases.append(release) self._write_all(releases) return release # -- 回滚 -- def rollback( self, target_version: str, released_by: str = "system", reason: str = "", ) -> Release: """回滚到指定历史版本的快照(把 ConfigStore 恢复成该版本快照)。 回滚**不删除**任何历史版本,而是:恢复快照 + 记一条回滚说明。返回 目标版本(便于调用方确认恢复到哪)。 Raises: ValueError: 目标版本不存在 / 回滚到当前已是的状态。 """ target = self.get(target_version) if target is None: raise ValueError(f"回滚目标版本 '{target_version}' 不存在") self.store.restore(target.snapshot) # 记录回滚事件(作为一条带 changelog 的元信息,不新增版本号) rollback_note = ( f"[回滚] 已把配置恢复到 {target_version}(发布于 {target.created_at});" f"操作人={released_by};原因={reason or '未说明'}") # 把回滚事件追加到目标版本的 reason 字段(可追溯,不污染版本号序列) target.reason = (target.reason + " | " + rollback_note).strip(" |") if target.reason else rollback_note releases = self._read_all() for i, r in enumerate(releases): if r.version == target_version: releases[i] = target self._write_all(releases) return target def history(self) -> List[Dict[str, Any]]: """发布历史摘要(配置台版本列表展示用)。""" return [ { "version": r.version, "created_at": r.created_at, "released_by": r.released_by, "changelog": r.changelog, "item_count": sum(len(v) for v in r.snapshot.get("kinds", {}).values()), "reason": r.reason, } for r in self.list() ]