Files
RuoYi-Vue3-FastAPI/ruoyi-fastapi-backend/cli/groups/plugin/commands/artifact.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

362 lines
15 KiB
Python

from collections.abc import Callable
from pathlib import Path
from typing import Annotated, Any
import typer
from cli.context import AllowProdOption, DryRunOption, EnvOption, OutputOption, YesOption
from cli.groups.plugin.commands.artifact_maintenance import register_artifact_maintenance_commands
MaintenanceOption = Annotated[
bool, typer.Option('--maintenance', help='确认已停止全部宿主worker,并在独立维护进程执行')
]
ExpectedGenerationOption = Annotated[
str, typer.Option('--expected-generation', help='最近一次plan/status返回的发布代际,防止并发覆盖')
]
def register_artifact_commands(app: typer.Typer, get_controller: Callable[[], Any]) -> None:
"""
注册制品与维护发布命令,帮助和补全阶段不加载配置或数据库。
:param app: Typer 命令组
:param get_controller: 制品命令控制器工厂
:return: None
"""
artifact_app = typer.Typer(help='构建、验证和导入签名插件制品', no_args_is_help=True)
release_app = typer.Typer(help='维护准备、目标选择和worker实际加载状态', no_args_is_help=True)
app.add_typer(artifact_app, name='artifact')
app.add_typer(release_app, name='release')
register_artifact_maintenance_commands(artifact_app, get_controller)
_register_release_enablement(release_app, get_controller)
@artifact_app.command('build', help='离线签名打包预构建插件目录,不覆盖已有输出')
def artifact_build(
source: Annotated[Path, typer.Argument(help='已构建插件目录,目录名必须与插件ID一致')],
destination: Annotated[Path, typer.Argument(help='输出.rpk文件')],
key_file: Annotated[Path, typer.Option('--key-file', help='插件目录之外的Ed25519 PKCS8 PEM签名私钥')],
key_id: Annotated[str, typer.Option('--key-id', help='发布者公钥标识')],
password_env: Annotated[str | None, typer.Option('--password-env', help='读取加密私钥密码的环境变量名')] = None,
env: EnvOption = 'dev',
output: OutputOption = 'text',
) -> None:
"""
离线签名打包预构建插件目录。
:param source: 已构建插件目录
:param destination: 签名制品输出文件
:param key_file: 插件目录之外的 Ed25519 PKCS8 私钥文件
:param key_id: 发布者公钥标识
:param password_env: 保存加密私钥密码的环境变量名称
:param env: 当前命令运行环境
:param output: 输出格式
:return: None
"""
get_controller().build_artifact(
source, destination, env, output, key_file=key_file, key_id=key_id, password_env=password_env
)
@artifact_app.command('verify', help='使用宿主信任公钥和发布者范围验证制品,不加载插件')
def artifact_verify(
path: Annotated[Path, typer.Argument(help='待验证.rpk文件')],
env: EnvOption = 'dev',
output: OutputOption = 'text',
) -> None:
"""
使用宿主信任配置验证制品。
:param path: 待处理的签名制品文件
:param env: 当前命令运行环境
:param output: 输出格式
:return: None
"""
get_controller().verify_artifact(path, env, output)
@artifact_app.command('import', help='验证并导入不可变存储和制品索引,不安装或激活')
def artifact_import(
path: Annotated[Path, typer.Argument(help='待导入.rpk文件')],
env: EnvOption = 'dev',
output: OutputOption = 'text',
allow_prod: AllowProdOption = False,
yes: YesOption = False,
dry_run: DryRunOption = False,
) -> None:
"""
导入不可变制品及索引,暂不激活插件。
:param path: 待处理的签名制品文件
:param env: 当前命令运行环境
:param output: 输出格式
:param allow_prod: 是否允许生产环境危险命令
:param yes: 是否跳过确认
:param dry_run: 是否仅预演而不写入发布状态
:return: None
"""
get_controller().import_artifact(path, env, output, allow_prod=allow_prod, yes=yes, dry_run=dry_run)
@artifact_app.command('list', help='查询已导入制品')
def artifact_list(
plugin_id: Annotated[str | None, typer.Option('--plugin-id', help='仅列出指定插件')] = None,
env: EnvOption = 'dev',
output: OutputOption = 'text',
) -> None:
"""
查询已导入的制品列表。
:param plugin_id: 插件 ID
:param env: 当前命令运行环境
:param output: 输出格式
:return: None
"""
get_controller().list_artifacts(env, output, plugin_id=plugin_id)
@release_app.command('plan', help='静态预检制品维护计划,不执行迁移或Hook')
def release_plan(
plugin_id: Annotated[str, typer.Argument(help='插件ID')],
digest: Annotated[str, typer.Argument(help='已导入制品的SHA256')],
env: EnvOption = 'dev',
output: OutputOption = 'text',
) -> None:
"""
查询目标制品的静态维护计划。
:param plugin_id: 插件 ID
:param digest: 已导入制品的 SHA256 摘要
:param env: 当前命令运行环境
:param output: 输出格式
:return: None
"""
get_controller().plan_release(plugin_id, digest, env, output)
@release_app.command('prepare', help='停机维护执行安装或升级,成功后记录精确制品准备证据')
def release_prepare(
plugin_id: Annotated[str, typer.Argument(help='插件ID')],
digest: Annotated[str, typer.Argument(help='已导入制品的SHA256')],
maintenance: MaintenanceOption = False,
env: EnvOption = 'dev',
output: OutputOption = 'text',
allow_prod: AllowProdOption = False,
yes: YesOption = False,
dry_run: DryRunOption = False,
) -> None:
"""
在维护窗口准备指定制品的数据库与管理资源。
:param plugin_id: 插件 ID
:param digest: 已导入制品的 SHA256 摘要
:param maintenance: 是否确认已停止全部宿主 worker 并进入维护窗口
:param env: 当前命令运行环境
:param output: 输出格式
:param allow_prod: 是否允许生产环境危险命令
:param yes: 是否跳过确认
:param dry_run: 是否仅预演而不写入发布状态
:return: None
"""
get_controller().prepare_release(
plugin_id, digest, env, output, maintenance=maintenance, allow_prod=allow_prod, yes=yes, dry_run=dry_run
)
@release_app.command('select', help='CAS选择已准备制品为目标,重启后由worker报告生效状态')
def release_select(
plugin_id: Annotated[str, typer.Argument(help='插件ID')],
digest: Annotated[str, typer.Argument(help='已准备制品的SHA256')],
expected_generation: ExpectedGenerationOption,
expected_workers: Annotated[int, typer.Option('--expected-workers', min=1, help='预期宿主worker数量')] = 1,
maintenance: MaintenanceOption = False,
env: EnvOption = 'dev',
output: OutputOption = 'text',
allow_prod: AllowProdOption = False,
yes: YesOption = False,
) -> None:
"""
按预期发布代际选择已准备的目标制品。
:param plugin_id: 插件 ID
:param digest: 已导入制品的 SHA256 摘要
:param expected_generation: 最近一次查询得到的发布代际,用于防止并发覆盖
:param expected_workers: 发布完成时预期就绪的宿主 worker 数量
:param maintenance: 是否确认已停止全部宿主 worker 并进入维护窗口
:param env: 当前命令运行环境
:param output: 输出格式
:param allow_prod: 是否允许生产环境危险命令
:param yes: 是否跳过确认
:return: None
"""
get_controller().select_release(
plugin_id,
digest,
env,
output,
expected_generation=expected_generation,
expected_workers=expected_workers,
maintenance=maintenance,
allow_prod=allow_prod,
yes=yes,
)
@release_app.command('status', help='查询目标、数据库准备版本及各worker实际加载结果')
def release_status(
plugin_id: Annotated[str | None, typer.Option('--plugin-id', help='仅查询指定插件')] = None,
env: EnvOption = 'dev',
output: OutputOption = 'text',
) -> None:
"""
查询制品发布状态及 worker 实际加载结果。
:param plugin_id: 插件 ID
:param env: 当前命令运行环境
:param output: 输出格式
:return: None
"""
get_controller().release_status(env, output, plugin_id=plugin_id)
@release_app.command('wait', help='只读等待指定摘要、代际和worker数量全部就绪;超时或失败返回非零')
def release_wait(
plugin_id: Annotated[str, typer.Argument(help='固定等待的插件ID')],
digest: Annotated[str, typer.Argument(help='固定等待的64位小写SHA256摘要')],
generation: Annotated[
str, typer.Option('--generation', help='select、enable或rollback返回的新发布代际,32位小写十六进制')
],
expected_workers: Annotated[int, typer.Option('--expected-workers', min=1, help='本次发布预期宿主worker数量')],
timeout: Annotated[float, typer.Option('--timeout', min=0, help='轮询超时秒数,0表示只查询并断言一次')] = 300,
interval: Annotated[float, typer.Option('--interval', help='两次查询间隔秒数,必须大于0')] = 2,
env: EnvOption = 'dev',
output: OutputOption = 'text',
) -> None:
"""
等待精确发布目标就绪,只输出一次最终结果。
:param plugin_id: 固定等待的插件 ID
:param digest: 固定等待的制品 SHA256 摘要
:param generation: 选择、启用或回滚目标后返回的新发布代际
:param expected_workers: 本次发布要求的宿主 worker 数量
:param timeout: 轮询超时秒数,为零时只查询一次
:param interval: 查询间隔秒数
:param env: 当前命令运行环境
:param output: 输出格式
:return: None
"""
get_controller().wait_release(
plugin_id,
digest,
env,
output,
expected_generation=generation,
expected_workers=expected_workers,
timeout_seconds=timeout,
poll_interval_seconds=interval,
)
@release_app.command('rollback', help='选择上一个代码制品,保留当前数据库安装版本')
def release_rollback(
plugin_id: Annotated[str, typer.Argument(help='插件ID')],
expected_generation: ExpectedGenerationOption,
schema_compatible: Annotated[
bool, typer.Option('--schema-compatible', help='确认旧代码兼容当前数据结构,不执行数据库回滚')
] = False,
maintenance: MaintenanceOption = False,
env: EnvOption = 'dev',
output: OutputOption = 'text',
allow_prod: AllowProdOption = False,
yes: YesOption = False,
) -> None:
"""
确认数据兼容后选择上一代码制品。
:param plugin_id: 插件 ID
:param expected_generation: 最近一次查询得到的发布代际,用于防止并发覆盖
:param schema_compatible: 是否确认旧代码兼容当前数据结构
:param maintenance: 是否确认已停止全部宿主 worker 并进入维护窗口
:param env: 当前命令运行环境
:param output: 输出格式
:param allow_prod: 是否允许生产环境危险命令
:param yes: 是否跳过确认
:return: None
"""
get_controller().rollback_release(
plugin_id,
env,
output,
expected_generation=expected_generation,
schema_compatible=schema_compatible,
maintenance=maintenance,
allow_prod=allow_prod,
yes=yes,
)
def _register_release_enablement(app: typer.Typer, get_controller: Callable[[], Any]) -> None:
"""
注册维护窗口内使用的制品启停命令。
:param app: Typer 命令组
:param get_controller: 制品命令控制器工厂
:return: None
"""
@app.command('enable', help='维护启用目标制品并更新代际,重启后生效')
def release_enable(
plugin_id: Annotated[str, typer.Argument(help='插件ID')],
expected_generation: ExpectedGenerationOption,
maintenance: MaintenanceOption = False,
env: EnvOption = 'dev',
output: OutputOption = 'text',
allow_prod: AllowProdOption = False,
yes: YesOption = False,
) -> None:
"""
在维护窗口启用目标制品,重启后生效。
:param plugin_id: 插件 ID
:param expected_generation: 最近一次查询得到的发布代际,用于防止并发覆盖
:param maintenance: 是否确认已停止全部宿主 worker 并进入维护窗口
:param env: 当前命令运行环境
:param output: 输出格式
:param allow_prod: 是否允许生产环境危险命令
:param yes: 是否跳过确认
:return: None
"""
get_controller().set_release_enabled(
plugin_id,
env,
output,
enabled=True,
expected_generation=expected_generation,
maintenance=maintenance,
allow_prod=allow_prod,
yes=yes,
)
@app.command('disable', help='维护停用目标制品并更新代际,重启后生效')
def release_disable(
plugin_id: Annotated[str, typer.Argument(help='插件ID')],
expected_generation: ExpectedGenerationOption,
maintenance: MaintenanceOption = False,
env: EnvOption = 'dev',
output: OutputOption = 'text',
allow_prod: AllowProdOption = False,
yes: YesOption = False,
) -> None:
"""
在维护窗口停用目标制品,重启后生效。
:param plugin_id: 插件 ID
:param expected_generation: 最近一次查询得到的发布代际,用于防止并发覆盖
:param maintenance: 是否确认已停止全部宿主 worker 并进入维护窗口
:param env: 当前命令运行环境
:param output: 输出格式
:param allow_prod: 是否允许生产环境危险命令
:param yes: 是否跳过确认
:return: None
"""
get_controller().set_release_enabled(
plugin_id,
env,
output,
enabled=False,
expected_generation=expected_generation,
maintenance=maintenance,
allow_prod=allow_prod,
yes=yes,
)