feat(#182): 模型注册表后端化 TemplateRegistry 服务化 HTTP API(E1) #184

Closed
bot_dev1 wants to merge 1 commits from feature/issue-182 into main
4 changed files with 870 additions and 0 deletions
+513
View File
@@ -0,0 +1,513 @@
# -*- 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()
+52
View File
@@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
"""测试用 FBA 鉴权桩(issue #182 注册表 API 测试)。
避免真实回源 FBA /auth/codes,本地固定权限码映射:
- token == "good" → 拥有 iaop:admin(写权限)
- token == "readonly" → 仅读,无任何写权限码
- 其它 / 缺失 → 401
模拟 ``core/auth/fba_jwt.FbaAuth`` 的 ``verify`` / ``require_perm`` 接口形状。
"""
from __future__ import annotations
from http import HTTPStatus
class _FbaError(Exception):
def __init__(self, status: int, message: str) -> None:
super().__init__(message)
self.status = status
self.message = message
class StubFba:
"""FbaAuth 桩:固定权限码,不回源网络。"""
_PERMS = {
"good": {"iaop:admin", "iaop:studio"},
"readonly": set(),
}
def verify(self, authorization: str) -> dict:
token = self._extract(authorization)
if token not in self._PERMS:
raise _FbaError(HTTPStatus.UNAUTHORIZED, "Token 无效")
return {"sub": token}
def require_perm(self, authorization: str, perm: str) -> dict:
claims = self.verify(authorization)
token = self._extract(authorization)
if perm not in self._PERMS.get(token, set()):
raise _FbaError(HTTPStatus.FORBIDDEN, f"缺少权限:{perm}")
return claims
@staticmethod
def _extract(authorization: str) -> str:
if not authorization:
raise _FbaError(HTTPStatus.UNAUTHORIZED, "未携带认证信息")
parts = authorization.split(None, 1)
if len(parts) != 2 or parts[0].lower() != "bearer":
raise _FbaError(HTTPStatus.UNAUTHORIZED, "认证头格式应为 Bearer <token>")
return parts[1].strip()
@@ -0,0 +1,267 @@
# -*- coding: utf-8 -*-
"""模型注册表 HTTP API 单元测试(issue #182)。
覆盖验收要求:
- 四接口 curl 联调(通过 make_server + urllib 真实起服务回环联调);
- 写操作 403 / 401 路径(无 token / 权限码缺失);
- promote / rollback 正确流转 + 落盘持久化(重启不丢);
- 种子首次启动初始化 4 条演示模型。
运行(仓库根):PYTHONPATH=. python -m unittest core.model_framework.tests.test_registry_api -v
"""
from __future__ import annotations
import json
import os
import sys
import tempfile
import time
import unittest
import urllib.error
import urllib.request
HERE = os.path.dirname(os.path.abspath(__file__))
if HERE not in sys.path:
sys.path.insert(0, HERE)
import _bootstrap # noqa: E402 加载 model_framework 包
from model_framework.registry_api import ( # noqa: E402
AuthService,
PersistedRegistry,
RegistryApiError,
make_server,
)
def _free_port() -> int:
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
return port
class _ServerCtx:
"""起一个回环 RegistryAPI 服务,返回 (base_url, stop)。"""
def __init__(self, data_path: str, *, fba=None, user_store=None,
autoload: bool = True):
self.port = _free_port()
self.srv = make_server(data_path, "127.0.0.1", self.port,
fba=fba, user_store=user_store, autoload=autoload)
import threading
self._t = threading.Thread(target=self.srv.serve_forever, daemon=True)
self._t.start()
self.base = f"http://127.0.0.1:{self.port}"
def stop(self):
self.srv.shutdown()
self.srv.server_close()
def _req(method: str, url: str, body=None, headers=None):
data = None
h = dict(headers or {})
if body is not None:
data = json.dumps(body).encode("utf-8")
h.setdefault("Content-Type", "application/json")
req = urllib.request.Request(url, data=data, method=method, headers=h)
try:
with urllib.request.urlopen(req, timeout=5) as r:
return r.status, json.loads(r.read().decode("utf-8"))
except urllib.error.HTTPError as e:
payload = e.read().decode("utf-8")
try:
return e.code, json.loads(payload)
except ValueError:
return e.code, {"raw": payload}
class PersistedRegistryTest(unittest.TestCase):
"""底层 PersistedRegistry:注册 / promote / rollback / 持久化 / 种子。"""
def test_seed_on_first_start(self):
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "registry.json")
reg = PersistedRegistry(path)
models = reg.list_models()
self.assertEqual(len(models), 4)
names = {m["name"] for m in models}
self.assertIn("quality_forecast", names)
# 文件已落盘
self.assertTrue(os.path.exists(path))
def test_persist_across_restart(self):
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "registry.json")
reg = PersistedRegistry(path)
reg.register({"name": "quality_forecast", "version": "9.9.9",
"stage": "dev"})
# 重新加载同一文件
reg2 = PersistedRegistry(path)
versions = reg2._reg.list_versions("quality_forecast") # noqa: SLF001
self.assertIn("9.9.9", versions)
def test_promote_and_rollback(self):
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "registry.json")
reg = PersistedRegistry(path)
# 种子 quality_forecast 1.2.0 在 prod;用新 dev 版本走完整流转
reg.register({"name": "quality_forecast", "version": "2.0.0",
"stage": "dev"})
promoted = reg.promote("quality_forecast", "2.0.0")
self.assertEqual(promoted["stage"], "staging")
# rollback staging 指针回到 1.2.0
rb = reg.rollback("quality_forecast", "1.2.0", "staging")
self.assertEqual(rb["version"], "1.2.0")
self.assertEqual(
reg._reg.stage_pointer("quality_forecast", # noqa: SLF001
__import__(
"model_framework.template_registry",
fromlist=["Stage"]).Stage.STAGING),
"1.2.0")
def test_register_validates_bad_input(self):
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "registry.json")
reg = PersistedRegistry(path)
with self.assertRaises(RegistryApiError):
reg.register({"name": "", "version": "1.0.0"})
with self.assertRaises(RegistryApiError):
reg.register({"name": "x", "version": "not-a-version"})
with self.assertRaises(RegistryApiError):
reg.register({"name": "x", "version": "1.0.0",
"backbone": "unknown_backbone"})
def test_duplicate_register_conflict(self):
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "registry.json")
reg = PersistedRegistry(path)
with self.assertRaises(RegistryApiError) as ctx:
reg.register({"name": "quality_forecast", "version": "1.2.0",
"stage": "dev"})
self.assertEqual(ctx.exception.status, 409)
class RegistryHttpApiTest(unittest.TestCase):
"""四接口 HTTP 回环联调 + 鉴权 401/403 路径。"""
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.path = os.path.join(self._tmp.name, "registry.json")
self.ctx = _ServerCtx(self.path)
def tearDown(self):
self.ctx.stop()
self._tmp.cleanup()
def test_list_and_seed(self):
status, body = _req("GET", self.ctx.base + "/api/v1/registry/models")
self.assertEqual(status, 200)
self.assertEqual(body["total"], 4)
# stage 过滤
status, body = _req("GET",
self.ctx.base + "/api/v1/registry/models?stage=prod")
self.assertEqual(status, 200)
self.assertTrue(all(m["stage"] == "prod" for m in body["models"]))
self.assertEqual(body["total"], 1)
def test_health(self):
status, body = _req("GET", self.ctx.base + "/api/v1/registry/health")
self.assertEqual(status, 200)
self.assertEqual(body["status"], "ok")
def test_register_then_promote_then_rollback(self):
# 演示模式(无鉴权后端):写接口需要 token 占位
st, bd = _req("POST", self.ctx.base + "/api/v1/registry/models",
body={"name": "demo_model", "version": "0.1.0",
"stage": "dev", "backbone": "generic"},
headers={"Authorization": "Bearer demo-token"})
self.assertEqual(st, 201)
self.assertEqual(bd["name"], "demo_model")
# promote dev→staging
st, bd = _req("POST",
self.ctx.base + "/api/v1/registry/models/demo_model/0.1.0/promote",
headers={"Authorization": "Bearer demo-token"})
self.assertEqual(st, 200)
self.assertEqual(bd["stage"], "staging")
# rollback staging → 重新指向自身(验证回滚路径无错)
st, bd = _req("POST",
self.ctx.base + "/api/v1/registry/models/demo_model/rollback?version=0.1.0&stage=staging",
headers={"Authorization": "Bearer demo-token"})
self.assertEqual(st, 200)
self.assertEqual(bd["version"], "0.1.0")
def test_write_requires_token_in_demo_mode(self):
# 无 token → 401
st, bd = _req("POST", self.ctx.base + "/api/v1/registry/models",
body={"name": "x", "version": "1.0.0"})
self.assertEqual(st, 401)
def test_promote_not_found(self):
st, bd = _req("POST",
self.ctx.base + "/api/v1/registry/models/no_such/9.9.9/promote",
headers={"Authorization": "Bearer demo-token"})
self.assertEqual(st, 404)
def test_rollback_missing_version_param(self):
st, bd = _req("POST",
self.ctx.base + "/api/v1/registry/models/demo_model/rollback",
headers={"Authorization": "Bearer demo-token"})
self.assertEqual(st, 400)
def test_persist_after_register(self):
_req("POST", self.ctx.base + "/api/v1/registry/models",
body={"name": "persist_model", "version": "1.0.0", "stage": "dev"},
headers={"Authorization": "Bearer demo-token"})
# 重启服务读同一文件
ctx2 = _ServerCtx(self.path)
try:
st, bd = _req("GET", ctx2.base + "/api/v1/registry/models")
names = {m["name"] for m in bd["models"]}
self.assertIn("persist_model", names)
finally:
ctx2.stop()
class AuthWithFbaTest(unittest.TestCase):
"""配置 FBA 轨时:读需有效 token;写需权限码,401/403 路径。"""
def setUp(self):
from model_framework.tests._fba_stub import StubFba
self._tmp = tempfile.TemporaryDirectory()
self.path = os.path.join(self._tmp.name, "registry.json")
# StubFba:token == "good" 有 iaop:admin;"readonly" 无写权限;无 token 401
self.ctx = _ServerCtx(self.path, fba=StubFba())
def tearDown(self):
self.ctx.stop()
self._tmp.cleanup()
def test_read_without_token_unauthorized(self):
st, _ = _req("GET", self.ctx.base + "/api/v1/registry/models")
self.assertEqual(st, 401)
def test_read_with_token_ok(self):
st, bd = _req("GET", self.ctx.base + "/api/v1/registry/models",
headers={"Authorization": "Bearer good"})
self.assertEqual(st, 200)
self.assertEqual(bd["total"], 4)
def test_write_without_perm_forbidden(self):
st, bd = _req("POST", self.ctx.base + "/api/v1/registry/models",
body={"name": "x", "version": "1.0.0"},
headers={"Authorization": "Bearer readonly"})
self.assertEqual(st, 403)
def test_write_with_perm_ok(self):
st, bd = _req("POST", self.ctx.base + "/api/v1/registry/models",
body={"name": "y", "version": "1.0.0", "stage": "dev"},
headers={"Authorization": "Bearer good"})
self.assertEqual(st, 201)
if __name__ == "__main__":
unittest.main()
+38
View File
@@ -150,3 +150,41 @@ docker exec -it fba_postgres psql -U postgres -d fba # 进数据库
### 旧架构存档
- `web/` 静态演示外壳保留(FBA 停掉时兜底;/index.html、/auth/login.html 已加 C1 自动收口跳转)
- 旧登录页三轨会话已精简为 FBA 单轨(session.js,FBA 接入版)
---
## 模型注册表 API 反代(issue #182,PRD 5.3 ③)
iAOP 后端的模型模板注册表(`core/model-framework/registry_api.py`)以独立
`http.server` 服务运行(默认 `127.0.0.1:8090`,零依赖标准库实现)。前端模型管理 /
版本发布(E2,issue #183)通过 nginx 把 `/api/v1/registry/` 反代到该端口:
```
location /api/v1/registry/ {
proxy_pass http://127.0.0.1:8090/api/v1/registry/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
```
服务启动:
```bash
cd /opt/apps/iAOP
# 配置 FBA 密钥后(与 fba.env 的 TOKEN_SECRET_KEY 一致)写操作接 FBA JWT 鉴权
export FBA_TOKEN_SECRET_KEY=<同 fba.env>
python -m core.model_framework.registry_api
# → iAOP RegistryAPI on http://127.0.0.1:8090 (data=deploy/data/registry/registry.json, fba=on)
```
数据落盘到 `deploy/data/registry/registry.json`(生产可挂卷),重启不丢。
首次启动自动播种 4 条演示模型(与 `web/admin/admin.js` 的 `seedModels()` 对齐)。
鉴权双轨:读接口接 FBA JWT(`FbaAuth.verify`);写接口要求权限码 `iaop:admin` 或
`iaop:studio`。未配置 `FBA_TOKEN_SECRET_KEY` 时降级旧 `core/auth` 会话(写权限由
角色 admin/engineer 判定);两者均未配置时为本地/CI 演示模式——读免鉴权、写需
带任意 token 占位(避免裸奔写)。