239 lines
9.4 KiB
Python
239 lines
9.4 KiB
Python
# -*- 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()
|
||
]
|