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,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:
"""
将插件文档生成负载渲染为文本。