feat: 支持 v2 插件、独立前端和签名制品发布 (#130)

* feat: 支持 v2 插件、原生扩展与签名发布

* fix: 修复测试

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

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

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

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

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

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

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

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

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

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

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

* fix: 修复测试
This commit is contained in:
insistence
2026-10-09 23:49:45 +08:00
committed by GitHub
parent a89aec54aa
commit 4bd83bc7cd
287 changed files with 41945 additions and 331 deletions
@@ -0,0 +1,194 @@
import argparse
import json
import shutil
import stat
import subprocess
import sys
from pathlib import Path
from typing import Any
from zipfile import ZIP_DEFLATED, ZipFile
BACKEND_ROOT = Path(__file__).resolve().parents[1]
def _resource_roots(source: Path, manifest: Any, output: Path) -> list[Path]:
"""
检查清单声明的资源根路径及前端入口,在构建前拒绝缺失产物。
:param source: 原生插件源码根目录
:param manifest: 已解析的插件清单
:param output: 待创建的构建输出目录
:return: 通过路径检查的部署资源根路径
"""
from plugins.core.artifacts.package import is_link_or_reparse # noqa: PLC0415
from plugins.core.artifacts.schema import safe_artifact_path # noqa: PLC0415
resources = {str(item) for item in manifest.backend.migrations}
resources.update(str(item) for item in manifest.backend.seeds)
if (source / 'plugin.lock.yaml').exists():
resources.add('plugin.lock.yaml')
if manifest.frontend.delivery.type == 'bundle':
bundle = manifest.frontend.bundle
resources.add(bundle.directory)
if not (source / bundle.directory / bundle.entry).is_file():
raise ValueError('缺少 bundle HTML 入口,请先独立构建前端后再组装制品')
roots = []
for relative in sorted(resources):
safe_artifact_path(relative)
resource = source / relative
# 逐级检查,不能先 resolve 后丢失中间目录的链接信息。
current = source
for component in Path(relative).parts:
current /= component
if is_link_or_reparse(current.lstat()):
raise ValueError(f'交付资源不能包含链接或重解析点:{relative}')
if resource.is_dir() and output.is_relative_to(resource.resolve()):
raise ValueError('输出目录不能位于待交付资源目录内')
roots.append(resource)
return roots
def collect_deployment_resources(source: Path, manifest: Any, output: Path) -> dict[str, Path]:
"""
收集清单资源和已构建 bundle,拒绝开发工程、敏感文件及链接。
:param source: 原生插件源码根目录
:param manifest: 已解析的插件清单
:param output: 待创建的构建输出目录
:return: 插件内相对路径与对应源码文件的映射
"""
from plugins.core.artifacts.package import ( # noqa: PLC0415
CACHE_DIRECTORIES,
FORBIDDEN_DIRECTORIES,
FORBIDDEN_FILES,
FORBIDDEN_SUFFIXES,
is_link_or_reparse,
)
from plugins.core.artifacts.schema import ( # noqa: PLC0415
DEFAULT_LIMITS,
safe_artifact_path,
validate_path_inventory,
)
files: dict[str, Path] = {}
total_bytes = 0
native_root = Path(manifest.backend.native.module_root)
for resource in _resource_roots(source, manifest, output):
pending = [resource]
while pending:
item = pending.pop()
name = item.relative_to(source).as_posix()
path = safe_artifact_path(name)
if any(part.casefold() in CACHE_DIRECTORIES for part in path.parts):
continue
if (
any(part.casefold() in FORBIDDEN_DIRECTORIES for part in path.parts)
or path.name.casefold() in FORBIDDEN_FILES
or path.suffix.casefold() in FORBIDDEN_SUFFIXES
or any(part.casefold() == '.env' or part.casefold().startswith('.env.') for part in path.parts)
):
raise ValueError(f'交付资源包含开发工程或敏感配置:{name}')
info = item.lstat()
if is_link_or_reparse(info) or not item.resolve().is_relative_to(source):
raise ValueError(f'交付资源不能包含链接或越界路径:{name}')
if stat.S_ISDIR(info.st_mode):
pending.extend(item.iterdir())
continue
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
raise ValueError(f'交付资源不能包含硬链接或特殊文件:{name}')
if name.casefold() == 'plugin.yaml' or Path(name).is_relative_to(native_root):
raise ValueError(f'交付资源不能覆盖清单或原生发行包:{name}')
if name in files:
continue
files[name] = item
total_bytes += info.st_size
if (
len(files) > DEFAULT_LIMITS.max_files
or info.st_size > DEFAULT_LIMITS.max_file_bytes
or total_bytes > DEFAULT_LIMITS.max_total_bytes
):
raise ValueError('交付资源超过制品文件数量或大小限制')
validate_path_inventory(tuple(files))
return files
def main() -> None:
"""
构建原生 wheel 并组装可离线交付的插件目录与 ZIP。
:return: None
"""
parser = argparse.ArgumentParser(
description='构建、静态验证并组装原生插件;使用当前 Python 环境,不写入运行中的 plugins。'
)
parser.add_argument('--source', type=Path, default=BACKEND_ROOT / 'plugins' / 'examples' / 'rust' / 'rust_demo')
parser.add_argument('--output', type=Path, required=True, help='全新输出目录(其中创建 <plugin_id>/)')
arguments = parser.parse_args()
source = arguments.source.resolve()
output = arguments.output.resolve()
if output.exists():
parser.error('输出目录已存在,请选择全新目录,避免覆盖正在使用的二进制')
sys.path.insert(0, str(BACKEND_ROOT))
from plugins.core.discovery.scanner import PluginScanner # noqa: PLC0415
from plugins.core.native.wheel import install_native_wheel, select_native_wheel # noqa: PLC0415
from plugins.core.validation.structure import PluginStructureChecker # noqa: PLC0415
manifest = PluginScanner(source.parent).load_manifest(source / 'plugin.yaml').manifest
if manifest.runtime_kind != 'native':
parser.error('清单必须声明 native 运行时')
if manifest.frontend.delivery.type not in {'none', 'bundle'}:
parser.error('原生交付只支持 none 或已独立构建的 bundle 前端')
if not (source / 'Cargo.lock').is_file():
parser.error('缺少 Cargo.lock,请先在源码目录执行 cargo generate-lockfile 并提交锁文件')
collect_deployment_resources(source, manifest, output)
output.mkdir(parents=True)
wheels = output / 'wheels'
subprocess.run(
[
sys.executable,
'-m',
'maturin',
'build',
'--release',
'--locked',
'--strip',
'--interpreter',
sys.executable,
'--manifest-path',
str(source / 'Cargo.toml'),
'--out',
str(wheels),
],
cwd=source,
check=True,
)
wheel = select_native_wheel(wheels, manifest)
plugin = output / manifest.id
install_native_wheel(wheel, plugin / manifest.backend.native.module_root, manifest)
shutil.copyfile(source / 'plugin.yaml', plugin / 'plugin.yaml')
# 复制预检过的部署文件;前端必须已构建,不隐式运行 npm。
# Cargo 构建可能耗时较长,复制前再次检查资源路径。
resources = collect_deployment_resources(source, manifest, output)
for relative, resource in sorted(resources.items()):
target = (plugin / relative).resolve()
if not resource.is_relative_to(source) or not target.is_relative_to(plugin):
raise ValueError('交付资源不能越过插件目录')
if target.exists():
raise ValueError(f'交付资源重复或覆盖原生目录:{relative}')
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(resource, target)
discovered = PluginScanner(output).load_manifest(plugin / 'plugin.yaml')
checked = PluginStructureChecker(BACKEND_ROOT).check(discovered)
if not checked.ok:
raise ValueError(';'.join(item.message for item in checked.failed_items))
archive_path = output / f'{manifest.id}-{manifest.version}.zip'
with ZipFile(archive_path, 'x', compression=ZIP_DEFLATED) as archive:
for item in sorted(plugin.rglob('*')):
if item.is_file():
archive.write(item, item.relative_to(output).as_posix())
print(json.dumps({'wheel': str(wheel), 'plugin': str(plugin), 'archive': str(archive_path)}, ensure_ascii=False))
if __name__ == '__main__':
main()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,215 @@
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')
@@ -18,7 +18,8 @@ from utils.time_util import TimezoneUtil
BACKEND = Path(__file__).resolve().parents[1]
MILLISECOND_PRECISION = 3
MAIN_INSTANT_COLUMNS = 73
# 包含插件制品、发布目标和 worker 状态表新增的 6 个 UTC 时刻字段。
MAIN_INSTANT_COLUMNS = 79
PLUGIN_INSTANT_COLUMNS = 4
TIMEZONE_LENGTH = 64
ASYNC_PROBE_ID = 99
@@ -320,7 +321,7 @@ def main() -> None:
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8')
print(
f'{source.db_type} {report["version"]}: passed; database={target.db_database}; '
f'{MAIN_INSTANT_COLUMNS}+4 time columns, 13 roundtrips, 3 generated CRUD cases'
f'{MAIN_INSTANT_COLUMNS}+{PLUGIN_INSTANT_COLUMNS} time columns, 13 roundtrips, 3 generated CRUD cases'
)