Files
iAOP/core/template-console/release.py
T

239 lines
9.4 KiB
Python
Raw Normal View History

# -*- 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()
]