# -*- 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()