mirror of
https://github.com/insistence/RuoYi-Vue3-FastAPI.git
synced 2026-10-14 05:00:07 +00:00
* feat: 支持 v2 插件、原生扩展与签名发布 * fix: 修复测试 * feat: 增强宿主 SDK、运行观测与制品维护能力 * feat: 增强文件传输、配置观测与本地开发能力 * feat: 支持 SSE 实时事件流并完善开发脚手架 * feat: 完善长连接鉴权复核与关闭回收 * feat: 增强生命周期与任务关闭可靠性 * feat: 收紧健康检查契约并补齐前端与真实网络验收 * feat: 增加SDK溯源与离线更新工具并验证跨版本兼容 * feat: 增加持久化业务示例与受控 Python 交付 * feat: 增加统一运行诊断与多worker状态观测 * feat: 增加固定目标发布等待与自动验收 * fix: 修复多环境插件部署前缀与代理配置不一致 * fix: 修复测试
195 lines
8.4 KiB
Python
195 lines
8.4 KiB
Python
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()
|