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

195 lines
8.4 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 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()