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:
@@ -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()
|
||||
Reference in New Issue
Block a user