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

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