mirror of
https://github.com/insistence/RuoYi-Vue3-FastAPI.git
synced 2026-10-17 06:55:35 +00:00
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:
@@ -0,0 +1,655 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from cli.context import CliContext
|
||||
from cli.core import DEFAULT_CORE_SERVICES, CliContextFactory, CliExecutionService
|
||||
from cli.exit_codes import ARGUMENT_ERROR
|
||||
from cli.runtime.base import RUNTIME_OPERATOR
|
||||
|
||||
MAX_SIGNING_KEY_BYTES = 64 * 1024
|
||||
|
||||
|
||||
class PluginArtifactCommandController:
|
||||
"""
|
||||
制品发布命令控制器。
|
||||
|
||||
普通插件命令及帮助查询不加载制品部署依赖。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
context_factory: CliContextFactory | None = None,
|
||||
execution_service: CliExecutionService | None = None,
|
||||
config_factory: Callable[[], Any] | None = None,
|
||||
catalog_factory: Callable[[Any], Any] | None = None,
|
||||
service_factory: Callable[[Any], Any] | None = None,
|
||||
enablement_factory: Callable[[Any], Any] | None = None,
|
||||
maintenance_factory: Callable[[Any], Any] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化制品命令控制器,并保留部署依赖的延迟加载。
|
||||
|
||||
:param context_factory: CLI 上下文工厂
|
||||
:param execution_service: CLI 执行服务
|
||||
:param config_factory: 制品部署配置工厂
|
||||
:param catalog_factory: 制品目录服务工厂
|
||||
:param service_factory: 维护发布服务工厂
|
||||
:param enablement_factory: 制品启停服务工厂
|
||||
:param maintenance_factory: 制品清理与签名轮换服务工厂
|
||||
:return: None
|
||||
"""
|
||||
self.context_factory = context_factory or DEFAULT_CORE_SERVICES.context_factory
|
||||
self.execution_service = execution_service or DEFAULT_CORE_SERVICES.execution_service
|
||||
self.config_factory = config_factory or self._default_config
|
||||
self.catalog_factory = catalog_factory or self._default_catalog
|
||||
self.service_factory = service_factory or self._default_service
|
||||
self.enablement_factory = enablement_factory or self._default_enablement
|
||||
self.maintenance_factory = maintenance_factory or self._default_maintenance
|
||||
|
||||
@staticmethod
|
||||
def _default_maintenance(config: Any) -> Any:
|
||||
"""
|
||||
延迟创建制品维护服务,帮助查询不加载数据库。
|
||||
|
||||
:param config: 制品发布配置
|
||||
:return: 制品维护服务
|
||||
"""
|
||||
return importlib.import_module('plugins.core.deployment.maintenance').PluginArtifactMaintenanceService(config)
|
||||
|
||||
def maintain_artifacts(
|
||||
self,
|
||||
operation: str,
|
||||
env: str,
|
||||
output: str,
|
||||
*,
|
||||
options: dict[str, Any],
|
||||
allow_prod: bool = False,
|
||||
yes: bool = False,
|
||||
dry_run: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
执行白名单中的维护操作,并复用 CLI 生产环境保护。
|
||||
|
||||
:param operation: inspect、prune、rotate-signature 或 reconcile
|
||||
:param env: 当前命令环境
|
||||
:param output: 输出格式
|
||||
:param options: 当前命令显式支持的维护参数
|
||||
:param allow_prod: 是否允许生产环境变更
|
||||
:param yes: 是否跳过交互确认
|
||||
:param dry_run: 是否只生成清理计划
|
||||
:return: None
|
||||
"""
|
||||
methods = {
|
||||
'inspect': 'inspect',
|
||||
'prune': 'prune',
|
||||
'rotate-signature': 'rotate_signature',
|
||||
'reconcile': 'reconcile',
|
||||
}
|
||||
if operation not in methods:
|
||||
raise ValueError('未知制品维护命令')
|
||||
ctx = self._context(
|
||||
env,
|
||||
output,
|
||||
command_name=None if operation == 'inspect' else f'plugin artifact {operation}',
|
||||
allow_prod=allow_prod,
|
||||
yes=yes,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
arguments = dict(options)
|
||||
if operation != 'inspect':
|
||||
arguments['actor'] = ctx.operator
|
||||
if operation == 'prune':
|
||||
arguments['dry_run'] = dry_run
|
||||
self._execute(
|
||||
ctx,
|
||||
lambda: self.execution_service.run_async(
|
||||
getattr(self.maintenance_factory(self.config_factory()), methods[operation])(**arguments)
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _default_config() -> Any:
|
||||
"""
|
||||
延迟读取当前环境的制品部署配置。
|
||||
|
||||
:return: 制品部署配置
|
||||
"""
|
||||
module = importlib.import_module('plugins.core.deployment.config')
|
||||
return module.PluginDeploymentConfig.from_settings(Path(__file__).resolve().parents[3])
|
||||
|
||||
@staticmethod
|
||||
def _default_catalog(config: Any) -> Any:
|
||||
"""
|
||||
延迟创建制品目录服务。
|
||||
|
||||
:param config: 制品部署配置
|
||||
:return: 制品目录服务
|
||||
"""
|
||||
return importlib.import_module('plugins.core.deployment.catalog').PluginArtifactCatalog(config)
|
||||
|
||||
@staticmethod
|
||||
def _default_service(config: Any) -> Any:
|
||||
"""
|
||||
延迟创建维护发布服务。
|
||||
|
||||
:param config: 制品部署配置
|
||||
:return: 维护发布服务
|
||||
"""
|
||||
return importlib.import_module('plugins.core.deployment.service').PluginDeploymentService(config)
|
||||
|
||||
@staticmethod
|
||||
def _default_enablement(config: Any) -> Any:
|
||||
"""
|
||||
延迟创建维护启停服务。
|
||||
|
||||
:param config: 制品部署配置
|
||||
:return: 维护启停服务
|
||||
"""
|
||||
return importlib.import_module('plugins.core.deployment.enablement').PluginArtifactEnablementService(config)
|
||||
|
||||
def _context(
|
||||
self,
|
||||
env: str,
|
||||
output: str,
|
||||
*,
|
||||
command_name: str | None = None,
|
||||
allow_prod: bool = False,
|
||||
yes: bool = False,
|
||||
dry_run: bool = False,
|
||||
) -> CliContext:
|
||||
# config.env 可能在应用日志策略时首次导入,因此必须先设置命令选择的环境。
|
||||
"""
|
||||
先设置命令环境,再应用危险操作保护并解析操作者。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param command_name: 用于匹配危险操作规则的命令名称
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否仅预演而不写入发布状态
|
||||
:return: 完成环境与操作者设置的 CLI 上下文
|
||||
"""
|
||||
os.environ['APP_ENV'] = env
|
||||
if command_name and not dry_run:
|
||||
ctx = self.context_factory.build_dangerous(env, output, allow_prod, yes, dry_run, command_name=command_name)
|
||||
else:
|
||||
ctx = self.context_factory.build_regular(env, output, allow_prod, yes, dry_run)
|
||||
ctx.operator = RUNTIME_OPERATOR.resolve_operator()
|
||||
return ctx
|
||||
|
||||
def _execute(self, ctx: CliContext, action: Callable[[], dict[str, Any]]) -> None:
|
||||
"""
|
||||
执行命令操作并统一输出成功结果或异常信息。
|
||||
|
||||
:param ctx: CLI 上下文
|
||||
:param action: 当前命令需要执行的操作
|
||||
:return: None
|
||||
"""
|
||||
try:
|
||||
payload = action()
|
||||
except Exception as exc:
|
||||
payload = {'ok': False, 'message': str(exc), 'errorType': type(exc).__name__}
|
||||
self.execution_service.complete_payload(ctx, {**payload, 'env': ctx.env})
|
||||
|
||||
@staticmethod
|
||||
def _identity(artifact: Any) -> dict[str, Any]:
|
||||
"""
|
||||
提取制品身份和文件数量,供命令结果复用。
|
||||
|
||||
:param artifact: 已构建或验证的制品
|
||||
:return: 制品身份字段
|
||||
"""
|
||||
return {
|
||||
'pluginId': artifact.plugin_id,
|
||||
'version': artifact.version,
|
||||
'digest': artifact.digest,
|
||||
'keyId': artifact.key_id,
|
||||
'fileCount': len(artifact.files),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _signing_key(source: Path, key_file: Path, password_env: str | None) -> Any:
|
||||
"""
|
||||
读取位于插件目录之外的签名私钥,密码仅从指定环境变量读取。
|
||||
|
||||
:param source: 已构建插件目录
|
||||
:param key_file: 插件目录之外的 Ed25519 PKCS8 私钥文件
|
||||
:param password_env: 保存加密私钥密码的环境变量名称
|
||||
:return: Ed25519 签名私钥
|
||||
"""
|
||||
if key_file.resolve().is_relative_to(source.resolve()):
|
||||
raise ValueError('签名私钥必须放在插件源码/预构建目录之外,防止被打入制品')
|
||||
if not key_file.is_file() or key_file.stat().st_size > MAX_SIGNING_KEY_BYTES:
|
||||
raise ValueError('签名私钥文件不存在或过大')
|
||||
password = None
|
||||
if password_env is not None:
|
||||
value = os.environ.get(password_env)
|
||||
if not value:
|
||||
raise ValueError(f'私钥密码环境变量未设置:{password_env}')
|
||||
password = value.encode('utf-8')
|
||||
data = key_file.read_bytes()
|
||||
if not data.startswith((b'-----BEGIN PRIVATE KEY-----', b'-----BEGIN ENCRYPTED PRIVATE KEY-----')):
|
||||
raise ValueError('签名私钥必须是PKCS8 PEM格式')
|
||||
serialization = importlib.import_module('cryptography.hazmat.primitives.serialization')
|
||||
ed25519 = importlib.import_module('cryptography.hazmat.primitives.asymmetric.ed25519')
|
||||
try:
|
||||
key = serialization.load_pem_private_key(data, password=password)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError('无法读取PKCS8私钥,请检查密钥格式和密码环境变量') from None
|
||||
if not isinstance(key, ed25519.Ed25519PrivateKey):
|
||||
raise ValueError('制品签名私钥必须为Ed25519')
|
||||
return key
|
||||
|
||||
def build_artifact(
|
||||
self,
|
||||
source: Path,
|
||||
destination: Path,
|
||||
env: str,
|
||||
output: str,
|
||||
*,
|
||||
key_file: Path,
|
||||
key_id: str,
|
||||
password_env: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
离线签名打包预构建目录,不加载部署配置或插件代码。
|
||||
|
||||
:param source: 已构建插件目录
|
||||
:param destination: 签名制品输出文件
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param key_file: 插件目录之外的 Ed25519 PKCS8 私钥文件
|
||||
:param key_id: 发布者公钥标识
|
||||
:param password_env: 保存加密私钥密码的环境变量名称
|
||||
:return: None
|
||||
"""
|
||||
ctx = self._context(env, output)
|
||||
|
||||
def build() -> dict[str, Any]:
|
||||
"""
|
||||
读取签名私钥并返回离线构建结果。
|
||||
|
||||
:return: 制品路径和身份信息
|
||||
"""
|
||||
key = self._signing_key(source, key_file, password_env)
|
||||
artifact = importlib.import_module('plugins.core.artifacts').build_artifact(
|
||||
source, destination, key, key_id
|
||||
)
|
||||
return {'ok': True, 'path': str(artifact.artifact_path), **self._identity(artifact)}
|
||||
|
||||
self._execute(ctx, build)
|
||||
|
||||
def verify_artifact(self, path: Path, env: str, output: str) -> None:
|
||||
"""
|
||||
校验制品签名、完整摘要与发布者授权范围。
|
||||
|
||||
:param path: 待处理的签名制品文件
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
ctx = self._context(env, output)
|
||||
|
||||
def verify() -> dict[str, Any]:
|
||||
"""
|
||||
按当前宿主信任配置验证制品及发布者范围。
|
||||
|
||||
:return: 制品验证结果
|
||||
"""
|
||||
config = self.config_factory()
|
||||
verified = importlib.import_module('plugins.core.artifacts').verify_artifact(path, config.trusted_keys())
|
||||
config.authorize_publisher(verified.key_id, verified.plugin_id)
|
||||
return {'ok': True, 'message': '签名、完整摘要和发布者授权检查通过', **self._identity(verified)}
|
||||
|
||||
self._execute(ctx, verify)
|
||||
|
||||
def import_artifact(self, path: Path, env: str, output: str, *, allow_prod: bool, yes: bool, dry_run: bool) -> None:
|
||||
"""
|
||||
导入已验证制品及其索引,预演时只执行检查。
|
||||
|
||||
:param path: 待处理的签名制品文件
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否仅预演而不写入发布状态
|
||||
:return: None
|
||||
"""
|
||||
ctx = self._context(
|
||||
env, output, command_name='plugin artifact import', allow_prod=allow_prod, yes=yes, dry_run=dry_run
|
||||
)
|
||||
self._execute(
|
||||
ctx,
|
||||
lambda: self.execution_service.run_async(
|
||||
self.catalog_factory(self.config_factory()).import_artifact(path, actor=ctx.operator, dry_run=dry_run)
|
||||
),
|
||||
)
|
||||
|
||||
def list_artifacts(self, env: str, output: str, *, plugin_id: str | None = None) -> None:
|
||||
"""
|
||||
查询已导入制品,可按插件 ID 筛选。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param plugin_id: 插件 ID
|
||||
:return: None
|
||||
"""
|
||||
ctx = self._context(env, output)
|
||||
self._execute(
|
||||
ctx,
|
||||
lambda: self.execution_service.run_async(
|
||||
self.catalog_factory(self.config_factory()).list_artifacts(plugin_id)
|
||||
),
|
||||
)
|
||||
|
||||
def plan_release(self, plugin_id: str, digest: str, env: str, output: str) -> None:
|
||||
"""
|
||||
静态检查目标制品并输出维护计划。
|
||||
|
||||
:param plugin_id: 插件 ID
|
||||
:param digest: 已导入制品的 SHA256 摘要
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
ctx = self._context(env, output)
|
||||
self._execute(
|
||||
ctx,
|
||||
lambda: self.execution_service.run_async(
|
||||
self.service_factory(self.config_factory()).plan(plugin_id, digest)
|
||||
),
|
||||
)
|
||||
|
||||
def prepare_release(
|
||||
self,
|
||||
plugin_id: str,
|
||||
digest: str,
|
||||
env: str,
|
||||
output: str,
|
||||
*,
|
||||
maintenance: bool,
|
||||
allow_prod: bool,
|
||||
yes: bool,
|
||||
dry_run: bool,
|
||||
) -> None:
|
||||
"""
|
||||
在维护窗口执行安装或升级,并记录制品准备结果。
|
||||
|
||||
:param plugin_id: 插件 ID
|
||||
:param digest: 已导入制品的 SHA256 摘要
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param maintenance: 是否确认已停止全部宿主 worker 并进入维护窗口
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否仅预演而不写入发布状态
|
||||
:return: None
|
||||
"""
|
||||
ctx = self._context(
|
||||
env, output, command_name='plugin release prepare', allow_prod=allow_prod, yes=yes, dry_run=dry_run
|
||||
)
|
||||
self._execute(
|
||||
ctx,
|
||||
lambda: self.execution_service.run_async(
|
||||
self.service_factory(self.config_factory()).prepare(
|
||||
plugin_id, digest, maintenance=maintenance, actor=ctx.operator, dry_run=dry_run
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
def select_release(
|
||||
self,
|
||||
plugin_id: str,
|
||||
digest: str,
|
||||
env: str,
|
||||
output: str,
|
||||
*,
|
||||
expected_generation: str,
|
||||
expected_workers: int,
|
||||
maintenance: bool,
|
||||
allow_prod: bool,
|
||||
yes: bool,
|
||||
) -> None:
|
||||
"""
|
||||
按预期发布代际选择目标制品,供重启后的 worker 加载。
|
||||
|
||||
:param plugin_id: 插件 ID
|
||||
:param digest: 已导入制品的 SHA256 摘要
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param expected_generation: 最近一次查询得到的发布代际,用于防止并发覆盖
|
||||
:param expected_workers: 发布完成时预期就绪的宿主 worker 数量
|
||||
:param maintenance: 是否确认已停止全部宿主 worker 并进入维护窗口
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:return: None
|
||||
"""
|
||||
ctx = self._context(env, output, command_name='plugin release select', allow_prod=allow_prod, yes=yes)
|
||||
self._execute(
|
||||
ctx,
|
||||
lambda: self.execution_service.run_async(
|
||||
self.service_factory(self.config_factory()).select(
|
||||
plugin_id,
|
||||
digest,
|
||||
expected_generation=expected_generation,
|
||||
expected_workers=expected_workers,
|
||||
maintenance=maintenance,
|
||||
actor=ctx.operator,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
def release_status(self, env: str, output: str, *, plugin_id: str | None = None) -> None:
|
||||
"""
|
||||
查询目标制品、数据准备状态和 worker 加载结果。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param plugin_id: 插件 ID
|
||||
:return: None
|
||||
"""
|
||||
ctx = self._context(env, output)
|
||||
self._execute(
|
||||
ctx,
|
||||
lambda: self.execution_service.run_async(self.service_factory(self.config_factory()).status(plugin_id)),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _release_wait_argument_error(
|
||||
plugin_id: str,
|
||||
digest: str,
|
||||
expected_generation: str,
|
||||
expected_workers: int,
|
||||
timeout_seconds: float,
|
||||
poll_interval_seconds: float,
|
||||
) -> str | None:
|
||||
"""
|
||||
在读取部署配置前校验固定目标和有限时间参数。
|
||||
|
||||
:param plugin_id: 固定等待的插件 ID
|
||||
:param digest: 固定等待的制品 SHA256 摘要
|
||||
:param expected_generation: 选择、启用或回滚目标后返回的新发布代际
|
||||
:param expected_workers: 本次发布要求的宿主 worker 数量
|
||||
:param timeout_seconds: 非负的有限轮询超时秒数
|
||||
:param poll_interval_seconds: 正的有限查询间隔秒数
|
||||
:return: 固定参数错误描述,参数有效时为 None
|
||||
"""
|
||||
for value, pattern, message in (
|
||||
(plugin_id, r'[a-z][a-z0-9_]{1,63}', '插件 ID 必须为 2 至 64 位小写字母、数字或下划线,并以字母开头'),
|
||||
(digest, r'[0-9a-f]{64}', 'digest 必须为 64 位小写十六进制摘要'),
|
||||
(expected_generation, r'[0-9a-f]{32}', 'generation 必须为 32 位小写十六进制代际'),
|
||||
):
|
||||
if not isinstance(value, str) or re.fullmatch(pattern, value) is None:
|
||||
return message
|
||||
if type(expected_workers) is not int or expected_workers < 1:
|
||||
return 'expected-workers 必须为正整数'
|
||||
for value, label, allow_zero in (
|
||||
(timeout_seconds, 'timeout', True),
|
||||
(poll_interval_seconds, 'interval', False),
|
||||
):
|
||||
try:
|
||||
finite = type(value) in (int, float) and math.isfinite(value)
|
||||
except OverflowError:
|
||||
finite = False
|
||||
if not finite or value < 0 or (not allow_zero and value == 0):
|
||||
return f'{label} 必须为有限数值,且' + ('不小于 0' if allow_zero else '大于 0')
|
||||
return None
|
||||
|
||||
def wait_release(
|
||||
self,
|
||||
plugin_id: str,
|
||||
digest: str,
|
||||
env: str,
|
||||
output: str,
|
||||
*,
|
||||
expected_generation: str,
|
||||
expected_workers: int,
|
||||
timeout_seconds: float = 300,
|
||||
poll_interval_seconds: float = 2,
|
||||
) -> None:
|
||||
"""
|
||||
只读等待固定发布目标,将取消或初始化异常转换为脱敏的最终结果。
|
||||
|
||||
:param plugin_id: 固定等待的插件 ID
|
||||
:param digest: 固定等待的制品 SHA256 摘要
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param expected_generation: 选择、启用或回滚目标后返回的新发布代际
|
||||
:param expected_workers: 本次发布要求的宿主 worker 数量
|
||||
:param timeout_seconds: 轮询超时秒数,为零时只查询一次
|
||||
:param poll_interval_seconds: 两次查询的间隔秒数
|
||||
:return: None
|
||||
"""
|
||||
error = self._release_wait_argument_error(
|
||||
plugin_id, digest, expected_generation, expected_workers, timeout_seconds, poll_interval_seconds
|
||||
)
|
||||
ctx = CliContext(env=env, output=output)
|
||||
payload: dict[str, Any] = {
|
||||
'ok': False,
|
||||
'operation': 'release_wait',
|
||||
'reason': 'invalid_arguments' if error else 'unavailable',
|
||||
'message': error or '发布状态暂不可查询',
|
||||
'target': {
|
||||
'pluginId': plugin_id,
|
||||
'digest': digest,
|
||||
'generation': expected_generation,
|
||||
'expectedWorkers': expected_workers,
|
||||
},
|
||||
'lastObserved': None,
|
||||
'attempts': 0 if error else None,
|
||||
'elapsedSeconds': 0.0,
|
||||
'timeoutSeconds': None if error else timeout_seconds,
|
||||
'intervalSeconds': None if error else poll_interval_seconds,
|
||||
}
|
||||
if error:
|
||||
self.execution_service.complete_payload(ctx, {**payload, 'exit_code': ARGUMENT_ERROR})
|
||||
return
|
||||
started = time.monotonic()
|
||||
try:
|
||||
ctx = self._context(env, output)
|
||||
payload = self.execution_service.run_async(
|
||||
self.service_factory(self.config_factory()).wait(
|
||||
plugin_id,
|
||||
digest,
|
||||
expected_generation=expected_generation,
|
||||
expected_workers=expected_workers,
|
||||
timeout_seconds=timeout_seconds,
|
||||
poll_interval_seconds=poll_interval_seconds,
|
||||
)
|
||||
)
|
||||
except (asyncio.CancelledError, KeyboardInterrupt):
|
||||
payload.update(ok=False, reason='cancelled', message='已取消等待发布就绪', attempts=None, lastObserved=None)
|
||||
payload['elapsedSeconds'] = round(max(0.0, time.monotonic() - started), 3)
|
||||
except Exception:
|
||||
payload.update(
|
||||
ok=False, reason='unavailable', message='发布状态暂不可查询', attempts=None, lastObserved=None
|
||||
)
|
||||
payload['elapsedSeconds'] = round(max(0.0, time.monotonic() - started), 3)
|
||||
self.execution_service.complete_payload(ctx, payload)
|
||||
|
||||
def rollback_release(
|
||||
self,
|
||||
plugin_id: str,
|
||||
env: str,
|
||||
output: str,
|
||||
*,
|
||||
expected_generation: str,
|
||||
schema_compatible: bool,
|
||||
maintenance: bool,
|
||||
allow_prod: bool,
|
||||
yes: bool,
|
||||
) -> None:
|
||||
"""
|
||||
确认数据兼容后选择上一代码制品,不回滚数据库迁移。
|
||||
|
||||
:param plugin_id: 插件 ID
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param expected_generation: 最近一次查询得到的发布代际,用于防止并发覆盖
|
||||
:param schema_compatible: 是否确认旧代码兼容当前数据结构
|
||||
:param maintenance: 是否确认已停止全部宿主 worker 并进入维护窗口
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:return: None
|
||||
"""
|
||||
ctx = self._context(env, output, command_name='plugin release rollback', allow_prod=allow_prod, yes=yes)
|
||||
self._execute(
|
||||
ctx,
|
||||
lambda: self.execution_service.run_async(
|
||||
self.service_factory(self.config_factory()).rollback(
|
||||
plugin_id,
|
||||
expected_generation=expected_generation,
|
||||
schema_compatible=schema_compatible,
|
||||
maintenance=maintenance,
|
||||
actor=ctx.operator,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
def set_release_enabled(
|
||||
self,
|
||||
plugin_id: str,
|
||||
env: str,
|
||||
output: str,
|
||||
*,
|
||||
enabled: bool,
|
||||
expected_generation: str,
|
||||
maintenance: bool,
|
||||
allow_prod: bool,
|
||||
yes: bool,
|
||||
) -> None:
|
||||
"""
|
||||
在维护窗口更新目标制品的启停状态与发布代际。
|
||||
|
||||
:param plugin_id: 插件 ID
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param enabled: 是否启用目标制品
|
||||
:param expected_generation: 最近一次查询得到的发布代际,用于防止并发覆盖
|
||||
:param maintenance: 是否确认已停止全部宿主 worker 并进入维护窗口
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:return: None
|
||||
"""
|
||||
operation = 'enable' if enabled else 'disable'
|
||||
ctx = self._context(env, output, command_name=f'plugin release {operation}', allow_prod=allow_prod, yes=yes)
|
||||
self._execute(
|
||||
ctx,
|
||||
lambda: self.execution_service.run_async(
|
||||
self.enablement_factory(self.config_factory()).set_enabled(
|
||||
plugin_id,
|
||||
enabled=enabled,
|
||||
expected_generation=expected_generation,
|
||||
maintenance=maintenance,
|
||||
actor=ctx.operator,
|
||||
)
|
||||
),
|
||||
)
|
||||
@@ -4,13 +4,16 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import typer
|
||||
|
||||
from .commands.artifact import register_artifact_commands
|
||||
from .commands.configuration import register_configuration_commands
|
||||
from .commands.dependency import register_dependency_commands
|
||||
from .commands.developer import register_developer_commands
|
||||
from .commands.discovery import register_discovery_commands
|
||||
from .commands.lifecycle import register_lifecycle_commands
|
||||
from .commands.sdk import register_sdk_commands
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .artifact_controller import PluginArtifactCommandController
|
||||
from .controller import PluginCommandController
|
||||
|
||||
app = typer.Typer(
|
||||
@@ -31,8 +34,21 @@ def _get_plugin_command_controller() -> 'PluginCommandController':
|
||||
return controller_class()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _get_plugin_artifact_controller() -> 'PluginArtifactCommandController':
|
||||
"""
|
||||
延迟获取制品命令控制器,避免帮助查询加载部署依赖。
|
||||
|
||||
:return: 制品命令控制器
|
||||
"""
|
||||
controller_class = importlib.import_module('cli.groups.plugin.artifact_controller').PluginArtifactCommandController
|
||||
return controller_class()
|
||||
|
||||
|
||||
register_discovery_commands(app, _get_plugin_command_controller)
|
||||
register_configuration_commands(app, _get_plugin_command_controller)
|
||||
register_dependency_commands(app, _get_plugin_command_controller)
|
||||
register_lifecycle_commands(app, _get_plugin_command_controller)
|
||||
register_developer_commands(app, _get_plugin_command_controller)
|
||||
register_artifact_commands(app, _get_plugin_artifact_controller)
|
||||
register_sdk_commands(app)
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,152 @@
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
|
||||
import typer
|
||||
|
||||
from cli.context import AllowProdOption, EnvOption, OutputOption, YesOption
|
||||
|
||||
KeepLastOption = Annotated[int, typer.Option('--keep-last', min=0, help='每个插件保留最近导入的制品数')]
|
||||
MinAgeOption = Annotated[int, typer.Option('--min-age-days', min=0, help='最短保留天数')]
|
||||
MaintenanceOption = Annotated[bool, typer.Option('--maintenance', help='确认全部宿主进程已停止')]
|
||||
|
||||
|
||||
def register_artifact_maintenance_commands(app: typer.Typer, get_controller: Callable[[], Any]) -> None:
|
||||
"""
|
||||
注册制品只读检查、清理预演、签名轮换及中断恢复命令。
|
||||
|
||||
:param app: 制品命令组
|
||||
:param get_controller: 延迟获取命令控制器的工厂
|
||||
:return: None
|
||||
"""
|
||||
|
||||
@app.command('inspect', help='核对存储和索引,列出保护引用、孤儿对象与无效制品')
|
||||
def inspect_artifacts(
|
||||
keep_last: KeepLastOption = 2,
|
||||
min_age_days: MinAgeOption = 7,
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
) -> None:
|
||||
"""
|
||||
只读检查制品存储。
|
||||
|
||||
:param keep_last: 每个插件保留的最近导入数量
|
||||
:param min_age_days: 最短保留天数
|
||||
:param env: 当前命令环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
get_controller().maintain_artifacts(
|
||||
'inspect',
|
||||
env,
|
||||
output,
|
||||
options={
|
||||
'keep_last': keep_last,
|
||||
'min_age_days': min_age_days,
|
||||
},
|
||||
)
|
||||
|
||||
@app.command('prune', help='默认只预演;核对计划后传入 --execute 才执行清理')
|
||||
def prune_artifacts(
|
||||
keep_last: KeepLastOption = 2,
|
||||
min_age_days: MinAgeOption = 7,
|
||||
expected_plan: Annotated[str | None, typer.Option('--expected-plan', help='已核对的预演计划 SHA256')] = None,
|
||||
execute: Annotated[bool, typer.Option('--execute', help='执行清理,默认不写入')] = False,
|
||||
maintenance: MaintenanceOption = False,
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
allow_prod: AllowProdOption = False,
|
||||
yes: YesOption = False,
|
||||
) -> None:
|
||||
"""
|
||||
生成或执行已核对的制品清理计划。
|
||||
|
||||
:param keep_last: 每个插件保留的最近导入数量
|
||||
:param min_age_days: 最短保留天数
|
||||
:param expected_plan: 已核对的预演计划摘要
|
||||
:param execute: 是否真正执行文件和索引清理
|
||||
:param maintenance: 是否确认全部宿主进程已停止
|
||||
:param env: 当前命令环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境变更
|
||||
:param yes: 是否跳过交互确认
|
||||
:return: None
|
||||
"""
|
||||
get_controller().maintain_artifacts(
|
||||
'prune',
|
||||
env,
|
||||
output,
|
||||
options={
|
||||
'keep_last': keep_last,
|
||||
'min_age_days': min_age_days,
|
||||
'expected_plan': expected_plan,
|
||||
'maintenance': maintenance,
|
||||
},
|
||||
allow_prod=allow_prod,
|
||||
yes=yes,
|
||||
dry_run=not execute,
|
||||
)
|
||||
|
||||
@app.command('rotate-signature', help='用新签名 rpk 轮换同一摘要制品的签名,不更改插件字节')
|
||||
def rotate_signature(
|
||||
archive: Annotated[Path, typer.Argument(help='使用新可信密钥签名、内容完全一致的 rpk')],
|
||||
expected_key_id: Annotated[str, typer.Option('--expected-key-id', help='当前索引中的原签名密钥标识')],
|
||||
maintenance: MaintenanceOption = False,
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
allow_prod: AllowProdOption = False,
|
||||
yes: YesOption = False,
|
||||
) -> None:
|
||||
"""
|
||||
维护轮换同一制品的签名密钥。
|
||||
|
||||
:param archive: 新密钥签名的同内容制品
|
||||
:param expected_key_id: 原签名密钥标识
|
||||
:param maintenance: 是否确认全部宿主进程已停止
|
||||
:param env: 当前命令环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境变更
|
||||
:param yes: 是否跳过交互确认
|
||||
:return: None
|
||||
"""
|
||||
get_controller().maintain_artifacts(
|
||||
'rotate-signature',
|
||||
env,
|
||||
output,
|
||||
options={
|
||||
'archive': archive,
|
||||
'expected_key_id': expected_key_id,
|
||||
'maintenance': maintenance,
|
||||
},
|
||||
allow_prod=allow_prod,
|
||||
yes=yes,
|
||||
)
|
||||
|
||||
@app.command('reconcile', help='按已提交索引恢复中断的清理或签名轮换')
|
||||
def reconcile_artifacts(
|
||||
maintenance: MaintenanceOption = False,
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
allow_prod: AllowProdOption = False,
|
||||
yes: YesOption = False,
|
||||
) -> None:
|
||||
"""
|
||||
在停机窗口恢复中断维护。
|
||||
|
||||
:param maintenance: 是否确认全部宿主进程已停止
|
||||
:param env: 当前命令环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境变更
|
||||
:param yes: 是否跳过交互确认
|
||||
:return: None
|
||||
"""
|
||||
get_controller().maintain_artifacts(
|
||||
'reconcile',
|
||||
env,
|
||||
output,
|
||||
options={
|
||||
'maintenance': maintenance,
|
||||
},
|
||||
allow_prod=allow_prod,
|
||||
yes=yes,
|
||||
)
|
||||
@@ -57,11 +57,15 @@ def register_developer_commands(app: typer.Typer, get_controller: Callable[[], A
|
||||
output: OutputOption = 'text',
|
||||
template: Annotated[
|
||||
str,
|
||||
typer.Option('--template', help='插件模板:minimal、backend-only、full-stack、scheduled-job、crud-page'),
|
||||
typer.Option(
|
||||
'--template',
|
||||
help='v1:minimal、backend-only、full-stack、scheduled-job、crud-page;'
|
||||
'v2:python-asgi、python-bundle、rust-asgi、rust-bundle(生成独立源码工程)',
|
||||
),
|
||||
] = 'full-stack',
|
||||
frontend_version: Annotated[
|
||||
Literal['auto', 'vue2', 'vue3'],
|
||||
typer.Option('--frontend-version', help='前端 Vue 版本:auto、vue2、vue3;auto 读取 package.json'),
|
||||
typer.Option('--frontend-version', help='v1 前端 Vue 版本:auto、vue2、vue3;v2 bundle 不使用宿主 Vue'),
|
||||
] = 'auto',
|
||||
backend_only: Annotated[bool, typer.Option('--backend-only', help='只创建后端插件模板')] = False,
|
||||
frontend_only: Annotated[bool, typer.Option('--frontend-only', help='只创建前端插件模板')] = False,
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
|
||||
import typer
|
||||
|
||||
from cli.context import CliContext, DryRunOption, OutputOption
|
||||
from cli.core import DEFAULT_CORE_SERVICES
|
||||
from cli.exit_codes import ARGUMENT_ERROR, DEPENDENCY_ERROR, RUNTIME_ERROR, SUCCESS
|
||||
|
||||
FrontendRootOption = Annotated[
|
||||
Path | None,
|
||||
typer.Option('--frontend-root', help='作为 SDK 来源的宿主前端目录,默认相邻前端或 RUOYI_PLUGIN_FRONTEND_ROOT'),
|
||||
]
|
||||
|
||||
|
||||
def _format_sdk_result(payload: dict[str, Any]) -> str:
|
||||
"""
|
||||
展示来源版本、检查状态和备份位置,方便人工核对更新结果。
|
||||
|
||||
:param payload: SDK 操作结果
|
||||
:return: 文本输出
|
||||
"""
|
||||
lines = [f'ok: {str(payload.get("ok", False)).lower()}']
|
||||
lines.extend(
|
||||
f'{key}: {payload[key]}'
|
||||
for key in (
|
||||
'status',
|
||||
'message',
|
||||
'sdkVersion',
|
||||
'currentSdkVersion',
|
||||
'protocolCompatible',
|
||||
'updateAvailable',
|
||||
'backupDir',
|
||||
)
|
||||
if key in payload
|
||||
)
|
||||
if payload.get('files'):
|
||||
lines.append('files: ' + json.dumps(payload['files'], ensure_ascii=False))
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _execute_sdk(
|
||||
operation: str,
|
||||
project: Path,
|
||||
frontend_root: Path | None,
|
||||
output: str,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
仅加载离线 SDK 文件服务,使用统一 CLI 输出和非零失败退出码。
|
||||
|
||||
:param operation: check 或 update
|
||||
:param project: 独立 bundle 源码工程目录
|
||||
:param frontend_root: 可选宿主前端源码目录
|
||||
:param output: text 或 json
|
||||
:param dry_run: 是否只预演更新
|
||||
:param force: 是否备份后替换改动过或未跟踪的 SDK
|
||||
:return: None
|
||||
"""
|
||||
try:
|
||||
root = frontend_root or Path(
|
||||
os.environ.get('RUOYI_PLUGIN_FRONTEND_ROOT')
|
||||
or Path(__file__).resolve().parents[5] / 'ruoyi-fastapi-frontend'
|
||||
)
|
||||
service = importlib.import_module('cli.runtime.plugin.sdk').PluginBridgeSdk(root)
|
||||
payload = (
|
||||
service.check(project) if operation == 'check' else service.update(project, dry_run=dry_run, force=force)
|
||||
)
|
||||
code = SUCCESS if payload['ok'] else DEPENDENCY_ERROR
|
||||
except ValueError as exc:
|
||||
payload = {'ok': False, 'status': 'invalid', 'message': str(exc), 'error': str(exc)}
|
||||
code = ARGUMENT_ERROR
|
||||
except OSError as exc:
|
||||
payload = {'ok': False, 'status': 'error', 'message': str(exc), 'error': str(exc)}
|
||||
code = RUNTIME_ERROR
|
||||
DEFAULT_CORE_SERVICES.execution_service.complete_payload_with_text(
|
||||
CliContext(output=output, dry_run=dry_run),
|
||||
payload,
|
||||
text_builder=_format_sdk_result,
|
||||
default_exit_code=code,
|
||||
)
|
||||
|
||||
|
||||
def register_sdk_commands(app: typer.Typer) -> None:
|
||||
"""
|
||||
注册纯离线 SDK 子命令,帮助查询不加载工程或宿主配置。
|
||||
|
||||
:param app: 插件命令组
|
||||
:return: None
|
||||
"""
|
||||
sdk_app = typer.Typer(help='检查和显式更新独立 bundle 工程中的桥接 SDK', no_args_is_help=True)
|
||||
app.add_typer(sdk_app, name='sdk')
|
||||
|
||||
@sdk_app.command('check', help='比较 SDK 版本、摘要和当前宿主副本,非 current 状态返回非零退出码')
|
||||
def check_sdk(
|
||||
project: Annotated[Path, typer.Argument(help='含 plugin.yaml 的独立 bundle 源码工程目录')],
|
||||
frontend_root: FrontendRootOption = None,
|
||||
output: OutputOption = 'text',
|
||||
) -> None:
|
||||
"""
|
||||
检查源码工程的 SDK 来源与本地修改,不写文件或连接外部服务。
|
||||
|
||||
:param project: 独立 bundle 源码工程目录
|
||||
:param frontend_root: 可选宿主前端源码目录
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
_execute_sdk('check', project, frontend_root, output)
|
||||
|
||||
@sdk_app.command('update', help='备份后更新 SDK 的三个文件;不会构建、安装或重启插件')
|
||||
def update_sdk(
|
||||
project: Annotated[Path, typer.Argument(help='含 plugin.yaml 的独立 bundle 源码工程目录')],
|
||||
frontend_root: FrontendRootOption = None,
|
||||
output: OutputOption = 'text',
|
||||
dry_run: DryRunOption = False,
|
||||
force: Annotated[bool, typer.Option('--force', help='备份后纳管旧副本或替换本地 SDK 修改')] = False,
|
||||
) -> None:
|
||||
"""
|
||||
显式更新源码工程的 SDK;默认拒绝覆盖用户改动或来源不明的副本。
|
||||
|
||||
:param project: 独立 bundle 源码工程目录
|
||||
:param frontend_root: 可选宿主前端源码目录
|
||||
:param output: 输出格式
|
||||
:param dry_run: 是否仅预演而不写入文件
|
||||
:param force: 是否备份后替换改动或未跟踪副本
|
||||
:return: None
|
||||
"""
|
||||
_execute_sdk('update', project, frontend_root, output, dry_run=dry_run, force=force)
|
||||
@@ -243,7 +243,7 @@ class PluginCommandController:
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.execution_service.run_async(self.core_runtime.diagnose_plugin(plugin_id))
|
||||
payload = self.execution_service.run_async(self.plugin_runtime.diagnose_plugin_with_runtime(plugin_id))
|
||||
if output_file.strip():
|
||||
payload = PluginCommandFileAdapter.write_json_file(
|
||||
payload,
|
||||
|
||||
@@ -47,6 +47,9 @@ class PluginCommandPayloadAdapter:
|
||||
cls._adapt_plugin_runtime_state(node)
|
||||
cls._adapt_manifest_job_state(node, parent_key)
|
||||
for key, value in list(node.items()):
|
||||
if key == 'runtime':
|
||||
# 新运行观测使用 API 与 CLI 共享契约,不重新解释发布目标的 enabled。
|
||||
continue
|
||||
cls._adapt_node(value, parent_key=key)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -206,17 +206,22 @@ class PluginCommandPresenter:
|
||||
checks = check.get('checks')
|
||||
check_items = checks if isinstance(checks, list) else []
|
||||
first_check = check_items[0] if check_items and isinstance(check_items[0], dict) else {}
|
||||
lines.append(f'check_ok: {str(check.get("ok", False)).lower()}')
|
||||
lines.append(f'dependency_count: {len(first_check.get("dependencies", [])) if first_check else 0}')
|
||||
lines.append(f'structure_errors: {len(first_check.get("structureErrors", [])) if first_check else 0}')
|
||||
lines.append(f'menu_conflicts: {len(first_check.get("menuConflicts", [])) if first_check else 0}')
|
||||
if isinstance(menu_plan, dict):
|
||||
if check.get('supported') is False:
|
||||
lines.append('check: unavailable (database_only)')
|
||||
else:
|
||||
lines.append(f'check_ok: {str(check.get("ok", False)).lower()}')
|
||||
lines.append(f'dependency_count: {len(first_check.get("dependencies", [])) if first_check else 0}')
|
||||
lines.append(f'structure_errors: {len(first_check.get("structureErrors", [])) if first_check else 0}')
|
||||
lines.append(f'menu_conflicts: {len(first_check.get("menuConflicts", [])) if first_check else 0}')
|
||||
if isinstance(menu_plan, dict) and menu_plan.get('supported') is not False:
|
||||
lines.append(
|
||||
f'menu_plan: total={menu_plan.get("total", 0)} | '
|
||||
f'permissions={menu_plan.get("permissionCount", 0)} | '
|
||||
f'enabled={menu_plan.get("enabledCount", 0)}'
|
||||
)
|
||||
if isinstance(config, dict):
|
||||
if isinstance(config, dict) and config.get('supported') is False:
|
||||
lines.append('configs: unavailable (database_only)')
|
||||
elif isinstance(config, dict):
|
||||
configs = config.get('configs')
|
||||
lines.append(f'configs: {len(configs) if isinstance(configs, list) else 0}')
|
||||
summary = config.get('summary')
|
||||
@@ -228,12 +233,83 @@ class PluginCommandPresenter:
|
||||
)
|
||||
if isinstance(audit, dict):
|
||||
lines.append(f'audit_available: {str(audit.get("available", False)).lower()}')
|
||||
runtime = payload.get('runtime')
|
||||
if isinstance(runtime, dict):
|
||||
lines.extend(self._build_runtime_diagnostics_lines(runtime))
|
||||
if payload.get('outputFile'):
|
||||
lines.append(f'output_file: {payload.get("outputFile")}')
|
||||
lines.append(f'exported: {str(payload.get("exported", False)).lower()}')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _build_runtime_diagnostics_lines(runtime: dict[str, object]) -> list[str]:
|
||||
"""
|
||||
显示观测范围及实际进程状态,缺失快照不得呈现为零计数。
|
||||
|
||||
:param runtime: 运行诊断聚合负载
|
||||
:return: 包含范围、时效和实际状态的文本行
|
||||
"""
|
||||
|
||||
def value(item: object) -> str:
|
||||
"""
|
||||
将未观测值显示为未知,并保留真实零计数。
|
||||
|
||||
:param item: 待显示的状态值
|
||||
:return: 适合 CLI 文本的状态字符串
|
||||
"""
|
||||
return 'unknown' if item is None else str(item).lower() if isinstance(item, bool) else str(item)
|
||||
|
||||
lines = [
|
||||
f'runtime_state: {runtime.get("state", "unavailable")}',
|
||||
f'runtime_scope: {runtime.get("scope", "unavailable")}',
|
||||
(
|
||||
f'runtime_workers: observed={value(runtime.get("observedWorkers"))} | '
|
||||
f'expected={value(runtime.get("expectedWorkers"))} | missing={value(runtime.get("missingWorkers"))}'
|
||||
),
|
||||
(
|
||||
f'runtime_samples: ttl_seconds={value(runtime.get("sampleTtlSeconds"))} | '
|
||||
f'stale={value(runtime.get("staleSnapshots"))} | invalid={value(runtime.get("invalidSnapshots"))}'
|
||||
),
|
||||
]
|
||||
release = runtime.get('release')
|
||||
summary = release.get('summary') if isinstance(release, dict) else None
|
||||
if isinstance(summary, dict):
|
||||
lines.append(
|
||||
f'release_target: status={summary.get("status", "unknown")} | '
|
||||
f'digest={value(summary.get("targetDigest"))} | generation={value(summary.get("generation"))}'
|
||||
)
|
||||
workers = runtime.get('workers')
|
||||
for worker in workers if isinstance(workers, list) else []:
|
||||
actual = worker.get('actual') or {}
|
||||
connections = actual.get('connections') or {}
|
||||
health = actual.get('activationHealth') or {}
|
||||
lines.extend(
|
||||
[
|
||||
(
|
||||
f'worker: {worker.get("workerId")} | freshness={worker.get("freshness")} | '
|
||||
f'observation={worker.get("observation")} | age_seconds={value(worker.get("ageSeconds"))}'
|
||||
),
|
||||
(
|
||||
f' actual: version={value(actual.get("version"))} | digest={value(actual.get("digest"))} | '
|
||||
f'generation={value(actual.get("generation"))} | ready={value(actual.get("ready"))} | '
|
||||
f'closing={value(actual.get("closing"))}'
|
||||
),
|
||||
(
|
||||
f' running: jobs={value(actual.get("activeJobs"))} | sse={value(connections.get("sse"))} | '
|
||||
f'websocket={value(connections.get("websocket"))} | '
|
||||
f'pending_connection_tasks={value(actual.get("pendingConnectionTasks"))} | '
|
||||
f'pending_job_tasks={value(actual.get("pendingJobTasks"))} | '
|
||||
f'worker_pending_cleanup={value(worker.get("workerPendingCleanupTasks"))}'
|
||||
),
|
||||
(
|
||||
f' activation_health: {health.get("status", "unknown")} | '
|
||||
f'checked_at={value(health.get("checkedAt"))}'
|
||||
),
|
||||
]
|
||||
)
|
||||
return lines
|
||||
|
||||
def build_docs_text(self, payload: dict[str, object]) -> str:
|
||||
"""
|
||||
将插件文档生成负载渲染为文本。
|
||||
|
||||
Reference in New Issue
Block a user