Files
iAOP/core/model-framework/registry_api.py
T
bot_dev1 609fda0505 feat(#182): 模型注册表后端化 TemplateRegistry 服务化 HTTP API(E1)
新增 core/model-framework/registry_api.py(标准库 http.server,对齐 auth_api
风格,零依赖),把已交付的 TemplateRegistry 暴露为 HTTP 服务:

- GET  /api/v1/registry/models                 列表(可按 stage 过滤)
- POST /api/v1/registry/models                 注册新版本(服务端校验)
- POST /api/v1/registry/models/{name}/{version}/promote   dev→staging→prod
- POST /api/v1/registry/models/{name}/rollback?version=&stage=  回滚指定 stage

服务端持久化:PersistedRegistry 每次写操作原子落盘到
deploy/data/registry/registry.json(可挂卷),重启不丢;首次启动自动播种
4 条演示模型(对齐 web/admin/admin.js seedModels)。

鉴权双轨:读接 FBA JWT(core/auth/fba_jwt.py),写要求权限码 iaop:admin
或 iaop:studio;未配置 FBA_TOKEN_SECRET_KEY 降级旧 core/auth 会话;
两者均无时为本地/CI 演示模式(读免鉴权、写需 token 占位)。

测试:tests/test_registry_api.py(16 例,含四接口回环联调、401/403 路径、
promote/rollback/持久化/种子)+ _fba_stub.py(FBA 桩)。全 284 例通过。

部署:deploy/fba/README.md 补 nginx 反代 /api/v1/registry/ → :8090 约定。
2026-08-06 15:30:57 +08:00

514 lines
22 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 -*-
"""模型注册表 HTTP API(issue #182 / PRD 5.3 ③)。
把已交付的 ``TemplateRegistry``(``core/model-framework/template_registry.py``)
通过标准库 ``http.server`` 暴露为 HTTP 服务,对外提供四类接口:
- ``GET /api/v1/registry/models`` 列表(可按 stage 过滤)
- ``POST /api/v1/registry/models`` 注册新版本(服务端校验)
- ``POST /api/v1/registry/models/{name}/{version}/promote`` dev→staging→prod
- ``POST /api/v1/registry/models/{name}/rollback?version=&stage=`` 回滚到指定 stage 版本
设计要点
--------
- **零依赖**:与 ``core/auth/auth_api.py`` 同款风格(``http.server.ThreadingHTTPServer``),
纯标准库,CI 可加载、可单测。
- **服务端持久化**:注册表底层 ``TemplateRegistry.save/load`` JSON 落盘到
``deploy/data/registry/registry.json``(生产可挂卷),重启不丢数据。
- **鉴权双轨**:读接口接 FBA JWT(``core/auth/fba_jwt.py`` 的 ``FbaAuth``),写操作
要求权限码 ``iaop:admin`` 或 ``iaop:studio``;未配置 ``FBA_TOKEN_SECRET_KEY`` 时
降级旧 ``core/auth`` 会话(``auth_api.require_auth``)。两者均未配置时,
仅本地/CI 演示用——读接口免鉴权、写接口要求带任意有效 FBA/会话 token,
否则写接口返回 401(见 ``_require_write``)。
- **种子**:首次启动数据文件不存在时,用旧版 4 条演示模型初始化
(与 ``web/admin/admin.js`` 的 ``seedModels()`` 对齐,便于前端无缝迁移)。
可独立运行冒烟:``python -m core.model_framework.registry_api``(默认 :8090)。
nginx 反代约定见 ``deploy/fba/README.md``。
"""
from __future__ import annotations
import json
import os
import re
import threading
import time
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import parse_qs, urlsplit
# 兼容两种导入位置:包内 ``from .template_registry`` 与
# 单测 / 独立运行时 ``from model_framework.template_registry``。
try: # pragma: no cover - 取决于运行入口
from .template_registry import ( # type: ignore
ALLOWED_BACKBONES,
ModelTemplate,
Stage,
TemplateRegistry,
TemplateRegistryError,
is_valid_version,
)
except ImportError: # pragma: no cover
from model_framework.template_registry import ( # type: ignore
ALLOWED_BACKBONES,
ModelTemplate,
Stage,
TemplateRegistry,
TemplateRegistryError,
is_valid_version,
)
# 写操作要求的权限码(与 deploy/fba/seed_iaop_menus.py 播种一致)
WRITE_PERMS = ("iaop:admin", "iaop:studio")
# 默认服务端数据目录(生产可挂卷)。相对仓库根。
DEFAULT_DATA_DIR = os.path.normpath(
os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "..", "deploy", "data", "registry"))
DEFAULT_DATA_FILE = "registry.json"
#: 旧版演示模型种子(与 web/admin/admin.js seedModels 对齐,便于前端无缝迁移)
SEED_MODELS: List[Dict[str, Any]] = [
{"name": "quality_forecast", "version": "1.2.0", "stage": "prod",
"backbone": "quality_forecast", "author": "bot_dev1",
"registered_at": "2026-08-01T09:00:00Z"},
{"name": "anomaly_detection", "version": "1.0.3", "stage": "staging",
"backbone": "anomaly_detection", "author": "bot_dev1",
"registered_at": "2026-08-02T09:00:00Z"},
{"name": "process_optimizer", "version": "0.9.1", "stage": "dev",
"backbone": "recipe_opt", "author": "engineer",
"registered_at": "2026-08-03T09:00:00Z"},
{"name": "cross_process_optimizer", "version": "0.5.0", "stage": "dev",
"backbone": "cross_process_opt", "author": "engineer",
"registered_at": "2026-08-04T09:00:00Z"},
]
class RegistryApiError(Exception):
"""注册表 HTTP API 统一异常(携带 HTTP 状态码)。"""
def __init__(self, status: int, message: str) -> None:
super().__init__(message)
self.status = status
self.message = message
# ---------------------------------------------------------------------------
# 持久化注册表:写操作自动落盘
# ---------------------------------------------------------------------------
class PersistedRegistry:
"""包一层 ``TemplateRegistry``,每次写操作后自动 JSON 落盘。
线程安全:所有公共方法持锁(``ThreadingHTTPServer`` 多线程服务)。
"""
def __init__(self, data_path: str, *, autoload: bool = True) -> None:
self._path = data_path
self._reg = TemplateRegistry()
self._lock = threading.RLock()
os.makedirs(os.path.dirname(data_path), exist_ok=True)
if autoload and os.path.exists(data_path):
try:
self._reg = TemplateRegistry.load(data_path)
except (ValueError, OSError, TemplateRegistryError):
# 损坏的 JSON 不致命:退回空表,重新种子即可
self._reg = TemplateRegistry()
if autoload and len(self._reg) == 0:
self._seed(SEED_MODELS)
# -- 查询(只读)-------------------------------------------------------
def list_models(self, stage: Optional[str] = None) -> List[Dict[str, Any]]:
with self._lock:
names = self._reg.list_names()
out: List[Dict[str, Any]] = []
for n in names:
for v in self._reg.list_versions(n):
tpl = self._reg.get(n, v)
if stage and tpl.stage.value != stage:
continue
out.append(self._tpl_view(tpl))
return out
def get(self, name: str, version: Optional[str] = None) -> Dict[str, Any]:
with self._lock:
try:
tpl = self._reg.get(name, version)
except TemplateRegistryError as exc:
raise RegistryApiError(HTTPStatus.NOT_FOUND, str(exc))
return self._tpl_view(tpl)
# -- 写操作(落盘)-----------------------------------------------------
def register(self, payload: Dict[str, Any]) -> Dict[str, Any]:
with self._lock:
tpl = self._build_template(payload)
try:
self._reg.register(tpl)
except TemplateRegistryError as exc:
raise RegistryApiError(HTTPStatus.CONFLICT, str(exc))
self._persist()
return self._tpl_view(tpl)
def promote(self, name: str, version: str) -> Dict[str, Any]:
with self._lock:
try:
tpl = self._reg.promote(name, version)
except TemplateRegistryError as exc:
# 版本不存在 → 404;已在 prod → 409
status = (HTTPStatus.NOT_FOUND if "无版本" in str(exc) or "未注册" in str(exc)
else HTTPStatus.CONFLICT)
raise RegistryApiError(status, str(exc))
self._persist()
return self._tpl_view(tpl)
def rollback(self, name: str, version: str, stage: str) -> Dict[str, Any]:
with self._lock:
try:
st = Stage.from_str(stage)
except TemplateRegistryError as exc:
raise RegistryApiError(HTTPStatus.BAD_REQUEST, str(exc))
try:
tpl = self._reg.rollback(name, st, version)
except TemplateRegistryError as exc:
status = (HTTPStatus.NOT_FOUND if "无版本" in str(exc) or "未注册" in str(exc)
else HTTPStatus.BAD_REQUEST)
raise RegistryApiError(status, str(exc))
self._persist()
return self._tpl_view(tpl)
def history(self, name: Optional[str] = None) -> List[Dict[str, Any]]:
with self._lock:
return self._reg.history(name)
# -- 内部 --------------------------------------------------------------
def _persist(self) -> None:
tmp = self._path + ".tmp"
self._reg.save(tmp)
os.replace(tmp, self._path) # 原子替换,避免半写
def _seed(self, seeds: List[Dict[str, Any]]) -> None:
for s in seeds:
try:
ts = s.get("registered_at")
# template_registry 用 epoch;字符串时间转 epoch,失败回退当前
ra: float
if isinstance(ts, (int, float)):
ra = float(ts)
elif isinstance(ts, str):
try:
from datetime import datetime
ra = datetime.fromisoformat(
ts.replace("Z", "+00:00")).timestamp()
except ValueError:
ra = time.time()
else:
ra = time.time()
tpl = ModelTemplate(
name=s["name"], version=s["version"],
backbone=s.get("backbone", "generic"),
stage=Stage.from_str(s.get("stage", "dev")),
registered_at=ra,
description=s.get("description", ""),
extra={"author": s.get("author", "")},
)
self._reg.register(tpl, force=True)
except TemplateRegistryError:
continue # 脏种子跳过,不阻断启动
if len(self._reg) > 0:
self._persist()
def _build_template(self, payload: Dict[str, Any]) -> ModelTemplate:
name = str(payload.get("name", "")).strip()
version = str(payload.get("version", "")).strip()
if not name:
raise RegistryApiError(HTTPStatus.BAD_REQUEST, "name 不能为空")
if not is_valid_version(version):
raise RegistryApiError(
HTTPStatus.BAD_REQUEST,
f"非法版本号 {version!r}(例:v1 / 1.0.0 / v1.2-rc1)")
backbone = str(payload.get("backbone", "generic")).strip() or "generic"
if backbone not in ALLOWED_BACKBONES:
raise RegistryApiError(
HTTPStatus.BAD_REQUEST,
f"非法主干 {backbone!r},允许:{list(ALLOWED_BACKBONES)}")
try:
stage = Stage.from_str(str(payload.get("stage", "dev")))
except TemplateRegistryError as exc:
raise RegistryApiError(HTTPStatus.BAD_REQUEST, str(exc))
metrics = payload.get("metrics") or {}
if not isinstance(metrics, dict):
raise RegistryApiError(HTTPStatus.BAD_REQUEST, "metrics 必须是对象")
try:
return ModelTemplate(
name=name, version=version, backbone=backbone, stage=stage,
hyperparams=dict(payload.get("hyperparams") or {}),
feature_columns=tuple(payload.get("feature_columns") or []),
target_column=str(payload.get("target_column", "") or ""),
metrics={k: float(v) for k, v in metrics.items()},
description=str(payload.get("description", "") or ""),
extra=dict(payload.get("extra") or {}),
)
except TemplateRegistryError as exc:
raise RegistryApiError(HTTPStatus.BAD_REQUEST, str(exc))
@staticmethod
def _tpl_view(tpl: ModelTemplate) -> Dict[str, Any]:
d = tpl.to_dict()
# 补 author(种子放进 extra),便于前端展示与迁移
d["author"] = (tpl.extra.get("author") or "")
return d
# ---------------------------------------------------------------------------
# 鉴权(双轨:FBA JWT / 旧 core-auth 会话;均未配置时仅读免鉴权)
# ---------------------------------------------------------------------------
class AuthService:
"""双轨鉴权封装。读接口要求有效 token;写接口额外要求权限码。
- 配置了 ``FBA_TOKEN_SECRET_KEY``:走 ``FbaAuth.require_perm``;
- 否则若提供了 ``UserStore``:走 ``auth_api.require_auth``(旧会话,
写权限由 ``can_write`` 角色判定,对齐 iaop:admin/studio 语义);
- 两者都无:本地/CI 演示模式——读接口免鉴权;写接口必须有 token
(``_require_write`` 在无任何鉴权后端时直接 401,避免裸奔写)。
"""
def __init__(self, fba=None, user_store=None) -> None:
self._fba = fba
self._store = user_store
def require_read(self, headers) -> None:
# 读接口:有鉴权后端则校验;无后端(演示)放行
if self._fba is not None:
self._verify_fba(headers)
return
if self._store is not None:
self._require_session(headers)
return
# 无后端:读放行
def require_write(self, headers) -> None:
# 写接口:必须鉴权 + 权限码
if self._fba is not None:
for perm in WRITE_PERMS:
# iaop:admin 或 iaop:studio 任一即可
try:
self._fba.require_perm(self._authorization(headers), perm)
return
except Exception as exc: # noqa: BLE001
status = getattr(exc, "status", None)
if status == HTTPStatus.FORBIDDEN:
continue # 试下一个权限码
raise # 401 / 503 直接抛
raise RegistryApiError(
HTTPStatus.FORBIDDEN,
f"权限不足:需要 {list(WRITE_PERMS)} 之一")
if self._store is not None:
user = self._require_session(headers)
role = getattr(user, "role", "")
if role not in ("admin", "engineer"):
raise RegistryApiError(
HTTPStatus.FORBIDDEN,
"权限不足:当前角色不可执行写操作")
return
# 无任何后端:写操作必须带 token 才放行(演示模式仍需 token 占位)
if not self._authorization(headers):
raise RegistryApiError(HTTPStatus.UNAUTHORIZED,
"写操作需要认证(配置 FBA_TOKEN_SECRET_KEY 或旧会话)")
# -- 内部 --------------------------------------------------------------
def _verify_fba(self, headers) -> None:
try:
self._fba.verify(self._authorization(headers))
except Exception as exc: # noqa: BLE001
raise RegistryApiError(getattr(exc, "status", HTTPStatus.UNAUTHORIZED),
str(exc))
def _require_session(self, headers):
try:
from core.auth.auth_api import require_auth # type: ignore
except ImportError: # pragma: no cover
from .auth_api import require_auth # type: ignore
try:
user, _ = require_auth(headers, self._store)
except Exception as exc: # noqa: BLE001
raise RegistryApiError(getattr(exc, "status", HTTPStatus.UNAUTHORIZED),
str(exc))
return user
@staticmethod
def _authorization(headers) -> str:
if not headers:
return ""
return headers.get("Authorization", "") or ""
# ---------------------------------------------------------------------------
# HTTP 端点
# ---------------------------------------------------------------------------
#: 路由:GET /api/v1/registry/models
_ROUTES_GET = (
re.compile(r"^/api/v1/registry/models/?$"),
re.compile(r"^/api/v1/registry/models/history/?$"),
)
#: POST .../models/{name}/{version}/promote
_PROMOTE_RE = re.compile(
r"^/api/v1/registry/models/([^/]+)/([^/]+)/promote/?$")
#: POST .../models/{name}/rollback
_ROLLBACK_RE = re.compile(
r"^/api/v1/registry/models/([^/]+)/rollback/?$")
class RegistryAPIHandler(BaseHTTPRequestHandler):
"""注册表 HTTP 端点。``registry`` / ``auth`` 与 server 共享(见 make_server)。"""
server_version = "iAOP-RegistryAPI/1.0"
# HTTP/1.1 + Content-Length 让客户端能稳定读取错误响应体(401/403 等)
protocol_version = "HTTP/1.1"
def log_message(self, fmt, *args): # 安静日志
pass
def handle_one_request(self): # type: ignore[override]
# 每个请求后关闭连接:避免 keep-alive 下 ThreadingHTTPServer 对
# 缺 Content-Length 的客户端请求误判,从而中断错误响应。
super().handle_one_request()
self.close_connection = True
@property
def registry(self) -> PersistedRegistry:
return self.server.registry_store # type: ignore[attr-defined]
@property
def auth(self) -> AuthService:
return self.server.auth_service # type: ignore[attr-defined]
# -- 通用响应 ----------------------------------------------------------
def _json(self, status: int, body: Any) -> None:
payload = json.dumps(body, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def _read_json(self) -> Dict[str, Any]:
length = int(self.headers.get("Content-Length", 0) or 0)
if length <= 0 or length > 65536:
return {}
raw = self.rfile.read(length)
try:
data = json.loads(raw.decode("utf-8"))
return data if isinstance(data, dict) else {}
except (ValueError, UnicodeDecodeError):
raise RegistryApiError(HTTPStatus.BAD_REQUEST, "请求体不是合法 JSON")
def _handle_error(self, exc: Exception) -> None:
if isinstance(exc, RegistryApiError):
self._json(exc.status, {"error": exc.message})
else:
self._json(HTTPStatus.INTERNAL_SERVER_ERROR,
{"error": "server error: %s" % exc})
# -- GET ---------------------------------------------------------------
def do_GET(self) -> None: # noqa: N802 - http.server 约定
try:
split = urlsplit(self.path)
path = split.path
qs = parse_qs(split.query)
if _ROUTES_GET[0].match(path):
self.auth.require_read(self.headers)
stage = (qs.get("stage", [None])[0])
models = self.registry.list_models(stage)
self._json(HTTPStatus.OK, {"models": models, "total": len(models)})
elif _ROUTES_GET[1].match(path):
self.auth.require_read(self.headers)
name = qs.get("name", [None])[0]
self._json(HTTPStatus.OK,
{"history": self.registry.history(name)})
elif path in ("/api/v1/registry/health", "/health"):
self._json(HTTPStatus.OK, {"status": "ok",
"models": len(self.registry.list_models())})
else:
self._json(HTTPStatus.NOT_FOUND, {"error": "not found"})
except Exception as exc: # noqa: BLE001
self._handle_error(exc)
# -- POST --------------------------------------------------------------
def do_POST(self) -> None: # noqa: N802 - http.server 约定
try:
path = urlsplit(self.path).path
qs = parse_qs(urlsplit(self.path).query)
if _ROUTES_GET[0].match(path): # POST .../models → 注册
self.auth.require_write(self.headers)
body = self._read_json()
view = self.registry.register(body)
self._json(HTTPStatus.CREATED, view)
elif _PROMOTE_RE.match(path):
self.auth.require_write(self.headers)
m = _PROMOTE_RE.match(path)
view = self.registry.promote(m.group(1), m.group(2))
self._json(HTTPStatus.OK, view)
elif _ROLLBACK_RE.match(path):
self.auth.require_write(self.headers)
m = _ROLLBACK_RE.match(path)
name = m.group(1)
version = (qs.get("version", [None])[0] or "").strip()
stage = (qs.get("stage", [None])[0] or "prod").strip()
if not version:
raise RegistryApiError(HTTPStatus.BAD_REQUEST,
"缺少 query 参数 version")
view = self.registry.rollback(name, version, stage)
self._json(HTTPStatus.OK, view)
else:
self._json(HTTPStatus.NOT_FOUND, {"error": "not found"})
except Exception as exc: # noqa: BLE001
self._handle_error(exc)
def make_server(data_path: Optional[str] = None, host: str = "127.0.0.1",
port: int = 8090, *, fba=None, user_store=None,
autoload: bool = True) -> ThreadingHTTPServer:
"""构造注册表 HTTP 服务。
- ``data_path``:注册表 JSON 落盘路径,默认 ``deploy/data/registry/registry.json``。
- ``fba``:``FbaAuth`` 实例(读 ``FBA_TOKEN_SECRET_KEY``),None 则不启用 FBA 轨。
- ``user_store``:旧 ``core/auth`` ``UserStore``,None 则不启用旧会话轨。
"""
if data_path is None:
data_path = os.path.join(DEFAULT_DATA_DIR, DEFAULT_DATA_FILE)
srv = ThreadingHTTPServer((host, port), RegistryAPIHandler)
srv.registry_store = PersistedRegistry(data_path, autoload=autoload) # type: ignore[attr-defined]
srv.auth_service = AuthService(fba=fba, user_store=user_store) # type: ignore[attr-defined]
return srv
def _main() -> None:
# FBA 轨:读环境变量
fba = None
try:
from core.auth.fba_jwt import FbaAuth
fba = FbaAuth.from_env()
except Exception: # noqa: BLE001 - 无 FBA 时降级
fba = None
srv = make_server(fba=fba)
print("iAOP RegistryAPI on http://%s:%d (data=%s, fba=%s)" % (
srv.server_address[0], srv.server_address[1],
srv.registry_store._path, # noqa: SLF001
"on" if fba else "off")) # pragma: no cover
srv.serve_forever()
if __name__ == "__main__": # pragma: no cover
_main()