Files
RuoYi-Vue3-FastAPI/ruoyi-fastapi-backend/scripts/plugin_task_integration.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

216 lines
10 KiB
Python

import asyncio
import base64
import json
import shutil
import subprocess
import sys
from datetime import datetime, timezone
from http import HTTPStatus
from pathlib import Path
from typing import Any
TASK_PLUGIN_ID = 'task_demo'
LEGACY_TASK_ID = '0123456789abcdef0123456789abcdef'
LEGACY_CREATED_AT = datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc)
EXPECTED_MIGRATIONS = 2
def build_task_artifacts(source: Path, root: Path, trust_file: Path) -> tuple[Path, Path]:
"""
运行示例交付脚本,签名当前版本及仅用于迁移验收的旧结构夹具。
:param source: 包含已构建 web/dist 的任务示例源码目录
:param root: 本次验收专用且尚不存在的输出目录
:param trust_file: 本次验收使用的临时发布者公钥文件
:return: 旧结构夹具和当前业务插件的签名制品路径
"""
import yaml # noqa: PLC0415
from cryptography.hazmat.primitives import serialization # noqa: PLC0415
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey # noqa: PLC0415
from plugins.core.artifacts import build_artifact # noqa: PLC0415
if root.exists():
raise ValueError('Task integration output must be a new directory')
result = subprocess.run(
[sys.executable, str(source / 'build_release.py'), '--output', str(root / 'package')],
capture_output=True,
text=True,
encoding='utf-8',
errors='replace',
timeout=60,
check=False,
)
if result.returncode:
raise RuntimeError(f'Task package build failed: {result.stderr[-4000:]}')
current = root / 'package' / TASK_PLUGIN_ID
manifest = yaml.safe_load((current / 'plugin.yaml').read_text(encoding='utf-8'))
baseline = root / 'baseline' / TASK_PLUGIN_ID
baseline.mkdir(parents=True)
manifest['version'] = '1.0.0'
manifest['frontend'] = {'delivery': {'type': 'none'}}
manifest['backend'].pop('health', None)
manifest['backend']['migrations'] = [
item for item in manifest['backend']['migrations'] if item.endswith('/001_init.sql')
]
for name in manifest['backend']['migrations']:
destination = baseline / name
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(current / name, destination)
(baseline / 'plugin.yaml').write_text(yaml.safe_dump(manifest, allow_unicode=True), encoding='utf-8')
# 此夹具仅提供 1.0 数据结构;不启动旧业务 API,也不冒充历史发布包。
(baseline / '__init__.py').write_text(
'from fastapi import FastAPI\nfrom plugins.core.sdk import PluginDefinition\n'
'def create_plugin(host):\n return PluginDefinition(app_factory=lambda context: FastAPI())\n',
encoding='utf-8',
)
key = Ed25519PrivateKey.generate()
public = key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
trust = json.loads(trust_file.read_text(encoding='utf-8'))
trust['keys'].append(
{'keyId': 'task-integration', 'publicKey': base64.b64encode(public).decode(), 'pluginIds': [TASK_PLUGIN_ID]}
)
trust_file.write_text(json.dumps(trust), encoding='utf-8')
baseline_archive, current_archive = root / 'baseline.rpk', root / 'current.rpk'
build_artifact(baseline, baseline_archive, key, 'task-integration')
build_artifact(current, current_archive, key, 'task-integration')
return baseline_archive, current_archive
async def exercise_task_delivery(service: Any, sessions: Any, source: Path, root: Path) -> dict[str, Any]:
"""
从受控交付目录验证签名、维护迁移、目标选择、数据保留和真实业务事务。
:param service: 已注入隔离数据库及生命周期锁的发布服务
:param sessions: 隔离数据库的异步会话工厂
:param source: 包含已构建页面的 task_demo 源码目录
:param root: 当前任务验收专用的新输出目录
:return: 可用于 CI 输出的验收结果摘要
"""
from sqlalchemy import select, text # noqa: PLC0415
from plugins.core.management.entity.do.models import SysPluginMigration # noqa: PLC0415
baseline, current = await asyncio.to_thread(build_task_artifacts, source, root, service.config.trust_file)
first = await service.catalog.import_artifact(baseline)
first_prepared = await service.prepare(TASK_PLUGIN_ID, first['digest'], maintenance=True, actor='task-it')
await service.select(
TASK_PLUGIN_ID, first['digest'], expected_generation=first_prepared['generation'], maintenance=True
)
async with sessions() as db, db.begin():
await db.execute(
text(
'INSERT INTO ruoyi_plugin_task_demo (id, title, description, status, created_at, updated_at) '
'VALUES (:id, :title, :description, :status, :created, :updated)'
),
{
'id': LEGACY_TASK_ID,
'title': '升级前的任务',
'description': '保留已有业务数据',
'status': 'todo',
'created': LEGACY_CREATED_AT,
'updated': LEGACY_CREATED_AT,
},
)
latest = await service.catalog.import_artifact(current)
prepared = await service.prepare(TASK_PLUGIN_ID, latest['digest'], maintenance=True, actor='task-it')
await service.prepare(TASK_PLUGIN_ID, latest['digest'], maintenance=True, actor='task-it')
selected = await service.select(
TASK_PLUGIN_ID, latest['digest'], expected_generation=prepared['generation'], maintenance=True
)
async with sessions() as db:
history = (
await db.scalars(select(SysPluginMigration).where(SysPluginMigration.plugin_id == TASK_PLUGIN_ID))
).all()
if len(history) != EXPECTED_MIGRATIONS or any(
item.status != 'success' or item.attempt_count != 1 for item in history
):
raise AssertionError('Task migrations must succeed exactly once per database dialect')
artifact = await service.catalog.get(latest['digest'])
plugin = service.catalog.discovered(artifact)
await exercise_task_api(plugin, sessions)
await service.catalog.get(latest['digest'])
if list(plugin.backend_path.rglob('*.pyc')):
raise AssertionError('Task runtime wrote bytecode into the immutable artifact')
return {
'pluginId': TASK_PLUGIN_ID,
'version': artifact.version,
'digest': latest['digest'],
'generation': selected['generation'],
'migrations': len(history),
'retainedRows': 1,
'crud': True,
'readOnlyDenied': True,
}
async def exercise_task_api(plugin: Any, sessions: Any) -> None:
"""
从签名交付目录加载当前业务 API,注入权限并验证持久化 CRUD。
:param plugin: 从签名制品目录发现的任务插件
:param sessions: 隔离数据库会话工厂
:return: None
"""
from httpx import ASGITransport, AsyncClient # noqa: PLC0415
from plugins.core.runtime.entrypoint import PluginEntrypointLoader # noqa: PLC0415
from plugins.core.sdk import PluginHostContext, PluginRequestContext # noqa: PLC0415
host = PluginHostContext(TASK_PLUGIN_ID, plugin.backend_path, session_factory=sessions)
definition = PluginEntrypointLoader(plugin).load(host)
app = definition.app_factory(host)
permissions = frozenset({'task_demo:view', 'task_demo:write'})
@app.middleware('http')
async def authenticated_fixture(request: Any, call_next: Any) -> Any:
request.state.plugin_context = PluginRequestContext(host, user=None, permissions=permissions)
return await call_next(request)
async with AsyncClient(transport=ASGITransport(app=app), base_url='http://task-integration') as client:
old = await client.get(f'/api/tasks/{LEGACY_TASK_ID}')
old.raise_for_status()
retained = old.json()
if (
retained['title'] != '升级前的任务'
or retained['description'] != '保留已有业务数据'
or retained['status'] != 'todo'
or retained['priority'] != 'normal'
or datetime.fromisoformat(retained['createdAt'].replace('Z', '+00:00')) != LEGACY_CREATED_AT
):
raise AssertionError('Task migration changed existing business data')
permissions = frozenset({'task_demo:view'})
denied_writes = [
await client.post('/api/tasks', json={'title': '禁止的写入'}),
await client.put(f'/api/tasks/{LEGACY_TASK_ID}', json={'title': '禁止的改动'}),
await client.delete(f'/api/tasks/{LEGACY_TASK_ID}'),
]
if any(response.status_code != HTTPStatus.FORBIDDEN for response in denied_writes):
raise AssertionError('Read-only role was allowed to mutate a task')
if (await client.get(f'/api/tasks/{LEGACY_TASK_ID}')).json() != retained:
raise AssertionError('Denied writes changed the legacy task')
permissions = frozenset({'task_demo:view', 'task_demo:write'})
created = await client.post('/api/tasks', json={'title': '升级后新增', 'priority': 'high'})
created.raise_for_status()
task_id = created.json()['id']
updated = await client.put(
f'/api/tasks/{task_id}',
json={'title': '升级后更新', 'description': '事务已提交', 'status': 'done', 'priority': 'high'},
)
updated.raise_for_status()
saved = await client.get(f'/api/tasks/{task_id}')
saved.raise_for_status()
expected_fields = {'title': '升级后更新', 'description': '事务已提交', 'status': 'done', 'priority': 'high'}
if any(saved.json()[key] != value for key, value in expected_fields.items()):
raise AssertionError('Task update did not persist every editable field')
page = await client.get('/api/tasks', params={'page': 1, 'pageSize': 1, 'status': 'done'})
page.raise_for_status()
if page.json()['total'] != 1 or page.json()['items'][0]['id'] != task_id:
raise AssertionError('Task filtering or pagination lost committed changes')
deleted = await client.delete(f'/api/tasks/{task_id}')
deleted.raise_for_status()
if (await client.get(f'/api/tasks/{task_id}')).status_code != HTTPStatus.NOT_FOUND:
raise AssertionError('Task deletion was not committed')
if (await client.get('/api/tasks')).json()['total'] != 1:
raise AssertionError('CRUD changed the legacy task or committed an unauthorized write')