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 约定。
This commit is contained in:
2026-08-06 15:30:57 +08:00
parent ef2a921765
commit 609fda0505
4 changed files with 870 additions and 0 deletions
+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()