Files
RuoYi-Vue3-FastAPI/ruoyi-fastapi-backend/plugins/core/runtime/explicit.py
T
insistence 4bd83bc7cd feat: 支持 v2 插件、独立前端和签名制品发布 (#130)
* feat: 支持 v2 插件、原生扩展与签名发布

* fix: 修复测试

* feat: 增强宿主 SDK、运行观测与制品维护能力

* feat: 增强文件传输、配置观测与本地开发能力

* feat: 支持 SSE 实时事件流并完善开发脚手架

* feat: 完善长连接鉴权复核与关闭回收

* feat: 增强生命周期与任务关闭可靠性

* feat: 收紧健康检查契约并补齐前端与真实网络验收

* feat: 增加SDK溯源与离线更新工具并验证跨版本兼容

* feat: 增加持久化业务示例与受控 Python 交付

* feat: 增加统一运行诊断与多worker状态观测

* feat: 增加固定目标发布等待与自动验收

* fix: 修复多环境插件部署前缀与代理配置不一致

* fix: 修复测试
2026-10-09 23:49:45 +08:00

497 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import inspect
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any
from fastapi import Depends, FastAPI, HTTPException
from starlette.requests import Request
from starlette.routing import Mount
from starlette.types import Scope
from config.database import Base, DataSourceRegistry
from exceptions.exception import AuthException
from module_admin.service.login_service import LoginService
from plugins.core.discovery.registry import PluginRegistry, RegisteredPlugin
from plugins.core.discovery.scanner import is_artifact_plugin
from plugins.core.runtime.asgi import PluginGatewayASGI, PluginLifespanManager
from plugins.core.runtime.browser_session import authenticate_browser_session
from plugins.core.runtime.bundle import PluginBundleASGI
from plugins.core.runtime.configuration import PluginConfigObservation, PluginConfigReader, config_revision
from plugins.core.runtime.connections import AUTH_RECHECK_KEY
from plugins.core.runtime.diagnostics import (
PluginActivationHealth,
PluginRuntimeDiagnosticSnapshot,
activation_health_observation,
collect_runtime_diagnostics,
)
from plugins.core.runtime.entrypoint import PluginEntrypointLoader
from plugins.core.runtime.health import PluginHealthChecker
from plugins.core.runtime.host_services import build_host_services
from plugins.core.runtime.job_dispatcher import PluginJobBinding, bind_plugin_jobs, unbind_plugin_jobs
from plugins.core.runtime.metrics import PluginObservedASGI, PluginRuntimeMetrics
from plugins.core.runtime.metrics_store import PluginMetricsReporter
from plugins.core.runtime.route_guard import PluginEnabledDependency, PluginRouteStateGateway
from plugins.core.sdk import PluginDefinition, PluginHostContext, PluginRequestContext
from plugins.core.sdk.request import current_plugin_request_id
from plugins.core.validation.manifest import PluginManifestChecker
from plugins.core.validation.structure import PluginStructureChecker
from utils.log_util import logger
@dataclass
class LoadedExplicitPlugin:
"""
一个 worker 的已加载定义及可选 lifespan。
:param plugin: 注册表中的插件记录
:param host: 当前插件的宿主能力上下文
:param definition: 入口返回的插件能力定义
:param lifespan: ASGI 子应用生命周期管理器
:param active: 插件是否已完成激活
:param jobs: 当前 worker 中的插件任务绑定
:param gateway: 当前 worker 的 ASGI 长连接管理入口
:param activation_health: 最近一次激活健康检查的安全摘要,不表示持续健康
"""
plugin: RegisteredPlugin
host: PluginHostContext
definition: PluginDefinition
lifespan: PluginLifespanManager | None = None
active: bool = False
jobs: PluginJobBinding | None = None
gateway: PluginGatewayASGI | None = None
activation_health: PluginActivationHealth = field(default_factory=PluginActivationHealth)
class ExplicitPluginRuntime:
"""
仅负责当前 worker 的代码和资源,全局迁移仍由原有 writer 处理。
"""
def __init__(self, route_state_gateway: PluginRouteStateGateway) -> None:
"""
初始化当前 worker 的显式插件运行时。
:param route_state_gateway: 插件启用状态查询网关
:return: None
"""
self.route_state_gateway = route_state_gateway
self.loaded: dict[str, LoadedExplicitPlugin] = {}
self.metrics = PluginRuntimeMetrics()
self.metrics_reporter = PluginMetricsReporter(self.metrics)
self._closing = False
def diagnostic_snapshot(self) -> PluginRuntimeDiagnosticSnapshot:
"""
读取当前 worker 已加载插件的身份、资源与最近激活检查,不触发插件回调。
:return: 与旧版 metrics 协议独立的有界诊断快照
"""
return collect_runtime_diagnostics(self)
@staticmethod
def ordered_plugins(registry: PluginRegistry) -> list[RegisteredPlugin]:
"""
稳定的依赖拓扑排序,拒绝缺失依赖及循环。
:param registry: 包含待排序插件的注册表
:return: 按依赖优先顺序排列的已启用插件
"""
plugins = {item.plugin_id: item for item in registry.list_enabled_plugins()}
visiting: set[str] = set()
visited: set[str] = set()
result = []
def visit(plugin_id: str) -> None:
"""
递归访问插件依赖并记录拓扑顺序。
:param plugin_id: 当前访问的插件 ID
:return: None
"""
if plugin_id in visited:
return
if plugin_id in visiting:
raise ValueError(f'插件存在循环依赖:{plugin_id}')
if plugin_id not in plugins:
raise ValueError(f'插件依赖未启用:{plugin_id}')
visiting.add(plugin_id)
plugin = plugins[plugin_id]
for dependency in plugin.discovered_plugin.manifest.dependencies.plugins:
visit(dependency.id)
visiting.remove(plugin_id)
visited.add(plugin_id)
result.append(plugin)
for plugin_id in plugins:
visit(plugin_id)
return result
@classmethod
def dependency_order(cls, registry: PluginRegistry) -> tuple[list[RegisteredPlugin], dict[str, str]]:
"""
按依赖闭包分别排序,损坏分支不阻断独立插件。
:param registry: 包含全部已发现插件的注册表
:return: 可加载的插件依赖顺序及各失败入口的错误信息
"""
plugins = {item.plugin_id: item for item in registry.list_enabled_plugins()}
ordered: dict[str, RegisteredPlugin] = {}
errors: dict[str, str] = {}
for plugin_id, plugin in plugins.items():
if not plugin.discovered_plugin.manifest.uses_entrypoint:
continue
closure: dict[str, RegisteredPlugin] = {}
def collect(current: str, closure: dict[str, RegisteredPlugin] = closure) -> None:
"""
收集当前入口依赖的插件闭包。
:param current: 当前访问的插件 ID
:param closure: 当前入口已收集的依赖插件映射
:return: None
"""
if current in closure:
return
if current not in plugins:
raise ValueError(f'插件依赖未启用:{current}')
closure[current] = plugins[current]
for dependency in plugins[current].discovered_plugin.manifest.dependencies.plugins:
collect(dependency.id, closure)
try:
collect(plugin_id)
for dependency in cls.ordered_plugins(PluginRegistry(list(closure.values()))):
ordered[dependency.plugin_id] = dependency
except ValueError as exc:
errors[plugin_id] = str(exc)
return list(ordered.values()), errors
def prepare(
self,
plugin: RegisteredPlugin,
app: FastAPI,
*,
startup_write_enabled: bool,
config_values: Mapping[str, Any] | None = None,
) -> None:
"""
导入显式模型定义,在原有建表步骤之前完成。
:param plugin: 注册表中的待准备插件
:param app: 宿主 FastAPI 应用
:param startup_write_enabled: 当前 worker 是否允许执行启动期全局写入
:param config_values: 插件专属配置快照,未提供时使用清单默认值
:return: None
"""
if plugin.plugin_id in self.loaded:
return
discovered = plugin.discovered_plugin
manifest = discovered.manifest
builder = getattr(app.state, 'plugin_runtime_builder', None)
backend_root = getattr(builder, 'backend_root', discovered.backend_path.parents[1])
structure = PluginStructureChecker(backend_root).check(discovered, include_frontend=False)
if not structure.ok:
raise ValueError(';'.join(item.message for item in structure.failed_items))
compatibility = PluginManifestChecker(backend_root=backend_root).check(manifest)
if not compatibility.ok:
raise ValueError(';'.join(item.message for item in compatibility.error_issues))
values = (
dict(config_values)
if config_values is not None
else {item.key: item.default for item in manifest.config.items}
)
observation = PluginConfigObservation(
plugin_id=plugin.plugin_id,
version=manifest.version,
digest=discovered.artifact_digest,
generation=discovered.artifact_generation,
startup_revision=config_revision(plugin.plugin_id, values),
)
host = PluginHostContext(
plugin_id=plugin.plugin_id,
resource_root=discovered.backend_path,
config=values,
config_revision=observation.startup_revision,
config_reader=PluginConfigReader(discovered, DataSourceRegistry.session, observation).read,
session_factory=DataSourceRegistry.session,
services=build_host_services(
plugin.plugin_id, DataSourceRegistry.session, getattr(app.state, 'redis', None)
),
redis=getattr(app.state, 'redis', None),
logger=logger.bind(plugin_id=plugin.plugin_id),
startup_write_enabled=startup_write_enabled and not is_artifact_plugin(discovered),
)
existing_tables = set(Base.metadata.tables)
try:
definition = PluginEntrypointLoader(discovered).load(host)
if definition.register_models:
result = definition.register_models(host)
if inspect.isawaitable(result):
if inspect.iscoroutine(result):
result.close()
raise TypeError('register_models 必须同步注册元数据,不能执行异步数据库操作')
finally:
if is_artifact_plugin(discovered):
for key in set(Base.metadata.tables) - existing_tables:
Base.metadata.tables[key].info['plugin_artifact'] = plugin.plugin_id
self.loaded[plugin.plugin_id] = LoadedExplicitPlugin(plugin, host, definition)
worker = getattr(app.state, 'plugin_release_worker', None)
if worker is not None:
self.metrics.worker_id = worker.worker_id
self.metrics.register(
plugin.plugin_id, manifest.version, discovered.artifact_digest, discovered.artifact_generation
)
if plugin.plugin_id in self.metrics.identities:
self.metrics.configurations[plugin.plugin_id] = observation
app.state.plugin_metrics_reporter = self.metrics_reporter
async def activate(self, plugin_id: str, app: FastAPI) -> None:
"""
全部校验和初始化成功后才向应用注册路由或挂载子应用。
:param plugin_id: 已完成准备的插件 ID
:param app: 宿主 FastAPI 应用
:return: None
"""
if self._closing:
raise RuntimeError('插件运行时已进入关闭阶段,不能激活插件')
loaded = self.loaded[plugin_id]
if loaded.active:
return
if loaded.lifespan is not None and loaded.lifespan.has_pending_task:
raise RuntimeError(f'插件上一次生命周期尚未退出:{plugin_id}')
if loaded.plugin.discovered_plugin.manifest.backend.jobs:
loaded.jobs = bind_plugin_jobs(
loaded.plugin.discovered_plugin,
loaded.host,
self.route_state_gateway,
lambda: loaded.active and (loaded.lifespan is None or loaded.lifespan.ready),
metrics=self.metrics,
)
try:
await self._activate_web(loaded, app)
except BaseException:
if loaded.jobs is not None:
try:
await unbind_plugin_jobs(loaded.jobs)
if not any(not task.done() for task in loaded.jobs.tasks):
loaded.jobs = None
except Exception:
logger.exception(f'插件激活失败后的任务回收失败:{plugin_id}')
raise
loaded.active = True
if plugin_id in self.metrics.configurations:
self.metrics.configurations[plugin_id].active = True
app.openapi_schema = None
async def _activate_web(self, loaded: LoadedExplicitPlugin, app: FastAPI) -> None:
"""
任务绑定成功后再注册 Web 能力,避免重复 worker 实例留下半注册路由。
:param loaded: 当前 worker 已准备的插件实例
:param app: 宿主 FastAPI 应用
:return: None
"""
plugin_id = loaded.plugin.plugin_id
manifest = loaded.plugin.discovered_plugin.manifest
if manifest.integration_kind == 'router':
await self._check_health(loaded, app)
if self._closing:
raise RuntimeError('插件运行时已进入关闭阶段,不能注册路由')
self._register_routers(loaded, app)
else:
child = loaded.definition.app_factory(loaded.host)
if inspect.isawaitable(child):
if inspect.iscoroutine(child):
child.close()
raise TypeError('app_factory 必须同步返回 ASGI 应用')
if not callable(child):
raise TypeError('app_factory 必须返回 ASGI callable')
if isinstance(child, FastAPI):
child.state.plugin_host = loaded.host
child.state.redis = loaded.host.redis
mount_path = manifest.backend.asgi.mount_path
if any(getattr(route, 'path', None) == mount_path for route in app.routes):
raise ValueError(f'插件挂载路径冲突:{mount_path}')
manager = PluginLifespanManager(child, managed=manifest.backend.asgi.lifespan == 'managed')
loaded.lifespan = manager
try:
await manager.startup()
await self._check_health(loaded, app)
if self._closing:
raise RuntimeError('插件运行时已进入关闭阶段,不能挂载子应用')
web_app = child
if manifest.frontend.delivery.type == 'bundle':
web_app = PluginBundleASGI(child, plugin_id, loaded.plugin.backend_path, manifest.frontend.bundle)
gateway = PluginGatewayASGI(web_app, manager, self._authorizer(loaded, app))
observed = PluginObservedASGI(gateway, self.metrics, plugin_id)
app.router.routes.append(Mount(mount_path, app=observed, name=f'plugin:{plugin_id}'))
loaded.gateway = gateway
except BaseException:
try:
await manager.shutdown()
except Exception:
logger.exception(f'插件激活失败后的 ASGI 回收失败:{plugin_id}')
raise
@staticmethod
async def _check_health(loaded: LoadedExplicitPlugin, app: FastAPI) -> None:
"""
执行清单声明的健康检查并阻止不健康插件激活。
:param loaded: 当前 worker 已准备的插件实例
:param app: 宿主 FastAPI 应用
:return: None
"""
if loaded.plugin.discovered_plugin.manifest.backend.health.checker:
result = await PluginHealthChecker(loaded.plugin.discovered_plugin).check(app=app)
loaded.activation_health = activation_health_observation(result)
if not result.ok:
raise RuntimeError(f'插件激活健康检查失败:{result.message}')
def _register_routers(self, loaded: LoadedExplicitPlugin, app: FastAPI) -> None:
"""
校验插件路由命名空间并注册带宿主权限依赖的路由。
:param loaded: 当前 worker 已准备的插件实例
:param app: 宿主 FastAPI 应用
:return: None
"""
plugin_id = loaded.plugin.plugin_id
async def set_context(request: Request, user: Any = Depends(LoginService.get_current_user)) -> None:
"""
检查插件状态并向请求写入宿主身份上下文。
:param request: 插件接口请求
:param user: 宿主登录依赖返回的当前用户
:return: None
"""
if not loaded.active:
raise HTTPException(status_code=503, detail='插件未就绪')
try:
request.state.plugin_context = self._request_context(loaded, user)
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
for router in loaded.definition.routers:
for route in router.routes:
if not PluginStructureChecker._is_plugin_route_prefix(plugin_id, getattr(route, 'path', '')):
raise ValueError(f'插件路由越过命名空间:{getattr(route, "path", "")}')
if not hasattr(route, 'methods'):
raise ValueError('v2 Router 的 WebSocket 或嵌套 Mount 请使用 ASGI 接入')
for router in loaded.definition.routers:
previous_routes = len(app.router.routes)
app.include_router(
router,
dependencies=[
PluginEnabledDependency(plugin_id, self.route_state_gateway),
Depends(set_context),
],
)
for route in app.router.routes[previous_routes:]:
route.handle = PluginObservedASGI(route.handle, self.metrics, plugin_id)
def _authorizer(self, loaded: LoadedExplicitPlugin, app: FastAPI) -> Any:
"""
创建复用宿主登录及插件会话校验的 ASGI 授权器。
:param loaded: 当前 worker 已准备的插件实例
:param app: 宿主 FastAPI 应用
:return: 根据请求作用域构建插件身份上下文的异步函数
"""
async def authorize(scope: Scope) -> PluginRequestContext:
# LoginService 使用宿主 request.app.state,而子应用会替换 scope['app']。
"""
校验插件启用状态和登录凭证,并构建插件请求身份。
:param scope: HTTP 或 WebSocket 的 ASGI 请求作用域
:return: 已通过宿主身份及插件访问权限校验的请求上下文
"""
auth_scope = dict(scope, type='http', app=app)
request = Request(auth_scope)
if not loaded.active:
raise PermissionError('插件未激活或正在关闭')
authorization = request.headers.get('authorization', '')
scheme, _, token = authorization.partition(' ')
async with DataSourceRegistry.session() as db:
if not await self.route_state_gateway.is_plugin_enabled(db, loaded.plugin.plugin_id):
raise PermissionError('插件未启用')
if authorization:
if scheme.lower() != 'bearer' or not token.strip():
raise LookupError('Bearer 凭证无效')
try:
resolve_user = (
LoginService.get_current_user_for_plugin_session
if scope.get(AUTH_RECHECK_KEY)
else LoginService.get_current_user
)
user = await resolve_user(request=request, token=token.strip(), query_db=db)
except AuthException as exc:
raise LookupError('登录已失效') from exc
elif loaded.plugin.discovered_plugin.manifest.frontend.delivery.type == 'bundle':
user = await authenticate_browser_session(
request,
plugin_id=loaded.plugin.plugin_id,
version=loaded.plugin.discovered_plugin.manifest.version,
query_db=db,
websocket=scope['type'] == 'websocket',
)
else:
raise LookupError('缺少 Bearer 凭证')
return self._request_context(loaded, user)
return authorize
@staticmethod
def _request_context(loaded: LoadedExplicitPlugin, user: Any) -> PluginRequestContext:
"""
检查插件访问权限并构建宿主签发的请求上下文。
:param loaded: 当前 worker 已准备的插件实例
:param user: 宿主登录服务返回的当前用户
:return: 绑定插件能力与当前用户身份的请求上下文
"""
permissions = frozenset(user.permissions)
declared = set(loaded.plugin.discovered_plugin.manifest.permission_codes)
if declared and '*:*:*' not in permissions and not declared.intersection(permissions):
raise PermissionError('缺少插件访问权限')
return PluginRequestContext(loaded.host, user, permissions, request_id=current_plugin_request_id())
async def shutdown(self) -> None:
"""
逆序关闭,每个实例的失败不阻止其他实例释放资源。
:return: None
"""
self._closing = True
instances = list(reversed(self.loaded.values()))
for loaded in instances:
loaded.active = False
if loaded.plugin.plugin_id in self.metrics.configurations:
self.metrics.configurations[loaded.plugin.plugin_id].active = False
for loaded in instances:
if loaded.gateway is not None:
try:
await loaded.gateway.drain()
except Exception:
logger.exception(f'插件长连接关闭失败:{loaded.plugin.plugin_id}')
if loaded.jobs is not None:
try:
await unbind_plugin_jobs(loaded.jobs)
if not any(not task.done() for task in loaded.jobs.tasks):
loaded.jobs = None
except Exception:
logger.exception(f'插件任务关闭失败:{loaded.plugin.plugin_id}')
if loaded.lifespan is not None:
try:
await loaded.lifespan.shutdown()
except Exception:
logger.exception(f'插件 ASGI 关闭失败:{loaded.plugin.plugin_id}')
try:
await self.metrics_reporter.stop()
except Exception:
logger.exception('插件指标上报关闭失败')