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,56 @@
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from fastapi import FastAPI, Request
from module_plugin.controller.plugin_controller import diagnose_system_plugin
@pytest.mark.asyncio
async def test_diagnose_api_attaches_runtime_without_changing_static_ok(monkeypatch: pytest.MonkeyPatch) -> None:
app = FastAPI()
reporter = object()
app.state.plugin_diagnostics_reporter = reporter
app.state.redis = object()
app.state.plugin_application_runtime = SimpleNamespace(ready_key='isolated:ready')
operation = SimpleNamespace(
diagnose_plugin_with_audit_services=AsyncMock(
return_value={
'ok': True,
'pluginId': 'demo',
'message': '插件诊断包生成完成',
'audit': {'available': True},
}
)
)
read = AsyncMock(return_value={'state': 'unavailable', 'observedTotals': None})
monkeypatch.setattr('module_plugin.controller.plugin_controller.get_plugin_operation_service', lambda: operation)
monkeypatch.setattr('plugins.core.runtime.diagnostics_query.read_plugin_runtime_diagnostics', read)
response = await diagnose_system_plugin(Request({'type': 'http', 'app': app}), object(), 'demo')
payload = json.loads(response.body)['data']
assert payload['ok'] is True
assert payload['runtime'] == {'state': 'unavailable', 'observedTotals': None}
read.assert_awaited_once_with('demo', reporter=reporter, redis=app.state.redis, ready_key='isolated:ready')
@pytest.mark.asyncio
@pytest.mark.parametrize('complete', [True, False])
async def test_failed_check_remains_visible_but_generation_failure_keeps_error_envelope(
monkeypatch: pytest.MonkeyPatch, complete: bool
) -> None:
payload = {'ok': False, 'message': '插件诊断包生成完成,发现问题', 'info': {'pluginId': 'demo'}}
if complete:
payload.update(check={'ok': False}, config={}, menuPlan={}, audit={})
operation = SimpleNamespace(diagnose_plugin_with_audit_services=AsyncMock(return_value=payload))
monkeypatch.setattr('module_plugin.controller.plugin_controller.get_plugin_operation_service', lambda: operation)
monkeypatch.setattr(
'plugins.core.runtime.diagnostics_query.read_plugin_runtime_diagnostics',
AsyncMock(return_value={'state': 'observed'}),
)
response = await diagnose_system_plugin(Request({'type': 'http', 'app': FastAPI()}), object(), 'demo')
result = json.loads(response.body)
assert result['code'] == (200 if complete else 601)
assert result['data']['ok'] is False
assert result['data']['runtime'] == {'state': 'observed'}
@@ -0,0 +1,80 @@
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from fastapi import FastAPI, Request
from common.aspect.interface_auth import CheckUserInterfaceAuth
from module_plugin.controller.plugin_metrics_controller import (
get_plugin_config_status,
get_plugin_runtime_metrics,
plugin_metrics_controller,
)
from plugins.core.runtime.metrics import PluginRuntimeMetrics
from plugins.core.runtime.metrics_store import PluginMetricsReporter
def test_runtime_metrics_endpoint_is_readonly_and_permission_gated() -> None:
"""运行指标仅通过受插件查询权限保护的 GET 接口公开。"""
route = plugin_metrics_controller.routes[0]
assert route.path == '/system/plugin/runtime/metrics'
assert route.methods == {'GET'}
assert any(
isinstance(dependency.dependency, CheckUserInterfaceAuth)
and dependency.dependency.perm == 'system:plugin:query'
for dependency in route.dependencies
)
@pytest.mark.asyncio
async def test_metrics_endpoint_without_explicit_runtime_reports_unsupported() -> None:
"""未加载显式插件时不可用状态不能伪装成全部指标为零。"""
response = await get_plugin_runtime_metrics(Request({'type': 'http', 'app': FastAPI()}))
payload = json.loads(response.body)['data']
assert payload['supported'] is False
assert payload['scope'] == 'unavailable'
@pytest.mark.asyncio
async def test_metrics_endpoint_forwards_plugin_filter() -> None:
"""接口保留报告器的采样范围并传递插件过滤条件。"""
app = FastAPI()
reporter = PluginMetricsReporter(PluginRuntimeMetrics())
reporter.read = AsyncMock(return_value={'ok': True, 'scope': 'current_worker', 'series': []})
app.state.plugin_metrics_reporter = reporter
response = await get_plugin_runtime_metrics(Request({'type': 'http', 'app': app}), 'metric_demo')
assert json.loads(response.body)['data']['scope'] == 'current_worker'
reporter.read.assert_awaited_once_with('metric_demo')
@pytest.mark.asyncio
async def test_config_status_is_query_gated_and_never_exposes_config_values(monkeypatch: pytest.MonkeyPatch) -> None:
"""读取敏感配置只用于计算版本,API 与无指标场景均不回显明文。"""
route = next(route for route in plugin_metrics_controller.routes if route.path.endswith('/config/status'))
assert route.methods == {'GET'}
assert any(
isinstance(dependency.dependency, CheckUserInterfaceAuth)
and dependency.dependency.perm == 'system:plugin:query'
for dependency in route.dependencies
)
runtime = SimpleNamespace(
get_plugin_config=AsyncMock(
return_value={
'ok': True,
'configs': [{'key': 'api_key', 'value': 'private-config-secret', 'secret': True}],
}
)
)
monkeypatch.setattr(
'module_plugin.controller.plugin_metrics_controller.get_plugin_runtime_service',
lambda: runtime,
)
response = await get_plugin_config_status(Request({'type': 'http', 'app': FastAPI()}), 'config_demo')
data = json.loads(response.body)['data']
assert data['state'] == 'unobserved' and data['scope'] == 'unavailable'
assert 'private-config-secret' not in response.body.decode() and 'api_key' not in response.body.decode()
runtime.get_plugin_config.assert_awaited_once_with('config_demo', reveal_secret=True)
runtime.get_plugin_config.return_value = {'ok': False, 'message': 'private exception value'}
failure = await get_plugin_config_status(Request({'type': 'http', 'app': FastAPI()}), 'config_demo')
assert 'private exception value' not in failure.body.decode()
@@ -0,0 +1,74 @@
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from common.aspect.interface_auth import CheckUserInterfaceAuth
from module_plugin.controller import plugin_release_controller as controller
DIGEST = 'a' * 64
def test_artifact_http_endpoints_are_get_only_and_require_query_permission() -> None:
"""验证制品 HTTP 接口只接受查询且要求插件查询权限。"""
routes = controller.plugin_release_controller.routes
assert {route.path for route in routes} == {
'/system/plugin/artifacts/list',
'/system/plugin/release/status',
'/system/plugin/release/plan',
}
for route in routes:
assert route.methods == {'GET'}
assert any(
isinstance(dependency.dependency, CheckUserInterfaceAuth)
and dependency.dependency.perm == 'system:plugin:query'
for dependency in route.dependencies
)
@pytest.mark.asyncio
async def test_readonly_endpoints_forward_filters_and_plan_target(monkeypatch: pytest.MonkeyPatch) -> None:
"""验证只读接口正确传递过滤条件与目标制品信息。"""
service = SimpleNamespace(
catalog=SimpleNamespace(list_artifacts=AsyncMock(return_value={'ok': True, 'artifacts': []})),
status=AsyncMock(return_value={'ok': True, 'releases': []}),
plan=AsyncMock(return_value={'ok': True, 'pluginId': 'demo', 'digest': DIGEST}),
)
monkeypatch.setattr(controller, 'get_plugin_deployment_service', lambda: service)
responses = [
await controller.list_plugin_artifacts('demo'),
await controller.get_plugin_release_status('demo'),
await controller.plan_plugin_release('demo', DIGEST),
]
assert all(json.loads(response.body)['success'] for response in responses)
service.catalog.list_artifacts.assert_awaited_once_with('demo')
service.status.assert_awaited_once_with('demo')
service.plan.assert_awaited_once_with('demo', DIGEST)
@pytest.mark.asyncio
async def test_disabled_feature_returns_clear_blocked_response(monkeypatch: pytest.MonkeyPatch) -> None:
"""验证功能关闭时返回明确的受限响应。"""
def unavailable() -> None:
raise ValueError('签名制品发布功能未启用')
monkeypatch.setattr(controller, 'get_plugin_deployment_service', unavailable)
body = json.loads((await controller.get_plugin_release_status()).body)
assert body['success'] is False
assert body['data']['status'] == 'blocked'
assert '未启用' in body['msg']
@pytest.mark.asyncio
async def test_unexpected_database_error_does_not_expose_connection_details(monkeypatch: pytest.MonkeyPatch) -> None:
"""验证意外数据库异常不会将连接细节返回客户端。"""
def unavailable() -> None:
raise RuntimeError('mysql://secret-user:secret-password@host')
monkeypatch.setattr(controller, 'get_plugin_deployment_service', unavailable)
response = await controller.list_plugin_artifacts()
assert json.loads(response.body)['success'] is False
assert b'secret-password' not in response.body