Files
RuoYi-Vue3-FastAPI/ruoyi-fastapi-backend/plugins/core/deployment/worker.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

277 lines
11 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 asyncio
import hashlib
import json
import sys
import time
from contextlib import suppress
from dataclasses import dataclass
from typing import Any
from uuid import uuid4
from config.database import DataSourceRegistry
from plugins.core.deployment.catalog import PluginArtifactCatalog
from plugins.core.deployment.config import PluginDeploymentConfig
from plugins.core.deployment.state import RUNTIME_PLUGIN_ID
from plugins.core.discovery.scanner import PluginDiscoveryError
from plugins.core.management.dao.dao import PluginDao
from plugins.core.management.dao.release_dao import PluginReleaseDao
from utils.log_util import logger
from utils.time_util import TimezoneUtil
@dataclass(frozen=True)
class WorkerTarget:
"""
当前宿主进程固定的插件目标、发布代际和维护准备状态快照。
"""
plugin_id: str
digest: str
generation: str
enabled: bool
prepared: bool
class PluginReleaseWorker:
"""
宿主发布快照与心跳上报器,注册时与维护服务共用全局锁。
"""
def __init__(
self,
config: PluginDeploymentConfig,
*,
session_factory: Any = None,
dao: Any = PluginReleaseDao,
) -> None:
"""
初始化当前宿主进程的发布快照与心跳上报器。
:param config: 制品发布配置快照
:param session_factory: 异步数据库会话工厂,未提供时使用宿主数据源
:param dao: 制品、发布目标及进程报告的数据访问接口
:return: None
"""
self.config = config
self.session_factory = session_factory or DataSourceRegistry.session
self.dao = dao
self.catalog = PluginArtifactCatalog(config, session_factory=self.session_factory, dao=dao)
self.worker_id = uuid4().hex
self.targets: tuple[WorkerTarget, ...] = ()
self.reserved_ids: set[str] = set()
self.versions: dict[str, str] = {}
self.errors: dict[str, str] = {}
self.state = 'starting'
self.task: asyncio.Task | None = None
self.app: Any = None
self.registered = False
async def start(self, app: Any, builder: Any, lifecycle_lock: Any, *, timeout: float = 60) -> None:
"""
在生命周期锁内登记宿主快照,验证目标制品并启动心跳上报。
:param app: 当前宿主应用对象
:param builder: 接收制品发现快照的插件运行时构建器
:param lifecycle_lock: 与插件维护发布共用的生命周期锁
:param timeout: 等待生命周期锁的最长时间,单位为秒
:return: None
:raises TimeoutError: 在等待时限内未能取得维护发布锁
"""
self.config.require_enabled()
self.app = app
deadline = time.monotonic() + timeout
while True:
async with lifecycle_lock.lock(RUNTIME_PLUGIN_ID, 'release_snapshot') as lock:
if lock.acquired:
await self._register_snapshot()
break
if time.monotonic() >= deadline:
raise TimeoutError('等待插件维护发布锁超时')
await asyncio.sleep(min(1, self.config.heartbeat_seconds))
# 验签和大文件散列期间也维持宿主存活,维护命令不能越过启动中的进程。
self.task = asyncio.create_task(self._heartbeat(), name=f'plugin-release-{self.worker_id}')
plugins = []
for target in self.targets:
try:
if not target.prepared:
raise ValueError('目标制品缺少匹配的数据结构维护准备证据')
artifact = await self.catalog.get(target.digest)
if artifact.plugin_id != target.plugin_id:
raise ValueError('发布目标与制品插件 ID 不一致')
self.versions[target.plugin_id] = artifact.version
plugins.append(self.catalog.discovered(artifact, target.generation))
except Exception as exc: # noqa: PERF203 - 单个制品损坏不能阻断其他插件。
self.errors[target.plugin_id] = str(exc)[:2000]
logger.error(f'插件发布目标验证失败:{target.plugin_id},{exc}')
errors = [
PluginDiscoveryError(None, self.config.store_root / plugin_id, message)
for plugin_id, message in self.errors.items()
]
builder.set_artifact_plugins(plugins, reserved_ids=self.reserved_ids, errors=errors)
if self.targets:
# 延迟导入也不能在已签名的目录内产生未登记字节码。
sys.dont_write_bytecode = True
await self.report()
async def _register_snapshot(self) -> None:
"""
固定当前发布目标和保留插件ID,并登记正在启动的宿主进程。
:return: None
"""
async with self.session_factory() as db:
targets = []
# 即使尚未选择目标,已导入制品的 ID 也不能回退为同名源码插件。
self.reserved_ids = {item.plugin_id for item in await self.dao.list_artifacts(db)}
for release in await self.dao.list_releases(db):
self.reserved_ids.add(release.plugin_id)
if not release.target_digest:
continue
plugin = await PluginDao.get_plugin_by_id(db, release.plugin_id)
prepared = bool(
plugin
and plugin.installed_version
and release.prepare_status == 'prepared'
and release.prepared_digest == release.target_digest
and release.prepared_version == plugin.installed_version
)
targets.append(
WorkerTarget(
release.plugin_id,
release.target_digest,
release.generation,
bool(plugin and plugin.enabled == '0'),
prepared,
)
)
self.targets = tuple(sorted(targets, key=lambda item: item.plugin_id))
await self.dao.upsert_worker_report(
db,
worker_id=self.worker_id,
plugin_id=RUNTIME_PLUGIN_ID,
state='starting',
heartbeat_time=TimezoneUtil.utc_now(),
)
await db.commit()
self.registered = True
def generation(self, base: str) -> str:
"""
将基础启动代际与目标快照及保留插件ID合成为就绪屏障代际。
:param base: 宿主提供的基础启动代际标识
:return: 当前启动快照对应的小写 SHA256 代际摘要
"""
payload = [(item.plugin_id, item.digest, item.generation, item.enabled) for item in self.targets]
return hashlib.sha256(
json.dumps([base, payload, sorted(self.reserved_ids)], separators=(',', ':')).encode()
).hexdigest()
async def assert_snapshot_current(self) -> None:
"""
在导入或就绪前确认数据库发布目标仍与当前进程快照一致。
:return: None
:raises RuntimeError: 当前发布目标或代际与进程启动快照不一致
"""
async with self.session_factory() as db:
current = [
(item.plugin_id, item.target_digest, item.generation)
for item in await self.dao.list_releases(db)
if item.target_digest
]
expected = [(item.plugin_id, item.digest, item.generation) for item in self.targets]
if sorted(current) != expected:
raise RuntimeError('插件发布目标在当前 worker 启动过程中变化,请重新启动')
async def ready(self) -> None:
"""
确认发布快照未变后上报宿主就绪及各插件的实际状态。
:return: None
:raises RuntimeError: 当前发布目标或代际在启动过程中发生变化
"""
await self.assert_snapshot_current()
self.state = 'ready'
await self.report()
def _plugin_state(self, target: WorkerTarget) -> tuple[str, str | None]:
"""
根据实际激活结果生成单个目标的进程报告状态。
:param target: 当前进程固定的单个插件发布目标快照
:return: 报告状态与可选错误说明
"""
if self.state in {'failed', 'stopped'}:
return self.state, self.errors.get(target.plugin_id)
if target.plugin_id in self.errors:
return 'failed', self.errors[target.plugin_id]
if not target.enabled:
return 'stopped', None
if self.state == 'starting':
return 'starting', None
runtime = getattr(self.app.state, 'plugin_explicit_runtime', None)
loaded = getattr(runtime, 'loaded', {}).get(target.plugin_id)
if loaded is not None and loaded.active and (loaded.lifespan is None or loaded.lifespan.ready):
discovered = loaded.plugin.discovered_plugin
if discovered.artifact_digest == target.digest and discovered.artifact_generation == target.generation:
return 'ready', None
return 'failed', '当前 worker 未完成目标制品激活或健康检查'
async def report(self) -> None:
"""
以同一心跳时间写入宿主状态及全部目标插件的实际加载状态。
:return: None
"""
if not self.registered:
return
now = TimezoneUtil.utc_now()
async with self.session_factory() as db:
await self.dao.upsert_worker_report(
db, worker_id=self.worker_id, plugin_id=RUNTIME_PLUGIN_ID, state=self.state, heartbeat_time=now
)
for target in self.targets:
state, error = self._plugin_state(target)
await self.dao.upsert_worker_report(
db,
worker_id=self.worker_id,
plugin_id=target.plugin_id,
state=state,
heartbeat_time=now,
artifact_digest=target.digest,
version=self.versions.get(target.plugin_id),
generation=target.generation,
error=error,
)
await db.commit()
async def _heartbeat(self) -> None:
"""
周期性刷新进程报告,数据库暂不可用时保留旧报告等待自然过期。
:return: None
"""
while True:
await asyncio.sleep(self.config.heartbeat_seconds)
try:
await self.report()
except Exception:
# 数据库暂不可用时不伪造就绪时间;旧报告会按 TTL 过期。
logger.exception('插件 worker 心跳上报失败')
async def stop(self, *, failed: bool = False) -> None:
"""
停止心跳任务,并上报正常停止或宿主失败状态。
:param failed: 是否以失败状态结束宿主报告,为 False 时报告正常停止
:return: None
"""
if self.task is not None:
self.task.cancel()
with suppress(asyncio.CancelledError):
await self.task
self.task = None
self.state = 'failed' if failed else 'stopped'
await self.report()