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,85 @@
from datetime import datetime, timezone
from fastapi import FastAPI
from plugins.core.sdk import (
PluginDefinition,
PluginHostContext,
PluginRequestContext,
plugin_endpoint,
plugin_lifespan,
)
async def startup(host: PluginHostContext) -> dict[str, bool]:
"""
记录插件启动并返回就绪状态,可在此初始化插件自有资源。
:param host: 宿主提供的插件上下文
:return: 插件就绪状态
"""
host.logger.info("__PLUGIN_ID__ started")
return {"pluginReady": True}
async def shutdown(host: PluginHostContext) -> None:
"""
记录插件关闭,可在此释放插件自有资源并保留宿主共享连接池。
:param host: 宿主提供的插件上下文
:return: None
"""
host.logger.info("__PLUGIN_ID__ stopped")
# 仅释放插件自身创建的资源,不关闭宿主 Redis 或数据库连接池。
async def health(context: object) -> bool:
"""
返回本模板的健康检查结果,可在此扩展插件自有依赖检查。
:param context: 宿主调用健康检查时传入的上下文
:return: 插件是否健康
"""
return True
async def info(context: PluginRequestContext) -> dict[str, str]:
return {
"pluginId": context.host.plugin_id,
"hostApiVersion": context.host.api_version,
"engine": "python",
"serverTime": datetime.now(timezone.utc).isoformat(),
}
def create_app(host: PluginHostContext) -> FastAPI:
"""
创建受宿主权限保护的 ASGI 子应用并绑定插件生命周期。
:param host: 宿主提供的插件上下文
:return: 插件 ASGI 子应用
"""
app = FastAPI(
title="__PLUGIN_ID__",
docs_url=None,
redoc_url=None,
lifespan=plugin_lifespan(host, startup, shutdown),
)
app.add_api_route(
"/api/info",
plugin_endpoint(info, permission="__PLUGIN_ID__:view"),
summary="获取插件信息接口",
description="用于获取当前插件标识、宿主API版本、运行引擎和服务器时间",
response_model=dict[str, str],
)
return app
def create_plugin(host: PluginHostContext) -> PluginDefinition:
"""
声明插件能力,实际资源初始化交给生命周期回调。
:param host: 宿主提供的插件上下文
:return: 插件能力声明
"""
return PluginDefinition(app_factory=create_app)
@@ -0,0 +1,233 @@
import argparse
import json
import shutil
import stat
import unicodedata
from pathlib import Path, PureWindowsPath
from typing import Any
PLUGIN_ID = '__PLUGIN_ID__'
BUNDLE = __BUNDLE__
SOURCE = Path(__file__).resolve().parent
RELEASE_MANIFEST = 'release-files.json'
MANIFEST_VERSION = 1
MAX_MANIFEST_BYTES = 64 * 1024
MAX_SOURCE_FILES = 1024
MAX_PATH_LENGTH = 1024
MAX_PATH_COMPONENTS = 32
MAX_COMPONENT_LENGTH = 255
FIRST_PRINTABLE_CHARACTER = 32
DELETE_CHARACTER = 127
SOURCE_EXTENSIONS = {'.py', '.sql'}
FORBIDDEN_DIRECTORIES = {
'tests',
'test',
'keys',
'vendor',
'node_modules',
'__pycache__',
'build',
'dist',
'target',
'web',
}
FORBIDDEN_FILES = {'build_release.py', 'conftest.py', 'setup.py'}
WEB_EXTENSIONS = {
'.html',
'.js',
'.css',
'.svg',
'.png',
'.jpg',
'.jpeg',
'.gif',
'.webp',
'.ico',
'.woff',
'.woff2',
'.ttf',
}
REPARSE_POINT = getattr(stat, 'FILE_ATTRIBUTE_REPARSE_POINT', 0x400)
def checked_file(path: Path) -> Path:
"""
校验交付文件位于源码目录内,且不含链接、重解析点或硬链接。
:param path: 待交付的源码文件路径
:return: 通过检查的源码文件路径
"""
for part in (path, *path.parents):
if part == SOURCE:
break
metadata = part.lstat()
if part.is_symlink() or getattr(metadata, 'st_file_attributes', 0) & REPARSE_POINT:
raise ValueError(f'交付资源不能是链接或重解析点:{part}')
resolved = path.resolve()
if not resolved.is_relative_to(SOURCE) or not path.is_file():
raise ValueError(f'交付文件必须位于源码目录:{path}')
if path.stat().st_nlink > 1:
raise ValueError(f'交付文件不能是硬链接:{path}')
return path
def checked_relative_path(value: str) -> tuple[str, ...]:
"""
校验明确的跨平台相对路径,不接受通配符、隐藏组件或设备路径。
:param value: 清单或 bundle 中的相对路径
:return: 未经折叠或重写的路径组件
"""
if not isinstance(value, str):
raise ValueError('交付路径必须为字符串')
parts = value.split('/')
if (
not value
or len(value) > MAX_PATH_LENGTH
or len(parts) > MAX_PATH_COMPONENTS
or unicodedata.normalize('NFC', value) != value
or any(
not part
or part.startswith('.')
or part.endswith((' ', '.'))
or len(part) > MAX_COMPONENT_LENGTH
or PureWindowsPath(part).is_reserved()
or any(
char in '<>:"\\|?*[]' or ord(char) < FIRST_PRINTABLE_CHARACTER or ord(char) == DELETE_CHARACTER
for char in part
)
for part in parts
)
):
raise ValueError(f'交付清单包含不安全路径:{value!r}')
return tuple(parts)
def checked_inventory(names: list[str]) -> None:
"""
拒绝重复文件、文件与目录冲突以及任一级目录的大小写歧义。
:param names: 全部交付文件的相对路径
:return: None
"""
spellings: dict[str, str] = {}
files: set[str] = set()
directories: set[str] = set()
for name in names:
parts = checked_relative_path(name)
key = name.casefold()
if key in files:
raise ValueError(f'交付清单包含重复文件或大小写冲突:{name}')
files.add(key)
for count in range(1, len(parts) + 1):
prefix = '/'.join(parts[:count])
if spellings.setdefault(prefix.casefold(), prefix) != prefix:
raise ValueError(f'交付清单包含目录大小写冲突:{name}')
if count < len(parts):
directories.add(prefix.casefold())
if files & directories:
raise ValueError('交付清单存在文件与父目录冲突')
def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
"""
解析 JSON 对象时拒绝重复字段,避免不同工具对同一清单作不同解释。
:param pairs: JSON 对象按原始顺序出现的字段
:return: 不包含重复字段的对象
"""
result = {}
for key, value in pairs:
if key in result:
raise ValueError(f'交付清单包含重复字段:{key}')
result[key] = value
return result
def source_resources() -> list[Path]:
"""
按有大小和数量上限的显式清单读取 Python、SQL 资源,不扫描业务源码。
:return: 已通过路径、类型和链接检查的源码文件列表
"""
path = checked_file(SOURCE / RELEASE_MANIFEST)
with path.open('rb') as source:
content = source.read(MAX_MANIFEST_BYTES + 1)
if len(content) > MAX_MANIFEST_BYTES:
raise ValueError('release-files.json 超过 64 KiB 上限')
manifest = json.loads(content.decode('utf-8-sig'), object_pairs_hook=unique_object)
if (
not isinstance(manifest, dict)
or set(manifest) != {'schemaVersion', 'files'}
or type(manifest['schemaVersion']) is not int
or manifest['schemaVersion'] != MANIFEST_VERSION
):
raise ValueError('release-files.json 必须包含 schemaVersion: 1 和 files')
names = manifest['files']
if not isinstance(names, list) or not 1 <= len(names) <= MAX_SOURCE_FILES:
raise ValueError('交付清单 files 必须为包含 1 至 1024 项的列表')
checked_inventory(names)
for name in names:
parts = name.split('/')
filename = parts[-1].casefold()
if (
Path(name).suffix not in SOURCE_EXTENSIONS
or any(part.casefold() in FORBIDDEN_DIRECTORIES for part in parts[:-1])
or filename in FORBIDDEN_FILES
or filename.startswith('test_')
or filename.endswith('_test.py')
):
raise ValueError(f'仅允许业务 .py/.sql 文件,不能包含测试、密钥或开发资源:{name}')
return [checked_file(SOURCE / name) for name in names]
def bundle_resources() -> list[Path]:
"""
收集已构建 bundle,保留原有静态扩展名、隐藏文件和链接限制。
:return: 已通过检查的 web/dist 文件列表
"""
dist = SOURCE / 'web' / 'dist'
checked_file(dist / 'index.html')
resources = []
for path in sorted(dist.rglob('*')):
if path.is_symlink() or getattr(path.lstat(), 'st_file_attributes', 0) & REPARSE_POINT:
raise ValueError(f'bundle 不允许链接:{path}')
if not path.is_file():
continue
if path.suffix.lower() not in WEB_EXTENSIONS:
raise ValueError(f'bundle 存在非交付文件;不要包含源码、source map 或密钥:{path}')
resources.append(checked_file(path))
if not any(path.relative_to(dist).as_posix() == 'index.html' for path in resources):
raise ValueError('bundle 入口必须使用准确的 index.html 文件名')
return resources
def main() -> None:
"""
按显式文件清单组装新交付目录,不安装依赖、导入插件或生成签名。
:return: None
"""
parser = argparse.ArgumentParser(
description='按 release-files.json 组装交付文件;不安装依赖、不导入插件、不生成签名。'
)
parser.add_argument('--output', type=Path, required=True, help='全新输出目录(其中创建插件ID目录)')
output = parser.parse_args().output.resolve()
if output.exists() or output.is_relative_to(SOURCE) or SOURCE.is_relative_to(output):
parser.error('输出目录必须不存在,且不得与源码目录重叠')
resources = [checked_file(SOURCE / 'plugin.yaml'), *source_resources()]
if BUNDLE:
resources.extend(bundle_resources())
checked_inventory([path.relative_to(SOURCE).as_posix() for path in resources])
plugin = output / PLUGIN_ID
plugin.mkdir(parents=True)
for resource in resources:
target = plugin / resource.relative_to(SOURCE)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(resource, target)
print(json.dumps({'plugin': str(plugin), 'signed': False}, ensure_ascii=False))
if __name__ == '__main__':
main()