新增 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 约定。
514 lines
22 KiB
Python
514 lines
22 KiB
Python
# -*- 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()
|