diff --git a/.github/workflows/native-plugin.yml b/.github/workflows/native-plugin.yml new file mode 100644 index 0000000..49555c5 --- /dev/null +++ b/.github/workflows/native-plugin.yml @@ -0,0 +1,97 @@ +name: Native Plugin + +on: + push: + branches: [master] + paths: + - 'ruoyi-fastapi-backend/plugins/core/**' + - 'ruoyi-fastapi-backend/plugins/examples/rust/**' + - 'ruoyi-fastapi-backend/plugins/examples/python/task_demo/**' + - 'ruoyi-fastapi-backend/scripts/plugin_task_integration.py' + - 'ruoyi-fastapi-backend/scripts/build_native_plugin.py' + - 'ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/**' + - 'ruoyi-fastapi-backend/cli/runtime/plugin/sdk.py' + - 'ruoyi-fastapi-backend/cli/groups/plugin/commands/developer.py' + - 'ruoyi-fastapi-backend/cli/groups/plugin/commands/sdk.py' + - 'ruoyi-fastapi-backend/cli/groups/plugin/commands/artifact*.py' + - 'ruoyi-fastapi-backend/cli/groups/plugin/artifact_controller.py' + - 'ruoyi-fastapi-backend/cli/groups/plugin/command.py' + - 'ruoyi-fastapi-frontend/src/utils/pluginBridge*' + - 'ruoyi-fastapi-backend/pyproject.toml' + - 'ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_scaffold*.py' + - 'ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_sdk.py' + - 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_sdk_commands.py' + - 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py' + - 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_release_wait.py' + - 'ruoyi-fastapi-backend/tests/plugins/**' + - '.github/workflows/native-plugin.yml' + pull_request: + branches: [master] + paths: + - 'ruoyi-fastapi-backend/plugins/core/**' + - 'ruoyi-fastapi-backend/plugins/examples/rust/**' + - 'ruoyi-fastapi-backend/plugins/examples/python/task_demo/**' + - 'ruoyi-fastapi-backend/scripts/plugin_task_integration.py' + - 'ruoyi-fastapi-backend/scripts/build_native_plugin.py' + - 'ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/**' + - 'ruoyi-fastapi-backend/cli/runtime/plugin/sdk.py' + - 'ruoyi-fastapi-backend/cli/groups/plugin/commands/developer.py' + - 'ruoyi-fastapi-backend/cli/groups/plugin/commands/sdk.py' + - 'ruoyi-fastapi-backend/cli/groups/plugin/commands/artifact*.py' + - 'ruoyi-fastapi-backend/cli/groups/plugin/artifact_controller.py' + - 'ruoyi-fastapi-backend/cli/groups/plugin/command.py' + - 'ruoyi-fastapi-frontend/src/utils/pluginBridge*' + - 'ruoyi-fastapi-backend/pyproject.toml' + - 'ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_scaffold*.py' + - 'ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_sdk.py' + - 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_sdk_commands.py' + - 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py' + - 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_release_wait.py' + - 'ruoyi-fastapi-backend/tests/plugins/**' + - '.github/workflows/native-plugin.yml' + workflow_dispatch: + +jobs: + native: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + python: ['3.10', '3.11', '3.12', '3.13'] + runs-on: ${{ matrix.os }} + defaults: + run: + working-directory: ruoyi-fastapi-backend + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python }} + cache: pip + cache-dependency-path: ruoyi-fastapi-backend/requirements.txt + - name: Install host and build dependencies + run: python -m pip install -r requirements.txt 'maturin>=1.15,<2' pytest pytest-asyncio aiosqlite + - name: Install Rust + run: rustup toolchain install 1.99.0 --profile minimal --component rustfmt --component clippy + - name: Select Rust + run: rustup default 1.99.0 + - name: Check Rust formatting + run: cargo fmt --manifest-path plugins/examples/rust/rust_demo/Cargo.toml --check + - name: Verify v1 and v2 plugin scaffolds + run: python -m pytest tests/cli/runtime/plugin/test_runtime_scaffold.py tests/cli/runtime/plugin/test_runtime_scaffold_v2.py -q + - name: Verify SDK provenance and offline update commands + run: python -m pytest tests/cli/runtime/plugin/test_runtime_sdk.py tests/cli/root/test_plugin_sdk_commands.py -q + - name: Verify persistent task plugin + run: python -m pytest tests/plugins/examples/test_task_demo.py -q + - name: Build and assemble source-free plugin + run: python scripts/build_native_plugin.py --output target/native-package + - name: Check Rust code + run: cargo clippy --manifest-path plugins/examples/rust/rust_demo/Cargo.toml --locked -- -D warnings + - name: Exercise native library through the host runtime + env: + RUOYI_NATIVE_PLUGIN_DIR: ${{ github.workspace }}/ruoyi-fastapi-backend/target/native-package/rust_demo + run: python -m pytest tests/plugins/native tests/plugins/core/native tests/plugins/core/runtime/test_host_services.py tests/plugins/core/runtime/test_job_dispatcher.py -q + - name: Verify signed artifacts and maintenance releases + env: + RUOYI_NATIVE_PLUGIN_DIR: ${{ github.workspace }}/ruoyi-fastapi-backend/target/native-package/rust_demo + run: python -m pytest tests/plugins/core/artifacts tests/plugins/core/deployment tests/plugins/core/management/test_release_dao.py tests/plugins/core/management/test_artifact_views.py tests/module_plugin/controller/test_plugin_release_controller.py tests/cli/root/test_plugin_artifact_commands.py tests/cli/root/test_plugin_release_wait.py tests/sql/test_plugin_release_schema.py -q diff --git a/.github/workflows/plugin-frontend.yml b/.github/workflows/plugin-frontend.yml new file mode 100644 index 0000000..ad3769c --- /dev/null +++ b/.github/workflows/plugin-frontend.yml @@ -0,0 +1,69 @@ +name: Plugin Frontend Tests + +on: + push: + branches: [master] + paths: + - 'ruoyi-fastapi-frontend/src/api/system/plugin.js' + - 'ruoyi-fastapi-frontend/src/components/PluginFrame/**' + - 'ruoyi-fastapi-frontend/src/views/system/plugin/**' + - 'ruoyi-fastapi-frontend/src/utils/**' + - 'ruoyi-fastapi-frontend/src/plugins/**' + - 'ruoyi-fastapi-frontend/src/router/**' + - 'ruoyi-fastapi-frontend/src/store/modules/permission.js' + - 'ruoyi-fastapi-frontend/src/store/modules/user.js' + - 'ruoyi-fastapi-frontend/src/permission.js' + - 'ruoyi-fastapi-frontend/plugins/**' + - 'ruoyi-fastapi-frontend/tests/plugins/**' + - 'ruoyi-fastapi-frontend/tests/run-tests.js' + - 'ruoyi-fastapi-frontend/package.json' + - 'ruoyi-fastapi-frontend/package-lock.json' + - 'ruoyi-fastapi-frontend/vite.config.js' + - 'ruoyi-fastapi-frontend/vite/**' + - 'ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/**' + - '.github/workflows/plugin-frontend.yml' + pull_request: + branches: [master] + paths: + - 'ruoyi-fastapi-frontend/src/api/system/plugin.js' + - 'ruoyi-fastapi-frontend/src/components/PluginFrame/**' + - 'ruoyi-fastapi-frontend/src/views/system/plugin/**' + - 'ruoyi-fastapi-frontend/src/utils/**' + - 'ruoyi-fastapi-frontend/src/plugins/**' + - 'ruoyi-fastapi-frontend/src/router/**' + - 'ruoyi-fastapi-frontend/src/store/modules/permission.js' + - 'ruoyi-fastapi-frontend/src/store/modules/user.js' + - 'ruoyi-fastapi-frontend/src/permission.js' + - 'ruoyi-fastapi-frontend/plugins/**' + - 'ruoyi-fastapi-frontend/tests/plugins/**' + - 'ruoyi-fastapi-frontend/tests/run-tests.js' + - 'ruoyi-fastapi-frontend/package.json' + - 'ruoyi-fastapi-frontend/package-lock.json' + - 'ruoyi-fastapi-frontend/vite.config.js' + - 'ruoyi-fastapi-frontend/vite/**' + - 'ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/**' + - '.github/workflows/plugin-frontend.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + plugins: + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: ruoyi-fastapi-frontend + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: npm + cache-dependency-path: ruoyi-fastapi-frontend/package.json + - name: Install frontend dependencies + # This repository does not commit npm lockfiles, so npm ci is not available. + run: npm install --no-audit --no-fund --package-lock=false + - name: Verify plugin bridge, sessions and management views + run: npm run test:plugin diff --git a/.github/workflows/plugin-network-smoke.yml b/.github/workflows/plugin-network-smoke.yml new file mode 100644 index 0000000..6d26c4b --- /dev/null +++ b/.github/workflows/plugin-network-smoke.yml @@ -0,0 +1,63 @@ +name: Plugin Network Smoke + +on: + pull_request: + branches: [master] + paths: + - 'ruoyi-fastapi-backend/plugins/**' + - 'ruoyi-fastapi-backend/module_plugin/**' + - 'ruoyi-fastapi-backend/module_admin/service/login_service.py' + - 'ruoyi-fastapi-backend/common/**' + - 'ruoyi-fastapi-backend/config/**' + - 'ruoyi-fastapi-backend/middlewares/**' + - 'ruoyi-fastapi-backend/utils/**' + - 'ruoyi-fastapi-backend/tests/**' + - 'ruoyi-fastapi-backend/requirements.txt' + - 'ruoyi-fastapi-backend/pyproject.toml' + - 'ruoyi-fastapi-frontend/**' + - '.github/workflows/plugin-network-smoke.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + browser: + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: ruoyi-fastapi-backend + env: + RUOYI_PLUGIN_NETWORK_SMOKE: '1' + # The fixture replaces account, DB and Redis access; no external service is used. + DB_SOURCES: '{"primary":{"db_type":"mysql","db_host":"127.0.0.1","db_port":3306,"db_username":"network-smoke","db_password":"network-smoke","db_database":"network-smoke"}}' + JWT_SECRET_KEY: 'isolated-plugin-network-smoke-ci-only' + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + cache: pip + cache-dependency-path: ruoyi-fastapi-backend/requirements.txt + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: npm + cache-dependency-path: ruoyi-fastapi-frontend/package.json + - name: Install host and browser test dependencies + run: python -m pip install -r requirements.txt pytest pytest-asyncio aiosqlite playwright + - name: Install Chromium + run: python -m playwright install --with-deps chromium + - name: Install frontend dependencies + # This repository does not commit npm lockfiles, so npm ci is not available. + run: npm --prefix ../ruoyi-fastapi-frontend install --no-audit --no-fund --package-lock=false + - name: Verify HTTPS proxy, iframe sessions and long connections + run: python -m pytest tests/plugins/integration/test_network_smoke.py -v --junitxml=target/plugin-network-smoke/results.xml + - name: Upload browser trace, screenshots and proxy log + if: always() + uses: actions/upload-artifact@v4 + with: + name: plugin-network-smoke + path: ruoyi-fastapi-backend/target/plugin-network-smoke/ + retention-days: 7 diff --git a/.github/workflows/plugin-release-integration.yml b/.github/workflows/plugin-release-integration.yml new file mode 100644 index 0000000..793ad00 --- /dev/null +++ b/.github/workflows/plugin-release-integration.yml @@ -0,0 +1,94 @@ +name: Plugin Release Integration + +on: + pull_request: + branches: [master] + paths: + - 'ruoyi-fastapi-backend/plugins/core/**' + - 'ruoyi-fastapi-backend/plugins/examples/python/task_demo/**' + - 'ruoyi-fastapi-frontend/src/utils/pluginBridge.js' + - 'ruoyi-fastapi-backend/config/**' + - 'ruoyi-fastapi-backend/cli/groups/plugin/**' + - 'ruoyi-fastapi-backend/cli/runtime/plugin/**' + - 'ruoyi-fastapi-backend/module_plugin/**' + - 'ruoyi-fastapi-backend/server.py' + - 'ruoyi-fastapi-backend/app.py' + - 'ruoyi-fastapi-backend/sql/ruoyi-fastapi*.sql' + - 'ruoyi-fastapi-backend/scripts/plugin_release_integration.py' + - 'ruoyi-fastapi-backend/scripts/plugin_task_integration.py' + - 'ruoyi-fastapi-backend/tests/plugins/**' + - 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py' + - 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_release_wait.py' + - 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_diagnostics.py' + - 'ruoyi-fastapi-backend/tests/cli/runtime/plugin/**' + - 'ruoyi-fastapi-backend/tests/module_plugin/**' + - 'ruoyi-fastapi-backend/tests/server/test_plugin_runtime.py' + - 'ruoyi-fastapi-backend/tests/sql/test_plugin_release_schema.py' + - 'ruoyi-fastapi-backend/pyproject.toml' + - 'ruoyi-fastapi-backend/requirements.txt' + - 'ruoyi-fastapi-backend/requirements-pg.txt' + - '.github/workflows/plugin-release-integration.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + release: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python: ['3.10', '3.11', '3.12', '3.13'] + defaults: + run: + working-directory: ruoyi-fastapi-backend + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: ci-only-mysql + ports: ['3306:3306'] + options: >- + --health-cmd="mysqladmin ping -h localhost" + --health-interval=10s --health-timeout=5s --health-retries=12 + postgres: + image: postgres:14 + env: + POSTGRES_PASSWORD: ci-only-postgres + ports: ['5432:5432'] + options: >- + --health-cmd="pg_isready -U postgres" + --health-interval=10s --health-timeout=5s --health-retries=12 + redis: + image: redis:6.2 + ports: ['6379:6379'] + options: >- + --health-cmd="redis-cli ping" + --health-interval=10s --health-timeout=5s --health-retries=12 + env: + RUOYI_PLUGIN_IT_ALLOW: '1' + RUOYI_IT_MYSQL_PASSWORD: ci-only-mysql + RUOYI_IT_PG_PASSWORD: ci-only-postgres + RUOYI_IT_REDIS_PASSWORD: '' + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python }} + cache: pip + cache-dependency-path: ruoyi-fastapi-backend/requirements*.txt + - name: Install host dependencies + run: python -m pip install -r requirements.txt -r requirements-pg.txt + - uses: actions/setup-node@v5 + with: + node-version: '22' + - name: Test and build task plugin bundle + run: | + npm --prefix plugins/examples/python/task_demo/web install --no-audit --no-fund --package-lock=false + npm --prefix plugins/examples/python/task_demo/web test + npm --prefix plugins/examples/python/task_demo/web run build + - name: Verify MySQL and Redis maintenance releases + run: python scripts/plugin_release_integration.py --engine mysql + - name: Verify PostgreSQL and Redis maintenance releases + run: python scripts/plugin_release_integration.py --engine postgresql diff --git a/ruoyi-fastapi-backend/.env.dev b/ruoyi-fastapi-backend/.env.dev index 3b7a0d5..98ef2d5 100644 --- a/ruoyi-fastapi-backend/.env.dev +++ b/ruoyi-fastapi-backend/.env.dev @@ -283,3 +283,15 @@ PLUGIN_DEPENDENCY_PIP_INDEX_URL = '' PLUGIN_DEPENDENCY_NPM_REGISTRY = '' # 单条依赖安装命令超时时间(秒) PLUGIN_DEPENDENCY_INSTALL_TIMEOUT = 600 + +# -------- 签名插件制品与维护发布配置 -------- +# 是否启用签名插件制品;启用前必须配置外部信任公钥文件 +PLUGIN_ARTIFACT_ENABLED = false +# 不可变制品存储目录,相对路径基于后端项目根目录解析 +PLUGIN_ARTIFACT_STORE = 'vf_admin/plugin_artifacts' +# 宿主管理的发布者公钥 JSON 文件路径;启用时必填,相对路径基于后端项目根目录解析 +PLUGIN_ARTIFACT_TRUST_FILE = '' +# 宿主 worker 心跳间隔(秒),取值范围为 1 至 60 +PLUGIN_ARTIFACT_HEARTBEAT_SECONDS = 15 +# 宿主 worker 心跳失效时间(秒),取值范围为 3 至 600,且不得小于心跳间隔的三倍 +PLUGIN_ARTIFACT_WORKER_TTL_SECONDS = 60 diff --git a/ruoyi-fastapi-backend/.env.dockermy b/ruoyi-fastapi-backend/.env.dockermy index a3bb6bc..2259d6a 100644 --- a/ruoyi-fastapi-backend/.env.dockermy +++ b/ruoyi-fastapi-backend/.env.dockermy @@ -283,3 +283,15 @@ PLUGIN_DEPENDENCY_PIP_INDEX_URL = '' PLUGIN_DEPENDENCY_NPM_REGISTRY = '' # 单条依赖安装命令超时时间(秒) PLUGIN_DEPENDENCY_INSTALL_TIMEOUT = 600 + +# -------- 签名插件制品与维护发布配置 -------- +# 是否启用签名插件制品;启用前必须配置外部信任公钥文件 +PLUGIN_ARTIFACT_ENABLED = false +# 不可变制品存储目录,相对路径基于后端项目根目录解析 +PLUGIN_ARTIFACT_STORE = 'vf_admin/plugin_artifacts' +# 宿主管理的发布者公钥 JSON 文件路径;启用时必填,相对路径基于后端项目根目录解析 +PLUGIN_ARTIFACT_TRUST_FILE = '' +# 宿主 worker 心跳间隔(秒),取值范围为 1 至 60 +PLUGIN_ARTIFACT_HEARTBEAT_SECONDS = 15 +# 宿主 worker 心跳失效时间(秒),取值范围为 3 至 600,且不得小于心跳间隔的三倍 +PLUGIN_ARTIFACT_WORKER_TTL_SECONDS = 60 diff --git a/ruoyi-fastapi-backend/.env.dockerpg b/ruoyi-fastapi-backend/.env.dockerpg index 81aad9c..c18634b 100644 --- a/ruoyi-fastapi-backend/.env.dockerpg +++ b/ruoyi-fastapi-backend/.env.dockerpg @@ -283,3 +283,15 @@ PLUGIN_DEPENDENCY_PIP_INDEX_URL = '' PLUGIN_DEPENDENCY_NPM_REGISTRY = '' # 单条依赖安装命令超时时间(秒) PLUGIN_DEPENDENCY_INSTALL_TIMEOUT = 600 + +# -------- 签名插件制品与维护发布配置 -------- +# 是否启用签名插件制品;启用前必须配置外部信任公钥文件 +PLUGIN_ARTIFACT_ENABLED = false +# 不可变制品存储目录,相对路径基于后端项目根目录解析 +PLUGIN_ARTIFACT_STORE = 'vf_admin/plugin_artifacts' +# 宿主管理的发布者公钥 JSON 文件路径;启用时必填,相对路径基于后端项目根目录解析 +PLUGIN_ARTIFACT_TRUST_FILE = '' +# 宿主 worker 心跳间隔(秒),取值范围为 1 至 60 +PLUGIN_ARTIFACT_HEARTBEAT_SECONDS = 15 +# 宿主 worker 心跳失效时间(秒),取值范围为 3 至 600,且不得小于心跳间隔的三倍 +PLUGIN_ARTIFACT_WORKER_TTL_SECONDS = 60 diff --git a/ruoyi-fastapi-backend/.env.prod b/ruoyi-fastapi-backend/.env.prod index a9bef3d..bcabcee 100644 --- a/ruoyi-fastapi-backend/.env.prod +++ b/ruoyi-fastapi-backend/.env.prod @@ -283,3 +283,15 @@ PLUGIN_DEPENDENCY_PIP_INDEX_URL = '' PLUGIN_DEPENDENCY_NPM_REGISTRY = '' # 单条依赖安装命令超时时间(秒) PLUGIN_DEPENDENCY_INSTALL_TIMEOUT = 600 + +# -------- 签名插件制品与维护发布配置 -------- +# 是否启用签名插件制品;启用前必须配置外部信任公钥文件 +PLUGIN_ARTIFACT_ENABLED = false +# 不可变制品存储目录,相对路径基于后端项目根目录解析 +PLUGIN_ARTIFACT_STORE = 'vf_admin/plugin_artifacts' +# 宿主管理的发布者公钥 JSON 文件路径;启用时必填,相对路径基于后端项目根目录解析 +PLUGIN_ARTIFACT_TRUST_FILE = '' +# 宿主 worker 心跳间隔(秒),取值范围为 1 至 60 +PLUGIN_ARTIFACT_HEARTBEAT_SECONDS = 15 +# 宿主 worker 心跳失效时间(秒),取值范围为 3 至 600,且不得小于心跳间隔的三倍 +PLUGIN_ARTIFACT_WORKER_TTL_SECONDS = 60 diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/artifact_controller.py b/ruoyi-fastapi-backend/cli/groups/plugin/artifact_controller.py new file mode 100644 index 0000000..47aba0f --- /dev/null +++ b/ruoyi-fastapi-backend/cli/groups/plugin/artifact_controller.py @@ -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, + ) + ), + ) diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/command.py b/ruoyi-fastapi-backend/cli/groups/plugin/command.py index a535034..eb63598 100644 --- a/ruoyi-fastapi-backend/cli/groups/plugin/command.py +++ b/ruoyi-fastapi-backend/cli/groups/plugin/command.py @@ -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) diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/commands/artifact.py b/ruoyi-fastapi-backend/cli/groups/plugin/commands/artifact.py new file mode 100644 index 0000000..36ac147 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/groups/plugin/commands/artifact.py @@ -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, + ) diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/commands/artifact_maintenance.py b/ruoyi-fastapi-backend/cli/groups/plugin/commands/artifact_maintenance.py new file mode 100644 index 0000000..33212e8 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/groups/plugin/commands/artifact_maintenance.py @@ -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, + ) diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/commands/developer.py b/ruoyi-fastapi-backend/cli/groups/plugin/commands/developer.py index 8606c38..79cd577 100644 --- a/ruoyi-fastapi-backend/cli/groups/plugin/commands/developer.py +++ b/ruoyi-fastapi-backend/cli/groups/plugin/commands/developer.py @@ -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, diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/commands/sdk.py b/ruoyi-fastapi-backend/cli/groups/plugin/commands/sdk.py new file mode 100644 index 0000000..e6aefc2 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/groups/plugin/commands/sdk.py @@ -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) diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/controller.py b/ruoyi-fastapi-backend/cli/groups/plugin/controller.py index c0935fe..89b0d78 100644 --- a/ruoyi-fastapi-backend/cli/groups/plugin/controller.py +++ b/ruoyi-fastapi-backend/cli/groups/plugin/controller.py @@ -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, diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/payload.py b/ruoyi-fastapi-backend/cli/groups/plugin/payload.py index 7941b18..5dd2645 100644 --- a/ruoyi-fastapi-backend/cli/groups/plugin/payload.py +++ b/ruoyi-fastapi-backend/cli/groups/plugin/payload.py @@ -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 diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/presenter.py b/ruoyi-fastapi-backend/cli/groups/plugin/presenter.py index 047e367..c18eeb2 100644 --- a/ruoyi-fastapi-backend/cli/groups/plugin/presenter.py +++ b/ruoyi-fastapi-backend/cli/groups/plugin/presenter.py @@ -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: """ 将插件文档生成负载渲染为文本。 diff --git a/ruoyi-fastapi-backend/cli/guards.py b/ruoyi-fastapi-backend/cli/guards.py index cdc3976..2bad51f 100644 --- a/ruoyi-fastapi-backend/cli/guards.py +++ b/ruoyi-fastapi-backend/cli/guards.py @@ -208,6 +208,33 @@ DEFAULT_DANGEROUS_COMMAND_RULES: dict[str, DangerousCommandRule] = { 'gen export': DangerousCommandRule(command_name='gen export', risk_level='high', supports_dry_run=True), 'gen sync-db': DangerousCommandRule(command_name='gen sync-db', risk_level='normal', supports_dry_run=False), 'plugin install': DangerousCommandRule(command_name='plugin install', risk_level='high', supports_dry_run=True), + 'plugin artifact import': DangerousCommandRule( + command_name='plugin artifact import', risk_level='high', supports_dry_run=True + ), + 'plugin artifact prune': DangerousCommandRule( + command_name='plugin artifact prune', risk_level='high', supports_dry_run=True + ), + 'plugin artifact rotate-signature': DangerousCommandRule( + command_name='plugin artifact rotate-signature', risk_level='high', supports_dry_run=False + ), + 'plugin artifact reconcile': DangerousCommandRule( + command_name='plugin artifact reconcile', risk_level='high', supports_dry_run=False + ), + 'plugin release prepare': DangerousCommandRule( + command_name='plugin release prepare', risk_level='high', supports_dry_run=True + ), + 'plugin release select': DangerousCommandRule( + command_name='plugin release select', risk_level='high', supports_dry_run=False + ), + 'plugin release rollback': DangerousCommandRule( + command_name='plugin release rollback', risk_level='high', supports_dry_run=False + ), + 'plugin release enable': DangerousCommandRule( + command_name='plugin release enable', risk_level='high', supports_dry_run=False + ), + 'plugin release disable': DangerousCommandRule( + command_name='plugin release disable', risk_level='high', supports_dry_run=False + ), 'plugin install-deps': DangerousCommandRule( command_name='plugin install-deps', risk_level='high', diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/gitignore.tmpl b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/gitignore.tmpl new file mode 100644 index 0000000..d0b32c1 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/gitignore.tmpl @@ -0,0 +1,9 @@ +__pycache__/ +.pytest_cache/ +*.pyc +target/ +web/node_modules/ +web/dist/ +.env +*.pem +*.key diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/native/Cargo.lock.tmpl b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/native/Cargo.lock.tmpl new file mode 100644 index 0000000..867e450 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/native/Cargo.lock.tmpl @@ -0,0 +1,224 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "700d18fa267b73b9b521fd7e13580e2f446916f176cacee1ab63fcc8191f1655" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-async-runtimes" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3ef68daa7316a3fac65e5e18b2203f010346de1c1c53456811a2624673ab046" +dependencies = [ + "futures-channel", + "futures-util", + "once_cell", + "pin-project-lite", + "pyo3", + "tokio", +] + +[[package]] +name = "pyo3-build-config" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b3fc0c4d08f6bb10e71fe39dfb9e2f59c6eb6854e22ec8092f50c69a4499adb" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc0b8e19df29aad7086cf977bb0c2a2f143e30567eb113e9cf72b62ca698330" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6100e8a4b5eba53afaa5ed078364851a0b2499c553a44c31026b929049b49dc6" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6143877a16e82b5a727b7127ff4cd86858a24a28d745d72f43e6f227c7b1bdb3" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "__DISTRIBUTION__" +version = "1.0.0" +dependencies = [ + "pyo3", + "pyo3-async-runtimes", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/native/Cargo.toml.tmpl b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/native/Cargo.toml.tmpl new file mode 100644 index 0000000..e415ea4 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/native/Cargo.toml.tmpl @@ -0,0 +1,18 @@ +[package] +name = "__DISTRIBUTION__" +version = "1.0.0" +edition = "2021" +publish = false + +[lib] +name = "_native" +crate-type = ["cdylib"] + +[dependencies] +pyo3 = { version = "0.29.2", features = ["abi3-py310"] } +pyo3-async-runtimes = { version = "0.29.0", features = ["tokio-runtime"] } + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = "symbols" diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/native/lib.rs.tmpl b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/native/lib.rs.tmpl new file mode 100644 index 0000000..5065f60 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/native/lib.rs.tmpl @@ -0,0 +1,112 @@ +//! 编译为原生扩展的 ASGI 插件;身份认证与权限校验继续使用宿主 Python SDK。 +use pyo3::prelude::*; +use pyo3::types::PyDict; + +/// 声明子应用工厂,实际资源初始化交给生命周期回调。 +#[pyfunction] +fn create_plugin<'py>(py: Python<'py>, _host: Bound<'py, PyAny>) -> PyResult> { + let kwargs = PyDict::new(py); + kwargs.set_item("app_factory", wrap_pyfunction!(create_app, py)?)?; + py.import("plugins.core.sdk")? + .getattr("PluginDefinition")? + .call((), Some(&kwargs)) +} + +/// 为当前 worker 创建独立子应用,并通过宿主 SDK 绑定生命周期和接口权限。 +#[pyfunction] +fn create_app<'py>(py: Python<'py>, host: Bound<'py, PyAny>) -> PyResult> { + let sdk = py.import("plugins.core.sdk")?; + let lifespan = sdk.getattr("plugin_lifespan")?.call1(( + host, + wrap_pyfunction!(startup, py)?, + wrap_pyfunction!(shutdown, py)?, + ))?; + let kwargs = PyDict::new(py); + kwargs.set_item("title", "__PLUGIN_ID__")?; + kwargs.set_item("lifespan", lifespan)?; + kwargs.set_item("docs_url", py.None())?; + kwargs.set_item("redoc_url", py.None())?; + let app = py + .import("fastapi")? + .getattr("FastAPI")? + .call((), Some(&kwargs))?; + let options = PyDict::new(py); + options.set_item("permission", "__PLUGIN_ID__:view")?; + let endpoint = sdk + .getattr("plugin_endpoint")? + .call((wrap_pyfunction!(info, py)?,), Some(&options))?; + let route_options = PyDict::new(py); + route_options.set_item("summary", "获取插件信息接口")?; + route_options.set_item( + "description", + "用于获取当前插件标识、宿主API版本、运行引擎和服务器时间", + )?; + route_options.set_item("response_model", py.get_type::())?; + app.call_method( + "add_api_route", + ("/api/info", endpoint), + Some(&route_options), + )?; + Ok(app) +} + +/// 记录插件启动,并通过 Python 可等待对象返回就绪状态。 +#[pyfunction] +fn startup<'py>(py: Python<'py>, host: Bound<'py, PyAny>) -> PyResult> { + host.getattr("logger")? + .call_method1("info", ("__PLUGIN_ID__ started",))?; + pyo3_async_runtimes::tokio::future_into_py(py, async { + Python::attach(|py| { + let state = PyDict::new(py); + state.set_item("pluginReady", true)?; + Ok(state.unbind()) + }) + }) +} + +/// 演示插件关闭回调,仅释放插件自己创建的资源。 +#[pyfunction] +fn shutdown<'py>(py: Python<'py>, host: Bound<'py, PyAny>) -> PyResult> { + host.getattr("logger")? + .call_method1("info", ("__PLUGIN_ID__ stopped",))?; + // 宿主共享的数据库连接池和 Redis 客户端继续由宿主负责关闭。 + pyo3_async_runtimes::tokio::future_into_py(py, async { Ok(()) }) +} + +/// 通过可等待对象返回健康结果,可扩展插件自有依赖检查。 +#[pyfunction] +fn health<'py>(py: Python<'py>, _context: Bound<'py, PyAny>) -> PyResult> { + pyo3_async_runtimes::tokio::future_into_py(py, async { Ok(true) }) +} + +#[pyfunction] +fn info<'py>(py: Python<'py>, context: Bound<'py, PyAny>) -> PyResult> { + let host = context.getattr("host")?; + let plugin_id: String = host.getattr("plugin_id")?.extract()?; + let api_version: String = host.getattr("api_version")?.extract()?; + let timestamp: String = py + .import("utils.time_util")? + .getattr("TimezoneUtil")? + .call_method0("utc_now")? + .call_method0("isoformat")? + .extract()?; + pyo3_async_runtimes::tokio::future_into_py(py, async move { + Python::attach(|py| { + let result = PyDict::new(py); + result.set_item("pluginId", plugin_id)?; + result.set_item("hostApiVersion", api_version)?; + result.set_item("engine", "rust")?; + result.set_item("serverTime", timestamp)?; + Ok(result.unbind()) + }) + }) +} + +/// 导出清单和宿主运行时需要的 Python 扩展入口。 +#[pymodule] +fn _native(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_function(wrap_pyfunction!(create_plugin, module)?)?; + module.add_function(wrap_pyfunction!(create_app, module)?)?; + module.add_function(wrap_pyfunction!(health, module)?)?; + Ok(()) +} diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/native/pyproject.toml.tmpl b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/native/pyproject.toml.tmpl new file mode 100644 index 0000000..8f63667 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/native/pyproject.toml.tmpl @@ -0,0 +1,14 @@ +[build-system] +requires = ["maturin>=1.15,<2"] +build-backend = "maturin" + +[project] +name = "__DISTRIBUTION__" +version = "1.0.0" +requires-python = ">=3.10" +description = "RuoYi native ASGI plugin example" + +[tool.maturin] +python-source = "python" +module-name = "ruoyi_plugin___PLUGIN_ID__._native" +bindings = "pyo3" diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/python/__init__.py.tmpl b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/python/__init__.py.tmpl new file mode 100644 index 0000000..dc09a9e --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/python/__init__.py.tmpl @@ -0,0 +1,85 @@ +from datetime import datetime, timezone + +from fastapi import FastAPI + +from plugins.core.sdk import ( + PluginDefinition, + PluginHostContext, + PluginRequestContext, + plugin_endpoint, + plugin_lifespan, +) + + +async def startup(host: PluginHostContext) -> dict[str, bool]: + """ + 记录插件启动并返回就绪状态,可在此初始化插件自有资源。 + + :param host: 宿主提供的插件上下文 + :return: 插件就绪状态 + """ + host.logger.info("__PLUGIN_ID__ started") + return {"pluginReady": True} + + +async def shutdown(host: PluginHostContext) -> None: + """ + 记录插件关闭,可在此释放插件自有资源并保留宿主共享连接池。 + + :param host: 宿主提供的插件上下文 + :return: None + """ + host.logger.info("__PLUGIN_ID__ stopped") + # 仅释放插件自身创建的资源,不关闭宿主 Redis 或数据库连接池。 + + +async def health(context: object) -> bool: + """ + 返回本模板的健康检查结果,可在此扩展插件自有依赖检查。 + + :param context: 宿主调用健康检查时传入的上下文 + :return: 插件是否健康 + """ + return True + + +async def info(context: PluginRequestContext) -> dict[str, str]: + return { + "pluginId": context.host.plugin_id, + "hostApiVersion": context.host.api_version, + "engine": "python", + "serverTime": datetime.now(timezone.utc).isoformat(), + } + + +def create_app(host: PluginHostContext) -> FastAPI: + """ + 创建受宿主权限保护的 ASGI 子应用并绑定插件生命周期。 + + :param host: 宿主提供的插件上下文 + :return: 插件 ASGI 子应用 + """ + app = FastAPI( + title="__PLUGIN_ID__", + docs_url=None, + redoc_url=None, + lifespan=plugin_lifespan(host, startup, shutdown), + ) + app.add_api_route( + "/api/info", + plugin_endpoint(info, permission="__PLUGIN_ID__:view"), + summary="获取插件信息接口", + description="用于获取当前插件标识、宿主API版本、运行引擎和服务器时间", + response_model=dict[str, str], + ) + return app + + +def create_plugin(host: PluginHostContext) -> PluginDefinition: + """ + 声明插件能力,实际资源初始化交给生命周期回调。 + + :param host: 宿主提供的插件上下文 + :return: 插件能力声明 + """ + return PluginDefinition(app_factory=create_app) diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/python/build_release.py.tmpl b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/python/build_release.py.tmpl new file mode 100644 index 0000000..cace05e --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/python/build_release.py.tmpl @@ -0,0 +1,233 @@ +import argparse +import json +import shutil +import stat +import unicodedata +from pathlib import Path, PureWindowsPath +from typing import Any + +PLUGIN_ID = '__PLUGIN_ID__' +BUNDLE = __BUNDLE__ +SOURCE = Path(__file__).resolve().parent +RELEASE_MANIFEST = 'release-files.json' +MANIFEST_VERSION = 1 +MAX_MANIFEST_BYTES = 64 * 1024 +MAX_SOURCE_FILES = 1024 +MAX_PATH_LENGTH = 1024 +MAX_PATH_COMPONENTS = 32 +MAX_COMPONENT_LENGTH = 255 +FIRST_PRINTABLE_CHARACTER = 32 +DELETE_CHARACTER = 127 +SOURCE_EXTENSIONS = {'.py', '.sql'} +FORBIDDEN_DIRECTORIES = { + 'tests', + 'test', + 'keys', + 'vendor', + 'node_modules', + '__pycache__', + 'build', + 'dist', + 'target', + 'web', +} +FORBIDDEN_FILES = {'build_release.py', 'conftest.py', 'setup.py'} +WEB_EXTENSIONS = { + '.html', + '.js', + '.css', + '.svg', + '.png', + '.jpg', + '.jpeg', + '.gif', + '.webp', + '.ico', + '.woff', + '.woff2', + '.ttf', +} +REPARSE_POINT = getattr(stat, 'FILE_ATTRIBUTE_REPARSE_POINT', 0x400) + + +def checked_file(path: Path) -> Path: + """ + 校验交付文件位于源码目录内,且不含链接、重解析点或硬链接。 + + :param path: 待交付的源码文件路径 + :return: 通过检查的源码文件路径 + """ + for part in (path, *path.parents): + if part == SOURCE: + break + metadata = part.lstat() + if part.is_symlink() or getattr(metadata, 'st_file_attributes', 0) & REPARSE_POINT: + raise ValueError(f'交付资源不能是链接或重解析点:{part}') + resolved = path.resolve() + if not resolved.is_relative_to(SOURCE) or not path.is_file(): + raise ValueError(f'交付文件必须位于源码目录:{path}') + if path.stat().st_nlink > 1: + raise ValueError(f'交付文件不能是硬链接:{path}') + return path + + +def checked_relative_path(value: str) -> tuple[str, ...]: + """ + 校验明确的跨平台相对路径,不接受通配符、隐藏组件或设备路径。 + + :param value: 清单或 bundle 中的相对路径 + :return: 未经折叠或重写的路径组件 + """ + if not isinstance(value, str): + raise ValueError('交付路径必须为字符串') + parts = value.split('/') + if ( + not value + or len(value) > MAX_PATH_LENGTH + or len(parts) > MAX_PATH_COMPONENTS + or unicodedata.normalize('NFC', value) != value + or any( + not part + or part.startswith('.') + or part.endswith((' ', '.')) + or len(part) > MAX_COMPONENT_LENGTH + or PureWindowsPath(part).is_reserved() + or any( + char in '<>:"\\|?*[]' or ord(char) < FIRST_PRINTABLE_CHARACTER or ord(char) == DELETE_CHARACTER + for char in part + ) + for part in parts + ) + ): + raise ValueError(f'交付清单包含不安全路径:{value!r}') + return tuple(parts) + + +def checked_inventory(names: list[str]) -> None: + """ + 拒绝重复文件、文件与目录冲突以及任一级目录的大小写歧义。 + + :param names: 全部交付文件的相对路径 + :return: None + """ + spellings: dict[str, str] = {} + files: set[str] = set() + directories: set[str] = set() + for name in names: + parts = checked_relative_path(name) + key = name.casefold() + if key in files: + raise ValueError(f'交付清单包含重复文件或大小写冲突:{name}') + files.add(key) + for count in range(1, len(parts) + 1): + prefix = '/'.join(parts[:count]) + if spellings.setdefault(prefix.casefold(), prefix) != prefix: + raise ValueError(f'交付清单包含目录大小写冲突:{name}') + if count < len(parts): + directories.add(prefix.casefold()) + if files & directories: + raise ValueError('交付清单存在文件与父目录冲突') + + +def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + """ + 解析 JSON 对象时拒绝重复字段,避免不同工具对同一清单作不同解释。 + + :param pairs: JSON 对象按原始顺序出现的字段 + :return: 不包含重复字段的对象 + """ + result = {} + for key, value in pairs: + if key in result: + raise ValueError(f'交付清单包含重复字段:{key}') + result[key] = value + return result + + +def source_resources() -> list[Path]: + """ + 按有大小和数量上限的显式清单读取 Python、SQL 资源,不扫描业务源码。 + + :return: 已通过路径、类型和链接检查的源码文件列表 + """ + path = checked_file(SOURCE / RELEASE_MANIFEST) + with path.open('rb') as source: + content = source.read(MAX_MANIFEST_BYTES + 1) + if len(content) > MAX_MANIFEST_BYTES: + raise ValueError('release-files.json 超过 64 KiB 上限') + manifest = json.loads(content.decode('utf-8-sig'), object_pairs_hook=unique_object) + if ( + not isinstance(manifest, dict) + or set(manifest) != {'schemaVersion', 'files'} + or type(manifest['schemaVersion']) is not int + or manifest['schemaVersion'] != MANIFEST_VERSION + ): + raise ValueError('release-files.json 必须包含 schemaVersion: 1 和 files') + names = manifest['files'] + if not isinstance(names, list) or not 1 <= len(names) <= MAX_SOURCE_FILES: + raise ValueError('交付清单 files 必须为包含 1 至 1024 项的列表') + checked_inventory(names) + for name in names: + parts = name.split('/') + filename = parts[-1].casefold() + if ( + Path(name).suffix not in SOURCE_EXTENSIONS + or any(part.casefold() in FORBIDDEN_DIRECTORIES for part in parts[:-1]) + or filename in FORBIDDEN_FILES + or filename.startswith('test_') + or filename.endswith('_test.py') + ): + raise ValueError(f'仅允许业务 .py/.sql 文件,不能包含测试、密钥或开发资源:{name}') + return [checked_file(SOURCE / name) for name in names] + + +def bundle_resources() -> list[Path]: + """ + 收集已构建 bundle,保留原有静态扩展名、隐藏文件和链接限制。 + + :return: 已通过检查的 web/dist 文件列表 + """ + dist = SOURCE / 'web' / 'dist' + checked_file(dist / 'index.html') + resources = [] + for path in sorted(dist.rglob('*')): + if path.is_symlink() or getattr(path.lstat(), 'st_file_attributes', 0) & REPARSE_POINT: + raise ValueError(f'bundle 不允许链接:{path}') + if not path.is_file(): + continue + if path.suffix.lower() not in WEB_EXTENSIONS: + raise ValueError(f'bundle 存在非交付文件;不要包含源码、source map 或密钥:{path}') + resources.append(checked_file(path)) + if not any(path.relative_to(dist).as_posix() == 'index.html' for path in resources): + raise ValueError('bundle 入口必须使用准确的 index.html 文件名') + return resources + + +def main() -> None: + """ + 按显式文件清单组装新交付目录,不安装依赖、导入插件或生成签名。 + + :return: None + """ + parser = argparse.ArgumentParser( + description='按 release-files.json 组装交付文件;不安装依赖、不导入插件、不生成签名。' + ) + parser.add_argument('--output', type=Path, required=True, help='全新输出目录(其中创建插件ID目录)') + output = parser.parse_args().output.resolve() + if output.exists() or output.is_relative_to(SOURCE) or SOURCE.is_relative_to(output): + parser.error('输出目录必须不存在,且不得与源码目录重叠') + resources = [checked_file(SOURCE / 'plugin.yaml'), *source_resources()] + if BUNDLE: + resources.extend(bundle_resources()) + checked_inventory([path.relative_to(SOURCE).as_posix() for path in resources]) + plugin = output / PLUGIN_ID + plugin.mkdir(parents=True) + for resource in resources: + target = plugin / resource.relative_to(SOURCE) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(resource, target) + print(json.dumps({'plugin': str(plugin), 'signed': False}, ensure_ascii=False)) + + +if __name__ == '__main__': + main() diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/test_plugin.py.tmpl b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/test_plugin.py.tmpl new file mode 100644 index 0000000..5a24982 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/test_plugin.py.tmpl @@ -0,0 +1,74 @@ +import logging +import os +from collections.abc import Awaitable, Callable +from pathlib import Path + +import httpx +import pytest +from fastapi import Request +from starlette import status +from starlette.responses import Response + +from plugins.core.discovery.scanner import PluginScanner +from plugins.core.runtime.entrypoint import PluginEntrypointLoader +from plugins.core.sdk import ( + PluginHostContext, + PluginRequestContext, + await_plugin_callback, +) + +NATIVE = __NATIVE__ +DIRECTORY = os.environ.get("RUOYI_PLUGIN_DIRECTORY") +ROOT = Path(DIRECTORY).resolve() if DIRECTORY else Path(__file__).resolve().parents[1] +pytestmark = pytest.mark.skipif( + NATIVE and not DIRECTORY, reason="请先构建并设置 RUOYI_PLUGIN_DIRECTORY" +) + + +@pytest.mark.asyncio +async def test_plugin_contract_and_permission() -> None: + """ + 验证插件工厂、生命周期、接口权限和健康检查合约。 + + :return: None + """ + plugin = PluginScanner(ROOT.parent).load_manifest(ROOT / "plugin.yaml") + host = PluginHostContext( + "__PLUGIN_ID__", ROOT, logger=logging.getLogger("__PLUGIN_ID__") + ) + loader = PluginEntrypointLoader(plugin) + definition = loader.load(host) + app = definition.app_factory(host) + permissions = set() + + @app.middleware("http") + async def inject_test_context( + request: Request, call_next: Callable[[Request], Awaitable[Response]] + ) -> Response: + """ + 注入本地请求上下文替身,不连接宿主数据库。 + + :param request: 已由宿主注入插件上下文的请求 + :param call_next: 继续执行后续请求处理的回调 + :return: 后续请求处理返回的响应 + """ + request.state.plugin_context = PluginRequestContext( + host, object(), frozenset(permissions) + ) + return await call_next(request) + + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://plugin.test" + ) as client, + ): + denied = await client.get("/api/info") + assert denied.status_code == status.HTTP_403_FORBIDDEN + permissions.add("__PLUGIN_ID__:view") + response = await client.get("/api/info") + assert response.status_code == status.HTTP_200_OK + assert response.json()["pluginId"] == "__PLUGIN_ID__" + assert response.json()["engine"] == "__RUNTIME__" + health = loader.load_callable(plugin.manifest.backend.health.checker) + assert await await_plugin_callback(health, host) is True diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/web/dev.html.tmpl b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/web/dev.html.tmpl new file mode 100644 index 0000000..d895436 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/web/dev.html.tmpl @@ -0,0 +1,116 @@ + + + + + + __PLUGIN_ID__ 本地开发 + + + +
+

__PLUGIN_ID__ · 模拟宿主

+

仅使用本地模拟响应,不连接真实后端。此页面不进入生产构建。

+
+ + + + + + 等待连接 +
+
+ + + + diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/web/dev.js.tmpl b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/web/dev.js.tmpl new file mode 100644 index 0000000..5035cb8 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/web/dev.js.tmpl @@ -0,0 +1,62 @@ +import { createPluginHostBridge, validatePluginSession } from './vendor/pluginBridge.js' + +const element = (id) => document.getElementById(id) +const frame = element('plugin-frame') +let route = '/' +const bridge = createPluginHostBridge({ + pluginId: '__PLUGIN_ID__', + session: validatePluginSession( + { + pluginId: '__PLUGIN_ID__', + bridgeVersion: 1, + uiBase: '/apps/__PLUGIN_ID__/ui/', + apiBase: '/apps/__PLUGIN_ID__/api/', + csrfToken: 'local-mock-no-credentials', + expiresIn: 3600, + }, + '__PLUGIN_ID__' + ), + getTarget: () => frame.contentWindow, + request: async ({ url, method, signal }) => { + await new Promise((resolve, reject) => { + if (signal.aborted) return reject(new Error('已取消')) + const cancel = () => { + clearTimeout(timer) + reject(new Error('已取消')) + } + const timer = setTimeout( + () => { + signal.removeEventListener('abort', cancel) + resolve() + }, + Math.max(0, Math.min(5000, Number(element('delay').value) || 0)) + ) + signal.addEventListener('abort', cancel, { once: true }) + }) + if (element('failure').checked) throw new Error('模拟请求失败') + if (method !== 'get' || url !== '/apps/__PLUGIN_ID__/api/info') + throw new Error('模拟接口未实现') + return { pluginId: '__PLUGIN_ID__', serverTime: new Date().toISOString() } + }, + getContext: () => ({ + theme: { mode: element('theme').value }, + language: 'zh-CN', + timeZone: 'Asia/Shanghai', + route, + }), + onReady: () => { + element('status').textContent = '模拟连接已建立' + }, + onRoute: (next) => { + route = next + bridge.updateRoute(route) + }, +}) +frame.src = '/index.html' +element('theme').addEventListener('change', () => bridge.updatePreferences()) +element('reload').addEventListener('click', () => frame.contentWindow.location.reload()) +element('logout').addEventListener('click', () => { + bridge.destroy({ logout: true }) + element('status').textContent = '模拟会话已退出;刷新整个页面可重新连接' +}) +window.addEventListener('pagehide', () => bridge.destroy(), { once: true }) diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/web/index.html.tmpl b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/web/index.html.tmpl new file mode 100644 index 0000000..729dbe3 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/web/index.html.tmpl @@ -0,0 +1,22 @@ + + + + + + __PLUGIN_ID__ + + +
+
+

插件应用

__PLUGIN_ID__

+ +
+

正在连接管理平台…

+
+
插件标识
等待连接
+
服务端时间
等待连接
+
+
+ + + diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/web/main.js.tmpl b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/web/main.js.tmpl new file mode 100644 index 0000000..c119605 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/web/main.js.tmpl @@ -0,0 +1,86 @@ +import { createPluginClient } from '../vendor/pluginBridge.js' +import './style.css' + +// 统一按 ID 获取模板页面中的固定元素。 +const element = (id) => document.getElementById(id) +let client +let context = {} +let connected = false +let ended = false +/** + * 更新连接提示,并区分错误状态。 + */ +const showNotice = (text, error = false) => { + element('notice').textContent = text + element('notice').classList.toggle('error', error) +} +/** + * 同步请求忙碌状态;退出登录后保持刷新按钮禁用。 + */ +const setBusy = (busy) => { + element('workspace').setAttribute('aria-busy', String(busy)) + element('refresh').disabled = busy || ended +} +/** + * 保存宿主上下文,并应用当前主题和语言。 + */ +const applyContext = (next) => { + context = { ...context, ...next } + document.documentElement.dataset.theme = context.theme?.mode || 'light' + document.documentElement.lang = context.language || 'zh-CN' +} +/** + * 通过宿主桥接会话查询插件状态,忽略退出登录后的响应。 + */ +async function refresh() { + if (!connected || ended) return + setBusy(true) + try { + const result = await client.request({ method: 'GET', path: 'info' }) + if (ended) return + element('plugin-id').textContent = result.pluginId + element('server-time').textContent = new Intl.DateTimeFormat(context.language || 'zh-CN', { + timeZone: context.timeZone || 'UTC', dateStyle: 'short', timeStyle: 'medium', + }).format(new Date(result.serverTime)) + showNotice('连接正常,已获取最新状态。') + } catch (error) { + if (!ended) showNotice(`${error.message},可点击“刷新状态”重试。`, true) + } finally { + setBusy(false) + } +} +/** + * 建立或重建宿主会话,并订阅偏好、刷新和退出登录事件。 + */ +async function connect() { + setBusy(true) + try { + client?.destroy() + const config = JSON.parse(element('ruoyi-plugin-config').textContent) + client = createPluginClient({ pluginId: config.pluginId }) + client.subscribe(({ type, payload }) => { + if (type === 'preferences' || type === 'route') applyContext(payload) + if (type === 'refresh') refresh() + if (type === 'logout') { + ended = true + connected = false + client.destroy() + element('plugin-id').textContent = '会话已结束' + element('server-time').textContent = '—' + showNotice('登录已结束,请重新登录管理平台。') + setBusy(false) + } + }) + applyContext(await client.ready) + connected = true + await refresh() + } catch (error) { + connected = false + showNotice(error.message || '请从管理平台打开插件页面。', true) + setBusy(false) + } +} +element('refresh').addEventListener('click', () => connected ? refresh() : connect()) +// 页面离开时释放桥接层的事件监听和待处理请求。 +window.addEventListener('pagehide', () => client?.destroy(), { once: true }) +connect() diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/web/package.json.tmpl b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/web/package.json.tmpl new file mode 100644 index 0000000..8319b87 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/web/package.json.tmpl @@ -0,0 +1,8 @@ +{ + "name": "__DISTRIBUTION__-web", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { "dev": "vite --host 127.0.0.1 --open /dev.html", "build": "vite build" }, + "devDependencies": { "vite": "6.4.3" } +} diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/web/style.css.tmpl b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/web/style.css.tmpl new file mode 100644 index 0000000..a6000ea --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/web/style.css.tmpl @@ -0,0 +1,41 @@ +:root { + color-scheme: light; + --background: #f4f6f8; + --surface: #ffffff; + --text: #192b3a; + --muted: #50616f; + --border: #d6dfe6; + --accent: #075c9e; + --error: #a92121; + font: 16px/1.6 system-ui, sans-serif; + color: var(--text); + background: var(--background); +} +:root[data-theme='dark'] { + color-scheme: dark; + --background: #121c24; + --surface: #1b2b36; + --text: #edf4f8; + --muted: #b4c8d5; + --border: #405867; + --accent: #7cc4f7; + --error: #ffaaa2; +} +* { box-sizing: border-box; } +body { margin: 0; padding: clamp(16px, 4vw, 40px); } +main { max-width: 880px; margin-inline: auto; } +header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px; } +h1 { margin: 0; font-size: clamp(24px, 5vw, 36px); overflow-wrap: anywhere; } +.eyebrow { margin: 0; color: var(--muted); } +button { + min-height: 44px; padding: 8px 18px; border: 1px solid var(--accent); border-radius: 6px; + background: var(--surface); color: var(--accent); font: inherit; cursor: pointer; +} +button:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; } +button:disabled { opacity: 0.6; cursor: default; } +#notice { min-height: 52px; margin-block: 24px; color: var(--muted); overflow-wrap: anywhere; } +#notice.error { color: var(--error); } +dl { margin: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); gap: 16px; } +dl > div { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 24px; } +dt { color: var(--muted); font-size: 14px; } +dd { margin: 8px 0 0; font-size: 18px; overflow-wrap: anywhere; } diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/web/vite.config.js.tmpl b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/web/vite.config.js.tmpl new file mode 100644 index 0000000..f202db1 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/web/vite.config.js.tmpl @@ -0,0 +1,26 @@ +export default { + // 使用相对资源路径,使 bundle 可由宿主从插件目录交付。 + base: './', + plugins: [ + { + name: 'local-plugin-config', + apply: 'serve', + transformIndexHtml(html, context) { + if (!['/', '/index.html'].includes(context.path)) return html + return { + html, + tags: [ + { + tag: 'script', + attrs: { id: 'ruoyi-plugin-config', type: 'application/json' }, + children: JSON.stringify({ pluginId: '__PLUGIN_ID__' }), + injectTo: 'head', + }, + ], + } + }, + }, + ], + // 交付目录只包含构建产物,不生成源码映射文件。 + build: { sourcemap: false }, +} diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/builder.py b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/builder.py index c651e23..a48542d 100644 --- a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/builder.py +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/builder.py @@ -7,6 +7,7 @@ from .backend import PluginBackendScaffoldTemplateBuilder from .frontend import FrontendVersion, PluginFrontendScaffoldTemplateBuilder, PluginFrontendVersionResolver from .options import PluginScaffoldOptions, PluginScaffoldTemplateResolver from .payload import PluginScaffoldPayloadBuilder, PluginScaffoldPlanPayload +from .v2 import PluginV2ScaffoldBuilder class PluginScaffoldBuilder: @@ -56,6 +57,16 @@ class PluginScaffoldBuilder: :return: 插件模板写入计划 """ self._validate_plugin_id(plugin_id) + normalized_template = (template or PluginScaffoldTemplateResolver.DEFAULT_TEMPLATE).strip() + if normalized_template in PluginScaffoldTemplateResolver.V2_TEMPLATES: + return PluginV2ScaffoldBuilder(self.backend_root, self.frontend_root).build_plan( + plugin_id, + template=normalized_template, + backend=backend, + frontend=frontend, + test=test, + frontend_version=frontend_version, + ) options = self._merge_options( PluginScaffoldTemplateResolver.resolve(template), backend=backend, diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/options.py b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/options.py index c9a8a88..0c08373 100644 --- a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/options.py +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/options.py @@ -33,7 +33,8 @@ class PluginScaffoldTemplateResolver: 使用 Resolver 模式将模板名称转换为稳定的模板生成选项。 """ - SUPPORTED_TEMPLATES = {'minimal', 'backend-only', 'full-stack', 'scheduled-job', 'crud-page'} + V2_TEMPLATES = frozenset({'python-asgi', 'python-bundle', 'rust-asgi', 'rust-bundle'}) + SUPPORTED_TEMPLATES = {'minimal', 'backend-only', 'full-stack', 'scheduled-job', 'crud-page'} | V2_TEMPLATES DEFAULT_TEMPLATE = 'full-stack' @classmethod @@ -45,6 +46,16 @@ class PluginScaffoldTemplateResolver: :return: 插件模板生成选项 """ normalized_template = (template or cls.DEFAULT_TEMPLATE).strip() or cls.DEFAULT_TEMPLATE + if normalized_template in cls.V2_TEMPLATES: + return PluginScaffoldOptions( + backend=True, + frontend=normalized_template.endswith('-bundle'), + migration=False, + seed=False, + job=False, + config=False, + test=True, + ) if normalized_template == 'minimal': return PluginScaffoldOptions( backend=True, diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/v2.py b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/v2.py new file mode 100644 index 0000000..8b99fb7 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/v2.py @@ -0,0 +1,363 @@ +import json +from importlib.resources import files +from pathlib import Path +from typing import Any + +import yaml + +from cli.runtime.plugin.sdk import PluginBridgeSdk + +from .payload import PluginScaffoldPlanPayload + + +class PluginV2ScaffoldBuilder: + """ + 独立 v2 插件源码工程模板构建器。 + """ + + def __init__(self, backend_root: Path, frontend_root: Path) -> None: + """ + 初始化独立 v2 工程模板构建器。 + + :param backend_root: 宿主后端项目根目录 + :param frontend_root: 宿主前端项目根目录,用于读取桥接 SDK + :return: None + """ + self.backend_root = backend_root + self.frontend_root = frontend_root + + def build_plan( + self, plugin_id: str, *, template: str, backend: bool, frontend: bool, test: bool, frontend_version: str + ) -> dict[str, Any]: + """ + 生成独立源码工程的文件计划,不安装、启用或导入插件。 + + :param plugin_id: 插件 ID + :param template: v2 模板名称 + :param backend: 是否创建后端工程 + :param frontend: 是否创建前端工程 + :param test: 是否生成本地合约测试 + :param frontend_version: 旧版前端选项,v2 模板仅接受 auto + :return: 包含生成文件、目标目录与冲突信息的计划 + """ + native = template.startswith('rust-') + bundle = template.endswith('-bundle') + if not backend: + raise ValueError('v2 ASGI 模板需要后端,不能使用 --frontend-only') + if bundle and not frontend: + raise ValueError('bundle 模板不能使用 --backend-only;仅 API 请使用 python-asgi 或 rust-asgi') + if frontend_version != 'auto': + raise ValueError('v2 模板不使用 --frontend-version;独立 bundle 与宿主 Vue 版本无关') + + root = self.backend_root / 'plugin-projects' / plugin_id + replacements = { + '__PLUGIN_ID__': plugin_id, + '__DISTRIBUTION__': 'ruoyi-plugin-' + plugin_id.replace('_', '-'), + '__RUNTIME__': 'rust' if native else 'python', + '__BUNDLE__': str(bundle), + '__NATIVE__': str(native), + } + entries = [ + (root / 'plugin.yaml', self._manifest(plugin_id, native=native, bundle=bundle)), + (root / 'README.md', self._readme(plugin_id, native=native, bundle=bundle, test=test)), + (root / '.gitignore', self._render('gitignore', replacements)), + ] + if native: + for name in ('Cargo.toml', 'Cargo.lock', 'pyproject.toml', 'src/lib.rs'): + resource = name.replace('src/', '') + entries.append((root / name, self._render(f'native/{resource}', replacements))) + entries.append( + ( + root / 'python' / f'ruoyi_plugin_{plugin_id}' / '__init__.py', + '', + ) + ) + else: + entries.extend( + (root / name, self._render(f'python/{name}', replacements)) + for name in ('__init__.py', 'build_release.py') + ) + entries.append( + ( + root / 'release-files.json', + json.dumps({'schemaVersion': 1, 'files': ['__init__.py']}, indent=2) + '\n', + ) + ) + if test: + entries.append((root / 'tests' / 'test_plugin.py', self._render('test_plugin.py', replacements))) + if bundle: + entries.extend( + (root / 'web' / 'vendor' / sdk_name, content) + for sdk_name, content in PluginBridgeSdk(self.frontend_root).build_vendor_files().items() + ) + entries.extend( + (root / 'web' / name, self._render(f'web/{name.removeprefix("src/")}', replacements)) + for name in ( + 'index.html', + 'dev.html', + 'dev.js', + 'package.json', + 'vite.config.js', + 'src/main.js', + 'src/style.css', + ) + ) + + payload = PluginScaffoldPlanPayload( + template=template, + backend=True, + frontend=bundle, + migration=False, + seed=False, + job=False, + config=False, + crud=False, + test=test, + backend_test=test, + frontend_test=False, + frontend_version=None, + target_dirs=[str(root)], + files=entries, + conflicts=[str(root)] if root.exists() else [], + ).to_payload() + payload.update(manifestVersion=2, runtime='native' if native else 'python', sourceDir=root.as_posix()) + return payload + + @staticmethod + def _render(resource: str, replacements: dict[str, str]) -> str: + """ + 读取打包在 CLI 中的模板资源并替换占位符。 + + :param resource: 相对于模板资源目录的文件名,不含 .tmpl 后缀 + :param replacements: 模板占位符及其替换内容 + :return: 完成占位符替换的模板内容 + """ + content = files('cli.runtime.plugin.scaffold').joinpath(f'assets/{resource}.tmpl').read_text(encoding='utf-8') + for key, value in replacements.items(): + content = content.replace(key, value) + return content + + @staticmethod + def _manifest(plugin_id: str, *, native: bool, bundle: bool) -> str: + """ + 生成显式 ASGI 入口、权限与前端交付声明。 + + :param plugin_id: 插件 ID + :param native: 是否生成 Rust 原生插件 + :param bundle: 是否包含独立前端 bundle + :return: manifest v2 的 YAML 内容 + """ + module = f'ruoyi_plugin_{plugin_id}' if native else f'plugins.{plugin_id}' + entry_module = f'{module}._native' if native else module + backend = { + 'runtime': 'native' if native else 'python', + 'integration': 'asgi', + 'module': module, + 'entrypoint': f'{entry_module}:create_plugin', + 'asgi': {'mountPath': f'/apps/{plugin_id}', 'lifespan': 'managed'}, + 'health': {'checker': f'{entry_module}:health'}, + } + if native: + backend['native'] = {'distribution': 'ruoyi-plugin-' + plugin_id.replace('_', '-')} + frontend: dict[str, Any] = {'delivery': {'type': 'bundle' if bundle else 'none'}} + if bundle: + frontend.update( + bundle={'directory': 'web/dist', 'entry': 'index.html', 'spaFallback': True}, + menus=[ + { + 'name': plugin_id, + 'path': plugin_id, + 'component': 'PluginFrame', + 'type': 'C', + 'icon': 'component', + 'perms': f'{plugin_id}:view', + } + ], + ) + return yaml.safe_dump( + { + 'manifestVersion': 2, + 'id': plugin_id, + 'name': plugin_id, + 'version': '1.0.0', + 'description': '独立构建、通过宿主身份与权限访问的 ASGI 插件。', + 'backend': backend, + 'frontend': frontend, + 'permissions': [{'code': f'{plugin_id}:view', 'name': '访问插件'}], + 'compatibility': {'hostApiVersion': '^1.2.0', 'pythonVersion': '>=3.10'}, + }, + allow_unicode=True, + sort_keys=False, + ) + + @staticmethod + def _readme(plugin_id: str, *, native: bool, bundle: bool, test: bool) -> str: + """ + 生成与模板类型匹配的构建、测试及维护发布说明。 + + :param plugin_id: 插件 ID + :param native: 是否生成 Rust 原生插件 + :param bundle: 是否包含独立前端 bundle + :param test: 是否生成本地合约测试 + :return: 工程 README 内容 + """ + root = f'plugin-projects/{plugin_id}' + output = f'target/{plugin_id}-package' + build = ( + f'python scripts/build_native_plugin.py --source {root} --output {output}' + if native + else f'python {root}/build_release.py --output {output}' + ) + frontend_steps = ( + f'```bash\nnpm --prefix {root}/web install\nnpm --prefix {root}/web run build\n```\n\n' + '提交生成的 package-lock.json;后续可用 `npm ci` 重现前端依赖。构建器只复制已构建的 ' + '`web/dist`,不会自动执行 npm。桥接 SDK 和溯源清单已从生成时的宿主复制到 `web/vendor`,' + '检查及更新方式见下文。\n\n' + f'本地调试可运行 `npm --prefix {root}/web run dev`,打开 `/dev.html`。' + '模拟宿主支持主题、延迟、失败、重载及退出,使用模拟 `/api/info` 响应;业务接口需自行扩展 `web/dev.js`。' + '模拟页不进入生产构建,也不能替代真实权限、会话和传输加密验证。' + '同目录的 `pluginBridge.d.ts` 提供 JSON、文件及可协商 SSE 能力的类型;' + '实时事件示例与协议限制见宿主开发手册。\n\n' + if bundle + else '' + ) + native_steps = ( + '需预先安装 Rust 工具链和匹配目标平台的编译工具,以及 `maturin>=1.15,<2`。' + '模板包含 Cargo.lock,构建使用 `--locked`。二进制必须匹配部署系统、架构、Python ABI。\n\n' + if native + else 'Python 交付目录包含业务 Python 源码;签名验证不提供源码保护。\n\n' + ) + packaging_note = ( + '原生打包器复制扩展、发行包元数据、清单及声明的交付资源;不复制 Rust 工程。' + if native + else 'Python 打包脚本按 `release-files.json` 的 `files` 列表复制业务 `.py`、`.sql` 文件。' + '初始清单为 `{"schemaVersion": 1, "files": ["__init__.py"]}`;增加模块或迁移 SQL 时,' + '将每个文件的明确相对路径加入清单,例如 `models.py`、`migrations/mysql/001_init.sql`,再重新构建并测试。' + '`plugin.yaml` 固定纳入,bundle 模板自动纳入已构建的 `web/dist`,这两项不写入列表;' + '清单本身和构建脚本不进入交付。清单上限 64 KiB、1024 个文件,不支持目录或通配符。' + '隐藏路径、重复或大小写冲突、tests/keys/vendor 等开发目录、链接、重解析点和硬链接均会被拒绝。' + '所有资源校验通过后才创建输出目录;未列出的私有文件不会自动复制。' + ) + tests = ( + 'Windows PowerShell:\n\n' + f'```powershell\n$env:RUOYI_PLUGIN_DIRECTORY = (Resolve-Path "{output}/{plugin_id}").Path\n' + f'python -m pytest {root}/tests -q\n```\n\n' + 'macOS / Linux(Bash、Zsh):\n\n' + f'```bash\nexport RUOYI_PLUGIN_DIRECTORY="$(pwd)/{output}/{plugin_id}"\n' + f'python -m pytest {root}/tests -q\n```\n\n' + '测试使用宿主 SDK 和本地请求上下文替身,不连接数据库。Rust 模板未指定构建产物时明确跳过,' + 'Python 模板也可直接测试源码目录。测试不代替真实登录、数据库和多 worker 发布验收。\n\n' + if test + else '' + ) + sdk_steps = ( + '## SDK 检查与更新\n\n' + '`web/vendor/pluginBridge.sdk.json` 记录 SDK 版本、桥协议版本、能力声明和来源摘要。' + 'SDK 版本独立于桥协议 v1;两个源码文件的摘要按 UTF-8、LF 换行计算,' + '跨平台换行转换不会被误判为 SDK 改动。协议兼容不等于业务兼容。\n\n' + f'```bash\nruoyi plugin sdk check {root} --output json\n' + f'ruoyi plugin sdk update {root} --dry-run\n' + f'ruoyi plugin sdk update {root}\n```\n\n' + '`check` 只有 `current` 状态返回成功退出码;`outdated` 或其他状态均返回非零退出码。' + '`update --dry-run` 只预览,实际 `update` 命令直接写入,不需要额外的 `--yes`。' + '修改过或没有溯源记录的旧副本默认拒绝覆盖,需显式使用 `--force`。' + '每次实际更新都先将原 SDK 备份到本工程的 `.plugin-sdk-backups/`。\n\n' + '命令只使用本地宿主 SDK,不联网、不安装依赖、不更新业务源码或运行中的插件。' + f'更新后必须重新运行 `npm --prefix {root}/web run build`,' + '验证业务页面、类型与宿主能力,再重新打包并按维护流程发布。\n\n' + if bundle + else '' + ) + access_note = ( + '由宿主 PluginFrame 打开页面;前端通过 `createPluginClient.ready` 建立会话,' + '再调用 `request({method: "GET", path: "info"})`,不得读取或保存管理员 token。' + 'SDK 仅访问当前插件 API 下的相对路径,不代理任意外部 URL、任意字节流或 WebSocket。\n\n' + '- `request`:JSON 请求和响应每条最多 64 KiB,默认超时 15 秒。\n' + '- `upload` / `download`:通过 `capabilities.files.version=1` 协商,' + '使用 Blob 传输文件,单次最多 10 MiB,默认超时 120 秒,支持进度和取消。\n' + '- `stream`:通过 `capabilities.streams.version=1` 协商只读 GET SSE,' + '每个页面最多 2 条连接,每条连接最长 5 分钟、累计最多 10 MiB,单条事件最多 64 KiB;' + '必须提供 `onEvent`,支持取消,不自动重连。\n\n' + '以上请求合计最多 8 个待处理任务;旧宿主未公布的能力会被拒绝,桥协议仍为 v1。' + '本模板仅生成 `/api/info`,文件和事件接口需自行实现并校验权限。' + '文件和 SSE 沿用普通 HTTPS 传输,不使用 JSON 加密信封;' + '若传输加密策略要求加密,需按宿主开发手册将具体接口路径加入 ' + '`TRANSPORT_CRYPTO_EXCLUDE_PATHS`,不能排除整个插件 API 命名空间。' + '完整用法、鉴权和代理部署要求见宿主 `docs/plugin_development.md` 第 7.3 节。' + if bundle + else '先给角色授予插件权限,再通过宿主认证调用 API;子应用不提供独立登录。' + ) + return f"""# {plugin_id} + +这是 manifest v2 的 {'Rust native' if native else 'Python'} ASGI 源码工程{',包含独立前端 bundle' if bundle else ''}。 +源码位于 `{root}`,创建工程不会安装、启用或导入插件。下面的命令均在宿主后端根目录执行。 + +先创建并激活 Python 3.10–3.13 环境;已有宿主环境可直接激活并使用,无需重复创建。 + +Windows PowerShell: + +```powershell +python -m venv .venv +.\\.venv\\Scripts\\Activate.ps1 +``` + +macOS / Linux(Bash、Zsh): + +```bash +python3 -m venv .venv +source .venv/bin/activate +``` + +随后按宿主数据库类型安装依赖:MySQL 使用 `python -m pip install -r requirements.txt`, +PostgreSQL 使用 `python -m pip install -r requirements-pg.txt`。下文 `python` 指向已激活环境。 +通用构建与发布命令适用于 Windows PowerShell、macOS 和 Linux;Shell 语法不同的步骤分别列出。 + +## 构建与测试 + +{frontend_steps}{native_steps}```bash +{build} +``` + +每次使用不存在的新输出目录。交付目录为 `{output}/{plugin_id}`;不要签名整个源码工程、wheel 输出目录或 Cargo 工程。 +{packaging_note} + +{tests}{sdk_steps}## 签名与维护发布 + +先在宿主启用 `PLUGIN_ARTIFACT_ENABLED`,设置制品存储位置和外部信任公钥文件;公钥必须授权 `{plugin_id}`。 +发布者 Ed25519 PKCS8 私钥放在源码及交付目录之外。以下路径和大写占位符需要替换为实际值。 + +```bash +ruoyi plugin artifact build {output}/{plugin_id} target/{plugin_id}-1.0.0.rpk --key-file ../signing-keys/publisher.pem --key-id publisher +ruoyi plugin artifact verify target/{plugin_id}-1.0.0.rpk --env prod +ruoyi plugin artifact import target/{plugin_id}-1.0.0.rpk --env prod --allow-prod --yes +ruoyi plugin release plan {plugin_id} DIGEST --env prod +``` + +确认计划后停止全部宿主 worker,再执行以下维护操作。`DIGEST` 使用导入结果;`GENERATION` 使用 prepare 后 status 返回的值。 + +```bash +ruoyi plugin release prepare {plugin_id} DIGEST --maintenance --env prod --allow-prod --yes +ruoyi plugin release status --plugin-id {plugin_id} --env prod +ruoyi plugin release select {plugin_id} DIGEST --expected-generation GENERATION --expected-workers 1 --maintenance --env prod --allow-prod --yes +``` + +将 expected-workers 改为实际数量,保存 select 返回的新 generation;若随后 enable 或 rollback,则使用该操作返回的新代际。 +通过进程管理器启动全部 worker 后,使用新的 `SELECTED_GENERATION` 等待发布就绪,不要复用上面输入的旧 `GENERATION`: + +```bash +ruoyi plugin release wait {plugin_id} DIGEST --generation SELECTED_GENERATION --expected-workers 1 --timeout 300 --interval 2 --env prod --output json +``` + +wait 的 worker 数量应与 select 一致;四个固定目标均必填。只有全部存活 worker 加载同一目标且数量达到预期才返回 0。 +`--timeout 0` 只查询并断言一次;尚未就绪、超时、目标变化、停用或 worker 故障均返回非零。wait 只读,不负责重启或变更目标。 +`installedVersion` 是数据准备状态,选择目标不会立即切换运行代码。更新版本时同步清单{'、Cargo.toml、Cargo.lock 和 pyproject.toml' if native else ''}。 +旧代码回滚必须兼容当前数据库,不撤销迁移。不要覆盖已加载二进制或修改不可变制品目录。 + +`plugins/{plugin_id}` 如有同名源码目录,会与制品发布冲突;在维护窗口备份并移出后才能导入。 +原生插件与同源 iframe 均为可信扩展,不构成安全沙箱。 + +## 访问 + +权限为 `{plugin_id}:view`,接口为 `GET /apps/{plugin_id}/api/info`。请求沿用宿主认证与传输加密策略。 +{access_note} +""" diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/sdk.py b/ruoyi-fastapi-backend/cli/runtime/plugin/sdk.py new file mode 100644 index 0000000..0ee2f43 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/sdk.py @@ -0,0 +1,502 @@ +import hashlib +import json +import os +import re +import stat +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path +from typing import Any +from uuid import uuid4 + +import yaml + +SDK_FILES = ('pluginBridge.js', 'pluginBridge.d.ts') +SDK_METADATA = 'pluginBridge.sdk.json' +VENDOR_FILES = (*SDK_FILES, SDK_METADATA) +HASH_ALGORITHM = 'sha256-utf8-lf' +SDK_SOURCE = {'project': 'RuoYi-Vue3-FastAPI', 'directory': 'ruoyi-fastapi-frontend/src/utils'} +METADATA_FIELDS = {'schemaVersion', 'sdkVersion', 'bridgeVersion', 'capabilities'} +SCHEMA_VERSION = 1 +MANIFEST_VERSION = 2 + + +class PluginBridgeSdk: + """离线检查和更新独立 bundle 工程中的桥接 SDK 副本。""" + + def __init__(self, frontend_root: Path) -> None: + """ + 保存用于读取当前宿主 SDK 的前端目录,不初始化宿主运行环境。 + + :param frontend_root: 宿主前端工程根目录 + :return: None + """ + self.frontend_root = Path(frontend_root) + + def build_vendor_files(self) -> dict[str, str]: + """ + 校验宿主 SDK 并生成使用 UTF-8、LF 和内容摘要的三个副本文件。 + + :return: 按固定文件名索引的文本,不包含时间戳或本机路径 + """ + directory = self._safe_path(self.frontend_root / 'src' / 'utils') + contents: dict[str, str] = {} + for name in VENDOR_FILES: + path = self._safe_path(directory / name) + if not path.is_file(): + raise ValueError(f'缺少宿主桥接 SDK:{path};请配置正确的前端工程目录') + contents[name] = self._text(path.read_bytes()) + metadata = self._metadata(contents[SDK_METADATA], vendor=False) + self._validate_exports(contents, metadata) + metadata.update( + hashAlgorithm=HASH_ALGORITHM, + source=dict(SDK_SOURCE), + files={name: self._hash(contents[name]) for name in SDK_FILES}, + ) + contents[SDK_METADATA] = json.dumps(metadata, ensure_ascii=False, indent=2) + '\n' + return contents + + def check(self, project: Path) -> dict[str, Any]: + """ + 静态检查 v2 bundle 工程副本,仅与当前宿主完全一致时通过检查。 + + :param project: 含 plugin.yaml 的独立插件工程目录 + :return: 副本状态、版本、协议兼容性与三个文件的摘要信息 + """ + try: + current = self.build_vendor_files() + _, vendor = self._project_paths(project) + report, _ = self._inspect(vendor, current) + return report + except (OSError, ValueError, yaml.YAMLError) as exc: + return self._report('invalid', f'SDK 检查失败:{exc}') + + def update(self, project: Path, *, dry_run: bool = False, force: bool = False) -> dict[str, Any]: + """ + 备份并更新三个 SDK 文件,先写代码和类型、最后写来源记录。 + + :param project: 含 plugin.yaml 的独立插件工程目录 + :param dry_run: 仅预演,不创建锁、目录、备份或文件 + :param force: 允许备份后覆盖改动副本或纳管缺失、损坏的来源记录 + :return: 更新结果、原状态、备份位置及更新后的检查信息 + """ + backup: Path | None = None + previous: dict[str, Any] = self._report('invalid', 'SDK 更新尚未执行') + try: + current = self.build_vendor_files() + root, vendor = self._project_paths(project) + previous, _ = self._inspect(vendor, current) + refusal = self._refusal(previous, force=force) + if refusal: + return {**previous, 'ok': False, 'message': refusal, 'dryRun': dry_run, 'updated': False} + backup_root = self._safe_path(root / '.plugin-sdk-backups') + if backup_root.exists() and not backup_root.is_dir(): + raise ValueError('SDK 备份路径必须是目录') + if dry_run or previous['status'] == 'current': + return { + **previous, + 'ok': True, + 'dryRun': dry_run, + 'updated': False, + 'backupDir': None, + 'previousStatus': previous['status'], + 'message': 'SDK 已与当前宿主一致' if previous['status'] == 'current' else '可备份并更新 SDK 副本', + } + with self._lock(root): + # 取得锁后重新读取,不能覆盖另一次更新之后的新状态。 + root, vendor = self._project_paths(root) + previous, original = self._inspect(vendor, current) + refusal = self._refusal(previous, force=force) + if refusal: + return {**previous, 'ok': False, 'message': refusal, 'dryRun': False, 'updated': False} + if previous['status'] == 'current': + return {**previous, 'dryRun': False, 'updated': False, 'backupDir': None} + backup = self._backup(root, original) + vendor.mkdir(parents=True, exist_ok=True) + result = self._apply(vendor, current, original, backup) + return { + **result, + 'dryRun': False, + 'updated': True, + 'backupDir': str(backup), + 'previousStatus': previous['status'], + 'message': 'SDK 副本已更新,原文件已备份;请重新构建并验证插件', + } + except (OSError, ValueError, yaml.YAMLError) as exc: + return { + **self._report('invalid', f'SDK 更新失败:{exc}'), + 'dryRun': dry_run, + 'updated': False, + 'backupDir': str(backup) if backup is not None else None, + 'previousStatus': previous['status'], + } + + @staticmethod + def _text(content: bytes) -> str: + """ + 解码 UTF-8 并统一换行,忽略编辑器添加的 UTF-8 BOM。 + + :param content: 文件原始字节 + :return: 不含 BOM 且只使用 LF 的文本 + """ + return content.decode('utf-8-sig').replace('\r\n', '\n').replace('\r', '\n') + + @staticmethod + def _hash(content: str) -> str: + """ + 计算归一文本的内容摘要,该摘要不是发布者签名。 + + :param content: 已归一为 LF 的文本 + :return: UTF-8 字节的 SHA256 十六进制摘要 + """ + return hashlib.sha256(content.encode('utf-8')).hexdigest() + + @staticmethod + def _safe_path(path: Path) -> Path: + """ + 逐级拒绝符号链接和 Windows 重解析点,避免操作越过实际工程路径。 + + :param path: 待使用的文件或目录路径 + :return: 已检查现存路径组件的绝对路径 + """ + absolute = Path(os.path.abspath(path)) + for component in reversed((absolute, *absolute.parents)): + try: + info = component.lstat() + except FileNotFoundError: + continue + reparse = getattr(info, 'st_file_attributes', 0) & getattr(stat, 'FILE_ATTRIBUTE_REPARSE_POINT', 0) + if stat.S_ISLNK(info.st_mode) or reparse: + raise ValueError(f'SDK 路径不能包含符号链接或重解析点:{component}') + return absolute + + @classmethod + def _project_paths(cls, project: Path) -> tuple[Path, Path]: + """ + 只读取 YAML 判定工程类型,固定副本位置而不导入插件入口。 + + :param project: 用户指定的插件工程根目录 + :return: 工程根目录及固定的 web/vendor 目录 + """ + root = cls._safe_path(project) + if not root.is_dir(): + raise ValueError('插件工程目录不存在') + manifest_path = cls._safe_path(root / 'plugin.yaml') + manifest = yaml.safe_load(manifest_path.read_text(encoding='utf-8-sig')) + if not isinstance(manifest, dict) or type(manifest.get('manifestVersion')) is not int: + raise ValueError('SDK 命令只支持 manifestVersion: 2 的 bundle 工程') + frontend = manifest.get('frontend') + delivery = frontend.get('delivery') if isinstance(frontend, dict) else None + if ( + manifest['manifestVersion'] != MANIFEST_VERSION + or not isinstance(delivery, dict) + or delivery.get('type') != 'bundle' + ): + raise ValueError('SDK 命令只支持 manifestVersion: 2 的 bundle 工程') + vendor = cls._safe_path(root / 'web' / 'vendor') + if vendor.exists() and not vendor.is_dir(): + raise ValueError('web/vendor 必须是目录') + for name in VENDOR_FILES: + path = cls._safe_path(vendor / name) + if path.exists() and not path.is_file(): + raise ValueError(f'SDK 目标必须是普通文件:{name}') + return root, vendor + + @staticmethod + def _metadata(content: str, *, vendor: bool) -> dict[str, Any]: + """ + 校验版本与来源记录结构,不把自报摘要当作来源真实性证明。 + + :param content: 元数据 JSON 文本 + :param vendor: 是否要求副本的摘要与来源字段 + :return: 通过字段及类型检查的元数据 + """ + metadata = json.loads(content) + fields = METADATA_FIELDS | ({'hashAlgorithm', 'source', 'files'} if vendor else set()) + if not isinstance(metadata, dict) or set(metadata) != fields: + raise ValueError('桥接 SDK 元数据字段无效') + if type(metadata['schemaVersion']) is not int or metadata['schemaVersion'] != SCHEMA_VERSION: + raise ValueError('桥接 SDK 元数据 schemaVersion 无效') + version = metadata['sdkVersion'] + if not isinstance(version, str) or not re.fullmatch( + r'(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)' + r'(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?', + version, + ): + raise ValueError('桥接 SDK 的 sdkVersion 无效') + if type(metadata['bridgeVersion']) is not int or metadata['bridgeVersion'] <= 0: + raise ValueError('桥接 SDK 的 bridgeVersion 无效') + capabilities = metadata['capabilities'] + if not isinstance(capabilities, dict) or any( + not isinstance(name, str) or not name or type(value) is not int or value <= 0 + for name, value in capabilities.items() + ): + raise ValueError('桥接 SDK 的 capabilities 无效') + if vendor: + hashes = metadata['files'] + if metadata['hashAlgorithm'] != HASH_ALGORITHM or metadata['source'] != SDK_SOURCE: + raise ValueError('桥接 SDK 的摘要算法或来源记录无效') + if ( + not isinstance(hashes, dict) + or set(hashes) != set(SDK_FILES) + or any( + not isinstance(value, str) or not re.fullmatch(r'[0-9a-f]{64}', value) for value in hashes.values() + ) + ): + raise ValueError('桥接 SDK 文件摘要记录无效') + return metadata + + @staticmethod + def _wire_versions(content: str) -> list[str]: + """ + 只识别固定格式的协议版本导出,不执行或推断任意 JavaScript 表达式。 + + :param content: JS 或类型声明内容 + :return: 所有可明确识别的协议版本字面量 + """ + return re.findall( + r'^\s*export\s+(?:declare\s+)?const\s+PLUGIN_BRIDGE_VERSION\s*[:=]\s*(\d+)\s*;?\s*$', + content, + re.MULTILINE, + ) + + @classmethod + def _validate_exports(cls, contents: dict[str, str], metadata: dict[str, Any]) -> None: + """ + 静态核对 JS 与类型声明导出的 SDK 版本及通信协议版本。 + + :param contents: SDK 的 JS 和类型声明文本 + :param metadata: 对应的元数据 + :return: None + """ + for name in SDK_FILES: + sdk_versions = re.findall( + r'^\s*export\s+(?:declare\s+)?const\s+PLUGIN_BRIDGE_SDK_VERSION\s*[:=]\s*[\'"]([^\'"]+)[\'"]\s*;?\s*$', + contents[name], + re.MULTILINE, + ) + bridge_versions = cls._wire_versions(contents[name]) + if sdk_versions != [metadata['sdkVersion']] or bridge_versions != [str(metadata['bridgeVersion'])]: + raise ValueError(f'桥接 SDK 版本声明与元数据不一致:{name}') + + @classmethod + def _protocol_compatible( + cls, contents: dict[str, str], current_wire: int, saved: dict[str, Any] | None + ) -> bool | None: + """ + 结合实际导出和来源记录判断协议,无法识别实际导出时保留未知。 + + :param contents: 可成功解码的本地副本内容 + :param current_wire: 当前宿主的协议版本 + :param saved: 有效的本地来源记录,缺失或损坏时为 None + :return: True 表示协议一致,False 表示明确不兼容,None 表示无法确认 + """ + versions = [cls._wire_versions(contents.get(name, '')) for name in SDK_FILES] + if (saved is not None and saved['bridgeVersion'] != current_wire) or any( + value != str(current_wire) for values in versions for value in values + ): + return False + return True if all(len(values) == 1 for values in versions) else None + + @staticmethod + def _report(status: str, message: str) -> dict[str, Any]: + """ + 创建稳定的检查结果字段,未知协议不伪报兼容。 + + :param status: current、outdated、modified、untracked、invalid 或 incompatible + :param message: 可读的检查说明 + :return: 初始检查结果 + """ + return { + 'ok': status == 'current', + 'status': status, + 'sdkVersion': None, + 'currentSdkVersion': None, + 'protocolCompatible': None, + 'updateAvailable': False, + 'files': {}, + 'message': message, + 'forceRequired': False, + } + + @classmethod + def _snapshot( + cls, vendor: Path, latest: dict[str, Any] + ) -> tuple[dict[str, bytes | None], dict[str, str], dict[str, dict[str, Any]]]: + """ + 读取固定的三个文件,并保留不可解码文件的原始字节供备份。 + + :param vendor: 已完成路径检查的副本目录 + :param latest: 当前宿主的来源记录 + :return: 原始字节、可解码文本及文件摘要信息 + """ + originals = {name: (vendor / name).read_bytes() if (vendor / name).exists() else None for name in VENDOR_FILES} + contents: dict[str, str] = {} + rows: dict[str, dict[str, Any]] = {} + for name, raw in originals.items(): + row: dict[str, Any] = {'exists': raw is not None} + rows[name] = row + if name in SDK_FILES: + row.update(sha256=None, recordedSha256=None, currentSha256=latest['files'][name]) + if raw is not None: + try: + contents[name] = cls._text(raw) + row['sha256'] = cls._hash(contents[name]) + except UnicodeError: + row['error'] = '文件不是有效 UTF-8' + return originals, contents, rows + + @classmethod + def _inspect(cls, vendor: Path, current: dict[str, str]) -> tuple[dict[str, Any], dict[str, bytes | None]]: + """ + 将现存副本与记录摘要及当前宿主比较,保留原始字节供备份和回滚。 + + :param vendor: 已完成路径检查的 SDK 副本目录 + :param current: 当前宿主规范化后的三个 SDK 文件 + :return: 检查结果及原始文件快照 + """ + latest = cls._metadata(current[SDK_METADATA], vendor=True) + originals, contents, rows = cls._snapshot(vendor, latest) + report = cls._report('untracked', 'SDK 副本缺少来源记录,需显式强制纳管') + report.update(currentSdkVersion=latest['sdkVersion'], updateAvailable=True, forceRequired=True, files=rows) + saved = None + if originals[SDK_METADATA] is not None: + try: + saved = cls._metadata(contents.get(SDK_METADATA, ''), vendor=True) + report['sdkVersion'] = saved['sdkVersion'] + except ValueError as exc: + report.update(status='invalid', message=str(exc)) + report['protocolCompatible'] = cls._protocol_compatible(contents, latest['bridgeVersion'], saved) + if report['protocolCompatible'] is False: + report.update( + status='incompatible', + message='SDK 通信协议版本不同,需要人工迁移', + updateAvailable=False, + forceRequired=False, + ) + return report, originals + if saved is None: + return report, originals + for name in SDK_FILES: + report['files'][name]['recordedSha256'] = saved['files'][name] + if any(report['files'][name]['sha256'] != saved['files'][name] for name in SDK_FILES): + report.update(status='modified', message='SDK 文件缺失或已修改,默认不覆盖本地改动') + return report, originals + try: + cls._validate_exports(contents, saved) + except ValueError as exc: + report.update(status='invalid', message=str(exc)) + return report, originals + report['forceRequired'] = False + if saved == latest: + report.update(status='current', ok=True, message='SDK 副本与当前宿主一致', updateAvailable=False) + else: + report.update(status='outdated', message='SDK 副本完整,但与当前宿主不同,可显式更新') + return report, originals + + @staticmethod + def _refusal(report: dict[str, Any], *, force: bool) -> str | None: + """ + 判定更新是否允许,强制模式不能绕过明确的通信协议不兼容。 + + :param report: 最新副本检查结果 + :param force: 是否明确允许覆盖本地副本 + :return: 拒绝说明;可更新时为 None + """ + if report['protocolCompatible'] is False: + return 'SDK 通信协议不兼容,不能通过强制更新绕过,请先人工迁移' + if report['forceRequired'] and not force: + return 'SDK 副本存在改动或缺少有效来源记录;请检查后使用 --force 备份并更新' + return None + + @classmethod + @contextmanager + def _lock(cls, root: Path) -> Iterator[None]: + """ + 用工程局部独占文件串行更新,不自动抢占可能仍被使用的旧锁。 + + :param root: 已验证的工程根目录 + :return: 持有更新锁的上下文 + """ + path = cls._safe_path(root / '.plugin-sdk.lock') + try: + descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + except FileExistsError as exc: + raise ValueError('SDK 更新锁已存在,请确认没有并发更新后再处理锁文件') from exc + try: + os.close(descriptor) + yield + finally: + cls._safe_path(path).unlink() + + @classmethod + def _backup(cls, root: Path, originals: dict[str, bytes | None]) -> Path: + """ + 在唯一目录保留原文件字节,不覆盖已有备份。 + + :param root: 已验证的工程根目录 + :param originals: SDK 原始文件内容,None 表示原来不存在 + :return: 本次更新的备份目录 + """ + parent = cls._safe_path(root / '.plugin-sdk-backups') + parent.mkdir(exist_ok=True) + backup = parent / uuid4().hex + backup.mkdir() + for name, content in originals.items(): + if content is not None: + (backup / name).write_bytes(content) + return backup + + @classmethod + def _write(cls, path: Path, content: bytes) -> None: + """ + 在同目录排他创建临时文件,再原子替换固定的 SDK 目标。 + + :param path: 待替换的 SDK 文件路径 + :param content: 要保存的原始字节 + :return: None + """ + cls._safe_path(path) + temporary = path.with_name(f'.{path.name}.{uuid4().hex}.tmp') + try: + with temporary.open('xb') as output: + output.write(content) + cls._safe_path(path) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + @classmethod + def _apply( + cls, vendor: Path, current: dict[str, str], originals: dict[str, bytes | None], backup: Path + ) -> dict[str, Any]: + """ + 按元数据最后的顺序写入;失败时恢复全部原文件并保留备份。 + + :param vendor: 固定 SDK 副本目录 + :param current: 当前宿主 SDK 文本 + :param originals: 更新前的原始字节快照 + :param backup: 可供人工恢复的原文件备份目录 + :return: 更新后已核实为 current 的检查结果 + """ + try: + for name in VENDOR_FILES: + cls._write(vendor / name, current[name].encode('utf-8')) + result, _ = cls._inspect(vendor, current) + if not result['ok']: + raise ValueError('SDK 更新后的内容校验失败') + return result + except BaseException as exc: + failures = [] + for name in VENDOR_FILES: + try: + path = cls._safe_path(vendor / name) + if originals[name] is None: + path.unlink(missing_ok=True) + else: + cls._write(path, originals[name]) + except (OSError, ValueError) as rollback_error: # noqa: PERF203 - 单个恢复失败不能跳过其他原文件。 + failures.append(f'{name}: {rollback_error}') + if failures: + raise ValueError(f'SDK 更新失败且回滚未完成,请从 {backup} 恢复:{";".join(failures)}') from exc + raise diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/service.py b/ruoyi-fastapi-backend/cli/runtime/plugin/service.py index 1cd6543..3e2923f 100644 --- a/ruoyi-fastapi-backend/cli/runtime/plugin/service.py +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/service.py @@ -204,6 +204,21 @@ class CliPluginRuntimeService: failure_code=self.runtime_error_code, ).to_payload() + async def diagnose_plugin_with_runtime(self, plugin_id: str) -> dict[str, object]: + """ + 在静态诊断包上附加服务 worker 的只读快照,保留原诊断成功语义。 + + :param plugin_id: 待诊断的插件 ID + :return: 包含静态检查及独立运行观测的诊断包 + """ + from plugins.core.runtime.diagnostics_query import read_cli_runtime_diagnostics # noqa: PLC0415 + + payload = await self.core_runtime.diagnose_plugin(plugin_id) + payload['runtime'] = await read_cli_runtime_diagnostics( + plugin_id, backend_root=Path(self._resolve_runtime_environment().get_backend_dir()) + ) + return payload + def lock_plugin_dependencies( self, plugin_id: str, diff --git a/ruoyi-fastapi-backend/config/env.py b/ruoyi-fastapi-backend/config/env.py index d515a2c..e7e5391 100644 --- a/ruoyi-fastapi-backend/config/env.py +++ b/ruoyi-fastapi-backend/config/env.py @@ -533,6 +533,33 @@ class PluginDependencyPolicySettings(BaseSettings): plugin_dependency_install_timeout: int = 600 +class PluginArtifactSettings(BaseSettings): + """ + 签名插件制品与维护发布配置;默认关闭以兼容目录插件部署。 + """ + + plugin_artifact_enabled: bool = False + plugin_artifact_store: str = 'vf_admin/plugin_artifacts' + plugin_artifact_trust_file: str = '' + plugin_artifact_heartbeat_seconds: int = Field(default=15, ge=1, le=60) + plugin_artifact_worker_ttl_seconds: int = Field(default=60, ge=3, le=600) + + @model_validator(mode='after') + def validate_artifact_settings(self) -> 'PluginArtifactSettings': + """ + 拒绝缺少信任根的启用配置和无法容纳正常心跳的超时。 + + :return: 通过信任根及维护心跳配置校验的制品设置 + """ + if self.plugin_artifact_enabled and not self.plugin_artifact_trust_file.strip(): + raise ValueError('启用签名插件制品时必须配置 PLUGIN_ARTIFACT_TRUST_FILE') + if not self.plugin_artifact_store.strip(): + raise ValueError('PLUGIN_ARTIFACT_STORE 不能为空') + if self.plugin_artifact_worker_ttl_seconds < 3 * self.plugin_artifact_heartbeat_seconds: + raise ValueError('插件 worker 超时必须至少为心跳间隔的三倍') + return self + + class GenSettings: """ 代码生成配置 @@ -746,6 +773,8 @@ TransportCryptoConfig = get_config.get_transport_crypto_config() OidcConfig = get_config.get_oidc_config() # 插件依赖安装策略配置 PluginDependencyPolicyConfig = get_config.get_plugin_dependency_policy_config() +# 签名插件制品与维护发布配置 +PluginArtifactConfig = PluginArtifactSettings() # 代码生成配置 GenConfig = get_config.get_gen_config() # 上传配置 diff --git a/ruoyi-fastapi-backend/config/lifecycle.py b/ruoyi-fastapi-backend/config/lifecycle.py index 9bd8b1f..cc18c36 100644 --- a/ruoyi-fastapi-backend/config/lifecycle.py +++ b/ruoyi-fastapi-backend/config/lifecycle.py @@ -20,7 +20,13 @@ async def init_create_table( message = '🔎 初始化平台数据库元数据...' if stage == 'platform' else '🔎 同步插件实体表...' logger.bind(database_init_stage=stage).info(message) async with DataSourceRegistry.connection() as connection: - await connection.run_sync(Base.metadata.create_all) + # 签名制品的数据结构只能由维护迁移准备,普通 worker 不隐式补建其表。 + artifact_tables = [table for table in Base.metadata.tables.values() if table.info.get('plugin_artifact')] + if artifact_tables: + tables = [table for table in Base.metadata.tables.values() if not table.info.get('plugin_artifact')] + await connection.run_sync(Base.metadata.create_all, tables=tables) + else: + await connection.run_sync(Base.metadata.create_all) if log_success_enabled: message = '✅️ 平台数据库元数据初始化完成' if stage == 'platform' else '✅️ 插件实体表同步完成' logger.bind(database_init_stage=stage).info(message) diff --git a/ruoyi-fastapi-backend/docs/cli_usage.md b/ruoyi-fastapi-backend/docs/cli_usage.md index 112fde1..c8bcc96 100644 --- a/ruoyi-fastapi-backend/docs/cli_usage.md +++ b/ruoyi-fastapi-backend/docs/cli_usage.md @@ -39,30 +39,46 @@ ruoyi --help ### 2.2 安装依赖 -MySQL 版本: +使用 Python 3.10–3.13,先进入后端目录,再创建并激活虚拟环境。以下以标准库 `venv` 为例;已有环境也可直接使用,不要求特定环境名称或安装路径。 + +Windows PowerShell: + +```powershell +python -m venv .venv +.\.venv\Scripts\Activate.ps1 +``` + +macOS / Linux(Bash、Zsh): ```bash -cd ruoyi-fastapi-backend -pip3 install -r requirements.txt +python3 -m venv .venv +source .venv/bin/activate +``` + +创建环境时使用已安装的受支持 Python 解释器。激活后,下文统一使用 `python` 和 `python -m pip`,确保安装依赖与执行命令使用同一个环境。每次打开新终端,都应先激活该环境。 + +以下安装及 `ruoyi` 命令适用于 Windows PowerShell、macOS 和 Linux;只有涉及 Shell 语法的命令才分平台列出。 + +MySQL 版本(在后端目录执行): + +```bash +python -m pip install -r requirements.txt ``` PostgreSQL 版本: ```bash -cd ruoyi-fastapi-backend -pip3 install -r requirements-pg.txt +python -m pip install -r requirements-pg.txt ``` 说明: -- `requirements*.txt` 已包含当前项目自身安装项 `.`,因此不需要额外执行 `pip install -e .` +- `requirements*.txt` 已包含当前项目自身安装项 `.`,因此不需要额外执行 `python -m pip install -e .` - 安装完成后,`ruoyi` 会随当前 Python 环境一起可用 -如果本地使用 Conda,推荐先进入项目环境再执行命令: +安装完成后检查 CLI: ```bash -conda activate ruoyi-fastapi -cd ruoyi-fastapi-backend ruoyi --help ``` @@ -261,6 +277,40 @@ ruoyi plugin test demo --env=dev `install-deps` 是真实 Python/npm 依赖安装的唯一显式入口。文本 TTY 下可省略 `--yes`,CLI 会先输出依赖安装计划和策略判定,再询问是否执行;非 TTY、JSON 输出或 CI 场景应传 `--yes`,否则会由策略返回确认阻断。 +插件诊断包可输出完整 JSON 或保存到文件: + +```bash +ruoyi plugin diagnose demo --env=dev --output=json +ruoyi plugin diagnose demo --env=dev --output-file=demo-diagnose.json +``` + +包中的 `runtime` 汇集发布目标、各 worker 实际加载版本、连接与任务、激活健康历史及配置修订摘要。此命令不启动插件或重新执行健康检查;顶层 `ok` 保持静态诊断含义,运行态结果需单独判断。CLI 从 Redis 读取宿主快照,并保留缺失、过期或不可用状态,不能据此认定服务器没有连接或全部健康。快照范围、时效、脱敏和计数说明见[统一运行诊断](plugin_development.md#122-统一运行诊断)。管理端同一内容位于“诊断包 → 原始数据”。 + +完成制品目标选择、通过进程管理器启动全部 worker 后,可用固定目标等待命令作为发布流水线门禁: + +```bash +ruoyi plugin release wait demo DIGEST --generation SELECTED_GENERATION --expected-workers 2 --timeout 300 --interval 2 --env prod --output json +ruoyi plugin release wait demo DIGEST --generation SELECTED_GENERATION --expected-workers 2 --timeout 0 --env prod --output json +``` + +插件 ID、`DIGEST`、`--generation` 和 `--expected-workers` 均必填。`DIGEST` 是 64 位小写 SHA256;`SELECTED_GENERATION` 使用最近一次 `select`、`enable` 或 `rollback` 返回的 32 位新代际,不能复用该变更输入的 `--expected-generation`。worker 数量必须与发布记录一致,并填写实际部署要求的数量。命令只读,不需要维护确认,也不会启动进程或切换目标。 + +`--timeout` 默认为 300 秒,`--interval` 默认为 2 秒;前者必须为有限非负数,后者必须为有限正数。`--timeout 0` 只查询并断言一次,未就绪立即返回 `not_converged`。非零超时约束核心轮询,配置加载和模块导入发生在此前;单次状态查询最多 10 秒,并受剩余轮询预算约束,取消采用异步协作方式。 + +JSON 只输出一个最终对象,包含固定 `target`、脱敏的 `lastObserved`、查询次数、耗时及 `reason`;没有逐轮进度输出。只有 `converged` 返回 `0`;超时、目标变化、停用、worker 故障或观测不可用返回 `50`,CLI 取消返回 `cancelled` 和 `50`。CLI 参数校验失败返回 `invalid_arguments` 和 `2`;缺少必填参数或未知选项由命令行解析器直接以 `2` 拒绝。`lastObserved` 是最后一次可用观测,超时或查询失败时可能来自此前且已过期的轮次,不能仅依据其中的 `active` 判定成功。自动决策应以最终退出码和 `ok/reason` 为准;`release status` 的 `ok` 仍只表示查询成功,不能代替该门禁。完整判定与原因表见[制品发布与验收](plugin_development.md#153-停机维护准备证据与目标选择)。 + +独立 v2 bundle 工程可离线检查、更新复制到 `web/vendor` 的桥接 SDK: + +```bash +ruoyi plugin sdk check plugin-projects/report_center --output=json +ruoyi plugin sdk update plugin-projects/report_center --dry-run --output=json +ruoyi plugin sdk update plugin-projects/report_center +``` + +这些命令不接受 `--env`,也不读取宿主数据库配置或执行插件代码。来源默认为相邻前端目录,可用 `--frontend-root PATH` 或 `RUOYI_PLUGIN_FRONTEND_ROOT` 指定。`check` 仅在版本、来源记录及文件摘要与选定宿主一致(`current`)时返回 0;过期副本、本地修改或缺少记录返回非零,适合纳入构建检查。`protocolCompatible` 只比较桥协议,不表示业务功能已经验收。 + +`update` 是显式写入命令,先备份再替换三个 SDK 文件。默认拒绝覆盖本地 SDK 修改或旧工程的未跟踪副本;需要纳管时先审阅 `update --force --dry-run`,再执行 `update --force`。备份位于工程 `.plugin-sdk-backups/`,业务代码保持原样。更新后应重建 bundle 并执行兼容测试;不会下载依赖、签名发布或改变运行中的插件。完整状态与摘要规则见开发手册第 16.1 节。 + ### 4.7 Shell Completion 初始化 ```bash @@ -522,7 +572,7 @@ ruoyi tui --env=prod - 当前 TUI 是只读巡检工作台,页面内的写操作入口会通过确认弹窗或向导二次确认 - 页面切换快捷键为 `D/A/O/B/C/T/G/P/E`,分别对应总览、应用、运维、数据库、缓存、任务、代码生成、参数配置、加密 - 通用快捷键包括 `R` 刷新、`Q` 退出、`S` 聚焦侧栏、`←/→` 切换焦点或区域、`J/K` 滚动、`PgUp/PgDn` 翻页、`Home/End` 首尾跳转 -- 若当前 Python 环境缺少 TUI 依赖,`ruoyi tui` 会返回失败结果并提示重新执行 `pip install -r requirements.txt` 或 `pip install -r requirements-pg.txt` +- 若当前 Python 环境缺少 TUI 依赖,`ruoyi tui` 会返回失败结果并提示重新执行 `python -m pip install -r requirements.txt` 或 `python -m pip install -r requirements-pg.txt` ## 6. 危险命令清单 @@ -842,8 +892,8 @@ ruoyi ops health --env=dev --output=json 请依次确认: - 当前目录是否为 `ruoyi-fastapi-backend` -- 当前 Python 环境是否执行过 `pip install -r requirements.txt` 或 `pip install -r requirements-pg.txt` -- 当前终端是否真的使用了安装依赖的那个 Python/Conda 环境 +- 当前 Python 环境是否执行过 `python -m pip install -r requirements.txt` 或 `python -m pip install -r requirements-pg.txt` +- 当前终端是否真的使用了安装依赖的那个 Python 环境 ### 8.2 命令报数据库或 Redis 连接失败 diff --git a/ruoyi-fastapi-backend/docs/plugin_development.md b/ruoyi-fastapi-backend/docs/plugin_development.md index cf41b45..d64722d 100644 --- a/ruoyi-fastapi-backend/docs/plugin_development.md +++ b/ruoyi-fastapi-backend/docs/plugin_development.md @@ -2,6 +2,21 @@ 本文档面向插件开发者,说明如何在当前插件系统中创建、安装、启用、调试和发布插件。 +本文覆盖 v1 源码插件和 v2 显式入口插件。未特别注明的权限、菜单、配置、依赖和迁移规则两者共用;自动扫描控制器及默认 `create` 模板属于 v1。按开发目标选择入口: + +| 开发目标 | 使用说明 | 可运行示例 | +| --- | --- | --- | +| 沿用宿主源码页面和控制器扫描 | [快速开始](#2-快速开始)、[源码前端](#71-v1--source-页面) | [内置 AI 插件](../plugins/ai/README.md) | +| 显式 Router 或 ASGI 子应用 | [v2 清单](#510-v2-清单与能力组合)、[显式入口与 SDK](#66-v2-显式入口与宿主-sdk) | [Python ASGI](../plugins/examples/python/asgi_demo/README.md) | +| Rust 原生后端 | [原生插件](#68-rust-原生插件)、[项目脚手架与构建](#16-v2-项目脚手架与完整构建) | [Rust 示例](../plugins/examples/rust/rust_demo/README.md) | +| 独立构建的插件页面 | [bundle 页面](#72-v2-独立-bundle-页面)、[浏览器 SDK](#73-bundle-浏览器-sdk) | [Python bundle](../plugins/examples/python/bundle_demo/README.md) | +| 持久化业务与升级 | [受控 Python 交付](#162-python-业务模块与迁移交付)、[显式事务](#66-v2-显式入口与宿主-sdk) | [任务 CRUD 示例](../plugins/examples/python/task_demo/README.md) | +| 签名交付与生产维护 | [制品与发布](#15-v2-签名制品与维护发布)、[真实服务验收](#17-本地真实服务验收与-ci) | [Rust 签名发布示例](../plugins/examples/rust/rust_demo/README.md#签名制品与维护发布) | + +目录插件与签名制品不能同时使用相同插件 ID。两种方式均遵守进程重启边界,不提供原生模块热替换。 + +后端命令使用已安装项目依赖的 Python 3.10–3.13 环境;创建与激活步骤见 [CLI 环境准备](cli_usage.md#22-安装依赖)。下文的 `python` 指向已激活环境,命令默认在后端目录执行。通用命令同时适用于 Windows PowerShell、macOS 和 Linux;环境变量等 Shell 语法分别列出。 + ## 1. 基本模型 插件由后端插件和可选前端插件组成。默认源码布局如下: @@ -11,6 +26,9 @@ ruoyi-fastapi-backend/plugins// ruoyi-fastapi-frontend/plugins// ``` +开发示例统一位于后端 `plugins/examples/`,按实现语言分类:`python/` 保存 Python 示例,`rust/` 保存 Rust 原生插件工程。 +该目录不参与插件发现和启动代际计算。使用示例时,按各自 README 构建后部署到 `plugins//`,或通过签名制品发布流程安装。 + 运行时不会把前后端仓库名写死在各操作入口中。插件系统优先使用显式传入的目录,其次读取 `RUOYI_PLUGIN_BACKEND_ROOT`/`RUOYI_BACKEND_ROOT` 和 `RUOYI_PLUGIN_FRONTEND_ROOT`/`RUOYI_FRONTEND_ROOT`,再尝试从后端同级目录中识别前端工程; @@ -22,6 +40,8 @@ ruoyi-fastapi-frontend/plugins// ## 2. 快速开始 +本节使用默认 v1 模板。创建 v2 工程时,直接使用[第 16 节](#16-v2-项目脚手架与完整构建)的 `python-asgi`、`python-bundle`、`rust-asgi` 或 `rust-bundle` 模板。 + 进入后端项目目录: ```bash @@ -51,6 +71,8 @@ ruoyi plugin create demo --env=dev --template=crud-page --frontend-version=vue3 - `scheduled-job`:包含定时任务示例。 - `crud-page`:包含 CRUD 页面示例。 +以上为 v1 模板。v2 独立源码工程可选择 `python-asgi`、`python-bundle`、`rust-asgi`、`rust-bundle`,生成位置和完整构建步骤见[第 16 节](#16-v2-项目脚手架与完整构建)。 + 先预览写入计划: ```bash @@ -115,6 +137,8 @@ plugins/demo/ ## 4. plugin.yaml 示例 +以下为 v1 清单示例;v2 的完整清单及差异见[第 5.10 节](#510-v2-清单与能力组合)。 + ```yaml manifestVersion: 1 id: demo @@ -238,8 +262,8 @@ config: | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | -| `manifestVersion` | `number` | `1` | 插件清单版本。当前支持 `1`。 | -| `id` | `string` | 必填 | 插件唯一标识。只能包含小写字母、数字、下划线和中划线,长度 2-64,必须以小写字母开头。不能使用 `admin`、`system`、`monitor`、`tool`。 | +| `manifestVersion` | `number` | `1` | 插件清单版本,支持 `1` 和 `2`;显式入口必须声明 `2`。 | +| `id` | `string` | 必填 | 插件唯一标识。只能包含小写字母、数字和下划线,长度 2-64,必须以小写字母开头。不能使用 `admin`、`system`、`monitor`、`tool`。 | | `name` | `string` | 必填 | 插件展示名称。 | | `version` | `string` | 必填 | 插件源码版本,用于安装版本记录和升级判断。 | | `description` | `string` | `""` | 插件说明。 | @@ -268,8 +292,8 @@ config: | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | -| `module` | `string` | 必填 | 插件后端 Python 模块路径,必须是 `plugins.`。 | -| `routers` | `object` | `{ autoScan: true }` | 控制器自动扫描声明。 | +| `module` | `string` | 必填 | Python 插件为 `plugins.`;v2 native 插件为 `ruoyi_plugin_`。 | +| `routers` | `object` | v1 为 `{ autoScan: true }` | 控制器自动扫描声明;v2 必须为 `false`,见第 5.10 节。 | | `health` | `object` | `{}` | 健康检查声明。 | | `migrations` | `string[]` | `[]` | 数据库迁移 SQL 脚本相对路径列表。 | | `seeds` | `string[]` | `[]` | 初始化数据 SQL 脚本相对路径列表。 | @@ -280,7 +304,7 @@ config: | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | -| `autoScan` | `boolean` | `true` | 是否按插件模块自动扫描并注册控制器。 | +| `autoScan` | `boolean` | v1 为 `true`,v2 为 `false` | 是否按插件模块自动扫描并注册控制器;v2 只能由显式入口返回路由。 | `backend.health`: @@ -304,7 +328,7 @@ config: | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | -| `id` | `string` | 必填 | 插件内任务唯一标识。只能包含小写字母、数字、下划线和中划线,必须以小写字母开头。 | +| `id` | `string` | 必填 | 插件内任务唯一标识。只能包含小写字母、数字和下划线,长度 2-64,必须以小写字母开头。 | | `name` | `string \| null` | `id` | 任务展示名称。 | | `callable` | `string` | 必填 | 任务函数路径,格式为 `.`。 | | `trigger` | `"cron"` | `"cron"` | 任务触发器类型。 | @@ -315,7 +339,8 @@ config: | `description` | `string` | `""` | 任务说明。 | | `misfirePolicy` | `"1" \| "2" \| "3"` | `"3"` | 计划执行错误策略。`1` 立即执行,`2` 执行一次,`3` 放弃执行。 | | `concurrent` | `"0" \| "1"` | `"1"` | 是否允许并发执行。`0` 允许,`1` 禁止。 | -| `executor` | `"default" \| "processpool"` | `"default"` | 任务执行器。 | +| `executor` | `"default" \| "processpool"` | `"default"` | v1 任务执行器;v2 只允许 `default`。 | +| `timeoutSeconds` | `number` | `30` | 仅 v2:异步任务超时秒数,必须大于 0 且不超过 86400。 | ### 5.4 frontend @@ -332,7 +357,7 @@ config: | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | -| `type` | `"none" \| "source"` | `"none"` | 前端交付类型。存在菜单或 npm 依赖时会自动按源码交付处理。 | +| `type` | `"none" \| "source" \| "bundle"` | `"none"` | v1 支持 `none/source`,v2 增加 `bundle`。未显式选择 bundle 时,存在菜单或 npm 依赖会按源码交付处理。 | | `buildRequired` | `boolean` | `false` | 前端资源是否需要构建后生效。源码交付时会自动视为需要构建。 | `frontend.menus[]`: @@ -341,7 +366,7 @@ config: | --- | --- | --- | --- | | `name` | `string` | 必填 | 菜单名称。 | | `path` | `string` | 必填 | 菜单路由路径。普通菜单只能包含小写字母、数字、下划线、中划线和正斜杠,必须以小写字母开头;外链菜单必须使用 `http://` 或 `https://` 地址。 | -| `component` | `string` | `"Layout"` | 组件路径。核心组件允许 `Layout`、`ParentView`、`InnerLink`;插件页面使用 `plugin//`。 | +| `component` | `string` | `"Layout"` | 核心布局允许 `Layout`、`ParentView`、`InnerLink`;源码页面使用 `plugin//`,v2 bundle 页面使用 `PluginFrame`。 | | `perms` | `string` | `""` | 权限标识。非空时必须在顶层 `permissions` 中声明。 | | `icon` | `string` | `"#"` | 菜单图标。 | | `type` | `"M" \| "C" \| "F"` | `"C"` | 菜单类型。`M` 目录,`C` 菜单,`F` 按钮。 | @@ -418,6 +443,7 @@ dependencies: | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `backendVersion` | `string \| null` | `null` | 后端版本约束。 | +| `hostApiVersion` | `string` | `^1.0.0` | 仅 v2:宿主插件 SDK 版本约束,独立于应用版本;当前 Host API 为 `1.4.0`。bundle 至少使用 `^1.2.0`;请求 DTO、显式事务、字典及缓存服务使用 `^1.3.0`;按需配置读取使用 `^1.4.0`。 | | `frontendVersion` | `string \| null` | `null` | 前端版本约束。 | | `pythonVersion` | `string \| null` | `null` | Python 版本约束。 | | `nodeVersion` | `string \| null` | `null` | Node.js 版本约束。 | @@ -505,11 +531,81 @@ config: - `options` 只对 `select` 生效,其他类型声明后会产生检查提示。 - `group`、`order`、`placeholder` 会进入配置接口和导出元数据,可供插件自定义页面消费。 +### 5.10 v2 清单与能力组合 + +v2 将后端运行时、Web 接入方式和前端交付分开声明: + +| 维度 | 可选值 | 使用方式 | +| --- | --- | --- | +| `backend.runtime` | `python`、`native` | Python 源码或预编译原生扩展,默认 `python` | +| `backend.integration` | `router`、`asgi` | 返回宿主 Router 或创建独立子应用,默认 `router` | +| `frontend.delivery.type` | `none`、`source`、`bundle` | 无页面、参与宿主构建的源码页面或独立前端构建产物 | + +Python Router、Native Router、Python ASGI、Native ASGI 均通过显式入口加载。`bundle` 必须配合 `asgi`;签名 `.rpk` 只接受 `none/bundle`,`source` 沿用目录插件及宿主前端构建流程。 + +下面以 Python ASGI 与独立 bundle 为例。代码入口见第 6.6 节,前端构建见第 7.2 节;实际交付目录必须包含 `web/dist/index.html`。 + +```yaml +manifestVersion: 2 +id: report_center +name: 报表中心 +version: 1.0.0 +backend: + runtime: python + integration: asgi + module: plugins.report_center + entrypoint: plugins.report_center:create_plugin + asgi: + mountPath: /apps/report_center + lifespan: managed +frontend: + delivery: + type: bundle + buildRequired: false + bundle: + directory: web/dist + entry: index.html + spaFallback: true + menus: + - name: 报表中心 + path: report_center + component: PluginFrame + type: C + perms: report_center:view +permissions: + - code: report_center:view + name: 查看报表 +compatibility: + hostApiVersion: ^1.2.0 + pythonVersion: '>=3.10' + databases: [mysql, postgresql] +``` + +v2 新增或改变的字段如下,其余迁移、菜单、配置及插件依赖继续使用本章已有定义。 + +| 字段 | 默认值或要求 | 说明 | +| --- | --- | --- | +| `backend.entrypoint` | 必填 | `:`,模块必须属于当前插件;返回 `PluginDefinition` | +| `backend.routers.autoScan` | `false` | v2 不扫描 `controller/`,不能改为 `true` | +| `backend.native.distribution` | native 必填 | wheel 的发行包名,例如 `ruoyi-plugin-report-center`;Python 运行时不能声明 `native` | +| `backend.native.moduleRoot` | `native` | 已展开发行包的安全相对目录 | +| `backend.asgi.mountPath` | `/apps/` | 仅 ASGI 使用,不能改成其他路径 | +| `backend.asgi.lifespan` | `managed` | `managed` 由宿主驱动生命周期;`none` 用于不实现 lifespan 协议的应用 | +| `frontend.bundle.directory` | `web/dist` | 相对插件根目录的前端构建目录;选择 bundle 时必须声明 `frontend.bundle` | +| `frontend.bundle.entry` | `index.html` | 相对构建目录的 HTML 入口,必须以 `.html` 结尾 | +| `frontend.bundle.spaFallback` | `true` | 允许符合条件的 HTML 页面导航回落到入口,具体边界见第 7.2 节 | + +改用 Rust 时,将 `runtime` 改为 `native`、`module` 改为 `ruoyi_plugin_report_center`、`entrypoint` 改为 `ruoyi_plugin_report_center._native:create_plugin`,并声明 `native.distribution: ruoyi-plugin-report-center`。入口必须来自该发行包内的原生扩展,不能用另一个宿主模块或 Python 业务入口代替。发行包版本必须与插件 `version` 一致。 + +`entrypoint`、Hook、健康检查都使用插件自身模块的完整路径;任务使用 `.`。ASGI 插件不能同时声明 `hooks.onStartup/onShutdown`,启动和关闭统一写入 lifespan。资源路径使用 `/` 分隔的相对路径,不允许绝对路径、反斜杠、`.` 或 `..` 目录。 + +bundle 不因存在菜单而变为源码前端,`buildRequired` 必须为 `false`,构建依赖由插件自己的 `package.json` 管理,不写入清单的 `dependencies.npm/npmDev`。清单校验和静态预检不会导入插件代码;通过预检仍需进行实际加载、权限与生命周期测试。 + ## 6. 后端开发约定 ### 6.1 控制器 -应用启动时会按 `backend.module` 自动扫描已启用插件的控制器。推荐将接口放在 `controller/` 目录,并保持与项目原有 FastAPI 控制器风格一致。 +v1 在应用启动时按 `backend.module` 自动扫描已启用插件的控制器。推荐将接口放在 `controller/` 目录,并保持与项目原有 FastAPI 控制器风格一致。v2 返回显式路由或子应用,见第 6.6 节。 后端插件路由采用启动期挂载模型: @@ -525,8 +621,12 @@ from fastapi import APIRouter demo_controller = APIRouter(prefix='/demo', tags=['demo']) -@demo_controller.get('/ping') -async def ping(): +@demo_controller.get( + '/ping', + summary='获取插件连通状态接口', + description='用于检查插件接口是否可访问', +) +async def ping() -> dict[str, object]: return {'code': 200, 'msg': 'success', 'data': 'pong'} ``` @@ -594,7 +694,7 @@ backend: onInstall: plugins.demo.hooks:on_install ``` -钩子函数可以同步或异步,可以不接收参数,也可以接收 `context`: +v1 钩子必须使用 `async def`,可以不接收参数,也可以接收 `context`;普通同步函数会被拒绝。v2 钩子固定接收一个 `PluginHookContext`,调用后必须立即返回可等待对象,Python 实现通常同样使用 `async def`。ASGI 插件的启动和关闭使用 lifespan,不重复声明运行时 Hook: ```python async def on_startup(context): @@ -632,8 +732,184 @@ jobs: `callable` 使用 `.` 格式。任务会写入系统任务表,由调度器按原系统机制执行。 +v2 只支持 `executor: default`,回调签名为 `callback(context, *args, **kwargs)`,其中 `context` 是 `PluginTaskContext(host, job_id)`。回调必须返回 awaitable,`timeoutSeconds` 默认 30 秒;后台上下文没有请求用户,不能调用要求登录用户身份的服务。建议示例任务先设为 `enabled: false`,验证后再启用。 + +v2 调度记录保存宿主分发函数及插件 ID、任务 ID、版本,不直接持久化原生 callable。执行前检查当前 worker 就绪、版本一致、任务仍存在及插件已启用;任务在所属宿主事件循环执行,不支持进程池。多 worker 的调度选主沿用宿主调度器。 + +运行时关闭先将全部显式插件标记为未激活,再按依赖逆序回收各插件:关闭长连接、取消定时任务、释放 lifespan 资源。每个插件的运行任务整批并发取消,等待最多 5 秒;超时记录告警并继续后续清理,不按任务数量逐个累加等待。残留任务保留强引用,结束后回收异常;同进程中该插件的旧任务全部退出前,不允许重新绑定同名任务。关闭期间的状态查询即使迟到返回成功,也不会再启动业务回调。 + +未退出的任务仍计入在途,实际退出后才记录终态;关闭后迟到的成功结果按取消处理。`timeoutSeconds` 和任务取消都是协作式约束,不能强制终止吞掉取消或阻塞事件循环的代码,也不能撤销已经执行的写操作。超出关闭等待后,业务代码仍可能继续运行,而其依赖资源可能已释放;插件必须在 `finally` 中清理并传播取消,生产维护仍需全部 worker 退出。 + +### 6.6 v2 显式入口与宿主 SDK + +清单中的 `create_plugin(host)` 必须同步、快速地返回 `PluginDefinition`;它只声明能力,不打开连接、启动线程或创建后台任务。当前 `PluginDefinition.api_version` 为 `1`,与清单版本 `2`、Host API 版本 `1.4.0` 是三个不同的版本号。 + +| 定义字段 | 使用方式 | +| --- | --- | +| `routers` | Router 模式返回 `APIRouter` 实例序列,路由必须位于当前插件命名空间 | +| `app_factory` | ASGI 模式提供同步 `factory(host)`,返回 ASGI callable;不能同时返回 routers | +| `register_models` | 可选同步 `callback(host)`,仅注册 ORM 元数据,不执行数据库读写 | + +以下代码可作为第 5.10 节 Python 插件的 `__init__.py`,提供受权限保护的 `/api/info`。宿主 SDK 从 `plugins.core.sdk` 导入。 + +```python +from fastapi import FastAPI + +from plugins.core.sdk import PluginDefinition, PluginHostContext, PluginRequestContext, plugin_endpoint + + +async def info(context: PluginRequestContext) -> dict[str, str]: + return {'pluginId': context.host.plugin_id, 'hostApiVersion': context.host.api_version} + + +def create_app(host: PluginHostContext) -> FastAPI: + """ + 创建插件子应用并声明接口。 + + :param host: 宿主提供的插件上下文 + :return: 插件 ASGI 子应用 + """ + app = FastAPI(title=host.plugin_id, docs_url=None, redoc_url=None) + app.add_api_route( + '/api/info', + plugin_endpoint(info, permission='report_center:view'), + summary='获取报表插件信息接口', + description='用于获取当前插件标识和宿主API版本', + response_model=dict[str, str], + ) + return app + + +def create_plugin(host: PluginHostContext) -> PluginDefinition: + """ + 声明插件显式入口。 + + :param host: 宿主提供的插件上下文 + :return: 插件能力声明 + """ + return PluginDefinition(app_factory=create_app) +``` + +Router 模式将清单的 `integration` 改为 `router`,移除 `backend.asgi`;创建带 `/report_center` 等合法插件前缀的 `APIRouter`,注册接口后返回 `PluginDefinition(routers=(router,))`。若原清单使用 bundle,还需改为 `none/source` 并移除 `frontend.bundle`;同一插件 ID 不能同时启用两种接入方式。 + +宿主上下文按以下边界使用: + +| 对象 | 当前提供的能力 | 使用边界 | +| --- | --- | --- | +| `PluginHostContext` | `plugin_id`、`resource_root`、只读 `config`、`config_revision`、`services`、`session_factory`、`redis`、`logger`、`startup_write_enabled`、`api_version` | `config` 为启动快照;`await read_config()` 主动读取最新配置,不自动应用到业务资源;不保存请求身份或请求数据库会话 | +| `PluginRequestContext` | `host`、当前 `user`、`permissions`、`require_permission()`、`request_id`、`transaction()`;适配器提供 `request` | `query_db` 只在显式事务块内有效;数据权限仍由具体服务处理 | +| `PluginTaskContext` | `host`、`job_id`、本次执行的 `request_id` | 后台任务身份,不隐式获得用户或管理员权限 | + +普通 FastAPI 接口可从 `request.state.plugin_context` 获取请求上下文;WebSocket 对应 `websocket.state.plugin_context`。入口门禁验证用户能访问插件,具体 API 仍须调用 `require_permission()` 或使用 `plugin_endpoint(callback, permission=...)`。权限应同时声明在清单并授予使用者,仅有菜单可见性不代表 API 已鉴权。 + +`host.resource(relative_path)` 将资源定位在插件根目录内;资源、配置或当前请求对象不能跨插件混用。数据库连接池可共享,`AsyncSession` 必须通过 `async with host.session_factory() as db` 按操作创建并释放,不能在并发任务或线程间共用。签名制品的业务表通过维护迁移创建,`register_models` 不能用来绕过该流程。 + +版本化业务服务通过 `await host.service('服务名称')(context, ...)` 调用。服务再次核对插件归属、当前登录用户和接口权限,仅返回普通数据,不返回密码或 ORM 对象。权限须同时声明在清单并授予用户。 + +| 服务 | 参数(context 之后) | 权限 | 返回值与边界 | +| --- | --- | --- | --- | +| `users.current_profile.v1` | 无 | `:profile` | `userId/userName/nickName/avatar/postGroup/roleGroup` | +| `dictionaries.items.v1` | `dict_type` | `:dict` | 已启用选项的 `label/value/cssClass/listClass/isDefault`;字典类型为字母开头的字母、数字、下划线,最长 100 字符 | +| `cache.get.v1` | `key` | `:cache` | JSON 数据;不存在时为 `None` | +| `cache.set.v1` | `key, value, ttl_seconds=300` | `:cache` | JSON UTF-8 数据最多 64 KiB;TTL 为 1–86400 秒 | +| `cache.delete.v1` | `key` | `:cache` | 是否删除已有缓存 | + +缓存键以字母或数字开头,允许字母、数字、`_ . : -`,最长 128 字符。宿主自动添加 `plugin:sdk:cache::` 前缀;不提供扫描、通配删除或任意 Redis 命令。缓存按插件共享,不自动按用户隔离,需要用户隔离时将当前用户 ID 纳入业务键。缓存写入不参与数据库事务。 + +`plugin_endpoint` 把固定参数的插件回调转换为 FastAPI 端点;`plugin_lifespan(host, startup, shutdown)` 把两个接收 host 的异步回调转换为 lifespan,startup 可返回共享状态字典或 `None`。`await_plugin_callback` 统一等待 Python 协程或原生返回的 awaitable。v2 Hook 接收 `PluginHookContext`,健康检查接收 `PluginHealthContext`,两者并非 `PluginHostContext`。 + +Python 回调在宿主事件循环执行,不能在其中调用 `asyncio.run()` 或阻塞等待已有循环;Rust 同步导出函数应快速返回 awaitable,CPU 密集工作应脱离事件循环线程。超时和取消不能强制终止阻塞的原生代码,插件应配合取消并对写操作实现幂等。不要关闭宿主提供的 Redis 或数据库连接池。 + +#### 6.6.1 请求数据与校验 + +`plugin_endpoint` 保持 `callback(context)` 调用方式。回调中的 `context.request` 是 `PluginHttpRequest`:`method/path/path_params/query/body`;`query` 保留同名多值,`to_payload()` 返回独立的 JSON 副本,其中路径参数字段名为 `pathParams`。不传递原始 Request、Cookie 或认证头。未经该适配器包装的普通 Router/ASGI 端点仍直接使用 FastAPI Request,不保证 `context.request` 已填充。 + +默认不读取请求体;传入 `json_body=True` 或 Pydantic `body_model` 才启用 JSON 解析。默认上限为 1 MiB,可通过 `max_body_bytes` 调整。权限检查先于读取请求体;类型错误返回 415、实际接收字节超限返回 413、无效 JSON 返回 400、模型校验失败返回 422。校验错误不回显输入值。`timeout` 分别限制请求体读取和业务回调,每一阶段默认 30 秒。 + +```python +from pydantic import BaseModel, Field +from plugins.core.sdk import PluginRequestContext, plugin_endpoint + + +class MessageBody(BaseModel): + message: str = Field(min_length=1, max_length=200) + + +async def echo(context: PluginRequestContext) -> dict: + return {**context.request.to_payload(), 'requestId': context.request_id} + + +app.add_api_route( + '/api/echo/{item_id}', + plugin_endpoint(echo, permission='report_center:view', body_model=MessageBody), + methods=['POST'], + summary='验证请求上下文接口', + description='返回校验后的路径参数、多值查询参数、请求体和追踪标识', + response_model=dict, +) +``` + +Python 与 Rust 实例均提供 `POST /api/echo/{item_id}`;Rust 通过 PyO3 读取同一 DTO,见对应示例 README。 + +#### 6.6.2 显式请求事务 + +默认每次数据库服务调用创建独立会话。需要连续调用共享事务时,使用以下形式: + +```python +async with context.transaction() as transaction_context: + profile = await context.host.service('users.current_profile.v1')(transaction_context) + options = await context.host.service('dictionaries.items.v1')(transaction_context, 'sys_normal_disable') +``` + +事务正常结束时提交,异常或任务取消时回滚,最后关闭会话。业务 DAO 可使用块内的 `transaction_context.query_db`;不得自行提交、关闭或把该会话传给其他任务。宿主服务拒绝嵌套事务、块外继续使用事务上下文、手动注入的会话及跨 `asyncio` 任务复用;因此块内服务调用应顺序等待,不能用 `gather/create_task` 并发共享事务。该事务仅覆盖当前宿主数据库,不覆盖 Redis、外部 HTTP 或后台任务,也不自动提供行级数据权限。 + +### 6.7 ASGI 子应用与生命周期 + +宿主把子应用挂载到固定 `/apps/`。子应用声明相对路由,并通过 ASGI `root_path` 处理代理部署前缀,不要在子应用路由中再次写完整挂载地址。 + +| 对外地址 | 子应用或交付声明 | +| --- | --- | +| `/apps//api/...` | 子应用中的 `/api/...` 路由 | +| `/apps//ui/`、`/apps//ui/assets/...` | 仅 bundle:宿主提供已构建前端资源 | +| `/apps//ws/...` | 可选:插件自行声明 WebSocket 路由,握手仍经过宿主门禁 | +| `/apps//docs`、`/apps//openapi.json` | 取决于子应用自身配置,不自动合并到宿主 OpenAPI | + +外层网关检查插件启用状态、当前 worker 就绪状态和身份,再注入请求上下文;HTTP、静态资源和 WebSocket 握手均经过该边界。父应用的路由依赖、异常处理器和全部 state 不会自动继承;FastAPI 子应用由宿主显式提供 `app.state.plugin_host` 和共享 `app.state.redis`。具体 API 的权限与错误处理由插件声明,不要重复安装宿主的传输加密层。 + +`lifespan: managed` 是默认值。宿主显式发送 ASGI startup/shutdown,等待启动及清单健康检查通过后才挂载,不依赖 `app.mount()` 自动运行子应用生命周期。`lifespan: none` 只适用于不需要宿主发送生命周期事件的 ASGI callable;需要初始化和释放资源的应用应保留 managed。 + +每个 worker 都创建并启动自己的子应用实例。共享的迁移、菜单和安装操作由维护流程或相应单写者负责,不能放进每个 worker 都执行的资源初始化。目录插件需要全局启动写入时检查对应上下文的 `startup_write_enabled`;签名制品在普通 worker 中该能力为关闭状态。 + +应用按依赖顺序启动、逆序关闭。插件只释放自己创建的资源;启动中途失败也应清理已建立的资源,shutdown 应可重复调用。使用 `@asynccontextmanager` 时,返回类型写为 `AsyncGenerator[状态类型, None]`;也可复用 `plugin_lifespan`,其 shutdown 在 startup 失败后同样会调用。 + +宿主串行处理同一实例的启动和关闭,避免并发 shutdown 重复发送协议消息。协议确认及 shutdown 确认后的任务退出各等待最多 30 秒,取消回收每次最多 5 秒;未响应取消的任务继续保留并告警,不会无限等待。该任务退出前,生命周期管理器和显式运行时均拒绝为其启动重叠实例;退出后重试使用新的协议队列和状态,旧确认消息不会污染下一次启动。ASGI callable 可返回任意 awaitable,不要求直接返回协程。 + +子应用自行取消属于生命周期失败,不会被误当作整个宿主被取消。一个插件的连接、任务或 lifespan 清理报错后,仍会尝试其他清理步骤和其他插件;激活失败后的清理异常单独记录,保留最初的激活错误。运行时进入关闭后不再接受激活,进行中的健康检查也不能在关闭后重新发布路由。已关闭的运行时不能直接复用,重新启动应创建新的宿主运行时。宿主调用者自身的取消仍向上传播;这些等待上限也无法约束阻塞整个事件循环的同步代码。 + +ASGI 网关在响应声明 `text/event-stream` 或 WebSocket 接受握手后,开始周期复核。首次及每次复核之间等待 15 秒,单次鉴权设置 5 秒超时;每次使用独立数据库会话,复核结束即关闭,不因长连接长期占用会话。复核检查主登录、插件 Cookie(使用 Cookie 时)、插件启用状态、当前 worker 就绪状态和用户权限;复核请求不会续期主登录。 + +主登录失效、插件停用、身份变化或原权限集合中的任一权限被移除时,关闭连接。采用保守的权限减配策略,即使被移除的权限与当前接口无关,也需要重新连接;权限增加不修改已有 `plugin_context` 的快照。接口和数据权限仍由插件自身校验。复核查询出错或超时也会关闭连接,不能在无法确认权限时持续发送数据。撤销是周期检测,并非即时广播;已发出的数据无法撤回。 + +SSE 使用保留事件 `ruoyi.plugin.closed` 通知关闭,随后结束响应。完整关闭事件的 `data` 提供 `code` 和 `reason`,固定原因包括 `session_expired`、`access_revoked`、`authorization_unavailable`、`authorization_timeout` 和 `plugin_shutdown`,不返回底层异常详情。业务不能使用这一事件名;自写 SSE 客户端必须先按事件名识别并结束处理,不能把该 EOF 当作业务成功。如果关闭时业务事件尚未发送完整,宿主会覆盖其事件类型,`data` 可能含有未完成的业务内容,因此不能依赖 JSON 解析成功后才识别关闭。宿主浏览器适配器会拦截该事件并拒绝 `stream()`,不会交给插件的 `onEvent`。更新后端时需同步更新宿主前端,旧适配器不能识别这一关闭语义。WebSocket 的身份或权限失效使用关闭码 `1008`,复核不可用或超时使用 `1011`,运行时关闭使用 `1012`。 + +显式运行时进入 shutdown 后先拒绝新连接、并发取消当前插件的流,再释放其 lifespan 资源。业务任务的取消等待和关闭消息的发送超时均为 5 秒;发送超时后会取消发送任务并限时回收。忽略取消的任务会记录告警并隔离后续输出,Python 无法强制终止任意业务代码。插件应在 `finally` 中清理资源,不能吞掉取消后继续运行。这一机制覆盖 v2 **ASGI** 的 SSE/WebSocket;普通 HTTP、任意字节流、v2 Router 和 v1 路由不增加周期复核。ASGI 服务器可能先等待在途请求、再进入 lifespan shutdown,部署时仍须配置有限的优雅停机等待时间;网关不接管该服务器阶段。 + +新启用或更新代码仍需要重启。修改发布目标或撤销签名密钥不会热卸载已加载模块,也不会触发即时的跨 worker 关闭;正式升级仍须停止全部宿主 worker,并按第 15 节完成维护发布。 + +### 6.8 Rust 原生插件 + +Rust 使用 PyO3 暴露 Python 扩展,并通过 maturin 构建 wheel;Windows 交付 `.pyd`,Linux 交付 `.so`。部署端只需匹配的运行环境和已构建产物,无需 Rust 编译工具链。原生代码与宿主同进程,适用于可信扩展;二进制交付不等于不可逆向,也不提供 Python 依赖或进程隔离。 + +原生 wheel 在构建或维护环境中验证并展开到 `backend.native.moduleRoot`,默认 `native/`。宿主检查发行包名称、插件版本、Python 要求、wheel tag、已安装依赖及完整 RECORD SHA256;扩展只能从当前插件目录加载,不能覆盖宿主模块。RECORD 用于检查文件一致性,发布者信任仍需第 15 节的签名流程。 + +构建机需安装 Rust、maturin 和目标平台编译工具:Windows 使用 MSVC Build Tools 的 C++ 工具及 Windows SDK,macOS 使用 Xcode Command Line Tools,Linux 使用 GCC 或 Clang 等编译工具。先选择 `rust-asgi` 或 `rust-bundle` 脚手架,再按第 16 节构建。`abi3` 可减少 Python ABI 构建数量,但不能跨操作系统或架构复用制品;按实际目标平台及 Python 3.10–3.13 的测试结果验证兼容性。 + +Rust 通过 `PluginHostContext`、`plugin_endpoint`、`plugin_lifespan` 和版本化服务复用宿主能力,完整实现见 [Rust 示例](../plugins/examples/rust/rust_demo/README.md)。不要覆盖已加载的原生模块或依赖 `reload` 完成更新,发布后必须重启全部相关进程。 + ## 7. 前端开发约定 +### 7.1 v1 / source 页面 + 插件前端代码放在: ```text @@ -654,6 +930,103 @@ plugin/demo/report/list -> ruoyi-fastapi-frontend/plugins/demo/views/report/list 前端 API 建议放在 `plugins//api/`,视图放在 `plugins//views/`。插件页面不需要加入主工程内置路由,菜单安装后由后端返回动态路由,前端 resolver 会自动定位插件视图。 +### 7.2 v2 独立 bundle 页面 + +v2 ASGI 插件可声明 `frontend.delivery.type: bundle` 和 `frontend.bundle.directory: web/dist`,菜单组件使用 `PluginFrame`。`backend.runtime` 仍独立选择 `python` 或 `native`:bundle 只改变前端交付方式,不会保护 Python 后端源码;原生非源码交付参见 Rust 示例。此模式需要 Host API `^1.2.0`,不声明宿主 npm 依赖,也不参与宿主前端构建。首次升级宿主以加入 `PluginFrame` 时仍需构建宿主前端,之后新增或更新插件只构建插件自己的前端。 + +后端通过 `sys_plugin_menu.menu_id` 的可信归属输出路由 `meta.pluginId`,不接受菜单 query、URL 或插件名称代替该标识。`PluginFrame` 先以宿主 Bearer 调用 `POST /plugin/runtime//session`,取得服务端校验的固定入口和短期插件会话,然后打开 `/apps//ui/`。不要手写任意 iframe 地址,也不要把主 token 放入 URL 或传给插件。 + +插件浏览器 Cookie 使用 `HttpOnly`、`SameSite=Strict`,HTTPS 下为 `Secure`,路径限定为 `/apps//`。会话最多 300 秒并受主登录有效期限制,同一主会话的多标签续期复用 Cookie 与 CSRF。每次 API、页面和资源访问都会检查主会话、插件状态和权限;退出、主登录替换、插件禁用或版本变化会使后续访问失效。写请求还要求同源 Origin 与 CSRF。插件 Cookie 不能用于其他宿主 API。已建立的 ASGI SSE/WebSocket 按[生命周期说明](#67-asgi-子应用与生命周期)周期复核并关闭失效连接,不承诺即时撤销。 + +浏览器 SDK 位于前端 `src/utils/pluginBridge.js`,配套类型位于 `pluginBridge.d.ts`,无 Vue/Axios 依赖。示例通过构建别名引入;独立插件仓库可将这两个文件作为版本化依赖复制到自己的源码中。SDK 提供 `ready`、`request`、`upload`、`download`、`stream`、`navigate`、`subscribe` 和 `destroy`,具体用法见 [bundle_demo README](../plugins/examples/python/bundle_demo/README.md)。请求只允许当前插件 API 下的相对 `path`;JSON 与文件由主页面使用原有请求客户端代理,SSE 使用宿主专用 fetch 适配器;两者自动附加插件 CSRF,不发送主 Bearer。主 token、插件 Cookie 和 CSRF 值不进入桥消息。 + +JSON 请求、响应和文件元数据每条最多 64 KiB,所有请求合计最多 8 个待处理任务;JSON 默认 15 秒超时,文件传输默认 120 秒。`upload/download` 的文件内容使用 Blob 结构化克隆,单次最多 10 MiB,支持进度和取消。协议仍为 bridge v1,通过握手 `capabilities.files.version=1` 协商文件能力、`capabilities.streams.version=1` 协商 SSE 能力;旧宿主明确拒绝其未公布的能力,旧 JSON 客户端仍可工作。SSE 每个页面最多 2 条连接,默认且最长 5 分钟,单条事件最多 64 KiB、单次连接累计最多 10 MiB。当前不代理任意字节流或 WebSocket。通信桥不能替代后端具体 API 的 `require_permission`/`plugin_endpoint` 权限检查。 + +UI GET/HEAD 使用普通 HTTPS,仍经过插件身份门禁;`/apps//api/...` 与会话签发接口保留宿主传输加密,不能整体加入加密排除列表。加密 AAD 保留 `/apps//api/...` 完整插件命名空间,只按宿主规则剥离部署前缀;加密的会话响应仍保留 `Set-Cookie`。 + +HTML 注入有效 `uiBase`、`apiBase` 与 ``。SPA 回退仅接受 UI GET/HEAD 中带 `Accept: text/html` 的无扩展名页面导航;缺失脚本、API、保留路径和非 HTML 请求不能返回首页。静态服务拒绝路径越界和链接逃逸,并限制为同源 iframe 嵌入。HTML 当前 `no-store`,其他静态资源 `no-cache`,不自动启用 immutable 缓存。 + +前端各环境已显式配置 `VITE_APP_PLUGIN_BASE`:development 为 `/dev-api`、production 为 `/prod-api`、docker 为 `/docker-api`、staging 为 `/stage-api`,与各环境的 `VITE_APP_BASE_API` 一致。开发、生产、Docker 分别对齐后端 `.env.dev`、`.env.prod`、`.env.dockermy`/`.env.dockerpg` 的 `APP_ROOT_PATH`;仓库未提供独立的后端 staging 环境文件,部署 staging 时需将后端 `APP_ROOT_PATH` 配为 `/stage-api`。以开发环境为例,插件使用同源 `/dev-api/apps/` 和 `/dev-api/plugin/runtime/`,Cookie Path 和会话响应也包含此前缀。 + +自定义部署时,宿主前端的 `VITE_APP_PLUGIN_BASE` 必须与后端 `APP_ROOT_PATH` 一致;只有后端前缀为空时,前端才留空并使用 `/apps/` 和 `/plugin/runtime/`。浏览器侧保留部署前缀,代理转发至通过 `app.py` 启动的 Uvicorn 时剥离此前缀一次,由 Uvicorn 补回 ASGI `root_path`;保留外部 Host,HTTPS 的原始 scheme 由可信代理配置传递。仓库 Vite 已按此规则改写插件路径;两份 Docker Nginx 配置也已提供 `/docker-api/apps/` 和 `/docker-api/plugin/runtime/` 专用代理,并在转发时剥离 `/docker-api`。生产、staging 或自定义反向代理须配套设置相同前缀、专用代理入口及转发路径,保留 WebSocket 升级和 SSE 非缓冲配置,并验证 Cookie Path、Origin、Secure 和加密 AAD。独立页面属于同源可信代码;iframe 提供布局和依赖隔离,不提供对恶意插件的安全隔离。 + +### 7.3 bundle 浏览器 SDK + +插件前端通过构建别名或复制的 SDK 模块引入 `createPluginClient`。宿主在 HTML 的 `ruoyi-plugin-config` 中注入插件标识及包含真实部署前缀的运行基址;不要硬编码 UI 或 API 的绝对地址。 + +```javascript +import { createPluginClient } from '@ruoyi/plugin-bridge' + +const config = JSON.parse(document.getElementById('ruoyi-plugin-config').textContent) +const client = createPluginClient({ pluginId: config.pluginId }) +const context = await client.ready +// context 包含 theme、language、timeZone、route、uiBase 和 apiBase。 +const info = await client.request({ method: 'GET', path: 'info' }) +console.log(info.pluginId) + +const unsubscribe = client.subscribe(({ type, payload }) => { + // 根据 preferences、route、refresh、logout 事件更新插件自身界面。 + console.log(type, payload) +}) + +window.addEventListener('pagehide', () => { + unsubscribe() + client.destroy() +}, { once: true }) +``` + +`request` 的 `path` 相对本插件 `apiBase`,例如 `info`、`reports/month`;不允许完整 URL、前导 `/`、`..` 或自行拼接查询串。查询参数放在 `params`,JSON 请求体放在 `data`,`GET/HEAD` 不带请求体。可在第二个参数传入 `{ signal: controller.signal }` 取消请求,不能传入任意请求头或 Axios 配置。 + +```javascript +const controller = new AbortController() +const options = { + signal: controller.signal, + onProgress: ({ phase, loaded, total }) => { + // total 为 null 时显示已传输字节或不确定进度;进度 100% 不代表服务端处理完成。 + console.log(phase, loaded, total) + }, +} +const inspected = await client.upload({ + path: 'files/inspect', file: selectedFile, + // method 默认 POST,也支持 PUT/PATCH;file 为浏览器 File 或 Blob。 + fieldName: 'file', filename: selectedFile.name, fields: { category: 'report' }, +}, options) +const report = await client.download({ path: 'files/report', params: { month: '2026-10' } }, options) +// report 是 Blob,由插件决定保存名称和时机;使用 URL.createObjectURL 后必须释放 URL。 +``` + +`upload` 由宿主重建 multipart 表单,`fields` 仅接受字符串字段,返回 JSON;不能直接向 `request` 传入 `FormData`。`download` 默认 GET,也可使用允许的其他方法和 JSON `data`,只返回 Blob,不暴露响应头或宿主请求对象。下载时收到小于等于 64 KiB 且包含非 200 数字 `code` 的 JSON 错误响应会被拒绝;普通 JSON 文件仍可下载。进度按字节报告,上传进度可能包含 multipart 开销;返回的 Promise 成功才表示响应处理完成。取消、超时、重载和退出后,迟到的进度与结果不会交付给旧页面。 + +文件传输沿用宿主已有的二进制请求策略,不使用 JSON 加密信封;HTTPS、插件 Cookie、Origin、CSRF 和接口权限校验仍然执行。部署处于传输加密 `required` 模式时,须将**具体文件接口**加入 `TRANSPORT_CRYPTO_EXCLUDE_PATHS`,如 `/apps/bundle_demo/api/files/inspect,/apps/bundle_demo/api/files/report`,追加到已有列表并保留原有配置。路径按后端规则不带代理前缀。未配置时请求会被拒绝,不自动旁路;不要排除整个 `/apps` 或插件 API 命名空间。服务端与反向代理也应限制上传大小;示例后端会独立检查文件 10 MiB 和表单附加 64 KiB 的实际字节上限。 + +SSE 使用 `stream` 发起只读 GET 请求;必须提供 `onEvent`,其中 `data` 是原始字符串,由业务决定是否解析 JSON: + +```javascript +const controller = new AbortController() +let lastEventId = '' +async function receive() { + await client.stream({ path: 'events', params: { count: 10 }, lastEventId }, { + signal: controller.signal, + onEvent: async ({ event, data, id }) => { + await saveEvent({ event, data }) // 由插件实现;处理成功后再记下游标。 + lastEventId = id + }, + }) +} +receive().catch(showStreamError) // 由插件实现错误提示和重新连接入口。 +// 用户停止时调用 controller.abort();重试时创建新的 AbortController,并传回 lastEventId。 +``` + +宿主增量解析 UTF-8、跨分片换行、多行 data 和 id;只分发以空行结尾的完整事件。每次等待 `onEvent` 返回的 Promise 完成后才继续交付,回调超过 15 秒或失败会取消连接。`stream()` 在正常 EOF 后完成,取消、超时或网络错误时拒绝;不自动重连,也不使用服务端的 `retry` 字段。重试间隔、持久化游标、事件去重及断线补发由插件与服务端共同实现,不能据此承诺不丢失或只处理一次。`lastEventId` 仅接受最多 1024 个可打印 ASCII 字符,通过 `Last-Event-ID` 请求头发送;业务服务应选择符合这一约束的游标。取消、重载、退出和 `destroy()` 会终止宿主连接,丢弃迟到事件;已开始的业务回调仍需自行处理取消。 + +事件接口必须返回 `text/event-stream`,禁止跨源地址和重定向。SSE 不使用 JSON 加密信封,依赖 HTTPS;若当前传输策略会加密该端点,需将**精确事件路径**(如 `/apps/bundle_demo/api/events`,不含部署前缀)追加到既有 `TRANSPORT_CRYPTO_EXCLUDE_PATHS`,宿主不会自动绕过策略。建立请求时仍校验插件 Cookie、主登录和接口权限;宿主会附加 CSRF 头,后端沿用只读 GET 的门禁规则,强制 Origin/CSRF 校验针对写请求。ASGI 网关会周期复核已建立连接;收到宿主关闭通知时 `stream()` 拒绝,业务应保留已处理游标并按新的登录及权限状态决定是否重试。反向代理应关闭该事件接口的响应缓冲,并配置相应超时;示例返回 `X-Accel-Buffering: no`。 + +本地开发可运行 `npm --prefix plugins/examples/python/bundle_demo/web run dev`(从后端目录)。`/dev.html` 使用真实桥协议和内存模拟请求,支持文件与逐条事件,可切换主题、延迟、故障、重载及退出;不连接实际账号、数据库或 Redis。模拟宿主和开发配置注入只用于 Vite 开发服务,默认生产构建只有插件 `index.html`,不含模拟宿主。它便于开发交互,实际会话、权限与代理仍应在宿主集成环境验证。 + +`client.navigate('/details')` 同步插件内部路由,宿主保存在 `pluginRoute` 查询参数中;插件通过 `route` 事件更新自己的页面。使用 Vite 时采用相对资源基址,如 `base: './'`,前端路由基址使用注入的 `uiBase`。 + +桥校验同源 origin、消息 source、插件 ID、协议版本和页面连接实例。重载、卸载、退出及超时会清理待处理请求;`destroy()` 用于插件自身清理。响应与宿主请求客户端的 JSON 结构一致,不额外解包 `data`,因此插件接口若返回 `{ code, data, msg }`,页面应按此结构读取。完整的请求、导航和偏好更新示例见 [bundle_demo](../plugins/examples/python/bundle_demo/README.md)。 + ## 8. 配置项 插件配置写在 `config.items` 中。支持类型: @@ -703,6 +1076,25 @@ ruoyi plugin config demo import --env=dev --input-file=demo-config.json --yes 内置管理页会根据 `type` 渲染基础控件,支持必填校验、下拉选项、敏感输入和配置说明;更复杂的分组、排序或提示布局可以在插件自定义页面中消费配置元数据后自行实现。 +### 8.1 启动快照、按需读取与生效状态 + +v2 的 `host.config` 在加载时读取,`host.config_revision` 标识该启动快照。保存配置不会热替换此对象,也不会自动重建连接池、任务或子应用。需要变更这些启动资源时,应保存配置后按部署流程重启所有相关 worker。 + +Host API `^1.4.0` 提供 `await host.read_config()`,返回 `PluginConfigSnapshot(values, revision)`。每次调用创建独立数据库会话,按**当前进程已加载清单**读取本插件已提交配置并合并默认值,敏感项在插件服务端解密;不接受其他插件 ID,不复用请求事务,不改变 `host.config`。请求、任务或健康检查均可主动调用。数据库故障会抛出异常,不以旧值冒充最新配置;业务代码负责缓存策略和应用失败的处理。一次操作应复用同一个快照,避免中途重复读取导致设置不一致。 + +```python +async def summary(context: PluginRequestContext) -> dict[str, str]: + snapshot = await context.host.read_config() + # 只选取明确可公开的字段,不向浏览器返回整个 values。 + return {'greeting': str(snapshot.values.get('greeting', '欢迎使用'))} +``` + +`bundle_demo` 的欢迎语按上例每次请求读取,因此保存后点击“刷新状态”即可看到新文案。对于需要异步重建资源的配置,插件应先完成资源校验和替换,再更新自己的业务状态;SDK 的最近读取版本仅证明读取完成,不能证明这些操作成功。 + +管理页配置弹窗在保存后保留打开,并刷新 `GET /system/plugin//config/status`。该只读接口要求登录和 `system:plugin:query`,返回保存版本 `desiredRevision`、各进程 `startupRevision`、最近 `lastReadRevision/lastReadAt`,不返回配置值。版本使用绑定插件 ID 的 HMAC,不能按顺序比较;相同有效配置的版本稳定,敏感值变化也会改变版本,宿主 JWT 密钥变化会使版本改变。 + +`state=restart_required` 表示至少一个已观测活跃进程的启动快照不同;`observed_match` 仅表示已观测到匹配进程且未观测到活跃差异;`unobserved` 表示尚不能确认,包括未启动、v1 或旧宿主。`unknownWorkers` 表示已上报运行指标却未上报配置版本的进程数量。最近按需读取版本不会把旧启动快照标记为已生效。远程数据复用运行观测的 15 秒采样、60 秒 TTL 和读取上限;始终结合 `scope`、缺失/过期/无效计数判断范围,不能把局部匹配当作整个集群一致。 + ## 9. 依赖管理 插件依赖声明在 `dependencies` 中: @@ -771,7 +1163,7 @@ ruoyi plugin batch enable --env=dev --yes - `uninstall`:安全卸载,保留可恢复数据。 - `purge`:清理插件平台元数据,属于高风险操作。 -生产环境执行危险操作需要显式传入 `--allow-prod --yes`。 +生产环境执行危险操作需要显式传入 `--allow-prod --yes`;这两个参数不会绕过服务模式的生命周期限制。v2 签名制品应使用第 15 节的独立维护发布命令,不要通过切换到 dev 环境操作生产数据库。 ## 11. 默认启用内置插件 @@ -808,6 +1200,62 @@ ruoyi plugin diagnose demo --env=dev --output-file=demo-diagnose.json ruoyi plugin docs demo --env=dev --output-file=demo.md ``` +v2 检查器必须返回 `True` / `False`,或包含布尔字段 `ok` 的字典,例如 `{'ok': True, 'message': '就绪', 'details': {'database': True}}`。`None`、空字典、字符串、数字以及 `{'ok': 'false'}` 都属于契约错误,检查结果为 `ok=False, status=error`,启动时会阻止该插件激活。`message`、`status`、`details` 是可选诊断信息,是否通过始终由 `ok` 决定。未声明检查器时仍返回 `unknown`,表示未执行健康探测;v1 保留原有返回值兼容行为。 + +### 12.1 v2 运行观测 + +管理端“插件详情 → 运行观测”可手动刷新。只读接口 `GET /system/plugin/runtime/metrics?pluginId=` 要求宿主登录和 `system:plugin:query`。支持显式 Router、ASGI 的 HTTP/WebSocket 以及宿主分发的 v2 定时任务;v1 扫描路由不纳入本指标。 + +指标按实际加载的插件、版本、digest、generation 和操作(`http/websocket/job:`)分组,记录调用、在途、成功、拒绝、失败、取消、超时、失败率、平均耗时、最大耗时和近似 P95。失败率为失败次数除以已完成次数(成功、拒绝、失败、取消之和),超时计入失败;P95 是固定直方图区间的上界估计,不是精确分位数。HTTP 耗时覆盖完整响应,WebSocket 耗时覆盖连接生命周期;ASGI 组也包含静态资源和门禁拒绝,因此不能直接理解为某个业务接口的延迟。 + +ASGI 长连接因登录或权限撤销而关闭时计入“拒绝”,复核不可用计入“失败”,复核超时同时计入“超时”,客户端断开或运行时关闭计入“取消”。SSE 即使已经发送 HTTP 200,也按实际关闭原因分类,错误类型显示 `PluginConnection:`,不记录用户凭证或异常详情。 + +每个 worker 从启动开始累计,重启归零,不提供持久历史。每 15 秒向 Redis 发布一次快照,60 秒过期,正常退出仅删除自身快照。接口返回 `scope=reporting_workers` 表示本次有效采样中的进程,并不保证包含全部预期 worker;Redis 不可用时明确返回 `scope=current_worker`。首次启动的远程进程可能需等待一个采样周期才出现。当前进程使用即时快照,远程进程使用最近一次快照。 + +返回数据包含 `observedWorkers`、采样时间、`invalidSnapshots/staleSnapshots/workerLimitReached` 和各进程的 `droppedSeries`,用于判断采样是否完整。单次最多读取 64 个远程快照,每个进程最多 1024 条指标序列。该页面不替代 `release status` 的发布一致性确认。 + +请求和任务均设置追踪标识;日志上下文附带插件 ID、版本、制品摘要、代际、worker 和操作。指标只保存最近异常类型、时间和追踪标识,不采集 URL、请求体、认证头或异常消息。可用 `lastErrorRequestId` 关联宿主日志排查。 + +### 12.2 统一运行诊断 + +管理端“诊断包 → 原始数据”、`GET /system/plugin/{plugin_id}/diagnose` 和 `ruoyi plugin diagnose` 的 JSON 包中,`runtime` 汇集发布目标、worker 实际加载状态与运行快照。Web 接口要求宿主登录和 `system:plugin:query`。现有页面会保留并显示整个 JSON;诊断弹窗没有文件下载按钮,需要导出时使用 CLI: + +```bash +ruoyi plugin diagnose demo --env=dev --output=json +ruoyi plugin diagnose demo --env=dev --output-file=demo-diagnose.json +``` + +诊断包顶层 `ok` 仍表示原有静态诊断结果,页面的“正常/异常”也读取该字段。已经生成的诊断包通过成功响应返回,检查发现问题时仍保留 `data.ok=false`;插件不存在或诊断包生成失败仍返回失败响应。接口成功不表示全部 worker 已加载目标制品、实时健康或资源已经释放。运行态状态应单独查看 `runtime.state` 和各 worker 的 `freshness/observation`。`runtime.release.summary` 中的目标与 `runtime.workers[].actual` 中实际加载的 `version/digest/generation` 分开记录;`releaseReport` 是发布上报记录,不能代替当前 `actual`。源码插件没有制品摘要和发布代际,对应值为 `null`。 + +`runtime.state=observed` 表示取得新鲜 worker 快照,不保证该 worker 已加载目标插件;`partial` 表示存在已知采样缺口,`unobserved` 表示没有新鲜快照,`unavailable` 表示运行观测不可用。这些状态均不等同 `healthy`。 + +诊断进程扫描不到制品 manifest 时,保留 `database_only` 信息并标记静态检查未完成,仍可查看发布与运行快照;此路径不导入制品代码,也不会将尚未检查的配置或结构标记为正常。 + +采集只观察宿主已经维护的状态,不启动插件、不重新执行健康检查器,也不触发迁移、停用或资源清理。有效快照的 `workers[].actual` 包含以下插件信息,worker 整体清理总数单独放在 worker 行: + +| 信息 | 含义与边界 | +| --- | --- | +| `active/ready/closing` | 当前 worker 中实际加载插件的运行与关闭状态。 | +| `lifespan` | ASGI 的 `managed/started/ready/taskActive`;Router 没有 ASGI lifespan,此项为 `null`。 | +| `connections`、`activeJobs` | 宿主登记的 SSE/WebSocket 连接数量和当前任务数量;不枚举插件内部任意协程、线程或原生任务。 | +| `pendingConnectionTasks/pendingJobTasks` | 宿主发出取消后仍未退出、仍保留用于清理的已知任务数量。 | +| `workerPendingCleanupTasks` | 整个 worker 的去重清理任务总数,包含插件子项,不能与上述数量相加。 | +| `activationHealth` | 激活时已执行检查的 `status/checkedAt/durationMs` 历史摘要。未声明或尚未执行检查时为 `unknown`,不等同持续健康探测。 | +| `config` | `startupRevision` 是启动配置,`lastReadRevision/lastReadAt` 是最近一次按需读取,读取完成不表示业务已经应用新配置;修订标识使用 HMAC,不包含运行配置值。 | +| `lastError` | 已有观测记录中的异常类型、`requestId` 和时间;不包含异常消息、堆栈、URL、请求体或凭证。 | + +每个 worker 最多报告 1024 个显式加载的插件,超限通过 `droppedPlugins` 表达。`observedTotals` 仅合计本次新鲜 `actual` 中的连接、任务及插件清理计数,其 `totalsScope` 固定为 `fresh_observed_workers`,没有可用观测时为 `null`。`workerPendingCleanupTasks` 不计入这些合计。`observedWorkers` 是包含目标插件新鲜观测的 worker 数量;预期数量和存活报告基线都不可得时,`missingWorkers` 为 `null`。没有采集到的项目使用 `null` 或未知状态,不能把缺失快照解释成连接数为 0、清理完成或插件健康。 + +配置修订标识在同一插件、相同宿主密钥下可跨 worker 和重启比较;不同环境或密钥轮换后的摘要不能直接用于判断业务配置差异。运行配置的摘要脱敏只针对新增快照,诊断包原有 `config` 区域继续遵循其配置脱敏规则。采集时间、健康检查完成时间、配置读取时间与最近错误时间使用 epoch 秒,耗时 `durationMs` 使用毫秒。 + +运行快照每 15 秒写入独立的 Redis 诊断命名空间,TTL 为 60 秒;该命名空间以宿主 ready key 加 `:diagnostics:v1` 为前缀,不修改既有运行观测指标的 schema,也不新增数据库表。聚合最多读取 64 个 worker,每份快照最大 512 KiB;返回采样范围、`collectedAt/ageSeconds`,并通过 `invalidSnapshots/staleSnapshots/workerLimitReached` 标记损坏、过期与采样上限。worker 的 `source=current_worker` 表示即时本地采样,`source=redis` 表示周期快照。过期或损坏数据不会作为当前 `actual` 或计入 `observedTotals`,远程结果仅代表最近一次有效采样。 + +诊断快照按 `runtime.sampleTtlSeconds` 判断新鲜度;数据库中的宿主和插件发布上报按 `runtime.release.workerTtlSeconds` 判断 `host.fresh/releaseReport.fresh`。两种时效独立,发布心跳仍有效不代表运行快照仍可用。 + +远端快照必须包含完整的协议字段,缺少字段或含非法 UTF-8 的记录按损坏处理,不使用模型默认值补成零计数,也不影响其他有效 worker。最终聚合时会重新校验快照时间,避免将查询过程中已经过期的记录计入结果。 + +Redis 不可用且本地采样可用时,Web 诊断退回处理当前请求的 worker 的真实本地状态,范围为 `current_worker`;CLI 没有正在服务请求的宿主 worker,运行快照标记为 `unavailable`,不能用 CLI 进程的空状态代替服务器。采样缺失、部分上报和不可用都不表示全部 worker 健康。需要确认发布一致性时,还应结合 `release status` 的目标、存活上报及缺失/陈旧/失败 worker 信息;本诊断不改变发布状态。 + ## 13. 测试和发布前检查 后端单插件测试: @@ -852,9 +1300,422 @@ npm run build:prod - 插件 ID、后端模块、前端插件目录三者一致。 - 菜单权限全部声明在顶层 `permissions`。 -- 菜单组件路径能映射到实际 Vue 文件。 +- source 菜单组件路径能映射到实际 Vue 文件;bundle 页面使用 `PluginFrame` 且 HTML 入口存在。 - SQL seed 可重复执行。 - migration 和 seed 不写插件目录外文件。 -- 生命周期钩子中的全局写操作检查 `startup_write_enabled`。 +- 生命周期钩子中的全局写操作检查 `startup_write_enabled`;v2 ASGI 资源使用 lifespan 初始化及释放。 - 依赖声明完整,并通过 `check-deps`。 - 插件状态只使用 `status` 的四个生命周期值。 +- v2 显式入口同步返回 `PluginDefinition`,异步回调符合 SDK 参数和 awaitable 约定。 +- 原生扩展及 bundle 已在目标环境验证;签名制品的运行期文件写到制品目录之外。 + +## 15. v2 签名制品与维护发布 + +### 15.1 制品目录与信任配置 + +`.rpk` 是受约束的 ZIP。它只接受 `manifestVersion: 2`,前端交付类型为 `none` 或 `bundle`;目录名必须等于清单中的插件 ID。先构建 Rust wheel、独立前端和其他部署资源,再对准备好的插件目录签名。原生插件使用已展开且通过 wheel 校验的 `native/` 目录;制品导入不会替你执行 Cargo、maturin、npm、pip 或插件入口。 + +```text +sample.rpk + artifacts.json + signature.json + payload/ + / + plugin.yaml + native/... # 原生插件的预编译模块与发行包元数据 + web/dist/... # bundle 插件的前端构建产物 + migrations/... # 清单声明的资源,按需交付 + plugin.lock.yaml +``` + +Python 插件仍交付自身 Python 文件;`.rpk` 的签名不提供源码加密。打包目录不能包含 Rust/前端开发工程、`.env`、版本库、`node_modules` 或构建缓存。构建器忽略 Python/测试缓存,已导入目录的复验则拒绝任何额外文件,包括新增的 `__pycache__`。插件运行期文件、日志、上传和缓存必须存放在制品目录之外。 + +签名私钥由发布者保管,使用 Ed25519 PKCS8 PEM,必须放在插件源码及预构建目录之外;不要交付给宿主运行进程。加密私钥可通过 `artifact build --password-env <环境变量名>` 读取密码。宿主只配置经过独立可信渠道取得的公钥,并为每个 keyId 指定可发布的插件 ID。包内 `signature.json` 的 keyId 只用于查询宿主信任配置,包内自带的公钥不能建立信任。 + +宿主环境配置示例,路径相对于后端项目目录: + +```env +PLUGIN_ARTIFACT_ENABLED=true +PLUGIN_ARTIFACT_STORE=vf_admin/plugin_artifacts +PLUGIN_ARTIFACT_TRUST_FILE=config/plugin_publishers.json +PLUGIN_ARTIFACT_HEARTBEAT_SECONDS=15 +PLUGIN_ARTIFACT_WORKER_TTL_SECONDS=60 +``` + +功能默认关闭。存储目录不能是后端项目本身、其上级或 `plugins/` 内部;它及其路径组件不能使用符号链接或 Windows 重解析点。TTL 至少为心跳间隔的三倍。启用前先按宿主数据库升级流程准备新增的制品、发布目标和 worker 状态表,不能把插件的业务 migration 当作宿主表升级。 + +目录隔离不提供同进程 Python 依赖隔离。原生发行包和插件缺少或冲突的依赖应在维护环境处理,导入及 worker 启动不会隐式安装依赖。 + +新建数据库使用的 [MySQL 初始化 SQL](../sql/ruoyi-fastapi.sql) 和 [PostgreSQL 初始化 SQL](../sql/ruoyi-fastapi-pg.sql) 已包含这三张宿主状态表。已有数据库按 [CLI 数据库迁移流程](cli_usage.md#43-数据库迁移)生成迁移文件,补齐并审查这三张表的建表、索引和约束后执行升级,不要在已有数据库上直接重跑完整初始化 SQL。普通 worker 不会隐式创建签名插件的业务表,业务结构由维护准备的迁移负责。 + +`config/plugin_publishers.json` 格式如下;`publicKey` 占位符必须替换为真实 Ed25519 公钥的 PEM 字符串或 32 字节原始公钥的标准 Base64: + +```json +{ + "schemaVersion": 1, + "keys": [ + { + "keyId": "publisher", + "publicKey": "<通过可信渠道取得的公钥>", + "pluginIds": ["rust_demo", "asgi_demo", "bundle_demo"], + "enabled": true + } + ] +} +``` + +`pluginIds: ["*"]` 表示明确授权所有插件,应按实际发布职责配置范围。删除发布者或设置 `enabled: false` 后,后续验签、导入、维护准备和启动读取均按当前信任配置拒绝该签名;已经导入过不构成永久信任。已运行的代码不会因为编辑信任文件而自动热卸载,需要停止进程并完成维护处置。 + +### 15.2 构建、校验和导入 + +以下命令在后端目录执行,示例采用已构建好的 Rust 交付目录。私钥路径为示例,需替换为发布者实际管理的路径。 + +```bash +ruoyi plugin artifact build target/native-package/rust_demo target/rust_demo-1.0.0.rpk --key-file ../signing-keys/publisher.pem --key-id publisher --env=dev --output=json +ruoyi plugin artifact verify target/rust_demo-1.0.0.rpk --env=prod --output=json +ruoyi plugin artifact import target/rust_demo-1.0.0.rpk --env=prod --dry-run --output=json +ruoyi plugin artifact import target/rust_demo-1.0.0.rpk --env=prod --allow-prod --yes --output=json +ruoyi plugin artifact list --plugin-id rust_demo --env=prod --output=json +``` + +`build` 不覆盖已有输出。`verify` 只检查签名、全部文件摘要和发布者授权;`import --dry-run` 还检查源码目录冲突,但不展开目录,因此不代替正式导入的平台和结构检查。正式导入先接收私有快照,验签后展开到临时目录,完成兼容性与结构检查,再原子发布并登记数据库索引。所有路径、文件大小、解压数量和库存均受校验,链接与特殊文件会被拒绝。导入成功只代表制品可供维护流程选择,不执行 migration、seed 或 Hook,也不修改当前发布目标。 + +已导入目录固定为: + +```text +//// + artifacts.json + signature.json + payload//... +``` + +同 ID 的 `plugins/` 源码目录会阻止制品导入和使用。迁移部署方式时应先在维护窗口备份并移出旧目录,保留数据库安装状态;不能留下旧源码并期待制品覆盖它。原有版本已经安装但没有对应制品的准备证据时,需要发布更高版本,不能根据版本号猜测对应 digest。 + +重复导入不会覆盖现有对象;每次使用仍校验当前信任、文件库存和摘要。文件落盘与数据库登记不共享事务:登记失败时保留已验签对象,修复数据库后重试同一导入可完成登记。不要修改已导入目录来修复代码或权限;修改内容应生成新的制品。 + +### 15.3 停机维护、准备证据与目标选择 + +流程为:导入 → 查看计划 → 停止全部宿主 worker → 独立维护进程准备 → 读取发布代际 → 选择目标 → 启动全部 worker → 查看实际加载状态。第一版采用完整维护窗口,不提供二进制热替换或自动滚动发布。 + +下面命令中的 `DIGEST` 和 `GENERATION` 是占位符,执行前必须替换:`DIGEST` 原样取自导入或列表结果;`GENERATION` 取自本次准备后最新的 `status` 结果,不能自造、使用空值或根据版本推断。 + +```bash +ruoyi plugin release plan rust_demo DIGEST --env=prod --output=json +ruoyi plugin release prepare rust_demo DIGEST --env=prod --dry-run --output=json +``` + +计划及 dry-run 不执行 migration 或 Hook。确认计划和依赖后,通过现有进程管理器停止全部宿主 worker,再执行: + +```bash +ruoyi plugin release prepare rust_demo DIGEST --maintenance --env=prod --allow-prod --yes --output=json +ruoyi plugin release status --plugin-id rust_demo --env=prod --output=json +ruoyi plugin release select rust_demo DIGEST --expected-generation GENERATION --expected-workers 2 --maintenance --env=prod --allow-prod --yes --output=json +``` + +`--expected-workers 2` 只是示例,必须填写本次实际部署的宿主 worker 数。CLI 不负责停止或启动宿主。`--maintenance` 是操作者已停机的声明;服务还会检查当前有效心跳,发现存活 worker 时拒绝执行。心跳过期不是进程已停止的充分证据,不能仅等 TTL 而保持旧进程运行。 + +每次维护使用新 CLI 进程。`prepare` 复用安装/升级生命周期,执行需要的 migration、seed、菜单、配置、任务及 Hook。成功后记录精确的 `preparedDigest` 和 `preparedVersion`;`preparedVersion` 对应数据库的 `installedVersion`。相同版本只有已有准备证据同时匹配 digest 和数据库版本时才能复用;同版本换包、仅手工修改安装版本或缺失证据均不能跳过准备,应提升版本重新发布。准备失败不代表数据库 DDL 已自动回滚,应检查错误及迁移记录,修复后采用明确的新版本处理。 + +`select` 只接受已准备的指定 digest,并以 `--expected-generation` 做并发条件更新;代际已变化时拒绝覆盖,应重新查询和审阅。首次 `plan` 可能尚无 generation,因此应在成功 `prepare` 后读取 `status`。选择目标不修改 `installedVersion`,也不代替启用操作:首装沿用生命周期默认开关,升级保留原有 enabled 状态,已停用插件不会因为选择新制品自动启用。 + +启动全部 worker 后再执行 `release status`。`targetDigest` 是选择目标,`workers[].digest/version/generation/state` 才是各进程报告。`installedVersion` 是数据安装版本,不能据此判断进程实际加载的代码。 + +| 发布状态 | 含义 | +| --- | --- | +| `no_target` | 尚未选择运行目标 | +| `pending_restart` | 目标已选择,尚无足够且匹配的就绪报告 | +| `partial` | 部分 worker 已就绪或确认停用,其他 worker 尚未满足发布目标 | +| `failed` | 尚无匹配成功的 worker,且存在失败报告 | +| `active` | 存活宿主数量达到预期,所有存活宿主均报告目标 digest 和 generation 已就绪 | +| `disabled` | 停用目标已被足够且全部存活宿主确认,插件报告同一代际的 stopped 状态 | + +诊断同时检查 `prepareStatus`、`enabled`、`restartRequired`、`missingWorkers`、`mismatchWorkers`、`staleWorkers`、`failedWorkers` 和 `workers[].error`。例如插件已停用会阻止业务激活,不能仅因没有 `active` 就反复重启。状态汇总依赖宿主级心跳,不会只统计已经成功加载该插件的进程。真实数据库、多 worker、反向代理及运维进程管理仍需在部署环境验收。 + +流水线可通过 `release wait` 等待精确目标收敛。保存刚才 `select` 返回的 `generation` 为 `SELECTED_GENERATION`;如果随后执行 `enable` 或 `rollback`,改用该操作返回的新代际。通过进程管理器启动全部 worker 后执行: + +```bash +ruoyi plugin release wait rust_demo DIGEST --generation SELECTED_GENERATION --expected-workers 2 --timeout 300 --interval 2 --env prod --output json +``` + +四个固定目标均必填:插件 ID、64 位小写 SHA256 digest、32 位小写十六进制 generation 和正整数 expected-workers。等待使用的 generation 是变更后返回的新值,与 `select/enable/rollback` 的 `--expected-generation` 变更前并发令牌不同。预期 worker 数量必须与当前发布记录一致;等待期间目标摘要、代际或预期数量变化都会立即失败,不会自动追踪另一次发布。 + +仅当目标处于启用状态、所有有效存活宿主均报告相同 digest 和 generation 已就绪,且存活数量达到固定的预期下限时,结果才是 `converged`。超过预期数量的存活宿主也必须全部就绪;缺失或过期报告不能补足数量。已经退出的历史 worker 的陈旧记录不会阻止足够的新 worker 收敛。部分成功的摘要可能同时含失败 worker,因此不能只用 `status=partial` 继续等待而忽略失败计数。 + +命令只查询已有发布状态,不选择目标、执行 Hook、迁移、重启或创建诊断采样,不接受 `--maintenance`、`--allow-prod`、`--yes` 或 `--dry-run`。默认轮询 300 秒、间隔 2 秒;超时必须为有限非负数,间隔必须为有限正数。`--timeout 0` 只进行一次状态查询,适合已完成外部等待后的断言: + +```bash +ruoyi plugin release wait rust_demo DIGEST --generation SELECTED_GENERATION --expected-workers 2 --timeout 0 --env prod --output json +``` + +超时约束核心轮询,不包含此前的配置初始化和模块导入。单次状态查询上限为 10 秒,非零等待还受剩余轮询预算约束;查询取消采用异步协作机制。`timeout 0` 也有单次查询上限,不表示跳过查询。 + +JSON 标准输出只包含一个最终对象:`ok`、`operation=release_wait`、`reason`、`message`、固定 `target`、`lastObserved`、`attempts`、`elapsedSeconds`、`timeoutSeconds` 和 `intervalSeconds`。`lastObserved` 仅保留发布摘要的白名单字段,不带 `workers[].error`、`lastError` 或原始异常正文。没有有效观测时它为 `null`;CLI 取消或初始化失败无法确认查询进度时,`attempts` 也为 `null`。`target` 包含 `pluginId/digest/generation/expectedWorkers`,不会被后续查询改写。 + +`lastObserved` 是最后一次可用观测;`timeout` 或 `unavailable` 时可能保留前一轮已经过期的摘要。自动决策应以最终 `ok/reason` 和退出码为准,不能因为其中仍有 `status=active` 就认定本次等待成功。 + +| `reason` | 含义与退出码 | +| --- | --- | +| `converged` | 固定目标已收敛,退出 `0`。 | +| `not_converged` | 单次断言获得有效状态,但尚未收敛,退出 `50`。 | +| `timeout` | 非零轮询预算耗尽仍未收敛,退出 `50`。 | +| `target_missing` | 没有指定插件的发布目标,退出 `50`。 | +| `target_changed` | 当前摘要、代际或预期数量与固定目标不同,退出 `50`。 | +| `target_disabled` | 当前发布目标已停用,退出 `50`。 | +| `worker_failed` | 已观测到当前发布的 worker 故障,退出 `50`。 | +| `unavailable` | 配置初始化、状态读取或有界查询不可用,退出 `50`。 | +| `invalid_observation` | 返回状态结构不完整或不符合观测契约,退出 `50`。 | +| `invalid_arguments` | CLI 在服务创建前拒绝无效固定目标或时间参数,退出 `2`。缺少参数或未知选项由解析器直接以 `2` 拒绝。 | +| `cancelled` | CLI 收到异步取消或键盘中断,输出固定脱敏结果并退出 `50`。 | + +`release status` 的顶层 `ok` 保持“状态查询成功”的含义,即使目标尚未生效也可能是 `true`。流水线应判断 `release wait` 的退出码与原因;就绪报告仍不能代替插件业务接口验收。 + +管理页提供制品与发布状态的只读查看入口,签名制品使用上述维护 CLI 完成变更。对应 HTTP 查询均要求宿主登录及 `system:plugin:query` 权限: + +| 接口 | 参数与用途 | +| --- | --- | +| `GET /system/plugin/artifacts/list` | 可选 `pluginId`,查询已验证制品 | +| `GET /system/plugin/release/status` | 可选 `pluginId`,查询目标及各 worker 的实际报告 | +| `GET /system/plugin/release/plan` | 必填 `pluginId`、`digest`,读取静态发布预检计划 | +| `GET /system/plugin/runtime/metrics` | 可选 `pluginId`,按实际运行版本读取请求、连接和任务累计指标 | +| `GET /system/plugin//config/status` | 比较保存配置与已观测进程的启动快照,显示按需读取版本,不返回敏感值 | + +当前宿主未启用制品发布监控时,管理页显示状态不可确认。worker 心跳只报告已加载的目标快照,不会自动选择新目标;选择目标成功也不代表运行中的进程已更新。 + +### 15.4 维护启停、代码回滚与签名轮换 + +已选择且具备完整准备证据的制品可通过独立维护命令启停。先停止全部宿主 worker,读取最新 `release status` 的 generation,再根据需要执行下面其中一个命令: + +```bash +ruoyi plugin release disable rust_demo --expected-generation GENERATION --maintenance --env=prod --allow-prod --yes --output=json +ruoyi plugin release enable rust_demo --expected-generation GENERATION --maintenance --env=prod --allow-prod --yes --output=json +``` + +每次成功操作都会生成新 generation,不能共用旧代际连续启停。命令先做依赖预检,再在一个数据库事务中更新 enabled、关联菜单、插件拥有的任务、发布代际和审计;任一步失败整体回滚。启用时仍尊重任务自身的 `enabled: false` 声明,不会误改同名前缀的人工任务。当前目标、上一目标和数据库安装版本保持不变,`previousDigest` 仍可供代码回滚使用。命令成功后启动全部 worker;停用和启用都不通过热卸载/热挂载实现。 + +回滚只选择 `previousDigest` 指向的上一代码制品。先确认旧代码兼容当前数据库结构,停止全部宿主 worker,再读取最新 `status` 的 generation: + +```bash +ruoyi plugin release rollback rust_demo --expected-generation GENERATION --schema-compatible --maintenance --env=prod --allow-prod --yes --output=json +``` + +命令会重新验签并静态检查上一制品,重建其菜单、配置和任务声明;不执行旧版 migration、seed 或 Hook,不倒退 `installedVersion`。`--schema-compatible` 是运维人员的兼容性确认,不是自动推导出的数据库降级方案。随后启动全部 worker 并检查 `release status`。若旧代码不能使用当前数据结构,应制定数据库恢复方案,不能用代码回滚命令替代。 + +当前没有自动停止进程或热卸载。ASGI SSE/WebSocket 支持周期权限复核及当前 worker 的运行时关闭回收,但这不代替维护窗口和全部进程退出。修改 enabled、选择目标或撤销签名密钥均不能卸载进程中已加载的 Python/Rust 模块,发布目标和签名状态也不属于连接的周期复核内容。制品清理与签名轮换使用下面的独立维护命令。 + +制品 digest 是规范化 `artifacts.json` 的 SHA256,覆盖每个有效载荷文件的路径、大小和摘要,不包含签名或 keyId。普通重复导入仍不覆盖原对象。需要保持同一内容轮换密钥时:先把新公钥加入外部信任文件并授权该插件;用新私钥对原构建目录重新打包;停止全部 worker,读取当前 `artifact list` 的 keyId,再执行: + +```bash +ruoyi plugin artifact build target/native-package/rust_demo target/rust_demo-rotated.rpk --key-file ../signing-keys/publisher-next.pem --key-id publisher-next --env=dev --output=json +ruoyi plugin artifact rotate-signature target/rust_demo-rotated.rpk --expected-key-id publisher --maintenance --env=prod --allow-prod --yes --output=json +``` + +命令以新可信签名核对原目录的全部字节,再原子替换签名、CAS 更新索引并记录审计;digest、版本、发布引用和 generation 保持不变。旧密钥已撤销也可执行,因为新签名必须独立证明现有内容。新制品内容或版本不同、发布者越权、原目录篡改、原 keyId 变化均拒绝操作。成功后检查制品并重启全部 worker,再移除不再需要的旧公钥;无需重跑迁移。 + +### 15.5 制品检查、保留策略与中断恢复 + +只读检查规范存储目录与数据库索引,显示 `registered/orphan/missing/invalid`、引用保护原因和候选清理摘要: + +```bash +ruoyi plugin artifact inspect --env=prod --output=json +ruoyi plugin artifact prune --keep-last 2 --min-age-days 7 --env=prod --output=json +``` + +`prune` 默认只预演。默认保留每个插件最近导入的 2 个制品以及未满 7 天的对象;按导入时间排序,不按语义版本排序。同版本的不同平台构建分别计数。当前目标、上一目标、已准备制品,以及未报告 stopped 的进程所引用的摘要始终保护,过期心跳也不会自动解除引用。缺失、无效、已撤销签名或静态检查不通过的对象只报告问题,不自动删除;`.staging/.locks` 等内部目录不属于该清理计划。 + +核对完整 `objects/protectedBy/candidates` 和 `planId` 后,停止全部宿主进程,使用同一保留参数执行: + +```bash +ruoyi plugin artifact prune --keep-last 2 --min-age-days 7 --expected-plan PLAN_ID --execute --maintenance --env=prod --allow-prod --yes --output=json +``` + +清理、导入、签名轮换和发布共用全局生命周期锁。正式执行会重新检查计划与进程报告;计划变化必须重新预演。每个候选先移入存储内部隔离目录,再提交索引删除和审计,最后验证并清理隔离对象。多对象操作按对象提交,后续对象失败不会撤销已经完成的清理。 + +文件与数据库不共享事务,因此 `.maintenance` 保存中断恢复日志。存在未完成日志时拒绝导入与制品加载,先保持停机并执行: + +```bash +ruoyi plugin artifact reconcile --maintenance --env=prod --allow-prod --yes --output=json +``` + +恢复以已提交索引为准:清理未提交时恢复原目录,已提交时完成隔离对象删除;签名轮换按索引中的新旧 keyId 恢复相应签名。恢复同样检查路径、摘要和当前信任,不执行插件代码、迁移或 Hook。若日志损坏、文件丢失或删除中途发生文件系统故障,命令保留证据并报错,需要根据备份人工处理,不能通过删除日志强行启动。恢复旧签名后若该密钥已撤销,仍需再次完成新签名轮换才可加载。 + +## 16. v2 项目脚手架与完整构建 + +现有 `create` 默认仍为 v1 `full-stack`。下列显式模板创建独立源码工程,统一位于后端 `plugin-projects//`;创建工程不安装、启用或导入插件。该目录与运行中的 `plugins/`、不可变制品存储分开,避免原生开发工程参与运行时扫描或与同 ID 制品冲突。 + +| 模板 | 后端 | 前端 | 交付准备 | +| --- | --- | --- | --- | +| `python-asgi` | Python ASGI | 无 | 生成的 `build_release.py` | +| `python-bundle` | Python ASGI | 独立 Vite bundle | 先构建 `web/`,再运行 `build_release.py` | +| `rust-asgi` | Rust PyO3 ASGI | 无 | 宿主 `scripts/build_native_plugin.py` | +| `rust-bundle` | Rust PyO3 ASGI | 独立 Vite bundle | 先构建 `web/`,再运行原生构建器 | + +```bash +ruoyi plugin create report_center --template rust-bundle --env dev --dry-run +ruoyi plugin create report_center --template rust-bundle --env dev +``` + +每个工程包含 v2 清单、受 `:view` 权限保护的 `/api/info`、显式入口、健康检查、lifespan、构建与发布 README,以及 SDK 合约测试。Rust 模板固定 `ruoyi_plugin_` Python 命名空间,包含锁定的 Cargo 依赖。bundle 复制当前宿主的 bridge SDK 及配套 `.d.ts`,使用相对资源基址和专用插件会话,不内嵌主登录 token。它与宿主 Vue 版本独立,v2 模板不接受 `--frontend-version`;bundle 也不能配合 `--backend-only`。 + +bundle 安装 npm 依赖后,可运行 `npm --prefix plugin-projects/report_center/web run dev`,自动打开生成的 `/dev.html`。`dev.js` 提供 `/api/info` 的内存模拟、主题、延迟、故障、重载和退出,可按业务扩展;默认模板未实现文件或 SSE 业务接口。模拟配置仅在开发服务注入,生产构建不包含开发宿主。文件与 SSE 的完整示例见 `bundle_demo`。 + +以下以 Rust bundle 为例,在后端目录及已激活的 Python 环境执行。构建机需具备前述 Rust、maturin、平台编译工具和 Node.js,运行宿主只接收构建产物。构建命令三种平台通用: + +```bash +npm --prefix plugin-projects/report_center/web install +npm --prefix plugin-projects/report_center/web run build +python scripts/build_native_plugin.py --source plugin-projects/report_center --output target/report_center-package +``` + +随后指定交付目录并测试。Windows PowerShell: + +```powershell +$env:RUOYI_PLUGIN_DIRECTORY = (Resolve-Path 'target/report_center-package/report_center').Path +python -m pytest plugin-projects/report_center/tests -q +``` + +macOS / Linux(Bash、Zsh): + +```bash +export RUOYI_PLUGIN_DIRECTORY="$(pwd)/target/report_center-package/report_center" +python -m pytest plugin-projects/report_center/tests -q +``` + +首次 `npm install` 产生的 `package-lock.json` 应随源码提交,后续构建可用 `npm ci`。原生构建器不会代替你运行 npm;缺少 bundle HTML 入口时在编译前报错。它只组装原生发行包、清单声明的迁移/seed 文件或目录、可选插件锁文件和已构建 bundle,拒绝链接、重解析点、硬链接、开发工程及敏感配置进入交付。`target/report_center-package/report_center` 才是签名输入,不是 wheel 所在目录或源码工程。 + +Python 模板将上一例中的原生构建步骤替换为: + +```bash +python plugin-projects/report_center/build_release.py --output target/report_center-package +``` + +Python 打包器固定复制 `plugin.yaml`,按生成的 `release-files.json` 纳入显式业务 `.py/.sql` 文件,再复制构建后的 bundle;新增业务模块与 SQL 时维护该清单,规则见第 16.2 节。Python 插件仍以 Python 源码交付。Rust 交付不包含 `.rs`、Cargo 或前端开发工程;两种方式均需按第 15 节签名、导入、维护准备、选择目标及重启验收。所有构建输出必须使用新目录,不能覆盖已加载版本。 + +### 16.1 桥接 SDK 溯源与显式更新 + +bundle 工程的 `web/vendor/pluginBridge.sdk.json` 记录独立 `sdkVersion`、桥协议 `bridgeVersion`、可选文件/SSE 能力版本、来源位置及 JS/类型声明摘要。SDK 的初始版本为 `1.0.0`,与宿主 Host API 版本和桥协议 v1 分开管理;只读导出 `PLUGIN_BRIDGE_SDK_VERSION` 不增加消息字段。纯 API 模板不生成前端 SDK。 + +摘要算法为 `sha256-utf8-lf`:按 UTF-8 读取、去掉可选的 UTF-8 BOM,再将 CRLF/CR 统一为 LF 后计算 SHA256,因此 Windows 和 Linux 的正常换行转换不会被判为业务修改。该记录用于源码追踪和修改检测,不提供签名可信性;同一 SDK 版本下来源内容变化也会被检出,SDK 维护者仍应按变更发布新的版本。 + +在后端目录使用离线命令: + +```bash +ruoyi plugin sdk check plugin-projects/report_center --output=json +ruoyi plugin sdk update plugin-projects/report_center --dry-run --output=json +ruoyi plugin sdk update plugin-projects/report_center +npm --prefix plugin-projects/report_center/web run build +``` + +默认来源为宿主相邻前端目录;独立检出时可传 `--frontend-root PATH`,或设置 `RUOYI_PLUGIN_FRONTEND_ROOT`。命令只静态读取工程清单和固定 SDK 文件,不导入插件、不读取宿主运行环境、不连接数据库或 registry。清单检查仅确认 YAML 可读、`manifestVersion: 2` 和 bundle 工程类型,不执行完整插件清单校验;完整检查与业务测试仍按第 13 节执行。 + +| `check` 状态 | 含义与处理 | +| --- | --- | +| `current` | 记录、版本及文件摘要与选定来源一致,退出码为 0。 | +| `outdated` | 本地 SDK 与其记录一致,但与选定来源不同;返回非零,可显式更新。来源可能比本地旧,命令不替你决定是否降级。 | +| `modified` | SDK 文件与本地记录不一致;先审阅自己的改动。 | +| `untracked` | 旧工程缺少溯源记录,不能假定原副本未修改。 | +| `invalid` | 工程、文件或元数据无效;按错误信息修复。 | +| `incompatible` | 桥协议不同,需要先完成协议迁移;`--force` 不会绕过。 | + +`protocolCompatible` 仅表示桥协议版本一致;可选能力可增减,不能据此推断所有业务功能都能运行。前端测试固定保留上轮 SDK 客户端,验证旧客户端与当前宿主的 JSON、文件和 SSE 调用;当前客户端遇到旧宿主未公布或不支持的能力时明确拒绝,并保留可用的 JSON 通道。 + +更新只处理 `pluginBridge.js`、`pluginBridge.d.ts` 和 `pluginBridge.sdk.json`。写入前将原文件保存到工程 `.plugin-sdk-backups/<唯一目录>/`,写入异常会尝试恢复原内容,溯源记录最后替换;同一工程并发更新被拒绝,符号链接和越界路径也被拒绝。正在构建时应先停止构建再更新,三个文件不构成跨文件系统原子事务;进程被强制终止后需先核对备份与文件状态,再人工处理遗留更新锁,不能把删除锁当作已完成恢复。 + +本地修改、旧副本或可安全备份的损坏溯源记录,默认不会被覆盖。确需用选定来源重新纳管时,先执行 `update --force --dry-run`,审阅结果后再执行 `update --force`;非法 YAML、非 v2 bundle 工程、无效来源 SDK 或不安全路径不能通过此选项绕过。业务源码不受影响,已构建的 `web/dist` 也不会自动改变;更新后要重建、回归,再按正式流程发布。 + +### 16.2 Python 业务模块与迁移交付 + +新生成的 Python 工程包含 `release-files.json`,初始为 `{"schemaVersion": 1, "files": ["__init__.py"]}`。该文件供独立构建脚本使用,不属于运行时插件清单,也不进入制品。示例扩展: + +```json +{ + "schemaVersion": 1, + "files": [ + "__init__.py", + "models.py", + "service.py", + "migrations/mysql/001_init.sql", + "migrations/postgresql/001_init.sql" + ] +} +``` + +文件必须显式列出,使用插件源码根目录下的相对路径,仅接受 `.py/.sql`;不接受目录、通配符、路径跳转、隐藏路径、开发目录、重复或大小写冲突以及链接、重解析点和硬链接。配置最多 64 KiB、1024 项。`plugin.yaml` 固定复制,bundle 的 `web/dist` 沿用静态扩展名白名单及链接检查。构建器先校验全部输入再创建输出目录,不导入插件、不安装依赖、不执行迁移。新增 SQL 还须加入 `plugin.yaml` 的迁移声明,并测试清单与实际制品一致;制品预检仍负责验证插件结构与迁移引用。 + +已生成的旧构建脚本不会自动改变,可以继续按原有约定交付;采用新脚本时应一并迁入文件清单,再验证完整交付目录。不要直接将整个 Python 工程、凭据、前端源码或依赖目录签名发布。 + +[`task_demo`](../plugins/examples/python/task_demo/README.md) 展示完整的共享任务业务:独立 bundle 表单、分页与状态筛选、`view/write` 分权、SDK 显式事务、参数校验、MySQL/PostgreSQL SQL,以及从 1.0 数据结构到 1.1 优先级字段的数据保留。示例源码与生成工程使用同一构建器契约;数据库写入不发生在插件启动阶段。 + +## 17. 本地真实服务验收与 CI + +本节用于插件交付前的自动化回归和部署环境验收。插件自身的测试与静态检查见[第 13 节](#13-测试和发布前检查),v2 工程构建及合约测试见[第 16 节](#16-v2-项目脚手架与完整构建),签名交付与维护操作按[第 15 节](#15-v2-签名制品与维护发布)执行。 + +### 17.1 本地自动化回归 + +在后端目录、已安装宿主依赖的 Python 环境中执行。下面的命令适用于 Windows PowerShell、macOS 和 Linux: + +```bash +python -m pip install pytest pytest-asyncio aiosqlite +python -m pytest tests/cli/runtime/plugin/test_runtime_scaffold.py tests/cli/runtime/plugin/test_runtime_scaffold_v2.py -q +python -m pytest tests/plugins/examples/test_task_demo.py tests/plugins/core/deployment/test_task_delivery.py -q +python -m pytest tests/plugins/core/artifacts tests/plugins/core/deployment tests/plugins/core/management/test_release_dao.py tests/plugins/core/management/test_artifact_views.py tests/module_plugin/controller/test_plugin_release_controller.py tests/cli/root/test_plugin_artifact_commands.py tests/cli/root/test_plugin_release_wait.py tests/sql/test_plugin_release_schema.py -q +``` + +上述回归覆盖脚手架、签名制品、维护发布、固定目标等待与单次断言、状态查询及数据库脚本约束。原生插件还应在目标平台构建后执行[原生示例的集成验证](../plugins/examples/rust/rust_demo/README.md#本地集成验证);测试因缺少原生制品而跳过时,不能据此确认原生交付可用。 + +bundle 插件完成独立前端构建后,还应执行宿主桥接测试(在后端目录执行,三种平台通用): + +```bash +npm --prefix ../ruoyi-fastapi-frontend run test:plugin +``` + +### 17.2 真实服务与多 worker 验收 + +准备独立的测试数据库、Redis、制品存储和发布者密钥,配置与目标部署一致的数据库类型、代理前缀、TLS 和 worker 数量。记录初始插件版本、数据库安装版本、目标 digest 和 generation;验收中的导入、迁移、目标选择及重启沿用第 15 节的正式维护流程。 + +按下表逐项确认结果: + +| 场景 | 验收要求 | +| --- | --- | +| 首次发布 | 验签与导入成功,完成维护准备和目标选择;重启后所有预期 worker 报告同一 digest、generation,汇总状态为 `active`。 | +| 升级 | 停止全部 worker 后准备新版本;迁移成功记录不重复执行,重启后全部 worker 加载新目标。 | +| 代码回滚 | 确认旧代码兼容当前数据结构后选择上一目标并重启;运行代码回退,数据库安装版本和迁移历史保持不变。 | +| 维护互斥 | 存在有效 worker 心跳时,维护准备被拒绝;并发维护操作不能绕过生命周期锁。 | +| 部分启动失败 | 某个 worker 的初始化或健康检查失败时,发布状态及错误可诊断,不能把该 worker 计为已就绪。 | +| 进程退出与重启 | 正常退出后释放资源;异常退出后的陈旧报告不再计入存活数量,新进程按当前目标加载。 | +| 登录与权限 | 未登录、缺少插件权限或会话退出后的请求被拒绝;已授权用户能调用插件 API。bundle 页面另外验证会话、CSRF、资源路径和代理前缀。 | +| 制品完整性 | 加载前后的签名与文件摘要检查通过,运行时未向不可变制品目录写入业务数据或缓存。 | + +保留每次操作的输出、发布状态和各 worker 的错误信息。成功依据是预期 worker 的实际就绪报告及业务接口验证,不能仅以导入成功、目标已选择或单进程测试通过判定部署完成。 + +### 17.3 数据隔离与清理 + +验收前明确本次使用的数据库、Redis 命名空间、制品目录和进程范围。结束后停止本次启动的进程、关闭连接并按记录清理临时资源;清理失败时保留资源标识供后续处理。插件自身的 migration 和回滚兼容性需要结合真实业务数据验证。 + +反向代理、TLS、跨机器网络、既有 WebSocket 或长连接排空仍需在实际部署拓扑中验收。自动化回归不替代这些检查。 + +### 17.4 CI 覆盖 + +仓库的原生插件工作流配置 Windows/Linux、Python 3.10、3.11、3.12、3.13 构建测试矩阵,覆盖 v1/v2 脚手架、SDK 溯源与离线更新命令、原生扩展以及签名制品与维护发布回归。 + +发布集成工作流在 Python 3.10、3.11、3.12、3.13 上使用临时 MySQL、PostgreSQL 和 Redis 服务验证多 worker 发布行为。工作流配置不代表已经执行成功;发布前应核对对应提交的实际 CI 结果,并完成目标平台的部署验收。 + +`scripts/plugin_release_integration.py` 还检查双 worker 首次发布、升级和代码回滚后的固定目标收敛,无 worker 时单次断言不收敛,旧目标代际被拒绝、单 worker 故障立即失败,以及报告过期、缺员和遗留陈旧记录下的等待结果。足够的新 worker 全部就绪后,遗留陈旧记录不应造成永久等待;失败结果保留退出码和固定原因供流水线判断。脚本还针对两种数据库启动独立的完整 CLI 进程,验证标准输出可整体解析为一个 JSON 对象,并核对未就绪时退出 `50`、就绪后退出 `0`。 + +该工作流还实际构建 `task_demo` 的 bundle,并通过 `scripts/plugin_task_integration.py` 验证 Python 受控目录打包、签名、导入、维护准备、目标选择、旧结构数据保留以及当前业务 CRUD/读写分权。旧版本是仅承载 001 迁移的测试夹具;当前版本从真实签名制品加载。业务 HTTP 验收使用已认证上下文和 ASGI 适配器,不代替浏览器登录验收。运行真实发布集成脚本前须先按任务示例 README 构建 `web/dist`;本地 SQLite 交付回归使用最小 HTML 夹具,单独验证维护链路。 + +[`plugin-frontend.yml`](../../.github/workflows/plugin-frontend.yml) 在 Node.js 22 上执行 `npm run test:plugin`,相关源码、测试、SDK、构建配置及依赖变更会触发检查。仓库当前不提交 npm 锁文件,因此使用 `npm install --package-lock=false`,依赖解析仍遵循 `package.json` 的版本范围。 + +[`plugin-network-smoke.yml`](../../.github/workflows/plugin-network-smoke.yml) 在 Linux、Python 3.12、Node.js 22 与 Chromium 上执行真实网络 smoke。链路为宿主 Vue `PluginFrame` → iframe SDK → 临时 HTTPS Vite 代理 → Uvicorn → 生产插件会话与门禁,使用 `/gateway` 部署前缀。覆盖未登录拒绝、限定路径的 Secure/HttpOnly/SameSite Cookie、JSON POST、CSRF/Origin 拒绝、SSE 增量接收与取消后游标恢复、SSE/WebSocket 在权限及主会话撤销后的关闭,以及 runtime drain 后连接和 lifespan 回收。工作流保留截图、Playwright trace、代理日志与 JUnit 结果。 + +本地复现(在后端目录执行,三个平台通用): + +```bash +python -m pip install pytest pytest-asyncio aiosqlite playwright +python -m playwright install chromium +npm --prefix ../ruoyi-fastapi-frontend install --no-audit --no-fund --package-lock=false +python -c "import os,pytest; os.environ['RUOYI_PLUGIN_NETWORK_SMOKE']='1'; raise SystemExit(pytest.main(['tests/plugins/integration/test_network_smoke.py','-q']))" +``` + +常规 pytest 不设置 `RUOYI_PLUGIN_NETWORK_SMOKE=1` 时跳过此项,不要求安装浏览器。显式启用后,缺少浏览器或 Node 依赖会直接失败。可通过 `RUOYI_SMOKE_BROWSER_EXECUTABLE` 指定已有 Chromium 浏览器,通过 `RUOYI_SMOKE_OUTPUT` 指定诊断目录;默认输出在后端 `target/plugin-network-smoke/`。测试仅绑定 loopback 随机端口,临时证书不加入系统信任库,结束时回收浏览器和服务。 + +该 smoke 的账号查询、数据库会话、插件启用状态和 Redis 使用隔离夹具,传输加密为 off;它验证真实网络上的生产会话协议,不覆盖真实账号登录、Redis 故障、required 加密、生产证书信任或进程信号触发的停机。runtime drain 之后才让 Uvicorn 正常退出,SIGTERM、完整宿主 lifespan 及目标部署代理仍按 17.2 节另行验收。 diff --git a/ruoyi-fastapi-backend/docs/unified_authentication_developer_guide.md b/ruoyi-fastapi-backend/docs/unified_authentication_developer_guide.md index 2cdcb33..ff0b9ae 100644 --- a/ruoyi-fastapi-backend/docs/unified_authentication_developer_guide.md +++ b/ruoyi-fastapi-backend/docs/unified_authentication_developer_guide.md @@ -55,9 +55,9 @@ Issuer 必须是无路径前缀、无查询串的固定公开地址。`OIDC_PUBL ## 3. 平台启用与检查 -所有后端命令在 `ruoyi-fastapi-backend` 目录执行,并使用已安装项目依赖的 Python 3 解释器。命令适用于 Windows PowerShell 和 macOS Terminal,不依赖特定的 Python 环境管理工具。下面以 `dev` 环境为例,其他环境替换为对应的 `--env` 和配置文件。 +所有后端命令在 `ruoyi-fastapi-backend` 目录执行,并使用已安装项目依赖的 Python 3.10–3.13 环境。命令适用于 Windows PowerShell、macOS 和 Linux,不依赖特定的环境管理工具、环境名称或安装路径。创建与激活步骤见 [CLI 环境准备](cli_usage.md#22-安装依赖)。下面以 `dev` 环境为例,其他环境替换为对应的 `--env` 和配置文件。 -`ruoyi` 命令在 Windows 和 macOS 上的参数一致。直接调用 Python 时,下文分别给出 Windows 的 `python` 和 macOS 的 `python3` 写法;若本机解释器命令不同,替换为实际安装项目依赖的解释器。 +`ruoyi` 命令在三个平台上的参数一致。激活环境后,直接调用解释器统一使用 `python`;在新终端执行命令前也应激活同一环境。 ### 3.1 配置功能开关和密钥材料 @@ -94,10 +94,10 @@ Windows PowerShell: python -c "import secrets; print(secrets.token_urlsafe(48))" ``` -macOS Terminal: +macOS / Linux(Bash、Zsh): ```shell -python3 -c "import secrets; print(secrets.token_urlsafe(48))" +python -c "import secrets; print(secrets.token_urlsafe(48))" ``` 多实例使用同一套持久化配置。每次重启重新生成 Pepper 会使已有不透明凭据无法匹配;更换加密材料会影响数据库中现有签名私钥的解密。配置文件不提交真实密钥。修改环境配置后重启后端实例。 @@ -124,10 +124,10 @@ ruoyi app doctor --env dev 若终端找不到 `ruoyi` 命令,可直接使用 Python 模块入口,将上面命令的 `ruoyi` 前缀替换为下表中的调用方式,其余参数保持不变: -| 系统 | 替代前缀 | 检查命令示例 | -| ------------------ | ----------------------------- | -------------------------------------------------- | -| Windows PowerShell | `python -X utf8 -m cli.main` | `python -X utf8 -m cli.main app doctor --env dev` | -| macOS Terminal | `python3 -X utf8 -m cli.main` | `python3 -X utf8 -m cli.main app doctor --env dev` | +| 系统 | 替代前缀 | 检查命令示例 | +| --- | --- | --- | +| Windows PowerShell | `python -X utf8 -m cli.main` | `python -X utf8 -m cli.main app doctor --env dev` | +| macOS / Linux(Bash、Zsh) | `python -X utf8 -m cli.main` | `python -X utf8 -m cli.main app doctor --env dev` | 生产密钥初始化还需要显式传入 `--allow-prod`,并指定实际生产环境。 @@ -159,11 +159,11 @@ python -c "import urllib.request; print(urllib.request.urlopen('https://auth.exa python -c "import urllib.request; print(urllib.request.urlopen('https://auth.example.com/oauth2/jwks', timeout=10).read().decode('utf-8'))" ``` -macOS Terminal: +macOS / Linux(Bash、Zsh): ```shell -python3 -c "import urllib.request; print(urllib.request.urlopen('https://auth.example.com/.well-known/openid-configuration', timeout=10).read().decode('utf-8'))" -python3 -c "import urllib.request; print(urllib.request.urlopen('https://auth.example.com/oauth2/jwks', timeout=10).read().decode('utf-8'))" +python -c "import urllib.request; print(urllib.request.urlopen('https://auth.example.com/.well-known/openid-configuration', timeout=10).read().decode('utf-8'))" +python -c "import urllib.request; print(urllib.request.urlopen('https://auth.example.com/oauth2/jwks', timeout=10).read().decode('utf-8'))" ``` 检查 Discovery 中的 `issuer` 和各端点是否为预期公开 HTTPS 地址,JWKS 是否包含可用 RSA 公钥,并确认 `ruoyi app doctor` 的 OIDC 检查通过。开关为真但无有效签名密钥时,Discovery/JWKS 等就绪检查可能返回 503;先检查密钥状态和加密配置。 diff --git a/ruoyi-fastapi-backend/middlewares/transport_crypto_middleware.py b/ruoyi-fastapi-backend/middlewares/transport_crypto_middleware.py index 6289ce2..af36983 100644 --- a/ruoyi-fastapi-backend/middlewares/transport_crypto_middleware.py +++ b/ruoyi-fastapi-backend/middlewares/transport_crypto_middleware.py @@ -71,6 +71,7 @@ class TransportCryptoMiddleware: path = self._normalize_path(str(scope.get('path', ''))) if ( self._is_excluded_path(path) + or self._is_bundle_ui_navigation(scope, path) or TransportCryptoConfig.transport_crypto_mode == 'off' or not self._is_enabled_path(path) ): @@ -711,6 +712,26 @@ class TransportCryptoMiddleware: return 'replay_detected' return 'decrypt_failed' + @staticmethod + def _is_bundle_ui_navigation(scope: Scope, path: str) -> bool: + """ + 仅已加载 bundle 的只读 UI 使用普通 HTTPS;API 仍遵守信封协议。 + + :param scope: ASGI 请求作用域 + :param path: 去除宿主部署前缀后的请求路径 + :return: 是否为已加载插件构建产物的只读 UI 请求 + """ + if scope.get('method') not in {'GET', 'HEAD'}: + return False + runtime = getattr(getattr(scope.get('app'), 'state', None), 'plugin_explicit_runtime', None) + for plugin_id, loaded in getattr(runtime, 'loaded', {}).items(): + if loaded.plugin.discovered_plugin.manifest.frontend.delivery.type != 'bundle': + continue + prefix = f'/apps/{plugin_id}/ui' + if path == prefix or path.startswith(f'{prefix}/'): + return True + return False + @classmethod def _is_excluded_path(cls, path: str) -> bool: """ diff --git a/ruoyi-fastapi-backend/module_admin/entity/vo/login_vo.py b/ruoyi-fastapi-backend/module_admin/entity/vo/login_vo.py index 565f0c3..579a7d8 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/vo/login_vo.py +++ b/ruoyi-fastapi-backend/module_admin/entity/vo/login_vo.py @@ -79,6 +79,7 @@ class MetaModel(BaseModel): icon: str | None = Field(default=None, description='设置路由的图标') no_cache: bool | None = Field(default=None, description='设置为true,则不会被 缓存') link: str | None = Field(default=None, description='内链地址(http(s)://开头)') + plugin_id: str | None = Field(default=None, description='由平台菜单归属确定的插件ID') class RouterModel(BaseModel): diff --git a/ruoyi-fastapi-backend/module_admin/service/login_service.py b/ruoyi-fastapi-backend/module_admin/service/login_service.py index 5368d15..b7692c1 100644 --- a/ruoyi-fastapi-backend/module_admin/service/login_service.py +++ b/ruoyi-fastapi-backend/module_admin/service/login_service.py @@ -26,6 +26,7 @@ from module_identity.service.identity_service import ( CredentialAuthenticationError, CredentialAuthenticationService, ) +from plugins.core.management.service.router_metadata import PluginRouterMetadataService from utils.client_ip_util import ClientIPUtil from utils.common_util import CamelCaseUtil from utils.jwt_util import JwtUtil @@ -152,6 +153,35 @@ class LoginService: :return: 当前用户信息对象 :raise: 令牌异常AuthException """ + return await cls._resolve_current_user(request, token, query_db, refresh_session=True) + + @classmethod + async def get_current_user_for_plugin_session( + cls, request: Request, token: str, query_db: AsyncSession + ) -> CurrentUserModel: + """ + 插件 Cookie 只验证主会话,不通过 GET/SET 续期复活并发退出的登录。 + + :param request: 当前插件请求 + :param token: 插件会话绑定的宿主登录凭证 + :param query_db: 用于查询用户及权限的数据库会话 + :return: 通过宿主身份及实时权限校验的当前用户 + """ + return await cls._resolve_current_user(request, token, query_db, refresh_session=False) + + @classmethod + async def _resolve_current_user( + cls, request: Request, token: str, query_db: AsyncSession, *, refresh_session: bool + ) -> CurrentUserModel: + """ + 共享身份和实时权限校验,调用方明确是否续期主会话。 + + :param request: 当前请求 + :param token: 宿主登录凭证 + :param query_db: 用于查询用户及权限的数据库会话 + :param refresh_session: 是否续期宿主主登录会话 + :return: 通过宿主身份及实时权限校验的当前用户 + """ # if token[:6] != 'Bearer': # logger.warning("用户token不合法") # raise AuthException(data="", message="用户token不合法") @@ -180,13 +210,13 @@ class LoginService: f'{RedisInitKeyConfig.ACCESS_TOKEN.key}:{query_user.get("user_basic_info").user_id}' ) if token == redis_token: - if AppConfig.app_same_time_login: + if refresh_session and AppConfig.app_same_time_login: await request.app.state.redis.set( f'{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}', redis_token, ex=timedelta(minutes=JwtConfig.jwt_redis_expire_minutes), ) - else: + elif refresh_session: await request.app.state.redis.set( f'{RedisInitKeyConfig.ACCESS_TOKEN.key}:{query_user.get("user_basic_info").user_id}', redis_token, @@ -294,8 +324,9 @@ class LoginService: ], key=lambda x: x.order_num, ) + plugin_menu_ids = await PluginRouterMetadataService.get_menu_plugin_ids(query_db, user_router_menu) menus = cls.__generate_menus(MenuConstant.ROOT_ID, user_router_menu) - user_router = cls.__generate_user_router_menu(menus) + user_router = cls.__generate_user_router_menu(menus, plugin_menu_ids) return [router.model_dump(exclude_unset=True, by_alias=True) for router in user_router] @classmethod @@ -319,11 +350,16 @@ class LoginService: return menu_list @classmethod - def __generate_user_router_menu(cls, permission_list: list[MenuTreeModel]) -> list[RouterModel]: + def __generate_user_router_menu( + cls, + permission_list: list[MenuTreeModel], + plugin_menu_ids: dict[int, str] | None = None, + ) -> list[RouterModel]: """ 工具方法:根据菜单树信息生成路由信息树形嵌套数据 :param permission_list: 菜单树列表信息 + :param plugin_menu_ids: 当前用户页面菜单的可信插件归属 :return: 路由信息树形嵌套数据 """ router_list: list[RouterModel] = [] @@ -345,7 +381,7 @@ class LoginService: if c_menus and permission.menu_type == MenuConstant.TYPE_DIR: router.always_show = True router.redirect = 'noRedirect' - router.children = cls.__generate_user_router_menu(c_menus) + router.children = cls.__generate_user_router_menu(c_menus, plugin_menu_ids) elif RouterUtil.is_menu_frame(permission): router.meta = None children_list: list[RouterModel] = [] @@ -381,6 +417,13 @@ class LoginService: children_list.append(children) router.children = children_list + plugin_id = (plugin_menu_ids or {}).get(permission.menu_id) + if plugin_id and permission.component == 'PluginFrame': + # 一级页面由 Layout 包裹,归属必须附在真正渲染 PluginFrame 的子路由。 + frame_routes = [router] if router.component == 'PluginFrame' else (router.children or []) + for frame_route in frame_routes: + if frame_route.component == 'PluginFrame' and frame_route.meta is not None: + frame_route.meta.plugin_id = plugin_id router_list.append(router) return router_list diff --git a/ruoyi-fastapi-backend/module_identity/service/key_service.py b/ruoyi-fastapi-backend/module_identity/service/key_service.py index d043882..ece3420 100644 --- a/ruoyi-fastapi-backend/module_identity/service/key_service.py +++ b/ruoyi-fastapi-backend/module_identity/service/key_service.py @@ -1,7 +1,7 @@ import asyncio import inspect import secrets -from collections.abc import AsyncIterator, Awaitable, Callable +from collections.abc import AsyncGenerator, Awaitable, Callable from contextlib import asynccontextmanager from datetime import datetime, timedelta from typing import Any, Literal, TypeVar @@ -68,7 +68,7 @@ class KeyService: @staticmethod @asynccontextmanager - async def _rotation_lock(redis: Any) -> AsyncIterator[None]: + async def _rotation_lock(redis: Any) -> AsyncGenerator[None, None]: """ 获取密钥轮换锁 diff --git a/ruoyi-fastapi-backend/module_plugin/controller/plugin_browser_controller.py b/ruoyi-fastapi-backend/module_plugin/controller/plugin_browser_controller.py new file mode 100644 index 0000000..070fc1d --- /dev/null +++ b/ruoyi-fastapi-backend/module_plugin/controller/plugin_browser_controller.py @@ -0,0 +1,74 @@ +from typing import Annotated + +from fastapi import Depends, HTTPException, Path, Request, Response +from sqlalchemy.ext.asyncio import AsyncSession + +from common.aspect.db_session import DBSessionDependency +from common.aspect.pre_auth import CurrentUserDependency +from common.router import APIRouterPro +from common.vo import DataResponseModel +from module_admin.entity.vo.user_vo import CurrentUserModel +from module_admin.service.login_service import oauth2_scheme +from plugins.core.runtime.browser_session import COOKIE_NAME_PREFIX, issue_browser_session, plugin_browser_base +from plugins.core.runtime.explicit import ExplicitPluginRuntime +from utils.response_util import ResponseUtil + +plugin_browser_controller = APIRouterPro(prefix='/plugin/runtime', tags=['插件浏览器会话']) + + +@plugin_browser_controller.post( + '/{plugin_id}/session', + summary='创建插件浏览器会话接口', + description='用于为当前登录用户创建插件页面访问会话,并返回访问地址和防跨站请求令牌', + response_model=DataResponseModel[dict], +) +async def create_plugin_browser_session( + request: Request, + plugin_id: Annotated[str, Path(description='插件ID')], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + token: Annotated[str, Depends(oauth2_scheme)], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + runtime = getattr(request.app.state, 'plugin_explicit_runtime', None) + loaded = runtime.loaded.get(plugin_id) if isinstance(runtime, ExplicitPluginRuntime) else None + if loaded is None or loaded.plugin.discovered_plugin.manifest.frontend.delivery.type != 'bundle': + raise HTTPException(status_code=404, detail='插件页面不存在') + if not loaded.active or loaded.lifespan is None or not loaded.lifespan.ready: + raise HTTPException(status_code=503, detail='插件页面尚未就绪') + try: + if not await runtime.route_state_gateway.is_plugin_enabled(query_db, plugin_id): + raise PermissionError('插件未启用') + runtime._request_context(loaded, current_user) + cookie, csrf, seconds = await issue_browser_session( + request, + plugin_id=plugin_id, + version=loaded.plugin.discovered_plugin.manifest.version, + main_token=token, + user_id=current_user.user.user_id, + ) + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + except LookupError as exc: + raise HTTPException(status_code=401, detail=str(exc)) from exc + base = plugin_browser_base(request, plugin_id) + response = ResponseUtil.success( + data={ + 'pluginId': plugin_id, + 'uiBase': f'{base}ui/', + 'apiBase': f'{base}api/', + 'bridgeVersion': 1, + 'csrfToken': csrf, + 'expiresIn': seconds, + }, + headers={'Cache-Control': 'no-store'}, + ) + response.set_cookie( + f'{COOKIE_NAME_PREFIX}{plugin_id}', + cookie, + max_age=seconds, + path=base, + httponly=True, + secure=request.url.scheme == 'https', + samesite='strict', + ) + return response diff --git a/ruoyi-fastapi-backend/module_plugin/controller/plugin_controller.py b/ruoyi-fastapi-backend/module_plugin/controller/plugin_controller.py index 9f49369..f60e507 100644 --- a/ruoyi-fastapi-backend/module_plugin/controller/plugin_controller.py +++ b/ruoyi-fastapi-backend/module_plugin/controller/plugin_controller.py @@ -451,11 +451,26 @@ async def diagnose_system_plugin( query_db: Annotated[AsyncSession, DBSessionDependency()], plugin_id: Annotated[str, Path(description='插件ID')], ) -> Response: + from plugins.core.runtime.diagnostics_query import read_plugin_runtime_diagnostics # noqa: PLC0415 + diagnose_plugin_result = await get_plugin_operation_service().diagnose_plugin_with_audit_services( query_db, plugin_id ) + application_runtime = getattr(request.app.state, 'plugin_application_runtime', None) + diagnose_plugin_result['runtime'] = await read_plugin_runtime_diagnostics( + plugin_id, + reporter=getattr(request.app.state, 'plugin_diagnostics_reporter', None), + redis=getattr(request.app.state, 'redis', None), + ready_key=getattr(application_runtime, 'ready_key', None), + ) logger.info(diagnose_plugin_result.get('message', '插件诊断包生成完成')) + if all(key in diagnose_plugin_result for key in ('info', 'check', 'config', 'menuPlan', 'audit')): + # 完整诊断包中的 ok=false 表示检查发现问题,仍需让页面读取其运行观测证据。 + return ResponseUtil.success( + msg=str(diagnose_plugin_result.get('message') or '插件诊断包生成完成'), + data=_public_plugin_payload(diagnose_plugin_result), + ) return _plugin_operation_response(diagnose_plugin_result, '插件诊断包生成完成') diff --git a/ruoyi-fastapi-backend/module_plugin/controller/plugin_metrics_controller.py b/ruoyi-fastapi-backend/module_plugin/controller/plugin_metrics_controller.py new file mode 100644 index 0000000..f26ace5 --- /dev/null +++ b/ruoyi-fastapi-backend/module_plugin/controller/plugin_metrics_controller.py @@ -0,0 +1,58 @@ +from typing import Annotated + +from fastapi import Path, Query, Request, Response + +from common.aspect.interface_auth import UserInterfaceAuthDependency +from common.aspect.pre_auth import PreAuthDependency +from common.router import APIRouterPro +from common.vo import DataResponseModel +from module_plugin.service.plugin_service import get_plugin_runtime_service +from plugins.core.runtime.configuration import build_config_status, config_revision +from plugins.core.runtime.metrics_store import PluginMetricsReporter +from utils.response_util import ResponseUtil + +plugin_metrics_controller = APIRouterPro( + prefix='/system/plugin', + order_num=6, + tags=['系统管理-插件运行观测'], + dependencies=[PreAuthDependency(), UserInterfaceAuthDependency('system:plugin:query')], +) + + +@plugin_metrics_controller.get( + '/runtime/metrics', + summary='获取插件运行指标接口', + description='用于查询显式插件的请求与任务累计指标,按实际加载版本和可观测worker分组', + response_model=DataResponseModel[dict], +) +async def get_plugin_runtime_metrics( + request: Request, + plugin_id: Annotated[ + str | None, Query(alias='pluginId', description='插件ID', pattern=r'^[a-z][a-z0-9_]{1,63}$') + ] = None, +) -> Response: + reporter = getattr(request.app.state, 'plugin_metrics_reporter', None) + if not isinstance(reporter, PluginMetricsReporter): + return ResponseUtil.success( + data={'ok': True, 'supported': False, 'scope': 'unavailable', 'workers': [], 'series': []} + ) + return ResponseUtil.success(data=await reporter.read(plugin_id)) + + +@plugin_metrics_controller.get( + '/{plugin_id}/config/status', + summary='获取插件配置生效状态接口', + description='比较数据库配置与已观测进程的启动快照;按需读取版本仅表示读取完成,不代表业务设置已经应用', + response_model=DataResponseModel[dict], +) +async def get_plugin_config_status( + request: Request, + plugin_id: Annotated[str, Path(description='插件ID', pattern=r'^[a-z][a-z0-9_]{1,63}$')], +) -> Response: + payload = await get_plugin_runtime_service().get_plugin_config(plugin_id, reveal_secret=True) + if not payload.get('ok'): + return ResponseUtil.failure(msg='插件配置状态读取失败') + values = {item['key']: item.get('value') for item in payload.get('configs', [])} + reporter = getattr(request.app.state, 'plugin_metrics_reporter', None) + report = await reporter.read(plugin_id) if isinstance(reporter, PluginMetricsReporter) else {'scope': 'unavailable'} + return ResponseUtil.success(data=build_config_status(plugin_id, config_revision(plugin_id, values), report)) diff --git a/ruoyi-fastapi-backend/module_plugin/controller/plugin_release_controller.py b/ruoyi-fastapi-backend/module_plugin/controller/plugin_release_controller.py new file mode 100644 index 0000000..8e5b31b --- /dev/null +++ b/ruoyi-fastapi-backend/module_plugin/controller/plugin_release_controller.py @@ -0,0 +1,94 @@ +from collections.abc import Awaitable, Callable +from importlib import import_module +from pathlib import Path +from typing import Annotated, Any + +from fastapi import Query, Response + +from common.aspect.interface_auth import UserInterfaceAuthDependency +from common.aspect.pre_auth import PreAuthDependency +from common.router import APIRouterPro +from common.vo import DataResponseModel +from utils.log_util import logger +from utils.response_util import ResponseUtil + +plugin_release_controller = APIRouterPro( + prefix='/system/plugin', + order_num=6, + tags=['系统管理-插件制品发布'], + dependencies=[PreAuthDependency(), UserInterfaceAuthDependency('system:plugin:query')], +) + + +def get_plugin_deployment_service() -> Any: + """ + 按需创建制品发布查询服务,功能关闭时不访问新增数据库表。 + + :return: 当前宿主配置下的制品发布服务 + :raises ValueError: 签名制品发布功能未启用或存储配置无效 + """ + config_class = import_module('plugins.core.deployment.config').PluginDeploymentConfig + config = config_class.from_settings(Path(__file__).resolve().parents[2]) + config.require_enabled() + service_class = import_module('plugins.core.deployment.service').PluginDeploymentService + return service_class(config) + + +async def _read_release_payload(operation: Callable[[], Awaitable[dict[str, Any]]]) -> Response: + """ + 统一执行发布只读查询,并转换预检失败及内部异常响应。 + + :param operation: 不接收参数并异步返回发布查询数据的操作 + :return: 成功数据或不暴露内部连接信息的失败响应 + """ + try: + payload = await operation() + except ValueError as exc: + return ResponseUtil.failure(msg=str(exc), data={'ok': False, 'status': 'blocked'}) + except Exception: + logger.exception('读取插件制品发布状态失败') + return ResponseUtil.failure(msg='读取插件制品发布状态失败') + if not payload.get('ok', True): + return ResponseUtil.failure(msg=str(payload.get('message') or '插件发布预检未通过'), data=payload) + return ResponseUtil.success(data=payload) + + +@plugin_release_controller.get( + '/artifacts/list', + summary='获取已验证插件制品列表接口', + description='用于获取已验证的插件制品列表,支持按插件ID筛选', + response_model=DataResponseModel[dict], +) +async def list_plugin_artifacts( + plugin_id: Annotated[ + str | None, Query(alias='pluginId', description='插件ID', pattern=r'^[a-z][a-z0-9_]{1,63}$') + ] = None, +) -> Response: + return await _read_release_payload(lambda: get_plugin_deployment_service().catalog.list_artifacts(plugin_id)) + + +@plugin_release_controller.get( + '/release/status', + summary='获取插件发布状态接口', + description='用于查询插件发布目标及各工作进程的实际状态,支持按插件ID筛选', + response_model=DataResponseModel[dict], +) +async def get_plugin_release_status( + plugin_id: Annotated[ + str | None, Query(alias='pluginId', description='插件ID', pattern=r'^[a-z][a-z0-9_]{1,63}$') + ] = None, +) -> Response: + return await _read_release_payload(lambda: get_plugin_deployment_service().status(plugin_id)) + + +@plugin_release_controller.get( + '/release/plan', + summary='获取插件发布预检计划接口', + description='用于根据插件ID和制品摘要获取维护发布前的静态预检计划', + response_model=DataResponseModel[dict], +) +async def plan_plugin_release( + plugin_id: Annotated[str, Query(alias='pluginId', description='插件ID', pattern=r'^[a-z][a-z0-9_]{1,63}$')], + digest: Annotated[str, Query(description='插件制品SHA256摘要', pattern=r'^[0-9a-f]{64}$')], +) -> Response: + return await _read_release_payload(lambda: get_plugin_deployment_service().plan(plugin_id, digest)) diff --git a/ruoyi-fastapi-backend/plugins/core/artifacts/__init__.py b/ruoyi-fastapi-backend/plugins/core/artifacts/__init__.py new file mode 100644 index 0000000..af7732c --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/artifacts/__init__.py @@ -0,0 +1,22 @@ +from plugins.core.artifacts.package import build_artifact, verify_artifact +from plugins.core.artifacts.schema import ( + ArtifactFile, + ArtifactLimits, + ArtifactMetadata, + ArtifactSignature, + StoredArtifact, + VerifiedArtifact, +) +from plugins.core.artifacts.store import ArtifactStore + +__all__ = [ + 'ArtifactFile', + 'ArtifactLimits', + 'ArtifactMetadata', + 'ArtifactSignature', + 'ArtifactStore', + 'StoredArtifact', + 'VerifiedArtifact', + 'build_artifact', + 'verify_artifact', +] diff --git a/ruoyi-fastapi-backend/plugins/core/artifacts/package.py b/ruoyi-fastapi-backend/plugins/core/artifacts/package.py new file mode 100644 index 0000000..1e7a895 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/artifacts/package.py @@ -0,0 +1,515 @@ +import base64 +import hashlib +import json +import os +import stat +import struct +import tempfile +from collections.abc import Mapping +from pathlib import Path +from typing import BinaryIO +from zipfile import ZIP_DEFLATED, ZIP_STORED, BadZipFile, ZipFile, ZipInfo + +import yaml +from cryptography.exceptions import InvalidSignature + +from plugins.core.artifacts.schema import ( + DEFAULT_LIMITS, + ArtifactFile, + ArtifactLimits, + ArtifactMetadata, + ArtifactSignature, + PrivateKeyInput, + PublicKeyInput, + VerifiedArtifact, + public_key, + safe_artifact_path, + strict_json, + validate_path_inventory, +) +from plugins.core.artifacts.schema import ( + private_key as load_private_key, +) +from plugins.core.manifest.schema import EXPLICIT_MANIFEST_VERSION, PluginManifest, PluginManifestFactory + +CHUNK_BYTES = 1024 * 1024 +ZIP_END = struct.Struct('<4s4H2LH') +ZIP_CENTRAL = struct.Struct('<4s6H3L5H2L') +ZIP_MAX_COMMENT_BYTES = 65535 +ZIP64_COUNT_SENTINEL = 0xFFFF +ZIP64_SIZE_SENTINEL = 0xFFFFFFFF +ROOT_DOCUMENTS = frozenset({'artifacts.json', 'signature.json'}) +CACHE_DIRECTORIES = frozenset({'__pycache__', '.pytest_cache', '.ruff_cache', '.mypy_cache'}) +FORBIDDEN_DIRECTORIES = CACHE_DIRECTORIES | { + 'node_modules', + '.git', + '.hg', + '.svn', + '.venv', + 'venv', + 'target', + '.idea', + '.vscode', +} +FORBIDDEN_FILES = frozenset( + { + 'cargo.toml', + 'cargo.lock', + 'rust-toolchain.toml', + 'package.json', + 'package-lock.json', + 'pnpm-lock.yaml', + 'yarn.lock', + 'pyproject.toml', + 'setup.py', + 'setup.cfg', + } +) +FORBIDDEN_SUFFIXES = frozenset({'.rs', '.vue', '.tsx', '.jsx', '.ts', '.pyc', '.pyo'}) + + +def is_link_or_reparse(info: os.stat_result) -> bool: + """ + 判断文件状态是否表示符号链接或 Windows 重解析点。 + + :param info: 通过 lstat 获取的文件状态信息 + :return: 是否为符号链接、目录联接或其他重解析点 + """ + return stat.S_ISLNK(info.st_mode) or bool( + getattr(info, 'st_file_attributes', 0) & getattr(stat, 'FILE_ATTRIBUTE_REPARSE_POINT', 0x400) + ) + + +def _regular_file(path: Path) -> os.stat_result: + """ + 检查路径是否为未建立额外硬链接的普通文件。 + + :param path: 待处理的文件路径 + :return: 普通文件的状态信息 + :raises ValueError: 路径为链接、特殊文件或具有额外硬链接 + """ + info = path.lstat() + if is_link_or_reparse(info) or not stat.S_ISREG(info.st_mode) or info.st_nlink > 1: + raise ValueError(f'制品不接受链接或特殊文件:{path}') + return info + + +def _payload_path(name: str) -> None: + """ + 校验部署文件路径,拒绝源码工程、环境文件和构建缓存。 + + :param name: 制品内文件的规范相对路径 + :return: None + :raises ValueError: 路径不安全或包含禁止交付的文件 + """ + path = safe_artifact_path(name) + if ( + any(part.casefold() in FORBIDDEN_DIRECTORIES for part in path.parts) + or path.name.casefold() in FORBIDDEN_FILES + or path.suffix.casefold() in FORBIDDEN_SUFFIXES + or any(part.casefold() == '.env' or part.casefold().startswith('.env.') for part in path.parts) + ): + raise ValueError(f'制品必须使用预构建部署目录,不能包含源码工程或缓存:{name}') + + +def _scan_directory( + root: Path, limits: ArtifactLimits, *, skip_caches: bool = False +) -> tuple[dict[str, Path], set[str]]: + """ + 在不跟随链接的前提下枚举目录,并检查文件类型和资源上限。 + + :param root: 待扫描的普通目录 + :param limits: 文件数量、大小及压缩容器的资源上限 + :param skip_caches: 是否在构建阶段跳过 Python 和测试缓存 + :return: 相对路径到文件路径的映射,以及扫描到的相对目录集合 + :raises ValueError: 目录、文件类型、路径或资源用量不符合制品约束 + """ + root_info = root.lstat() + if is_link_or_reparse(root_info) or not stat.S_ISDIR(root_info.st_mode): + raise ValueError('制品目录必须是普通目录,不能是链接或重解析点') + files: dict[str, Path] = {} + directories: set[str] = set() + pending = [root] + total_bytes = 0 + while pending: + directory = pending.pop() + with os.scandir(directory) as entries: + for entry in entries: + if skip_caches and ( + entry.name.casefold() in CACHE_DIRECTORIES or Path(entry.name).suffix.casefold() in {'.pyc', '.pyo'} + ): + continue + path = Path(entry.path) + relative = path.relative_to(root).as_posix() + safe_artifact_path(relative) + # Windows DirEntry.stat 的 st_nlink 可为 0;lstat 才提供硬链接计数。 + info = path.lstat() + if is_link_or_reparse(info): + raise ValueError(f'制品目录包含链接或重解析点:{relative}') + if stat.S_ISDIR(info.st_mode): + directories.add(relative) + pending.append(path) + elif stat.S_ISREG(info.st_mode) and info.st_nlink == 1: + files[relative] = path + total_bytes += info.st_size + if info.st_size > limits.max_file_bytes: + raise ValueError('制品单文件大小超过限制') + else: + raise ValueError(f'制品目录包含特殊文件或硬链接:{relative}') + if len(files) > limits.max_files or total_bytes > limits.max_total_bytes: + raise ValueError('制品文件数量或总大小超过限制') + if len(directories) > limits.max_files * 32: + raise ValueError('制品目录数量超过限制') + validate_path_inventory(tuple(files)) + return files, directories + + +def _read_limited(stream: BinaryIO, maximum: int) -> bytes: + """ + 在指定字节上限内读取完整文档。 + + :param stream: 当前位置开始读取的二进制输入流 + :param maximum: 允许读取的最大字节数 + :return: 已读取的文档字节 + :raises ValueError: 文档实际大小超过读取上限 + """ + data = stream.read(maximum + 1) + if len(data) > maximum: + raise ValueError('制品文档大小超过限制') + return data + + +def _hash_stream(stream: BinaryIO, maximum: int, output: BinaryIO | None = None) -> tuple[int, str]: + """ + 分块读取内容并计算大小和 SHA256,可同时复制到输出流。 + + :param stream: 当前位置开始读取的二进制输入流 + :param maximum: 允许读取的最大字节数 + :param output: 可选的二进制输出流,为 None 时只计算摘要 + :return: 实际读取的字节数与小写 SHA256 摘要 + :raises ValueError: 文件实际大小超过读取上限 + """ + hasher = hashlib.sha256() + size = 0 + for chunk in iter(lambda: stream.read(CHUNK_BYTES), b''): + size += len(chunk) + if size > maximum: + raise ValueError('制品文件实际大小超过限制') + hasher.update(chunk) + if output is not None: + output.write(chunk) + return size, hasher.hexdigest() + + +def _preflight_zip(path: Path, limits: ArtifactLimits) -> None: + """ + 在创建 ZipFile 对象前流式检查中央目录,限制一次性元数据分配。 + + :param path: 待处理的文件路径 + :param limits: 文件数量、大小及压缩容器的资源上限 + :return: None + :raises ValueError: ZIP 结束记录、中央目录或容器类型不受支持 + """ + with path.open('rb') as stream: + size = stream.seek(0, os.SEEK_END) + tail_size = min(size, ZIP_END.size + ZIP_MAX_COMMENT_BYTES) + stream.seek(size - tail_size) + tail = stream.read(tail_size) + marker = tail.rfind(b'PK\x05\x06') + if marker < 0 or len(tail) - marker < ZIP_END.size: + raise ValueError('制品 ZIP 缺少有效结束记录') + _, disk, central_disk, disk_count, count, central_size, offset, comment_size = ZIP_END.unpack_from(tail, marker) + if marker + ZIP_END.size + comment_size != len(tail): + raise ValueError('制品 ZIP 结束记录或注释长度无效') + if disk or central_disk or disk_count != count: + raise ValueError('制品 ZIP 不支持分卷容器') + if count == ZIP64_COUNT_SENTINEL or ZIP64_SIZE_SENTINEL in {central_size, offset}: + raise ValueError('制品 ZIP 不支持 ZIP64 容器') + central_end = size - tail_size + marker + if offset + central_size != central_end or central_size > limits.max_central_directory_bytes: + raise ValueError('制品 ZIP 中央目录位置或大小无效') + if count > limits.max_files: + raise ValueError('制品 ZIP 文件数量超过限制') + stream.seek(offset) + _preflight_entries(stream, central_end, count, limits) + + +def _preflight_entries(stream: BinaryIO, central_end: int, count: int, limits: ArtifactLimits) -> None: + """ + 逐条检查 ZIP 中央目录记录,并核对实际数量与结束位置。 + + :param stream: 当前位置开始读取的二进制输入流 + :param central_end: 中央目录结束位置的绝对字节偏移 + :param count: ZIP 结束记录声明的条目数量 + :param limits: 文件数量、大小及压缩容器的资源上限 + :return: None + :raises ValueError: 中央目录记录不完整、越界或实际数量不匹配 + """ + actual_count = 0 + while stream.tell() < central_end: + if central_end - stream.tell() < ZIP_CENTRAL.size: + raise ValueError('制品 ZIP 中央目录记录不完整') + header = ZIP_CENTRAL.unpack(stream.read(ZIP_CENTRAL.size)) + if header[0] != b'PK\x01\x02' or header[13]: + raise ValueError('制品 ZIP 中央目录记录无效') + if ZIP64_SIZE_SENTINEL in {header[8], header[9], header[16]}: + raise ValueError('制品 ZIP 不支持 ZIP64 文件') + actual_count += 1 + if actual_count > limits.max_files: + raise ValueError('制品 ZIP 实际文件数量超过限制') + next_position = stream.tell() + sum(header[10:13]) + if next_position > central_end: + raise ValueError('制品 ZIP 中央目录字段越界') + stream.seek(next_position) + if actual_count != count: + raise ValueError('制品 ZIP 中央目录文件数量不一致') + + +def _zip_inventory(archive: ZipFile, limits: ArtifactLimits) -> dict[str, ZipInfo]: + """ + 校验 ZIP 文件类型、路径和资源上限,并建立文件清单。 + + :param archive: 已打开的 ZIP 压缩容器 + :param limits: 文件数量、大小及压缩容器的资源上限 + :return: 安全相对文件路径与 ZIP 条目的映射 + :raises ValueError: ZIP 条目不安全、超过限制或缺少必要文档 + """ + entries = archive.infolist() + if len(entries) > limits.max_files or sum(entry.file_size for entry in entries) > limits.max_total_bytes: + raise ValueError('制品 ZIP 文件数量或展开大小超过限制') + files: dict[str, ZipInfo] = {} + directories: set[str] = set() + names: set[str] = set() + for entry in entries: + if entry.orig_filename != entry.filename: + raise ValueError('制品 ZIP 文件名包含截断字符') + name = entry.filename[:-1] if entry.is_dir() else entry.filename + safe_artifact_path(name) + if name.casefold() in names: + raise ValueError(f'制品 ZIP 包含重复或大小写冲突路径:{name}') + names.add(name.casefold()) + mode = entry.external_attr >> 16 + kind = stat.S_IFMT(mode) + if ( + kind not in {0, stat.S_IFREG, stat.S_IFDIR} + or bool(entry.external_attr & 0x400) + or (kind == stat.S_IFDIR and not entry.is_dir()) + or (kind == stat.S_IFREG and entry.is_dir()) + ): + raise ValueError('制品 ZIP 不能包含链接、重解析点或特殊文件') + if entry.flag_bits & 1 or entry.compress_type not in {ZIP_STORED, ZIP_DEFLATED}: + raise ValueError('制品 ZIP 不支持加密或该压缩算法') + if ( + entry.file_size > limits.max_file_bytes + or entry.file_size > max(1, entry.compress_size) * limits.max_compression_ratio + ): + raise ValueError('制品 ZIP 单文件大小或压缩比超过限制') + if entry.is_dir(): + if entry.file_size: + raise ValueError('制品 ZIP 目录不能携带数据') + directories.add(name) + else: + files[name] = entry + validate_path_inventory(tuple(files), directories) + if not ROOT_DOCUMENTS.issubset(files): + raise ValueError('制品缺少 artifacts.json 或 signature.json') + return files + + +def _verify_documents( + metadata_bytes: bytes, signature_bytes: bytes, trusted_keys: Mapping[str, PublicKeyInput], limits: ArtifactLimits +) -> tuple[ArtifactMetadata, ArtifactSignature, str]: + """ + 解析制品元数据和签名文档,并使用宿主可信公钥验签。 + + :param metadata_bytes: artifacts.json 的原始内容 + :param signature_bytes: signature.json 的原始内容 + :param trusted_keys: 由宿主提供的签名密钥标识与可信公钥映射 + :param limits: 文件数量、大小及压缩容器的资源上限 + :return: 制品元数据、签名信息和规范元数据摘要 + :raises ValueError: 元数据、签名、信任关系或资源声明校验失败 + """ + if len(metadata_bytes) > limits.max_metadata_bytes or len(signature_bytes) > limits.max_signature_bytes: + raise ValueError('制品元数据或签名文档超过大小限制') + metadata = ArtifactMetadata.model_validate(strict_json(metadata_bytes)) + signature = ArtifactSignature.model_validate(strict_json(signature_bytes)) + if len(metadata.files) > limits.max_files or sum(item.size for item in metadata.files) > limits.max_total_bytes: + raise ValueError('制品清单文件数量或总大小超过限制') + for item in metadata.files: + _payload_path(item.path) + if item.size > limits.max_file_bytes: + raise ValueError('制品清单单文件大小超过限制') + if signature.key_id not in trusted_keys: + raise ValueError(f'未受信任或已撤销的制品签名密钥:{signature.key_id}') + canonical = metadata.canonical_bytes() + try: + public_key(trusted_keys[signature.key_id]).verify(signature.signature_bytes(), canonical) + except InvalidSignature as exc: + raise ValueError('制品签名验证失败') from exc + return metadata, signature, hashlib.sha256(canonical).hexdigest() + + +def _verify_manifest(data: bytes, metadata: ArtifactMetadata, limits: ArtifactLimits) -> PluginManifest: + """ + 静态解析插件清单,检查协议、身份、版本及前端交付类型。 + + :param data: plugin.yaml 的原始 UTF-8 内容 + :param metadata: 签名覆盖的制品元数据 + :param limits: 文件数量、大小及压缩容器的资源上限 + :return: 通过校验的插件清单对象 + :raises ValueError: 插件清单无效、身份不匹配或使用不支持的交付协议 + """ + if len(data) > limits.max_manifest_bytes: + raise ValueError('plugin.yaml 大小超过限制') + try: + raw = yaml.safe_load(data.decode('utf-8')) + except (UnicodeError, yaml.YAMLError) as exc: + raise ValueError('制品 plugin.yaml 无效') from exc + if not isinstance(raw, dict) or raw.get('manifestVersion') != EXPLICIT_MANIFEST_VERSION: + raise ValueError('签名制品仅支持 manifestVersion 2') + manifest = PluginManifestFactory.create(raw) + if manifest.id != metadata.plugin_id or manifest.version != metadata.version: + raise ValueError('制品身份或版本与 plugin.yaml 不一致') + if manifest.frontend.delivery.type not in {'none', 'bundle'}: + raise ValueError('签名制品仅支持 none/bundle,不能交付 source 前端') + return manifest + + +def _verify_archive( + archive: ZipFile, artifact_path: Path, trusted_keys: Mapping[str, PublicKeyInput], limits: ArtifactLimits +) -> VerifiedArtifact: + """ + 校验签名、完整文件清单及逐文件摘要,生成只读验证结果。 + + :param archive: 已打开的 ZIP 压缩容器 + :param artifact_path: 当前受验证制品容器的绝对路径 + :param trusted_keys: 由宿主提供的签名密钥标识与可信公钥映射 + :param limits: 文件数量、大小及压缩容器的资源上限 + :return: 完成全部静态校验的制品信息 + :raises ValueError: 文件清单、签名或实际文件内容不一致 + """ + files = _zip_inventory(archive, limits) + with archive.open(files['artifacts.json']) as source: + metadata_bytes = _read_limited(source, limits.max_metadata_bytes) + with archive.open(files['signature.json']) as source: + signature_bytes = _read_limited(source, limits.max_signature_bytes) + metadata, signature, digest = _verify_documents(metadata_bytes, signature_bytes, trusted_keys, limits) + if set(files) != ROOT_DOCUMENTS | {item.path for item in metadata.files}: + raise ValueError('制品文件库存与签名清单不一致,存在额外或缺失文件') + for item in metadata.files: + if files[item.path].file_size != item.size: + raise ValueError(f'制品文件大小不匹配:{item.path}') + with archive.open(files[item.path]) as source: + size, actual = _hash_stream(source, item.size) + if size != item.size or actual != item.sha256: + raise ValueError(f'制品文件 SHA256 或大小不匹配:{item.path}') + with archive.open(files[f'payload/{metadata.plugin_id}/plugin.yaml']) as source: + manifest = _verify_manifest(_read_limited(source, limits.max_manifest_bytes), metadata, limits) + return VerifiedArtifact( + metadata.plugin_id, + metadata.version, + digest, + signature.key_id, + metadata.files, + metadata, + signature, + manifest, + artifact_path, + ) + + +def verify_artifact( + path: Path | str, trusted_keys: Mapping[str, PublicKeyInput], *, limits: ArtifactLimits = DEFAULT_LIMITS +) -> VerifiedArtifact: + """ + 只读校验制品签名、清单和全部文件摘要,不安装依赖或导入插件代码。 + + :param path: 待处理的文件路径 + :param trusted_keys: 由宿主提供的签名密钥标识与可信公钥映射 + :param limits: 文件数量、大小及压缩容器的资源上限 + :return: 完成全部静态校验的制品信息 + :raises ValueError: 制品容器、签名、清单或文件内容校验失败 + """ + path = Path(path).absolute() + if _regular_file(path).st_size > limits.max_archive_bytes: + raise ValueError('制品容器大小超过限制') + try: + _preflight_zip(path, limits) + with ZipFile(path) as archive: + return _verify_archive(archive, path, trusted_keys, limits) + except (BadZipFile, struct.error) as exc: + raise ValueError('制品 ZIP 无效或 CRC 校验失败') from exc + + +def build_artifact( + source_plugin_dir: Path | str, + output_file: Path | str, + private_key: PrivateKeyInput, + key_id: str, + *, + limits: ArtifactLimits = DEFAULT_LIMITS, +) -> VerifiedArtifact: + """ + 从预构建部署目录签名打包,不覆盖已有输出,仅忽略 Python 和测试缓存。 + + :param source_plugin_dir: 目录名与插件ID一致的预构建部署目录 + :param output_file: 待创建的 rpk 文件路径,必须位于源目录之外 + :param private_key: 仅在当前构建进程内使用的 Ed25519 签名私钥 + :param key_id: 签名公钥在宿主信任配置中的标识 + :param limits: 文件数量、大小及压缩容器的资源上限 + :return: 构建完成并重新校验的制品信息 + :raises ValueError: 部署内容不合法、超过限制或在构建期间发生变化 + :raises FileExistsError: 输出路径已经存在,无法安全创建新制品 + """ + source = Path(source_plugin_dir).absolute() + files, _ = _scan_directory(source, limits, skip_caches=True) + if 'plugin.yaml' not in files: + raise ValueError('预构建插件目录缺少 plugin.yaml') + with files['plugin.yaml'].open('rb') as stream: + raw_manifest = _read_limited(stream, limits.max_manifest_bytes) + raw = yaml.safe_load(raw_manifest.decode('utf-8')) + if not isinstance(raw, dict): + raise ValueError('plugin.yaml 必须是对象') + records = [] + for relative, path in sorted(files.items()): + _payload_path(relative) + with path.open('rb') as stream: + size, digest = _hash_stream(stream, limits.max_file_bytes) + records.append(ArtifactFile(path=f'payload/{source.name}/{relative}', size=size, sha256=digest)) + metadata = ArtifactMetadata(schemaVersion=1, pluginId=source.name, version=raw.get('version'), files=tuple(records)) + _verify_manifest(raw_manifest, metadata, limits) + key = load_private_key(private_key) + signature = ArtifactSignature( + algorithm='Ed25519', + keyId=key_id, + signature=base64.b64encode(key.sign(metadata.canonical_bytes())).decode(), + ) + output = Path(output_file).absolute() + if output.exists() or output.is_symlink(): + raise FileExistsError('制品输出已经存在,拒绝覆盖') + if output.is_relative_to(source): + raise ValueError('制品输出不能位于源插件目录内') + output.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp(prefix='.rpk-build-', dir=output.parent) + os.close(descriptor) + temporary = Path(temporary_name) + try: + with ZipFile(temporary, 'w', compression=ZIP_DEFLATED) as archive: + archive.writestr('artifacts.json', metadata.canonical_bytes()) + archive.writestr('signature.json', json.dumps(signature.model_dump(by_alias=True), separators=(',', ':'))) + for item in records: + relative = item.path.removeprefix(f'payload/{source.name}/') + path = files[relative] + _regular_file(path) + if path.resolve() != path or not path.resolve().is_relative_to(source.resolve()): + raise ValueError('构建期间插件资源路径发生变化') + with path.open('rb') as stream, archive.open(item.path, 'w') as target: + size, digest = _hash_stream(stream, item.size, target) + if (size, digest) != (item.size, item.sha256): + raise ValueError('构建期间插件资源内容发生变化') + verify_artifact(temporary, {key_id: key.public_key()}, limits=limits) + # 硬链接创建具备跨平台的 O_EXCL 语义,避免 POSIX rename 覆盖已有文件。 + os.link(temporary, output) + finally: + temporary.unlink(missing_ok=True) + return verify_artifact(output, {key_id: key.public_key()}, limits=limits) diff --git a/ruoyi-fastapi-backend/plugins/core/artifacts/schema.py b/ruoyi-fastapi-backend/plugins/core/artifacts/schema.py new file mode 100644 index 0000000..c1f63ff --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/artifacts/schema.py @@ -0,0 +1,394 @@ +import base64 +import json +import re +import unicodedata +from dataclasses import dataclass +from pathlib import Path, PurePosixPath, PureWindowsPath +from typing import Any, Literal + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from plugins.core.manifest.schema import PluginManifest +from plugins.core.utils import validate_plugin_id_value + +DIGEST_PATTERN = re.compile(r'^[a-f0-9]{64}$') +KEY_ID_PATTERN = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$') +ED25519_KEY_BYTES = 32 +ED25519_SIGNATURE_BYTES = 64 +FIRST_PRINTABLE_CHARACTER = 32 +DELETE_CHARACTER = 127 +MAX_PATH_COMPONENTS = 32 +MAX_PATH_LENGTH = 1024 +MAX_COMPONENT_LENGTH = 255 + +PublicKeyInput = Ed25519PublicKey | bytes | str +PrivateKeyInput = Ed25519PrivateKey | bytes | str + + +@dataclass(frozen=True) +class ArtifactLimits: + """ + 制品压缩容器、元数据和实际展开内容的独立资源上限。 + """ + + max_files: int = 10000 + max_file_bytes: int = 256 * 1024 * 1024 + max_total_bytes: int = 512 * 1024 * 1024 + max_archive_bytes: int = 600 * 1024 * 1024 + max_central_directory_bytes: int = 16 * 1024 * 1024 + max_metadata_bytes: int = 4 * 1024 * 1024 + max_signature_bytes: int = 16 * 1024 + max_manifest_bytes: int = 1024 * 1024 + max_compression_ratio: int = 200 + + +DEFAULT_LIMITS = ArtifactLimits() + + +def safe_artifact_path(value: str) -> PurePosixPath: + """ + 校验跨平台相对路径,拒绝设备名、目录穿越和不可移植字符。 + + :param value: 待校验的制品相对路径 + :return: 通过校验的 POSIX 相对路径 + :raises ValueError: 路径不符合跨平台安全约束 + """ + parts = value.split('/') + if ( + not value + or len(value) > MAX_PATH_LENGTH + or len(parts) > MAX_PATH_COMPONENTS + or unicodedata.normalize('NFC', value) != value + or any( + part in {'', '.', '..'} + or len(part) > MAX_COMPONENT_LENGTH + or part.endswith((' ', '.')) + or PureWindowsPath(part).is_reserved() + or any( + char in '<>:"\\|?*' or ord(char) < FIRST_PRINTABLE_CHARACTER or ord(char) == DELETE_CHARACTER + for char in part + ) + for part in parts + ) + ): + raise ValueError(f'制品包含不安全路径:{value!r}') + return PurePosixPath(value) + + +def validate_path_inventory(files: list[str] | tuple[str, ...], directories: set[str] | None = None) -> None: + """ + 按路径组件检查重复文件、大小写歧义及文件与目录冲突。 + + :param files: 全部文件的相对路径列表 + :param directories: 压缩容器显式声明的目录集合,可不提供 + :return: None + :raises ValueError: 文件或目录存在重复、大小写歧义或层级冲突 + """ + all_spellings: dict[str, str] = {} + file_keys: set[str] = set() + directory_keys: set[str] = set() + for name in files: + parts = safe_artifact_path(name).parts + key = name.casefold() + if key in file_keys: + raise ValueError(f'制品包含重复文件或大小写冲突:{name}') + file_keys.add(key) + for index in range(1, len(parts) + 1): + part_path = '/'.join(parts[:index]) + previous = all_spellings.setdefault(part_path.casefold(), part_path) + if previous != part_path: + raise ValueError(f'制品包含目录大小写冲突:{name}') + if index < len(parts): + directory_keys.add(part_path.casefold()) + if file_keys & directory_keys: + raise ValueError('制品存在文件与父目录冲突') + for directory in directories or set(): + safe_artifact_path(directory) + if directory.casefold() not in directory_keys or all_spellings.get(directory.casefold()) != directory: + raise ValueError(f'制品包含多余或冲突目录:{directory}') + + +def strict_json(data: bytes) -> dict[str, Any]: + """ + 解析 UTF-8 JSON 对象,在所有层级拒绝重复键和非有限数值。 + + :param data: 待解析的原始字节内容 + :return: 通过严格解析的 JSON 对象 + :raises ValueError: 内容不是有效的 UTF-8 JSON 对象,或包含重复键及非法常量 + """ + + def object_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + """ + 将 JSON 键值对转换为字典,并拒绝重复字段。 + + :param pairs: 按原始顺序提供的 JSON 对象键值对 + :return: 字段名唯一的字典 + :raises ValueError: 同一 JSON 对象中存在重复字段 + """ + result = {} + for key, value in pairs: + if key in result: + raise ValueError(f'制品 JSON 包含重复键:{key}') + result[key] = value + return result + + def invalid_constant(value: str) -> None: + """ + 拒绝 JSON 中不属于标准数值范围的常量。 + + :param value: JSON 解析器识别到的非法常量文本 + :return: None + :raises ValueError: 遇到 NaN、Infinity 等非标准 JSON 常量 + """ + raise ValueError(f'制品 JSON 包含非法常量:{value}') + + try: + value = json.loads(data.decode('utf-8'), object_pairs_hook=object_pairs, parse_constant=invalid_constant) + except (UnicodeError, RecursionError, json.JSONDecodeError) as exc: + raise ValueError('制品 JSON 不是有效的 UTF-8 对象') from exc + if not isinstance(value, dict): + raise ValueError('制品 JSON 必须为对象') + return value + + +class ArtifactFile(BaseModel): + """ + 签名文件记录,路径相对于 rpk 根目录并位于自身 payload 目录中。 + """ + + model_config = ConfigDict(strict=True, extra='forbid', frozen=True) + + path: str + size: int = Field(ge=0) + sha256: str = Field(pattern=r'^[a-f0-9]{64}$') + + @field_validator('path') + @classmethod + def validate_path(cls, value: str) -> str: + """ + 校验签名文件记录中的相对路径。 + + :param value: 待校验的制品相对路径 + :return: 通过校验的原始路径字符串 + :raises ValueError: 路径不符合跨平台安全约束 + """ + safe_artifact_path(value) + return value + + +class ArtifactMetadata(BaseModel): + """ + 签名覆盖的制品元数据,字段及文件列表顺序共同确定签名内容。 + """ + + model_config = ConfigDict(strict=True, extra='forbid', frozen=True) + + schema_version: Literal[1] = Field(alias='schemaVersion') + plugin_id: str = Field(alias='pluginId') + version: str = Field(min_length=1, max_length=32, pattern=r'^[0-9][a-z0-9._+\-]*$') + files: tuple[ArtifactFile, ...] + + @field_validator('schema_version', mode='before') + @classmethod + def validate_schema_version(cls, value: Any) -> int: + """ + 要求协议版本为整数1,拒绝布尔值和数值类型转换。 + + :param value: 模型收到的原始协议版本值 + :return: 已确认的协议版本1 + :raises ValueError: 协议版本不是整数1 + """ + if type(value) is not int or value != 1: + raise ValueError('schemaVersion 必须为整数 1') + return value + + @field_validator('plugin_id') + @classmethod + def validate_plugin_id(cls, value: str) -> str: + """ + 校验制品所属插件的标识格式。 + + :param value: 待校验的插件ID + :return: 通过校验的插件ID + """ + return validate_plugin_id_value(value) + + @field_validator('version') + @classmethod + def validate_version(cls, value: str) -> str: + """ + 校验制品版本能安全用作跨平台目录名。 + + :param value: 待校验的插件版本 + :return: 通过校验的版本字符串 + """ + safe_artifact_path(value) + return value + + @field_validator('files', mode='before') + @classmethod + def parse_files(cls, value: Any) -> Any: + """ + 将 JSON 文件列表转换为不可变元组供后续模型校验。 + + :param value: 模型收到的原始文件列表字段 + :return: 列表转换后的元组,其他输入保持原值 + """ + return tuple(value) if isinstance(value, list) else value + + @model_validator(mode='after') + def validate_files(self) -> 'ArtifactMetadata': + """ + 检查文件均属于当前插件,包含清单且不存在路径冲突。 + + :return: 完成文件清单校验的当前元数据对象 + :raises ValueError: 文件归属、必要清单或路径清单不符合制品约束 + """ + paths = tuple(file.path for file in self.files) + prefix = f'payload/{self.plugin_id}/' + if not paths or any(not path.startswith(prefix) for path in paths): + raise ValueError('制品文件必须全部位于自身 payload// 目录') + if f'{prefix}plugin.yaml' not in paths: + raise ValueError('制品缺少 plugin.yaml') + validate_path_inventory(paths) + return self + + def canonical_bytes(self) -> bytes: + """ + 序列化规范 JSON,供签名验证和制品摘要计算共同使用。 + + :return: 字段排序且不包含额外空白的 UTF-8 JSON 字节 + """ + return json.dumps( + self.model_dump(mode='json', by_alias=True), + sort_keys=True, + separators=(',', ':'), + ensure_ascii=False, + allow_nan=False, + ).encode('utf-8') + + +class ArtifactSignature(BaseModel): + """ + Ed25519 签名记录,密钥标识仅用于查询宿主提供的信任配置。 + """ + + model_config = ConfigDict(strict=True, extra='forbid', frozen=True) + + algorithm: Literal['Ed25519'] + key_id: str = Field(alias='keyId', pattern=KEY_ID_PATTERN.pattern) + signature: str + + def signature_bytes(self) -> bytes: + """ + 严格解码标准 Base64 签名并检查 Ed25519 签名长度。 + + :return: 64字节的 Ed25519 签名内容 + :raises ValueError: 签名编码或长度不合法 + """ + try: + signature = base64.b64decode(self.signature, validate=True) + except ValueError as exc: + raise ValueError('制品签名必须为标准 Base64') from exc + if len(signature) != ED25519_SIGNATURE_BYTES or base64.b64encode(signature).decode() != self.signature: + raise ValueError('制品 Ed25519 签名长度或编码不合法') + return signature + + +def public_key(value: PublicKeyInput) -> Ed25519PublicKey: + """ + 加载 Ed25519 公钥,支持密钥对象、原始字节、PEM 和 Base64。 + + :param value: 公钥对象、32字节原始公钥、PEM 或原始公钥的 Base64 文本 + :return: 可用于验签的 Ed25519 公钥对象 + :raises ValueError: 公钥格式无法解析或不是 Ed25519 公钥 + """ + if isinstance(value, Ed25519PublicKey): + return value + data = _key_bytes(value) + key = ( + Ed25519PublicKey.from_public_bytes(data) + if len(data) == ED25519_KEY_BYTES + else serialization.load_pem_public_key(data) + ) + if not isinstance(key, Ed25519PublicKey): + raise ValueError('制品可信公钥必须为 Ed25519') + return key + + +def private_key(value: PrivateKeyInput) -> Ed25519PrivateKey: + """ + 在内存中加载 Ed25519 签名私钥,不写入制品或目标目录。 + + :param value: 私钥对象、32字节原始私钥、未加密 PEM 或原始私钥的 Base64 文本 + :return: 可用于签名的 Ed25519 私钥对象 + :raises ValueError: 私钥格式无法解析或不是 Ed25519 私钥 + """ + if isinstance(value, Ed25519PrivateKey): + return value + data = _key_bytes(value) + key = ( + Ed25519PrivateKey.from_private_bytes(data) + if len(data) == ED25519_KEY_BYTES + else serialization.load_pem_private_key(data, password=None) + ) + if not isinstance(key, Ed25519PrivateKey): + raise ValueError('制品签名私钥必须为 Ed25519') + return key + + +def _key_bytes(value: bytes | str) -> bytes: + """ + 规范化密钥字节输入,解析 PEM 文本或严格 Base64 编码。 + + :param value: 密钥原始字节、PEM 文本或 Base64 文本 + :return: 原始密钥字节或 PEM 文本对应的字节 + :raises ValueError: 密钥输入类型或 Base64 编码不合法 + """ + if isinstance(value, bytes): + return value + if not isinstance(value, str): + raise ValueError('制品密钥类型无效') + if value.startswith('-----BEGIN '): + return value.encode('ascii') + try: + return base64.b64decode(value, validate=True) + except ValueError as exc: + raise ValueError('制品密钥必须为 PEM 或原始密钥的 Base64') from exc + + +@dataclass(frozen=True) +class VerifiedArtifact: + """ + 完成静态校验的容器信息,不替代文件变化后的重新验证。 + """ + + plugin_id: str + version: str + digest: str + key_id: str + files: tuple[ArtifactFile, ...] + metadata: ArtifactMetadata + signature: ArtifactSignature + manifest: PluginManifest + artifact_path: Path + + +@dataclass(frozen=True) +class StoredArtifact: + """ + 完成验证的不可变存储信息,供平台预检与发布服务读取。 + """ + + plugin_id: str + version: str + digest: str + key_id: str + files: tuple[ArtifactFile, ...] + metadata: ArtifactMetadata + signature: ArtifactSignature + manifest: PluginManifest + root_path: Path + plugin_path: Path diff --git a/ruoyi-fastapi-backend/plugins/core/artifacts/store.py b/ruoyi-fastapi-backend/plugins/core/artifacts/store.py new file mode 100644 index 0000000..b421f61 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/artifacts/store.py @@ -0,0 +1,348 @@ +import errno +import inspect +import os +import shutil +import stat +import tempfile +import time +from collections.abc import Callable, Iterator, Mapping +from contextlib import contextmanager +from dataclasses import replace +from pathlib import Path +from zipfile import ZipFile + +from plugins.core.artifacts.package import ( + ROOT_DOCUMENTS, + _hash_stream, + _read_limited, + _regular_file, + _scan_directory, + _verify_documents, + _verify_manifest, + is_link_or_reparse, + verify_artifact, +) +from plugins.core.artifacts.schema import ( + DEFAULT_LIMITS, + DIGEST_PATTERN, + ArtifactLimits, + PublicKeyInput, + StoredArtifact, + VerifiedArtifact, + safe_artifact_path, + validate_path_inventory, +) + +LOCK_TIMEOUT_SECONDS = 30.0 +LOCK_RETRY_SECONDS = 0.025 +STORED_ROOT_PARTS = 3 +STORED_PLUGIN_PARTS = 5 + + +class ArtifactStore: + """ + 不可变制品存储,每次使用时重新检查签名和内容。 + """ + + def __init__(self, root: Path | str, *, limits: ArtifactLimits = DEFAULT_LIMITS) -> None: + """ + 初始化制品存储,并检查根目录及其父路径。 + + :param root: 不可变制品存储的根目录 + :param limits: 文件数量、大小及压缩容器的资源上限 + :return: None + """ + self.root = Path(os.path.abspath(root)) + self.limits = limits + self._assert_real_path(self.root) + + @staticmethod + def _assert_real_path(path: Path) -> None: + """ + 从文件系统根逐级检查路径,拒绝符号链接和 Windows 重解析点。 + + :param path: 待处理的文件路径 + :return: None + :raises ValueError: 路径本身或某一级父路径为链接或重解析点 + """ + for current in reversed([path, *path.parents]): + try: + info = current.lstat() + except FileNotFoundError: + continue + if is_link_or_reparse(info): + raise ValueError(f'制品存储路径不能包含链接或重解析点:{current}') + + def _mkdir(self, path: Path) -> None: + """ + 校验父路径后创建普通目录,并重新检查目录边界。 + + :param path: 待创建的存储内部目录路径 + :return: None + """ + if not path.is_relative_to(self.root): + raise ValueError('制品存储路径越界') + self._assert_real_path(path) + path.mkdir(parents=True, exist_ok=True) + self._assert_real_path(path) + if not path.is_dir(): + raise ValueError('制品存储路径不是目录') + + def import_artifact( + self, + path: Path | str, + trusted_keys: Mapping[str, PublicKeyInput], + *, + validate_candidate: Callable[[StoredArtifact], object] | None = None, + ) -> StoredArtifact: + """ + 建立私有快照并验签,完成候选校验后发布到不可变目录。 + + 候选检查只用于平台、ABI 或 RECORD 等静态验证,不执行插件代码。 + 目标已经存在时仍重新验签、核对全部字节并执行候选检查,保持目标内容不变。 + + :param path: 待导入的 rpk 文件路径 + :param trusted_keys: 由宿主提供的签名密钥标识与可信公钥映射 + :param validate_candidate: 可选的同步只读检查回调,返回 False 或抛出异常时拒绝发布 + :return: 已发布或幂等复用的制品存储对象 + :raises ValueError: 制品、存储布局或候选检查不符合发布要求 + :raises TypeError: 候选检查回调返回异步对象 + """ + self._mkdir(self.root) + staging_root = self.root / '.staging' + self._mkdir(staging_root) + temporary = Path(tempfile.mkdtemp(prefix='import-', dir=staging_root)) + try: + snapshot = temporary / 'received.rpk' + self._snapshot(Path(path), snapshot) + verified = verify_artifact(snapshot, trusted_keys, limits=self.limits) + candidate = temporary / 'object' + self._extract(snapshot, candidate, verified) + staged = self._verify_directory(candidate, trusted_keys) + if staged.digest != verified.digest: + raise ValueError('制品展开结果与签名容器不一致') + self._validate_candidate(staged, trusted_keys, validate_candidate) + destination = self.root / staged.plugin_id / staged.version / staged.digest + self._mkdir(destination.parent) + with self._digest_lock(staged.digest): + self._assert_real_path(destination) + if destination.exists(): + existing = self.verify_stored(destination, trusted_keys) + self._validate_candidate(existing, trusted_keys, validate_candidate) + return existing + # 锁涵盖目标检查与 rename;同一 digest 的其他导入不会覆盖或读取半成品。 + candidate.rename(destination) + return replace( + staged, + root_path=destination, + plugin_path=destination / 'payload' / staged.plugin_id, + ) + finally: + # 只删除本次创建的临时目录;再次验证绝对目标属于预期 staging 边界。 + self._assert_real_path(temporary) + if temporary.exists() and temporary.resolve().is_relative_to(staging_root.resolve()): + shutil.rmtree(temporary) + + def _snapshot(self, source: Path, target: Path) -> None: + """ + 将输入容器复制到本次导入的私有文件,并检查文件类型与大小。 + + :param source: 待复制内容的源文件路径 + :param target: 本次导入独占的容器快照路径 + :return: None + """ + if _regular_file(source).st_size > self.limits.max_archive_bytes: + raise ValueError('制品容器大小超过限制') + with source.open('rb') as incoming, target.open('xb') as output: + _hash_stream(incoming, self.limits.max_archive_bytes, output) + + def _extract(self, snapshot: Path, candidate: Path, verified: VerifiedArtifact) -> None: + """ + 将已验证容器展开到本次创建的候选目录。 + + :param snapshot: 已经完成验签的私有容器快照路径 + :param candidate: 本次导入独占的候选根目录 + :param verified: 私有容器快照对应的验证结果 + :return: None + """ + candidate.mkdir() + expected = {item.path: item for item in verified.files} + with ZipFile(snapshot) as archive: + for name in sorted(ROOT_DOCUMENTS | set(expected)): + target = candidate.joinpath(*safe_artifact_path(name).parts) + if not target.resolve().is_relative_to(candidate.resolve()): + raise ValueError('制品解压目标越界') + target.parent.mkdir(parents=True, exist_ok=True) + limit = expected[name].size if name in expected else self.limits.max_metadata_bytes + with archive.open(name) as source, target.open('xb') as output: + size, digest = _hash_stream(source, limit, output) + if name in expected and (size, digest) != (expected[name].size, expected[name].sha256): + raise ValueError('制品快照在解压期间发生变化') + + def _validate_candidate( + self, + candidate: StoredArtifact, + trusted_keys: Mapping[str, PublicKeyInput], + callback: Callable[[StoredArtifact], object] | None, + ) -> None: + """ + 执行可选的同步静态检查,并在回调完成后重新核对候选目录内容。 + + :param candidate: 待执行静态检查的候选制品对象 + :param trusted_keys: 由宿主提供的签名密钥标识与可信公钥映射 + :param callback: 同步只读检查回调,为 None 时跳过附加检查 + :return: None + :raises ValueError: 回调拒绝候选或修改了受保护内容 + :raises TypeError: 回调返回异步对象,无法同步完成检查 + """ + if callback is not None: + result = callback(candidate) + if inspect.isawaitable(result): + if inspect.iscoroutine(result): + result.close() + raise TypeError('制品静态候选检查必须同步执行') + if result is False: + raise ValueError('制品静态候选检查失败') + # 检查器也不被允许修改制品,重新核对后才发布或返回已存在对象。 + checked = self._verify_directory(candidate.root_path, trusted_keys) + if checked.digest != candidate.digest or checked.key_id != candidate.key_id: + raise ValueError('制品静态检查改变了候选内容') + + def verify_stored(self, path_or_digest: Path | str, trusted_keys: Mapping[str, PublicKeyInput]) -> StoredArtifact: + """ + 按目录、插件目录或唯一摘要定位制品并重新验证信任和内容。 + + :param path_or_digest: 摘要目录、其内部插件目录,或当前存储中唯一的制品摘要 + :param trusted_keys: 由宿主提供的签名密钥标识与可信公钥映射 + :return: 当前信任配置下通过验证的制品存储对象 + :raises ValueError: 定位结果不唯一、越过存储边界或制品验证失败 + """ + value = str(path_or_digest) + if DIGEST_PATTERN.fullmatch(value): + matches = list(self.root.glob(f'*/*/{value}')) + if len(matches) != 1: + raise ValueError('制品 digest 不存在或对应多个存储目录') + path = matches[0] + else: + path = Path(path_or_digest) + if not path.is_absolute(): + path = self.root / path + path = Path(os.path.abspath(path)) + if not path.is_relative_to(self.root): + raise ValueError('制品目录不属于当前存储根目录') + self._assert_real_path(path) + parts = path.relative_to(self.root).parts + if len(parts) == STORED_PLUGIN_PARTS and parts[3:] == ('payload', parts[0]): + path = path.parents[1] + parts = parts[:STORED_ROOT_PARTS] + if len(parts) != STORED_ROOT_PARTS or not DIGEST_PATTERN.fullmatch(parts[2]): + raise ValueError('制品目录必须遵循 // 布局') + artifact = self._verify_directory(path, trusted_keys) + if parts != (artifact.plugin_id, artifact.version, artifact.digest): + raise ValueError('制品目录身份或 digest 与签名元数据不一致') + return artifact + + def _verify_directory( + self, directory: Path, trusted_keys: Mapping[str, PublicKeyInput], *, signature_override: bytes | None = None + ) -> StoredArtifact: + """ + 核对制品目录内部布局、签名文档和全部已登记文件。 + + :param directory: 待验证的候选目录或已发布制品根目录 + :param trusted_keys: 由宿主提供的签名密钥标识与可信公钥映射 + :param signature_override: 维护轮换时用于验证现有字节的新签名,不修改磁盘 + :return: 通过目录完整性校验的制品存储对象 + :raises ValueError: 目录布局、签名、文件清单或实际内容不一致 + """ + self._assert_real_path(directory) + files, directories = _scan_directory(directory, self.limits) + if not ROOT_DOCUMENTS.issubset(files): + raise ValueError('存储制品缺少签名文档') + with files['artifacts.json'].open('rb') as source: + metadata_bytes = _read_limited(source, self.limits.max_metadata_bytes) + with files['signature.json'].open('rb') as source: + signature_bytes = _read_limited(source, self.limits.max_signature_bytes) + if signature_override is not None: + signature_bytes = signature_override + metadata, signature, digest = _verify_documents(metadata_bytes, signature_bytes, trusted_keys, self.limits) + if set(files) != ROOT_DOCUMENTS | {item.path for item in metadata.files}: + raise ValueError('存储制品存在额外或缺失文件') + validate_path_inventory(tuple(files), directories) + for item in metadata.files: + with files[item.path].open('rb') as source: + size, actual = _hash_stream(source, item.size) + if (size, actual) != (item.size, item.sha256): + raise ValueError(f'存储制品文件 SHA256 或大小不匹配:{item.path}') + with files[f'payload/{metadata.plugin_id}/plugin.yaml'].open('rb') as source: + manifest = _verify_manifest(_read_limited(source, self.limits.max_manifest_bytes), metadata, self.limits) + return StoredArtifact( + metadata.plugin_id, + metadata.version, + digest, + signature.key_id, + metadata.files, + metadata, + signature, + manifest, + directory, + directory / 'payload' / metadata.plugin_id, + ) + + @contextmanager + def _digest_lock(self, digest: str) -> Iterator[None]: + """ + 使用操作系统文件锁串行化同一摘要的发布,进程退出时自动释放。 + + :param digest: 制品规范元数据的小写 SHA256 摘要 + :return: 持有对应摘要锁的上下文 + :raises TimeoutError: 等待同一制品摘要的文件锁超时 + """ + lock_root = self.root / '.locks' + self._mkdir(lock_root) + path = lock_root / f'{digest}.lock' + self._assert_real_path(path) + flags = os.O_CREAT | os.O_RDWR | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_BINARY', 0) + descriptor = os.open(path, flags, 0o600) + acquired = False + try: + info = os.fstat(descriptor) + if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1: + raise ValueError('制品锁文件必须为普通文件') + deadline = time.monotonic() + LOCK_TIMEOUT_SECONDS + while not acquired: + try: + self._lock_file(descriptor, unlock=False) + acquired = True + except OSError as exc: # noqa: PERF203 - 操作系统非阻塞锁必须重试 + if exc.errno not in {errno.EACCES, errno.EAGAIN, errno.EDEADLK}: + raise + if time.monotonic() >= deadline: + raise TimeoutError('等待制品导入锁超时') from exc + time.sleep(LOCK_RETRY_SECONDS) + if not info.st_size: + # Windows 支持锁住 EOF 之后的字节;持锁后才初始化,避免首次创建竞争。 + os.write(descriptor, b'\0') + yield + finally: + if acquired: + self._lock_file(descriptor, unlock=True) + os.close(descriptor) + + @staticmethod + def _lock_file(descriptor: int, *, unlock: bool) -> None: + """ + 按平台获取或释放文件描述符上的非阻塞排他锁。 + + :param descriptor: 本次发布锁文件的文件描述符 + :param unlock: 是否释放已有锁,为 False 时尝试获取锁 + :return: None + """ + os.lseek(descriptor, 0, os.SEEK_SET) + if os.name == 'nt': + import msvcrt # noqa: PLC0415 + + msvcrt.locking(descriptor, msvcrt.LK_UNLCK if unlock else msvcrt.LK_NBLCK, 1) + else: + import fcntl # noqa: PLC0415 + + fcntl.flock(descriptor, fcntl.LOCK_UN if unlock else fcntl.LOCK_EX | fcntl.LOCK_NB) diff --git a/ruoyi-fastapi-backend/plugins/core/capability.py b/ruoyi-fastapi-backend/plugins/core/capability.py index 359c7fc..f460867 100644 --- a/ruoyi-fastapi-backend/plugins/core/capability.py +++ b/ruoyi-fastapi-backend/plugins/core/capability.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import Literal -from plugins.core.discovery.scanner import DiscoveredPlugin +from plugins.core.discovery.scanner import DiscoveredPlugin, is_artifact_plugin PluginOperation = Literal[ 'install', @@ -115,18 +115,29 @@ class PluginRuntimeCapabilityResolver: """ manifest = discovered_plugin.manifest has_frontend_resources = bool( - manifest.frontend.menus or manifest.dependencies.npm or manifest.dependencies.npm_dev + manifest.frontend.menus + or manifest.dependencies.npm + or manifest.dependencies.npm_dev + or manifest.frontend.delivery.type == 'bundle' + ) + frontend_build_required = manifest.frontend.delivery.type != 'bundle' and ( + manifest.frontend.delivery.build_required or has_frontend_resources ) - frontend_build_required = manifest.frontend.delivery.build_required or has_frontend_resources frontend_runtime_manageable = not ( self.frontend_mode == 'built' and has_frontend_resources and frontend_build_required ) - backend_runtime_manageable = self.backend_runtime_mode == 'dev' + backend_runtime_manageable = self.backend_runtime_mode in {'dev', 'maintenance'} + if is_artifact_plugin(discovered_plugin) and self.backend_runtime_mode != 'maintenance': + backend_runtime_manageable = False runtime_manageable = frontend_runtime_manageable and backend_runtime_manageable warnings = [] if not backend_runtime_manageable: - warnings.append(SERVICE_MODE_REASON) + warnings.append( + '签名制品的生命周期只能通过插件维护发布命令执行,完成后重启 worker。' + if is_artifact_plugin(discovered_plugin) + else SERVICE_MODE_REASON + ) if not frontend_runtime_manageable: warnings.append(BUILT_FRONTEND_REASON) diff --git a/ruoyi-fastapi-backend/plugins/core/deployment/__init__.py b/ruoyi-fastapi-backend/plugins/core/deployment/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ruoyi-fastapi-backend/plugins/core/deployment/catalog.py b/ruoyi-fastapi-backend/plugins/core/deployment/catalog.py new file mode 100644 index 0000000..ea13fce --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/deployment/catalog.py @@ -0,0 +1,221 @@ +import asyncio +import json +from dataclasses import replace +from pathlib import Path +from typing import Any + +from config.database import DataSourceRegistry +from plugins.core.artifacts import ArtifactStore, StoredArtifact, verify_artifact +from plugins.core.deployment.config import PluginDeploymentConfig +from plugins.core.discovery.scanner import DiscoveredPlugin, PluginScanner +from plugins.core.management.dao.release_dao import PluginReleaseDao +from plugins.core.manifest.schema import EXPLICIT_MANIFEST_VERSION +from plugins.core.runtime.service.lifecycle_lock import RedisPluginLifecycleLock +from plugins.core.validation.manifest import PluginManifestChecker +from plugins.core.validation.structure import PluginStructureChecker + + +def artifact_payload(artifact: Any) -> dict[str, Any]: + """ + 构建只包含制品身份的返回数据,避免以文件路径授予发布权限。 + + :param artifact: 已完成签名和文件校验的制品对象 + :return: 插件ID、版本、摘要和签名密钥标识组成的字典 + """ + return { + 'pluginId': artifact.plugin_id, + 'version': artifact.version, + 'digest': artifact.digest, + 'keyId': artifact.key_id, + } + + +class PluginArtifactCatalog: + """ + 已验证制品文件与数据库索引之间的访问服务。 + """ + + def __init__( + self, + config: PluginDeploymentConfig, + *, + session_factory: Any = None, + dao: Any = PluginReleaseDao, + lifecycle_lock: Any = None, + ) -> None: + """ + 初始化不可变制品目录与数据库索引的访问服务。 + + :param config: 制品发布配置快照 + :param session_factory: 异步数据库会话工厂,未提供时使用宿主数据源 + :param dao: 制品、发布目标及进程报告的数据访问接口 + :param lifecycle_lock: 与维护、清理和轮换共用的全局生命周期锁 + :return: None + """ + self.config = config + self.session_factory = session_factory or DataSourceRegistry.session + self.dao = dao + self.lifecycle_lock = lifecycle_lock or RedisPluginLifecycleLock() + + def require_no_pending_maintenance(self) -> None: + """ + 拒绝在文件与索引尚未完成恢复时导入或加载制品。 + + :return: None + """ + pending = self.config.store_root / '.maintenance' + ArtifactStore._assert_real_path(pending) + if pending.exists() and any(pending.glob('*.json')): + raise ValueError('存在未完成的制品维护,请先执行 artifact reconcile') + + @property + def store(self) -> ArtifactStore: + """ + 根据当前配置创建受限的不可变制品存储。 + + :return: 制品存储实例 + """ + self.config.require_enabled() + return ArtifactStore(self.config.store_root) + + def reject_source_conflict(self, plugin_id: str) -> None: + """ + 检查目标插件ID是否与已有源码目录冲突。 + + :param plugin_id: 插件ID + :return: None + :raises ValueError: 源码插件目录与制品插件ID冲突 + """ + if (self.config.backend_root / 'plugins' / plugin_id).exists(): + raise ValueError(f'插件 {plugin_id} 已存在源码目录,不能与制品发布同时使用') + + def discovered(self, artifact: StoredArtifact, generation: str | None = None) -> DiscoveredPlugin: + """ + 将验证后的制品转换为带发布身份的插件发现结果。 + + :param artifact: 已完成签名和文件校验的制品对象 + :param generation: 附加到发现结果的发布代际,静态维护检查时可不提供 + :return: 指向不可变部署目录的插件发现对象 + """ + plugin = PluginScanner(artifact.plugin_path.parent).load_manifest(artifact.plugin_path / 'plugin.yaml') + return replace(plugin, artifact_digest=artifact.digest, artifact_generation=generation) + + def validate_candidate(self, artifact: StoredArtifact) -> None: + """ + 校验候选制品的交付协议、发布者授权及静态平台兼容性。 + + :param artifact: 已完成签名和文件校验的制品对象 + :return: None + :raises ValueError: 协议、信任授权、源码目录或运行平台校验失败 + """ + if ( + artifact.manifest.manifest_version != EXPLICIT_MANIFEST_VERSION + or artifact.manifest.frontend.delivery.type not in {'none', 'bundle'} + ): + raise ValueError('制品发布只接受 manifest v2 和 none/bundle 前端') + self.config.authorize_publisher(artifact.key_id, artifact.plugin_id) + self.reject_source_conflict(artifact.plugin_id) + plugin = self.discovered(artifact) + compatibility = PluginManifestChecker(backend_root=self.config.backend_root).check(plugin.manifest) + structure = PluginStructureChecker(self.config.backend_root).check(plugin) + errors = [item.message for item in compatibility.error_issues] + errors.extend(item.message for item in structure.failed_items) + if errors: + raise ValueError('制品静态检查失败:' + ';'.join(errors)) + + async def import_artifact( + self, path: Path | str, *, actor: str | None = None, dry_run: bool = False + ) -> dict[str, Any]: + """ + 验证并登记制品,不选择发布目标、不安装依赖或运行插件代码。 + + :param path: 待导入的签名 rpk 文件路径 + :param actor: 本次操作的执行者,未提供时不记录执行者名称 + :param dry_run: 是否只执行预检而不写入文件或数据库 + :return: 制品身份及导入或预检结果 + :raises ValueError: 制品完整性、发布者授权或部署兼容性校验失败 + """ + self.config.require_enabled() + keys = self.config.trusted_keys() + if dry_run: + verified = await asyncio.to_thread(verify_artifact, path, keys) + self.config.authorize_publisher(verified.key_id, verified.plugin_id) + self.reject_source_conflict(verified.plugin_id) + return { + 'ok': True, + 'dryRun': True, + 'message': '容器验签和摘要检查通过;平台及目录结构将在正式导入的临时目录中检查', + **artifact_payload(verified), + } + async with self.lifecycle_lock.lock('__artifacts__', 'artifact_import') as lock: + if not lock.acquired: + raise ValueError(lock.message) + self.require_no_pending_maintenance() + return await self._import_locked(path, keys, actor) + + async def _import_locked(self, path: Path | str, keys: dict[str, Any], actor: str | None) -> dict[str, Any]: + """ + 持有全局维护锁时完成文件发布和索引事务,取消时先等待文件操作结束。 + + :param path: 已签名制品路径 + :param keys: 当前可信公钥 + :param actor: 操作者 + :return: 导入后的制品身份 + """ + work = asyncio.create_task( + asyncio.to_thread(self.store.import_artifact, path, keys, validate_candidate=self.validate_candidate) + ) + try: + stored = await asyncio.shield(work) + except asyncio.CancelledError: + await work + raise + # 文件与数据库不能共享事务。数据库失败时保留已验签不可变对象,重试可幂等登记。 + async with self.session_factory() as db: + await self.dao.register_artifact( + db, + digest=stored.digest, + plugin_id=stored.plugin_id, + version=stored.version, + key_id=stored.key_id, + relative_path=stored.root_path.relative_to(self.config.store_root).as_posix(), + manifest_json=json.dumps(stored.manifest.model_dump(mode='json', by_alias=True), ensure_ascii=False), + created_by=actor, + ) + await db.commit() + return {'ok': True, 'message': '已导入并验证制品,尚未选择发布目标', **artifact_payload(stored)} + + async def get(self, digest: str) -> StoredArtifact: + """ + 根据数据库索引定位制品,并在返回前重新验证签名和全部文件。 + + :param digest: 制品规范元数据的小写 SHA256 摘要 + :return: 当前信任配置下通过验证的制品存储对象 + :raises ValueError: 制品不存在、索引不一致或当前信任及文件校验失败 + """ + self.require_no_pending_maintenance() + async with self.session_factory() as db: + record = await self.dao.get_artifact(db, digest) + if record is None: + raise ValueError('制品尚未导入') + identity = (record.plugin_id, record.version, record.digest, record.key_id) + relative_path = record.relative_path + stored = await asyncio.to_thread( + self.store.verify_stored, self.config.store_root / relative_path, self.config.trusted_keys() + ) + if (stored.plugin_id, stored.version, stored.digest, stored.key_id) != identity: + raise ValueError('制品索引与实际验证内容不一致') + self.validate_candidate(stored) + return stored + + async def list_artifacts(self, plugin_id: str | None = None) -> dict[str, Any]: + """ + 查询数据库中已登记制品的身份信息。 + + :param plugin_id: 可选的插件ID过滤条件,为 None 时查询全部制品 + :return: 已登记制品的插件ID、版本、摘要和签名密钥标识列表 + """ + self.config.require_enabled() + async with self.session_factory() as db: + artifacts = await self.dao.list_artifacts(db, plugin_id) + return {'ok': True, 'artifacts': [artifact_payload(item) for item in artifacts]} diff --git a/ruoyi-fastapi-backend/plugins/core/deployment/config.py b/ruoyi-fastapi-backend/plugins/core/deployment/config.py new file mode 100644 index 0000000..1d6132c --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/deployment/config.py @@ -0,0 +1,158 @@ +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from config.env import PluginArtifactConfig + +MAX_TRUST_FILE_BYTES = 1024 * 1024 + + +class TrustedPublisher(BaseModel): + """ + 将公钥与可发布插件ID显式绑定的发布者配置。 + """ + + model_config = ConfigDict(extra='forbid', populate_by_name=True, strict=True) + key_id: str = Field(alias='keyId', pattern=r'^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$') + public_key: str = Field(alias='publicKey', min_length=1, max_length=8192) + plugin_ids: list[str] = Field(alias='pluginIds', min_length=1, max_length=256) + enabled: bool = True + + @field_validator('plugin_ids') + @classmethod + def validate_plugin_ids(cls, values: list[str]) -> list[str]: + """ + 校验发布者授权范围,通配授权必须明确使用星号。 + + :param values: 发布者允许发布的插件ID或显式通配符列表 + :return: 无重复且格式正确的插件ID授权列表 + :raises ValueError: 授权范围存在重复或非法插件ID + """ + import re # noqa: PLC0415 + + if len(set(values)) != len(values): + raise ValueError('发布者插件授权不能重复') + if any(value != '*' and not re.fullmatch(r'[a-z][a-z0-9_]{1,63}', value) for value in values): + raise ValueError('发布者插件授权包含非法插件 ID') + return values + + +class PluginTrustDocument(BaseModel): + """ + 由宿主管理的发布者公钥及授权范围配置文档。 + """ + + model_config = ConfigDict(extra='forbid', populate_by_name=True, strict=True) + schema_version: int = Field(alias='schemaVersion', ge=1, le=1) + keys: list[TrustedPublisher] = Field(max_length=256) + + +def _unique_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + """ + 构造信任配置对象,拒绝同一对象中的重复字段。 + + :param pairs: 按原始顺序提供的 JSON 对象键值对 + :return: 字段名唯一的配置字典 + :raises ValueError: 公钥配置对象包含重复字段 + """ + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError(f'公钥配置存在重复字段:{key}') + result[key] = value + return result + + +@dataclass(frozen=True) +class PluginDeploymentConfig: + """ + 制品发布配置快照,支持为测试和维护实例注入独立目录。 + """ + + backend_root: Path + store_root: Path + trust_file: Path + enabled: bool = True + heartbeat_seconds: int = 15 + worker_ttl_seconds: int = 60 + + @classmethod + def from_settings(cls, backend_root: Path | str) -> 'PluginDeploymentConfig': + """ + 读取宿主制品配置,并将相对存储路径转换为绝对路径。 + + :param backend_root: 宿主后端项目根目录 + :return: 制品发布配置快照 + """ + root = Path(backend_root).resolve() + settings = PluginArtifactConfig + store = Path(settings.plugin_artifact_store) + trust = Path(settings.plugin_artifact_trust_file) + return cls( + backend_root=root, + store_root=(store if store.is_absolute() else root / store).resolve(), + trust_file=(trust if trust.is_absolute() else root / trust).resolve(), + enabled=settings.plugin_artifact_enabled, + heartbeat_seconds=settings.plugin_artifact_heartbeat_seconds, + worker_ttl_seconds=settings.plugin_artifact_worker_ttl_seconds, + ) + + def require_enabled(self) -> None: + """ + 检查发布功能已启用且制品存储与源码目录相互隔离。 + + :return: None + :raises ValueError: 功能未启用或制品存储与宿主源码路径重叠 + """ + if not self.enabled: + raise ValueError('签名插件制品未启用,请配置 PLUGIN_ARTIFACT_ENABLED 和外部信任公钥文件') + if self.store_root == self.backend_root or self.store_root in self.backend_root.parents: + raise ValueError('插件制品目录不能是宿主目录或其上级目录') + plugins_root = (self.backend_root / 'plugins').resolve() + if self.store_root == plugins_root or self.store_root.is_relative_to(plugins_root): + raise ValueError('不可变插件制品必须保存在源码 plugins 目录之外') + + def read_publishers(self) -> dict[str, TrustedPublisher]: + """ + 读取并校验宿主管理的发布者公钥配置。 + + :return: 签名密钥标识到发布者配置的映射 + :raises ValueError: 公钥配置缺失、超过大小限制或内容不合法 + """ + self.require_enabled() + if not self.trust_file.is_file() or self.trust_file.stat().st_size > MAX_TRUST_FILE_BYTES: + raise ValueError('宿主插件信任公钥文件不存在或过大') + data = json.loads(self.trust_file.read_text(encoding='utf-8'), object_pairs_hook=_unique_json_object) + document = PluginTrustDocument.model_validate(data) + result: dict[str, TrustedPublisher] = {} + for publisher in document.keys: + if publisher.key_id in result: + raise ValueError(f'重复的发布者 keyId:{publisher.key_id}') + result[publisher.key_id] = publisher + return result + + def trusted_keys(self) -> dict[str, str]: + """ + 从当前发布者配置中提取启用的可信公钥。 + + :return: 启用的签名密钥标识与公钥文本映射 + """ + return {key: item.public_key for key, item in self.read_publishers().items() if item.enabled} + + def authorize_publisher(self, key_id: str, plugin_id: str) -> None: + """ + 检查发布者是否启用并获准发布指定插件。 + + :param key_id: 签名公钥在宿主信任配置中的标识 + :param plugin_id: 插件ID + :return: None + :raises ValueError: 发布者不受信任、已停用或未获准发布指定插件 + """ + publisher = self.read_publishers().get(key_id) + if publisher is None or not publisher.enabled: + raise ValueError('插件发布者不在当前信任列表中') + if '*' not in publisher.plugin_ids and plugin_id not in publisher.plugin_ids: + raise ValueError(f'发布者 {key_id} 未获准发布插件 {plugin_id}') diff --git a/ruoyi-fastapi-backend/plugins/core/deployment/convergence.py b/ruoyi-fastapi-backend/plugins/core/deployment/convergence.py new file mode 100644 index 0000000..a34a85e --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/deployment/convergence.py @@ -0,0 +1,406 @@ +import math +import re +from asyncio import ensure_future, gather, sleep, wait +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass +from datetime import datetime +from time import monotonic +from typing import Any + +from plugins.core.deployment.state import RUNTIME_PLUGIN_ID, aggregate_release +from plugins.core.utils import validate_plugin_id_value +from utils.time_util import TimezoneUtil + +STATUS_READ_TIMEOUT_SECONDS = 10 +COUNT_FIELDS = ( + 'liveWorkers', + 'healthyWorkers', + 'disabledWorkers', + 'mismatchWorkers', + 'staleWorkers', + 'missingWorkers', + 'failedWorkers', +) +SUMMARY_FIELDS = ( + 'pluginId', + 'targetDigest', + 'generation', + 'expectedWorkers', + 'enabled', + 'status', + 'restartRequired', + *COUNT_FIELDS, + 'prepareStatus', +) +REASONS = { + 'converged': '全部有效存活 worker 已加载固定发布目标且达到预期数量', + 'not_converged': '固定发布目标尚未在全部有效存活 worker 中就绪', + 'timeout': '等待固定发布目标收敛超时', + 'target_missing': '请求的插件没有发布目标', + 'target_changed': '发布目标、代际或预期 worker 数量已变化,停止等待', + 'target_disabled': '固定发布目标已停用,不能通过启用状态验收', + 'worker_failed': '有效存活 worker 已报告运行或目标加载失败', + 'unavailable': '发布状态暂不可读取,未使用历史观测判定成功', + 'invalid_observation': '发布状态结构或 worker 报告不一致,无法判定收敛', +} + + +@dataclass(frozen=True) +class _ReleaseTarget: + """调用方固定的插件、制品、代际和预期容量,轮询期间不跟随数据库目标变化。""" + + plugin_id: str + digest: str + generation: str + expected_workers: int + + def __post_init__(self) -> None: + """ + 校验固定发布身份及容量,拒绝布尔值冒充预期 worker 数量。 + + :return: None + :raises ValueError: 插件 ID、摘要、代际或预期数量不合法 + """ + validate_plugin_id_value(self.plugin_id) + if not _hex(self.digest, 64): + raise ValueError('制品 digest 必须是64位小写 SHA256') + if not _hex(self.generation, 32): + raise ValueError('发布代际必须是32位小写 UUID 十六进制字符串') + if not _integer(self.expected_workers, minimum=1): + raise ValueError('预期 worker 数量必须是正整数') + + def to_payload(self) -> dict[str, Any]: + """ + 序列化固定目标,不使用后续查询结果覆盖调用方身份。 + + :return: 使用管理端字段名的固定发布目标 + """ + return { + 'pluginId': self.plugin_id, + 'digest': self.digest, + 'generation': self.generation, + 'expectedWorkers': self.expected_workers, + } + + +def _hex(value: object, length: int) -> bool: + """ + 判断标识是否为指定长度的小写十六进制字符串。 + + :param value: 待检查标识 + :param length: 标识要求的字符数量 + :return: 标识是否合法 + """ + return isinstance(value, str) and re.fullmatch(rf'[0-9a-f]{{{length}}}', value) is not None + + +def _integer(value: object, *, minimum: int = 0) -> bool: + """ + 校验真实整数及其下界,拒绝布尔类型。 + + :param value: 待检查数值 + :param minimum: 允许的最小整数 + :return: 数值是否为满足下界的整数 + """ + return isinstance(value, int) and not isinstance(value, bool) and value >= minimum + + +def _seconds(value: object, *, allow_zero: bool = False) -> bool: + """ + 校验有限的等待时间,不接受负数、无穷或非数值。 + + :param value: 待检查秒数 + :param allow_zero: 是否允许零秒作为单次断言 + :return: 时间参数是否合法 + """ + try: + return ( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and math.isfinite(value) + and (value >= 0 if allow_zero else value > 0) + ) + except OverflowError: + return False + + +def _valid_summary(summary: Mapping[str, Any]) -> bool: + """ + 验证发布摘要的必需字段和基本计数关系,避免损坏摘要被当作成功。 + + :param summary: 发布状态查询返回的单个插件摘要 + :return: 摘要是否具备可安全比较的身份和计数 + """ + if ( + not all(field in summary for field in SUMMARY_FIELDS) + or not _integer(summary.get('expectedWorkers'), minimum=1) + or not _hex(summary.get('generation'), 32) + or (summary.get('targetDigest') is not None and not _hex(summary['targetDigest'], 64)) + or not isinstance(summary.get('enabled'), bool) + or not isinstance(summary.get('restartRequired'), bool) + or not isinstance(summary.get('status'), str) + or summary['status'] not in {'no_target', 'active', 'disabled', 'partial', 'failed', 'pending_restart'} + or not isinstance(summary.get('prepareStatus'), str) + or summary['prepareStatus'] not in {'idle', 'preparing', 'prepared', 'failed'} + or any(not _integer(summary.get(field)) for field in COUNT_FIELDS) + ): + return False + live = summary['liveWorkers'] + accounted = sum( + summary[field] for field in ('healthyWorkers', 'disabledWorkers', 'mismatchWorkers', 'failedWorkers') + ) + if accounted > live or summary['missingWorkers'] != max(live, summary['expectedWorkers']) - accounted: + return False + matched = summary['healthyWorkers'] if summary['enabled'] else summary['disabledWorkers'] + if summary['targetDigest'] is None: + expected_status = 'no_target' + elif live >= summary['expectedWorkers'] and matched == live: + expected_status = 'active' if summary['enabled'] else 'disabled' + elif matched: + expected_status = 'partial' + elif summary['failedWorkers']: + expected_status = 'failed' + else: + expected_status = 'pending_restart' + expected_restart = summary['targetDigest'] is not None and expected_status not in {'active', 'disabled'} + return ( + summary['status'] == expected_status + and summary['restartRequired'] == expected_restart + and (expected_status not in {'active', 'disabled'} or summary['missingWorkers'] == 0) + ) + + +def _worker_reports(raw: object, plugin_id: str) -> list[dict[str, Any]]: + """ + 严格验证 worker 身份、重复记录和必需字段,再转换为现有聚合器的输入。 + + :param raw: 发布状态查询返回的 worker 报告列表 + :param plugin_id: 本次唯一允许的业务插件 ID + :return: 不包含错误正文的内部字段报告列表 + :raises ValueError: 报告字段缺失、身份重复或数据不一致 + """ + if not isinstance(raw, list): + raise ValueError('worker 报告列表不可用') + reports = [] + seen = set() + for row in raw: + if ( + not isinstance(row, Mapping) + or not _hex(row.get('workerId'), 32) + or not all( + field in row for field in ('pluginId', 'digest', 'version', 'generation', 'state', 'heartbeatTime') + ) + ): + raise ValueError('worker 身份无效') + row_plugin = row.get('pluginId') + identity = (row['workerId'], row_plugin) + if row_plugin not in {RUNTIME_PLUGIN_ID, plugin_id} or identity in seen: + raise ValueError('worker 报告重复或插件身份不符') + if row.get('state') not in {'starting', 'ready', 'failed', 'stopped'}: + raise ValueError('worker 状态无效') + heartbeat = row.get('heartbeatTime') + if not isinstance(heartbeat, str): + raise ValueError('worker 心跳缺失') + heartbeat = datetime.fromisoformat(heartbeat) + if heartbeat.tzinfo is None or heartbeat.utcoffset() is None: + raise ValueError('worker 心跳必须携带时区') + digest, generation = row.get('digest'), row.get('generation') + if ( + (digest is not None and not _hex(digest, 64)) + or (generation is not None and not _hex(generation, 32)) + or ( + row_plugin == RUNTIME_PLUGIN_ID + and any(row.get(field) is not None for field in ('digest', 'version', 'generation')) + ) + ): + raise ValueError('worker 制品身份无效') + seen.add(identity) + reports.append( + { + 'worker_id': row['workerId'], + 'plugin_id': row_plugin, + 'state': row['state'], + 'heartbeat_time': heartbeat, + 'artifact_digest': digest, + 'generation': generation, + } + ) + return reports + + +def _evaluate(payload: object, target: _ReleaseTarget, worker_ttl_seconds: float) -> tuple[str, dict[str, Any] | None]: + """ + 在查询返回时重新校验心跳,只对固定目标作出收敛判定。 + + :param payload: 当前只读状态查询结果 + :param target: 调用方固定的发布身份 + :param worker_ttl_seconds: 发布 worker 的有效心跳秒数 + :return: 判定原因与经过字段白名单过滤的最近摘要 + """ + if not isinstance(payload, Mapping) or payload.get('ok') is not True: + return ( + 'unavailable' if isinstance(payload, Mapping) and payload.get('ok') is False else 'invalid_observation' + ), None + rows = payload.get('releases') + if not isinstance(rows, list) or any( + not isinstance(row, Mapping) or not isinstance(row.get('pluginId'), str) for row in rows + ): + return 'invalid_observation', None + matches = [row for row in rows if row.get('pluginId') == target.plugin_id] + if not matches: + return 'target_missing', None + if len(matches) != 1 or not _valid_summary(matches[0]): + return 'invalid_observation', None + summary = matches[0] + observed = {key: summary[key] for key in SUMMARY_FIELDS} + if summary['targetDigest'] is None: + return 'target_missing', observed + if ( + summary['targetDigest'] != target.digest + or summary['generation'] != target.generation + or summary['expectedWorkers'] != target.expected_workers + ): + return 'target_changed', observed + if not summary['enabled']: + return 'target_disabled', observed + try: + reports = _worker_reports(payload.get('workers'), target.plugin_id) + current = aggregate_release( + { + 'plugin_id': target.plugin_id, + 'target_digest': target.digest, + 'generation': target.generation, + 'expected_workers': target.expected_workers, + 'prepare_status': summary['prepareStatus'], + }, + reports, + now=TimezoneUtil.utc_now(), + ttl_seconds=worker_ttl_seconds, + ).to_payload() + except (TypeError, ValueError): + return 'invalid_observation', observed + observed.update({key: current[key] for key in ('status', 'restartRequired', *COUNT_FIELDS)}) + if current['failedWorkers']: + return 'worker_failed', observed + return ('converged' if current['status'] == 'active' else 'not_converged'), observed + + +def _result( + target: _ReleaseTarget, + reason: str, + observed: dict[str, Any] | None, + attempts: int, + started: float, + timeout: float, + interval: float, +) -> dict[str, Any]: + """ + 构建不会泄露连接错误或 worker 异常正文的固定结果契约。 + + :param target: 调用方固定的发布身份 + :param reason: 收敛或停止等待的固定原因 + :param observed: 最近一次可解释的发布摘要 + :param attempts: 已发起的只读查询次数 + :param started: 等待开始的单调时刻 + :param timeout: 调用方配置的总体等待秒数 + :param interval: 调用方配置的轮询间隔秒数 + :return: 可由 CLI 直接用于成功或非零退出的发布等待结果 + """ + return { + 'ok': reason == 'converged', + 'operation': 'release_wait', + 'reason': reason, + 'message': REASONS[reason], + 'target': target.to_payload(), + 'lastObserved': observed, + 'attempts': attempts, + 'elapsedSeconds': round(max(0, monotonic() - started), 3), + 'timeoutSeconds': timeout, + 'intervalSeconds': interval, + } + + +async def _read_once( + status_reader: Callable[[str], Awaitable[dict[str, Any]]], plugin_id: str, read_limit: float +) -> tuple[bool, object]: + """ + 显式记录读取预算是否耗尽,取消后的迟到结果不能覆盖已发生的超时。 + + :param status_reader: 发布状态只读查询函数 + :param plugin_id: 固定查询的插件 ID + :param read_limit: 本轮读取预算秒数 + :return: 是否在预算内完成以及正常完成时的查询结果 + :raises Exception: 查询自身异常,包含查询主动抛出的超时异常 + :raises asyncio.CancelledError: 外部取消,清理查询任务后继续传播 + """ + task = ensure_future(status_reader(plugin_id)) + try: + done, _ = await wait({task}, timeout=read_limit) + if task not in done: + return False, None + return True, task.result() + finally: + if not task.done(): + task.cancel() + await gather(task, return_exceptions=True) + + +async def wait_for_release( + status_reader: Callable[[str], Awaitable[dict[str, Any]]], + plugin_id: str, + digest: str, + *, + expected_generation: str, + expected_workers: int, + worker_ttl_seconds: float, + timeout_seconds: float = 300, + poll_interval_seconds: float = 2, +) -> dict[str, Any]: + """ + 只读等待固定发布目标;零超时执行一次断言,取消保持协程取消语义。 + + :param status_reader: 每轮创建新查询会话的发布状态读取函数 + :param plugin_id: 固定验收的插件 ID + :param digest: 固定验收的制品 SHA256 摘要 + :param expected_generation: 固定验收的发布代际 + :param expected_workers: 必须与发布记录一致的预期 worker 数量 + :param worker_ttl_seconds: 发布 worker 的有效心跳秒数 + :param timeout_seconds: 总体等待秒数,零表示一次断言 + :param poll_interval_seconds: 尚未收敛时再次读取前的间隔秒数 + :return: 收敛成功或明确失败原因及最后可用状态 + :raises ValueError: 固定身份或等待参数不合法 + :raises asyncio.CancelledError: 调用方取消当前等待,取消不转换成成功或继续轮询 + """ + target = _ReleaseTarget(plugin_id, digest, expected_generation, expected_workers) + if not _seconds(timeout_seconds, allow_zero=True): + raise ValueError('等待超时必须是有限的非负秒数') + if not _seconds(poll_interval_seconds) or not _seconds(worker_ttl_seconds): + raise ValueError('轮询间隔和心跳 TTL 必须是有限的正秒数') + started = monotonic() + deadline = started + timeout_seconds + attempts = 0 + observed = None + while True: + remaining = deadline - monotonic() if timeout_seconds else STATUS_READ_TIMEOUT_SECONDS + if remaining <= 0: + return _result(target, 'timeout', observed, attempts, started, timeout_seconds, poll_interval_seconds) + attempts += 1 + try: + completed, payload = await _read_once(status_reader, plugin_id, min(STATUS_READ_TIMEOUT_SECONDS, remaining)) + except Exception: + return _result(target, 'unavailable', observed, attempts, started, timeout_seconds, poll_interval_seconds) + if not completed: + # asyncio 定时器可能早于粗粒度单调时钟触发,按预算来源而非触发后时钟归类。 + reason = 'timeout' if timeout_seconds and remaining <= STATUS_READ_TIMEOUT_SECONDS else 'unavailable' + return _result(target, reason, observed, attempts, started, timeout_seconds, poll_interval_seconds) + try: + reason, current = _evaluate(payload, target, worker_ttl_seconds) + except (KeyError, TypeError, ValueError, OverflowError): + # 外部状态损坏不得泄漏内部异常,也不能继承上次成功判断。 + reason, current = 'invalid_observation', None + observed = current if current is not None else observed + if timeout_seconds and monotonic() >= deadline: + reason = 'timeout' + if reason != 'not_converged' or not timeout_seconds: + return _result(target, reason, observed, attempts, started, timeout_seconds, poll_interval_seconds) + await sleep(min(poll_interval_seconds, max(0, deadline - monotonic()))) diff --git a/ruoyi-fastapi-backend/plugins/core/deployment/enablement.py b/ruoyi-fastapi-backend/plugins/core/deployment/enablement.py new file mode 100644 index 0000000..ee758de --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/deployment/enablement.py @@ -0,0 +1,106 @@ +from typing import Any +from uuid import uuid4 + +from sqlalchemy import select, update + +from plugins.core.deployment.service import PluginDeploymentService +from plugins.core.management.dao.release_dao import PluginReleaseConflictError +from plugins.core.management.entity.do.models import SysPlugin +from plugins.core.management.entity.do.release_models import SysPluginRelease +from plugins.core.management.service.service import PluginService +from plugins.core.utils import validate_plugin_id_value + + +class PluginArtifactEnablementService(PluginDeploymentService): + """ + 在停机维护窗口原子更新制品启停状态与声明资源的服务。 + """ + + async def set_enabled( + self, + plugin_id: str, + *, + enabled: bool, + expected_generation: str, + maintenance: bool = False, + actor: str | None = None, + ) -> dict[str, Any]: + """ + 在停机维护窗口原子更新制品启停状态、声明资源和发布代际。 + + :param plugin_id: 插件ID + :param enabled: 插件目标是否启用 + :param expected_generation: 调用方读取并确认的发布代际,用于并发状态校验 + :param maintenance: 是否已显式确认在停止全部宿主进程后执行维护 + :param actor: 本次操作的执行者,未提供时不记录执行者名称 + :return: 新启停状态、发布代际及重启要求 + :raises ValueError: 维护条件、准备证据、依赖或资源更新不满足启停要求 + :raises PluginReleaseConflictError: 预检后发布代际或维护准备证据发生变化 + """ + self._require_maintenance(maintenance) + validate_plugin_id_value(plugin_id) + operation = 'release_enable' if enabled else 'release_disable' + async with self.lifecycle_lock.lock(plugin_id, operation) as lock: + if not lock.acquired: + raise ValueError(lock.message) + await self._require_quiescent() + release, plugin = await self._read_state(plugin_id) + if release is None or release.generation != expected_generation: + raise PluginReleaseConflictError('发布代际已变化,请重新读取目标') + if ( + not release.target_digest + or release.prepare_status != 'prepared' + or release.prepared_digest != release.target_digest + or plugin is None + or not plugin.installed_version + or release.prepared_version != plugin.installed_version + ): + raise ValueError('当前发布目标未完成维护准备,不能更改启停状态') + artifact = await self.catalog.get(release.target_digest) + discovered = self.catalog.discovered(artifact) + runtime = self.runtime_factory(self.config, await self._snapshot(artifact)) + precheck = await runtime.set_plugin_enabled(plugin_id, enabled=enabled, dry_run=True) + if not precheck.get('ok'): + raise ValueError(str(precheck.get('message') or '插件启停依赖预检失败')) + async with self.session_factory() as db: + generation = uuid4().hex + installed_matches = ( + select(SysPlugin.plugin_id) + .where( + SysPlugin.plugin_id == plugin_id, + SysPlugin.installed_version == SysPluginRelease.prepared_version, + ) + .exists() + ) + changed = await db.execute( + update(SysPluginRelease) + .where( + SysPluginRelease.plugin_id == plugin_id, + SysPluginRelease.generation == expected_generation, + SysPluginRelease.target_digest == artifact.digest, + SysPluginRelease.prepared_digest == artifact.digest, + SysPluginRelease.prepare_status == 'prepared', + SysPluginRelease.prepared_version == plugin.installed_version, + installed_matches, + ) + .values(generation=generation, update_by=actor) + .execution_options(synchronize_session=False) + ) + if changed.rowcount != 1: + raise PluginReleaseConflictError('发布状态已变化,启停操作未执行') + result = await PluginService.update_plugin_enabled_services(db, plugin_id, enabled, discovered) + if not result.is_success: + raise ValueError(result.message) + payload = { + 'ok': True, + 'operation': operation, + 'pluginId': plugin_id, + 'enabled': enabled, + 'digest': artifact.digest, + 'generation': generation, + 'restartRequired': True, + 'message': '维护启停状态已保存,请启动全部 worker', + } + await self._audit(db, payload, actor) + await db.commit() + return payload diff --git a/ruoyi-fastapi-backend/plugins/core/deployment/maintenance.py b/ruoyi-fastapi-backend/plugins/core/deployment/maintenance.py new file mode 100644 index 0000000..d895d91 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/deployment/maintenance.py @@ -0,0 +1,475 @@ +import asyncio +import hashlib +import json +import os +import shutil +import tempfile +from datetime import timedelta +from pathlib import Path +from typing import Any + +from sqlalchemy import delete, update + +from plugins.core.artifacts import ArtifactStore, verify_artifact +from plugins.core.artifacts.schema import DIGEST_PATTERN, safe_artifact_path, strict_json +from plugins.core.deployment.service import PluginDeploymentService +from plugins.core.management.entity.do.release_models import SysPluginArtifact +from utils.time_util import TimezoneUtil + +MAX_INVENTORY_OBJECTS = 10000 +MAX_JOURNAL_BYTES = 64 * 1024 +OBJECT_PATH_PARTS = 3 + + +class PluginArtifactMaintenanceService(PluginDeploymentService): + """ + 停机维护制品存储,使用可恢复日志连接文件变更与数据库事务。 + """ + + def _object_path(self, relative: str) -> Path: + """ + 解析规范的制品目录,拒绝越界、链接和非摘要目录。 + + :param relative: 存储内的插件、版本和摘要路径 + :return: 已检查的绝对目录 + """ + parts = safe_artifact_path(relative).parts + if len(parts) != OBJECT_PATH_PARTS or not DIGEST_PATTERN.fullmatch(parts[-1]): + raise ValueError('制品维护目录必须是 plugin/version/digest') + path = self.catalog.store.root.joinpath(*parts) + ArtifactStore._assert_real_path(path) + return path + + def _pending(self) -> list[Path]: + """ + 列出本服务创建的维护日志,不递归扫描外部路径。 + + :return: 按摘要排序的维护日志路径 + """ + root = self.catalog.store.root / '.maintenance' + ArtifactStore._assert_real_path(root) + return sorted(root.glob('*.json')) if root.exists() else [] + + def _paths(self) -> list[Path]: + """ + 有界枚举普通制品目录,在进入每级目录前排除链接。 + + :return: 已检查边界的摘要目录列表 + """ + root = self.catalog.store.root + result = [] + if not root.exists(): + return result + for plugin in root.iterdir(): + if plugin.name.startswith('.'): + continue + ArtifactStore._assert_real_path(plugin) + if not plugin.is_dir(): + continue + for version in plugin.iterdir(): + ArtifactStore._assert_real_path(version) + if not version.is_dir(): + continue + for path in version.iterdir(): + ArtifactStore._assert_real_path(path) + if DIGEST_PATTERN.fullmatch(path.name) and path.is_dir(): + result.append(path) + if len(result) > MAX_INVENTORY_OBJECTS: + raise ValueError('制品数量超过单次维护检查上限') + return result + + async def inspect(self, *, keep_last: int = 2, min_age_days: int = 7) -> dict[str, Any]: + """ + 核对文件、索引和发布引用,生成无写入的保留与清理计划。 + + :param keep_last: 每个插件至少保留的最近导入制品数量 + :param min_age_days: 制品最短保留天数 + :return: 完整检查结果、候选摘要与计划摘要 + """ + self.config.require_enabled() + if keep_last < 0 or min_age_days < 0: + raise ValueError('保留数量和天数不能为负数') + async with self.session_factory() as db: + records = {row.digest: row for row in await self.dao.list_artifacts(db)} + releases = await self.dao.list_releases(db) + workers = await self.dao.list_worker_reports(db) + protected: dict[str, set[str]] = {} + for release in releases: + for field in ('target_digest', 'previous_digest', 'prepared_digest'): + digest = getattr(release, field) + if digest: + protected.setdefault(digest, set()).add(field) + for worker in workers: + if worker.artifact_digest and worker.state != 'stopped': + protected.setdefault(worker.artifact_digest, set()).add('worker_reference') + recent: dict[str, int] = {} + for record in sorted(records.values(), key=lambda item: (item.create_time, item.digest), reverse=True): + count = recent.get(record.plugin_id, 0) + if count < keep_last: + protected.setdefault(record.digest, set()).add('keep_last') + recent[record.plugin_id] = count + 1 + paths = {path.relative_to(self.catalog.store.root).as_posix(): path for path in self._paths()} + for record in records.values(): + # 索引只能定位规范目录,不能授予任意文件系统路径权限。 + paths.setdefault(record.relative_path, self._object_path(record.relative_path)) + keys = self.config.trusted_keys() + cutoff = (TimezoneUtil.utc_now() - timedelta(days=min_age_days)).timestamp() + rows = [] + for relative, path in sorted(paths.items()): + digest = path.name + record = records.get(digest) + reasons = set(protected.get(digest, set())) + row = {'digest': digest, 'relativePath': relative, 'registered': record is not None} + await asyncio.to_thread(self._inspect_object, row, path, record, reasons, cutoff, keys) + rows.append(row) + pending = [path.stem for path in self._pending()] + basis = {'keepLast': keep_last, 'minAgeDays': min_age_days, 'objects': rows, 'pending': pending} + plan_id = hashlib.sha256(json.dumps(basis, sort_keys=True, ensure_ascii=False).encode()).hexdigest() + return { + 'ok': True, + 'dryRun': True, + **basis, + 'planId': plan_id, + 'candidates': [row['digest'] for row in rows if row['candidate']], + } + + def _inspect_object( + self, row: dict[str, Any], path: Path, record: Any, reasons: set[str], cutoff: float, keys: dict[str, Any] + ) -> None: + """ + 校验单个对象并附加不可清理原因。 + + :param row: 待补充的对象结果 + :param path: 规范制品路径 + :param record: 可选数据库索引 + :param reasons: 当前保护原因 + :param cutoff: 最短保留时间边界 + :param keys: 当前可信公钥 + :return: None + """ + relative = row['relativePath'] + try: + stored = self.catalog.store.verify_stored(path, keys) + self.catalog.validate_candidate(stored) + if record is not None and ( + record.relative_path != relative + or (record.plugin_id, record.version, record.key_id) + != (stored.plugin_id, stored.version, stored.key_id) + ): + raise ValueError('制品索引与实际身份不一致') + created = record.create_time.timestamp() if record is not None else path.stat().st_mtime + if created > cutoff: + reasons.add('min_age') + row.update(pluginId=stored.plugin_id, version=stored.version, keyId=stored.key_id) + row['status'] = 'registered' if record is not None else 'orphan' + row['bytes'] = sum(item.size for item in stored.files) + except (ValueError, OSError) as exc: + row['status'] = 'invalid' if path.exists() else 'missing' + row['error'] = str(exc) + reasons.add('verification_failed') + row['protectedBy'] = sorted(reasons) + row['candidate'] = not reasons + + def _write_journal(self, journal: dict[str, Any]) -> Path: + """ + 在任何文件变更前独占写入并刷盘维护日志。 + + :param journal: 仅包含制品身份与恢复材料的日志 + :return: 已持久化日志路径 + """ + root = self.catalog.store.root / '.maintenance' + self.catalog.store._mkdir(root) + path = root / f'{journal["digest"]}.json' + ArtifactStore._assert_real_path(path) + with path.open('xb') as stream: + stream.write(json.dumps(journal, ensure_ascii=False, sort_keys=True).encode()) + stream.flush() + os.fsync(stream.fileno()) + return path + + def _quarantine(self, digest: str) -> Path: + """ + 获取维护日志所属摘要的隔离目录。 + + :param digest: 已校验的制品摘要 + :return: 存储内部的隔离路径 + """ + if not DIGEST_PATTERN.fullmatch(digest): + raise ValueError('维护日志摘要无效') + root = self.catalog.store.root / '.maintenance' / 'objects' + self.catalog.store._mkdir(root) + path = root / digest + ArtifactStore._assert_real_path(path) + return path + + def _remove_quarantine(self, path: Path, digest: str) -> None: + """ + 再次验证隔离对象及其删除边界,只移除已确认摘要的完整对象。 + + :param path: 本次维护隔离目录 + :param digest: 授权清理的摘要 + :return: None + """ + root = (self.catalog.store.root / '.maintenance' / 'objects').resolve() + ArtifactStore._assert_real_path(path) + if path.parent.resolve() != root or path.name != digest: + raise ValueError('制品清理目录越界') + stored = self.catalog.store._verify_directory(path, self.config.trusted_keys()) + if stored.digest != digest: + raise ValueError('隔离制品摘要不一致') + shutil.rmtree(path) + + async def prune( + self, + *, + keep_last: int = 2, + min_age_days: int = 7, + expected_plan: str | None = None, + dry_run: bool = True, + maintenance: bool = False, + actor: str | None = None, + ) -> dict[str, Any]: + """ + 按复核后的计划清理未引用制品,默认只预演。 + + :param keep_last: 每个插件至少保留的最近导入数量 + :param min_age_days: 最短保留天数 + :param expected_plan: 操作者已核对的计划摘要 + :param dry_run: 是否仅返回计划 + :param maintenance: 是否确认全部宿主进程已停止 + :param actor: 操作者 + :return: 计划或实际清理摘要 + """ + if dry_run: + return await self.inspect(keep_last=keep_last, min_age_days=min_age_days) + self._require_maintenance(maintenance) + async with self.lifecycle_lock.lock('__artifacts__', 'artifact_prune') as lock: + if not lock.acquired: + raise ValueError(lock.message) + await self._require_quiescent() + self.catalog.require_no_pending_maintenance() + plan = await self.inspect(keep_last=keep_last, min_age_days=min_age_days) + if not expected_plan or plan['planId'] != expected_plan: + raise ValueError('制品清理计划已变化,请重新预演并核对 --expected-plan') + removed = [] + for row in plan['objects']: + if not row['candidate']: + continue + path = self._object_path(row['relativePath']) + journal = self._write_journal({**row, 'action': 'prune'}) + quarantine = self._quarantine(row['digest']) + if quarantine.exists(): + raise ValueError('隔离目录已存在,须先恢复维护日志') + path.rename(quarantine) + async with self.session_factory() as db: + await db.execute(delete(SysPluginArtifact).where(SysPluginArtifact.digest == row['digest'])) + await self._audit(db, {**row, 'ok': True, 'operation': 'artifact_prune'}, actor) + await db.commit() + self._remove_quarantine(quarantine, row['digest']) + journal.unlink() + removed.append(row['digest']) + return {'ok': True, 'dryRun': False, 'removed': removed, 'planId': plan['planId']} + + def _replace_signature(self, path: Path, signature: str) -> None: + """ + 在存储内部写入临时签名后原子替换,不改变插件内容和制品摘要。 + + :param path: 已验证的制品根目录 + :param signature: 待写入的签名 JSON + :return: None + """ + temporary_root = self.catalog.store.root / '.maintenance' + ArtifactStore._assert_real_path(temporary_root) + ArtifactStore._assert_real_path(path / 'signature.json') + descriptor, temporary_name = tempfile.mkstemp(prefix=f'{path.name}-', suffix='.signature', dir=temporary_root) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, 'wb') as stream: + stream.write(signature.encode()) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path / 'signature.json') + finally: + temporary.unlink(missing_ok=True) + + async def rotate_signature( + self, + archive: Path, + *, + expected_key_id: str, + maintenance: bool = False, + actor: str | None = None, + ) -> dict[str, Any]: + """ + 用新可信签名验证同一摘要的现有字节,再更新签名和索引。 + + :param archive: 由新密钥签名且内容完全一致的 rpk + :param expected_key_id: 最近读取的原签名密钥标识 + :param maintenance: 是否确认全部宿主进程已停止 + :param actor: 操作者 + :return: 保持摘要与版本不变的轮换结果 + """ + self._require_maintenance(maintenance) + async with self.lifecycle_lock.lock('__artifacts__', 'artifact_rotate_signature') as lock: + if not lock.acquired: + raise ValueError(lock.message) + await self._require_quiescent() + self.catalog.require_no_pending_maintenance() + verified = await asyncio.to_thread(verify_artifact, archive, self.config.trusted_keys()) + self.config.authorize_publisher(verified.key_id, verified.plugin_id) + async with self.session_factory() as db: + record = await self.dao.get_artifact(db, verified.digest) + if record is None or record.key_id != expected_key_id: + raise ValueError('制品未登记或签名密钥已变化') + if verified.key_id == record.key_id: + raise ValueError('新旧签名密钥标识必须不同') + path = self._object_path(record.relative_path) + new_signature = verified.signature.model_dump_json(by_alias=True) + stored = self.catalog.store._verify_directory( + path, self.config.trusted_keys(), signature_override=new_signature.encode() + ) + if (stored.plugin_id, stored.version, stored.digest) != ( + record.plugin_id, + record.version, + record.digest, + ) or path.relative_to(self.catalog.store.root).parts != ( + stored.plugin_id, + stored.version, + stored.digest, + ): + raise ValueError('轮换制品内容与原索引不一致') + self.catalog.validate_candidate(stored) + old_signature = (path / 'signature.json').read_text(encoding='utf-8') + journal = self._write_journal( + { + 'action': 'rotate', + 'digest': stored.digest, + 'relativePath': record.relative_path, + 'oldKeyId': record.key_id, + 'newKeyId': stored.key_id, + 'oldSignature': old_signature, + 'newSignature': new_signature, + } + ) + self._replace_signature(path, new_signature) + result = await db.execute( + update(SysPluginArtifact) + .where( + SysPluginArtifact.digest == stored.digest, + SysPluginArtifact.key_id == expected_key_id, + ) + .values(key_id=stored.key_id) + ) + if result.rowcount != 1: + raise ValueError('制品签名索引发生并发变化') + payload = { + 'ok': True, + 'operation': 'artifact_rotate_signature', + 'pluginId': stored.plugin_id, + 'digest': stored.digest, + 'version': stored.version, + 'oldKeyId': expected_key_id, + 'keyId': stored.key_id, + } + await self._audit(db, payload, actor) + await db.commit() + journal.unlink() + return payload + + async def reconcile(self, *, maintenance: bool = False, actor: str | None = None) -> dict[str, Any]: + """ + 依据已提交的数据库状态恢复中断操作,不重新执行发布或迁移。 + + :param maintenance: 是否确认全部宿主进程已停止 + :param actor: 操作者 + :return: 已恢复的制品摘要列表 + """ + self._require_maintenance(maintenance) + restored = [] + async with self.lifecycle_lock.lock('__artifacts__', 'artifact_reconcile') as lock: + if not lock.acquired: + raise ValueError(lock.message) + await self._require_quiescent() + for journal_path in self._pending(): + ArtifactStore._assert_real_path(journal_path) + if journal_path.stat().st_size > MAX_JOURNAL_BYTES: + raise ValueError('制品维护日志过大') + journal = strict_json(journal_path.read_bytes()) + digest = journal['digest'] + if not DIGEST_PATTERN.fullmatch(digest) or journal_path.stem != digest: + raise ValueError('制品维护日志身份无效') + path = self._object_path(journal['relativePath']) + if path.name != digest: + raise ValueError('维护日志目录与摘要不一致') + async with self.session_factory() as db: + record = await self.dao.get_artifact(db, digest) + if journal['action'] == 'prune': + self._recover_prune(journal, path, record) + elif journal['action'] == 'rotate': + self._recover_rotation(journal, path, record) + else: + raise ValueError('未知制品维护日志操作') + await self._audit( + db, + { + 'ok': True, + 'operation': 'artifact_reconcile', + 'pluginId': path.parts[-OBJECT_PATH_PARTS], + 'digest': digest, + 'recoveredAction': journal['action'], + }, + actor, + ) + await db.commit() + journal_path.unlink() + restored.append(digest) + return {'ok': True, 'restored': restored} + + def _recover_prune(self, journal: dict[str, Any], path: Path, record: Any) -> None: + """ + 根据索引提交结果恢复或清理隔离对象。 + + :param journal: 已检查的维护日志 + :param path: 规范制品目录 + :param record: 当前已提交的索引 + :return: None + """ + digest = journal['digest'] + quarantine = self._quarantine(digest) + if quarantine.exists(): + if path.exists(): + raise ValueError('原目录和隔离目录同时存在,拒绝自动覆盖') + stored = self.catalog.store._verify_directory(quarantine, self.config.trusted_keys()) + if stored.digest != digest: + raise ValueError('隔离对象与日志摘要不一致') + if record is not None: + if record.relative_path != journal['relativePath']: + raise ValueError('恢复目标与制品索引不一致') + quarantine.rename(path) + else: + self._remove_quarantine(quarantine, digest) + elif not path.exists() and record is not None: + raise ValueError('索引仍存在但原目录和隔离目录均已丢失') + + def _recover_rotation(self, journal: dict[str, Any], path: Path, record: Any) -> None: + """ + 根据索引中的密钥恢复签名文件。 + + :param journal: 已检查的维护日志 + :param path: 规范制品目录 + :param record: 当前已提交的索引 + :return: None + """ + digest = journal['digest'] + if record is None or record.relative_path != journal['relativePath']: + raise ValueError('轮换恢复缺少匹配的制品索引') + if record.key_id not in {journal['oldKeyId'], journal['newKeyId']}: + raise ValueError('签名索引已被其他操作更新') + stored = self.catalog.store._verify_directory( + path, self.config.trusted_keys(), signature_override=journal['newSignature'].encode() + ) + if stored.digest != digest or stored.key_id != journal['newKeyId']: + raise ValueError('轮换恢复签名与日志身份不一致') + self.catalog.validate_candidate(stored) + signature = journal['newSignature'] if record.key_id == journal['newKeyId'] else journal['oldSignature'] + self._replace_signature(path, signature) diff --git a/ruoyi-fastapi-backend/plugins/core/deployment/service.py b/ruoyi-fastapi-backend/plugins/core/deployment/service.py new file mode 100644 index 0000000..c4da7c5 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/deployment/service.py @@ -0,0 +1,538 @@ +import sys +from collections.abc import Callable +from datetime import timedelta +from typing import Any + +from config.database import DataSourceRegistry +from plugins.core.artifacts import StoredArtifact +from plugins.core.deployment.catalog import PluginArtifactCatalog, artifact_payload +from plugins.core.deployment.config import PluginDeploymentConfig +from plugins.core.deployment.convergence import wait_for_release +from plugins.core.deployment.state import RUNTIME_PLUGIN_ID, aggregate_release +from plugins.core.discovery.scanner import DiscoveredPlugin, PluginScanner +from plugins.core.environment import PluginRuntimeEnvironmentService +from plugins.core.management.dao.dao import PluginDao +from plugins.core.management.dao.release_dao import PluginReleaseConflictError, PluginReleaseDao +from plugins.core.management.service.service import PluginService +from plugins.core.runtime.service.lifecycle_lock import NoopPluginLifecycleLock, RedisPluginLifecycleLock +from plugins.core.utils import validate_plugin_id_value +from plugins.core.validation.versioning import PluginVersionComparator +from utils.time_util import TimezoneUtil + + +def build_maintenance_runtime(config: PluginDeploymentConfig, plugins: list[DiscoveredPlugin]) -> Any: + """ + 复用现有生命周期用例,为独立维护实例注入制品发现快照。 + + :param config: 制品发布配置快照 + :param plugins: 候选制品及其依赖使用的插件发现快照 + :return: 已配置维护模式和管理网关的插件运行时 + """ + from plugins.core.management.service.gateway import PluginManagementRuntimeGateway # noqa: PLC0415 + from plugins.core.runtime.service import PluginRuntimeService # noqa: PLC0415 + from plugins.core.runtime.service.dependency_container import PluginRuntimeGatewayOverrides # noqa: PLC0415 + + environment = PluginRuntimeEnvironmentService(backend_root=config.backend_root, python_executable=sys.executable) + environment.backend_runtime_mode = 'maintenance' + gateway = PluginManagementRuntimeGateway() + runtime = PluginRuntimeService( + runtime_environment=environment, + gateways=PluginRuntimeGatewayOverrides( + config_gateway=gateway, + audit_gateway=gateway, + state_query_gateway=gateway, + migration_history_gateway=gateway, + purge_plan_gateway=gateway, + lifecycle_state_gateway=gateway, + lifecycle_uow_gateway=gateway, + migration_execution_gateway=gateway, + ), + model_gateway=gateway, + command_gateway=gateway, + # 外层发布服务已经持有同一个全局生命周期锁,不能在此二次竞争。 + lifecycle_lock=NoopPluginLifecycleLock(), + ) + runtime.context.set_discovery_snapshot(plugins) + return runtime + + +class PluginDeploymentService: + """ + 签名制品维护准备、发布目标选择及集群状态查询服务。 + """ + + def __init__( + self, + config: PluginDeploymentConfig, + *, + session_factory: Any = None, + dao: Any = PluginReleaseDao, + lifecycle_lock: Any = None, + runtime_factory: Callable[..., Any] = build_maintenance_runtime, + ) -> None: + """ + 初始化签名制品维护发布服务。 + + :param config: 制品发布配置快照 + :param session_factory: 异步数据库会话工厂,未提供时使用宿主数据源 + :param dao: 制品、发布目标及进程报告的数据访问接口 + :param lifecycle_lock: 与插件维护发布共用的生命周期锁 + :param runtime_factory: 用于创建独立维护运行时的工厂 + :return: None + """ + self.config = config + self.session_factory = session_factory or DataSourceRegistry.session + self.dao = dao + self.catalog = PluginArtifactCatalog(config, session_factory=self.session_factory, dao=dao) + self.lifecycle_lock = lifecycle_lock or RedisPluginLifecycleLock() + self.runtime_factory = runtime_factory + + async def status(self, plugin_id: str | None = None) -> dict[str, Any]: + """ + 汇总发布目标、维护准备证据及宿主进程实际加载状态。 + + :param plugin_id: 可选的插件ID过滤条件,为 None 时汇总全部发布记录 + :return: 发布摘要列表和进程报告列表 + """ + self.config.require_enabled() + if plugin_id is not None: + validate_plugin_id_value(plugin_id) + async with self.session_factory() as db: + releases = await self.dao.list_releases(db) + reports = await self.dao.list_worker_reports(db, plugin_id) + rows = [] + for release in releases: + if plugin_id is not None and release.plugin_id != plugin_id: + continue + plugin = await PluginDao.get_plugin_by_id(db, release.plugin_id) + summary = aggregate_release( + release, + reports, + now=TimezoneUtil.utc_now(), + ttl_seconds=self.config.worker_ttl_seconds, + enabled=getattr(plugin, 'enabled', '1') == '0', + ).to_payload() + summary.update( + installedVersion=getattr(plugin, 'installed_version', None), + enabled=getattr(plugin, 'enabled', '1') == '0', + preparedDigest=release.prepared_digest, + preparedVersion=release.prepared_version, + previousDigest=release.previous_digest, + ) + rows.append(summary) + workers = [ + { + 'workerId': item.worker_id, + 'pluginId': item.plugin_id, + 'digest': item.artifact_digest, + 'version': item.version, + 'generation': item.generation, + 'state': item.state, + 'heartbeatTime': item.heartbeat_time.isoformat(), + 'error': item.error, + } + for item in reports + ] + return {'ok': True, 'releases': rows, 'workers': workers} + + async def wait( + self, + plugin_id: str, + digest: str, + *, + expected_generation: str, + expected_workers: int, + timeout_seconds: float = 300, + poll_interval_seconds: float = 2, + ) -> dict[str, Any]: + """ + 只读等待调用方固定的发布目标在全部有效存活 worker 上收敛。 + + :param plugin_id: 固定验收的插件 ID + :param digest: 固定验收的制品 SHA256 摘要 + :param expected_generation: 固定验收的发布代际 + :param expected_workers: 必须与发布记录相同的预期 worker 数量下限 + :param timeout_seconds: 总体等待秒数,零表示单次断言 + :param poll_interval_seconds: 未收敛时的查询间隔秒数 + :return: 固定目标的收敛结果及最后一次有效观测 + :raises ValueError: 固定身份或等待参数不合法 + :raises asyncio.CancelledError: 调用方取消等待 + """ + return await wait_for_release( + self.status, + plugin_id, + digest, + expected_generation=expected_generation, + expected_workers=expected_workers, + worker_ttl_seconds=self.config.worker_ttl_seconds, + timeout_seconds=timeout_seconds, + poll_interval_seconds=poll_interval_seconds, + ) + + async def _require_quiescent(self) -> None: + """ + 在全局锁内检查存活宿主报告,确认当前没有进程阻挡维护。 + + 该检查依赖已上报的进程状态,调用方仍须先实际停止全部宿主进程。 + + :return: None + :raises ValueError: 仍存在有效期内且尚未停止的宿主进程报告 + """ + cutoff = TimezoneUtil.utc_now() - timedelta(seconds=self.config.worker_ttl_seconds) + async with self.session_factory() as db: + reports = await self.dao.list_worker_reports(db) + if any( + item.plugin_id == RUNTIME_PLUGIN_ID and item.state != 'stopped' and item.heartbeat_time >= cutoff + for item in reports + ): + raise ValueError('仍有存活的宿主 worker,请停止全部 worker 后执行维护发布') + + @staticmethod + def _require_maintenance(maintenance: bool) -> None: + """ + 要求调用方显式确认已进入停机维护窗口。 + + :param maintenance: 是否已显式确认在停止全部宿主进程后执行维护 + :return: None + :raises ValueError: 调用方未显式确认停机维护 + """ + if not maintenance: + raise ValueError('该操作必须在独立维护进程执行,并显式传入 --maintenance') + + @staticmethod + def _require_clean_process(plugin: DiscoveredPlugin) -> None: + """ + 拒绝已加载目标插件模块的进程继续执行制品维护准备。 + + :param plugin: 本次拟执行维护准备的插件发现对象 + :return: None + :raises ValueError: 当前进程已经导入该插件的模块命名空间 + """ + namespace = plugin.manifest.backend.module + if any(name == namespace or name.startswith(f'{namespace}.') for name in sys.modules): + raise ValueError('该维护进程已加载插件模块,请启动新进程执行,不能复用旧版本模块') + + async def _snapshot(self, candidate: StoredArtifact) -> list[DiscoveredPlugin]: + """ + 收集源码插件、候选制品和其他发布目标供静态依赖预检使用。 + + :param candidate: 本次拟预检的制品对象 + :return: 未导入插件代码的发现结果列表 + """ + sources = PluginScanner(self.config.backend_root / 'plugins').discover_with_errors().plugins + async with self.session_factory() as db: + selected = [(row.plugin_id, row.target_digest) for row in await self.dao.list_releases(db)] + result = {plugin.manifest.id: plugin for plugin in sources} + for plugin_id, digest in selected: + if digest and plugin_id != candidate.plugin_id: + artifact = await self.catalog.get(digest) + result[plugin_id] = self.catalog.discovered(artifact) + result[candidate.plugin_id] = self.catalog.discovered(candidate) + return list(result.values()) + + async def _read_state(self, plugin_id: str) -> tuple[Any, Any]: + """ + 读取指定插件的发布记录和数据结构安装状态。 + + :param plugin_id: 插件ID + :return: 发布记录与插件记录,缺失记录分别为 None + """ + async with self.session_factory() as db: + release = await self.dao.get_release(db, plugin_id) + plugin = await PluginDao.get_plugin_by_id(db, plugin_id) + return release, plugin + + @staticmethod + def _has_prepared_evidence(release: Any, digest: str, installed: str | None) -> bool: + """ + 检查制品摘要与已安装数据结构版本是否具有一致的维护准备证据。 + + :param release: 插件发布目标及维护准备记录 + :param digest: 制品规范元数据的小写 SHA256 摘要 + :param installed: 当前已安装的数据结构版本,未安装时为 None + :return: 是否存在完全匹配的维护准备证据 + """ + return bool( + release is not None + and release.prepare_status == 'prepared' + and release.prepared_digest == digest + and release.prepared_version == installed + ) + + async def plan(self, plugin_id: str, digest: str) -> dict[str, Any]: + """ + 静态预检制品及维护操作,区分数据结构准备与代码目标切换。 + + :param plugin_id: 插件ID + :param digest: 制品规范元数据的小写 SHA256 摘要 + :return: 可执行性、所需维护操作及准备证据说明 + :raises ValueError: 请求的制品不属于指定插件或当前制品校验失败 + """ + validate_plugin_id_value(plugin_id) + artifact = await self.catalog.get(digest) + if artifact.plugin_id != plugin_id: + raise ValueError('制品不属于请求的插件') + release, plugin = await self._read_state(plugin_id) + installed = getattr(plugin, 'installed_version', None) + operation = 'install' if not installed else 'upgrade' + comparison = PluginVersionComparator.compare(artifact.version, installed) if installed else 1 + if installed and comparison != 1: + operation = 'reuse_prepared' if comparison == 0 else 'rollback_required' + runtime = self.runtime_factory(self.config, await self._snapshot(artifact)) + if operation in {'install', 'upgrade'}: + precheck = await getattr(runtime, f'{operation}_plugin')(plugin_id, dry_run=True) + else: + precheck = await runtime.check_plugin_async(plugin_id) + preparation_matches = self._has_prepared_evidence(release, digest, installed) + same_version_ready = operation != 'reuse_prepared' or (installed == artifact.version and preparation_matches) + return { + 'ok': bool(precheck.get('ok')) and operation != 'rollback_required' and same_version_ready, + 'operation': operation, + **artifact_payload(artifact), + 'installedVersion': installed, + 'targetDigest': getattr(release, 'target_digest', None), + 'generation': getattr(release, 'generation', None), + 'restartRequired': True, + 'maintenanceRequired': True, + 'preparationMatches': preparation_matches, + 'message': ( + '同版本制品缺少匹配的维护准备证据;请提升版本后发布,不能猜测已安装内容' + if not same_version_ready + else str(precheck.get('message') or '') + ), + 'precheck': precheck, + } + + async def prepare( + self, + plugin_id: str, + digest: str, + *, + maintenance: bool = False, + actor: str | None = None, + dry_run: bool = False, + ) -> dict[str, Any]: + """ + 在停机维护窗口执行安装或升级,并保存与制品绑定的准备证据。 + + 准备成功不会自动选择发布目标;目标选择须通过单独的显式操作完成。 + + :param plugin_id: 插件ID + :param digest: 制品规范元数据的小写 SHA256 摘要 + :param maintenance: 是否已显式确认在停止全部宿主进程后执行维护 + :param actor: 本次操作的执行者,未提供时不记录执行者名称 + :param dry_run: 是否只执行预检而不写入文件或数据库 + :return: 维护准备结果或只读预检计划 + :raises ValueError: 维护条件、版本顺序或生命周期准备结果不符合发布要求 + """ + if dry_run: + return await self.plan(plugin_id, digest) + self._require_maintenance(maintenance) + async with self.lifecycle_lock.lock(plugin_id, 'release_prepare') as lock: + if not lock.acquired: + raise ValueError(lock.message) + await self._require_quiescent() + artifact = await self.catalog.get(digest) + if artifact.plugin_id != validate_plugin_id_value(plugin_id): + raise ValueError('制品不属于请求的插件') + candidate = self.catalog.discovered(artifact) + self._require_clean_process(candidate) + release, plugin = await self._read_state(plugin_id) + installed = getattr(plugin, 'installed_version', None) + if installed == artifact.version: + if self._has_prepared_evidence(release, digest, installed): + return {'ok': True, 'message': '该制品已完成维护准备', **artifact_payload(artifact)} + raise ValueError('同版本制品缺少匹配的维护准备证据;请提升版本后发布,不能猜测已安装内容') + if installed and PluginVersionComparator.compare(artifact.version, installed) != 1: + raise ValueError('维护准备只允许升级;代码回滚请使用显式 rollback 命令') + async with self.session_factory() as db: + release = await self.dao.set_preparation_status(db, plugin_id, status='preparing', actor=actor) + generation = release.generation + await db.commit() + operation = 'install' if not installed else 'upgrade' + try: + # 插件可能在 Hook 内延迟 import;整个维护进程禁写字节码以保持制品完整性。 + sys.dont_write_bytecode = True + runtime = self.runtime_factory(self.config, await self._snapshot(artifact)) + result = await getattr(runtime, f'{operation}_plugin')(plugin_id, operated_by=actor) + if not result.get('ok'): + raise ValueError(str(result.get('message') or '插件生命周期准备失败')) + # Hook 不得修改自身发布目录;准备完成前重新检查全部字节。 + await self.catalog.get(digest) + async with self.session_factory() as db: + current = await PluginDao.get_plugin_by_id(db, plugin_id) + if current is None or current.installed_version != artifact.version: + raise ValueError('维护准备后安装版本没有到达目标版本') + await self.dao.set_preparation_status( + db, + plugin_id, + status='prepared', + prepared_digest=digest, + prepared_version=current.installed_version, + actor=actor, + expected_generation=generation, + ) + await db.commit() + return { + 'ok': True, + 'message': '维护准备完成;选择目标制品后再启动 worker', + 'generation': generation, + 'restartRequired': True, + **artifact_payload(artifact), + } + except BaseException as exc: + async with self.session_factory() as db: + await self.dao.set_preparation_status( + db, + plugin_id, + status='failed', + last_error=str(exc)[:2000] or type(exc).__name__, + actor=actor, + expected_generation=generation, + ) + await db.commit() + raise + + async def select( + self, + plugin_id: str, + digest: str, + *, + expected_generation: str, + expected_workers: int = 1, + maintenance: bool = False, + actor: str | None = None, + ) -> dict[str, Any]: + """ + 在匹配准备证据和发布代际的前提下选择目标制品。 + + :param plugin_id: 插件ID + :param digest: 制品规范元数据的小写 SHA256 摘要 + :param expected_generation: 调用方读取并确认的发布代际,用于并发状态校验 + :param expected_workers: 预期启动并报告当前目标的宿主进程数量 + :param maintenance: 是否已显式确认在停止全部宿主进程后执行维护 + :param actor: 本次操作的执行者,未提供时不记录执行者名称 + :return: 新发布代际、目标制品身份和重启要求 + :raises ValueError: 维护条件、制品归属或准备证据不符合要求 + :raises PluginReleaseConflictError: 发布代际在操作期间发生变化 + """ + self._require_maintenance(maintenance) + async with self.lifecycle_lock.lock(plugin_id, 'release_select') as lock: + if not lock.acquired: + raise ValueError(lock.message) + await self._require_quiescent() + artifact = await self.catalog.get(digest) + if artifact.plugin_id != validate_plugin_id_value(plugin_id): + raise ValueError('目标制品不属于请求的插件') + async with self.session_factory() as db: + release = await self.dao.select_target( + db, + plugin_id, + target_digest=digest, + expected_generation=expected_generation, + expected_workers=expected_workers, + actor=actor, + ) + payload = { + 'ok': True, + 'operation': 'release_select', + 'message': '发布目标已更新,等待全部 worker 重启并报告实际版本', + 'generation': release.generation, + 'restartRequired': True, + 'expectedWorkers': expected_workers, + **artifact_payload(artifact), + } + await self._audit(db, payload, actor) + await db.commit() + return payload + + async def rollback( + self, + plugin_id: str, + *, + expected_generation: str, + schema_compatible: bool = False, + maintenance: bool = False, + actor: str | None = None, + ) -> dict[str, Any]: + """ + 切回上一代码制品并重建声明资源,保留当前数据结构安装版本。 + + :param plugin_id: 插件ID + :param expected_generation: 调用方读取并确认的发布代际,用于并发状态校验 + :param schema_compatible: 是否明确确认旧代码兼容当前数据结构,回滚不会撤销数据库迁移 + :param maintenance: 是否已显式确认在停止全部宿主进程后执行维护 + :param actor: 本次操作的执行者,未提供时不记录执行者名称 + :return: 回滚后的发布代际、代码制品身份及保留的安装版本 + :raises ValueError: 未确认结构兼容性、没有上一制品或回滚预检失败 + :raises PluginReleaseConflictError: 发布代际在操作期间发生变化 + """ + self._require_maintenance(maintenance) + if not schema_compatible: + raise ValueError('代码回滚不会撤销数据库迁移,须确认旧代码兼容当前数据结构并传入 --schema-compatible') + async with self.lifecycle_lock.lock(plugin_id, 'release_rollback') as lock: + if not lock.acquired: + raise ValueError(lock.message) + await self._require_quiescent() + release, plugin = await self._read_state(validate_plugin_id_value(plugin_id)) + if release is None or release.generation != expected_generation: + raise PluginReleaseConflictError('发布代际已变化,请重新读取目标') + if not release.previous_digest or plugin is None or not plugin.installed_version: + raise ValueError('没有可回滚的上一制品或数据结构安装状态') + artifact = await self.catalog.get(release.previous_digest) + candidate = self.catalog.discovered(artifact) + runtime = self.runtime_factory(self.config, await self._snapshot(artifact)) + check = await runtime.check_plugin_async(plugin_id) + if not check.get('ok'): + raise ValueError(str(check.get('message') or '回滚制品预检失败')) + async with self.session_factory() as db: + # 重建菜单/配置/任务声明,绝不执行旧版迁移、seed或Hook。 + await PluginService.upsert_discovered_plugin_services( + db, candidate, self.config.backend_root / 'plugins' + ) + await PluginService.install_plugin_menu_services(db, candidate, enabled=plugin.enabled == '0') + await PluginService.install_plugin_default_config_services(db, candidate) + await PluginService.install_plugin_job_services(db, candidate, enabled=plugin.enabled == '0') + await self.dao.set_preparation_status( + db, + plugin_id, + status='prepared', + prepared_digest=artifact.digest, + prepared_version=plugin.installed_version, + actor=actor, + expected_generation=expected_generation, + ) + selected = await self.dao.select_target( + db, + plugin_id, + target_digest=artifact.digest, + expected_generation=expected_generation, + expected_workers=release.expected_workers, + actor=actor, + ) + payload = { + 'ok': True, + 'operation': 'release_rollback', + 'message': '已选择上一代码制品,数据库版本保持不变;请重启全部 worker', + 'installedVersion': plugin.installed_version, + 'generation': selected.generation, + 'restartRequired': True, + **artifact_payload(artifact), + } + await self._audit(db, payload, actor) + await db.commit() + return payload + + @staticmethod + async def _audit(db: Any, payload: dict[str, Any], actor: str | None) -> None: + """ + 在调用方事务中记录发布操作审计,审计失败时向上传播异常。 + + :param db: 当前操作使用的数据库会话 + :param payload: 需要写入审计的操作结果数据 + :param actor: 本次操作的执行者,未提供时不记录执行者名称 + :return: None + """ + await PluginService.add_plugin_operation_log_services( + db, {**payload, 'operatedBy': actor}, dry_run=False, continue_on_error=False + ) diff --git a/ruoyi-fastapi-backend/plugins/core/deployment/state.py b/ruoyi-fastapi-backend/plugins/core/deployment/state.py new file mode 100644 index 0000000..6d2662b --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/deployment/state.py @@ -0,0 +1,234 @@ +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any + +RUNTIME_PLUGIN_ID = '__runtime__' + + +def _field(row: object, name: str, default: Any = None) -> Any: + """ + 从映射或对象属性中读取统一的状态字段。 + + :param row: 状态字段所在的映射或对象 + :param name: 需要读取的字段名称 + :param default: 字段不存在时使用的默认值 + :return: 指定字段值,不存在时返回默认值 + """ + return row.get(name, default) if isinstance(row, Mapping) else getattr(row, name, default) + + +def _fresh(report: object, *, now: datetime, cutoff: datetime) -> bool: + """ + 判断报告心跳是否带时区且位于当前有效时间窗口内。 + + :param report: 插件进程报告,缺少报告时可为 None + :param now: 本次汇总使用的带时区 UTC 时间 + :param cutoff: 仍可视为有效心跳的最早时间 + :return: 心跳是否有效且没有位于未来时间 + """ + heartbeat = _field(report, 'heartbeat_time') + return ( + isinstance(heartbeat, datetime) + and heartbeat.tzinfo is not None + and heartbeat.utcoffset() is not None + and cutoff <= heartbeat <= now + ) + + +def _worker_state( + host: object, + report: object, + target_digest: str | None, + generation: str | None, + *, + now: datetime, + cutoff: datetime, + enabled: bool, +) -> str: + """ + 对比宿主和插件报告,判定该进程对当前目标的加载状态。 + + :param host: 宿主进程的运行状态报告 + :param report: 插件进程报告,缺少报告时可为 None + :param target_digest: 当前发布目标的制品摘要 + :param generation: 发布代际标识 + :param now: 本次汇总使用的带时区 UTC 时间 + :param cutoff: 仍可视为有效心跳的最早时间 + :param enabled: 插件目标是否启用 + :return: 健康、停用、失败、陈旧、缺失或目标不匹配状态 + """ + if _field(host, 'state') == 'failed': + return 'failed' + if report is None: + return 'missing' + if not _fresh(report, now=now, cutoff=cutoff): + return 'stale' + if _field(report, 'artifact_digest') != target_digest or _field(report, 'generation') != generation: + return 'mismatch' + if _field(report, 'state') == 'failed': + return 'failed' + if not enabled and target_digest and _field(host, 'state') == 'ready' and _field(report, 'state') == 'stopped': + return 'disabled' + if target_digest and _field(host, 'state') == 'ready' and _field(report, 'state') == 'ready': + return 'healthy' + return 'missing' + + +def _release_status(target_digest: str | None, live: int, healthy: int, failed: int, expected: int) -> str: + """ + 根据当前目标、有效进程数量和健康情况生成汇总状态。 + + :param target_digest: 当前发布目标的制品摘要 + :param live: 有效期内未停止的宿主进程数量 + :param healthy: 已就绪且加载当前目标的进程数量 + :param failed: 报告加载或宿主运行失败的进程数量 + :param expected: 发布配置要求的预期宿主进程数量 + :return: 发布状态标识 + """ + if not target_digest: + return 'no_target' + if live >= expected and healthy == live: + return 'active' + if healthy: + return 'partial' + if failed: + return 'failed' + return 'pending_restart' + + +@dataclass(frozen=True) +class PluginReleaseSummary: + """ + 发布目标在全部存活宿主进程中的状态汇总。 + """ + + plugin_id: str + target_digest: str | None + generation: str | None + status: str + restart_required: bool + expected_workers: int + live_workers: int + healthy_workers: int + disabled_workers: int + mismatch_workers: int + stale_workers: int + missing_workers: int + failed_workers: int + prepare_status: str + last_error: str | None + + def to_payload(self) -> dict[str, object]: + """ + 将发布汇总对象转换为管理端使用的字段格式。 + + :return: 使用驼峰字段名的发布状态字典 + """ + return { + 'pluginId': self.plugin_id, + 'targetDigest': self.target_digest, + 'generation': self.generation, + 'status': self.status, + 'restartRequired': self.restart_required, + 'expectedWorkers': self.expected_workers, + 'liveWorkers': self.live_workers, + 'healthyWorkers': self.healthy_workers, + 'disabledWorkers': self.disabled_workers, + 'mismatchWorkers': self.mismatch_workers, + 'staleWorkers': self.stale_workers, + 'missingWorkers': self.missing_workers, + 'failedWorkers': self.failed_workers, + 'prepareStatus': self.prepare_status, + 'lastError': self.last_error, + } + + +def aggregate_release( + release: object, + worker_reports: Iterable[object], + *, + now: datetime, + ttl_seconds: float = 60, + enabled: bool = True, +) -> PluginReleaseSummary: + """ + 汇总全部存活宿主对发布目标的实际加载情况。 + + 只有全部存活宿主均加载目标且达到预期数量时才报告 active。 + 报告集合必须包含宿主的 __runtime__ 行;过期及已停止进程仅保留诊断信息。 + + :param release: 插件发布目标及维护准备记录 + :param worker_reports: 包含宿主行和插件行的进程报告集合 + :param now: 本次汇总使用的带时区 UTC 时间 + :param ttl_seconds: 进程心跳的有效时长,单位为秒 + :param enabled: 插件目标是否启用 + :return: 发布目标的集群状态摘要 + :raises ValueError: 当前时间未携带时区,或心跳有效期及预期进程数量不合法 + """ + if now.tzinfo is None or now.utcoffset() is None: + raise ValueError('now必须是带时区的UTC时刻') + if ttl_seconds <= 0: + raise ValueError('心跳TTL必须大于零') + now = now.astimezone(timezone.utc) + cutoff = now - timedelta(seconds=ttl_seconds) + plugin_id = _field(release, 'plugin_id', '') + target_digest = _field(release, 'target_digest') + generation = _field(release, 'generation') + expected_workers = _field(release, 'expected_workers', 1) + if isinstance(expected_workers, bool) or not isinstance(expected_workers, int) or expected_workers < 1: + raise ValueError('expected_workers必须为正整数') + hosts: dict[str, object] = {} + plugins: dict[str, object] = {} + for report in worker_reports: + report_plugin = _field(report, 'plugin_id') + worker_id = _field(report, 'worker_id') + if report_plugin == RUNTIME_PLUGIN_ID: + hosts[worker_id] = report + elif report_plugin == plugin_id: + plugins[worker_id] = report + + live_hosts = { + worker_id: report + for worker_id, report in hosts.items() + if _field(report, 'state') != 'stopped' and _fresh(report, now=now, cutoff=cutoff) + } + stale = { + worker_id + for worker_id, report in hosts.items() + if _field(report, 'state') != 'stopped' and not _fresh(report, now=now, cutoff=cutoff) + } + counts = {'healthy': 0, 'disabled': 0, 'mismatch': 0, 'missing': 0, 'failed': 0, 'stale': 0} + for worker_id, host in live_hosts.items(): + state = _worker_state( + host, plugins.get(worker_id), target_digest, generation, now=now, cutoff=cutoff, enabled=enabled + ) + counts[state] += 1 + if state == 'stale': + stale.add(worker_id) + status = _release_status(target_digest, len(live_hosts), counts['healthy'], counts['failed'], expected_workers) + if not enabled and target_digest: + status = 'pending_restart' + if len(live_hosts) >= expected_workers and counts['disabled'] == len(live_hosts): + status = 'disabled' + elif counts['disabled']: + status = 'partial' + elif counts['failed']: + status = 'failed' + return PluginReleaseSummary( + plugin_id=plugin_id, + target_digest=target_digest, + generation=generation, + status=status, + restart_required=bool(target_digest) and status not in {'active', 'disabled'}, + expected_workers=expected_workers, + live_workers=len(live_hosts), + healthy_workers=counts['healthy'], + disabled_workers=counts['disabled'], + mismatch_workers=counts['mismatch'], + stale_workers=len(stale), + missing_workers=counts['missing'] + counts['stale'] + max(0, expected_workers - len(live_hosts)), + failed_workers=counts['failed'], + prepare_status=_field(release, 'prepare_status', 'idle'), + last_error=_field(release, 'last_error'), + ) diff --git a/ruoyi-fastapi-backend/plugins/core/deployment/worker.py b/ruoyi-fastapi-backend/plugins/core/deployment/worker.py new file mode 100644 index 0000000..7562b4b --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/deployment/worker.py @@ -0,0 +1,276 @@ +import asyncio +import hashlib +import json +import sys +import time +from contextlib import suppress +from dataclasses import dataclass +from typing import Any +from uuid import uuid4 + +from config.database import DataSourceRegistry +from plugins.core.deployment.catalog import PluginArtifactCatalog +from plugins.core.deployment.config import PluginDeploymentConfig +from plugins.core.deployment.state import RUNTIME_PLUGIN_ID +from plugins.core.discovery.scanner import PluginDiscoveryError +from plugins.core.management.dao.dao import PluginDao +from plugins.core.management.dao.release_dao import PluginReleaseDao +from utils.log_util import logger +from utils.time_util import TimezoneUtil + + +@dataclass(frozen=True) +class WorkerTarget: + """ + 当前宿主进程固定的插件目标、发布代际和维护准备状态快照。 + """ + + plugin_id: str + digest: str + generation: str + enabled: bool + prepared: bool + + +class PluginReleaseWorker: + """ + 宿主发布快照与心跳上报器,注册时与维护服务共用全局锁。 + """ + + def __init__( + self, + config: PluginDeploymentConfig, + *, + session_factory: Any = None, + dao: Any = PluginReleaseDao, + ) -> None: + """ + 初始化当前宿主进程的发布快照与心跳上报器。 + + :param config: 制品发布配置快照 + :param session_factory: 异步数据库会话工厂,未提供时使用宿主数据源 + :param dao: 制品、发布目标及进程报告的数据访问接口 + :return: None + """ + self.config = config + self.session_factory = session_factory or DataSourceRegistry.session + self.dao = dao + self.catalog = PluginArtifactCatalog(config, session_factory=self.session_factory, dao=dao) + self.worker_id = uuid4().hex + self.targets: tuple[WorkerTarget, ...] = () + self.reserved_ids: set[str] = set() + self.versions: dict[str, str] = {} + self.errors: dict[str, str] = {} + self.state = 'starting' + self.task: asyncio.Task | None = None + self.app: Any = None + self.registered = False + + async def start(self, app: Any, builder: Any, lifecycle_lock: Any, *, timeout: float = 60) -> None: + """ + 在生命周期锁内登记宿主快照,验证目标制品并启动心跳上报。 + + :param app: 当前宿主应用对象 + :param builder: 接收制品发现快照的插件运行时构建器 + :param lifecycle_lock: 与插件维护发布共用的生命周期锁 + :param timeout: 等待生命周期锁的最长时间,单位为秒 + :return: None + :raises TimeoutError: 在等待时限内未能取得维护发布锁 + """ + self.config.require_enabled() + self.app = app + deadline = time.monotonic() + timeout + while True: + async with lifecycle_lock.lock(RUNTIME_PLUGIN_ID, 'release_snapshot') as lock: + if lock.acquired: + await self._register_snapshot() + break + if time.monotonic() >= deadline: + raise TimeoutError('等待插件维护发布锁超时') + await asyncio.sleep(min(1, self.config.heartbeat_seconds)) + # 验签和大文件散列期间也维持宿主存活,维护命令不能越过启动中的进程。 + self.task = asyncio.create_task(self._heartbeat(), name=f'plugin-release-{self.worker_id}') + plugins = [] + for target in self.targets: + try: + if not target.prepared: + raise ValueError('目标制品缺少匹配的数据结构维护准备证据') + artifact = await self.catalog.get(target.digest) + if artifact.plugin_id != target.plugin_id: + raise ValueError('发布目标与制品插件 ID 不一致') + self.versions[target.plugin_id] = artifact.version + plugins.append(self.catalog.discovered(artifact, target.generation)) + except Exception as exc: # noqa: PERF203 - 单个制品损坏不能阻断其他插件。 + self.errors[target.plugin_id] = str(exc)[:2000] + logger.error(f'插件发布目标验证失败:{target.plugin_id},{exc}') + errors = [ + PluginDiscoveryError(None, self.config.store_root / plugin_id, message) + for plugin_id, message in self.errors.items() + ] + builder.set_artifact_plugins(plugins, reserved_ids=self.reserved_ids, errors=errors) + if self.targets: + # 延迟导入也不能在已签名的目录内产生未登记字节码。 + sys.dont_write_bytecode = True + await self.report() + + async def _register_snapshot(self) -> None: + """ + 固定当前发布目标和保留插件ID,并登记正在启动的宿主进程。 + + :return: None + """ + async with self.session_factory() as db: + targets = [] + # 即使尚未选择目标,已导入制品的 ID 也不能回退为同名源码插件。 + self.reserved_ids = {item.plugin_id for item in await self.dao.list_artifacts(db)} + for release in await self.dao.list_releases(db): + self.reserved_ids.add(release.plugin_id) + if not release.target_digest: + continue + plugin = await PluginDao.get_plugin_by_id(db, release.plugin_id) + prepared = bool( + plugin + and plugin.installed_version + and release.prepare_status == 'prepared' + and release.prepared_digest == release.target_digest + and release.prepared_version == plugin.installed_version + ) + targets.append( + WorkerTarget( + release.plugin_id, + release.target_digest, + release.generation, + bool(plugin and plugin.enabled == '0'), + prepared, + ) + ) + self.targets = tuple(sorted(targets, key=lambda item: item.plugin_id)) + await self.dao.upsert_worker_report( + db, + worker_id=self.worker_id, + plugin_id=RUNTIME_PLUGIN_ID, + state='starting', + heartbeat_time=TimezoneUtil.utc_now(), + ) + await db.commit() + self.registered = True + + def generation(self, base: str) -> str: + """ + 将基础启动代际与目标快照及保留插件ID合成为就绪屏障代际。 + + :param base: 宿主提供的基础启动代际标识 + :return: 当前启动快照对应的小写 SHA256 代际摘要 + """ + payload = [(item.plugin_id, item.digest, item.generation, item.enabled) for item in self.targets] + return hashlib.sha256( + json.dumps([base, payload, sorted(self.reserved_ids)], separators=(',', ':')).encode() + ).hexdigest() + + async def assert_snapshot_current(self) -> None: + """ + 在导入或就绪前确认数据库发布目标仍与当前进程快照一致。 + + :return: None + :raises RuntimeError: 当前发布目标或代际与进程启动快照不一致 + """ + async with self.session_factory() as db: + current = [ + (item.plugin_id, item.target_digest, item.generation) + for item in await self.dao.list_releases(db) + if item.target_digest + ] + expected = [(item.plugin_id, item.digest, item.generation) for item in self.targets] + if sorted(current) != expected: + raise RuntimeError('插件发布目标在当前 worker 启动过程中变化,请重新启动') + + async def ready(self) -> None: + """ + 确认发布快照未变后上报宿主就绪及各插件的实际状态。 + + :return: None + :raises RuntimeError: 当前发布目标或代际在启动过程中发生变化 + """ + await self.assert_snapshot_current() + self.state = 'ready' + await self.report() + + def _plugin_state(self, target: WorkerTarget) -> tuple[str, str | None]: + """ + 根据实际激活结果生成单个目标的进程报告状态。 + + :param target: 当前进程固定的单个插件发布目标快照 + :return: 报告状态与可选错误说明 + """ + if self.state in {'failed', 'stopped'}: + return self.state, self.errors.get(target.plugin_id) + if target.plugin_id in self.errors: + return 'failed', self.errors[target.plugin_id] + if not target.enabled: + return 'stopped', None + if self.state == 'starting': + return 'starting', None + runtime = getattr(self.app.state, 'plugin_explicit_runtime', None) + loaded = getattr(runtime, 'loaded', {}).get(target.plugin_id) + if loaded is not None and loaded.active and (loaded.lifespan is None or loaded.lifespan.ready): + discovered = loaded.plugin.discovered_plugin + if discovered.artifact_digest == target.digest and discovered.artifact_generation == target.generation: + return 'ready', None + return 'failed', '当前 worker 未完成目标制品激活或健康检查' + + async def report(self) -> None: + """ + 以同一心跳时间写入宿主状态及全部目标插件的实际加载状态。 + + :return: None + """ + if not self.registered: + return + now = TimezoneUtil.utc_now() + async with self.session_factory() as db: + await self.dao.upsert_worker_report( + db, worker_id=self.worker_id, plugin_id=RUNTIME_PLUGIN_ID, state=self.state, heartbeat_time=now + ) + for target in self.targets: + state, error = self._plugin_state(target) + await self.dao.upsert_worker_report( + db, + worker_id=self.worker_id, + plugin_id=target.plugin_id, + state=state, + heartbeat_time=now, + artifact_digest=target.digest, + version=self.versions.get(target.plugin_id), + generation=target.generation, + error=error, + ) + await db.commit() + + async def _heartbeat(self) -> None: + """ + 周期性刷新进程报告,数据库暂不可用时保留旧报告等待自然过期。 + + :return: None + """ + while True: + await asyncio.sleep(self.config.heartbeat_seconds) + try: + await self.report() + except Exception: + # 数据库暂不可用时不伪造就绪时间;旧报告会按 TTL 过期。 + logger.exception('插件 worker 心跳上报失败') + + async def stop(self, *, failed: bool = False) -> None: + """ + 停止心跳任务,并上报正常停止或宿主失败状态。 + + :param failed: 是否以失败状态结束宿主报告,为 False 时报告正常停止 + :return: None + """ + if self.task is not None: + self.task.cancel() + with suppress(asyncio.CancelledError): + await self.task + self.task = None + self.state = 'failed' if failed else 'stopped' + await self.report() diff --git a/ruoyi-fastapi-backend/plugins/core/discovery/scanner.py b/ruoyi-fastapi-backend/plugins/core/discovery/scanner.py index 072f22c..7eab247 100644 --- a/ruoyi-fastapi-backend/plugins/core/discovery/scanner.py +++ b/ruoyi-fastapi-backend/plugins/core/discovery/scanner.py @@ -20,6 +20,19 @@ class DiscoveredPlugin: manifest: PluginManifest backend_path: Path manifest_path: Path + artifact_digest: str | None = None + artifact_generation: str | None = None + + +def is_artifact_plugin(plugin: DiscoveredPlugin) -> bool: + """ + 只有显式的非空字符串摘要表示制品来源,兼容旧扫描器返回对象。 + + :param plugin: 待判断的插件发现对象 + :return: 插件是否来自带摘要的签名制品 + """ + digest = getattr(plugin, 'artifact_digest', None) + return isinstance(digest, str) and bool(digest) @dataclass(frozen=True) diff --git a/ruoyi-fastapi-backend/plugins/core/environment.py b/ruoyi-fastapi-backend/plugins/core/environment.py index ce5f981..016d710 100644 --- a/ruoyi-fastapi-backend/plugins/core/environment.py +++ b/ruoyi-fastapi-backend/plugins/core/environment.py @@ -5,7 +5,7 @@ from typing import Literal from config.env import AppConfig PluginFrontendMode = Literal['dev', 'built'] -PluginBackendRuntimeMode = Literal['dev', 'service'] +PluginBackendRuntimeMode = Literal['dev', 'service', 'maintenance'] BACKEND_ROOT_ENV_NAMES = ('RUOYI_PLUGIN_BACKEND_ROOT', 'RUOYI_BACKEND_ROOT') FRONTEND_ROOT_ENV_NAMES = ('RUOYI_PLUGIN_FRONTEND_ROOT', 'RUOYI_FRONTEND_ROOT') diff --git a/ruoyi-fastapi-backend/plugins/core/lifecycle/jobs.py b/ruoyi-fastapi-backend/plugins/core/lifecycle/jobs.py index 94d9b33..a9f1cbc 100644 --- a/ruoyi-fastapi-backend/plugins/core/lifecycle/jobs.py +++ b/ruoyi-fastapi-backend/plugins/core/lifecycle/jobs.py @@ -5,7 +5,7 @@ from module_admin.dao.job_dao import JobDao from module_admin.entity.do.job_do import SysJob from module_admin.entity.vo.job_vo import JobModel from plugins.core.discovery.scanner import DiscoveredPlugin -from plugins.core.manifest.schema import PluginJobManifest +from plugins.core.manifest.schema import PluginJobManifest, PluginManifest from plugins.core.utils import escape_sql_like @@ -22,24 +22,37 @@ class PluginJobModelBuilder: REMARK_PREFIX = '[plugin-job]' @classmethod - def build(cls, plugin_id: str, job: PluginJobManifest, *, enabled: bool = True) -> JobModel: + def build( + cls, plugin_id: str, job: PluginJobManifest, *, enabled: bool = True, manifest: PluginManifest | None = None + ) -> JobModel: """ 构建系统定时任务模型。 :param plugin_id: 插件ID :param job: 插件定时任务声明 :param enabled: 任务是否启用 + :param manifest: 用于校验任务归属并选择 v2 分发入口的插件清单 :return: 系统定时任务模型 """ - cls._validate_callable_belongs_to_plugin(plugin_id, job.callable) + target, args, kwargs = job.callable, job.args, job.kwargs + if manifest is not None and manifest.uses_entrypoint: + from plugins.core.runtime.job_dispatcher import DISPATCH_TARGET # noqa: PLC0415 + + if manifest.id != plugin_id or job not in manifest.backend.jobs: + raise ValueError('任务不属于当前插件清单') + if not job.callable.startswith(f'{manifest.backend.module}.'): + raise ValueError('任务 callable 越过插件模块边界') + target, args, kwargs = DISPATCH_TARGET, [plugin_id, job.id, manifest.version], {} + else: + cls._validate_callable_belongs_to_plugin(plugin_id, job.callable) return JobModel( jobName=cls.build_job_name(plugin_id, job.id), jobGroup=cls.JOB_GROUP, jobStore=job.job_store, jobExecutor=job.executor, - invokeTarget=job.callable, - jobArgs=job.args, - jobKwargs=job.kwargs, + invokeTarget=target, + jobArgs=args, + jobKwargs=kwargs, cronExpression=job.cron_expression, timeZone=job.time_zone, misfireGraceTime=job.misfire_grace_time, @@ -239,7 +252,7 @@ class PluginJobInstaller: desired_job_names = {PluginJobModelBuilder.build_job_name(manifest.id, job.id) for job in manifest.backend.jobs} await self.repository.delete_plugin_jobs_except(manifest.id, desired_job_names) for job in manifest.backend.jobs: - job_model = PluginJobModelBuilder.build(manifest.id, job, enabled=enabled) + job_model = PluginJobModelBuilder.build(manifest.id, job, enabled=enabled, manifest=manifest) await self.upsert_plugin_job(job_model) installed_jobs.append(job_model) diff --git a/ruoyi-fastapi-backend/plugins/core/management/dao/dao.py b/ruoyi-fastapi-backend/plugins/core/management/dao/dao.py index 4272cff..0121584 100644 --- a/ruoyi-fastapi-backend/plugins/core/management/dao/dao.py +++ b/ruoyi-fastapi-backend/plugins/core/management/dao/dao.py @@ -39,6 +39,7 @@ PLUGIN_MODEL_RUNTIME_FIELDS = { 'config', 'dependencies', 'plugin_dependencies', + 'release', } diff --git a/ruoyi-fastapi-backend/plugins/core/management/dao/release_dao.py b/ruoyi-fastapi-backend/plugins/core/management/dao/release_dao.py new file mode 100644 index 0000000..53870fd --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/management/dao/release_dao.py @@ -0,0 +1,426 @@ +import json +import re +from datetime import datetime +from pathlib import PurePosixPath +from typing import Any +from uuid import uuid4 + +from sqlalchemy import select, update +from sqlalchemy.dialects.mysql import insert as mysql_insert +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.dialects.sqlite import insert as sqlite_insert +from sqlalchemy.ext.asyncio import AsyncSession + +from plugins.core.management.entity.do.models import SysPlugin +from plugins.core.management.entity.do.release_models import SysPluginArtifact, SysPluginRelease, SysPluginWorker +from utils.time_util import TimezoneUtil + + +class PluginReleaseConflictError(ValueError): + """ + 发布代际或维护证据发生并发变化,需要调用方重新读取并确认目标。 + """ + + +def _check_digest(value: str) -> None: + """ + 校验制品摘要是否为64位小写 SHA256 字符串。 + + :param value: 待校验的制品摘要 + :return: None + :raises ValueError: 摘要不是64位小写十六进制字符串 + """ + if not re.fullmatch(r'[0-9a-f]{64}', value): + raise ValueError('制品digest必须是小写SHA256') + + +def _check_generation(value: str) -> None: + """ + 校验发布代际或进程标识是否为32位小写 UUID 十六进制字符串。 + + :param value: 待校验的发布代际或宿主进程标识 + :return: None + :raises ValueError: 标识不是32位小写十六进制字符串 + """ + if not re.fullmatch(r'[0-9a-f]{32}', value): + raise ValueError('发布代际或worker ID必须是32位小写UUID十六进制字符串') + + +async def _insert_if_missing(db: AsyncSession, model: Any, values: dict[str, Any]) -> None: + """ + 使用数据库方言提供的幂等插入保留调用方事务边界。 + + 避免唯一键竞争回滚外层事务,也避免 SQLite 首个保存点导致提前提交。 + + :param db: 当前操作使用的数据库会话 + :param model: 待插入记录的 ORM 模型类 + :param values: 字段名与待插入值组成的字典 + :return: None + :raises ValueError: 当前数据库方言不受制品发布模块支持 + """ + dialect = db.get_bind().dialect.name + primary_keys = [column.name for column in model.__table__.primary_key] + if dialect == 'mysql': + statement = mysql_insert(model).values(**values) + statement = statement.on_duplicate_key_update(**{primary_keys[0]: getattr(model, primary_keys[0])}) + elif dialect == 'postgresql': + statement = pg_insert(model).values(**values).on_conflict_do_nothing(index_elements=primary_keys) + elif dialect == 'sqlite': + statement = sqlite_insert(model).values(**values).on_conflict_do_nothing(index_elements=primary_keys) + else: + raise ValueError(f'制品发布不支持数据库方言:{dialect}') + await db.execute(statement) + + +class PluginReleaseDao: + """ + 制品、发布目标及进程报告的数据访问层,事务提交由调用方负责。 + """ + + @classmethod + async def register_artifact( + cls, + db: AsyncSession, + *, + digest: str, + plugin_id: str, + version: str, + key_id: str, + relative_path: str, + manifest_json: str, + created_by: str | None = None, + ) -> SysPluginArtifact: + """ + 首次登记或严格幂等复用制品记录,拒绝覆盖相同摘要的不同元数据。 + + :param db: 当前操作使用的数据库会话 + :param digest: 制品规范元数据的小写 SHA256 摘要 + :param plugin_id: 插件ID + :param version: 插件制品版本 + :param key_id: 签名公钥在宿主信任配置中的标识 + :param relative_path: 制品在不可变存储中的规范相对目录 + :param manifest_json: 已验证插件清单的 JSON 对象文本 + :param created_by: 首次登记制品的操作人 + :return: 已登记且元数据一致的制品记录 + :raises ValueError: 摘要、目录或清单无效,或已有同摘要记录的元数据不一致 + """ + _check_digest(digest) + path = PurePosixPath(relative_path) + if ( + not relative_path + or path.is_absolute() + or '\\' in relative_path + or ':' in relative_path + or any(part in {'', '.', '..'} for part in relative_path.split('/')) + ): + raise ValueError('制品目录必须是存储内的规范相对路径') + manifest = json.loads(manifest_json) + if not isinstance(manifest, dict): + raise ValueError('制品清单必须是JSON对象') + values = { + 'plugin_id': plugin_id, + 'version': version, + 'key_id': key_id, + 'relative_path': relative_path, + 'manifest_json': json.dumps(manifest, ensure_ascii=False, sort_keys=True, separators=(',', ':')), + } + artifact = await cls.get_artifact(db, digest) + if artifact is None: + await _insert_if_missing(db, SysPluginArtifact, {'digest': digest, 'created_by': created_by, **values}) + artifact = ( + await db.scalars(select(SysPluginArtifact).where(SysPluginArtifact.digest == digest).with_for_update()) + ).one() + if any(getattr(artifact, key) != value for key, value in values.items()): + raise ValueError('已存在制品digest的元数据不一致,拒绝覆盖') + return artifact + + @staticmethod + async def get_artifact(db: AsyncSession, digest: str) -> SysPluginArtifact | None: + """ + 根据制品摘要查询不可变制品索引。 + + :param db: 当前操作使用的数据库会话 + :param digest: 制品规范元数据的小写 SHA256 摘要 + :return: 制品记录,不存在时为 None + """ + return await db.get(SysPluginArtifact, digest) + + @staticmethod + async def list_artifacts(db: AsyncSession, plugin_id: str | None = None) -> list[SysPluginArtifact]: + """ + 按插件ID和摘要顺序查询制品列表。 + + :param db: 当前操作使用的数据库会话 + :param plugin_id: 可选的插件ID过滤条件,为 None 时查询全部记录 + :return: 匹配过滤条件的制品记录列表 + """ + statement = select(SysPluginArtifact).order_by(SysPluginArtifact.plugin_id, SysPluginArtifact.digest) + if plugin_id is not None: + statement = statement.where(SysPluginArtifact.plugin_id == plugin_id) + return list((await db.scalars(statement)).all()) + + @classmethod + async def ensure_release(cls, db: AsyncSession, plugin_id: str, *, actor: str | None = None) -> SysPluginRelease: + """ + 查询或幂等创建插件发布记录,不提交调用方事务。 + + :param db: 当前操作使用的数据库会话 + :param plugin_id: 插件ID + :param actor: 本次操作的执行者,未提供时不记录执行者名称 + :return: 已存在或新创建的插件发布记录 + """ + release = await cls.get_release(db, plugin_id) + if release is None: + await _insert_if_missing( + db, + SysPluginRelease, + { + 'plugin_id': plugin_id, + 'generation': uuid4().hex, + 'expected_workers': 1, + 'prepare_status': 'idle', + 'create_by': actor, + 'update_by': actor, + }, + ) + release = ( + await db.scalars( + select(SysPluginRelease).where(SysPluginRelease.plugin_id == plugin_id).with_for_update() + ) + ).one() + return release + + @staticmethod + async def get_release(db: AsyncSession, plugin_id: str) -> SysPluginRelease | None: + """ + 重新读取指定插件的发布记录,刷新会话中的已有状态。 + + :param db: 当前操作使用的数据库会话 + :param plugin_id: 插件ID + :return: 插件发布记录,不存在时为 None + """ + return await db.get(SysPluginRelease, plugin_id, populate_existing=True) + + @staticmethod + async def list_releases(db: AsyncSession) -> list[SysPluginRelease]: + """ + 按插件ID顺序查询全部发布目标。 + + :param db: 当前操作使用的数据库会话 + :return: 插件发布记录列表 + """ + return list((await db.scalars(select(SysPluginRelease).order_by(SysPluginRelease.plugin_id))).all()) + + @classmethod + async def set_preparation_status( + cls, + db: AsyncSession, + plugin_id: str, + *, + status: str, + prepared_digest: str | None = None, + prepared_version: str | None = None, + last_error: str | None = None, + actor: str | None = None, + expected_generation: str | None = None, + ) -> SysPluginRelease: + """ + 在匹配代际下更新维护准备证据,失败或重新准备时清除旧证据。 + + :param db: 当前操作使用的数据库会话 + :param plugin_id: 插件ID + :param status: 维护准备状态 + :param prepared_digest: 维护准备成功的制品摘要 + :param prepared_version: 维护准备确认的数据结构安装版本 + :param last_error: 最近一次维护准备失败的错误说明 + :param actor: 本次操作的执行者,未提供时不记录执行者名称 + :param expected_generation: 预期发布代际,未提供时使用刚读取的当前代际 + :return: 更新后的发布记录 + :raises ValueError: 准备状态或成功证据不完整,或制品不属于该插件 + :raises PluginReleaseConflictError: 维护结果写入前发布代际发生变化 + """ + if status not in {'idle', 'preparing', 'prepared', 'failed'}: + raise ValueError('非法维护准备状态') + if status == 'prepared': + if not prepared_digest or not prepared_version: + raise ValueError('准备成功必须提供制品digest和数据结构安装版本') + artifact = await cls.get_artifact(db, prepared_digest) + if artifact is None or artifact.plugin_id != plugin_id: + raise ValueError('准备制品不存在或不属于该插件') + else: + prepared_digest = prepared_version = None + release = await cls.ensure_release(db, plugin_id, actor=actor) + expected_generation = expected_generation or release.generation + statement = ( + update(SysPluginRelease) + .where(SysPluginRelease.plugin_id == plugin_id, SysPluginRelease.generation == expected_generation) + .values( + prepare_status=status, + prepared_digest=prepared_digest, + prepared_version=prepared_version, + last_error=last_error, + update_by=actor, + ) + ) + result = await db.execute(statement.execution_options(synchronize_session=False)) + if result.rowcount != 1: + raise PluginReleaseConflictError('发布代际已变化,维护准备结果未写入') + await db.flush() + await db.refresh(release) + return release + + @classmethod + async def select_target( + cls, + db: AsyncSession, + plugin_id: str, + *, + target_digest: str, + expected_generation: str, + expected_workers: int = 1, + actor: str | None = None, + ) -> SysPluginRelease: + """ + 以比较并更新方式切换代码目标,保留已安装数据结构版本和进程报告。 + + :param db: 当前操作使用的数据库会话 + :param plugin_id: 插件ID + :param target_digest: 当前发布目标的制品摘要 + :param expected_generation: 调用方读取并确认的发布代际,用于并发状态校验 + :param expected_workers: 预期启动并报告当前目标的宿主进程数量 + :param actor: 本次操作的执行者,未提供时不记录执行者名称 + :return: 目标和代际更新后的发布记录 + :raises ValueError: 目标制品、准备证据、已安装版本或预期进程数量不合法 + :raises PluginReleaseConflictError: 发布代际或维护准备状态在目标切换前发生变化 + """ + _check_digest(target_digest) + _check_generation(expected_generation) + if isinstance(expected_workers, bool) or not isinstance(expected_workers, int) or expected_workers < 1: + raise ValueError('expected_workers必须为正整数') + artifact = await cls.get_artifact(db, target_digest) + release = await cls.get_release(db, plugin_id) + if artifact is None or artifact.plugin_id != plugin_id: + raise ValueError('目标制品不存在或不属于该插件') + if release is None or release.generation != expected_generation: + raise PluginReleaseConflictError('发布代际已变化,请重新读取目标') + if release.prepare_status != 'prepared' or release.prepared_digest != target_digest: + raise ValueError('目标制品尚未完成维护准备') + installed_version = await db.scalar(select(SysPlugin.installed_version).where(SysPlugin.plugin_id == plugin_id)) + if not installed_version or release.prepared_version != installed_version: + raise ValueError('维护准备版本与当前数据结构安装版本不一致') + installed_matches = ( + select(SysPlugin.plugin_id) + .where(SysPlugin.plugin_id == plugin_id, SysPlugin.installed_version == SysPluginRelease.prepared_version) + .exists() + ) + statement = ( + update(SysPluginRelease) + .where( + SysPluginRelease.plugin_id == plugin_id, + SysPluginRelease.generation == expected_generation, + SysPluginRelease.prepare_status == 'prepared', + SysPluginRelease.prepared_digest == target_digest, + installed_matches, + ) + .values( + previous_digest=release.target_digest + if target_digest != release.target_digest + else release.previous_digest, + target_digest=target_digest, + generation=uuid4().hex, + expected_workers=expected_workers, + update_by=actor, + last_error=None, + ) + ) + result = await db.execute(statement.execution_options(synchronize_session=False)) + if result.rowcount != 1: + raise PluginReleaseConflictError('发布代际或维护准备状态已变化,目标未切换') + await db.flush() + await db.refresh(release) + return release + + @staticmethod + async def upsert_worker_report( + db: AsyncSession, + *, + worker_id: str, + plugin_id: str, + state: str, + heartbeat_time: datetime, + artifact_digest: str | None = None, + version: str | None = None, + generation: str | None = None, + error: str | None = None, + ) -> SysPluginWorker: + """ + 登记进程实际加载状态,阻止较旧心跳覆盖较新的报告。 + + :param db: 当前操作使用的数据库会话 + :param worker_id: 宿主进程的32位小写 UUID 标识 + :param plugin_id: 插件ID,宿主进程本身使用 __runtime__ + :param state: 进程当前状态 + :param heartbeat_time: 本次报告的带时区心跳时间,将统一为 UTC 毫秒精度 + :param artifact_digest: 进程实际加载的制品摘要,宿主报告不设置此值 + :param version: 进程实际加载的制品版本,宿主报告不设置此值 + :param generation: 进程实际加载的发布代际,宿主报告不设置此值 + :param error: 进程加载或运行失败的错误说明 + :return: 插入或更新后的进程报告记录 + :raises ValueError: 报告标识、状态、时间或宿主与插件字段组合不合法 + """ + _check_generation(worker_id) + if generation is not None: + _check_generation(generation) + if artifact_digest is not None: + _check_digest(artifact_digest) + if state not in {'starting', 'ready', 'failed', 'stopped'}: + raise ValueError('非法worker状态') + heartbeat_time = TimezoneUtil.to_utc_milliseconds(heartbeat_time) + if plugin_id == '__runtime__' and any(value is not None for value in (artifact_digest, version, generation)): + raise ValueError('宿主worker报告不能声明插件制品或代际') + values = { + 'artifact_digest': artifact_digest, + 'version': version, + 'generation': generation, + 'state': state, + 'heartbeat_time': heartbeat_time, + 'error': error, + } + identity = (worker_id, plugin_id) + worker = await db.get(SysPluginWorker, identity) + if worker is None: + await _insert_if_missing(db, SysPluginWorker, {'worker_id': worker_id, 'plugin_id': plugin_id, **values}) + worker = ( + await db.scalars( + select(SysPluginWorker) + .where(SysPluginWorker.worker_id == worker_id, SysPluginWorker.plugin_id == plugin_id) + .with_for_update() + ) + ).one() + await db.execute( + update(SysPluginWorker) + .where( + SysPluginWorker.worker_id == worker_id, + SysPluginWorker.plugin_id == plugin_id, + SysPluginWorker.heartbeat_time <= heartbeat_time, + ) + .values(**values) + .execution_options(synchronize_session=False) + ) + await db.flush() + await db.refresh(worker) + return worker + + @staticmethod + async def list_worker_reports(db: AsyncSession, plugin_id: str | None = None) -> list[SysPluginWorker]: + """ + 查询进程报告,指定插件时同时包含宿主运行状态行。 + + :param db: 当前操作使用的数据库会话 + :param plugin_id: 可选的插件ID过滤条件,为 None 时查询全部记录 + :return: 匹配过滤条件的宿主和插件报告列表 + """ + statement = select(SysPluginWorker).order_by(SysPluginWorker.worker_id, SysPluginWorker.plugin_id) + if plugin_id is not None: + statement = statement.where(SysPluginWorker.plugin_id.in_([plugin_id, '__runtime__'])) + return list((await db.scalars(statement)).all()) diff --git a/ruoyi-fastapi-backend/plugins/core/management/entity/do/release_models.py b/ruoyi-fastapi-backend/plugins/core/management/entity/do/release_models.py new file mode 100644 index 0000000..153d1da --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/management/entity/do/release_models.py @@ -0,0 +1,75 @@ +from sqlalchemy import CheckConstraint, Column, Index, Integer, String, Text + +from common.mixin import AuditTimeMixin, CreateTimeMixin +from common.types import DbUtcDateTime +from config.database import Base + + +class SysPluginArtifact(CreateTimeMixin, Base): + """ + 已验证不可变制品索引,同一插件版本允许登记多个平台构建。 + """ + + __tablename__ = 'sys_plugin_artifact' + __table_args__ = ( + Index('idx_sys_plugin_artifact_plugin', 'plugin_id', 'version'), + {'comment': '插件已验证制品表'}, + ) + + digest = Column(String(64), primary_key=True, nullable=False, comment='制品SHA256') + plugin_id = Column(String(64), nullable=False, comment='插件ID') + version = Column(String(32), nullable=False, comment='制品版本') + key_id = Column(String(128), nullable=False, comment='签名公钥标识') + relative_path = Column(String(512), nullable=False, comment='制品存储内不可变相对目录') + manifest_json = Column(Text, nullable=False, comment='验证后的清单JSON') + created_by = Column(String(64), nullable=True, comment='导入者') + + +class SysPluginRelease(AuditTimeMixin, Base): + """ + 代码发布目标及维护准备证据,与数据结构安装版本分别保存。 + """ + + __tablename__ = 'sys_plugin_release' + __table_args__ = ( + CheckConstraint('expected_workers >= 1', name='ck_sys_plugin_release_workers'), + CheckConstraint( + "prepare_status in ('idle', 'preparing', 'prepared', 'failed')", + name='ck_sys_plugin_release_prepare', + ), + {'comment': '插件目标发布表'}, + ) + + plugin_id = Column(String(64), primary_key=True, nullable=False, comment='插件ID') + target_digest = Column(String(64), nullable=True, comment='目标制品SHA256') + previous_digest = Column(String(64), nullable=True, comment='上一次目标制品SHA256') + prepared_digest = Column(String(64), nullable=True, comment='维护准备成功的制品SHA256') + generation = Column(String(32), nullable=False, comment='目标发布代际UUID') + expected_workers = Column(Integer, nullable=False, server_default='1', comment='预期宿主worker数量') + prepare_status = Column(String(16), nullable=False, server_default='idle', comment='维护准备状态') + prepared_version = Column(String(32), nullable=True, comment='维护确认的数据结构安装版本') + last_error = Column(Text, nullable=True, comment='维护准备错误') + create_by = Column(String(64), nullable=True, comment='创建者') + update_by = Column(String(64), nullable=True, comment='更新者') + + +class SysPluginWorker(AuditTimeMixin, Base): + """ + 宿主和插件实际运行报告,__runtime__ 行记录宿主存活状态。 + """ + + __tablename__ = 'sys_plugin_worker' + __table_args__ = ( + CheckConstraint("state in ('starting', 'ready', 'failed', 'stopped')", name='ck_sys_plugin_worker_state'), + Index('idx_sys_plugin_worker_plugin', 'plugin_id', 'heartbeat_time'), + {'comment': '插件worker加载状态表'}, + ) + + worker_id = Column(String(32), primary_key=True, nullable=False, comment='宿主进程UUID') + plugin_id = Column(String(64), primary_key=True, nullable=False, comment='插件ID或__runtime__') + artifact_digest = Column(String(64), nullable=True, comment='实际加载的制品SHA256') + version = Column(String(32), nullable=True, comment='实际加载的制品版本') + generation = Column(String(32), nullable=True, comment='实际加载的发布代际UUID') + state = Column(String(16), nullable=False, comment='worker状态') + heartbeat_time = Column(DbUtcDateTime(), nullable=False, comment='UTC心跳时间') + error = Column(Text, nullable=True, comment='加载或运行错误') diff --git a/ruoyi-fastapi-backend/plugins/core/management/entity/vo/schemas.py b/ruoyi-fastapi-backend/plugins/core/management/entity/vo/schemas.py index bf0733c..efdc1da 100644 --- a/ruoyi-fastapi-backend/plugins/core/management/entity/vo/schemas.py +++ b/ruoyi-fastapi-backend/plugins/core/management/entity/vo/schemas.py @@ -44,6 +44,7 @@ class PluginModel(BaseModel): config: list[dict[str, Any]] | None = Field(default=None, description='插件配置声明') dependencies: dict[str, Any] | None = Field(default=None, description='插件依赖声明') plugin_dependencies: list[dict[str, Any]] | None = Field(default=None, description='插件依赖声明') + release: dict[str, Any] | None = Field(default=None, description='制品目标发布及各worker实际加载状态') class PluginQueryModel(BaseModel): diff --git a/ruoyi-fastapi-backend/plugins/core/management/service/gateway.py b/ruoyi-fastapi-backend/plugins/core/management/service/gateway.py index c19f020..346f428 100644 --- a/ruoyi-fastapi-backend/plugins/core/management/service/gateway.py +++ b/ruoyi-fastapi-backend/plugins/core/management/service/gateway.py @@ -1,6 +1,7 @@ import subprocess from collections.abc import Mapping from importlib import import_module +from pathlib import Path from typing import TYPE_CHECKING from plugins.core.lifecycle.migration import PluginMigrationRunner @@ -242,7 +243,36 @@ class PluginManagementRuntimeGateway: async_session_local = self.get_async_session_local() plugin_service = self.get_plugin_service() async with async_session_local() as session: - return await plugin_service.plugin_detail_services(session, plugin_id) + state_reader = getattr(plugin_service, 'get_plugin_state_services', plugin_service.plugin_detail_services) + return await state_reader(session, plugin_id) + + async def get_selected_artifact_plugin(self, plugin_id: str, backend_root: Path) -> object: + """ + 按发布目标和当前宿主信任配置解析制品清单,供只读配置入口使用。 + + :param plugin_id: 插件ID + :param backend_root: 宿主后端项目根目录 + :return: 完成当前信任校验并携带发布代际的插件发现对象 + :raises ValueError: 功能未启用、尚未选择目标,或制品归属及验证结果不符合要求 + """ + from plugins.core.deployment.catalog import PluginArtifactCatalog # noqa: PLC0415 + from plugins.core.deployment.config import PluginDeploymentConfig # noqa: PLC0415 + from plugins.core.management.dao.release_dao import PluginReleaseDao # noqa: PLC0415 + + config = PluginDeploymentConfig.from_settings(backend_root) + config.require_enabled() + async_session_local = self.get_async_session_local() + async with async_session_local() as session: + release = await PluginReleaseDao.get_release(session, plugin_id) + if release is None or not release.target_digest: + raise ValueError('插件尚未选择发布目标') + digest, generation = release.target_digest, release.generation + catalog = PluginArtifactCatalog(config, session_factory=async_session_local) + catalog.reject_source_conflict(plugin_id) + artifact = await catalog.get(digest) + if artifact.plugin_id != plugin_id: + raise ValueError('发布目标制品与插件ID不一致') + return catalog.discovered(artifact, generation) @staticmethod def build_operation_log_export_query(export_limit: int) -> 'PluginOperationLogExportQueryModel': diff --git a/ruoyi-fastapi-backend/plugins/core/management/service/release_view.py b/ruoyi-fastapi-backend/plugins/core/management/service/release_view.py new file mode 100644 index 0000000..3f8fb47 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/management/service/release_view.py @@ -0,0 +1,113 @@ +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from plugins.core.capability import STATE_CHANGE_OPERATIONS +from plugins.core.deployment.catalog import PluginArtifactCatalog +from plugins.core.deployment.config import PluginDeploymentConfig +from plugins.core.deployment.state import aggregate_release +from plugins.core.environment import PLUGIN_RUNTIME_ENVIRONMENT +from plugins.core.management.dao.release_dao import PluginReleaseDao +from plugins.core.management.entity.vo.schemas import PluginModel +from utils.common_util import CamelCaseUtil +from utils.time_util import TimezoneUtil + +ARTIFACT_MAINTENANCE_REASON = '签名制品插件仅允许通过维护发布命令变更,完成后重启全部worker。' + + +def artifact_capability(plugin_id: str) -> dict[str, Any]: + """ + 构建制品插件的维护能力说明,目录不可用时仍保留制品身份限制。 + + :param plugin_id: 插件ID + :return: 管理端使用的能力、受限操作和提示信息 + """ + return { + 'pluginId': plugin_id, + 'frontendMode': PLUGIN_RUNTIME_ENVIRONMENT.get_frontend_mode(), + 'backendRuntimeMode': PLUGIN_RUNTIME_ENVIRONMENT.get_backend_runtime_mode(), + 'hasFrontendResources': False, + 'frontendBuildRequired': False, + 'frontendRuntimeManageable': False, + 'backendRuntimeManageable': False, + 'runtimeManageable': False, + 'blockedOperations': sorted(STATE_CHANGE_OPERATIONS), + 'warnings': [ARTIFACT_MAINTENANCE_REASON], + 'primaryReason': ARTIFACT_MAINTENANCE_REASON, + } + + +async def build_artifact_plugin_view( + db: AsyncSession, + plugin: object, + backend_root: Path, + frontend_root: Path | None, + build_model: Callable[..., PluginModel], +) -> PluginModel: + """ + 展示目标制品及进程状态,读取清单前重新验签且不导入插件代码。 + + :param db: 当前操作使用的数据库会话 + :param plugin: 数据库中的插件状态记录 + :param backend_root: 后端插件根目录 + :param frontend_root: 前端插件根目录,未配置时为 None + :param build_model: 将发现结果、目录及已有状态转换为展示模型的回调 + :return: 合并制品清单、安装版本及发布摘要的插件展示模型 + """ + model = PluginModel(**CamelCaseUtil.transform_result(plugin)) + model.source = 'artifact' + model.capability = artifact_capability(model.plugin_id) + config = PluginDeploymentConfig.from_settings(backend_root.parent) + if not config.enabled: + model.release = { + 'status': 'unavailable', + 'lastError': '签名制品发布功能未启用,无法确认 worker 实际状态', + } + return model + release = await PluginReleaseDao.get_release(db, model.plugin_id) + if release is None: + model.release = {'status': 'no_target', 'targetDigest': None, 'restartRequired': False} + return model + reports = await PluginReleaseDao.list_worker_reports(db, model.plugin_id) + summary = aggregate_release( + release, + reports, + now=TimezoneUtil.utc_now(), + ttl_seconds=config.worker_ttl_seconds, + enabled=getattr(plugin, 'enabled', '0') != '1', + ).to_payload() + summary.update( + previousDigest=release.previous_digest, + preparedDigest=release.prepared_digest, + preparedVersion=release.prepared_version, + installedVersion=model.installed_version, + workers=[ + { + 'workerId': report.worker_id, + 'digest': report.artifact_digest, + 'version': report.version, + 'generation': report.generation, + 'state': report.state, + 'heartbeatTime': TimezoneUtil.format_rfc3339(report.heartbeat_time), + 'error': report.error, + } + for report in reports + if report.plugin_id == model.plugin_id + ], + ) + digest = release.target_digest or release.prepared_digest + catalog = PluginArtifactCatalog(config) + try: + catalog.reject_source_conflict(model.plugin_id) + if digest: + artifact = await catalog.get(digest) + if artifact.plugin_id != model.plugin_id: + raise ValueError('发布目标制品与插件ID不一致') + model = build_model(catalog.discovered(artifact, release.generation), backend_root, frontend_root, plugin) + summary['targetVersion'] = artifact.version if release.target_digest else None + except (ValueError, OSError) as exc: + summary['verificationError'] = str(exc) + model.release = summary + return model diff --git a/ruoyi-fastapi-backend/plugins/core/management/service/router_metadata.py b/ruoyi-fastapi-backend/plugins/core/management/service/router_metadata.py new file mode 100644 index 0000000..97f3d65 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/management/service/router_metadata.py @@ -0,0 +1,42 @@ +from collections.abc import Iterable + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from module_admin.entity.do.menu_do import SysMenu +from plugins.core.management.entity.do.models import SysPlugin, SysPluginMenu + + +class PluginRouterMetadataService: + """ + 按平台菜单关联读取插件页面归属,仅查询当前用户可见的插件页面。 + """ + + @staticmethod + async def get_menu_plugin_ids(query_db: AsyncSession, menus: Iterable[SysMenu]) -> dict[int, str]: + """ + 根据可见 PluginFrame 菜单关联查询已安装且启用的唯一插件归属。 + + :param query_db: 当前查询使用的数据库会话 + :param menus: 当前用户可见的菜单记录集合 + :return: 菜单ID到插件ID的映射,归属不明确的菜单不返回 + """ + menu_ids = {menu.menu_id for menu in menus if menu.component == 'PluginFrame' and menu.menu_id is not None} + if not menu_ids: + return {} + result = await query_db.execute( + select(SysPluginMenu.menu_id, SysPluginMenu.plugin_id) + .join(SysPlugin, SysPlugin.plugin_id == SysPluginMenu.plugin_id) + .where( + SysPluginMenu.menu_id.in_(menu_ids), + SysPlugin.enabled == '0', + SysPlugin.status != 'error', + SysPlugin.installed_version.is_not(None), + SysPlugin.installed_version != '', + ) + ) + owners: dict[int, set[str]] = {} + for menu_id, plugin_id in result.all(): + owners.setdefault(menu_id, set()).add(plugin_id) + # 历史损坏数据可能把同一菜单关联到多个插件,此时拒绝替前端猜测目标。 + return {menu_id: next(iter(plugin_ids)) for menu_id, plugin_ids in owners.items() if len(plugin_ids) == 1} diff --git a/ruoyi-fastapi-backend/plugins/core/management/service/service.py b/ruoyi-fastapi-backend/plugins/core/management/service/service.py index d01170f..6d61759 100644 --- a/ruoyi-fastapi-backend/plugins/core/management/service/service.py +++ b/ruoyi-fastapi-backend/plugins/core/management/service/service.py @@ -1,4 +1,4 @@ -from collections.abc import Mapping +from collections.abc import Iterable, Mapping from datetime import timedelta from pathlib import Path from typing import Any @@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from common.vo import CrudResponseModel, PageModel from plugins.core.capability import STATE_CHANGE_OPERATIONS, PluginRuntimeCapabilityResolver -from plugins.core.discovery.scanner import DiscoveredPlugin, PluginScanner +from plugins.core.discovery.scanner import DiscoveredPlugin, PluginScanner, is_artifact_plugin from plugins.core.environment import PLUGIN_RUNTIME_ENVIRONMENT from plugins.core.lifecycle.jobs import PluginJobInstaller, PluginJobRepository from plugins.core.lifecycle.purge import PluginPurgePlan, PluginPurgePlanner @@ -60,6 +60,18 @@ class PluginService: return [PluginModel(**CamelCaseUtil.transform_result(plugin)) for plugin in plugin_list] + @classmethod + async def get_plugin_state_services(cls, query_db: AsyncSession, plugin_id: str) -> PluginModel | None: + """ + 读取原始持久化状态,不依赖展示层发现、制品目录或发布聚合。 + + :param query_db: 当前查询使用的数据库会话 + :param plugin_id: 插件ID + :return: 插件持久化状态模型,不存在时为 None + """ + plugin = await PluginDao.get_plugin_by_id(query_db, plugin_id) + return PluginModel(**CamelCaseUtil.transform_result(plugin)) if plugin is not None else None + @classmethod async def get_plugin_page_list_services( cls, @@ -85,6 +97,9 @@ class PluginService: discovered_plugins = PluginScanner(backend_root).discover() database_plugins = await PluginDao.get_plugin_list(query_db) database_plugin_map = {plugin.plugin_id: plugin for plugin in database_plugins} + artifact_plugins = await cls._build_artifact_plugin_models( + query_db, database_plugins, backend_root, frontend_root + ) discovered_plugin_ids = {plugin.manifest.id for plugin in discovered_plugins} plugin_items = [ cls._build_plugin_model( @@ -94,11 +109,13 @@ class PluginService: database_plugin_map.get(discovered_plugin.manifest.id), ).model_dump(by_alias=True) for discovered_plugin in discovered_plugins + if discovered_plugin.manifest.id not in artifact_plugins ] + plugin_items.extend(plugin.model_dump(by_alias=True) for plugin in artifact_plugins.values()) plugin_items.extend( cls._build_orphan_plugin_model(plugin).model_dump(by_alias=True) for plugin in database_plugins - if plugin.plugin_id not in discovered_plugin_ids + if plugin.plugin_id not in discovered_plugin_ids and plugin.plugin_id not in artifact_plugins ) plugin_items = cls._filter_plugin_page_items(plugin_items, query_object) @@ -128,12 +145,40 @@ class PluginService: backend_root = backend_root or Path(PLUGIN_RUNTIME_ENVIRONMENT.get_backend_plugins_dir()) frontend_root = frontend_root or Path(PLUGIN_RUNTIME_ENVIRONMENT.get_frontend_plugins_dir()) plugin = await PluginDao.get_plugin_by_id(query_db, plugin_id) + if plugin is not None and getattr(plugin, 'source', None) == 'artifact': + models = await cls._build_artifact_plugin_models(query_db, [plugin], backend_root, frontend_root) + return models[plugin_id] discovered_plugin = cls._get_discovered_plugin(backend_root, plugin_id) if discovered_plugin: return cls._build_plugin_model(discovered_plugin, backend_root, frontend_root, plugin) return cls._build_orphan_plugin_model(plugin) if plugin else None + @classmethod + async def _build_artifact_plugin_models( + cls, query_db: AsyncSession, plugins: Iterable[object], backend_root: Path, frontend_root: Path | None + ) -> dict[str, PluginModel]: + """ + 筛选制品来源的插件记录,并构建包含发布状态的展示模型。 + + :param query_db: 当前查询使用的数据库会话 + :param plugins: 待筛选的插件持久化记录集合 + :param backend_root: 后端插件根目录 + :param frontend_root: 前端插件根目录,未配置时为 None + :return: 插件ID到制品展示模型的映射,没有制品记录时返回空字典 + """ + artifacts = [plugin for plugin in plugins if getattr(plugin, 'source', None) == 'artifact'] + if not artifacts: + return {} + from plugins.core.management.service.release_view import build_artifact_plugin_view # noqa: PLC0415 + + return { + plugin.plugin_id: await build_artifact_plugin_view( + query_db, plugin, backend_root, frontend_root, cls._build_plugin_model + ) + for plugin in artifacts + } + @classmethod async def upsert_discovered_plugin_services( cls, @@ -770,6 +815,8 @@ class PluginService: :return: 插件物理清理计划 """ plugin = await PluginDao.get_plugin_by_id(query_db, plugin_id) + if getattr(plugin, 'source', None) == 'artifact': + raise ValueError('制品插件不能通过孤儿元数据清理入口删除,请使用维护发布流程') menu_count = await PluginDao.count_plugin_menus(query_db, plugin_id) config_count = await PluginDao.count_plugin_configs(query_db, plugin_id) migration_count = await PluginDao.count_plugin_migrations(query_db, plugin_id) @@ -811,10 +858,12 @@ class PluginService: :param plugin_id: 插件ID :return: None """ + plugin = await PluginDao.get_plugin_by_id(query_db, plugin_id) + if getattr(plugin, 'source', None) == 'artifact': + raise ValueError('制品插件不能通过普通purge删除平台状态,请使用维护发布流程') plugin_menus = await PluginDao.get_plugin_menu_list(query_db, plugin_id) menu_ids = [plugin_menu.menu_id for plugin_menu in plugin_menus] - plugin = await PluginDao.get_plugin_by_id(query_db, plugin_id) if plugin: await cls.update_plugin_enabled_services(query_db, plugin_id, enabled=False) await PluginDao.delete_plugin_menus(query_db, plugin_id) @@ -962,6 +1011,8 @@ class PluginService: current_status = getattr(existing_plugin, 'status', None) last_error = getattr(existing_plugin, 'last_error', None) status = PluginService._resolve_status(manifest.version, installed_version, enabled, current_status) + if is_artifact_plugin(discovered_plugin) and current_status is not None: + status = current_status frontend_path = frontend_root / manifest.frontend.plugin_id if frontend_root else None frontend_menus = PluginMenuTree.flatten(manifest.frontend.menus) @@ -972,8 +1023,12 @@ class PluginService: installedVersion=installed_version, enabled=enabled, status=status, - source='local', - backendPath=str(discovered_plugin.backend_path.relative_to(backend_root)), + source='artifact' if is_artifact_plugin(discovered_plugin) else 'local', + backendPath=( + f'artifact:{discovered_plugin.artifact_digest}' + if is_artifact_plugin(discovered_plugin) + else str(discovered_plugin.backend_path.relative_to(backend_root)) + ), frontendPath=str(frontend_path.relative_to(frontend_root)) if frontend_path and frontend_root else None, lastError=last_error, description=manifest.description, diff --git a/ruoyi-fastapi-backend/plugins/core/manifest/schema.py b/ruoyi-fastapi-backend/plugins/core/manifest/schema.py index 29b323c..5c800cf 100644 --- a/ruoyi-fastapi-backend/plugins/core/manifest/schema.py +++ b/ruoyi-fastapi-backend/plugins/core/manifest/schema.py @@ -42,6 +42,7 @@ def get_default_job_timezone() -> str: MIN_PLUGIN_COMPONENT_PARTS = 3 SUPPORTED_MANIFEST_VERSION = 1 +EXPLICIT_MANIFEST_VERSION = 2 class PluginManifestError(ValueError): @@ -1013,6 +1014,33 @@ class PluginManifest(BaseModel): """ return [permission.code for permission in self.permissions] + @property + def runtime_kind(self) -> str: + """ + 返回统一的后端交付类型,v1 保持 Python 源码语义。 + + :return: 插件后端交付类型 + """ + return getattr(self.backend, 'runtime', 'python') + + @property + def integration_kind(self) -> str: + """ + 返回统一的 Web 接入类型。 + + :return: 插件 Web 接入类型 + """ + return getattr(self.backend, 'integration', 'router') + + @property + def uses_entrypoint(self) -> bool: + """ + 判断插件是否使用 v2 显式入口。 + + :return: 插件是否使用 v2 显式入口 + """ + return self.manifest_version == EXPLICIT_MANIFEST_VERSION + @property def permission_name_map(self) -> dict[str, str]: """ @@ -1186,4 +1214,8 @@ class PluginManifestFactory: :param raw_manifest: YAML 解析得到的原始清单字典 :return: 插件清单模型 """ + if raw_manifest.get('manifestVersion', raw_manifest.get('manifest_version', 1)) == EXPLICIT_MANIFEST_VERSION: + from plugins.core.manifest.v2 import PluginManifestV2 # noqa: PLC0415 + + return PluginManifestV2.model_validate(raw_manifest) return PluginManifest.model_validate(raw_manifest) diff --git a/ruoyi-fastapi-backend/plugins/core/manifest/v2.py b/ruoyi-fastapi-backend/plugins/core/manifest/v2.py new file mode 100644 index 0000000..f290b7f --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/manifest/v2.py @@ -0,0 +1,260 @@ +import re +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from plugins.core.manifest.menu_tree import PluginMenuTree +from plugins.core.manifest.schema import ( + CORE_FRONTEND_COMPONENTS, + HOOK_PATTERN, + VERSION_CONSTRAINT_PATTERN, + BackendManifest, + CompatibilityManifest, + FrontendDeliveryManifest, + FrontendManifest, + PluginJobManifest, + PluginManifest, + RouterManifest, +) + +MODULE_PATTERN = re.compile(r'^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*$') +SAFE_PATH_PATTERN = re.compile(r'^[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*$') + + +def validate_relative_resource(value: str) -> str: + """ + 只允许可移植的相对资源路径;真实路径边界在读取前再次检查。 + + :param value: 清单声明的相对资源路径 + :return: 通过格式校验的相对资源路径 + """ + if not SAFE_PATH_PATTERN.fullmatch(value) or any(part in {'.', '..'} for part in value.split('/')): + raise ValueError('资源必须使用安全相对路径,不能包含绝对路径、反斜杠或点目录') + return value + + +class NativeManifest(BaseModel): + """ + 已经离线安装到插件目录的原生发行包声明。 + """ + + model_config = ConfigDict(populate_by_name=True, extra='forbid') + + distribution: str = Field(pattern=r'^[A-Za-z0-9](?:[A-Za-z0-9_.-]*[A-Za-z0-9])?$') + module_root: str = Field(default='native', alias='moduleRoot') + + _validate_module_root = field_validator('module_root')(validate_relative_resource) + + +class AsgiManifest(BaseModel): + """ + 子应用挂载与生命周期声明。 + """ + + model_config = ConfigDict(populate_by_name=True, extra='forbid') + + mount_path: str | None = Field(default=None, alias='mountPath') + lifespan: Literal['managed', 'none'] = 'managed' + + +class PluginJobManifestV2(PluginJobManifest): + """ + 在宿主事件循环中分发,禁止把原生对象发送至进程池。 + """ + + executor: Literal['default'] = 'default' + timeout_seconds: float = Field(default=30.0, gt=0, le=86400, allow_inf_nan=False, alias='timeoutSeconds') + + +class BackendManifestV2(BackendManifest): + """ + 交付运行时和 Web 接入方式彼此独立。 + """ + + runtime: Literal['python', 'native'] = 'python' + integration: Literal['router', 'asgi'] = 'router' + entrypoint: str + routers: RouterManifest = Field(default_factory=lambda: RouterManifest(autoScan=False)) + native: NativeManifest | None = None + asgi: AsgiManifest | None = None + jobs: list[PluginJobManifestV2] = Field(default_factory=list) + + @field_validator('entrypoint') + @classmethod + def validate_entrypoint(cls, value: str) -> str: + """ + 入口必须是显式模块及顶层 callable。 + + :param value: 清单声明的显式入口路径 + :return: 通过格式校验的入口路径 + """ + if not HOOK_PATTERN.fullmatch(value): + raise ValueError('backend.entrypoint 必须使用 : 格式') + return value + + @model_validator(mode='after') + def validate_delivery(self) -> 'BackendManifestV2': + """ + 拒绝含糊或冲突的加载方式。 + + :return: 通过运行时及接入方式一致性校验的后端声明 + """ + if not MODULE_PATTERN.fullmatch(self.module): + raise ValueError('backend.module 必须是合法 Python 模块名') + entry_module = self.entrypoint.split(':', 1)[0] + if entry_module != self.module and not entry_module.startswith(f'{self.module}.'): + raise ValueError('backend.entrypoint 必须位于当前插件模块内') + if self.routers.auto_scan: + raise ValueError('v2 使用显式入口,backend.routers.autoScan 必须为 false') + if (self.runtime == 'native') != (self.native is not None): + raise ValueError('仅 native 运行时必须声明 backend.native') + callbacks = [value for value in self.hooks.model_dump().values() if value is not None] + if self.health.checker: + callbacks.append(self.health.checker) + for callback in callbacks: + callback_module = callback.split(':', 1)[0] + if callback_module != self.module and not callback_module.startswith(f'{self.module}.'): + raise ValueError('v2 Hook 和健康检查必须使用当前插件内的完整模块路径') + if self.integration == 'asgi': + self.asgi = self.asgi or AsgiManifest() + if self.hooks.on_startup or self.hooks.on_shutdown: + raise ValueError('ASGI 的启动和关闭必须使用 lifespan,不能重复声明运行时 Hook') + elif self.asgi is not None: + raise ValueError('仅 ASGI 接入方式允许 backend.asgi') + return self + + +class FrontendDeliveryManifestV2(FrontendDeliveryManifest): + """ + 独立构建产物不参与宿主前端构建。 + """ + + type: Literal['none', 'source', 'bundle'] = 'none' + + +class FrontendBundleManifest(BaseModel): + """ + 插件内的静态构建资源。 + """ + + model_config = ConfigDict(populate_by_name=True, extra='forbid') + + directory: str = 'web/dist' + entry: str = 'index.html' + spa_fallback: bool = Field(default=True, alias='spaFallback') + + _validate_paths = field_validator('directory', 'entry')(validate_relative_resource) + + @field_validator('entry') + @classmethod + def validate_html_entry(cls, value: str) -> str: + """ + 入口为 HTML,避免把任意资源作为页面声明。 + + :param value: 前端构建产物的入口文件路径 + :return: 通过 HTML 后缀校验的入口文件路径 + """ + if not value.endswith('.html'): + raise ValueError('bundle.entry 必须是 HTML 文件') + return value + + +class FrontendManifestV2(FrontendManifest): + """ + 兼容源码页面并增加构建产物声明。 + """ + + delivery: FrontendDeliveryManifestV2 = Field(default_factory=FrontendDeliveryManifestV2) + bundle: FrontendBundleManifest | None = None + + +class CompatibilityManifestV2(CompatibilityManifest): + """ + 宿主插件 API 与应用版本分别约束。 + """ + + host_api_version: str = Field(default='^1.0.0', alias='hostApiVersion') + + @field_validator('host_api_version') + @classmethod + def validate_host_api_version(cls, value: str) -> str: + """ + 沿用平台现有版本约束语法。 + + :param value: 宿主插件 API 版本约束表达式 + :return: 通过语法校验的版本约束表达式 + """ + if not VERSION_CONSTRAINT_PATTERN.fullmatch(value): + raise ValueError('compatibility.hostApiVersion 必须是版本号或带操作符的版本约束') + return value + + +class PluginManifestV2(PluginManifest): + """ + 继承通用权限、菜单、配置及依赖规则,替换交付相关约束。 + """ + + manifest_version: Literal[2] = Field(default=2, alias='manifestVersion') + backend: BackendManifestV2 + frontend: FrontendManifestV2 = Field(default_factory=FrontendManifestV2) + compatibility: CompatibilityManifestV2 = Field(default_factory=CompatibilityManifestV2) + + def _validate_backend_module(self) -> None: + """ + 校验后端模块命名空间,并补全固定的 ASGI 挂载路径。 + + :return: None + """ + expected = f'ruoyi_plugin_{self.id}' if self.backend.runtime == 'native' else f'plugins.{self.id}' + if self.backend.module != expected: + raise ValueError(f'backend.module 必须为 {expected}') + if self.backend.asgi is not None: + expected_mount = f'/apps/{self.id}' + if self.backend.asgi.mount_path not in (None, expected_mount): + raise ValueError(f'backend.asgi.mountPath 必须为 {expected_mount}') + self.backend.asgi.mount_path = expected_mount + + def _fill_frontend_delivery_defaults(self) -> None: + """ + 补全前端交付默认值并校验独立构建产物的交付约束。 + + :return: None + """ + if self.frontend.delivery.type != 'bundle': + if self.frontend.bundle is not None: + raise ValueError('仅 bundle 交付允许 frontend.bundle') + super()._fill_frontend_delivery_defaults() + return + if self.backend.integration != 'asgi': + raise ValueError('bundle 前端需要 ASGI 接入方式') + if self.frontend.bundle is None: + raise ValueError('bundle 交付必须声明 frontend.bundle') + if self.frontend.delivery.build_required: + raise ValueError('bundle 不参与宿主构建,buildRequired 必须为 false') + if self.dependencies.npm or self.dependencies.npm_dev: + raise ValueError('bundle 的 npm 构建依赖由插件独立管理,不能安装到宿主') + + def _validate_menu_components(self) -> None: + """ + 校验独立构建产物菜单仅使用插件框架或核心布局组件。 + + :return: None + """ + if self.frontend.delivery.type != 'bundle': + super()._validate_menu_components() + return + for menu in PluginMenuTree.flatten(self.frontend.menus): + if menu.type == 'F': + continue + if menu.component not in CORE_FRONTEND_COMPONENTS | {'PluginFrame'}: + raise ValueError('bundle 菜单仅允许 PluginFrame 或核心布局组件') + + def _validate_job_callable_prefixes(self) -> None: + """ + 校验任务回调位于当前插件后端模块命名空间内。 + + :return: None + """ + expected_prefix = f'{self.backend.module}.' + if any(not job.callable.startswith(expected_prefix) for job in self.backend.jobs): + raise ValueError(f'插件任务 callable 必须使用 {expected_prefix} 前缀') diff --git a/ruoyi-fastapi-backend/plugins/core/native/__init__.py b/ruoyi-fastapi-backend/plugins/core/native/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ruoyi-fastapi-backend/plugins/core/native/wheel.py b/ruoyi-fastapi-backend/plugins/core/native/wheel.py new file mode 100644 index 0000000..54655e0 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/native/wheel.py @@ -0,0 +1,265 @@ +import base64 +import csv +import hashlib +import importlib.metadata +import io +import platform +import shutil +import stat +from email.parser import Parser +from pathlib import Path, PurePosixPath, PureWindowsPath +from zipfile import ZipFile, ZipInfo + +from packaging.requirements import Requirement +from packaging.specifiers import SpecifierSet +from packaging.tags import parse_tag, sys_tags +from packaging.utils import canonicalize_name, parse_wheel_filename +from packaging.version import Version + +from plugins.core.manifest.schema import PluginManifest + +MAX_WHEEL_FILES = 10000 +MAX_WHEEL_BYTES = 512 * 1024 * 1024 +MAX_MEMBER_BYTES = 256 * 1024 * 1024 +RECORD_COLUMN_COUNT = 3 + + +def _matching_rank(path: Path, manifest: PluginManifest) -> int: + """ + 校验 wheel 身份并计算当前解释器支持的最佳平台标签优先级。 + + :param path: 待处理的文件路径 + :param manifest: 插件清单对象 + :return: 匹配标签在解释器支持列表中的最小序号 + :raises ValueError: wheel 身份不匹配或当前解释器、ABI、平台不受支持 + """ + name, version, _, tags = parse_wheel_filename(path.name) + if name != canonicalize_name(manifest.backend.native.distribution) or version != Version(manifest.version): + raise ValueError('wheel 名称或版本与插件清单不一致') + ranks = [ + index for index, tag in enumerate(sys_tags()) if tag in tags and tag.abi != 'none' and tag.platform != 'any' + ] + if not ranks: + raise ValueError('wheel 不支持当前 Python、ABI 或平台') + return min(ranks) + + +def select_native_wheel(directory: Path, manifest: PluginManifest) -> Path: + """ + 从目录中选择名称、版本及解释器平台标签均匹配的原生 wheel。 + + :param directory: 包含待选择 wheel 文件的目录 + :param manifest: 插件清单对象 + :return: 兼容性优先级最高的 wheel 文件路径 + :raises ValueError: 插件不是原生类型或目录中没有匹配的 wheel + """ + if manifest.runtime_kind != 'native': + raise ValueError('仅 native 插件可选择 wheel') + + def candidate(path: Path) -> tuple[int, str, Path] | None: + """ + 尝试生成单个 wheel 的排序候选,忽略不兼容的文件。 + + :param path: 待处理的文件路径 + :return: 标签优先级、文件名和路径,不兼容时为 None + """ + try: + return _matching_rank(path, manifest), path.name, path + except ValueError: + return None + + candidates = [item for path in directory.glob('*.whl') if (item := candidate(path)) is not None] + if not candidates: + raise ValueError('未找到与当前解释器及插件版本匹配的原生 wheel') + return min(candidates)[2] + + +def _safe_member(name: str) -> PurePosixPath: + """ + 校验 wheel 内相对路径,拒绝目录穿越及 Windows 路径歧义。 + + :param name: wheel 中的原始条目名称 + :return: 通过校验的 POSIX 相对路径 + :raises ValueError: 条目路径不符合跨平台安全要求 + """ + parts = name.rstrip('/').split('/') + path = PurePosixPath(name) + if ( + not parts + or path.is_absolute() + or '\\' in name + or ':' in name + or any( + part in {'', '.', '..'} or part.endswith((' ', '.')) or PureWindowsPath(part).is_reserved() + for part in parts + ) + ): + raise ValueError(f'wheel 中包含不安全路径:{name}') + return path + + +def _inventory(archive: ZipFile, manifest: PluginManifest) -> tuple[dict[str, ZipInfo], str]: + """ + 检查压缩条目、资源上限及允许的模块边界,并定位唯一元数据目录。 + + :param archive: 已打开的 ZIP 压缩容器 + :param manifest: 插件清单对象 + :return: 文件条目映射与 dist-info 目录名 + :raises ValueError: 压缩条目、文件类型、资源用量或顶层模块范围不合法 + """ + entries = archive.infolist() + if len(entries) > MAX_WHEEL_FILES or sum(item.file_size for item in entries) > MAX_WHEEL_BYTES: + raise ValueError('wheel 文件数量或解压大小超过限制') + names: dict[str, str] = {} + files = {} + for entry in entries: + path = _safe_member(entry.filename) + normalized = str(path).casefold() + if normalized in names: + raise ValueError(f'wheel 包含重复或大小写冲突路径:{entry.filename}') + names[normalized] = entry.filename + mode = entry.external_attr >> 16 + if stat.S_ISLNK(mode) or (stat.S_IFMT(mode) and not (stat.S_ISREG(mode) or stat.S_ISDIR(mode))): + raise ValueError('wheel 不能包含链接或特殊文件') + if entry.file_size > MAX_MEMBER_BYTES or entry.flag_bits & 1: + raise ValueError('wheel 包含超限或加密文件') + if not entry.is_dir(): + files[entry.filename] = entry + + wheel_files = [name for name in files if name.endswith('.dist-info/WHEEL') and name.count('/') == 1] + if len(wheel_files) != 1: + raise ValueError('wheel 必须包含唯一的发行包元数据') + metadata_root = wheel_files[0].split('/')[0] + allowed_roots = {manifest.backend.module, metadata_root} + if any(PurePosixPath(name).parts[0] not in allowed_roots for name in names.values()): + raise ValueError('wheel 只能包含插件模块及其 dist-info;不支持 .data 或其他顶层模块') + return files, metadata_root + + +def _verify_dependency(value: str) -> None: + """ + 要求宿主已安装满足约束的依赖,不隐式联网安装。 + + :param value: METADATA 中的单条 Requires-Dist 依赖声明 + :return: None + :raises ValueError: 依赖使用未锁定 URL、尚未安装或版本不满足要求 + """ + requirement = Requirement(value) + if requirement.marker and not requirement.marker.evaluate({'extra': ''}): + return + if requirement.url: + raise ValueError('原生 wheel 依赖不能使用未锁定的 URL') + try: + installed = importlib.metadata.version(requirement.name) + except importlib.metadata.PackageNotFoundError as exc: + raise ValueError(f'宿主缺少 wheel 依赖:{requirement.name}') from exc + if not requirement.specifier.contains(installed): + raise ValueError(f'宿主 wheel 依赖版本不匹配:{requirement}') + + +def _verify_metadata(archive: ZipFile, metadata_root: str, wheel: Path, manifest: PluginManifest) -> None: + """ + 核对 wheel 发行包身份、版本、Python 要求及已安装依赖。 + + :param archive: 已打开的 ZIP 压缩容器 + :param metadata_root: wheel 内唯一的 dist-info 元数据目录名 + :param wheel: 待验证或展开的原生 wheel 文件路径 + :param manifest: 插件清单对象 + :return: None + :raises ValueError: 发行包元数据、平台标签或宿主依赖不匹配 + """ + metadata_path = f'{metadata_root}/METADATA' + record_path = f'{metadata_root}/RECORD' + if metadata_path not in archive.namelist() or record_path not in archive.namelist(): + raise ValueError('wheel 缺少 METADATA 或 RECORD') + metadata = Parser().parsestr(archive.read(metadata_path).decode('utf-8')) + if canonicalize_name(metadata.get('Name', '')) != canonicalize_name(manifest.backend.native.distribution): + raise ValueError('wheel METADATA 的发行包名称不匹配') + if Version(metadata.get('Version', '')) != Version(manifest.version): + raise ValueError('wheel METADATA 的版本不匹配') + if metadata.get('Requires-Python') and not SpecifierSet(metadata['Requires-Python']).contains( + platform.python_version() + ): + raise ValueError('wheel Requires-Python 与当前解释器不兼容') + for value in metadata.get_all('Requires-Dist', []): + _verify_dependency(value) + wheel_metadata = Parser().parsestr(archive.read(f'{metadata_root}/WHEEL').decode('utf-8')) + declared_tags = set().union(*(parse_tag(value) for value in wheel_metadata.get_all('Tag', []))) + if ( + declared_tags != parse_wheel_filename(wheel.name)[3] + or wheel_metadata.get('Root-Is-Purelib', '').lower() != 'false' + ): + raise ValueError('WHEEL 的 tag 或原生安装布局不匹配') + + +def _verify_record(archive: ZipFile, files: dict[str, ZipInfo], record_path: str) -> None: + """ + 按 RECORD 校验完整文件清单、大小和 SHA256,拒绝未登记文件。 + + :param archive: 已打开的 ZIP 压缩容器 + :param files: 通过安全清单校验的 wheel 文件条目映射 + :param record_path: wheel 内 RECORD 文件的相对路径 + :return: None + :raises ValueError: RECORD 文件清单、大小或 SHA256 与实际内容不一致 + """ + records = {} + for row in csv.reader(io.StringIO(archive.read(record_path).decode('utf-8'))): + if len(row) != RECORD_COLUMN_COUNT or row[0] in records: + raise ValueError('RECORD 存在非法或重复条目') + records[row[0]] = (row[1], row[2]) + if set(records) != set(files): + raise ValueError('RECORD 必须完整覆盖 wheel 文件,且不能引用包外文件') + for name, entry in files.items(): + digest, size = records[name] + if name == record_path: + if digest or size: + raise ValueError('RECORD 自身不应声明摘要或大小') + continue + if not digest.startswith('sha256=') or size != str(entry.file_size): + raise ValueError(f'RECORD 缺少 SHA256 或大小不一致:{name}') + hasher = hashlib.sha256() + with archive.open(entry) as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + hasher.update(chunk) + actual = base64.urlsafe_b64encode(hasher.digest()).rstrip(b'=').decode('ascii') + if digest != f'sha256={actual}': + raise ValueError(f'wheel 文件摘要不匹配:{name}') + + +def install_native_wheel(wheel: Path, destination: Path, manifest: PluginManifest) -> None: + """ + 验证原生 wheel 后展开到全新目录,不覆盖已有版本或执行安装脚本。 + + RECORD 只证明包内一致性,发布者身份由后续制品签名层验证。 + 展开失败时仅清理本次创建的目标目录。 + + :param wheel: 待验证或展开的原生 wheel 文件路径 + :param destination: 尚不存在的原生插件安装目录 + :param manifest: 插件清单对象 + :return: None + :raises ValueError: wheel 类型、兼容性、文件内容或展开路径校验失败 + :raises FileExistsError: 目标目录已经存在,不能覆盖安装 + """ + if manifest.runtime_kind != 'native': + raise ValueError('仅 native 插件可安装 wheel') + target = destination.resolve() + if target.exists(): + raise FileExistsError('原生安装目录已存在,请使用新目录并通过重启切换版本') + with ZipFile(wheel) as archive: + _matching_rank(wheel, manifest) + files, metadata_root = _inventory(archive, manifest) + _verify_metadata(archive, metadata_root, wheel, manifest) + _verify_record(archive, files, f'{metadata_root}/RECORD') + target.mkdir(parents=True, exist_ok=False) + try: + for name in files: + path = target.joinpath(*PurePosixPath(name).parts) + if not path.resolve().is_relative_to(target): + raise ValueError('wheel 解压目标越过安装目录') + path.parent.mkdir(parents=True, exist_ok=True) + with archive.open(name) as source, path.open('xb') as output: + shutil.copyfileobj(source, output, length=1024 * 1024) + except BaseException: + # target 是本次创建且已解析的目录,所有写入都验证过边界。 + shutil.rmtree(target) + raise diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/application.py b/ruoyi-fastapi-backend/plugins/core/runtime/application.py index 695b2fa..2a581b5 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/application.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/application.py @@ -7,6 +7,8 @@ from functools import cache from fastapi import FastAPI from common.constant import LockConstant +from plugins.core.runtime.diagnostics_store import PluginDiagnosticsReporter, diagnostics_namespace +from plugins.core.runtime.explicit import ExplicitPluginRuntime from plugins.core.runtime.startup import PluginRuntimeStartupManager from plugins.core.runtime.startup_coordination import PluginStartupGenerationResolver from utils.log_util import logger @@ -33,6 +35,7 @@ class PluginApplicationRuntime: ready_wait_interval_seconds: int = LockConstant.PLUGIN_STARTUP_READY_WAIT_INTERVAL_SECONDS, lifecycle_lock: PluginLifecycleLock | None = None, startup_generation: str | None = None, + release_worker: object | None = None, ) -> None: """ 初始化插件应用运行时适配器。 @@ -45,6 +48,7 @@ class PluginApplicationRuntime: :param ready_wait_interval_seconds: 等待 ready 轮询间隔 :param lifecycle_lock: 插件生命周期全局锁 :param startup_generation: 测试或部署显式注入的启动代际 + :param release_worker: 维护当前发布快照和 worker 心跳的可选协调器 :return: None """ self.startup_manager = startup_manager or PluginRuntimeStartupManager() @@ -55,6 +59,7 @@ class PluginApplicationRuntime: self.ready_wait_interval_seconds = ready_wait_interval_seconds self.lifecycle_lock = lifecycle_lock or NoopPluginLifecycleLock() self.startup_generation = startup_generation + self.release_worker = release_worker def bind_app(self, app: FastAPI) -> None: """ @@ -75,6 +80,8 @@ class PluginApplicationRuntime: """ self._ensure_bound(app) self.startup_manager.import_builtin_entities() + if self.release_worker is not None: + from plugins.core.management.entity.do import release_models # noqa: F401, PLC0415 async def startup( self, @@ -92,7 +99,57 @@ class PluginApplicationRuntime: :param create_tables: 数据库建表回调 :return: None """ + try: + if self.release_worker is not None: + await self.release_worker.start( + app, self.startup_manager.builder, self.lifecycle_lock, timeout=self.ready_wait_timeout_seconds + ) + self.startup_generation = self.release_worker.generation(self.resolve_startup_generation()) + app.state.plugin_release_worker = self.release_worker + await self._startup_coordinated(app, create_tables=create_tables) + if self.release_worker is not None: + await self.release_worker.ready() + runtime = getattr(app.state, 'plugin_explicit_runtime', None) + if isinstance(runtime, ExplicitPluginRuntime): + if self.release_worker is not None: + # 即使全部显式插件准备失败,空运行时也属于已有发布 worker。 + runtime.metrics.worker_id = self.release_worker.worker_id + runtime.metrics_reporter.start(getattr(app.state, 'redis', None), f'{self.ready_key}:metrics') + reporter = PluginDiagnosticsReporter(runtime.diagnostic_snapshot, runtime.metrics.worker_id) + app.state.plugin_diagnostics_reporter = reporter + reporter.start(getattr(app.state, 'redis', None), diagnostics_namespace(self.ready_key)) + except BaseException: + # 激活后的 ready 发布或锁释放也可能失败,此时 server 尚未标记启动完成。 + # 显式资源需在这里释放,不能依赖正常 shutdown 分支。 + runtime = getattr(app.state, 'plugin_explicit_runtime', None) + try: + if isinstance(runtime, ExplicitPluginRuntime): + await runtime.shutdown() + finally: + await self._stop_diagnostics(app) + if self.release_worker is not None: + try: + await self.release_worker.stop(failed=True) + except Exception: + logger.exception('插件启动失败后的 worker 状态上报失败;旧心跳将按 TTL 失效') + raise + + async def _startup_coordinated( + self, + app: FastAPI, + *, + create_tables: Callable[[], Awaitable[None]], + ) -> None: + """ + 执行 writer/reader 协调,包括激活后的 ready 发布及锁释放。 + + :param app: 宿主 FastAPI 应用 + :param create_tables: 在允许写入的 worker 中创建数据库表的异步回调 + :return: None + """ self._ensure_bound(app) + if self.release_worker is not None: + await self.release_worker.assert_snapshot_current() generation = self.resolve_startup_generation() app.state.plugin_startup_generation = generation app.state.plugin_startup_write_enabled = False @@ -337,7 +394,27 @@ class PluginApplicationRuntime: """ self._ensure_bound(app) startup_write_enabled = bool(getattr(app.state, 'plugin_startup_write_enabled', False)) - await self.startup_manager.shutdown(app, startup_write_enabled=startup_write_enabled) + try: + await self.startup_manager.shutdown(app, startup_write_enabled=startup_write_enabled) + finally: + await self._stop_diagnostics(app) + if self.release_worker is not None: + await self.release_worker.stop() + + @staticmethod + async def _stop_diagnostics(app: FastAPI) -> None: + """ + 关闭诊断采样,观测清理失败不阻止发布 worker 上报最终状态。 + + :param app: 宿主 FastAPI 应用 + :return: None + """ + reporter = getattr(app.state, 'plugin_diagnostics_reporter', None) + if isinstance(reporter, PluginDiagnosticsReporter): + try: + await reporter.stop() + except Exception: + logger.warning('插件运行诊断上报关闭失败,远程快照将按 TTL 过期') async def clear_startup_ready(self, app: FastAPI, generation: str | None = None) -> None: """ @@ -468,6 +545,7 @@ def get_plugin_application_runtime() -> PluginApplicationRuntime: :return: 应用插件运行时适配器 """ + from plugins.core.deployment.config import PluginDeploymentConfig # noqa: PLC0415 from plugins.core.management.service.startup_gateway import ( # noqa: PLC0415 PluginManagementRouteStateGateway, PluginManagementStartupGateway, @@ -478,7 +556,14 @@ def get_plugin_application_runtime() -> PluginApplicationRuntime: management_gateway=PluginManagementStartupGateway(), route_state_gateway=PluginManagementRouteStateGateway(), ) + deployment_config = PluginDeploymentConfig.from_settings(startup_manager.builder.backend_root) + release_worker = None + if deployment_config.enabled: + from plugins.core.deployment.worker import PluginReleaseWorker # noqa: PLC0415 + + release_worker = PluginReleaseWorker(deployment_config) return PluginApplicationRuntime( startup_manager=startup_manager, lifecycle_lock=RedisPluginLifecycleLock(), + release_worker=release_worker, ) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/asgi.py b/ruoyi-fastapi-backend/plugins/core/runtime/asgi.py new file mode 100644 index 0000000..3ff931a --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/asgi.py @@ -0,0 +1,289 @@ +import asyncio +import math +from collections.abc import Awaitable, Callable +from contextlib import suppress +from typing import Any + +from starlette.responses import JSONResponse +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from plugins.core.runtime.connections import ( + CANCEL_TIMEOUT_SECONDS, + RECHECK_INTERVAL_SECONDS, + RECHECK_TIMEOUT_SECONDS, + PluginConnectionManager, +) +from plugins.core.runtime.task_cleanup import cancel_plugin_tasks +from plugins.core.sdk.context import PluginRequestContext +from utils.log_util import logger + + +class PluginLifespanManager: + """ + 通过 ASGI lifespan 协议驱动一个 worker 内的插件实例。 + """ + + def __init__( + self, + app: ASGIApp, + *, + timeout: float = 30.0, + managed: bool = True, + cancel_timeout: float = CANCEL_TIMEOUT_SECONDS, + ) -> None: + """ + 初始化当前 worker 的插件生命周期管理器。 + + :param app: 插件 ASGI 应用 + :param timeout: 生命周期事件确认的超时时间,单位为秒 + :param managed: 是否由宿主驱动 ASGI 生命周期协议 + :param cancel_timeout: 生命周期任务取消的等待上限秒数 + :return: None + """ + if any(not math.isfinite(value) or value <= 0 for value in (timeout, cancel_timeout)): + raise ValueError('生命周期等待时间必须大于零') + self.app = app + self.timeout = timeout + self.cancel_timeout = cancel_timeout + self.managed = managed + self.state: dict[str, Any] = {} + self.started = False + self._stopping = False + self._operation_lock = asyncio.Lock() + self._task: asyncio.Task[None] | None = None + self._receive: asyncio.Queue[Message] = asyncio.Queue() + self._send: asyncio.Queue[Message] = asyncio.Queue() + + @property + def has_pending_task(self) -> bool: + """ + 判断上一次生命周期是否仍占用资源,防止重试创建重叠实例。 + + :return: 是否存在尚未退出的生命周期任务 + """ + return self._task is not None and not self._task.done() + + @property + def ready(self) -> bool: + """ + lifespan 意外退出后不再接受业务请求。 + + :return: 插件是否已启动且生命周期任务仍然存活 + """ + return self.started and not self._stopping and (not self.managed or self.has_pending_task) + + async def startup(self) -> None: + """ + 只有确认 startup.complete 后才允许接收业务请求。 + + :return: None + """ + async with self._operation_lock: + self._stopping = False + await self._startup() + + async def _startup(self) -> None: + """ + 串行启动新一轮协议,旧任务退出后才允许重试。 + + :return: None + """ + if self.started: + return + if self.has_pending_task: + raise RuntimeError('上一次插件生命周期尚未退出,不能重新启动') + self.state = {} + self._receive = asyncio.Queue() + self._send = asyncio.Queue() + if not self.managed: + self.started = True + return + scope = {'type': 'lifespan', 'asgi': {'version': '3.0', 'spec_version': '2.0'}, 'state': self.state} + self._task = asyncio.create_task(self._invoke(scope)) + try: + await self._exchange('startup') + self.started = True + except BaseException: + await self._cancel_task() + raise + + async def shutdown(self) -> None: + """ + 即使协议失败或超时,也回收本 worker 创建的 lifespan task。 + + :return: None + """ + self._stopping = True + async with self._operation_lock: + await self._shutdown() + + async def _shutdown(self) -> None: + """ + 串行发送关闭消息,避免并发调用消费同一个协议确认。 + + :return: None + """ + was_started = self.started + self.started = False + try: + if was_started and self.managed: + await self._exchange('shutdown') + done, _ = await asyncio.wait({self._task}, timeout=self.timeout) + if not done: + raise TimeoutError('ASGI lifespan.shutdown 确认后未退出') + self._check_task_result() + finally: + await self._cancel_task() + + async def _invoke(self, scope: Scope) -> None: + """ + 在独立协程中等待 ASGI 应用返回的任意 awaitable。 + + :param scope: 当前启动轮次的独立生命周期作用域 + :return: None + """ + await self.app(scope, self._receive.get, self._send.put) + + async def _exchange(self, event: str) -> None: + """ + 发送生命周期事件并等待子应用确认。 + + :param event: 生命周期事件名称,支持 startup 和 shutdown + :return: None + """ + await self._receive.put({'type': f'lifespan.{event}'}) + response = asyncio.create_task(self._send.get()) + try: + done, _ = await asyncio.wait( + (response, self._task), timeout=self.timeout, return_when=asyncio.FIRST_COMPLETED + ) + if response not in done: + if self._task in done: + self._check_task_result() + raise RuntimeError(f'ASGI 应用在 lifespan.{event} 确认前退出') + raise TimeoutError(f'ASGI lifespan.{event} 超时') + message = response.result() + if message.get('type') != f'lifespan.{event}.complete': + raise RuntimeError(f'ASGI lifespan.{event} 失败:{message.get("message", message.get("type"))}') + if event == 'startup' and self._task.done(): + self._check_task_result() + raise RuntimeError('ASGI lifespan 在 startup 后提前退出') + finally: + response.cancel() + with suppress(asyncio.CancelledError): + await response + + def _check_task_result(self) -> None: + """ + 将子应用自行取消作为协议失败,保留宿主调用者自身的取消语义。 + + :return: None + """ + if self._task.cancelled(): + raise RuntimeError('ASGI 生命周期任务自行取消') + self._task.result() + + async def _cancel_task(self) -> None: + """ + 取消并回收当前 worker 的生命周期任务。 + + :return: None + """ + if self._task is None: + return + if await cancel_plugin_tasks({self._task}, timeout=self.cancel_timeout): + logger.warning('插件生命周期未响应取消;保留任务并拒绝重启,等待任务自行退出') + else: + self._task = None + + +class PluginGatewayASGI: + """ + HTTP、静态资源和 WebSocket 共用的宿主门禁。 + + 接受宿主 Bearer 或限定 bundle 的插件会话,由宿主授权器校验。 + 静态资源与业务接口都必须经过同一身份门禁。 + """ + + def __init__( + self, + app: ASGIApp, + lifespan: PluginLifespanManager, + authorize: Callable[[Scope], Awaitable[PluginRequestContext]], + *, + recheck_interval: float = RECHECK_INTERVAL_SECONDS, + recheck_timeout: float = RECHECK_TIMEOUT_SECONDS, + cancel_timeout: float = CANCEL_TIMEOUT_SECONDS, + ) -> None: + """ + 初始化插件请求门禁。 + + :param app: 受保护的插件 ASGI 应用 + :param lifespan: 当前插件的生命周期管理器 + :param authorize: 根据 ASGI 请求作用域构建宿主身份上下文的异步授权器 + :param recheck_interval: 已建立长连接的身份复核间隔秒数 + :param recheck_timeout: 每次长连接身份复核的超时秒数 + :param cancel_timeout: 长连接任务取消的等待秒数 + :return: None + """ + self.app = app + self.lifespan = lifespan + self.authorize = authorize + self.connections = PluginConnectionManager( + authorize, + lambda: lifespan.ready, + recheck_interval=recheck_interval, + recheck_timeout=recheck_timeout, + cancel_timeout=cancel_timeout, + ) + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + """ + 校验插件就绪状态及访问权限,并向子应用注入请求上下文。 + + :param scope: ASGI 请求作用域 + :param receive: ASGI 消息接收函数 + :param send: ASGI 消息发送函数 + :return: None + """ + if scope['type'] not in {'http', 'websocket'}: + raise RuntimeError('插件 lifespan 必须由宿主管理器驱动') + if not self.lifespan.ready or self.connections.closing: + await self._reject(scope, receive, send, 503, '插件尚未就绪') + return + try: + context = await self.authorize(scope) + except PermissionError: + await self._reject(scope, receive, send, 403, '插件未启用或没有访问权限') + return + except LookupError: + await self._reject(scope, receive, send, 401, '请先登录') + return + child_scope = dict(scope) + child_scope['state'] = {**scope.get('state', {}), **self.lifespan.state, 'plugin_context': context} + await self.connections.run(self.app, scope, child_scope, context, receive, send) + + async def drain(self) -> None: + """ + 在释放插件资源前停止当前 worker 的 SSE 与 WebSocket。 + + :return: None + """ + await self.connections.drain() + + @staticmethod + async def _reject(scope: Scope, receive: Receive, send: Send, status: int, message: str) -> None: + """ + 按请求协议返回拒绝响应或关闭 WebSocket 连接。 + + :param scope: ASGI 请求作用域 + :param receive: ASGI 消息接收函数 + :param send: ASGI 消息发送函数 + :param status: HTTP 拒绝响应状态码 + :param message: 拒绝原因 + :return: None + """ + if scope['type'] == 'websocket': + await send({'type': 'websocket.close', 'code': 1008, 'reason': message}) + else: + await JSONResponse({'code': status, 'msg': message}, status_code=status)(scope, receive, send) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/bootstrap.py b/ruoyi-fastapi-backend/plugins/core/runtime/bootstrap.py index f74c8b3..2bff74c 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/bootstrap.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/bootstrap.py @@ -54,6 +54,30 @@ class PluginRuntimeBuilder: self.entity_importer = EntityModuleImporter(self.backend_root) self._discovered_plugins: list[DiscoveredPlugin] | None = None self._discovery_errors: list[PluginDiscoveryError] | None = None + self._artifact_plugins: list[DiscoveredPlugin] = [] + self._artifact_errors: list[PluginDiscoveryError] = [] + self._artifact_plugin_ids: set[str] = set() + + def set_artifact_plugins( + self, + plugins: list[DiscoveredPlugin], + *, + reserved_ids: set[str], + errors: list[PluginDiscoveryError] | None = None, + ) -> None: + """ + 绑定本进程启动时已验签的发布快照,失败的目标 ID 也禁止源码回退。 + + :param plugins: 启动时已通过验签的发布插件快照 + :param reserved_ids: 已被制品发布记录占用的插件 ID 集合 + :param errors: 制品解析过程中收集的插件发现错误列表 + :return: None + """ + self._artifact_plugins = list(plugins) + self._artifact_plugin_ids = set(reserved_ids) + self._artifact_errors = list(errors or []) + self._discovered_plugins = None + self._discovery_errors = None def discover_plugins(self) -> list[DiscoveredPlugin]: """ @@ -67,9 +91,20 @@ class PluginRuntimeBuilder: if self._discovered_plugins is not None: return self._discovered_plugins discovery_result = PluginScanner(self.plugins_root).discover_with_errors() - self._discovered_plugins = discovery_result.plugins - self._discovery_errors = discovery_result.errors - for error in discovery_result.errors: + conflicts = {plugin_id for plugin_id in self._artifact_plugin_ids if (self.plugins_root / plugin_id).exists()} + self._discovered_plugins = [ + plugin for plugin in discovery_result.plugins if plugin.manifest.id not in self._artifact_plugin_ids + ] + [plugin for plugin in self._artifact_plugins if plugin.manifest.id not in conflicts] + self._discovery_errors = [*discovery_result.errors, *self._artifact_errors] + self._discovery_errors.extend( + PluginDiscoveryError( + manifest_path=self.plugins_root / plugin_id / 'plugin.yaml', + plugin_dir=self.plugins_root / plugin_id, + error_message=f'插件 {plugin_id} 同时存在源码目录和制品发布目标,已拒绝两者', + ) + for plugin_id in sorted(conflicts) + ) + for error in self._discovery_errors: logger.error(f'❌ 插件扫描失败,已隔离损坏插件:目录={error.plugin_dir},错误:{error.error_message}') return self._discovered_plugins @@ -111,6 +146,8 @@ class PluginRuntimeBuilder: """ import_result = PluginEntityImportResult() for plugin in plugin_registry.list_enabled_plugins(): + if plugin.discovered_plugin.manifest.uses_entrypoint: + continue entity_do_dir = plugin.backend_path / 'entity' / 'do' if not entity_do_dir.is_dir(): continue diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/browser_session.py b/ruoyi-fastapi-backend/plugins/core/runtime/browser_session.py new file mode 100644 index 0000000..e072617 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/browser_session.py @@ -0,0 +1,211 @@ +import hashlib +import json +import re +import secrets +import time +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, ValidationError +from starlette.requests import Request + +from common.enums import RedisInitKeyConfig +from config.env import AppConfig +from exceptions.exception import AuthException +from module_admin.service.login_service import LoginService +from utils.jwt_util import JwtUtil + +SESSION_SECONDS = 300 +SESSION_KEY_PREFIX = 'plugin:browser-session:' +COOKIE_NAME_PREFIX = 'Plugin-Session-' +COOKIE_PATTERN = re.compile(r'^([a-f0-9]{64})\.([A-Za-z0-9_-]{43})$') +SAFE_ROOT_PATTERN = re.compile(r'^(?:/[A-Za-z0-9_-]+)*$') +SAFE_METHODS = frozenset({'GET', 'HEAD', 'OPTIONS'}) + + +class PluginBrowserSession(BaseModel): + """ + Redis 内部记录;不作为 API 响应或日志字段。 + """ + + model_config = ConfigDict(extra='forbid', frozen=True) + + plugin_id: str + version: str + main_key: str + main_digest: str = Field(pattern=r'^[a-f0-9]{64}$') + user_id: int = Field(gt=0) + origin: str + nonce: str = Field(pattern=r'^[A-Za-z0-9_-]{43}$') + csrf: str = Field(pattern=r'^[A-Za-z0-9_-]{43}$') + + +def _digest(value: str) -> str: + """ + 计算会话凭证或绑定信息的 SHA256 摘要。 + + :param value: 待计算摘要的字符串 + :return: 十六进制 SHA256 摘要 + """ + return hashlib.sha256(value.encode('utf-8')).hexdigest() + + +def request_origin(request: Request) -> str: + """ + 仅使用 ASGI 服务器处理过的 scheme/Host,不自行信任代理头。 + + :param request: 宿主 HTTP 请求或转换后的 WebSocket 请求 + :return: 转为小写的 HTTP 或 HTTPS 来源地址 + """ + scheme = {'ws': 'http', 'wss': 'https'}.get(request.url.scheme, request.url.scheme) + return f'{scheme}://{request.url.netloc}'.lower() + + +def plugin_browser_base(request: Request, plugin_id: str) -> str: + """ + 发行接口处的 root_path 为宿主部署前缀,而非插件 Mount 前缀。 + + :param request: 签发插件会话的宿主请求 + :param plugin_id: 插件 ID + :return: 包含宿主部署前缀的插件浏览器入口路径 + """ + root = str(request.scope.get('root_path', '')).rstrip('/') + if not SAFE_ROOT_PATTERN.fullmatch(root): + raise ValueError('插件浏览器入口需要安全的部署 root_path') + return f'{root}/apps/{plugin_id}/' + + +def _parse_record(raw: str | bytes | None) -> PluginBrowserSession: + """ + 解析 Redis 中的插件浏览器会话,拒绝过期或格式无效的记录。 + + :param raw: Redis 返回的会话 JSON 字符串或字节串 + :return: 验证后的插件浏览器会话记录 + """ + if not raw: + raise LookupError('插件会话已过期') + try: + return PluginBrowserSession.model_validate_json(raw) + except ValidationError as exc: + raise LookupError('插件会话无效') from exc + + +async def issue_browser_session( + request: Request, *, plugin_id: str, version: str, main_token: str, user_id: int +) -> tuple[str, str, int]: + """ + 认证成功的控制器调用;返回 cookie、CSRF 和有效秒数。 + + 一条 Redis 记录同时承担索引与认证,NX 竞争后读取获胜记录,避免多标签 + 签发覆盖 cookie/CSRF。主登录 token 仅用于即时检查,不存入插件记录。 + + :param request: 已通过宿主认证的会话签发请求 + :param plugin_id: 插件 ID + :param version: 当前插件版本 + :param main_token: 当前用户的宿主登录凭证 + :param user_id: 已认证的用户 ID + :return: 插件 Cookie 值、CSRF 凭证和有效秒数 + """ + origin = request_origin(request) + if request.headers.get('origin') not in (None, origin): + raise PermissionError('插件会话只能从同源宿主签发') + payload = JwtUtil.decode(main_token) + if int(payload.get('user_id', 0)) != user_id: + raise PermissionError('插件会话用户身份不匹配') + subject = payload.get('session_id') if AppConfig.app_same_time_login else str(user_id) + if not subject: + raise LookupError('主登录缺少会话标识') + main_key = f'{RedisInitKeyConfig.ACCESS_TOKEN.key}:{subject}' + redis = request.app.state.redis + current = await redis.get(main_key) + if isinstance(current, bytes): + current = current.decode('utf-8') + if not isinstance(current, str) or not secrets.compare_digest(current, main_token): + raise LookupError('主登录会话已失效') + remaining = int(float(payload.get('exp', 0)) - time.time()) + seconds = min(SESSION_SECONDS, remaining) + if seconds <= 0: + raise LookupError('主登录会话已过期') + main_digest = _digest(main_token) + identity = json.dumps([main_digest, plugin_id, version, origin], separators=(',', ':')) + scope_key = _digest(identity) + key = f'{SESSION_KEY_PREFIX}{scope_key}' + for _ in range(3): + raw = await redis.get(key) + if raw: + record = _parse_record(raw) + if ( + record.plugin_id != plugin_id + or record.version != version + or record.main_key != main_key + or record.main_digest != main_digest + or record.user_id != user_id + or record.origin != origin + ): + raise LookupError('插件会话绑定无效') + # GET 后旧记录可能到期并被其他标签的 NX 重建;不能把旧 nonce + # 随较晚的响应写回 Cookie。重新读取确认续期的仍是当前记录。 + if await redis.expire(key, seconds) and await redis.get(key) == raw: + return f'{scope_key}.{record.nonce}', record.csrf, seconds + else: + record = PluginBrowserSession( + plugin_id=plugin_id, + version=version, + main_key=main_key, + main_digest=main_digest, + user_id=user_id, + origin=origin, + nonce=secrets.token_urlsafe(32), + csrf=secrets.token_urlsafe(32), + ) + if await redis.set(key, record.model_dump_json(), ex=seconds, nx=True): + return f'{scope_key}.{record.nonce}', record.csrf, seconds + raise LookupError('插件会话正在更新,请重试') + + +async def authenticate_browser_session( + request: Request, *, plugin_id: str, version: str, query_db: Any, websocket: bool = False +) -> Any: + """ + 每次使用时回查宿主 token,再复用原有登录服务获取实时用户和权限。 + + :param request: 携带插件 Cookie 的请求 + :param plugin_id: 插件 ID + :param version: 当前插件版本 + :param query_db: 用于查询用户及权限的数据库会话 + :param websocket: 是否按 WebSocket 请求执行来源校验 + :return: 通过宿主身份及实时权限校验的当前用户 + """ + cookie = request.cookies.get(f'{COOKIE_NAME_PREFIX}{plugin_id}', '') + matched = COOKIE_PATTERN.fullmatch(cookie) + if matched is None: + raise LookupError('缺少插件会话') + scope_key, nonce = matched.groups() + redis = request.app.state.redis + record = _parse_record(await redis.get(f'{SESSION_KEY_PREFIX}{scope_key}')) + if record.plugin_id != plugin_id or record.version != version: + raise PermissionError('插件会话不适用于此插件或版本') + if not secrets.compare_digest(record.nonce, nonce): + raise LookupError('插件会话无效') + origin = request_origin(request) + supplied_origin = request.headers.get('origin') + if record.origin != origin or (supplied_origin is not None and supplied_origin != origin): + raise PermissionError('插件会话来源不匹配') + if websocket or request.method not in SAFE_METHODS: + if supplied_origin != origin: + raise PermissionError('插件请求缺少同源 Origin') + if not websocket and not secrets.compare_digest(request.headers.get('x-plugin-csrf', ''), record.csrf): + raise PermissionError('插件请求 CSRF 校验失败') + main_token = await redis.get(record.main_key) + if isinstance(main_token, bytes): + main_token = main_token.decode('utf-8') + if not isinstance(main_token, str) or not secrets.compare_digest(_digest(main_token), record.main_digest): + raise LookupError('主登录会话已失效') + try: + user = await LoginService.get_current_user_for_plugin_session( + request=request, token=main_token, query_db=query_db + ) + except AuthException as exc: + raise LookupError('主登录会话已失效') from exc + if user.user.user_id != record.user_id: + raise PermissionError('插件会话用户身份不匹配') + return user diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/bundle.py b/ruoyi-fastapi-backend/plugins/core/runtime/bundle.py new file mode 100644 index 0000000..1f8a74e --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/bundle.py @@ -0,0 +1,277 @@ +import json +import mimetypes +import re +from html import escape +from pathlib import Path +from urllib.parse import quote + +from starlette import status +from starlette._utils import get_route_path +from starlette.concurrency import run_in_threadpool +from starlette.datastructures import Headers +from starlette.responses import FileResponse, RedirectResponse, Response +from starlette.types import ASGIApp, Receive, Scope, Send + +from plugins.core.manifest.v2 import FrontendBundleManifest + +_MIME_TYPES = { + '.html': 'text/html', + '.htm': 'text/html', + '.js': 'text/javascript', + '.mjs': 'text/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.map': 'application/json', + '.svg': 'image/svg+xml', + '.wasm': 'application/wasm', + '.woff': 'font/woff', + '.woff2': 'font/woff2', +} +_RESERVED_FALLBACK_PATHS = frozenset({'api', 'assets', 'ws', 'docs', 'redoc'}) +_HEAD_TAG = re.compile(r']*)?>', re.IGNORECASE) +_HTML_TAG = re.compile(r']*)?>', re.IGNORECASE) +_DOCTYPE_TAG = re.compile(r']*>', re.IGNORECASE) +_FIRST_PRINTABLE_CHARACTER = 32 + + +class PluginBundleASGI: + """ + 仅为 /ui 命名空间提供资源;必须包装在 PluginGatewayASGI 内。 + + 构建目录应在部署后保持只读。此类只负责静态交付,不签发会话、不公开资源, + 也不改变外层传输加密策略。lifespan 和非 UI 请求始终委派原始子应用。 + """ + + def __init__( + self, + child_app: ASGIApp, + plugin_id: str, + plugin_root: Path, + bundle_manifest: FrontendBundleManifest, + ) -> None: + """ + 初始化插件前端资源交付器并校验构建目录和入口。 + + :param child_app: 接收非 UI 请求的原始插件 ASGI 应用 + :param plugin_id: 插件 ID + :param plugin_root: 插件资源根目录 + :param bundle_manifest: 插件前端构建产物声明 + :return: None + """ + self.child_app = child_app + self.plugin_id = plugin_id + self.plugin_root = Path(plugin_root).resolve(strict=True) + self.bundle_manifest = bundle_manifest + self.bundle_root = self._resolve_declared_path(self.plugin_root, bundle_manifest.directory) + if not self.bundle_root.is_dir(): + raise ValueError('插件 bundle 目录不存在') + self.entry_path = self._resolve_declared_path(self.bundle_root, bundle_manifest.entry) + if not self.entry_path.is_file(): + raise ValueError('插件 bundle 入口不存在') + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + """ + 交付 UI 静态资源,并将其他请求委派给原始子应用。 + + :param scope: ASGI 请求作用域 + :param receive: ASGI 消息接收函数 + :param send: ASGI 消息发送函数 + :return: None + """ + if scope['type'] != 'http': + await self.child_app(scope, receive, send) + return + route_path = get_route_path(scope) + if route_path != '/ui' and not route_path.startswith('/ui/'): + await self.child_app(scope, receive, send) + return + if scope['method'] not in {'GET', 'HEAD'}: + response = Response( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + headers={**self._headers('no-store'), 'Allow': 'GET, HEAD'}, + ) + elif route_path == '/ui': + location = self._base_paths(scope)['uiBase'] + if scope.get('query_string'): + location += '?' + scope['query_string'].decode('latin-1') + response = RedirectResponse(location, headers=self._headers('no-store')) + else: + response = await run_in_threadpool(self._resource_response, scope, route_path[4:]) + await response(scope, receive, send) + + def _resource_response(self, scope: Scope, resource_path: str) -> Response: + """ + 校验资源路径并生成文件响应或允许的页面回退响应。 + + :param scope: ASGI 请求作用域 + :param resource_path: 相对于 UI 命名空间的请求资源路径 + :return: 静态资源、HTML 页面或访问错误响应 + """ + accepts_html = self._accepts_html(scope) + relative_path = resource_path.rstrip('/') + if resource_path and not self._safe_parts(relative_path): + return self._not_found() + candidate = self.entry_path if not resource_path else self.bundle_root / relative_path + try: + candidate = candidate.resolve() + if not candidate.is_relative_to(self.bundle_root): + return self._not_found() + if candidate.is_dir(): + return self._not_found() + if not candidate.is_file(): + if not self._allows_fallback(relative_path, accepts_html): + return self._not_found() + candidate = self._resolve_declared_path(self.bundle_root, self.bundle_manifest.entry) + elif resource_path.endswith('/'): + return self._not_found() + if not candidate.is_file(): + return self._not_found() + media_type = _MIME_TYPES.get(candidate.suffix.lower()) or mimetypes.guess_type(candidate.name)[0] + if media_type == 'text/html': + if not accepts_html: + return Response(status_code=status.HTTP_406_NOT_ACCEPTABLE, headers=self._headers('no-store')) + html = candidate.read_text(encoding='utf-8') + if candidate == self.entry_path: + html = self._inject_config(html, scope) + content = html.encode('utf-8') + return Response( + content=content if scope['method'] == 'GET' else b'', + media_type='text/html', + headers={**self._headers('no-store'), 'Content-Length': str(len(content))}, + ) + return FileResponse( + candidate, + media_type=media_type or 'application/octet-stream', + headers=self._headers('no-cache'), + ) + except (OSError, ValueError, RuntimeError): + return self._not_found() + + def _allows_fallback(self, path: str, accepts_html: bool) -> bool: + """ + 判断缺失资源是否允许回退到单页应用入口。 + + :param path: 相对于构建目录的请求路径 + :param accepts_html: 请求是否接受 HTML 响应 + :return: 是否允许执行页面回退 + """ + parts = path.split('/') + return ( + self.bundle_manifest.spa_fallback + and accepts_html + and parts[0].lower() not in _RESERVED_FALLBACK_PATHS + and all('.' not in part for part in parts) + ) + + def _inject_config(self, html: str, scope: Scope) -> str: + """ + 向入口页面注入资源基址和经过转义的插件配置。 + + :param html: 插件入口页面的 HTML 内容 + :param scope: 包含实际挂载路径的 ASGI 请求作用域 + :return: 注入插件配置后的 HTML 内容 + """ + paths = self._base_paths(scope) + config = json.dumps({'pluginId': self.plugin_id, **paths}, ensure_ascii=True) + # JSON 位于 HTML raw-text 元素中,转义 < 可阻止 提前结束数据块。 + for character, escaped in (('<', '\\u003c'), ('>', '\\u003e'), ('&', '\\u0026')): + config = config.replace(character, escaped) + injected = ( + f'' + f'' + ) + head = _HEAD_TAG.search(html) + if head: + return html[: head.end()] + injected + html[head.end() :] + # 兼容省略 head 的 HTML;保留 doctype,避免把正常页面切换到 quirks mode。 + opening = _HTML_TAG.search(html) or _DOCTYPE_TAG.search(html) + position = opening.end() if opening else 0 + return html[:position] + f'{injected}' + html[position:] + + @staticmethod + def _base_paths(scope: Scope) -> dict[str, str]: + """ + 根据实际挂载路径生成插件页面和接口基址。 + + :param scope: ASGI 请求作用域 + :return: 页面和接口基址字典 + """ + mount_path = quote(scope.get('root_path', '').rstrip('/'), safe='/') + return {'uiBase': f'{mount_path}/ui/', 'apiBase': f'{mount_path}/api/'} + + @staticmethod + def _accepts_html(scope: Scope) -> bool: + """ + 检查 Accept 请求头是否明确接受有效权重的 HTML 类型。 + + :param scope: ASGI 请求作用域 + :return: 请求是否接受 HTML 响应 + """ + for item in Headers(scope=scope).get('accept', '').split(','): + media_type, *parameters = item.strip().lower().split(';') + if media_type != 'text/html': + continue + quality = next((part.strip()[2:] for part in parameters if part.strip().startswith('q=')), '1') + try: + if 0 < float(quality) <= 1: + return True + except ValueError: + continue + return False + + @staticmethod + def _safe_parts(path: str) -> bool: + """ + 检查相对资源路径的各级名称是否满足安全限制。 + + :param path: 待检查的相对资源路径 + :return: 路径各级名称是否安全 + """ + return bool(path) and all( + part + and not part.startswith('.') + and not part.endswith(('.', ' ')) + and not any(character in '<>:"\\|?*' or ord(character) < _FIRST_PRINTABLE_CHARACTER for character in part) + for part in path.split('/') + ) + + @classmethod + def _resolve_declared_path(cls, root: Path, relative_path: str) -> Path: + """ + 解析清单声明的资源路径并校验真实路径边界。 + + :param root: 允许访问的资源根目录 + :param relative_path: 清单声明的相对资源路径 + :return: 位于允许目录内的资源绝对路径 + """ + if not cls._safe_parts(relative_path): + raise ValueError('插件 bundle 资源路径非法') + path = (root / relative_path).resolve() + if not path.is_relative_to(root): + raise ValueError('插件 bundle 资源路径越界') + return path + + @staticmethod + def _headers(cache_control: str) -> dict[str, str]: + """ + 生成静态资源的缓存控制和同源安全响应头。 + + :param cache_control: 当前资源的缓存控制策略 + :return: 静态资源响应头字典 + """ + return { + 'Cache-Control': cache_control, + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'SAMEORIGIN', + 'Content-Security-Policy': "frame-ancestors 'self'", + 'Cross-Origin-Resource-Policy': 'same-origin', + } + + @classmethod + def _not_found(cls) -> Response: + """ + 生成禁止缓存的资源不存在响应。 + + :return: 携带安全响应头的 HTTP 404 响应 + """ + return Response(status_code=status.HTTP_404_NOT_FOUND, headers=cls._headers('no-store')) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/callable.py b/ruoyi-fastapi-backend/plugins/core/runtime/callable.py index eefa663..a590e64 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/callable.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/callable.py @@ -50,6 +50,13 @@ class PluginCallableLoader: :param callable_path: manifest 中声明的 callable 路径,格式为 : :return: 已加载 callable """ + if self.discovered_plugin.manifest.uses_entrypoint: + from plugins.core.runtime.entrypoint import PluginEntrypointLoader # noqa: PLC0415 + + loader = PluginEntrypointLoader(self.discovered_plugin) + loader.check_entrypoint() + module_name, callable_name = callable_path.split(':', 1) + return LoadedPluginCallable(module_name, callable_name, loader.load_callable(callable_path)) module_path, callable_name = callable_path.split(':', maxsplit=1) module_name = self.resolve_module_name(module_path) module = self.import_module(module_name) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/configuration.py b/ruoyi-fastapi-backend/plugins/core/runtime/configuration.py new file mode 100644 index 0000000..4d6f5c2 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/configuration.py @@ -0,0 +1,142 @@ +import hashlib +import hmac +import json +import time +from collections.abc import Callable, Mapping +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + +from config.env import JwtConfig +from plugins.core.discovery.scanner import DiscoveredPlugin +from plugins.core.sdk.config import PluginConfigSnapshot + + +def config_revision(plugin_id: str, values: Mapping[str, Any]) -> str: + """ + 生成绑定插件和宿主密钥的不透明版本,避免公开敏感配置的可枚举裸哈希。 + + :param plugin_id: 当前插件标识 + :param values: 按清单合并默认值后的配置明文 + :return: 可比较但不可据此还原配置的 HMAC 摘要 + """ + payload = json.dumps( + ['plugin-config-v1', plugin_id, dict(values)], + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(',', ':'), + ).encode('utf-8') + return hmac.new(JwtConfig.jwt_secret_key.encode('utf-8'), payload, hashlib.sha256).hexdigest() + + +class PluginConfigObservation(BaseModel): + """ + 当前 worker 的启动配置和最近一次按需读取版本,不包含配置值。 + """ + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, extra='forbid', strict=True) + + plugin_id: str = Field(pattern=r'^[a-z][a-z0-9_]{1,63}$') + version: str = Field(max_length=128) + digest: str | None = Field(default=None, pattern=r'^[0-9a-f]{64}$') + generation: str | None = Field(default=None, pattern=r'^[0-9a-f]{32}$') + active: bool = False + startup_revision: str = Field(pattern=r'^[0-9a-f]{64}$') + last_read_revision: str | None = Field(default=None, pattern=r'^[0-9a-f]{64}$') + last_read_at: float | None = Field(default=None, ge=0, allow_inf_nan=False) + + +class PluginConfigReader: + """ + 绑定已加载清单的配置读取器,每次读取使用自己的数据库会话。 + """ + + def __init__( + self, + plugin: DiscoveredPlugin, + session_factory: Callable[..., Any], + observation: PluginConfigObservation, + ) -> None: + """ + 固定插件身份,调用方不能提供其他插件 ID。 + + :param plugin: 当前进程实际加载的插件 + :param session_factory: 宿主提供的数据库会话工厂 + :param observation: 不包含敏感值的运行观测记录 + :return: None + """ + self.plugin = plugin + self.session_factory = session_factory + self.observation = observation + + async def read(self) -> PluginConfigSnapshot: + """ + 读取已提交配置并记录读取版本,数据库错误直接交由调用方处理。 + + :return: 独立的插件配置快照,不代表业务代码已完成应用 + """ + from plugins.core.management.service.service import PluginService # noqa: PLC0415 + + async with self.session_factory() as db: + configs = await PluginService.get_plugin_config_services(db, self.plugin, reveal_secret=True) + values = {item.key: item.value for item in configs} + snapshot = PluginConfigSnapshot(values, config_revision(self.plugin.manifest.id, values)) + self.observation.last_read_revision = snapshot.revision + self.observation.last_read_at = time.time() + return snapshot + + +def build_config_status(plugin_id: str, desired_revision: str, report: Mapping[str, Any]) -> dict[str, Any]: + """ + 比较已观测进程的启动快照,不把按需读取或局部观测当成全局生效。 + + :param plugin_id: 当前插件标识 + :param desired_revision: 当前数据库配置版本 + :param report: 指标报告器返回的进程快照与范围信息 + :return: 包含待重启数量和明确采样范围的配置状态 + """ + workers = [ + { + 'workerId': worker['workerId'], + 'collectedAt': worker['collectedAt'], + **config, + 'matchesDesired': config['active'] and config['startupRevision'] == desired_revision, + } + for worker in report.get('workers', []) + for config in worker.get('configurations', []) + if config['pluginId'] == plugin_id + ] + pending = sum(worker['active'] and not worker['matchesDesired'] for worker in workers) + matched = sum(worker['matchesDesired'] for worker in workers) + unknown = sum( + any(series['pluginId'] == plugin_id for series in worker.get('series', [])) + and not any(config['pluginId'] == plugin_id for config in worker.get('configurations', [])) + for worker in report.get('workers', []) + ) + return { + 'ok': True, + 'pluginId': plugin_id, + 'desiredRevision': desired_revision, + 'activationMode': 'startup_snapshot', + 'state': 'restart_required' if pending else 'observed_match' if matched else 'unobserved', + 'observedWorkers': len(workers), + 'matchedWorkers': matched, + 'pendingWorkers': pending, + 'unknownWorkers': unknown, + 'workers': workers, + **{ + key: report.get(key) + for key in ( + 'scope', + 'currentWorkerId', + 'clusterAvailable', + 'sampleIntervalSeconds', + 'sampleTtlSeconds', + 'invalidSnapshots', + 'staleSnapshots', + 'workerLimitReached', + ) + }, + } diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/connections.py b/ruoyi-fastapi-backend/plugins/core/runtime/connections.py new file mode 100644 index 0000000..a2ce90c --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/connections.py @@ -0,0 +1,390 @@ +import asyncio +import json +import math +from collections.abc import Awaitable, Callable +from contextlib import suppress +from dataclasses import dataclass +from typing import Any, Literal + +from starlette import status +from starlette.responses import JSONResponse +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from plugins.core.sdk.context import PluginRequestContext +from utils.log_util import logger + +RECHECK_INTERVAL_SECONDS = 15.0 +RECHECK_TIMEOUT_SECONDS = 5.0 +CANCEL_TIMEOUT_SECONDS = 5.0 +STREAM_CLOSED_EVENT = 'ruoyi.plugin.closed' +CONNECTION_OUTCOME_KEY = 'ruoyi.plugin.connection_outcome' +AUTH_RECHECK_KEY = 'ruoyi.plugin.auth_recheck' + + +@dataclass(frozen=True) +class ConnectionClosure: + """ + 可公开的连接关闭原因,不携带凭证或底层异常详情。 + + :param code: 对应的 HTTP 状态 + :param reason: 稳定的机器可读原因 + :param message: WebSocket 关闭提示 + :param outcome: 运行观测的终态分类 + :param websocket_code: WebSocket 关闭码 + """ + + code: int + reason: str + message: str + outcome: Literal['rejected', 'failed', 'cancelled'] + websocket_code: int + + +LOGIN_EXPIRED = ConnectionClosure(401, 'session_expired', '登录已失效', 'rejected', status.WS_1008_POLICY_VIOLATION) +ACCESS_REVOKED = ConnectionClosure(403, 'access_revoked', '访问权限已变更', 'rejected', status.WS_1008_POLICY_VIOLATION) +CHECK_UNAVAILABLE = ConnectionClosure( + 503, 'authorization_unavailable', '暂时无法复核访问权限', 'failed', status.WS_1011_INTERNAL_ERROR +) +CHECK_TIMED_OUT = ConnectionClosure( + 503, 'authorization_timeout', '访问权限复核超时', 'failed', status.WS_1011_INTERNAL_ERROR +) +PLUGIN_SHUTDOWN = ConnectionClosure(503, 'plugin_shutdown', '插件正在关闭', 'cancelled', status.WS_1012_SERVICE_RESTART) +CLIENT_DISCONNECTED = ConnectionClosure( + 499, 'client_disconnected', '客户端已断开', 'cancelled', status.WS_1001_GOING_AWAY +) + + +def _user_id(context: PluginRequestContext) -> Any: + """ + 读取宿主身份中的用户标识,兼容不含用户的隔离测试上下文。 + + :param context: 宿主签发的请求上下文 + :return: 用户标识;无身份时为 None + """ + return getattr(getattr(context.user, 'user', None), 'user_id', None) + + +class PluginConnectionManager: + """ + 复核当前 worker 的 SSE 与 WebSocket,关闭前先取消业务任务。 + """ + + def __init__( + self, + authorize: Callable[[Scope], Awaitable[PluginRequestContext]], + ready: Callable[[], bool], + *, + recheck_interval: float = RECHECK_INTERVAL_SECONDS, + recheck_timeout: float = RECHECK_TIMEOUT_SECONDS, + cancel_timeout: float = CANCEL_TIMEOUT_SECONDS, + ) -> None: + """ + 初始化连接管理器,普通 HTTP 响应不会启动复核任务。 + + :param authorize: 使用独立数据库会话重新鉴权的授权器 + :param ready: 当前插件是否仍然就绪 + :param recheck_interval: 相邻复核的等待秒数 + :param recheck_timeout: 单次复核的超时秒数 + :param cancel_timeout: 业务取消及关闭消息发送分别允许的等待秒数 + :return: None + """ + if any(not math.isfinite(value) or value <= 0 for value in (recheck_interval, recheck_timeout, cancel_timeout)): + raise ValueError('连接复核和关闭时间必须大于零') + self.authorize = authorize + self.ready = ready + self.recheck_interval = recheck_interval + self.recheck_timeout = recheck_timeout + self.cancel_timeout = cancel_timeout + self.closing = False + self.connections: set[_PluginConnection] = set() + self._unfinished: set[asyncio.Task[Any]] = set() + + def connection_counts(self) -> dict[str, int]: + """ + 读取当前已打开的协议连接数量,不公开作用域、身份或请求头。 + + :return: SSE 与 WebSocket 的当前连接数量 + """ + return { + 'sse': sum(connection.scope['type'] == 'http' for connection in self.connections), + 'websocket': sum(connection.scope['type'] == 'websocket' for connection in self.connections), + } + + def pending_tasks(self) -> frozenset[asyncio.Task[Any]]: + """ + 为宿主诊断提供尚未结束的取消超时任务快照,不改变任务或清理集合。 + + :return: 当前仍被连接回收器保留的任务集合,只供宿主统计使用 + """ + return frozenset(task for task in self._unfinished if not task.done()) + + async def run( + self, + app: ASGIApp, + scope: Scope, + child_scope: Scope, + context: PluginRequestContext, + receive: Receive, + send: Send, + ) -> None: + """ + 执行业务应用,在开始事件流或接受 WebSocket 后登记连接。 + + :param app: 插件 ASGI 应用 + :param scope: 宿主请求作用域,接收可观测的关闭原因 + :param child_scope: 注入请求上下文的子应用作用域 + :param context: 首次鉴权所得的权限快照 + :param receive: 原始 ASGI 消息接收函数 + :param send: 原始 ASGI 消息发送函数 + :return: None + """ + connection = _PluginConnection(self, scope, context, receive, send) + await connection.run(app, child_scope) + + async def drain(self) -> None: + """ + 拒绝新连接并并发关闭当前流,在有限时间内等待请求退出。 + + :return: None + """ + self.closing = True + connections = tuple(self.connections) + for connection in connections: + connection.stop(PLUGIN_SHUTDOWN) + owners = {connection.owner for connection in connections if connection.owner is not asyncio.current_task()} + if owners: + _, pending = await asyncio.wait(owners, timeout=self.cancel_timeout * 2 + 1) + if pending: + logger.warning('插件连接关闭等待超时:{} 个请求尚未退出', len(pending)) + + async def cancel_task(self, task: asyncio.Task[Any]) -> None: + """ + 有界等待协作式取消,保留未退出任务并回收它们最终的异常。 + + :param task: 需要停止的异步任务 + :return: None + """ + if not task.done(): + task.cancel() + await asyncio.wait({task}, timeout=self.cancel_timeout) + if task.done(): + with suppress(asyncio.CancelledError, Exception): + task.result() + else: + self._unfinished.add(task) + task.add_done_callback(self._finish_task) + logger.warning('插件连接任务未响应取消;输出已隔离,等待任务自行退出') + + def _finish_task(self, task: asyncio.Task[Any]) -> None: + """ + 释放迟到任务并读取异常,避免未处理任务异常告警。 + + :param task: 已完成的迟到任务 + :return: None + """ + self._unfinished.discard(task) + with suppress(asyncio.CancelledError, Exception): + task.result() + + +class _PluginConnection: + """ + 单条请求的收发状态及权限复核任务。 + """ + + def __init__( + self, + manager: PluginConnectionManager, + scope: Scope, + context: PluginRequestContext, + receive: Receive, + send: Send, + ) -> None: + """ + 保留首次授权的请求信息,避免子应用修改路由作用域影响复核。 + + :param manager: 当前插件的连接管理器 + :param scope: 宿主请求作用域 + :param context: 初始权限快照 + :param receive: 原始接收函数 + :param send: 原始发送函数 + :return: None + """ + self.manager = manager + self.scope = scope + self.auth_scope = {**scope, 'headers': list(scope.get('headers', [])), AUTH_RECHECK_KEY: True} + self.context = context + self.receive = receive + self.send = send + self.owner = asyncio.current_task() + self.closed: asyncio.Future[ConnectionClosure] = asyncio.get_running_loop().create_future() + self.checker: asyncio.Task[None] | None = None + self.started = False + self.completed = False + self.disconnected = False + self.line_ended = True + + def stop(self, reason: ConnectionClosure) -> None: + """ + 记录首次关闭原因并阻止后续业务消息进入连接。 + + :param reason: 已脱敏的关闭原因 + :return: None + """ + if not self.closed.done() and not self.completed: + self.closed.set_result(reason) + + def _open(self) -> None: + """ + 仅为事件流及已接受的 WebSocket 启动周期复核。 + + :return: None + """ + if self.manager.closing: + self.stop(PLUGIN_SHUTDOWN) + raise asyncio.CancelledError + if self.checker is not None: + return + self.manager.connections.add(self) + self.checker = asyncio.create_task(self._check()) + + async def _check(self) -> None: + """ + 定期重新鉴权;权限减配、用户变化或无法确认身份时关闭连接。 + + :return: None + """ + try: + while True: + await asyncio.sleep(self.manager.recheck_interval) + if not self.manager.ready(): + self.stop(CHECK_UNAVAILABLE) + return + current = await asyncio.wait_for( + self.manager.authorize(self.auth_scope), timeout=self.manager.recheck_timeout + ) + if _user_id(current) != _user_id(self.context) or ( + '*:*:*' not in current.permissions and not self.context.permissions.issubset(current.permissions) + ): + self.stop(ACCESS_REVOKED) + return + except PermissionError: + self.stop(ACCESS_REVOKED) + except LookupError: + self.stop(LOGIN_EXPIRED) + except (TimeoutError, asyncio.TimeoutError): + self.stop(CHECK_TIMED_OUT) + except Exception: + self.stop(CHECK_UNAVAILABLE) + + async def _receive(self) -> Message: + """ + 转发客户端消息,在断开后取消剩余业务处理。 + + :return: 原始 ASGI 消息 + """ + message = await self.receive() + if message['type'] in {'http.disconnect', 'websocket.disconnect'}: + self.disconnected = True + if self.started: + self.stop(CLIENT_DISCONNECTED) + return message + + async def _send(self, message: Message) -> None: + """ + 识别长连接并隔离已撤销连接的迟到输出。 + + :param message: 插件发送的 ASGI 消息 + :return: None + """ + if self.closed.done(): + raise asyncio.CancelledError + kind = message['type'] + if kind == 'websocket.accept': + self._open() + self.started = True + elif kind == 'http.response.start': + headers = {key.lower(): value for key, value in message.get('headers', [])} + if headers.get(b'content-type', b'').split(b';')[0].strip().lower() == b'text/event-stream': + self._open() + self.started = True + if self.started and kind == 'http.response.body' and message.get('body'): + self.line_ended = message['body'][-1:] in {b'\r', b'\n'} + await self.send(message) + if kind == 'websocket.close' or (kind == 'http.response.body' and not message.get('more_body', False)): + self.completed = True + self.manager.connections.discard(self) + if self.checker is not None: + self.checker.cancel() + + async def run(self, app: ASGIApp, child_scope: Scope) -> None: + """ + 协调业务完成、身份撤销及外部取消,并清理复核任务。 + + :param app: 插件 ASGI 应用 + :param child_scope: 已注入宿主上下文的请求作用域 + :return: None + """ + task = asyncio.create_task(self._invoke(app, child_scope)) + try: + await asyncio.wait({task, self.closed}, return_when=asyncio.FIRST_COMPLETED) + if self.closed.done() and not self.completed: + reason = self.closed.result() + self.scope[CONNECTION_OUTCOME_KEY] = reason + await self.manager.cancel_task(task) + if not self.disconnected: + await self._close_response(reason) + else: + await task + except asyncio.CancelledError: + # 服务器取消也必须隔离输出,业务即使吞掉 CancelledError 仍不能迟到写入。 + self.disconnected = True + if not self.closed.done(): + self.closed.set_result(CLIENT_DISCONNECTED) + raise + finally: + if self.checker is not None: + await self.manager.cancel_task(self.checker) + if not task.done() and task not in self.manager._unfinished: + await self.manager.cancel_task(task) + self.manager.connections.discard(self) + + async def _invoke(self, app: ASGIApp, child_scope: Scope) -> None: + """ + 在独立任务中等待 ASGI callable 返回的任意可等待对象。 + + :param app: 插件 ASGI 应用 + :param child_scope: 已注入宿主上下文的请求作用域 + :return: None + """ + await app(child_scope, self._receive, self._send) + + async def _close_response(self, reason: ConnectionClosure) -> None: + """ + 发送协议内的终止消息;SSE 控制事件由宿主适配器转换为失败。 + + :param reason: 首次记录的关闭原因 + :return: None + """ + if self.scope['type'] == 'websocket': + message = {'type': 'websocket.close', 'code': reason.websocket_code, 'reason': reason.message} + elif self.started: + data = json.dumps({'code': reason.code, 'reason': reason.reason}, separators=(',', ':')) + # 覆盖未完成事件的类型,不额外补空行将半条业务数据提前分发。 + prefix = '' if self.line_ended else '\n' + body = f'{prefix}event: {STREAM_CLOSED_EVENT}\ndata: {data}\n\n'.encode() + message = {'type': 'http.response.body', 'body': body, 'more_body': False} + else: + # 关闭与响应开始同时发生时尚未发送头部,正常返回 503。 + await JSONResponse({'code': reason.code, 'msg': reason.message}, status_code=reason.code)( + self.scope, self.receive, self.send + ) + return + sender = asyncio.create_task(self.send(message)) + try: + done, _ = await asyncio.wait({sender}, timeout=self.manager.cancel_timeout) + if sender in done: + with suppress(OSError): + sender.result() + finally: + await self.manager.cancel_task(sender) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/diagnostics.py b/ruoyi-fastapi-backend/plugins/core/runtime/diagnostics.py new file mode 100644 index 0000000..06a517d --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/diagnostics.py @@ -0,0 +1,251 @@ +import time +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, ValidationInfo, field_validator, model_validator +from pydantic.alias_generators import to_camel + +from plugins.core.runtime.task_cleanup import pending_plugin_tasks + +if TYPE_CHECKING: + from plugins.core.runtime.explicit import ExplicitPluginRuntime, LoadedExplicitPlugin + from plugins.core.runtime.health import PluginHealthResult + from plugins.core.runtime.metrics import PluginMetricSeries + +MAX_DIAGNOSTIC_PLUGINS = 1024 + + +class _DiagnosticModel(BaseModel): + """只接收有界的宿主观测字段,禁止混入插件业务对象或额外字段。""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, extra='forbid', strict=True) + + @model_validator(mode='before') + @classmethod + def require_complete_wire_fields(cls, value: Any, info: ValidationInfo) -> Any: + """ + 读取远端快照时要求每个已声明字段显式存在,不把缺失观测补成默认零值。 + + :param value: 当前层级待校验的输入,远端协议只接受 JSON 对象 + :param info: 校验上下文,仅 require_complete_snapshot 开启时要求完整协议字段 + :return: 字段完整的原始输入;本地构造仍保留模型默认值 + """ + if not isinstance(info.context, Mapping) or not info.context.get('require_complete_snapshot'): + return value + if not isinstance(value, Mapping): + raise ValueError('远端运行诊断必须使用完整 JSON 对象') + missing = [ + field.alias or name for name, field in cls.model_fields.items() if (field.alias or name) not in value + ] + if missing: + raise ValueError(f'远端运行诊断缺少协议字段:{", ".join(missing)}') + return value + + +class PluginActivationHealth(_DiagnosticModel): + """最近一次激活健康检查摘要,不表示持续健康且不保存插件返回的描述或详情。""" + + status: Literal['unknown', 'healthy', 'unhealthy', 'error', 'timeout'] = 'unknown' + checked_at: float | None = Field(default=None, ge=0, allow_inf_nan=False) + duration_ms: float | None = Field(default=None, ge=0, allow_inf_nan=False) + + +class PluginLifespanDiagnostic(_DiagnosticModel): + """宿主管理的生命周期状态;正常存活的任务不等于等待取消的任务。""" + + managed: bool + started: bool + ready: bool + task_active: bool + + +class PluginConnectionDiagnostic(_DiagnosticModel): + """已经接受的 WebSocket 和已开始响应的 SSE 连接数量。""" + + sse: NonNegativeInt = 0 + websocket: NonNegativeInt = 0 + + +class PluginConfigDiagnostic(_DiagnosticModel): + """不透明配置版本;按需读取完成不代表插件业务已经应用新配置。""" + + startup_revision: str | None = Field(default=None, pattern=r'^[0-9a-f]{64}$') + last_read_revision: str | None = Field(default=None, pattern=r'^[0-9a-f]{64}$') + last_read_at: float | None = Field(default=None, ge=0, allow_inf_nan=False) + + +class PluginDiagnosticError(_DiagnosticModel): + """宿主指标中最近错误的关联字段,不包含异常消息或请求内容。""" + + error_type: str = Field(min_length=1, max_length=80) + request_id: str | None = Field(default=None, max_length=128) + at: float = Field(ge=0, allow_inf_nan=False) + + +class PluginRuntimeDiagnostic(_DiagnosticModel): + """一个 worker 已加载的显式插件状态;身份始终来自实际加载对象。""" + + plugin_id: str = Field(pattern=r'^[a-z][a-z0-9_]{1,63}$') + version: str = Field(max_length=128) + digest: str | None = Field(default=None, pattern=r'^[0-9a-f]{64}$') + generation: str | None = Field(default=None, pattern=r'^[0-9a-f]{32}$') + active: bool + ready: bool + closing: bool + lifespan: PluginLifespanDiagnostic | None = None + connections: PluginConnectionDiagnostic = Field(default_factory=PluginConnectionDiagnostic) + active_jobs: NonNegativeInt = 0 + pending_connection_tasks: NonNegativeInt = 0 + pending_job_tasks: NonNegativeInt = 0 + activation_health: PluginActivationHealth = Field(default_factory=PluginActivationHealth) + config: PluginConfigDiagnostic = Field(default_factory=PluginConfigDiagnostic) + last_error: PluginDiagnosticError | None = None + + +class PluginRuntimeDiagnosticSnapshot(_DiagnosticModel): + """ + 当前进程的只读诊断快照,与已有运行指标协议独立。 + + worker_pending_cleanup_tasks 是宿主通用回收器与全部插件连接回收器的去重总数, + 包含各插件的待回收子项,不能与它们再次相加。未受宿主跟踪的业务任务不在此范围内。 + """ + + schema_version: Literal[1] = 1 + worker_id: str = Field(pattern=r'^[0-9a-f]{32}$') + collected_at: float = Field(ge=0, allow_inf_nan=False) + plugins: list[PluginRuntimeDiagnostic] = Field(default_factory=list, max_length=MAX_DIAGNOSTIC_PLUGINS) + worker_pending_cleanup_tasks: NonNegativeInt = 0 + dropped_plugins: NonNegativeInt = 0 + + @field_validator('schema_version', mode='before') + @classmethod + def validate_schema_version(cls, value: Any) -> int: + """ + 要求显式协议版本为整数 1,拒绝布尔值和数值相等的浮点值。 + + :param value: 显式提供的协议版本 + :return: 通过严格类型与版本检查的整数 + """ + if type(value) is not int or value != 1: + raise ValueError('schemaVersion 必须为整数 1') + return value + + @model_validator(mode='after') + def validate_unique_plugins(self) -> 'PluginRuntimeDiagnosticSnapshot': + """ + 拒绝单个 worker 在同一快照中重复报告同一插件。 + + :return: 插件身份唯一的诊断快照 + """ + if len({plugin.plugin_id for plugin in self.plugins}) != len(self.plugins): + raise ValueError('插件运行诊断快照包含重复插件 ID') + return self + + +def activation_health_observation(result: 'PluginHealthResult') -> PluginActivationHealth: + """ + 将激活检查结果转换为固定状态集合,丢弃插件提供的任意文本及详情。 + + :param result: 激活期间由宿主完成的健康检查结果 + :return: 带完成时间和耗时的安全摘要 + """ + status = 'healthy' if result.ok else result.status if result.status in {'timeout', 'error'} else 'unhealthy' + return PluginActivationHealth(status=status, checked_at=time.time(), duration_ms=result.duration_ms) + + +def _recent_errors(runtime: 'ExplicitPluginRuntime') -> dict[str, PluginDiagnosticError]: + """ + 从已有指标中选取每个插件最近的错误,不读取异常正文或业务内容。 + + :param runtime: 当前 worker 的显式插件运行时 + :return: 按插件 ID 索引的错误关联摘要 + """ + latest: dict[str, PluginMetricSeries] = {} + for series in runtime.metrics.series.values(): + if series.last_error_at is None or not series.last_error_type: + continue + previous = latest.get(series.plugin_id) + if previous is None or series.last_error_at > previous.last_error_at: + latest[series.plugin_id] = series + return { + plugin_id: PluginDiagnosticError( + error_type=series.last_error_type, + request_id=series.last_error_request_id, + at=series.last_error_at, + ) + for plugin_id, series in latest.items() + } + + +def _plugin_observation( + runtime: 'ExplicitPluginRuntime', loaded: 'LoadedExplicitPlugin', last_error: PluginDiagnosticError | None +) -> PluginRuntimeDiagnostic: + """ + 复制单个已加载插件的宿主状态,不执行插件代码或外部查询。 + + :param runtime: 当前 worker 的显式插件运行时 + :param loaded: 已加载的插件资源记录 + :param last_error: 已从宿主指标选取的最近错误 + :return: 不含任务对象、配置明文或请求数据的插件诊断 + """ + plugin = loaded.plugin.discovered_plugin + manager = loaded.gateway.connections if loaded.gateway is not None else None + binding = loaded.jobs + job_tasks = frozenset(task for task in binding.tasks if not task.done()) if binding is not None else frozenset() + closing = runtime._closing or bool(manager and manager.closing) or bool(binding and binding.closing) + lifespan = loaded.lifespan + observation = runtime.metrics.configurations.get(loaded.plugin.plugin_id) + return PluginRuntimeDiagnostic( + plugin_id=loaded.plugin.plugin_id, + version=plugin.manifest.version, + digest=plugin.artifact_digest, + generation=plugin.artifact_generation, + active=loaded.active, + ready=loaded.active and not closing and (lifespan is None or lifespan.ready), + closing=closing, + lifespan=( + PluginLifespanDiagnostic( + managed=lifespan.managed, + started=lifespan.started, + ready=lifespan.ready, + task_active=lifespan.has_pending_task, + ) + if lifespan is not None + else None + ), + connections=PluginConnectionDiagnostic(**manager.connection_counts()) + if manager + else PluginConnectionDiagnostic(), + active_jobs=len(job_tasks), + pending_connection_tasks=len(manager.pending_tasks()) if manager else 0, + pending_job_tasks=len(pending_plugin_tasks(job_tasks)), + activation_health=loaded.activation_health.model_copy(deep=True), + config=PluginConfigDiagnostic( + startup_revision=observation.startup_revision if observation else loaded.host.config_revision, + last_read_revision=observation.last_read_revision if observation else None, + last_read_at=observation.last_read_at if observation else None, + ), + last_error=last_error, + ) + + +def collect_runtime_diagnostics(runtime: 'ExplicitPluginRuntime') -> PluginRuntimeDiagnosticSnapshot: + """ + 在当前事件循环采样宿主已跟踪的插件资源,读取期间不触发回调或改变取消状态。 + + :param runtime: 当前 worker 的显式插件运行时 + :return: 有界且独立于后续资源变化的诊断快照 + """ + pending = set(pending_plugin_tasks()) + for loaded in runtime.loaded.values(): + if loaded.gateway is not None: + pending.update(loaded.gateway.connections.pending_tasks()) + errors = _recent_errors(runtime) + selected = sorted(runtime.loaded.items())[:MAX_DIAGNOSTIC_PLUGINS] + return PluginRuntimeDiagnosticSnapshot( + worker_id=runtime.metrics.worker_id, + collected_at=time.time(), + plugins=[_plugin_observation(runtime, loaded, errors.get(plugin_id)) for plugin_id, loaded in selected], + worker_pending_cleanup_tasks=len(pending), + dropped_plugins=max(0, len(runtime.loaded) - len(selected)), + ) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/diagnostics_query.py b/ruoyi-fastapi-backend/plugins/core/runtime/diagnostics_query.py new file mode 100644 index 0000000..ac8f941 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/diagnostics_query.py @@ -0,0 +1,326 @@ +import asyncio +import time +from collections.abc import Mapping +from contextlib import suppress +from datetime import datetime +from pathlib import Path +from typing import Any + +from plugins.core.runtime.diagnostics_store import DIAGNOSTICS_TTL_SECONDS, MAX_CLOCK_SKEW_SECONDS + +DIAGNOSTICS_QUERY_TIMEOUT_SECONDS = 3 +RUNTIME_COUNTERS = ('activeJobs', 'pendingConnectionTasks', 'pendingJobTasks') +DEFAULT_BACKEND_ROOT = Path(__file__).resolve().parents[3] + + +def _heartbeat_fresh(report: Mapping[str, Any] | None, now: float, ttl: float) -> bool: + """ + 判断数据库报告是否仍在发布心跳窗口内,不修改发布收敛规则。 + + :param report: 包含 ISO 心跳时刻的数据库报告,缺失时为 None + :param now: 本次诊断使用的 Unix 时刻 + :param ttl: 发布心跳有效秒数 + :return: 心跳是否携带时区且处于有效窗口内 + """ + if report is None: + return False + try: + heartbeat = datetime.fromisoformat(report['heartbeatTime']) + return heartbeat.tzinfo is not None and 0 <= now - heartbeat.timestamp() <= ttl + except (KeyError, TypeError, ValueError): + return False + + +def _release_report(report: Mapping[str, Any] | None, now: float, ttl: float) -> dict[str, Any] | None: + """ + 只公开发布报告的固定状态字段,避免错误正文或数据库路径进入运行诊断。 + + :param report: 原始宿主或插件发布报告,缺失时为 None + :param now: 本次诊断使用的 Unix 时刻 + :param ttl: 发布心跳有效秒数 + :return: 经过字段白名单筛选且带时效的报告,缺失时为 None + """ + if report is None: + return None + return { + **{ + key: report.get(key) + for key in ( + 'workerId', + 'pluginId', + 'digest', + 'version', + 'generation', + 'state', + 'heartbeatTime', + ) + }, + 'fresh': _heartbeat_fresh(report, now, ttl), + 'failureReason': 'worker_failed' if report.get('state') == 'failed' else None, + } + + +def _release_summary(summary: Mapping[str, Any] | None) -> dict[str, Any] | None: + """ + 保留发布目标与计数,维护错误只暴露固定分类。 + + :param summary: 现有发布服务返回的插件摘要,缺失时为 None + :return: 不包含错误正文的发布摘要 + """ + if summary is None: + return None + return { + **{ + key: summary[key] + for key in ( + 'pluginId', + 'targetDigest', + 'generation', + 'status', + 'restartRequired', + 'expectedWorkers', + 'liveWorkers', + 'healthyWorkers', + 'disabledWorkers', + 'mismatchWorkers', + 'staleWorkers', + 'missingWorkers', + 'failedWorkers', + 'prepareStatus', + 'installedVersion', + 'enabled', + 'preparedDigest', + 'preparedVersion', + 'previousDigest', + ) + if key in summary + }, + 'failureReason': 'preparation_failed' if summary.get('prepareStatus') == 'failed' else None, + } + + +def build_runtime_diagnostics( + plugin_id: str, + report: Mapping[str, Any], + release: Mapping[str, Any], + *, + now: float | None = None, +) -> dict[str, Any]: + """ + 关联运行快照与发布报告;实际加载身份及计数仅来自汇总时仍有效的运行快照。 + + :param plugin_id: 当前诊断插件 ID + :param report: 经过报告器身份与 TTL 校验的运行观测结果 + :param release: 包含可用性及心跳 TTL 的发布查询结果 + :param now: 可选的 Unix 汇总时刻,未指定时使用当前时刻 + :return: 明确区分实际观测、发布目标和缺失数据的运行诊断负载 + """ + now = time.time() if now is None else now + reported_snapshots = {worker['workerId']: worker for worker in report.get('workers', [])} + sample_ttl = report.get('sampleTtlSeconds', DIAGNOSTICS_TTL_SECONDS) + snapshots = { + worker_id: snapshot + for worker_id, snapshot in reported_snapshots.items() + if -MAX_CLOCK_SKEW_SECONDS <= now - snapshot['collectedAt'] <= sample_ttl + } + reports = release.get('workers', []) + hosts = {row['workerId']: row for row in reports if row['pluginId'] == '__runtime__'} + plugins = {row['workerId']: row for row in reports if row['pluginId'] == plugin_id} + summary = next((row for row in release.get('releases', []) if row['pluginId'] == plugin_id), None) + ttl = float(release.get('workerTtlSeconds', 60)) + stale_ids = set(report.get('staleWorkerIds', [])) + expired_ids = reported_snapshots.keys() - snapshots.keys() + stale_count = max(report.get('staleSnapshots', 0), len(stale_ids)) + len(expired_ids - stale_ids) + stale_ids.update(expired_ids) + invalid_ids = set(report.get('invalidWorkerIds', [])) + live_hosts = { + worker_id + for worker_id, host in hosts.items() + if host.get('state') != 'stopped' and _heartbeat_fresh(host, now, ttl) + } + workers = [] + for worker_id in sorted(set(snapshots) | set(hosts) | set(plugins) | stale_ids | invalid_ids): + snapshot = snapshots.get(worker_id) + reported_snapshot = reported_snapshots.get(worker_id) + actual = ( + next((plugin for plugin in snapshot.get('plugins', []) if plugin['pluginId'] == plugin_id), None) + if snapshot + else None + ) + freshness = ( + 'fresh' + if snapshot + else 'stale' + if worker_id in stale_ids + else 'invalid' + if worker_id in invalid_ids + else 'missing' + ) + host = hosts.get(worker_id) + worker_report = plugins.get(worker_id) + workers.append( + { + 'workerId': worker_id, + 'source': reported_snapshot.get('source') if reported_snapshot else None, + 'freshness': freshness, + 'observation': ( + 'observed' + if actual + else 'unknown' + if snapshot and snapshot.get('droppedPlugins') + else 'not_loaded' + if snapshot + else freshness + ), + 'collectedAt': reported_snapshot.get('collectedAt') if reported_snapshot else None, + 'ageSeconds': round(max(0, now - reported_snapshot['collectedAt']), 3) if reported_snapshot else None, + 'actual': actual, + 'workerPendingCleanupTasks': snapshot.get('workerPendingCleanupTasks') if snapshot else None, + 'droppedPlugins': snapshot.get('droppedPlugins') if snapshot else None, + 'host': _release_report(host, now, ttl), + 'releaseReport': _release_report(worker_report, now, ttl), + } + ) + observed = [worker for worker in workers if worker['actual'] is not None] + totals = None + if observed: + totals = { + **{key: sum(worker['actual'][key] for worker in observed) for key in RUNTIME_COUNTERS}, + 'sse': sum(worker['actual']['connections']['sse'] for worker in observed), + 'websocket': sum(worker['actual']['connections']['websocket'] for worker in observed), + } + expected = summary.get('expectedWorkers') if summary else None + missing = len(live_hosts - set(snapshots)) + if expected is not None: + # 历史宿主的缓存观测不能填补当前发布的宿主容量缺口。 + missing += max(0, expected - len(live_hosts)) + limited = bool(report.get('workerLimitReached')) or any( + worker.get('droppedPlugins') for worker in snapshots.values() + ) + incomplete = bool( + missing + or limited + or report.get('invalidSnapshots') + or report.get('scope') == 'current_worker' + or report.get('localSnapshotAvailable') is False + ) + state = 'partial' if snapshots and incomplete else 'observed' if snapshots else 'unobserved' + if report.get('scope') == 'unavailable': + state = 'unavailable' + return { + 'pluginId': plugin_id, + 'supported': report.get('supported', True), + 'state': state, + 'generatedAt': now, + **{ + key: report.get(key) + for key in ( + 'scope', + 'clusterAvailable', + 'currentWorkerId', + 'sampleIntervalSeconds', + 'sampleTtlSeconds', + 'invalidSnapshots', + 'workerLimitReached', + 'localSnapshotAvailable', + ) + }, + 'staleSnapshots': stale_count, + 'observedWorkers': len(observed), + 'freshWorkers': len(snapshots), + 'expectedWorkers': expected, + 'missingWorkers': missing if release.get('available') and (expected is not None or live_hosts) else None, + 'observedTotals': totals, + 'totalsScope': 'fresh_observed_workers', + 'release': { + 'supported': release.get('supported', False), + 'available': release.get('available', False), + 'summary': _release_summary(summary), + 'workerTtlSeconds': release.get('workerTtlSeconds'), + }, + 'workers': workers, + } + + +async def _read_release_status(plugin_id: str, backend_root: Path | None) -> dict[str, Any]: + """ + 按需读取现有发布表;制品功能关闭时不查询相关表。 + + :param plugin_id: 当前诊断插件 ID + :param backend_root: 用于解析发布配置的后端目录,未指定时使用当前项目 + :return: 带功能支持及查询可用性标记的发布状态 + """ + from plugins.core.deployment.config import PluginDeploymentConfig # noqa: PLC0415 + from plugins.core.deployment.service import PluginDeploymentService # noqa: PLC0415 + + try: + config = PluginDeploymentConfig.from_settings(backend_root or DEFAULT_BACKEND_ROOT) + if not config.enabled: + return {'supported': False, 'available': False} + payload = await asyncio.wait_for( + PluginDeploymentService(config).status(plugin_id), DIAGNOSTICS_QUERY_TIMEOUT_SECONDS + ) + return {**payload, 'supported': True, 'available': True, 'workerTtlSeconds': config.worker_ttl_seconds} + except Exception: + return {'supported': True, 'available': False} + + +async def read_plugin_runtime_diagnostics( + plugin_id: str, + *, + reporter: Any = None, + redis: Any = None, + ready_key: str | None = None, + backend_root: Path | None = None, +) -> dict[str, Any]: + """ + 为 API 与 CLI 读取相同运行诊断,观测失败独立于静态诊断结果。 + + :param plugin_id: 当前诊断插件 ID + :param reporter: 当前服务 worker 的报告器;CLI 不传入本地报告器 + :param redis: 无报告器时用于只读查询的 Redis 客户端 + :param ready_key: 可选的宿主启动命名空间前缀 + :param backend_root: 用于解析发布配置的后端目录 + :return: 发布状态与有效运行快照的只读聚合结果 + """ + from plugins.core.runtime.diagnostics_store import ( # noqa: PLC0415 + diagnostics_namespace, + read_runtime_diagnostics, + ) + + try: + if reporter is not None: + operation = reporter.read(plugin_id) + else: + namespace = diagnostics_namespace(ready_key) if ready_key is not None else diagnostics_namespace() + operation = read_runtime_diagnostics(redis, namespace, plugin_id) + report = await asyncio.wait_for(operation, DIAGNOSTICS_QUERY_TIMEOUT_SECONDS) + except Exception: + report = {'scope': 'unavailable', 'clusterAvailable': False, 'workers': []} + release = await _read_release_status(plugin_id, backend_root) + return build_runtime_diagnostics(plugin_id, report, release) + + +async def read_cli_runtime_diagnostics(plugin_id: str, *, backend_root: Path | None = None) -> dict[str, Any]: + """ + CLI 仅查询服务 worker 的快照,不登记或发布诊断进程的本地计数。 + + :param plugin_id: 当前诊断插件 ID + :param backend_root: 用于解析发布配置的后端目录 + :return: 关闭本次 Redis 连接后返回的运行诊断负载 + """ + from config.get_redis import RedisUtil # noqa: PLC0415 + + redis = None + try: + redis = await asyncio.wait_for( + RedisUtil.create_redis_pool(log_enabled=False), DIAGNOSTICS_QUERY_TIMEOUT_SECONDS + ) + except Exception: + pass + try: + return await read_plugin_runtime_diagnostics(plugin_id, redis=redis, backend_root=backend_root) + finally: + if redis is not None: + with suppress(Exception): + await asyncio.wait_for(redis.aclose(), DIAGNOSTICS_QUERY_TIMEOUT_SECONDS) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/diagnostics_store.py b/ruoyi-fastapi-backend/plugins/core/runtime/diagnostics_store.py new file mode 100644 index 0000000..2056005 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/diagnostics_store.py @@ -0,0 +1,287 @@ +import asyncio +import re +import time +from collections.abc import Callable +from contextlib import suppress +from typing import Any + +from redis.client import NEVER_DECODE + +from common.constant import LockConstant +from plugins.core.runtime.diagnostics import PluginRuntimeDiagnosticSnapshot +from utils.log_util import logger + +DIAGNOSTICS_PUBLISH_SECONDS = 15 +DIAGNOSTICS_TTL_SECONDS = 60 +DIAGNOSTICS_IO_TIMEOUT_SECONDS = 2 +MAX_DIAGNOSTICS_SNAPSHOT_BYTES = 512 * 1024 +MAX_DIAGNOSTICS_WORKERS = 64 +MAX_CLOCK_SKEW_SECONDS = 5 + + +def diagnostics_namespace(ready_key: str = LockConstant.PLUGIN_STARTUP_READY_KEY) -> str: + """ + 从宿主启动命名空间派生独立的诊断键空间,不改变现有指标协议。 + + :param ready_key: 当前部署的宿主启动协调键 + :return: 带诊断协议版本的 Redis 命名空间 + """ + return f'{ready_key}:diagnostics:v1' + + +async def _read_snapshots(redis: Any, namespace: str) -> dict[str, Any]: + """ + 有界读取有效诊断快照,仅保留无敏感数据的损坏或过期 worker 标识。 + + :param redis: 宿主或只读查询进程提供的 Redis 客户端 + :param namespace: 当前部署的诊断命名空间 + :return: 快照、无效及过期记录数量和查询是否达到上限 + """ + keys: list[str] = [] + invalid = 0 + limited = False + scanned = 0 + async for raw_key in redis.scan_iter(match=f'{namespace}:*', count=100, **{NEVER_DECODE: True}): + if scanned >= MAX_DIAGNOSTICS_WORKERS: + limited = True + break + scanned += 1 + try: + key = raw_key.decode('utf-8') if isinstance(raw_key, bytes) else raw_key + except UnicodeError: + invalid += 1 + continue + if not isinstance(key, str) or not re.fullmatch(rf'{re.escape(namespace)}:[0-9a-f]{{32}}', key): + invalid += 1 + continue + if key not in keys: + keys.append(key) + # 共享客户端通常自动解码;逐命令保留字节,避免一个坏 UTF-8 值使整批观测失效。 + values = await redis.execute_command('MGET', *keys, **{NEVER_DECODE: True}) if keys else [] + snapshots = [] + invalid_worker_ids = [] + stale_worker_ids = [] + now = time.time() + for key, raw in zip(keys, values, strict=True): + worker_id = key.rsplit(':', 1)[1] + if raw is None: + # 扫描之后过期的键同样没有可用观测,不能补成零计数。 + stale_worker_ids.append(worker_id) + continue + value = raw.encode('utf-8') if isinstance(raw, str) else raw + if not isinstance(value, bytes) or len(value) > MAX_DIAGNOSTICS_SNAPSHOT_BYTES: + invalid += 1 + invalid_worker_ids.append(worker_id) + continue + try: + snapshot = PluginRuntimeDiagnosticSnapshot.model_validate_json( + value, context={'require_complete_snapshot': True} + ) + except ValueError: + invalid += 1 + invalid_worker_ids.append(worker_id) + continue + if snapshot.worker_id != worker_id: + invalid += 1 + invalid_worker_ids.append(worker_id) + continue + age = now - snapshot.collected_at + if age > DIAGNOSTICS_TTL_SECONDS or age < -MAX_CLOCK_SKEW_SECONDS: + stale_worker_ids.append(worker_id) + continue + snapshots.append(snapshot) + return { + 'snapshots': snapshots, + 'invalidSnapshots': invalid, + 'staleSnapshots': len(stale_worker_ids), + 'staleWorkerIds': sorted(stale_worker_ids), + 'invalidWorkerIds': sorted(invalid_worker_ids), + 'workerLimitReached': limited, + } + + +async def read_runtime_diagnostics(redis: Any, namespace: str, plugin_id: str | None = None) -> dict[str, Any]: + """ + 只读取发布中的 worker 快照,不为 CLI 或管理进程创建虚假的本地采样。 + + :param redis: Redis 客户端;不可用时为 None + :param namespace: 当前部署的诊断命名空间 + :param plugin_id: 可选的插件过滤条件 + :return: 采样范围、时效、缺失原因和当前有效 worker 明细 + """ + result: dict[str, Any] = { + 'ok': True, + 'supported': True, + 'scope': 'unavailable', + 'clusterAvailable': False, + 'currentWorkerId': None, + 'observedWorkers': 0, + 'sampleIntervalSeconds': DIAGNOSTICS_PUBLISH_SECONDS, + 'sampleTtlSeconds': DIAGNOSTICS_TTL_SECONDS, + 'invalidSnapshots': 0, + 'staleSnapshots': 0, + 'staleWorkerIds': [], + 'invalidWorkerIds': [], + 'workerLimitReached': False, + 'workers': [], + } + if redis is None: + return result + try: + cluster = await asyncio.wait_for(_read_snapshots(redis, namespace), DIAGNOSTICS_IO_TIMEOUT_SECONDS) + except Exception: + # 连接错误可能包含凭据或地址;诊断响应仅说明无法观测。 + return result + snapshots = cluster.pop('snapshots') + result.update(cluster, scope='reporting_workers', clusterAvailable=True) + result['workers'] = [_worker_payload(snapshot, plugin_id, source='redis') for snapshot in snapshots] + result['workers'].sort(key=lambda worker: worker['workerId']) + result['observedWorkers'] = len(result['workers']) + return result + + +def _worker_payload(snapshot: PluginRuntimeDiagnosticSnapshot, plugin_id: str | None, *, source: str) -> dict[str, Any]: + """ + 复制指定插件的观测值,同时保留真实 worker 的空插件列表。 + + :param snapshot: 已通过身份与时效校验的宿主快照 + :param plugin_id: 可选的插件过滤条件 + :param source: 当前进程即时采样或 Redis 周期采样 + :return: 不含其他插件明细的独立 worker 负载 + """ + payload = snapshot.model_dump(by_alias=True) + payload.pop('schemaVersion') + payload['plugins'] = [item for item in payload['plugins'] if plugin_id is None or item['pluginId'] == plugin_id] + payload.update(source=source, freshness='fresh', ageSeconds=max(0.0, time.time() - snapshot.collected_at)) + return payload + + +class PluginDiagnosticsReporter: + """ + 发布独立版本的运行诊断快照,查询故障时仅返回当前进程的真实观测。 + """ + + def __init__(self, snapshot_provider: Callable[[], PluginRuntimeDiagnosticSnapshot], worker_id: str) -> None: + """ + 绑定宿主提供的纯内存采样函数,不执行插件回调。 + + :param snapshot_provider: 复制当前运行时状态的同步函数 + :param worker_id: 与发布心跳和指标一致的进程标识 + :return: None + """ + if re.fullmatch(r'[0-9a-f]{32}', worker_id) is None: + raise ValueError('诊断 worker 标识无效') + self.snapshot_provider = snapshot_provider + self.worker_id = worker_id + self.redis: Any = None + self.namespace = '' + self.task: asyncio.Task | None = None + self.publication_failed = False + self._stopping = False + + @property + def key(self) -> str: + """ + 返回当前 worker 独占的快照键。 + + :return: 当前 worker 的 Redis 键 + """ + return f'{self.namespace}:{self.worker_id}' + + def start(self, redis: Any, namespace: str) -> None: + """ + 启动有界周期采样,Redis 不可用时仍可读取当前进程。 + + :param redis: 当前宿主 Redis 客户端 + :param namespace: 当前部署的诊断键空间 + :return: None + """ + if self.task is not None: + return + self.redis = redis + self.namespace = namespace + self._stopping = False + if redis is not None: + self.task = asyncio.create_task(self._run(), name=f'plugin-diagnostics-{self.worker_id}') + + def _snapshot(self) -> PluginRuntimeDiagnosticSnapshot: + """ + 校验采样身份,防止误用其他 worker 的快照。 + + :return: 当前 worker 的独立采样 + """ + snapshot = self.snapshot_provider() + if snapshot.worker_id != self.worker_id: + raise ValueError('诊断快照的 worker 标识不匹配') + return snapshot + + async def publish(self) -> None: + """ + 发布有大小限制和 TTL 的运行快照,失败时保留本地查询能力。 + + :return: None + """ + if self.redis is None: + return + try: + snapshot = self._snapshot().model_dump_json(by_alias=True).encode('utf-8') + if len(snapshot) > MAX_DIAGNOSTICS_SNAPSHOT_BYTES: + raise ValueError('运行诊断快照超过大小限制') + await asyncio.wait_for( + self.redis.set(self.key, snapshot, ex=DIAGNOSTICS_TTL_SECONDS), DIAGNOSTICS_IO_TIMEOUT_SECONDS + ) + except Exception: + if not self.publication_failed: + logger.warning('插件运行诊断发布不可用,远程快照将按 TTL 过期') + self.publication_failed = True + else: + self.publication_failed = False + + async def _run(self) -> None: + """ + 在启动后尽快发布,随后定期复制纯内存状态。 + + :return: None + """ + while not self._stopping: + await self.publish() + if not self._stopping: + await asyncio.sleep(DIAGNOSTICS_PUBLISH_SECONDS) + + async def read(self, plugin_id: str | None = None) -> dict[str, Any]: + """ + 用当前即时采样替换本 worker 的 Redis 副本,避免重复计数。 + + :param plugin_id: 可选的插件过滤条件 + :return: 具有明确来源和观测范围的运行诊断数据 + """ + result = await read_runtime_diagnostics(self.redis, self.namespace, plugin_id) + result['currentWorkerId'] = self.worker_id + try: + local = _worker_payload(self._snapshot(), plugin_id, source='current_worker') + except Exception: + result['localSnapshotAvailable'] = False + return result + result['localSnapshotAvailable'] = True + result['workers'] = [worker for worker in result['workers'] if worker['workerId'] != self.worker_id] + result['workers'].append(local) + result['workers'].sort(key=lambda worker: worker['workerId']) + result['observedWorkers'] = len(result['workers']) + result['scope'] = 'reporting_workers' if result['clusterAvailable'] else 'current_worker' + return result + + async def stop(self) -> None: + """ + 停止周期发布并仅删除本 worker 的快照,删除失败时依赖 TTL。 + + :return: None + """ + self._stopping = True + if self.task is not None: + self.task.cancel() + with suppress(asyncio.CancelledError): + await self.task + self.task = None + if self.redis is not None and self.namespace: + with suppress(Exception): + await asyncio.wait_for(self.redis.delete(self.key), DIAGNOSTICS_IO_TIMEOUT_SECONDS) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/entrypoint.py b/ruoyi-fastapi-backend/plugins/core/runtime/entrypoint.py new file mode 100644 index 0000000..44098be --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/entrypoint.py @@ -0,0 +1,244 @@ +import base64 +import hashlib +import importlib.metadata +import importlib.util +import inspect +import platform +import sys +import sysconfig +from dataclasses import dataclass +from importlib.machinery import EXTENSION_SUFFIXES +from pathlib import Path +from types import ModuleType +from typing import Any + +from packaging.tags import parse_tag, sys_tags +from packaging.utils import canonicalize_name +from packaging.version import Version + +from plugins.core.discovery.scanner import DiscoveredPlugin +from plugins.core.sdk import PluginDefinition, PluginHostContext + + +@dataclass(frozen=True) +class PluginModuleLocation: + """ + 静态定位结果,不执行包初始化或原生模块代码。 + + :param module_name: 插件模块完整名称 + :param path: 模块文件的绝对路径 + :param is_package: 模块是否为 Python 包 + """ + + module_name: str + path: Path + is_package: bool = False + + +class PluginEntrypointLoader: + """ + 只加载当前插件目录下的模块,拒绝复用其他版本或其他路径的缓存。 + """ + + def __init__(self, plugin: DiscoveredPlugin) -> None: + """ + 初始化显式入口加载器并确认插件使用 v2 清单。 + + :param plugin: 待加载的已发现插件 + :return: None + """ + self.plugin = plugin + self.backend = plugin.manifest.backend + if not plugin.manifest.uses_entrypoint: + raise ValueError('显式入口加载器仅用于 manifestVersion 2') + + @property + def module_root(self) -> Path: + """ + 获取当前交付类型对应的模块根目录。 + + :return: Python 插件目录或原生发行包安装目录的绝对路径 + """ + if self.plugin.manifest.runtime_kind == 'native': + return self._contained(self.plugin.backend_path / self.backend.native.module_root) + return self.plugin.backend_path.resolve() + + def _contained(self, path: Path) -> Path: + """ + 解析真实路径并确认其位于当前插件目录内。 + + :param path: 待校验的模块或资源路径 + :return: 通过目录边界校验的绝对路径 + """ + resolved = path.resolve() + if not resolved.is_relative_to(self.plugin.backend_path.resolve()): + raise ValueError(f'模块或资源路径越过插件根目录:{path}') + return resolved + + def locate_module(self, module_name: str) -> PluginModuleLocation: + """ + 仅检查磁盘文件,不调用可能执行父包代码的 find_spec。 + + :param module_name: 当前插件命名空间内的完整模块名 + :return: 模块文件路径及包类型信息 + """ + prefix = self.backend.module + if module_name != prefix and not module_name.startswith(f'{prefix}.'): + raise ValueError(f'入口或回调必须位于当前插件模块:{module_name}') + parts = module_name.removeprefix(prefix).lstrip('.').split('.') if module_name != prefix else [] + root = self.module_root + if self.plugin.manifest.runtime_kind == 'native': + root = self._contained(root / prefix) + target = root.joinpath(*parts) + candidates: list[tuple[Path, bool]] = [] + if parts or self.plugin.manifest.runtime_kind == 'native': + candidates.extend((target.with_name(target.name + suffix), False) for suffix in EXTENSION_SUFFIXES) + candidates.append((target.with_suffix('.py'), False)) + candidates.append((target / '__init__.py', True)) + for path, is_package in candidates: + if path.is_file(): + return PluginModuleLocation(module_name, self._contained(path), is_package) + raise ImportError(f'插件模块不存在:{module_name}') + + def check_entrypoint(self) -> PluginModuleLocation: + """ + 执行静态来源和原生平台校验,不执行入口。 + + :return: 通过来源及平台校验的入口模块位置 + """ + module_name = self.backend.entrypoint.split(':', 1)[0] + location = self.locate_module(module_name) + if self.plugin.manifest.runtime_kind == 'native': + if not any(location.path.name.endswith(suffix) for suffix in EXTENSION_SUFFIXES): + raise ValueError('native entrypoint 必须位于原生 Python 扩展模块') + self._check_native_distribution(location) + return location + + def _check_native_distribution(self, location: PluginModuleLocation) -> None: + """ + 校验原生发行包版本、平台标签及入口文件摘要。 + + :param location: 已定位的原生入口模块 + :return: None + """ + if platform.python_implementation() != 'CPython' or sysconfig.get_config_var('Py_GIL_DISABLED'): + raise ValueError('当前 native 运行时仅支持带 GIL 的 CPython') + expected = canonicalize_name(self.backend.native.distribution) + matches = [ + dist + for dist in importlib.metadata.distributions(path=[str(self.module_root)]) + if canonicalize_name(dist.metadata.get('Name', '')) == expected + ] + if len(matches) != 1: + raise ValueError('native.moduleRoot 必须包含唯一匹配的已安装 wheel 元数据') + dist = matches[0] + if Version(dist.version) != Version(self.plugin.manifest.version): + raise ValueError('原生发行包版本必须与插件版本一致') + wheel_metadata = dist.read_text('WHEEL') or '' + tags = set() + for line in wheel_metadata.splitlines(): + if line.startswith('Tag: '): + tags.update(parse_tag(line.removeprefix('Tag: ').strip())) + if not any(tag.abi != 'none' and tag.platform != 'any' for tag in tags.intersection(sys_tags())): + raise ValueError('原生 wheel 平台、Python 或 ABI 与当前解释器不兼容') + entries = [entry for entry in dist.files or [] if Path(dist.locate_file(entry)).resolve() == location.path] + if len(entries) != 1 or entries[0].hash is None or entries[0].hash.mode != 'sha256': + raise ValueError('原生入口必须包含在发行包 RECORD 中并声明 SHA256') + digest = hashlib.sha256(location.path.read_bytes()).digest() + actual = base64.urlsafe_b64encode(digest).rstrip(b'=').decode('ascii') + if actual != entries[0].hash.value: + raise ValueError('原生入口 SHA256 与发行包 RECORD 不一致') + + def import_module(self, module_name: str) -> ModuleType: + """ + 按绑定路径依次加载包和模块,失败时移除本次新增的插件模块。 + + :param module_name: 当前插件命名空间内的完整模块名 + :return: 已从绑定路径加载的模块对象 + """ + self.locate_module(module_name) + before = set(sys.modules) + try: + prefix = self.backend.module + names = [prefix] + suffix = module_name.removeprefix(prefix).lstrip('.') + if suffix: + for part in suffix.split('.'): + names.append(f'{names[-1]}.{part}') + for name in names: + self._import_one(name) + return sys.modules[module_name] + except BaseException: + for name in set(sys.modules) - before: + if name == self.backend.module or name.startswith(f'{self.backend.module}.'): + failed_module = sys.modules.pop(name, None) + parent_name, _, child_name = name.rpartition('.') + parent = sys.modules.get(parent_name) + if parent is not None and getattr(parent, child_name, None) is failed_module: + delattr(parent, child_name) + raise + + def _import_one(self, module_name: str) -> ModuleType: + """ + 校验模块缓存来源,或从已定位的文件加载单个模块。 + + :param module_name: 当前插件命名空间内的完整模块名 + :return: 来源路径一致的模块对象 + """ + location = self.locate_module(module_name) + cached = sys.modules.get(module_name) + if cached is not None: + origin = getattr(cached, '__file__', None) + if not origin or Path(origin).resolve() != location.path: + raise ImportError(f'插件模块已从其他路径加载,必须重启进程:{module_name}') + return cached + spec = importlib.util.spec_from_file_location( + module_name, + location.path, + submodule_search_locations=[str(location.path.parent)] if location.is_package else None, + ) + if spec is None or spec.loader is None: + raise ImportError(f'无法创建插件模块加载器:{module_name}') + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + parent_name, _, child_name = module_name.rpartition('.') + parent = sys.modules.get(parent_name) + if parent is not None: + setattr(parent, child_name, module) + return module + + def load_callable(self, callable_path: str) -> Any: + """ + v2 回调使用固定参数协议,不依赖原生函数 inspect.signature。 + + :param callable_path: 采用模块路径与函数名冒号分隔格式的入口或回调路径 + :return: 通过可调用性校验的入口或回调对象 + """ + module_name, callable_name = callable_path.split(':', 1) + module = self.import_module(module_name) + callback = getattr(module, callable_name, None) + if not callable(callback): + raise TypeError(f'插件入口或回调不可调用:{callable_path}') + return callback + + def load(self, host: PluginHostContext) -> PluginDefinition: + """ + 加载入口并验证能力对象;工厂必须同步、快速且无资源副作用。 + + :param host: 与当前插件身份及资源目录绑定的宿主上下文 + :return: 通过协议校验的插件能力定义 + """ + self.check_entrypoint() + if host.plugin_id != self.plugin.manifest.id or host.resource_root != self.plugin.backend_path.resolve(): + raise ValueError('HostContext 与待加载插件不匹配') + factory = self.load_callable(self.backend.entrypoint) + definition = factory(host) + if inspect.isawaitable(definition): + if inspect.iscoroutine(definition): + definition.close() + raise TypeError('插件入口工厂必须同步返回 PluginDefinition,资源初始化请使用生命周期') + if not isinstance(definition, PluginDefinition): + raise TypeError('插件入口必须返回 PluginDefinition') + definition.validate(self.plugin.manifest.integration_kind) + return definition diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/explicit.py b/ruoyi-fastapi-backend/plugins/core/runtime/explicit.py new file mode 100644 index 0000000..04506e9 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/explicit.py @@ -0,0 +1,496 @@ +import inspect +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any + +from fastapi import Depends, FastAPI, HTTPException +from starlette.requests import Request +from starlette.routing import Mount +from starlette.types import Scope + +from config.database import Base, DataSourceRegistry +from exceptions.exception import AuthException +from module_admin.service.login_service import LoginService +from plugins.core.discovery.registry import PluginRegistry, RegisteredPlugin +from plugins.core.discovery.scanner import is_artifact_plugin +from plugins.core.runtime.asgi import PluginGatewayASGI, PluginLifespanManager +from plugins.core.runtime.browser_session import authenticate_browser_session +from plugins.core.runtime.bundle import PluginBundleASGI +from plugins.core.runtime.configuration import PluginConfigObservation, PluginConfigReader, config_revision +from plugins.core.runtime.connections import AUTH_RECHECK_KEY +from plugins.core.runtime.diagnostics import ( + PluginActivationHealth, + PluginRuntimeDiagnosticSnapshot, + activation_health_observation, + collect_runtime_diagnostics, +) +from plugins.core.runtime.entrypoint import PluginEntrypointLoader +from plugins.core.runtime.health import PluginHealthChecker +from plugins.core.runtime.host_services import build_host_services +from plugins.core.runtime.job_dispatcher import PluginJobBinding, bind_plugin_jobs, unbind_plugin_jobs +from plugins.core.runtime.metrics import PluginObservedASGI, PluginRuntimeMetrics +from plugins.core.runtime.metrics_store import PluginMetricsReporter +from plugins.core.runtime.route_guard import PluginEnabledDependency, PluginRouteStateGateway +from plugins.core.sdk import PluginDefinition, PluginHostContext, PluginRequestContext +from plugins.core.sdk.request import current_plugin_request_id +from plugins.core.validation.manifest import PluginManifestChecker +from plugins.core.validation.structure import PluginStructureChecker +from utils.log_util import logger + + +@dataclass +class LoadedExplicitPlugin: + """ + 一个 worker 的已加载定义及可选 lifespan。 + + :param plugin: 注册表中的插件记录 + :param host: 当前插件的宿主能力上下文 + :param definition: 入口返回的插件能力定义 + :param lifespan: ASGI 子应用生命周期管理器 + :param active: 插件是否已完成激活 + :param jobs: 当前 worker 中的插件任务绑定 + :param gateway: 当前 worker 的 ASGI 长连接管理入口 + :param activation_health: 最近一次激活健康检查的安全摘要,不表示持续健康 + """ + + plugin: RegisteredPlugin + host: PluginHostContext + definition: PluginDefinition + lifespan: PluginLifespanManager | None = None + active: bool = False + jobs: PluginJobBinding | None = None + gateway: PluginGatewayASGI | None = None + activation_health: PluginActivationHealth = field(default_factory=PluginActivationHealth) + + +class ExplicitPluginRuntime: + """ + 仅负责当前 worker 的代码和资源,全局迁移仍由原有 writer 处理。 + """ + + def __init__(self, route_state_gateway: PluginRouteStateGateway) -> None: + """ + 初始化当前 worker 的显式插件运行时。 + + :param route_state_gateway: 插件启用状态查询网关 + :return: None + """ + self.route_state_gateway = route_state_gateway + self.loaded: dict[str, LoadedExplicitPlugin] = {} + self.metrics = PluginRuntimeMetrics() + self.metrics_reporter = PluginMetricsReporter(self.metrics) + self._closing = False + + def diagnostic_snapshot(self) -> PluginRuntimeDiagnosticSnapshot: + """ + 读取当前 worker 已加载插件的身份、资源与最近激活检查,不触发插件回调。 + + :return: 与旧版 metrics 协议独立的有界诊断快照 + """ + return collect_runtime_diagnostics(self) + + @staticmethod + def ordered_plugins(registry: PluginRegistry) -> list[RegisteredPlugin]: + """ + 稳定的依赖拓扑排序,拒绝缺失依赖及循环。 + + :param registry: 包含待排序插件的注册表 + :return: 按依赖优先顺序排列的已启用插件 + """ + plugins = {item.plugin_id: item for item in registry.list_enabled_plugins()} + visiting: set[str] = set() + visited: set[str] = set() + result = [] + + def visit(plugin_id: str) -> None: + """ + 递归访问插件依赖并记录拓扑顺序。 + + :param plugin_id: 当前访问的插件 ID + :return: None + """ + if plugin_id in visited: + return + if plugin_id in visiting: + raise ValueError(f'插件存在循环依赖:{plugin_id}') + if plugin_id not in plugins: + raise ValueError(f'插件依赖未启用:{plugin_id}') + visiting.add(plugin_id) + plugin = plugins[plugin_id] + for dependency in plugin.discovered_plugin.manifest.dependencies.plugins: + visit(dependency.id) + visiting.remove(plugin_id) + visited.add(plugin_id) + result.append(plugin) + + for plugin_id in plugins: + visit(plugin_id) + return result + + @classmethod + def dependency_order(cls, registry: PluginRegistry) -> tuple[list[RegisteredPlugin], dict[str, str]]: + """ + 按依赖闭包分别排序,损坏分支不阻断独立插件。 + + :param registry: 包含全部已发现插件的注册表 + :return: 可加载的插件依赖顺序及各失败入口的错误信息 + """ + plugins = {item.plugin_id: item for item in registry.list_enabled_plugins()} + ordered: dict[str, RegisteredPlugin] = {} + errors: dict[str, str] = {} + for plugin_id, plugin in plugins.items(): + if not plugin.discovered_plugin.manifest.uses_entrypoint: + continue + closure: dict[str, RegisteredPlugin] = {} + + def collect(current: str, closure: dict[str, RegisteredPlugin] = closure) -> None: + """ + 收集当前入口依赖的插件闭包。 + + :param current: 当前访问的插件 ID + :param closure: 当前入口已收集的依赖插件映射 + :return: None + """ + if current in closure: + return + if current not in plugins: + raise ValueError(f'插件依赖未启用:{current}') + closure[current] = plugins[current] + for dependency in plugins[current].discovered_plugin.manifest.dependencies.plugins: + collect(dependency.id, closure) + + try: + collect(plugin_id) + for dependency in cls.ordered_plugins(PluginRegistry(list(closure.values()))): + ordered[dependency.plugin_id] = dependency + except ValueError as exc: + errors[plugin_id] = str(exc) + return list(ordered.values()), errors + + def prepare( + self, + plugin: RegisteredPlugin, + app: FastAPI, + *, + startup_write_enabled: bool, + config_values: Mapping[str, Any] | None = None, + ) -> None: + """ + 导入显式模型定义,在原有建表步骤之前完成。 + + :param plugin: 注册表中的待准备插件 + :param app: 宿主 FastAPI 应用 + :param startup_write_enabled: 当前 worker 是否允许执行启动期全局写入 + :param config_values: 插件专属配置快照,未提供时使用清单默认值 + :return: None + """ + if plugin.plugin_id in self.loaded: + return + discovered = plugin.discovered_plugin + manifest = discovered.manifest + builder = getattr(app.state, 'plugin_runtime_builder', None) + backend_root = getattr(builder, 'backend_root', discovered.backend_path.parents[1]) + structure = PluginStructureChecker(backend_root).check(discovered, include_frontend=False) + if not structure.ok: + raise ValueError(';'.join(item.message for item in structure.failed_items)) + compatibility = PluginManifestChecker(backend_root=backend_root).check(manifest) + if not compatibility.ok: + raise ValueError(';'.join(item.message for item in compatibility.error_issues)) + values = ( + dict(config_values) + if config_values is not None + else {item.key: item.default for item in manifest.config.items} + ) + observation = PluginConfigObservation( + plugin_id=plugin.plugin_id, + version=manifest.version, + digest=discovered.artifact_digest, + generation=discovered.artifact_generation, + startup_revision=config_revision(plugin.plugin_id, values), + ) + host = PluginHostContext( + plugin_id=plugin.plugin_id, + resource_root=discovered.backend_path, + config=values, + config_revision=observation.startup_revision, + config_reader=PluginConfigReader(discovered, DataSourceRegistry.session, observation).read, + session_factory=DataSourceRegistry.session, + services=build_host_services( + plugin.plugin_id, DataSourceRegistry.session, getattr(app.state, 'redis', None) + ), + redis=getattr(app.state, 'redis', None), + logger=logger.bind(plugin_id=plugin.plugin_id), + startup_write_enabled=startup_write_enabled and not is_artifact_plugin(discovered), + ) + existing_tables = set(Base.metadata.tables) + try: + definition = PluginEntrypointLoader(discovered).load(host) + if definition.register_models: + result = definition.register_models(host) + if inspect.isawaitable(result): + if inspect.iscoroutine(result): + result.close() + raise TypeError('register_models 必须同步注册元数据,不能执行异步数据库操作') + finally: + if is_artifact_plugin(discovered): + for key in set(Base.metadata.tables) - existing_tables: + Base.metadata.tables[key].info['plugin_artifact'] = plugin.plugin_id + self.loaded[plugin.plugin_id] = LoadedExplicitPlugin(plugin, host, definition) + worker = getattr(app.state, 'plugin_release_worker', None) + if worker is not None: + self.metrics.worker_id = worker.worker_id + self.metrics.register( + plugin.plugin_id, manifest.version, discovered.artifact_digest, discovered.artifact_generation + ) + if plugin.plugin_id in self.metrics.identities: + self.metrics.configurations[plugin.plugin_id] = observation + app.state.plugin_metrics_reporter = self.metrics_reporter + + async def activate(self, plugin_id: str, app: FastAPI) -> None: + """ + 全部校验和初始化成功后才向应用注册路由或挂载子应用。 + + :param plugin_id: 已完成准备的插件 ID + :param app: 宿主 FastAPI 应用 + :return: None + """ + if self._closing: + raise RuntimeError('插件运行时已进入关闭阶段,不能激活插件') + loaded = self.loaded[plugin_id] + if loaded.active: + return + if loaded.lifespan is not None and loaded.lifespan.has_pending_task: + raise RuntimeError(f'插件上一次生命周期尚未退出:{plugin_id}') + if loaded.plugin.discovered_plugin.manifest.backend.jobs: + loaded.jobs = bind_plugin_jobs( + loaded.plugin.discovered_plugin, + loaded.host, + self.route_state_gateway, + lambda: loaded.active and (loaded.lifespan is None or loaded.lifespan.ready), + metrics=self.metrics, + ) + try: + await self._activate_web(loaded, app) + except BaseException: + if loaded.jobs is not None: + try: + await unbind_plugin_jobs(loaded.jobs) + if not any(not task.done() for task in loaded.jobs.tasks): + loaded.jobs = None + except Exception: + logger.exception(f'插件激活失败后的任务回收失败:{plugin_id}') + raise + loaded.active = True + if plugin_id in self.metrics.configurations: + self.metrics.configurations[plugin_id].active = True + app.openapi_schema = None + + async def _activate_web(self, loaded: LoadedExplicitPlugin, app: FastAPI) -> None: + """ + 任务绑定成功后再注册 Web 能力,避免重复 worker 实例留下半注册路由。 + + :param loaded: 当前 worker 已准备的插件实例 + :param app: 宿主 FastAPI 应用 + :return: None + """ + plugin_id = loaded.plugin.plugin_id + manifest = loaded.plugin.discovered_plugin.manifest + if manifest.integration_kind == 'router': + await self._check_health(loaded, app) + if self._closing: + raise RuntimeError('插件运行时已进入关闭阶段,不能注册路由') + self._register_routers(loaded, app) + else: + child = loaded.definition.app_factory(loaded.host) + if inspect.isawaitable(child): + if inspect.iscoroutine(child): + child.close() + raise TypeError('app_factory 必须同步返回 ASGI 应用') + if not callable(child): + raise TypeError('app_factory 必须返回 ASGI callable') + if isinstance(child, FastAPI): + child.state.plugin_host = loaded.host + child.state.redis = loaded.host.redis + mount_path = manifest.backend.asgi.mount_path + if any(getattr(route, 'path', None) == mount_path for route in app.routes): + raise ValueError(f'插件挂载路径冲突:{mount_path}') + manager = PluginLifespanManager(child, managed=manifest.backend.asgi.lifespan == 'managed') + loaded.lifespan = manager + try: + await manager.startup() + await self._check_health(loaded, app) + if self._closing: + raise RuntimeError('插件运行时已进入关闭阶段,不能挂载子应用') + web_app = child + if manifest.frontend.delivery.type == 'bundle': + web_app = PluginBundleASGI(child, plugin_id, loaded.plugin.backend_path, manifest.frontend.bundle) + gateway = PluginGatewayASGI(web_app, manager, self._authorizer(loaded, app)) + observed = PluginObservedASGI(gateway, self.metrics, plugin_id) + app.router.routes.append(Mount(mount_path, app=observed, name=f'plugin:{plugin_id}')) + loaded.gateway = gateway + except BaseException: + try: + await manager.shutdown() + except Exception: + logger.exception(f'插件激活失败后的 ASGI 回收失败:{plugin_id}') + raise + + @staticmethod + async def _check_health(loaded: LoadedExplicitPlugin, app: FastAPI) -> None: + """ + 执行清单声明的健康检查并阻止不健康插件激活。 + + :param loaded: 当前 worker 已准备的插件实例 + :param app: 宿主 FastAPI 应用 + :return: None + """ + if loaded.plugin.discovered_plugin.manifest.backend.health.checker: + result = await PluginHealthChecker(loaded.plugin.discovered_plugin).check(app=app) + loaded.activation_health = activation_health_observation(result) + if not result.ok: + raise RuntimeError(f'插件激活健康检查失败:{result.message}') + + def _register_routers(self, loaded: LoadedExplicitPlugin, app: FastAPI) -> None: + """ + 校验插件路由命名空间并注册带宿主权限依赖的路由。 + + :param loaded: 当前 worker 已准备的插件实例 + :param app: 宿主 FastAPI 应用 + :return: None + """ + plugin_id = loaded.plugin.plugin_id + + async def set_context(request: Request, user: Any = Depends(LoginService.get_current_user)) -> None: + """ + 检查插件状态并向请求写入宿主身份上下文。 + + :param request: 插件接口请求 + :param user: 宿主登录依赖返回的当前用户 + :return: None + """ + if not loaded.active: + raise HTTPException(status_code=503, detail='插件未就绪') + try: + request.state.plugin_context = self._request_context(loaded, user) + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + + for router in loaded.definition.routers: + for route in router.routes: + if not PluginStructureChecker._is_plugin_route_prefix(plugin_id, getattr(route, 'path', '')): + raise ValueError(f'插件路由越过命名空间:{getattr(route, "path", "")}') + if not hasattr(route, 'methods'): + raise ValueError('v2 Router 的 WebSocket 或嵌套 Mount 请使用 ASGI 接入') + for router in loaded.definition.routers: + previous_routes = len(app.router.routes) + app.include_router( + router, + dependencies=[ + PluginEnabledDependency(plugin_id, self.route_state_gateway), + Depends(set_context), + ], + ) + for route in app.router.routes[previous_routes:]: + route.handle = PluginObservedASGI(route.handle, self.metrics, plugin_id) + + def _authorizer(self, loaded: LoadedExplicitPlugin, app: FastAPI) -> Any: + """ + 创建复用宿主登录及插件会话校验的 ASGI 授权器。 + + :param loaded: 当前 worker 已准备的插件实例 + :param app: 宿主 FastAPI 应用 + :return: 根据请求作用域构建插件身份上下文的异步函数 + """ + + async def authorize(scope: Scope) -> PluginRequestContext: + # LoginService 使用宿主 request.app.state,而子应用会替换 scope['app']。 + """ + 校验插件启用状态和登录凭证,并构建插件请求身份。 + + :param scope: HTTP 或 WebSocket 的 ASGI 请求作用域 + :return: 已通过宿主身份及插件访问权限校验的请求上下文 + """ + auth_scope = dict(scope, type='http', app=app) + request = Request(auth_scope) + if not loaded.active: + raise PermissionError('插件未激活或正在关闭') + authorization = request.headers.get('authorization', '') + scheme, _, token = authorization.partition(' ') + async with DataSourceRegistry.session() as db: + if not await self.route_state_gateway.is_plugin_enabled(db, loaded.plugin.plugin_id): + raise PermissionError('插件未启用') + if authorization: + if scheme.lower() != 'bearer' or not token.strip(): + raise LookupError('Bearer 凭证无效') + try: + resolve_user = ( + LoginService.get_current_user_for_plugin_session + if scope.get(AUTH_RECHECK_KEY) + else LoginService.get_current_user + ) + user = await resolve_user(request=request, token=token.strip(), query_db=db) + except AuthException as exc: + raise LookupError('登录已失效') from exc + elif loaded.plugin.discovered_plugin.manifest.frontend.delivery.type == 'bundle': + user = await authenticate_browser_session( + request, + plugin_id=loaded.plugin.plugin_id, + version=loaded.plugin.discovered_plugin.manifest.version, + query_db=db, + websocket=scope['type'] == 'websocket', + ) + else: + raise LookupError('缺少 Bearer 凭证') + return self._request_context(loaded, user) + + return authorize + + @staticmethod + def _request_context(loaded: LoadedExplicitPlugin, user: Any) -> PluginRequestContext: + """ + 检查插件访问权限并构建宿主签发的请求上下文。 + + :param loaded: 当前 worker 已准备的插件实例 + :param user: 宿主登录服务返回的当前用户 + :return: 绑定插件能力与当前用户身份的请求上下文 + """ + permissions = frozenset(user.permissions) + declared = set(loaded.plugin.discovered_plugin.manifest.permission_codes) + if declared and '*:*:*' not in permissions and not declared.intersection(permissions): + raise PermissionError('缺少插件访问权限') + return PluginRequestContext(loaded.host, user, permissions, request_id=current_plugin_request_id()) + + async def shutdown(self) -> None: + """ + 逆序关闭,每个实例的失败不阻止其他实例释放资源。 + + :return: None + """ + self._closing = True + instances = list(reversed(self.loaded.values())) + for loaded in instances: + loaded.active = False + if loaded.plugin.plugin_id in self.metrics.configurations: + self.metrics.configurations[loaded.plugin.plugin_id].active = False + for loaded in instances: + if loaded.gateway is not None: + try: + await loaded.gateway.drain() + except Exception: + logger.exception(f'插件长连接关闭失败:{loaded.plugin.plugin_id}') + if loaded.jobs is not None: + try: + await unbind_plugin_jobs(loaded.jobs) + if not any(not task.done() for task in loaded.jobs.tasks): + loaded.jobs = None + except Exception: + logger.exception(f'插件任务关闭失败:{loaded.plugin.plugin_id}') + if loaded.lifespan is not None: + try: + await loaded.lifespan.shutdown() + except Exception: + logger.exception(f'插件 ASGI 关闭失败:{loaded.plugin.plugin_id}') + try: + await self.metrics_reporter.stop() + except Exception: + logger.exception('插件指标上报关闭失败') diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/health.py b/ruoyi-fastapi-backend/plugins/core/runtime/health.py index c2155de..1dde851 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/health.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/health.py @@ -7,6 +7,7 @@ from typing import Any, cast from common.constant import PluginRuntimeConstant from plugins.core.discovery.scanner import DiscoveredPlugin from plugins.core.runtime.callable import LoadedPluginCallable, PluginCallableLoader +from plugins.core.sdk.definition import await_plugin_callback from plugins.core.types import JSONObject @@ -145,6 +146,8 @@ class PluginHealthChecker: :return: 健康检查原始返回值 """ callable_object = checker_callable.callable_object + if self.discovered_plugin.manifest.uses_entrypoint: + return await await_plugin_callback(callable_object, context, timeout=self.timeout_seconds) if not inspect.iscoroutinefunction(callable_object): raise TypeError('插件健康检查必须使用 async def 声明,平台不会在线程中执行不可终止的同步检查') raw_result = self._invoke_checker(checker_callable, context) @@ -178,6 +181,10 @@ class PluginHealthChecker: :param started_at: 检查开始时间 :return: 插件健康检查结果 """ + if self.discovered_plugin.manifest.uses_entrypoint and not ( + isinstance(raw_result, bool) or (isinstance(raw_result, dict) and isinstance(raw_result.get('ok'), bool)) + ): + raise TypeError('v2 插件健康检查必须返回 bool 或包含 bool 类型 ok 字段的字典') if isinstance(raw_result, dict): raw_details = raw_result.get('details') ok = bool(raw_result.get('ok', raw_result.get('healthy', True))) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/hooks.py b/ruoyi-fastapi-backend/plugins/core/runtime/hooks.py index ff9391d..5cd9701 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/hooks.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/hooks.py @@ -6,6 +6,7 @@ from typing import Any from common.constant import PluginRuntimeConstant from plugins.core.discovery.scanner import DiscoveredPlugin from plugins.core.runtime.callable import LoadedPluginCallable, PluginCallableLoader +from plugins.core.sdk.definition import await_plugin_callback from utils.log_util import logger @@ -151,6 +152,9 @@ class PluginHookRunner: :return: None """ callable_object = hook_callable.callable_object + if self.discovered_plugin.manifest.uses_entrypoint: + await await_plugin_callback(callable_object, context, timeout=self.timeout_seconds) + return if not inspect.iscoroutinefunction(callable_object): raise TypeError('生命周期钩子必须使用 async def 声明,平台不会在线程中执行不可终止的同步钩子') result = self._invoke_hook(hook_callable, context) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/host_services.py b/ruoyi-fastapi-backend/plugins/core/runtime/host_services.py new file mode 100644 index 0000000..b1cc704 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/host_services.py @@ -0,0 +1,184 @@ +import json +import re +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager +from typing import Any + +from plugins.core.sdk.context import PluginRequestContext + +MAX_CACHE_BYTES = 64 * 1024 +MAX_CACHE_TTL_SECONDS = 86400 + + +def _require_request(context: PluginRequestContext, plugin_id: str, capability: str) -> int: + """ + 在访问宿主资源前检查插件归属、能力权限和当前用户身份。 + + :param context: 宿主签发的请求上下文 + :param plugin_id: 服务绑定的插件 ID + :param capability: 当前服务要求的插件权限后缀 + :return: 当前登录用户 ID + """ + if not isinstance(context, PluginRequestContext) or context.host.plugin_id != plugin_id: + raise PermissionError('请求上下文不属于当前插件') + context.require_permission(f'{plugin_id}:{capability}') + user_id = getattr(getattr(context.user, 'user', None), 'user_id', None) + if not isinstance(user_id, int) or isinstance(user_id, bool) or user_id <= 0: + raise PermissionError('缺少有效的当前用户身份') + return user_id + + +@asynccontextmanager +async def _request_session(context: PluginRequestContext, factory: Callable[..., Any]) -> AsyncIterator[Any]: + """ + 复用当前任务的显式事务;未开启事务时为单次只读服务创建独立会话。 + + :param context: 当前插件请求上下文 + :param factory: 宿主数据库会话工厂 + :return: 当前服务允许使用的数据库会话 + """ + transaction = context.transaction_session() + if transaction is not None: + yield transaction + else: + async with factory() as db: + yield db + + +def build_host_services( + plugin_id: str, session_factory: Callable[..., Any], redis: Any = None +) -> dict[str, Callable[..., Any]]: + """ + 请求身份只能来自宿主上下文;数据库服务可显式复用当前任务的事务。 + + :param plugin_id: 使用宿主服务的插件 ID + :param session_factory: 创建宿主数据库会话的工厂 + :param redis: 可选的宿主 Redis 客户端,未提供时缓存服务拒绝执行 + :return: 按版本化服务名称索引的宿主服务集合 + """ + + async def current_user_profile(context: PluginRequestContext) -> dict[str, Any]: + """ + 验证插件身份和接口权限后读取当前用户资料。 + + :param context: 宿主鉴权层建立的插件请求上下文 + :return: 仅包含允许向插件公开字段的当前用户资料 + """ + user_id = _require_request(context, plugin_id, 'profile') + + from module_admin.service.user_service import UserService # noqa: PLC0415 + + async with _request_session(context, session_factory) as db: + profile = await UserService.user_profile_services(db, user_id) + if profile.data is None: + raise LookupError('当前用户不存在') + # 白名单 DTO,不将 ORM 对象、密码及宿主内部模型协议交给插件。 + return { + 'userId': profile.data.user_id, + 'userName': profile.data.user_name, + 'nickName': profile.data.nick_name, + 'avatar': profile.data.avatar, + 'postGroup': profile.post_group, + 'roleGroup': profile.role_group, + } + + async def dictionary_items(context: PluginRequestContext, dict_type: str) -> list[dict[str, Any]]: + """ + 读取已启用字典的公开选项,返回不包含宿主 ORM 对象的白名单数据。 + + :param context: 当前插件请求上下文 + :param dict_type: 非空字典类型标识 + :return: 按宿主字典顺序排列的选项数据 + """ + _require_request(context, plugin_id, 'dict') + if not isinstance(dict_type, str) or not re.fullmatch(r'[A-Za-z][A-Za-z0-9_]{0,99}', dict_type): + raise ValueError('字典类型必须是有效的非空标识') + from module_admin.service.dict_service import DictDataService # noqa: PLC0415 + + async with _request_session(context, session_factory) as db: + rows = await DictDataService.query_dict_data_list_services(db, dict_type) + return [ + { + 'label': row.dict_label, + 'value': row.dict_value, + 'cssClass': row.css_class, + 'listClass': row.list_class, + 'isDefault': row.is_default == 'Y', + } + for row in rows + if row is not None + ] + + def cache_key(context: PluginRequestContext, key: str) -> str: + """ + 校验缓存权限并构建固定插件命名空间,不接受通配查询或任意 Redis 命令。 + + :param context: 当前插件请求上下文 + :param key: 当前插件内的缓存键 + :return: 带有插件前缀的完整 Redis 键 + """ + _require_request(context, plugin_id, 'cache') + if not isinstance(key, str) or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}', key): + raise ValueError('插件缓存键必须是 1 至 128 位字母、数字或 _ . : -') + if redis is None: + raise RuntimeError('宿主未提供 Redis 缓存能力') + return f'plugin:sdk:cache:{plugin_id}:{key}' + + async def cache_get(context: PluginRequestContext, key: str) -> Any: + """ + 获取当前插件的 JSON 缓存,缺失时返回 None。 + + :param context: 当前插件请求上下文 + :param key: 当前插件内的缓存键 + :return: 已解码的 JSON 数据或 None + """ + name = cache_key(context, key) + value = await redis.get(name) + if value is None: + return None + encoded = value.encode('utf-8') if isinstance(value, str) else value + if len(encoded) > MAX_CACHE_BYTES: + raise ValueError('插件缓存内容超过大小限制') + try: + result = json.loads(encoded) + json.dumps(result, allow_nan=False) + except (ValueError, UnicodeError, RecursionError) as exc: + raise ValueError('插件缓存内容不是有效 JSON') from exc + return result + + async def cache_set(context: PluginRequestContext, key: str, value: Any, ttl_seconds: int = 300) -> None: + """ + 保存有界的 JSON 缓存,必须设置有限有效期;不参与数据库事务。 + + :param context: 当前插件请求上下文 + :param key: 当前插件内的缓存键 + :param value: 可序列化的 JSON 数据 + :param ttl_seconds: 有效期秒数,范围为 1 至 86400 + :return: None + """ + name = cache_key(context, key) + if type(ttl_seconds) is not int or not 1 <= ttl_seconds <= MAX_CACHE_TTL_SECONDS: + raise ValueError('插件缓存有效期必须在 1 至 86400 秒之间') + encoded = json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(',', ':')).encode('utf-8') + if len(encoded) > MAX_CACHE_BYTES: + raise ValueError('插件缓存内容超过大小限制') + await redis.set(name, encoded, ex=ttl_seconds) + + async def cache_delete(context: PluginRequestContext, key: str) -> bool: + """ + 删除当前插件的单个缓存键,不提供扫描或批量删除其他插件缓存的能力。 + + :param context: 当前插件请求上下文 + :param key: 当前插件内的缓存键 + :return: 是否删除了已有缓存 + """ + name = cache_key(context, key) + return bool(await redis.delete(name)) + + return { + 'users.current_profile.v1': current_user_profile, + 'dictionaries.items.v1': dictionary_items, + 'cache.get.v1': cache_get, + 'cache.set.v1': cache_set, + 'cache.delete.v1': cache_delete, + } diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/job_dispatcher.py b/ruoyi-fastapi-backend/plugins/core/runtime/job_dispatcher.py new file mode 100644 index 0000000..4196516 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/job_dispatcher.py @@ -0,0 +1,186 @@ +import asyncio +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any +from uuid import uuid4 + +from middlewares.trace_middleware.ctx import CTX_REQUEST_ID +from plugins.core.discovery.scanner import DiscoveredPlugin +from plugins.core.runtime.entrypoint import PluginEntrypointLoader +from plugins.core.runtime.metrics import MetricOutcome, PluginRuntimeMetrics +from plugins.core.runtime.route_guard import PluginRouteStateGateway +from plugins.core.runtime.task_cleanup import CANCEL_TIMEOUT_SECONDS, cancel_plugin_tasks +from plugins.core.sdk import PluginHostContext, PluginTaskContext, await_plugin_callback +from utils.log_util import logger + +DISPATCH_TARGET = 'plugins.core.runtime.job_dispatcher.dispatch_plugin_job' + + +@dataclass +class PluginJobBinding: + """ + 当前进程中已准备好的插件任务能力。 + + :param plugin: 任务所属的已发现插件 + :param host: 任务使用的宿主能力上下文 + :param gateway: 插件启用状态查询网关 + :param ready: 查询插件是否就绪的回调 + :param loop: 所属宿主事件循环 + :param tasks: 当前仍在执行的插件异步任务集合 + :param metrics: 可选的当前 worker 指标收集器 + :param closing: 是否已停止接收新任务并正在回收旧任务 + """ + + plugin: DiscoveredPlugin + host: PluginHostContext + gateway: PluginRouteStateGateway + ready: Callable[[], bool] + loop: asyncio.AbstractEventLoop + tasks: set[asyncio.Task[Any]] = field(default_factory=set) + metrics: PluginRuntimeMetrics | None = None + closing: bool = False + + def ensure_open(self) -> None: + """ + 关闭后不再启动业务回调,也不把迟到结果报告为成功。 + + :return: None + """ + if self.closing: + raise asyncio.CancelledError + + +_bindings: dict[str, PluginJobBinding] = {} + + +def bind_plugin_jobs( + plugin: DiscoveredPlugin, + host: PluginHostContext, + gateway: PluginRouteStateGateway, + ready: Callable[[], bool], + *, + metrics: PluginRuntimeMetrics | None = None, +) -> PluginJobBinding: + """ + 独占注册;同进程的第二个应用不能覆盖已有插件资源。 + + :param plugin: 任务所属的已发现插件 + :param host: 任务使用的宿主能力上下文 + :param gateway: 插件启用状态查询网关 + :param ready: 查询插件是否就绪的回调 + :param metrics: 可选的运行指标收集器 + :return: 绑定到当前事件循环的插件任务能力 + """ + plugin_id = plugin.manifest.id + existing = _bindings.get(plugin_id) + if existing is not None and existing.closing: + raise RuntimeError(f'插件旧任务尚未退出,不能重新绑定:{plugin_id}') + if existing is not None: + raise RuntimeError(f'插件任务运行时已绑定:{plugin_id}') + if host.plugin_id != plugin_id or host.session_factory is None: + raise ValueError('任务宿主身份不匹配或缺少数据库会话工厂') + binding = PluginJobBinding(plugin, host, gateway, ready, asyncio.get_running_loop(), metrics=metrics) + _bindings[plugin_id] = binding + return binding + + +def _release_binding(binding: PluginJobBinding) -> None: + """ + 全部旧任务退出后才释放绑定,防止同进程启动重叠实例。 + + :param binding: 已进入关闭阶段的插件任务绑定 + :return: None + """ + plugin_id = binding.plugin.manifest.id + if binding.closing and not any(not task.done() for task in binding.tasks) and _bindings.get(plugin_id) is binding: + del _bindings[plugin_id] + + +async def unbind_plugin_jobs(binding: PluginJobBinding, *, timeout: float = CANCEL_TIMEOUT_SECONDS) -> None: + """ + 先停止分发,再整批限时取消;未退出时保留绑定并禁止新实例覆盖。 + + :param binding: 待解除的插件任务绑定 + :param timeout: 整批运行任务的取消等待上限秒数 + :return: None + """ + if not binding.closing: + binding.closing = True + for task in binding.tasks: + task.add_done_callback(lambda _: _release_binding(binding)) + try: + pending = await cancel_plugin_tasks(binding.tasks, timeout=timeout) + if pending: + logger.warning('插件任务取消等待超时:{},尚有 {} 个任务未退出', binding.plugin.manifest.id, len(pending)) + finally: + _release_binding(binding) + + +async def dispatch_plugin_job(plugin_id: str, job_id: str, version: str) -> Any: + """ + APScheduler 仅保存此函数及三个字符串;不持久化上下文或原生 callable。 + + :param plugin_id: 任务所属的插件 ID + :param job_id: 插件清单声明的任务 ID + :param version: 调度记录绑定的插件版本 + :return: 插件异步任务回调的执行结果 + """ + binding = _bindings.get(plugin_id) + if binding is None or binding.closing or not binding.ready(): + raise RuntimeError(f'插件任务运行时未就绪:{plugin_id}') + if asyncio.get_running_loop() is not binding.loop: + raise RuntimeError('插件任务必须在所属宿主事件循环执行') + manifest = binding.plugin.manifest + if manifest.version != version: + raise RuntimeError(f'插件任务版本不匹配:{plugin_id},需重启并重新同步任务') + job = next((item for item in manifest.backend.jobs if item.id == job_id), None) + if job is None: + raise LookupError(f'插件未声明任务:{plugin_id}:{job_id}') + task = asyncio.current_task() + binding.tasks.add(task) + request_id = uuid4().hex + token = CTX_REQUEST_ID.set(request_id) + invocation = binding.metrics.begin(plugin_id, f'job:{job_id}', request_id) if binding.metrics is not None else None + log_fields = ( + binding.metrics.log_fields(plugin_id, f'job:{job_id}') + if binding.metrics is not None + else {'plugin_id': plugin_id, 'plugin_operation': f'job:{job_id}'} + ) + outcome: MetricOutcome = 'failed' + error_type, timed_out = None, False + try: + with logger.contextualize(**log_fields): + async with binding.host.session_factory() as db: + if not await binding.gateway.is_plugin_enabled(db, plugin_id): + raise PermissionError(f'插件未启用:{plugin_id}') + # 数据库等待期间可能开始关闭,不能在解绑后再启动原生回调。 + binding.ensure_open() + if _bindings.get(plugin_id) is not binding or not binding.ready(): + raise RuntimeError(f'插件任务运行时已停止:{plugin_id}') + module, name = job.callable.rsplit('.', 1) + callback = PluginEntrypointLoader(binding.plugin).load_callable(f'{module}:{name}') + result = await await_plugin_callback( + callback, + PluginTaskContext(binding.host, job_id, request_id=request_id), + *job.args, + kwargs=job.kwargs, + timeout=job.timeout_seconds, + ) + binding.ensure_open() + outcome = 'succeeded' + return result + except asyncio.CancelledError: + outcome, error_type = 'cancelled', 'CancelledError' + raise + except PermissionError: + outcome, error_type = 'rejected', 'PermissionError' + raise + except Exception as exc: + error_type = type(exc).__name__ + timed_out = isinstance(exc, (TimeoutError, asyncio.TimeoutError)) + raise + finally: + if invocation is not None: + invocation.finish(outcome, error_type=error_type, timed_out=timed_out) + CTX_REQUEST_ID.reset(token) + binding.tasks.discard(task) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/metrics.py b/ruoyi-fastapi-backend/plugins/core/runtime/metrics.py new file mode 100644 index 0000000..4d6e248 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/metrics.py @@ -0,0 +1,394 @@ +import asyncio +import math +import time +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any, Literal +from uuid import uuid4 + +from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, model_validator +from pydantic.alias_generators import to_camel +from starlette import status +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from middlewares.trace_middleware.ctx import CTX_REQUEST_ID +from plugins.core.runtime.configuration import PluginConfigObservation +from plugins.core.runtime.connections import CONNECTION_OUTCOME_KEY, ConnectionClosure +from plugins.core.sdk.request import current_plugin_request_id +from utils.log_util import logger + +LATENCY_BUCKETS_MS = (10, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000) +MAX_METRIC_SERIES = 1024 +MetricOutcome = Literal['succeeded', 'rejected', 'failed', 'cancelled'] + + +class PluginMetricSeries(BaseModel): + """ + 一个 worker 上某插件和操作的累计指标,不包含请求参数、路径或用户数据。 + """ + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, extra='forbid', strict=True) + + plugin_id: str = Field(pattern=r'^[a-z][a-z0-9_]{1,63}$') + version: str = Field(max_length=128) + digest: str | None = Field(default=None, pattern=r'^[0-9a-f]{64}$') + generation: str | None = Field(default=None, pattern=r'^[0-9a-f]{32}$') + operation: str = Field(pattern=r'^(http|websocket|job:[a-z][a-z0-9_-]{0,63})$') + started: NonNegativeInt = 0 + active: NonNegativeInt = 0 + succeeded: NonNegativeInt = 0 + rejected: NonNegativeInt = 0 + failed: NonNegativeInt = 0 + cancelled: NonNegativeInt = 0 + timed_out: NonNegativeInt = 0 + duration_sum_ms: float = Field(default=0.0, ge=0, allow_inf_nan=False) + duration_max_ms: float = Field(default=0.0, ge=0, allow_inf_nan=False) + latency_buckets: list[NonNegativeInt] = Field( + default_factory=lambda: [0] * (len(LATENCY_BUCKETS_MS) + 1), + min_length=len(LATENCY_BUCKETS_MS) + 1, + max_length=len(LATENCY_BUCKETS_MS) + 1, + ) + last_error_type: str | None = Field(default=None, max_length=80) + last_error_request_id: str | None = Field(default=None, max_length=128) + last_error_at: float | None = Field(default=None, ge=0, allow_inf_nan=False) + + @model_validator(mode='after') + def validate_counters(self) -> 'PluginMetricSeries': + """ + 拒绝损坏或不一致的跨 worker 计数,避免将错误快照混入汇总。 + + :return: 校验通过的指标序列 + """ + completed = self.succeeded + self.rejected + self.failed + self.cancelled + if self.started != self.active + completed or sum(self.latency_buckets) != completed: + raise ValueError('插件运行指标计数不一致') + if self.timed_out > self.failed: + raise ValueError('插件超时次数不能大于失败次数') + return self + + def to_payload(self) -> dict[str, Any]: + """ + 计算便于管理端展示的平均值和直方图近似 P95。 + + :return: 带派生耗时指标的 JSON 数据 + """ + completed = self.started - self.active + percentile = 0.0 + target = math.ceil(completed * 0.95) + seen = 0 + for boundary, count in zip((*LATENCY_BUCKETS_MS, self.duration_max_ms), self.latency_buckets, strict=True): + seen += count + if completed and seen >= target: + percentile = float(boundary) + break + return { + **self.model_dump(by_alias=True), + 'completed': completed, + 'failureRatePercent': round(self.failed * 100 / completed, 3) if completed else 0.0, + 'averageDurationMs': round(self.duration_sum_ms / completed, 3) if completed else 0.0, + 'p95UpperBoundMs': percentile, + } + + +class PluginMetricSnapshot(BaseModel): + """ + 带采样时间和进程标识的有界指标快照。 + """ + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, extra='forbid', strict=True) + + schema_version: Literal[1] = 1 + worker_id: str = Field(pattern=r'^[0-9a-f]{32}$') + collected_at: float = Field(ge=0, allow_inf_nan=False) + dropped_series: NonNegativeInt = 0 + series: list[PluginMetricSeries] = Field(max_length=MAX_METRIC_SERIES) + configurations: list[PluginConfigObservation] = Field(default_factory=list, max_length=MAX_METRIC_SERIES) + + +@dataclass +class PluginMetricInvocation: + """ + 单次调用的计时记录,结束方法幂等,避免异常分支重复计数。 + + :param series: 当前操作的可变指标序列 + :param request_id: 本次调用的追踪标识 + :param clock: 单调计时函数 + :param started_at: 本次调用的开始时间 + :param finished: 是否已记录结束结果 + """ + + series: PluginMetricSeries + request_id: str + clock: Callable[[], float] + started_at: float + finished: bool = False + + def finish(self, outcome: MetricOutcome, *, error_type: str | None = None, timed_out: bool = False) -> None: + """ + 完成调用统计,不保存错误消息或请求内容。 + + :param outcome: 成功、客户端拒绝、失败或取消 + :param error_type: 可选的错误类型名称 + :param timed_out: 是否为超时失败 + :return: None + """ + if self.finished: + return + self.finished = True + duration = max(0.0, (self.clock() - self.started_at) * 1000) + series = self.series + series.active -= 1 + setattr(series, outcome, getattr(series, outcome) + 1) + series.timed_out += int(timed_out and outcome == 'failed') + series.duration_sum_ms += duration + series.duration_max_ms = max(series.duration_max_ms, duration) + bucket = next( + (index for index, bound in enumerate(LATENCY_BUCKETS_MS) if duration <= bound), len(LATENCY_BUCKETS_MS) + ) + series.latency_buckets[bucket] += 1 + if error_type: + series.last_error_type = error_type[:80] + series.last_error_request_id = self.request_id[:128] + series.last_error_at = time.time() + + +@dataclass +class PluginRuntimeMetrics: + """ + 当前事件循环内的插件指标收集器,操作标签仅来自宿主和已声明任务。 + + :param worker_id: 当前 worker 唯一标识 + :param clock: 单调计时函数 + :param identities: 插件加载版本、摘要和发布代际 + :param series: 按插件及操作索引的计数器 + :param dropped_series: 超出容量而未采集的调用数 + :param configurations: 当前进程的配置版本观测,不保存配置明文 + """ + + worker_id: str = field(default_factory=lambda: uuid4().hex) + clock: Callable[[], float] = time.perf_counter + identities: dict[str, dict[str, Any]] = field(default_factory=dict) + series: dict[tuple[str, str], PluginMetricSeries] = field(default_factory=dict) + dropped_series: int = 0 + configurations: dict[str, PluginConfigObservation] = field(default_factory=dict) + + def register(self, plugin_id: str, version: str, digest: str | None, generation: str | None) -> None: + """ + 记录当前进程加载的插件身份,注册零值 HTTP 指标以显示尚无请求的插件。 + + :param plugin_id: 插件 ID + :param version: 实际加载版本 + :param digest: 签名制品摘要,源码插件为 None + :param generation: 实际加载的发布代际 + :return: None + """ + if plugin_id in self.identities: + return + if len(self.identities) >= MAX_METRIC_SERIES: + self.dropped_series += 1 + return + self.identities[plugin_id] = { + 'plugin_id': plugin_id, + 'version': version, + 'digest': digest, + 'generation': generation, + } + self._series_for(plugin_id, 'http') + + def _series_for(self, plugin_id: str, operation: str) -> PluginMetricSeries | None: + """ + 获取有界操作序列,不按 URL、用户或参数创建指标标签。 + + :param plugin_id: 已注册插件 ID + :param operation: 宿主定义的 HTTP、WebSocket 或任务操作 + :return: 可记录的指标序列,容量不足时为 None + """ + key = (plugin_id, operation) + if key in self.series: + return self.series[key] + if plugin_id not in self.identities or len(self.series) >= MAX_METRIC_SERIES: + self.dropped_series += 1 + return None + series = PluginMetricSeries(**self.identities[plugin_id], operation=operation) + self.series[key] = series + return series + + def begin(self, plugin_id: str, operation: str, request_id: str) -> PluginMetricInvocation | None: + """ + 开始一次调用,记录在途数量。 + + :param plugin_id: 当前插件 ID + :param operation: 当前操作标签 + :param request_id: 当前调用的追踪标识 + :return: 调用计时记录,超出容量时为 None + """ + series = self._series_for(plugin_id, operation) + if series is None: + return None + series.started += 1 + series.active += 1 + return PluginMetricInvocation(series, request_id, self.clock, self.clock()) + + def snapshot(self) -> PluginMetricSnapshot: + """ + 在当前事件循环中复制全部计数,返回不受后续请求修改影响的快照。 + + :return: 当前 worker 的指标快照 + """ + return PluginMetricSnapshot( + worker_id=self.worker_id, + collected_at=time.time(), + dropped_series=self.dropped_series, + series=[series.model_copy(deep=True) for _, series in sorted(self.series.items())], + configurations=[config.model_copy(deep=True) for _, config in sorted(self.configurations.items())], + ) + + def log_fields(self, plugin_id: str, operation: str) -> dict[str, Any]: + """ + 提供结构化日志关联字段,不覆盖宿主已有的 worker 和请求追踪标识。 + + :param plugin_id: 当前插件 ID + :param operation: 当前操作标签 + :return: 可传给日志上下文的插件版本字段 + """ + return { + 'plugin_id': plugin_id, + 'plugin_worker_id': self.worker_id, + 'plugin_operation': operation, + **{ + f'plugin_{key}': value + for key, value in self.identities.get(plugin_id, {}).items() + if key != 'plugin_id' + }, + } + + +class PluginObservedASGI: + """ + 统计完整 HTTP 请求与 WebSocket 生命周期,并为插件日志关联宿主追踪上下文。 + """ + + def __init__(self, app: ASGIApp, metrics: PluginRuntimeMetrics, plugin_id: str) -> None: + """ + 绑定待观测应用及所属插件。 + + :param app: 包含权限门禁的插件应用或路由 + :param metrics: 当前 worker 的指标收集器 + :param plugin_id: 当前插件 ID + :return: None + """ + self.app = app + self.metrics = metrics + self.plugin_id = plugin_id + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + """ + 记录调用结果,在取消及异常分支仍正确减少在途数量。 + + :param scope: 当前请求作用域 + :param receive: 请求消息接收器 + :param send: 响应消息发送器 + :return: None + """ + if scope['type'] not in {'http', 'websocket'}: + await self.app(scope, receive, send) + return + operation = scope['type'] + request_id = current_plugin_request_id() + token = CTX_REQUEST_ID.set(request_id) + invocation = self.metrics.begin(self.plugin_id, operation, request_id) + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + outcome: MetricOutcome = 'failed' + error_type = None + timed_out = False + + async def observed_send(message: Message) -> None: + """ + 观察状态码及 WebSocket 握手结果,不读取或复制响应体。 + + :param message: 应用输出的 ASGI 消息 + :return: None + """ + nonlocal status_code + if message['type'] == 'http.response.start': + status_code = message['status'] + elif message['type'] == 'websocket.accept': + status_code = status.HTTP_200_OK + elif message['type'] == 'websocket.close': + if status_code != status.HTTP_200_OK: + status_code = status.HTTP_403_FORBIDDEN + elif message.get('code', status.WS_1000_NORMAL_CLOSURE) not in { + status.WS_1000_NORMAL_CLOSURE, + status.WS_1001_GOING_AWAY, + }: + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + await send(message) + + try: + with logger.contextualize(**self.metrics.log_fields(self.plugin_id, operation)): + await self.app(scope, receive, observed_send) + outcome = ( + 'succeeded' + if status_code < status.HTTP_400_BAD_REQUEST + else 'rejected' + if status_code < status.HTTP_500_INTERNAL_SERVER_ERROR + else 'failed' + ) + timed_out = status_code == status.HTTP_504_GATEWAY_TIMEOUT + error_type = f'HTTP{status_code}' if status_code >= status.HTTP_400_BAD_REQUEST else None + except asyncio.CancelledError: + outcome, error_type = 'cancelled', 'CancelledError' + raise + except Exception as exc: + error_type = type(exc).__name__ + timed_out = isinstance(exc, (TimeoutError, asyncio.TimeoutError)) + raise + finally: + closure = scope.get(CONNECTION_OUTCOME_KEY) + if isinstance(closure, ConnectionClosure): + outcome = closure.outcome + error_type = f'PluginConnection:{closure.reason}' + timed_out = closure.reason == 'authorization_timeout' + if invocation is not None: + invocation.finish(outcome, error_type=error_type, timed_out=timed_out) + CTX_REQUEST_ID.reset(token) + + +def aggregate_metric_snapshots(snapshots: list[PluginMetricSnapshot], plugin_id: str | None) -> list[dict[str, Any]]: + """ + 按插件版本、制品、代际和操作汇总,避免混合新旧版本的运行指标。 + + :param snapshots: 当前有效 worker 的采样数据 + :param plugin_id: 可选的插件过滤条件 + :return: 包含平均耗时和近似 P95 的汇总序列 + """ + groups: dict[tuple[Any, ...], PluginMetricSeries] = {} + for snapshot in snapshots: + for series in snapshot.series: + if plugin_id is not None and series.plugin_id != plugin_id: + continue + key = (series.plugin_id, series.version, series.digest, series.generation, series.operation) + if key not in groups: + groups[key] = series.model_copy(deep=True) + continue + total = groups[key] + for name in ( + 'started', + 'active', + 'succeeded', + 'rejected', + 'failed', + 'cancelled', + 'timed_out', + 'duration_sum_ms', + ): + setattr(total, name, getattr(total, name) + getattr(series, name)) + total.duration_max_ms = max(total.duration_max_ms, series.duration_max_ms) + total.latency_buckets = [ + left + right for left, right in zip(total.latency_buckets, series.latency_buckets, strict=True) + ] + if (series.last_error_at or 0) > (total.last_error_at or 0): + total.last_error_at = series.last_error_at + total.last_error_type = series.last_error_type + total.last_error_request_id = series.last_error_request_id + return [groups[key].to_payload() for key in sorted(groups, key=lambda value: tuple(part or '' for part in value))] diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/metrics_store.py b/ruoyi-fastapi-backend/plugins/core/runtime/metrics_store.py new file mode 100644 index 0000000..d0152a1 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/metrics_store.py @@ -0,0 +1,214 @@ +import asyncio +import re +import time +from contextlib import suppress +from typing import Any + +from plugins.core.runtime.metrics import PluginMetricSnapshot, PluginRuntimeMetrics, aggregate_metric_snapshots +from utils.log_util import logger + +METRICS_PUBLISH_SECONDS = 15 +METRICS_TTL_SECONDS = 60 +METRICS_IO_TIMEOUT_SECONDS = 2 +MAX_METRICS_SNAPSHOT_BYTES = 512 * 1024 +MAX_METRICS_WORKERS = 64 +MAX_CLOCK_SKEW_SECONDS = 5 + + +class PluginMetricsReporter: + """ + 将进程累计指标定期发布到 Redis;观测失败不影响插件业务或发布状态。 + """ + + def __init__(self, metrics: PluginRuntimeMetrics) -> None: + """ + 初始化尚未连接外部存储的指标报告器。 + + :param metrics: 当前 worker 的本地指标收集器 + :return: None + """ + self.metrics = metrics + self.redis: Any = None + self.namespace = '' + self.task: asyncio.Task[None] | None = None + self.publication_failed = False + + @property + def key(self) -> str: + """ + 返回当前 worker 的独占快照键。 + + :return: 带运行环境前缀的 Redis 键 + """ + return f'{self.namespace}:{self.metrics.worker_id}' + + def start(self, redis: Any, namespace: str) -> None: + """ + 在宿主启动成功后启用采样,前缀跟随宿主启动协调配置实现环境隔离。 + + :param redis: 当前宿主 Redis 客户端 + :param namespace: 当前环境的指标命名空间 + :return: None + """ + if self.task is not None: + return + self.redis = redis + self.namespace = namespace + if redis is not None: + self.task = asyncio.create_task(self._run(), name=f'plugin-metrics-{self.metrics.worker_id}') + + async def publish(self) -> None: + """ + 发布有界快照并设置过期时间,避免异常退出的进程永久计入汇总。 + + :return: None + """ + if self.redis is None: + return + try: + snapshot = self.metrics.snapshot().model_dump_json(by_alias=True).encode('utf-8') + if len(snapshot) > MAX_METRICS_SNAPSHOT_BYTES: + raise ValueError('插件指标快照超过大小限制') + await asyncio.wait_for( + self.redis.set(self.key, snapshot, ex=METRICS_TTL_SECONDS), METRICS_IO_TIMEOUT_SECONDS + ) + except Exception: + if not self.publication_failed: + logger.warning('插件指标发布不可用,保留本地指标;远程快照将按 TTL 过期') + self.publication_failed = True + else: + self.publication_failed = False + + async def _run(self) -> None: + """ + 定期采样,不在请求路径同步写入 Redis。 + + :return: None + """ + while True: + await asyncio.sleep(METRICS_PUBLISH_SECONDS) + await self.publish() + + async def stop(self) -> None: + """ + 取消采样任务并仅移除本 worker 的快照;清理失败时依赖 TTL。 + + :return: None + """ + if self.task is not None: + self.task.cancel() + with suppress(asyncio.CancelledError): + await self.task + self.task = None + if self.redis is not None: + with suppress(Exception): + await asyncio.wait_for(self.redis.delete(self.key), METRICS_IO_TIMEOUT_SECONDS) + + async def _read_cluster(self) -> tuple[list[PluginMetricSnapshot], int, int, bool]: + """ + 有界读取当前命名空间的有效快照,拒绝损坏、过期及不匹配的进程身份。 + + :return: 快照列表、无效数量、过期数量以及是否达到 worker 上限 + """ + keys = [] + limited = False + invalid = 0 + stale = 0 + scanned = 0 + async for raw_key in self.redis.scan_iter(match=f'{self.namespace}:*', count=100): + if scanned >= MAX_METRICS_WORKERS: + limited = True + break + scanned += 1 + try: + key = raw_key.decode('utf-8') if isinstance(raw_key, bytes) else raw_key + except UnicodeError: + invalid += 1 + continue + if not isinstance(key, str) or not re.fullmatch(rf'{re.escape(self.namespace)}:[0-9a-f]{{32}}', key): + invalid += 1 + continue + if key not in keys: + keys.append(key) + values = await self.redis.mget(keys) if keys else [] + snapshots = [] + now = time.time() + for key, raw in zip(keys, values, strict=True): + if raw is None: + continue + value = raw.encode('utf-8') if isinstance(raw, str) else raw + if not isinstance(value, bytes) or len(value) > MAX_METRICS_SNAPSHOT_BYTES: + invalid += 1 + continue + try: + snapshot = PluginMetricSnapshot.model_validate_json(value) + except ValueError: + invalid += 1 + continue + if snapshot.worker_id != key.rsplit(':', 1)[1]: + invalid += 1 + continue + age = now - snapshot.collected_at + if age > METRICS_TTL_SECONDS or age < -MAX_CLOCK_SKEW_SECONDS: + stale += 1 + continue + snapshots.append(snapshot) + return snapshots, invalid, stale, limited + + async def read(self, plugin_id: str | None = None) -> dict[str, Any]: + """ + 汇总当前可观测进程;Redis 不可用时明确降级为当前 worker 的数据。 + + :param plugin_id: 可选的插件 ID 过滤条件 + :return: 采样范围、worker 明细和按版本分组的累计指标 + """ + snapshots = [] + invalid = stale = 0 + limited = False + cluster_available = False + if self.redis is not None: + try: + snapshots, invalid, stale, limited = await asyncio.wait_for( + self._read_cluster(), METRICS_IO_TIMEOUT_SECONDS + ) + cluster_available = True + except Exception: + # 查询失败不能把当前 worker 的计数伪装成集群汇总。 + snapshots = [] + snapshots = [snapshot for snapshot in snapshots if snapshot.worker_id != self.metrics.worker_id] + snapshots.append(self.metrics.snapshot()) + workers = [ + { + 'workerId': snapshot.worker_id, + 'collectedAt': snapshot.collected_at, + 'droppedSeries': snapshot.dropped_series, + 'configurations': [ + config.model_dump(by_alias=True) + for config in snapshot.configurations + if plugin_id is None or config.plugin_id == plugin_id + ], + 'series': [ + series.to_payload() + for series in snapshot.series + if plugin_id is None or series.plugin_id == plugin_id + ], + } + for snapshot in sorted(snapshots, key=lambda item: item.worker_id) + ] + workers = [worker for worker in workers if worker['series'] or worker['configurations']] + return { + 'ok': True, + 'supported': True, + 'scope': 'reporting_workers' if cluster_available else 'current_worker', + 'clusterAvailable': cluster_available, + 'currentWorkerId': self.metrics.worker_id, + 'observedWorkers': len(workers), + 'sampleIntervalSeconds': METRICS_PUBLISH_SECONDS, + 'sampleTtlSeconds': METRICS_TTL_SECONDS, + 'invalidSnapshots': invalid, + 'staleSnapshots': stale, + 'workerLimitReached': limited, + 'latencyScope': 'complete_request_or_job', + 'workers': workers, + 'series': aggregate_metric_snapshots(snapshots, plugin_id), + } diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/config.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/config.py index e13cd48..92cd6e7 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/service/config.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/config.py @@ -46,7 +46,7 @@ class PluginConfigUseCase: :return: 插件配置负载 """ try: - discovered_plugin = self._get_discovered_plugin(plugin_id) + discovered_plugin = await self.context.get_readonly_discovered_plugin(plugin_id) if not discovered_plugin: return PluginPayloadBuilder.build_plugin_not_found_payload(plugin_id) @@ -96,7 +96,7 @@ class PluginConfigUseCase: :return: 插件配置更新负载 """ try: - discovered_plugin = self._get_discovered_plugin(plugin_id) + discovered_plugin = await self.context.get_readonly_discovered_plugin(plugin_id) if not discovered_plugin: return PluginPayloadBuilder.build_plugin_not_found_payload(plugin_id) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/context.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/context.py index c6d5115..8d06e0f 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/service/context.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/context.py @@ -2,13 +2,14 @@ import asyncio from pathlib import Path from time import monotonic -from plugins.core.capability import PluginRuntimeCapability, PluginRuntimeCapabilityResolver +from plugins.core.capability import STATE_CHANGE_OPERATIONS, PluginRuntimeCapability, PluginRuntimeCapabilityResolver from plugins.core.discovery.registry import PluginRegistry from plugins.core.discovery.scanner import ( DiscoveredPlugin, PluginDiscoveryError, PluginDiscoveryResult, PluginScanner, + is_artifact_plugin, ) from plugins.core.lifecycle.migration import PluginMigrationRunner from plugins.core.lifecycle.precheck import PluginLifecycleScriptPrechecker @@ -27,6 +28,7 @@ from plugins.core.validation.structure import PluginStructureChecker from utils.log_util import logger from .dependency_container import PluginRuntimeDependencies +from .gateway import UnavailablePluginStateQueryGateway from .migration_store import PluginMigrationHistoryGatewayStore from .responses import PluginRuntimeBlockedPayload, PluginRuntimeBlockedPayloadDict @@ -51,6 +53,19 @@ class PluginRuntimeContextService: self.dependencies = dependencies self._discovered_plugins_cache: dict[Path, tuple[float, list[DiscoveredPlugin]]] = {} self._discovery_errors_cache: dict[Path, list[PluginDiscoveryError]] = {} + self._discovery_override: PluginDiscoveryResult | None = None + + def set_discovery_snapshot(self, plugins: list[DiscoveredPlugin]) -> None: + """ + 维护流程注入已验证目录,不修改全局扫描器或运行中进程的发现缓存。 + + :param plugins: 维护流程已验证的插件发现快照 + :return: None + """ + ids = [plugin.manifest.id for plugin in plugins] + if len(ids) != len(set(ids)): + raise ValueError('插件发现快照包含重复 ID') + self._discovery_override = PluginDiscoveryResult(plugins=list(plugins)) def build_registry(self) -> PluginRegistry: """ @@ -102,6 +117,102 @@ class PluginRuntimeContextService: logger.exception(f'读取数据库插件状态失败:{plugin_id},{exc}') return None, str(exc) + async def guard_artifact_operation( + self, plugin_id: str, operation: str, *, dry_run: bool = False, check_capability: bool = True + ) -> PluginRuntimeBlockedPayloadDict | None: + """ + 数据库来源是最终依据,缺少源码目录不能授权清理已发布制品。 + + :param plugin_id: 待操作的插件 ID + :param operation: 待执行的生命周期操作名称 + :param dry_run: 是否仅预览操作 + :param check_capability: 是否同时检查已发现插件的运行时管理能力 + :return: 制品操作拦截结果,允许操作时返回 None + """ + if self.dependencies.runtime_environment.get_backend_runtime_mode() == 'maintenance': + return None + discovered = self.get_discovered_plugin(plugin_id) + if discovered is not None and check_capability: + blocked = self.build_operation_blocked_payload(discovered, operation, dry_run=dry_run) + if blocked: + return blocked + if dry_run and isinstance(self.dependencies.state_query_gateway, UnavailablePluginStateQueryGateway): + return None + plugin, error = await self.load_database_plugin_state(plugin_id) + artifact = getattr(plugin, 'source', None) == 'artifact' or is_artifact_plugin(discovered) + if not artifact and not error: + return None + message = ( + '签名制品插件只能通过维护发布命令变更,完成后重启全部worker' + if artifact + else '无法确认插件来源,已阻止生命周期变更,请恢复插件状态查询后重试' + ) + return self._artifact_operation_blocked_payload(plugin_id, operation, message, dry_run=dry_run) + + def build_dependency_install_event_loop_blocked_payload(self, plugin_id: str) -> PluginRuntimeBlockedPayloadDict: + """ + 已有事件循环时,同步依赖安装不能另建循环查询同一个数据库连接池。 + + :param plugin_id: 待安装依赖的插件 ID + :return: 阻止在已有事件循环内同步安装依赖的结果载荷 + """ + return self._artifact_operation_blocked_payload( + plugin_id, + 'dependency_install', + '当前事件循环不能同步确认插件来源,请使用维护CLI执行依赖安装', + ) + + @staticmethod + def _artifact_operation_blocked_payload( + plugin_id: str, operation: str, message: str, *, dry_run: bool = False + ) -> PluginRuntimeBlockedPayloadDict: + """ + 构建制品生命周期操作的统一拦截结果。 + + :param plugin_id: 待操作的插件 ID + :param operation: 被拦截的操作名称 + :param message: 当前操作的拦截原因 + :param dry_run: 是否仅预览操作 + :return: 包含操作能力及维护建议的拦截结果载荷 + """ + return PluginRuntimeBlockedPayload( + ok=False, + status='blocked', + operation=operation, + plugin_id=plugin_id, + message=message, + suggestion='请使用 plugin release 命令,并在执行前停止全部宿主worker。', + capability={ + 'pluginId': plugin_id, + 'backendRuntimeManageable': False, + 'runtimeManageable': False, + 'blockedOperations': sorted(STATE_CHANGE_OPERATIONS), + 'warnings': [message], + 'primaryReason': message, + }, + dry_run=dry_run, + exit_code=1, + ).to_payload(exclude_none=True) + + async def get_readonly_discovered_plugin(self, plugin_id: str) -> DiscoveredPlugin | None: + """ + 配置读取可解析已选且重新验签的制品清单,不执行任何入口。 + + :param plugin_id: 待读取配置的插件 ID + :return: 已发现源码插件或重新验签的当前制品,未找到时返回 None + """ + if isinstance(self.dependencies.state_query_gateway, UnavailablePluginStateQueryGateway): + return self.get_discovered_plugin(plugin_id) + plugin, error = await self.load_database_plugin_state(plugin_id) + if error: + raise ValueError('无法确认插件来源,配置读取已中止') + if getattr(plugin, 'source', None) != 'artifact': + return self.get_discovered_plugin(plugin_id) + resolver = getattr(self.dependencies.state_query_gateway, 'get_selected_artifact_plugin', None) + if not callable(resolver): + raise ValueError('插件状态适配器未提供已选制品的只读解析能力') + return await resolver(plugin_id, Path(self.dependencies.runtime_environment.get_backend_dir())) + async def load_database_plugin_states_with_error(self) -> tuple[list[PluginStateRecord], str | None]: """ 读取数据库插件状态列表,并保留失败原因。 @@ -360,6 +471,8 @@ class PluginRuntimeContextService: :param backend_root: 后端项目根目录 :return: 插件发现结果 """ + if self._discovery_override is not None: + return PluginDiscoveryResult(plugins=list(self._discovery_override.plugins)) resolved_backend_root = backend_root.resolve() cached_entry = self._discovered_plugins_cache.get(resolved_backend_root) if cached_entry is not None: diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/facade.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/facade.py index 171a324..50ce7e5 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/service/facade.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/facade.py @@ -115,7 +115,6 @@ class PluginRuntimeService: ) ) self.lifecycle_lock = lifecycle_lock or NoopPluginLifecycleLock() - self._background_audit_tasks: set[asyncio.Task[None]] = set() def _replace_dependencies(self, dependencies: PluginRuntimeDependencies) -> None: """ @@ -462,54 +461,37 @@ class PluginRuntimeService: :param output_callback: 依赖安装实时输出回调 :return: 插件依赖安装负载 """ - if output_callback is None: - dependency_payload = installer( - plugin_id, - dry_run=dry_run, - policy_config=policy_config, - confirmed=confirmed, - ) - else: - dependency_payload = installer( + + def install_dependencies() -> PluginDependencyInstallResponse: + if output_callback is None: + return installer(plugin_id, dry_run=dry_run, policy_config=policy_config, confirmed=confirmed) + return installer( plugin_id, dry_run=dry_run, policy_config=policy_config, confirmed=confirmed, output_callback=output_callback, ) - payload = cast('PluginDependencyInstallResponse', dependency_payload) - if record_operation_log and not dry_run: - self._record_plugin_operation_log_sync(payload, dry_run=False, continue_on_error=False) - return payload - def _record_plugin_operation_log_sync( - self, - payload: Mapping[str, object], - *, - dry_run: bool, - continue_on_error: bool, - ) -> None: - """ - 从同步入口记录插件操作审计日志。 + async def execute_with_source_check() -> PluginDependencyInstallResponse: + # 来源查询与审计共用同一个循环,避免异步连接池复用已关闭loop的连接。 + blocked = await self.context.guard_artifact_operation( + plugin_id, 'dependency_install', check_capability=False + ) + if blocked: + return blocked + payload = install_dependencies() + if record_operation_log: + await self.record_plugin_operation_log(payload, dry_run=False, continue_on_error=False) + return payload - :param payload: 操作结果负载 - :param dry_run: 是否预演 - :param continue_on_error: 失败后是否继续 - :return: None - """ - record_coro = self.record_plugin_operation_log( - payload, - dry_run=dry_run, - continue_on_error=continue_on_error, - ) + if dry_run: + return install_dependencies() try: - running_loop = asyncio.get_running_loop() + asyncio.get_running_loop() except RuntimeError: - asyncio.run(record_coro) - return - task = running_loop.create_task(record_coro) - self._background_audit_tasks.add(task) - task.add_done_callback(self._background_audit_tasks.discard) + return asyncio.run(execute_with_source_check()) + return self.context.build_dependency_install_event_loop_blocked_payload(plugin_id) def install_plugin_dependencies_from_result( self, diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/enable.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/enable.py index 338dc56..0a38053 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/enable.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/enable.py @@ -176,6 +176,11 @@ class PluginEnableUseCase(PluginLifecycleUseCaseSupport): :param record_operation_log: 是否记录插件操作审计日志 :return: 插件启停结果负载 """ + blocked = await self.context.guard_artifact_operation( + plugin_id, 'enable' if enabled else 'disable', dry_run=dry_run + ) + if blocked: + return blocked payload = await self._set_plugin_enabled(plugin_id, enabled=enabled, dry_run=dry_run) payload_view = cast('dict[str, object]', payload) payload_view['operation'] = 'enable' if enabled else 'disable' @@ -438,6 +443,9 @@ class PluginEnableUseCase(PluginLifecycleUseCaseSupport): :param operated_by: 操作者用户名,非预演时写入审计日志 :return: 插件卸载结果负载 """ + blocked = await self.context.guard_artifact_operation(plugin_id, 'uninstall', dry_run=dry_run) + if blocked: + return blocked result = await self._uninstall_plugin(plugin_id, dry_run=dry_run) result = PluginEnablePayloadBuilder.build_uninstall_payload(cast('dict[str, object]', result), dry_run=dry_run) result_view = cast('dict[str, object]', result) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/install.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/install.py index 3db74de..722d0a7 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/install.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/install.py @@ -86,6 +86,9 @@ class PluginInstallUseCase(PluginLifecycleUseCaseSupport): :param operated_by: 操作者用户名,非预演时写入审计日志 :return: 插件安装结果负载 """ + blocked = await self.context.guard_artifact_operation(plugin_id, 'install', dry_run=dry_run) + if blocked: + return blocked payload = await self._install_plugin(plugin_id, dry_run=dry_run) payload_view = cast('dict[str, object]', payload) payload_view['operation'] = 'install' diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/purge.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/purge.py index d9e3c7a..64506db 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/purge.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/purge.py @@ -98,6 +98,9 @@ class PluginPurgeUseCase(PluginLifecycleUseCaseSupport): :param operated_by: 操作者用户名,非预演时写入审计日志 :return: 插件物理清理结果负载 """ + blocked = await self.context.guard_artifact_operation(plugin_id, 'purge', dry_run=dry_run) + if blocked: + return blocked payload = await self._purge_plugin(plugin_id, dry_run=dry_run) payload_view = cast('dict[str, object]', payload) payload_view['operation'] = 'purge' diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/upgrade.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/upgrade.py index 2bb2c57..0d923d5 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/upgrade.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/upgrade.py @@ -98,6 +98,9 @@ class PluginUpgradeUseCase(PluginLifecycleUseCaseSupport): :param operated_by: 操作者用户名,非预演时写入审计日志 :return: 插件升级结果负载 """ + blocked = await self.context.guard_artifact_operation(plugin_id, 'upgrade', dry_run=dry_run) + if blocked: + return blocked payload = await self._upgrade_plugin(plugin_id, dry_run=dry_run) payload_view = cast('dict[str, object]', payload) payload_view['operation'] = 'upgrade' diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle_lock.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle_lock.py index 9f28dd9..33141c6 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle_lock.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle_lock.py @@ -1,6 +1,6 @@ import asyncio -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager, suppress +from collections.abc import AsyncGenerator +from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress from dataclasses import dataclass from typing import Protocol from uuid import uuid4 @@ -43,7 +43,7 @@ class PluginLifecycleLock(Protocol): 插件生命周期操作锁接口。 """ - def lock(self, plugin_id: str, operation: str) -> AsyncIterator[PluginLifecycleLockResult]: + def lock(self, plugin_id: str, operation: str) -> AbstractAsyncContextManager[PluginLifecycleLockResult]: """ 获取插件生命周期操作锁。 @@ -59,7 +59,7 @@ class NoopPluginLifecycleLock: """ @asynccontextmanager - async def lock(self, plugin_id: str, operation: str) -> AsyncIterator[PluginLifecycleLockResult]: + async def lock(self, plugin_id: str, operation: str) -> AsyncGenerator[PluginLifecycleLockResult, None]: """ 返回已获取锁结果。 @@ -101,7 +101,7 @@ class RedisPluginLifecycleLock: self.expire_seconds = expire_seconds or LockConstant.PLUGIN_LIFECYCLE_LOCK_EXPIRE_SECONDS @asynccontextmanager - async def lock(self, plugin_id: str, operation: str) -> AsyncIterator[PluginLifecycleLockResult]: + async def lock(self, plugin_id: str, operation: str) -> AsyncGenerator[PluginLifecycleLockResult, None]: """ 获取插件生命周期操作锁。 diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/query.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/query.py index 69370c6..8d5469a 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/service/query.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/query.py @@ -377,6 +377,13 @@ class PluginQueryUseCase: """ info_payload = cast('dict[str, object]', await self.get_plugin_info_with_state(plugin_id)) if not info_payload.get('ok', False): + database_plugin, database_error = await self._load_database_plugin_state(plugin_id) + if ( + database_error is None + and getattr(database_plugin, 'source', None) == 'artifact' + and self._get_discovered_plugin(plugin_id) is None + ): + return await self._diagnose_database_artifact(plugin_id, database_plugin) return PluginRuntimePayloadBuilder.build_diagnose_failure_payload(plugin_id, info_payload) check_payload = cast('dict[str, object]', await self.check_plugin_async(plugin_id)) @@ -401,6 +408,40 @@ class PluginQueryUseCase: audit_payload=audit_payload, ) + async def _diagnose_database_artifact(self, plugin_id: str, plugin: PluginStateRecord) -> PluginDiagnoseResponse: + """ + 源码扫描不可见的制品仍保留数据库诊断,且不导入制品代码或猜测配置。 + + :param plugin_id: 待诊断的制品插件 ID + :param plugin: 已从数据库读取的插件状态 + :return: 明确标出静态检查不可用的数据库诊断包 + """ + unavailable = { + 'ok': False, + 'supported': False, + 'scope': 'database_only', + 'message': '当前诊断进程未发现制品 manifest,请结合发布与运行快照查看实际状态', + } + return PluginRuntimePayloadBuilder.build_diagnose_payload( + plugin_id, + info_payload={ + 'ok': True, + 'plugin': { + 'pluginId': plugin_id, + 'source': 'artifact', + 'scope': 'database_only', + 'installedVersion': plugin.installed_version, + 'status': plugin.status, + 'lastError': plugin.last_error, + 'database': {'available': True, 'enabled': plugin.enabled}, + }, + }, + check_payload={**unavailable, 'checks': []}, + menu_plan={**PluginRuntimePayloadBuilder.build_empty_menu_plan(), 'supported': False}, + config_payload={**unavailable, 'configs': []}, + audit_payload=await self._build_recent_audit_snapshot(plugin_id), + ) + async def _build_recent_audit_snapshot( self, plugin_id: str, *, audit_limit: int = 5 ) -> PluginAuditSnapshotResponse: diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/startup.py b/ruoyi-fastapi-backend/plugins/core/runtime/startup.py index 48966ff..e23f53f 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/startup.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/startup.py @@ -11,6 +11,7 @@ from common.router import auto_register_controller_files from config.database import DataSourceRegistry from config.env import AppConfig, get_config from plugins.core.discovery.registry import PluginRegistry, RegisteredPlugin +from plugins.core.discovery.scanner import is_artifact_plugin from plugins.core.lifecycle.migration import ( PluginMigrationHistoryRecord, PluginMigrationHistoryStore, @@ -18,6 +19,7 @@ from plugins.core.lifecycle.migration import ( ) from plugins.core.lifecycle.seed import PluginSeedRunner from plugins.core.runtime.bootstrap import PluginRuntimeBuilder +from plugins.core.runtime.explicit import ExplicitPluginRuntime from plugins.core.runtime.hooks import PluginHookRunner from plugins.core.runtime.route_guard import ( PluginEnabledDependency, @@ -287,12 +289,99 @@ class PluginRuntimeStartupManager: default_dependency_failed_plugin_ids | dependency_failed_plugin_ids ) self.disable_runtime_plugins(app, dependency_failed_plugin_ids) + await self.prepare_explicit_plugins(app, startup_write_enabled=startup_write_enabled) import_failed_plugin_ids = await self.import_enabled_plugin_entities( app, startup_write_enabled=startup_write_enabled, ) self.disable_runtime_plugins(app, import_failed_plugin_ids) + async def prepare_explicit_plugins(self, app: FastAPI, *, startup_write_enabled: bool) -> None: + """ + v2 在建表前导入入口及显式模型,每个应用保存自己的实例。 + + :param app: 宿主 FastAPI 应用 + :param startup_write_enabled: 当前 worker 是否允许执行启动期全局写入 + :return: None + """ + registry = getattr(app.state, 'plugin_registry', None) + if not isinstance(registry, PluginRegistry): + return + plugins, errors = ExplicitPluginRuntime.dependency_order(registry) + if not plugins and not errors: + return + runtime = ExplicitPluginRuntime(self.route_state_gateway) + app.state.plugin_explicit_runtime = runtime + for plugin in plugins: + if not plugin.discovered_plugin.manifest.uses_entrypoint: + continue + try: + failed_dependencies = [ + dep.id for dep in plugin.discovered_plugin.manifest.dependencies.plugins if dep.id in errors + ] + if failed_dependencies: + raise ValueError(f'插件依赖准备失败:{", ".join(failed_dependencies)}') + config_values = await self.load_explicit_plugin_config(plugin) + runtime.prepare(plugin, app, startup_write_enabled=startup_write_enabled, config_values=config_values) + except Exception as exc: + errors[plugin.plugin_id] = str(exc) + for plugin_id, message in errors.items(): + logger.error(f'插件显式入口准备失败:{plugin_id},{message}') + if startup_write_enabled: + await self.mark_plugin_runtime_error(app, plugin_id, message) + self.disable_runtime_plugins(app, set(errors)) + + @staticmethod + async def load_explicit_plugin_config(plugin: RegisteredPlugin) -> dict[str, Any]: + """ + 仅向插件注入自己的配置明文快照,读取沿用原有解密和默认值规则。 + + :param plugin: 待读取配置的注册插件 + :return: 按清单默认值和数据库配置合并的插件专属配置快照 + """ + if not plugin.discovered_plugin.manifest.config.items: + return {} + from plugins.core.management.service.service import PluginService # noqa: PLC0415 + + async with DataSourceRegistry.session() as db: + values = await PluginService.get_plugin_config_services(db, plugin.discovered_plugin, reveal_secret=True) + return {item.key: item.value for item in values} + + async def activate_explicit_plugins(self, app: FastAPI, *, startup_write_enabled: bool) -> None: + """ + 子应用必须完成 startup 后挂载,失败不会留下可访问的半初始化路由。 + + :param app: 宿主 FastAPI 应用 + :param startup_write_enabled: 当前 worker 是否允许执行启动期全局写入 + :return: None + """ + runtime = getattr(app.state, 'plugin_explicit_runtime', None) + if not isinstance(runtime, ExplicitPluginRuntime): + return + registry = getattr(app.state, 'plugin_registry', None) + enabled = {plugin.plugin_id for plugin in registry.list_enabled_plugins()} + failures: set[str] = set() + try: + for plugin_id, loaded in runtime.loaded.items(): + if plugin_id not in enabled: + continue + try: + for dependency in loaded.plugin.discovered_plugin.manifest.dependencies.plugins: + if dependency.id not in enabled or dependency.id in failures: + raise ValueError(f'插件依赖激活失败:{dependency.id}') + await runtime.activate(plugin_id, app) + except Exception as exc: + failures.add(plugin_id) + logger.exception(f'插件显式入口激活失败:{plugin_id}') + if startup_write_enabled: + await self.mark_plugin_runtime_error(app, plugin_id, str(exc)) + except BaseException: + # 整体启动取消或错误持久化失败时,主应用尚未标记 startup 完成。 + # 此处必须释放已经启动的子应用,不能依赖后续主应用 shutdown。 + await runtime.shutdown() + raise + self.disable_runtime_plugins(app, failures) + async def requires_startup_write(self) -> bool: """ 判断当前数据库状态是否要求重新执行启动期全局写入。 @@ -309,7 +398,7 @@ class PluginRuntimeStartupManager: discovered_plugin_ids = { plugin.manifest.id for plugin in self.builder.discover_plugins() - if plugin.manifest.id in self.default_enabled_builtin_plugin_ids + if plugin.manifest.id in self.default_enabled_builtin_plugin_ids and not is_artifact_plugin(plugin) } if not discovered_plugin_ids: return False @@ -351,7 +440,11 @@ class PluginRuntimeStartupManager: dependency_result = self._check_plugin_python_dependencies(plugin.plugin_id, python_requirements) failed_messages = self._build_dependency_failed_messages(dependency_result) if not failed_messages: - if startup_write_enabled and self._has_startup_dependency_error(plugin): + if ( + startup_write_enabled + and not is_artifact_plugin(plugin.discovered_plugin) + and self._has_startup_dependency_error(plugin) + ): recovered_plugins.append(plugin) continue failed_plugin_ids.add(plugin.plugin_id) @@ -570,6 +663,7 @@ class PluginRuntimeStartupManager: await self.install_enabled_plugin_resources(app) await self.run_enabled_plugin_hooks(app, 'on_startup', startup_write_enabled=startup_write_enabled) self.register_enabled_plugin_routers(app, startup_write_enabled=startup_write_enabled) + await self.activate_explicit_plugins(app, startup_write_enabled=startup_write_enabled) async def shutdown(self, app: FastAPI, *, startup_write_enabled: bool = True) -> None: """ @@ -579,7 +673,12 @@ class PluginRuntimeStartupManager: :param startup_write_enabled: 是否允许执行启动期写库操作 :return: None """ - await self.run_enabled_plugin_hooks(app, 'on_shutdown', startup_write_enabled=startup_write_enabled) + runtime = getattr(app.state, 'plugin_explicit_runtime', None) + try: + if isinstance(runtime, ExplicitPluginRuntime): + await runtime.shutdown() + finally: + await self.run_enabled_plugin_hooks(app, 'on_shutdown', startup_write_enabled=startup_write_enabled) async def load_registry_from_database(self, app: FastAPI) -> None: """ @@ -591,6 +690,12 @@ class PluginRuntimeStartupManager: async with DataSourceRegistry.session() as query_db: plugin_list = await self.management_gateway.list_plugins(query_db) app.state.plugin_registry = self.builder.build_registry(plugin_list) + # 源码插件错误/安装同步也会重建注册表,不能因此重新启用本进程已失败的制品。 + isolated_ids = set(getattr(app.state, 'plugin_artifact_failed_ids', set())) + worker_errors = getattr(getattr(app.state, 'plugin_release_worker', None), 'errors', {}) + if isinstance(worker_errors, dict): + isolated_ids.update(worker_errors) + self.disable_runtime_plugins(app, isolated_ids) async def import_enabled_plugin_entities( self, @@ -640,6 +745,8 @@ class PluginRuntimeStartupManager: plugins = list(plugin_registry.list_enabled_plugins()) for plugin in plugins: + if is_artifact_plugin(plugin.discovered_plugin): + continue await self.install_plugin_resources_with_isolation(app, plugin) async def install_plugin_resources_with_isolation( @@ -693,7 +800,7 @@ class PluginRuntimeStartupManager: plugins_to_sync = [ plugin for plugin in plugin_registry.list_enabled_plugins() - if self._should_sync_plugin_install_state(plugin) + if not is_artifact_plugin(plugin.discovered_plugin) and self._should_sync_plugin_install_state(plugin) ] if not plugins_to_sync: return @@ -970,7 +1077,7 @@ class PluginRuntimeStartupManager: await PluginHookRunner(plugin.discovered_plugin).run( hook_name, app=app, - startup_write_enabled=startup_write_enabled, + startup_write_enabled=startup_write_enabled and not is_artifact_plugin(plugin.discovered_plugin), ) except Exception as exc: logger.bind( @@ -995,6 +1102,13 @@ class PluginRuntimeStartupManager: :param error_message: 错误信息 :return: None """ + registered_plugin = self.get_registered_plugin(app, plugin_id) + if registered_plugin and is_artifact_plugin(registered_plugin.discovered_plugin): + worker = getattr(app.state, 'plugin_release_worker', None) + if worker is not None: + worker.errors[plugin_id] = error_message[:2000] + self.disable_runtime_plugins(app, {plugin_id}) + return async with DataSourceRegistry.session() as query_db: result = await self.management_gateway.mark_plugin_error(query_db, plugin_id, error_message) if not result.is_success: @@ -1030,7 +1144,7 @@ class PluginRuntimeStartupManager: discovered_plugins = [ plugin for plugin in self.builder.discover_plugins() - if plugin.manifest.id in self.default_enabled_builtin_plugin_ids + if plugin.manifest.id in self.default_enabled_builtin_plugin_ids and not is_artifact_plugin(plugin) ] if not discovered_plugins: return set() @@ -1144,6 +1258,13 @@ class PluginRuntimeStartupManager: plugin_registry = getattr(app.state, 'plugin_registry', None) if plugin_registry is None: return + isolated_ids = set(getattr(app.state, 'plugin_artifact_failed_ids', set())) + isolated_ids.update( + plugin.plugin_id + for plugin in plugin_registry.list_plugins() + if plugin.plugin_id in plugin_ids and is_artifact_plugin(plugin.discovered_plugin) + ) + app.state.plugin_artifact_failed_ids = isolated_ids app.state.plugin_registry = PluginRegistry( [ replace(plugin, enabled=False, status='error') if plugin.plugin_id in plugin_ids else plugin diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/startup_coordination.py b/ruoyi-fastapi-backend/plugins/core/runtime/startup_coordination.py index bcb0119..e8cdc6e 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/startup_coordination.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/startup_coordination.py @@ -2,8 +2,12 @@ from hashlib import sha256 from pathlib import Path from config.env import AppConfig +from plugins.core.discovery.scanner import PluginScanner -PLUGIN_STARTUP_FINGERPRINT_SUFFIXES = frozenset({'.py', '.sql', '.yaml', '.yml'}) +PLUGIN_STARTUP_FINGERPRINT_SUFFIXES = frozenset( + {'.py', '.sql', '.yaml', '.yml', '.pyd', '.so', '.whl', '.dll', '.dylib'} +) +FINGERPRINT_CHUNK_SIZE = 1024 * 1024 class PluginStartupGenerationResolver: @@ -43,18 +47,46 @@ class PluginStartupGenerationResolver: if not plugins_root.is_dir(): return digest.hexdigest()[:24] + # 开发示例及其构建缓存不参与宿主发布代际,也不递归遍历该目录。 + source_roots = (path for path in plugins_root.iterdir() if path.name != 'examples') source_files = ( path - for path in plugins_root.rglob('*') + for source_root in source_roots + for path in ( + source_root.rglob('*') if source_root.is_dir() and not source_root.is_symlink() else (source_root,) + ) if path.is_file() and path.suffix.lower() in PLUGIN_STARTUP_FINGERPRINT_SUFFIXES and '__pycache__' not in path.parts ) - for source_file in sorted(source_files): + files = set(source_files) + # 在不可变制品索引接入前,按清单精确覆盖 bundle 和 native 安装目录。 + # 不把上传数据、日志、pycache 等可变内容计入发布代际。 + for plugin in PluginScanner(plugins_root).discover_with_errors().plugins: + if not plugin.manifest.uses_entrypoint: + continue + resource_dirs = [] + if plugin.manifest.runtime_kind == 'native': + resource_dirs.append(plugin.backend_path / plugin.manifest.backend.native.module_root) + if plugin.manifest.frontend.delivery.type == 'bundle': + resource_dirs.append(plugin.backend_path / plugin.manifest.frontend.bundle.directory) + for resource_dir in resource_dirs: + if not resource_dir.resolve().is_relative_to(plugin.backend_path.resolve()): + continue + files.update( + path + for path in resource_dir.rglob('*') + if path.is_file() + and '__pycache__' not in path.parts + and path.resolve().is_relative_to(plugin.backend_path.resolve()) + ) + for source_file in sorted(files): relative_path = source_file.relative_to(self.backend_root).as_posix() digest.update(relative_path.encode()) digest.update(b'\0') - digest.update(source_file.read_bytes()) + with source_file.open('rb') as stream: + for chunk in iter(lambda: stream.read(FINGERPRINT_CHUNK_SIZE), b''): + digest.update(chunk) digest.update(b'\0') return digest.hexdigest()[:24] diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/catalog.py b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/catalog.py index b528cc5..b457139 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/catalog.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/catalog.py @@ -226,6 +226,7 @@ class PluginCatalogPayloadMixin: :param plugin: 已注册插件运行时快照或已发现插件 :param dependency_items: 依赖检查项列表 :param database_error: 数据库状态读取错误信息 + :param capability: 插件运行时管理能力及操作限制 :return: 插件详情负载 """ discovered_plugin = plugin.discovered_plugin if isinstance(plugin, RegisteredPlugin) else plugin @@ -247,6 +248,11 @@ class PluginCatalogPayloadMixin: 'database': cls.build_database_state(database_plugin, database_error), 'backend': { 'module': manifest.backend.module, + 'manifestVersion': manifest.manifest_version, + 'runtime': manifest.runtime_kind, + 'integration': manifest.integration_kind, + 'entrypoint': getattr(manifest.backend, 'entrypoint', None), + 'mountPath': getattr(getattr(manifest.backend, 'asgi', None), 'mount_path', None), 'autoScanRouters': manifest.backend.routers.auto_scan, 'migrations': manifest.backend.migrations, 'seeds': manifest.backend.seeds, diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/task_cleanup.py b/ruoyi-fastapi-backend/plugins/core/runtime/task_cleanup.py new file mode 100644 index 0000000..d25c9a9 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/task_cleanup.py @@ -0,0 +1,60 @@ +import asyncio +import math +from collections.abc import Iterable +from contextlib import suppress +from typing import Any + +CANCEL_TIMEOUT_SECONDS = 5.0 +_unfinished_tasks: set[asyncio.Task[Any]] = set() + + +def pending_plugin_tasks(tasks: Iterable[asyncio.Task[Any]] | None = None) -> frozenset[asyncio.Task[Any]]: + """ + 读取取消后仍未退出的宿主任务,可限制到已知插件任务,避免推测任务归属。 + + :param tasks: 可选的插件所属任务集合,省略时读取当前 worker 的通用回收集合 + :return: 尚未退出的任务只读快照,不包含已完成任务且不修改回收状态 + """ + selected = _unfinished_tasks if tasks is None else _unfinished_tasks.intersection(tasks) + return frozenset(task for task in selected if not task.done()) + + +def _finish_task(task: asyncio.Task[Any]) -> None: + """ + 回收延迟退出的任务及其异常,避免丢失后台任务引用。 + + :param task: 已经结束的插件任务 + :return: None + """ + _unfinished_tasks.discard(task) + with suppress(asyncio.CancelledError, Exception): + task.result() + + +async def cancel_plugin_tasks( + tasks: Iterable[asyncio.Task[Any]], *, timeout: float = CANCEL_TIMEOUT_SECONDS +) -> set[asyncio.Task[Any]]: + """ + 并发取消并限时等待;等待者被取消时也保留未退出任务。 + + :param tasks: 本次需要回收的任务,不取消调用者自身 + :param timeout: 整批任务允许的取消等待秒数 + :return: 等待结束后仍未退出的任务 + """ + if not math.isfinite(timeout) or timeout <= 0: + raise ValueError('任务取消等待时间必须大于零') + selected = set(tasks) - {asyncio.current_task()} + for task in selected: + if not task.done(): + task.cancel() + try: + if selected: + await asyncio.wait(selected, timeout=timeout) + finally: + for task in selected: + if task.done(): + _finish_task(task) + elif task not in _unfinished_tasks: + _unfinished_tasks.add(task) + task.add_done_callback(_finish_task) + return {task for task in selected if not task.done()} diff --git a/ruoyi-fastapi-backend/plugins/core/sdk/__init__.py b/ruoyi-fastapi-backend/plugins/core/sdk/__init__.py new file mode 100644 index 0000000..171b5a2 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/sdk/__init__.py @@ -0,0 +1,19 @@ +from plugins.core.sdk.asgi import plugin_endpoint, plugin_lifespan +from plugins.core.sdk.config import PluginConfigSnapshot +from plugins.core.sdk.context import PluginHostContext, PluginRequestContext, PluginTaskContext +from plugins.core.sdk.definition import PluginDefinition, await_plugin_callback +from plugins.core.sdk.request import PluginHttpRequest +from plugins.core.sdk.version import HOST_API_VERSION + +__all__ = [ + 'HOST_API_VERSION', + 'PluginConfigSnapshot', + 'PluginDefinition', + 'PluginHostContext', + 'PluginHttpRequest', + 'PluginRequestContext', + 'PluginTaskContext', + 'await_plugin_callback', + 'plugin_endpoint', + 'plugin_lifespan', +] diff --git a/ruoyi-fastapi-backend/plugins/core/sdk/asgi.py b/ruoyi-fastapi-backend/plugins/core/sdk/asgi.py new file mode 100644 index 0000000..ebe6147 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/sdk/asgi.py @@ -0,0 +1,148 @@ +import asyncio +import json +from collections.abc import AsyncGenerator, Callable, Mapping +from contextlib import asynccontextmanager +from dataclasses import replace +from typing import Any + +from fastapi import FastAPI, HTTPException, Request +from fastapi.encoders import jsonable_encoder +from pydantic import BaseModel, ValidationError + +from plugins.core.sdk.context import PluginHostContext, PluginRequestContext +from plugins.core.sdk.definition import await_plugin_callback +from plugins.core.sdk.request import PluginHttpRequest, current_plugin_request_id + +DEFAULT_PLUGIN_BODY_LIMIT = 1024 * 1024 + + +async def _read_json_body(request: Request, limit: int, model: type[BaseModel] | None) -> Any: + """ + 按实际接收字节数限制 JSON 请求体,并可选地执行业务模型校验。 + + :param request: 当前 HTTP 请求 + :param limit: 请求体允许的最大字节数 + :param model: 可选的 Pydantic 请求体模型 + :return: JSON 请求体或模型校验后的 JSON 数据 + :raises HTTPException: 请求类型、体积或内容不符合要求 + """ + content_type = request.headers.get('content-type', '').partition(';')[0].strip().lower() + if content_type != 'application/json' and not ( + content_type.startswith('application/') and content_type.endswith('+json') + ): + raise HTTPException(status_code=415, detail='插件接口需要 JSON 请求体') + body = bytearray() + async for chunk in request.stream(): + if len(body) + len(chunk) > limit: + raise HTTPException(status_code=413, detail='插件请求体超过大小限制') + body.extend(chunk) + try: + # allow_nan=False 的往返校验拒绝 NaN/Infinity,保持跨语言 JSON 契约。 + value = json.loads(body) + json.dumps(value, allow_nan=False) + except (ValueError, UnicodeError, RecursionError) as exc: + raise HTTPException(status_code=400, detail='插件请求体不是有效 JSON') from exc + if model is not None: + try: + value = model.model_validate(value).model_dump(mode='json') + except ValidationError as exc: + raise HTTPException( + status_code=422, + detail=exc.errors(include_input=False, include_context=False, include_url=False), + ) from exc + return value + + +def plugin_endpoint( + callback: Callable[..., Any], + *, + permission: str, + timeout: float = 30.0, + json_body: bool = False, + body_model: type[BaseModel] | None = None, + max_body_bytes: int = DEFAULT_PLUGIN_BODY_LIMIT, +) -> Callable[..., Any]: + """ + 原生函数只接收已鉴权的上下文,返回 awaitable;不让 FastAPI 反射原生签名。 + + :param callback: 接收插件请求上下文并返回可等待对象的业务回调 + :param permission: 调用业务回调要求的接口权限标识 + :param timeout: 业务回调等待超时时间,单位为秒 + :param json_body: 是否显式读取 JSON 请求体,默认只提供路径和查询参数 + :param body_model: 可选的请求体校验模型,设置后自动读取 JSON 请求体 + :param max_body_bytes: JSON 请求体最大字节数,默认 1 MiB + :return: 具有固定 Request 参数的 FastAPI 端点函数 + """ + + if type(max_body_bytes) is not int or max_body_bytes <= 0: + raise ValueError('插件请求体大小限制必须是正整数') + if body_model is not None and (not isinstance(body_model, type) or not issubclass(body_model, BaseModel)): + raise TypeError('插件请求体模型必须是 Pydantic BaseModel 子类') + + async def endpoint(request: Request) -> Any: + context = getattr(request.state, 'plugin_context', None) + if not isinstance(context, PluginRequestContext): + raise HTTPException(status_code=401, detail='缺少宿主插件请求上下文') + try: + context.require_permission(permission) + body = ( + await asyncio.wait_for(_read_json_body(request, max_body_bytes, body_model), timeout=timeout) + if json_body or body_model + else None + ) + request_data = PluginHttpRequest( + method=request.method, + path=request.url.path, + path_params=jsonable_encoder(request.path_params), + query={key: tuple(request.query_params.getlist(key)) for key in request.query_params}, + body=body, + ) + context = replace( + context, request=request_data, request_id=context.request_id or current_plugin_request_id() + ) + return await await_plugin_callback(callback, context, timeout=timeout) + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + except asyncio.TimeoutError as exc: + raise HTTPException(status_code=504, detail='插件调用超时') from exc + + return endpoint + + +def plugin_lifespan( + host: PluginHostContext, + on_startup: Callable[..., Any], + on_shutdown: Callable[..., Any], + *, + timeout: float = 30.0, +) -> Callable[..., Any]: + """ + 把固定参数的原生 awaitable 转换为 FastAPI lifespan。 + + 启动回调返回请求可读的 state。启动部分失败也调用关闭回调,因此关闭必须幂等。 + 插件只关闭自身资源,不能关闭 host.redis 或宿主数据库连接池。 + + :param host: 当前插件的宿主能力上下文 + :param on_startup: 返回请求共享状态的异步启动回调 + :param on_shutdown: 释放插件自身资源的异步关闭回调 + :param timeout: 各生命周期回调等待超时时间,单位为秒 + :return: 可传给 FastAPI 的异步生命周期上下文管理器 + """ + + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncGenerator[dict[str, Any], None]: + """ + 执行插件启动和关闭回调,并在请求期间提供共享状态。 + + :param app: 使用该生命周期管理器的 FastAPI 子应用 + :return: 供子应用请求使用的共享状态字典迭代器 + """ + try: + state = await await_plugin_callback(on_startup, host, timeout=timeout) + if state is not None and not isinstance(state, Mapping): + raise TypeError('插件启动回调必须返回状态字典或 None') + yield dict(state or {}) + finally: + await await_plugin_callback(on_shutdown, host, timeout=timeout) + + return lifespan diff --git a/ruoyi-fastapi-backend/plugins/core/sdk/config.py b/ruoyi-fastapi-backend/plugins/core/sdk/config.py new file mode 100644 index 0000000..5aa4c0a --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/sdk/config.py @@ -0,0 +1,26 @@ +from collections.abc import Mapping +from copy import deepcopy +from dataclasses import dataclass, field +from types import MappingProxyType +from typing import Any + + +@dataclass(frozen=True) +class PluginConfigSnapshot: + """ + 一次读取的插件专属配置,版本用于判断是否需要重新应用业务设置。 + + :param values: 当前插件的配置明文,只能在插件服务端使用 + :param revision: 宿主生成的不透明配置版本 + """ + + values: Mapping[str, Any] = field(repr=False) + revision: str + + def __post_init__(self) -> None: + """ + 深拷贝配置以隔离调用方修改,再保护顶层映射。 + + :return: None + """ + object.__setattr__(self, 'values', MappingProxyType(deepcopy(dict(self.values)))) diff --git a/ruoyi-fastapi-backend/plugins/core/sdk/context.py b/ruoyi-fastapi-backend/plugins/core/sdk/context.py new file mode 100644 index 0000000..fdb6e6b --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/sdk/context.py @@ -0,0 +1,188 @@ +import asyncio +from collections.abc import AsyncIterator, Awaitable, Callable, Mapping +from contextlib import asynccontextmanager +from dataclasses import dataclass, field, replace +from pathlib import Path +from types import MappingProxyType +from typing import Any + +from plugins.core.sdk.config import PluginConfigSnapshot +from plugins.core.sdk.request import PluginHttpRequest +from plugins.core.sdk.version import HOST_API_VERSION + + +@dataclass(frozen=True) +class PluginHostContext: + """ + 宿主注入的能力集合;不保存当前用户或请求数据库会话。 + + services 只包含宿主显式登记的接口,不提供通过字符串导入任意服务的执行器。 + 具体服务仍负责权限和事务规则;该对象不是针对不可信代码的沙箱。 + + :param plugin_id: 插件 ID + :param resource_root: 插件资源根目录 + :param config: 当前插件的只读配置快照 + :param services: 宿主显式注册的只读服务映射 + :param session_factory: 由宿主提供的数据库会话工厂 + :param redis: 宿主共享的 Redis 客户端 + :param logger: 绑定插件标识的日志对象 + :param startup_write_enabled: 当前实例是否允许执行启动期全局写入 + :param api_version: 宿主插件 API 版本 + :param config_revision: 启动配置快照的不透明版本,手动构造上下文时可为空 + :param config_reader: 宿主绑定的当前插件配置读取器 + """ + + plugin_id: str + resource_root: Path + config: Mapping[str, Any] = field(default_factory=dict, repr=False) + services: Mapping[str, Any] = field(default_factory=dict) + session_factory: Callable[..., Any] | None = None + redis: Any = None + logger: Any = None + startup_write_enabled: bool = False + api_version: str = HOST_API_VERSION + config_revision: str | None = None + config_reader: Callable[[], Awaitable[PluginConfigSnapshot]] | None = field(default=None, repr=False, compare=False) + + def __post_init__(self) -> None: + """ + 规范化资源根目录,并将配置和服务映射转换为只读快照。 + + :return: None + """ + object.__setattr__(self, 'resource_root', self.resource_root.resolve()) + object.__setattr__(self, 'config', MappingProxyType(dict(self.config))) + object.__setattr__(self, 'services', MappingProxyType(dict(self.services))) + + async def read_config(self) -> PluginConfigSnapshot: + """ + 使用独立会话读取当前插件最新配置,不替换启动快照或自动重建业务资源。 + + :return: 本次读取的配置明文和版本;敏感值不得直接返回浏览器 + :raises RuntimeError: 当前上下文未提供按需配置读取能力 + """ + if self.config_reader is None: + raise RuntimeError('宿主未提供按需配置读取能力') + return await self.config_reader() + + def resource(self, relative_path: str) -> Path: + """ + 解析自身资源;读取前再次检查真实路径以拒绝链接逃逸。 + + :param relative_path: 相对于当前插件资源根目录的路径 + :return: 通过插件目录边界校验的资源绝对路径 + """ + path = (self.resource_root / relative_path).resolve() + if not path.is_relative_to(self.resource_root): + raise ValueError('插件资源路径不能越过插件根目录') + return path + + def service(self, name: str) -> Any: + """ + 获取宿主显式注册的服务能力。 + + :param name: 宿主注册的版本化服务名称 + :return: 对应的宿主服务能力对象 + """ + if name not in self.services: + raise LookupError(f'宿主未提供插件服务:{name}') + return self.services[name] + + +@dataclass(frozen=True) +class PluginRequestContext: + """ + 由宿主鉴权层建立的请求身份,不能由客户端字段直接构造。 + + :param host: 当前插件的宿主能力上下文 + :param user: 宿主登录服务返回的当前用户 + :param permissions: 当前用户的接口权限集合 + :param request_id: 宿主提供的请求关联标识 + :param query_db: 显式事务块内有效的数据库会话,不得跨任务共享 + :param request: SDK 接口适配器提供的 HTTP 请求数据快照 + :param _transaction: 宿主内部的会话所有权记录,业务代码不应自行构造 + """ + + host: PluginHostContext + user: Any + permissions: frozenset[str] = frozenset() + request_id: str | None = None + query_db: Any = None + request: PluginHttpRequest | None = None + _transaction: '_PluginTransactionState | None' = field(default=None, repr=False, compare=False) + + def require_permission(self, permission: str) -> None: + """ + 执行接口权限检查;数据权限由具体服务按当前用户处理。 + + :param permission: 当前操作要求的接口权限标识 + :return: None + """ + if permission not in self.permissions and '*:*:*' not in self.permissions: + raise PermissionError(f'缺少权限:{permission}') + + @asynccontextmanager + async def transaction(self) -> AsyncIterator['PluginRequestContext']: + """ + 为当前任务创建显式事务,成功提交、异常或取消回滚,并关闭独占会话。 + + :return: 仅在当前事务块和当前 asyncio 任务内有效的请求上下文 + :raises RuntimeError: 嵌套事务、缺少会话工厂或在失效上下文上重新开启事务 + """ + if self.query_db is not None or self._transaction is not None: + raise RuntimeError('插件请求事务不能嵌套或复用,请使用原始请求上下文') + if self.host.session_factory is None: + raise RuntimeError('宿主未提供数据库会话工厂') + async with self.host.session_factory() as db: + state = _PluginTransactionState(db, asyncio.current_task()) + try: + async with db.begin(): + yield replace(self, query_db=db, _transaction=state) + finally: + state.active = False + + def transaction_session(self) -> Any: + """ + 为宿主服务取得当前任务拥有的事务会话,拒绝过期、伪造或跨任务复用。 + + :return: 当前有效事务的会话;未开启事务时为 None + :raises RuntimeError: 事务所有权或有效期不符合要求 + """ + state = self._transaction + if state is None and self.query_db is None: + return None + if state is None or not state.active or state.session is not self.query_db: + raise RuntimeError('插件请求事务上下文已失效或不是由宿主创建') + if state.owner is not asyncio.current_task(): + raise RuntimeError('插件请求事务会话不能跨 asyncio 任务共享') + return state.session + + +@dataclass +class _PluginTransactionState: + """ + 记录显式事务的会话、所属任务和有效期。 + + :param session: 当前事务独占的数据库会话 + :param owner: 开启事务的 asyncio 任务 + :param active: 事务块是否仍有效 + """ + + session: Any + owner: asyncio.Task[Any] | None + active: bool = True + + +@dataclass(frozen=True) +class PluginTaskContext: + """ + 后台任务身份;不隐式授予请求用户或管理员权限。 + + :param host: 当前插件的宿主能力上下文 + :param job_id: 插件清单声明的任务 ID + :param request_id: 本次任务执行的独立追踪标识 + """ + + host: PluginHostContext + job_id: str + request_id: str | None = None diff --git a/ruoyi-fastapi-backend/plugins/core/sdk/definition.py b/ruoyi-fastapi-backend/plugins/core/sdk/definition.py new file mode 100644 index 0000000..f72f6ed --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/sdk/definition.py @@ -0,0 +1,76 @@ +import asyncio +import inspect +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, field +from typing import Any + +from fastapi import APIRouter +from starlette.types import ASGIApp + +from plugins.core.sdk.context import PluginHostContext + + +@dataclass(frozen=True) +class PluginDefinition: + """ + 入口返回值;构造时不打开连接、不执行迁移。 + + 原生扩展通过 PyO3 导入这个类并创建实例即可,无需继承 Python 基类。 + 生命周期 Hook 继续在清单中声明,避免安装命令和请求 worker 得到不同的能力清单。 + + :param api_version: 插件能力对象协议版本 + :param routers: Router 接入方式显式提供的路由集合 + :param app_factory: ASGI 接入方式创建子应用的同步工厂 + :param register_models: 同步注册 ORM 元数据的可选回调 + """ + + api_version: int = 1 + routers: Sequence[APIRouter] = field(default_factory=tuple) + app_factory: Callable[[PluginHostContext], ASGIApp] | None = None + register_models: Callable[[PluginHostContext], None] | None = None + + def validate(self, integration: str) -> None: + """ + 导入后验证对象协议,不依赖 Rust 函数的反射签名。 + + :param integration: 清单声明的 Web 接入方式 + :return: None + """ + if type(self.api_version) is not int or self.api_version != 1: + raise ValueError(f'不支持的 PluginDefinition API 版本:{self.api_version}') + if any(not isinstance(router, APIRouter) for router in self.routers): + raise TypeError('PluginDefinition.routers 必须包含 APIRouter 实例') + if integration == 'asgi': + if not callable(self.app_factory) or self.routers: + raise ValueError('ASGI 插件必须提供 app_factory,且不能同时返回 routers') + elif integration == 'router': + if self.app_factory is not None: + raise ValueError('Router 插件不能返回 app_factory') + else: + raise ValueError(f'不支持的插件接入方式:{integration}') + if self.register_models is not None and not callable(self.register_models): + raise TypeError('register_models 必须可调用') + + +async def await_plugin_callback( + callback: Callable[..., Any], + *args: Any, + timeout: float = 30.0, + kwargs: Mapping[str, Any] | None = None, +) -> Any: + """ + 等待 Python 或原生 awaitable;同步入口必须立即返回。 + + 超时只能取消异步等待,不能强制终止阻塞原生函数。平台不在线程池中执行 + 生命周期写操作,也不将同步返回值视为成功,以免事务结束后继续产生副作用。 + + :param callback: 必须立即返回可等待对象的插件回调 + :param args: 传给回调的位置参数 + :param timeout: 异步等待超时时间,单位为秒 + :param kwargs: 传给回调的关键字参数 + :return: 等待插件回调完成后的返回值 + """ + result = callback(*args, **(kwargs or {})) + if not inspect.isawaitable(result): + raise TypeError('v2 插件异步回调必须立即返回 awaitable') + return await asyncio.wait_for(result, timeout=timeout) diff --git a/ruoyi-fastapi-backend/plugins/core/sdk/request.py b/ruoyi-fastapi-backend/plugins/core/sdk/request.py new file mode 100644 index 0000000..a54dbbe --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/sdk/request.py @@ -0,0 +1,65 @@ +import json +from collections.abc import Mapping +from dataclasses import dataclass, field +from types import MappingProxyType +from typing import Any +from uuid import uuid4 + +from middlewares.trace_middleware import TraceCtx + + +def current_plugin_request_id() -> str: + """ + 复用宿主请求追踪标识;独立挂载或测试环境未设置追踪时生成新标识。 + + :return: 当前请求的关联标识 + """ + return TraceCtx.get_request_id() or uuid4().hex + + +@dataclass(frozen=True) +class PluginHttpRequest: + """ + 提供给 Python 和原生回调的 HTTP 数据快照,不携带 Cookie、令牌或 Request 对象。 + + :param method: HTTP 请求方法 + :param path: 当前请求的路径,不包含查询串 + :param path_params: 路由已解析的路径参数,值为可序列化数据 + :param query: 保留同名多值的查询参数 + :param body: 显式开启 JSON 读取后获得的请求体 + """ + + method: str + path: str + path_params: Mapping[str, Any] = field(default_factory=dict) + query: Mapping[str, tuple[str, ...]] = field(default_factory=dict) + body: Any = None + + def __post_init__(self) -> None: + """ + 复制路径和查询参数,避免调用方修改原始请求的参数映射。 + + :return: None + """ + object.__setattr__(self, 'path_params', MappingProxyType(dict(self.path_params))) + object.__setattr__(self, 'query', MappingProxyType({key: tuple(values) for key, values in self.query.items()})) + + def to_payload(self) -> dict[str, Any]: + """ + 返回独立的 JSON 数据副本,供原生回调或序列化边界消费。 + + :return: 不包含宿主对象的 HTTP 请求数据 + """ + return json.loads( + json.dumps( + { + 'method': self.method, + 'path': self.path, + 'pathParams': dict(self.path_params), + 'query': dict(self.query), + 'body': self.body, + }, + ensure_ascii=False, + allow_nan=False, + ) + ) diff --git a/ruoyi-fastapi-backend/plugins/core/sdk/version.py b/ruoyi-fastapi-backend/plugins/core/sdk/version.py new file mode 100644 index 0000000..129b10b --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/sdk/version.py @@ -0,0 +1 @@ +HOST_API_VERSION = '1.4.0' diff --git a/ruoyi-fastapi-backend/plugins/core/validation/manifest.py b/ruoyi-fastapi-backend/plugins/core/validation/manifest.py index d1e660d..5aede7d 100644 --- a/ruoyi-fastapi-backend/plugins/core/validation/manifest.py +++ b/ruoyi-fastapi-backend/plugins/core/validation/manifest.py @@ -10,6 +10,7 @@ from config.env import DataBaseConfig from plugins.core.environment import PluginRuntimeEnvironmentService from plugins.core.manifest.menu_tree import PluginMenuTree from plugins.core.manifest.schema import PluginManifest +from plugins.core.sdk.version import HOST_API_VERSION from plugins.core.validation.dependencies import DependencyRequirementParser from plugins.core.validation.python_requirements import PythonRequirementParser from plugins.core.validation.result import PluginValidationIssue @@ -575,6 +576,10 @@ class PluginManifestChecker: ('pythonVersion', compatibility.python_version, lambda: self.python_version, 'python'), ('nodeVersion', compatibility.node_version, self._resolve_node_version, 'node'), ] + if manifest.uses_entrypoint: + checks.append( + ('hostApiVersion', manifest.compatibility.host_api_version, lambda: HOST_API_VERSION, 'hostApi') + ) issues = [] for field_name, constraint, version_loader, target_name in checks: if not constraint: diff --git a/ruoyi-fastapi-backend/plugins/core/validation/structure.py b/ruoyi-fastapi-backend/plugins/core/validation/structure.py index 84b1080..afaea42 100644 --- a/ruoyi-fastapi-backend/plugins/core/validation/structure.py +++ b/ruoyi-fastapi-backend/plugins/core/validation/structure.py @@ -135,6 +135,8 @@ class PluginStructureChecker: self._check_dir('backend_root', discovered_plugin.backend_path), self._check_file('manifest', discovered_plugin.manifest_path), ] + if manifest.uses_entrypoint: + return self._check_explicit_plugin(discovered_plugin, items, include_frontend=include_frontend) if manifest.backend.routers.auto_scan: controller_dir = discovered_plugin.backend_path / 'controller' items.append(self._check_dir('controller_dir', controller_dir)) @@ -153,6 +155,54 @@ class PluginStructureChecker: return PluginStructureCheckResult(plugin_id=manifest.id, items=items) + def _check_explicit_plugin( + self, + plugin: DiscoveredPlugin, + items: list[PluginStructureCheckItem], + *, + include_frontend: bool, + ) -> PluginStructureCheckResult: + """ + v2 只静态定位入口,编译模块不能用源码 AST 判断可调用性。 + + :param plugin: 待检查的已发现插件 + :param items: 已收集的结构检查项 + :param include_frontend: 是否同时检查前端资源结构 + :return: 包含入口、资源及前端结构校验项的检查结果 + """ + from plugins.core.runtime.entrypoint import PluginEntrypointLoader # noqa: PLC0415 + + loader = PluginEntrypointLoader(plugin) + try: + location = loader.check_entrypoint() + items.append(PluginStructureCheckItem('entrypoint', str(location.path), True, '插件入口文件与来源有效')) + # 父包在实际导入前也必须位于已绑定目录。 + loader.locate_module(plugin.manifest.backend.module) + callbacks = [value for value in plugin.manifest.backend.hooks.model_dump().values() if value is not None] + if plugin.manifest.backend.health.checker: + callbacks.append(plugin.manifest.backend.health.checker) + callbacks.extend(':'.join(job.callable.rsplit('.', 1)) for job in plugin.manifest.backend.jobs) + for callback in callbacks: + loader.locate_module(callback.split(':', 1)[0]) + except (ImportError, ValueError, OSError) as exc: + items.append(PluginStructureCheckItem('entrypoint', plugin.manifest.backend.entrypoint, False, str(exc))) + items.extend(self._check_migration_files(plugin)) + items.extend(self._check_seed_files(plugin)) + for job in plugin.manifest.backend.jobs: + items.append(self._check_job_name_length(plugin.manifest.id, job)) + items.append(self._check_job_callable_boundary(plugin.manifest.backend.module, job)) + items.append(self._check_job_cron_expression(job)) + if plugin.manifest.frontend.delivery.type == 'bundle': + bundle = plugin.manifest.frontend.bundle + items.append( + self._check_plugin_relative_file( + 'frontend_bundle', plugin.backend_path, f'{bundle.directory}/{bundle.entry}' + ) + ) + elif include_frontend: + items.extend(self._check_frontend(plugin)) + return PluginStructureCheckResult(plugin_id=plugin.manifest.id, items=items) + def _check_controller_route_prefixes( self, discovered_plugin: DiscoveredPlugin, diff --git a/ruoyi-fastapi-backend/plugins/examples/python/asgi_demo/README.md b/ruoyi-fastapi-backend/plugins/examples/python/asgi_demo/README.md new file mode 100644 index 0000000..4085732 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/asgi_demo/README.md @@ -0,0 +1,33 @@ +# ASGI 子应用示例 + +本示例验证 v2 显式入口、子应用挂载、宿主 Bearer 认证、请求权限及 lifespan。它只提供 API;需要已接入的 PluginFrame、独立打包页面与浏览器会话桥时,参见 [bundle_demo](../bundle_demo/README.md)。 + +示例要求 Host API `^1.3.0`。`POST /apps/asgi_demo/api/echo/item-7?tag=first&tag=second` 使用相同的 `asgi_demo:view` 权限,接收 `{"message":"你好"}` JSON 请求体(消息长度 1–200),返回路径参数、多值查询参数、校验后的请求体及 `requestId`。实现通过 `plugin_endpoint(..., body_model=EchoBody)` 演示统一校验;认证方式与 `/api/info` 相同。 + +清单、入口与 SDK 约定见[插件开发手册](../../../../docs/plugin_development.md#510-v2-清单与能力组合),资源初始化和关闭方式见[ASGI 生命周期说明](../../../../docs/plugin_development.md#67-asgi-子应用与生命周期)。 + +以下命令在后端目录执行,适用于 Windows PowerShell、macOS 和 Linux。先按 [CLI 环境准备](../../../../docs/cli_usage.md#22-安装依赖)激活已安装宿主依赖的 Python 环境。 + +1. 将此目录复制到后端 `plugins/asgi_demo`。 +2. 在已激活的 Python 环境中执行 `ruoyi plugin check asgi_demo --env=dev`。 +3. 使用现有安装与启用命令执行 `ruoyi plugin install asgi_demo --env=dev --yes` 和 `ruoyi plugin enable asgi_demo --env=dev --yes`。 +4. 重启后端。按原有角色权限管理授予 `asgi_demo:view`;管理员具有通配权限。 +5. 请求 `GET /apps/asgi_demo/api/info`,携带当前宿主登录的 `Authorization: Bearer `。若启用了传输加密,仍需遵守宿主加密协议。 + +入口函数快速返回 PluginDefinition,资源在 lifespan 中初始化和关闭。API 从 request.state.plugin_context 获取宿主鉴权后的身份,执行具体权限检查。数据库会话通过 host.session_factory 按请求创建,不在应用实例内保存请求会话。 + +服务模式仍执行既有生命周期写入限制;示例命令用于开发环境,不应将生产配置切到 dev 以绕过限制。生产变更使用维护部署流程。当前没有热挂载或热卸载。 + +## 签名制品交付 + +本例是 Python 源码插件,签名不改变源码交付方式。使用[开发手册第 15 节](../../../../docs/plugin_development.md#15-v2-签名制品与维护发布)配置宿主制品存储和外部发布者公钥,并授权 `asgi_demo`。发布者私钥必须在插件目录之外,以下路径需替换为实际的 Ed25519 PKCS8 PEM: + +```bash +ruoyi plugin artifact build plugins/examples/python/asgi_demo target/asgi_demo-1.0.0.rpk --key-file ../signing-keys/publisher.pem --key-id publisher --env=dev --output=json +ruoyi plugin artifact verify target/asgi_demo-1.0.0.rpk --env=prod --output=json +ruoyi plugin artifact import target/asgi_demo-1.0.0.rpk --env=prod --allow-prod --yes --output=json +``` + +导入只验签、检查并登记不可变目录,不加载子应用、不运行迁移或 Hook。选择制品部署时不能同时保留 `plugins/asgi_demo` 源码目录;如已按上面的开发步骤安装过,需在维护窗口处理目录冲突,并为缺少制品准备证据的既有安装提升版本。 + +后续严格使用导入返回的 digest,停止全部 worker 后执行 `release prepare`,从最新状态读取 generation,再执行 `release select` 并重启。每个 worker 分别启动 lifespan,只有实际报告匹配目标并就绪才算发布完成。生产操作不支持热挂载/卸载;代码回滚也不降级数据库。完整命令与签名轮换、中断恢复方式均见开发手册。 diff --git a/ruoyi-fastapi-backend/plugins/examples/python/asgi_demo/__init__.py b/ruoyi-fastapi-backend/plugins/examples/python/asgi_demo/__init__.py new file mode 100644 index 0000000..6882fd7 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/asgi_demo/__init__.py @@ -0,0 +1,79 @@ +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from typing import Any + +from fastapi import FastAPI, HTTPException, Request +from pydantic import BaseModel, Field + +from plugins.core.sdk import PluginDefinition, PluginHostContext, PluginRequestContext, plugin_endpoint + + +class EchoBody(BaseModel): + """ + SDK 请求体模型,约束示例消息长度。 + """ + + message: str = Field(min_length=1, max_length=200) + + +async def echo(context: PluginRequestContext) -> dict[str, Any]: + return {**context.request.to_payload(), 'requestId': context.request_id} + + +def create_plugin(host: PluginHostContext) -> PluginDefinition: + """ + 声明子应用工厂,资源初始化交给子应用生命周期。 + + :param host: 宿主提供的插件上下文 + :return: 插件能力声明 + """ + return PluginDefinition(app_factory=create_app) + + +def create_app(host: PluginHostContext) -> FastAPI: + """ + 为当前 worker 创建独立的 ASGI 子应用。 + + :param host: 宿主提供的插件上下文 + :return: 包含权限检查和生命周期管理的子应用 + """ + + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncGenerator[dict[str, str], None]: + """ + 演示插件自有资源的启动与关闭。 + + :param app: 正在启动或关闭的 ASGI 子应用 + :return: 可供请求访问的生命周期状态 + """ + host.logger.info('ASGI 示例启动') + try: + yield {'message': '子应用资源已就绪'} + finally: + host.logger.info('ASGI 示例关闭') + + app = FastAPI(title='ASGI 插件示例', lifespan=lifespan, docs_url=None, redoc_url=None) + + @app.get( + '/api/info', + summary='获取ASGI插件信息接口', + description='用于获取当前插件标识、宿主API版本和子应用资源状态', + ) + async def info(request: Request) -> dict[str, str]: + context = request.state.plugin_context + try: + context.require_permission('asgi_demo:view') + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + return {'pluginId': host.plugin_id, 'hostApiVersion': host.api_version, 'message': request.state.message} + + app.add_api_route( + '/api/echo/{item_id}', + plugin_endpoint(echo, permission='asgi_demo:view', body_model=EchoBody), + methods=['POST'], + summary='验证ASGI插件请求上下文接口', + description='返回校验后的请求体、路径参数、多值查询参数与宿主追踪标识', + response_model=dict[str, Any], + ) + + return app diff --git a/ruoyi-fastapi-backend/plugins/examples/python/asgi_demo/plugin.yaml b/ruoyi-fastapi-backend/plugins/examples/python/asgi_demo/plugin.yaml new file mode 100644 index 0000000..0a62c69 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/asgi_demo/plugin.yaml @@ -0,0 +1,22 @@ +manifestVersion: 2 +id: asgi_demo +name: ASGI 子应用示例 +version: 1.0.0 +description: 使用显式入口和宿主认证的独立 API 子应用。 +backend: + runtime: python + integration: asgi + module: plugins.asgi_demo + entrypoint: plugins.asgi_demo:create_plugin + asgi: + mountPath: /apps/asgi_demo + lifespan: managed +frontend: + delivery: + type: none +permissions: + - code: asgi_demo:view + name: 查看 ASGI 示例 +compatibility: + hostApiVersion: ^1.3.0 + pythonVersion: '>=3.10' diff --git a/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/README.md b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/README.md new file mode 100644 index 0000000..42a1284 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/README.md @@ -0,0 +1,239 @@ +# 独立打包前端插件示例 + +本示例使用 **Python ASGI 后端 + 独立 Vite 前端 bundle**。页面通过宿主 `PluginFrame` 打开,取得宿主主题、语言、时区和路由状态,再通过通信桥调用查询、回显、上传检查、报表下载和 SSE 事件 API。欢迎语展示按需配置读取的用法;业务接口不保存消息或文件内容。 + +通用清单与后端入口见[插件开发手册](../../../../docs/plugin_development.md#510-v2-清单与能力组合),浏览器集成协议见[bundle 页面与 SDK](../../../../docs/plugin_development.md#72-v2-独立-bundle-页面)。 + +前端交付 `web/dist`,不要求把插件页面加入宿主 Vue 源码或重新构建宿主。后端 `__init__.py` 仍是可读 Python 业务源码;需要保护后端源码时使用 [Rust 原生示例](../../rust/rust_demo/README.md),并按 `native + asgi + bundle` 组合交付。编译前端也不保证浏览器端逻辑不可逆向。 + +## 构建前端 + +以下命令从仓库的 `ruoyi-fastapi-backend` 目录执行。Python 命令使用已安装宿主依赖的环境,准备步骤见 [CLI 环境准备](../../../../docs/cli_usage.md#22-安装依赖)。通用命令适用于 Windows PowerShell、macOS 和 Linux;Shell 语法不同的操作分别列出。构建端需已安装 Node.js/npm;部署端只使用构建产物,无需 Node.js。示例使用独立 `package.json`,不会把构建依赖写入宿主前端。 + +```bash +npm --prefix plugins/examples/python/bundle_demo/web install +npm --prefix plugins/examples/python/bundle_demo/web run build +``` + +首次安装会生成此示例自己的锁文件;独立维护插件时应保留锁文件,在 CI 中使用 `npm ci`。示例的 SDK 别名指向本仓库 `ruoyi-fastapi-frontend/src/utils/pluginBridge.js`。迁移到独立插件仓库时,将该模块及相邻的 `pluginBridge.d.ts` 一起复制为自己的版本化源码依赖,并调整 `vite.config.js` 中 `@ruoyi/plugin-bridge` 的别名。TypeScript 项目还应在自己的 `tsconfig.json` 中将同一别名映射到复制后的模块路径。 + +如果本仓库宿主前端依赖已安装,也可复用其中的 Vite 可执行文件构建**同一个插件前端项目**: + +```bash +node ../ruoyi-fastapi-frontend/node_modules/vite/bin/vite.js build plugins/examples/python/bundle_demo/web --config plugins/examples/python/bundle_demo/web/vite.config.js +``` + +两种方式均输出 `plugins/examples/python/bundle_demo/web/dist`。配置使用 `base: './'`、关闭 sourcemap;宿主交付 HTML 时注入有效 `` 和运行基址,因此资源和页面深层链接不需写死域名或代理前缀。直接用 `file://` 打开 dist 不会建立宿主身份桥;真实账号交互应从管理平台的插件菜单进入。 + +## 本地模拟宿主 + +安装示例依赖后,从后端目录运行: + +```bash +npm --prefix plugins/examples/python/bundle_demo/web run dev +``` + +开发服务只监听本机,自动打开 `/dev.html`。模拟宿主使用真实通信桥,提供主题切换、0–5000 毫秒延迟、故障注入、页面重载和退出;上传检查、下载与逐条事件在浏览器内存中完成,不调用实际后端。事件间隔最短 100 毫秒,可停止后按游标继续。刷新整个页面可恢复模拟退出后的会话。 + +`web/dev/mockRequest.js` 是可扩展的纯请求适配器,可按业务补充本地响应;真实插件界面位于 `web/src/main.js`。Vite 仅在开发服务为插件入口注入模拟标识,生产构建只包含插件入口和其依赖,不包含开发宿主。模拟环境不能验证真实登录、权限、CSRF、反向代理或传输加密,这些仍需在宿主内验证。 + +## 离线目录包 + +构建完成后,只打包清单、Python 后端文件和前端构建产物。选择当前平台对应的命令创建新目录;两种写法均在输出已存在时停止,避免覆盖已有发布包。 + +Windows PowerShell: + +```powershell +$bundleSource = (Resolve-Path 'plugins/examples/python/bundle_demo').Path +$bundleDist = Join-Path $bundleSource 'web/dist' +if (-not (Test-Path -LiteralPath (Join-Path $bundleDist 'index.html'))) { + throw '请先构建 bundle_demo 前端' +} +$bundleRelease = Join-Path (Get-Location).Path 'target/bundle-demo-1.0.0' +if (Test-Path -LiteralPath $bundleRelease) { + throw '输出目录已存在,请选择新的发布目录' +} +$bundlePackage = Join-Path $bundleRelease 'bundle_demo' +New-Item -ItemType Directory -Path (Join-Path $bundlePackage 'web') -Force | Out-Null +Copy-Item -LiteralPath (Join-Path $bundleSource 'plugin.yaml') -Destination $bundlePackage +Copy-Item -LiteralPath (Join-Path $bundleSource '__init__.py') -Destination $bundlePackage +Copy-Item -LiteralPath (Join-Path $bundleSource 'files.py') -Destination $bundlePackage +Copy-Item -LiteralPath (Join-Path $bundleSource 'events.py') -Destination $bundlePackage +Copy-Item -LiteralPath $bundleDist -Destination (Join-Path $bundlePackage 'web') -Recurse +Compress-Archive -LiteralPath $bundlePackage -DestinationPath (Join-Path $bundleRelease 'bundle_demo-1.0.0.zip') +``` + +macOS / Linux(Bash、Zsh): + +```bash +( + set -eu + bundleSource='plugins/examples/python/bundle_demo' + bundleDist="$bundleSource/web/dist" + if [ ! -f "$bundleDist/index.html" ]; then + printf '%s\n' '请先构建 bundle_demo 前端' >&2 + exit 1 + fi + bundleRelease='target/bundle-demo-1.0.0' + if [ -e "$bundleRelease" ]; then + printf '%s\n' '输出目录已存在,请选择新的发布目录' >&2 + exit 1 + fi + mkdir -p target + mkdir "$bundleRelease" + bundlePackage="$bundleRelease/bundle_demo" + mkdir -p "$bundlePackage/web" + cp "$bundleSource/plugin.yaml" "$bundleSource/__init__.py" "$bundleSource/files.py" "$bundleSource/events.py" "$bundlePackage/" + cp -R "$bundleDist" "$bundlePackage/web/dist" + python -m zipfile -c "$bundleRelease/bundle_demo-1.0.0.zip" "$bundlePackage" +) +``` + +ZIP 内布局: + +```text +bundle_demo/ + plugin.yaml + __init__.py + files.py + events.py + web/ + dist/ + index.html + assets/... +``` + +包不包含 `web/src`、`node_modules`、Vite 配置或 npm 清单。这里的普通 ZIP 供开发环境复制部署,不是签名 `.rpk`;生产维护发布应继续对准备好的 `bundle_demo` 目录签名,不能直接把这个 ZIP 当作已验签制品。 + +## 签名制品与维护发布 + +先按[开发手册第 15 节](../../../../docs/plugin_development.md#15-v2-签名制品与维护发布)配置宿主制品功能及外部发布者信任文件,并授权 `bundle_demo`。发布者准备位于插件目录之外的 Ed25519 PKCS8 PEM 私钥,再执行: + +```bash +ruoyi plugin artifact build target/bundle-demo-1.0.0/bundle_demo target/bundle_demo-1.0.0.rpk --key-file ../signing-keys/publisher.pem --key-id publisher --env=dev --output=json +ruoyi plugin artifact verify target/bundle_demo-1.0.0.rpk --env=prod --output=json +ruoyi plugin artifact import target/bundle_demo-1.0.0.rpk --env=prod --allow-prod --yes --output=json +``` + +签名私钥路径仅作示例;包内公钥不能自行建立信任。输入为已经剔除前端工程的部署目录,不能直接传入包含 `web/src`、`package.json` 或 `node_modules` 的开发目录。本例 Python 后端仍以源码交付,前端以 `web/dist` 交付,签名并不加密这些文件。 + +导入成功后,制品保存在 `/bundle_demo///payload/bundle_demo`;验签、现有文件复验和发布者授权按当前宿主信任配置执行。与 `plugins/bundle_demo` 源码目录不能共存,导入也不等于安装或激活。继续使用返回的精确 digest 查看发布计划,停止全部 worker 后准备迁移/Hook,读取最新 generation 并选择目标,最后启动全部 worker 检查状态。相同版本缺少匹配 digest 的准备证据时应提升版本,不允许猜测已安装内容。 + +制品目录保持不可变,不手工替换页面资源、入口或 Python 模块。代码回滚要求旧代码兼容现有数据结构,不会回退数据库。相同内容重签不改变 digest,可通过 `artifact rotate-signature` 在维护模式下轮换签名;`artifact inspect/prune/reconcile` 提供预览、受保护清理和中断恢复。上述写操作仍需停止全部 worker,不自动热卸载或停止进程。完整流程和故障处理见开发手册。 + +## 在开发环境安装 + +1. 首次升级宿主时,构建包含 `PluginFrame` 的宿主前端;后续新增或更新此 bundle 不需要重建宿主。 +2. 将交付目录中的 `bundle_demo` 复制到后端 `plugins/bundle_demo`,确保 `web/dist/index.html` 已存在。不要把整个开发源码目录当成交付要求。 +3. 在已激活并安装宿主依赖的 Python 环境、后端目录执行(三种平台通用): + + ```bash + ruoyi plugin check bundle_demo --env=dev + ruoyi plugin install bundle_demo --env=dev --yes + ruoyi plugin enable bundle_demo --env=dev --yes + ``` + +4. 重启后端;在现有角色权限管理中授予 `bundle_demo:view`,或使用具有通配权限的管理员。 +5. 重新取得用户菜单后打开“独立前端示例”。页面会显示实际登录用户、服务端时间,并支持有权限的 POST 回显。 + +`plugin.yaml` 使用 Host API `^1.4.0`,供欢迎语接口调用 `host.read_config()`。基础 bundle 协议仍兼容 `^1.2.0`;文件与 SSE 能力由前端握手协商,首次使用时需要更新并构建支持这些能力的宿主前端。菜单组件为 `PluginFrame`,后端根据 `sys_plugin_menu` 的真实关联返回 `meta.pluginId`;不从路径或 `query.pluginId` 推测插件。只有已启用、已激活且用户有权限的 bundle 可以签发浏览器会话。 + +生产模式继续遵守现有生命周期写操作限制,应通过维护部署流程更新并重启。不要把生产配置切到 dev 来绕过限制,也不要期待复制目录后自动热挂载。 + +## 浏览器 SDK + +实际用例见 [web/src/main.js](web/src/main.js)。`createPluginClient` 只在同源宿主 iframe 中使用,初始化配置由宿主注入: + +```javascript +import { createPluginClient } from '@ruoyi/plugin-bridge' + +const config = JSON.parse(document.getElementById('ruoyi-plugin-config').textContent) +const client = createPluginClient({ pluginId: config.pluginId }) +const context = await client.ready +// context 包含 theme、language、timeZone、route、uiBase、apiBase。 + +const summary = await client.request({ method: 'GET', path: 'summary' }) +const echo = await client.request({ + method: 'POST', + path: 'echo', + data: { message: `你好,${summary.userName}` }, +}) +console.log(echo.message) + +const unsubscribe = client.subscribe(({ type, payload }) => { + if (type === 'preferences') applyPreferences(payload) + if (type === 'route') showPage(payload.route) + if (type === 'refresh') refreshData() + if (type === 'logout') showLoggedOut() +}) +client.navigate('/details') + +window.addEventListener('pagehide', () => { + unsubscribe() + client.destroy() +}, { once: true }) +``` + +`applyPreferences`、`showPage`、`refreshData`、`showLoggedOut` 由插件实现。路由通知只更新插件页面状态;示例将 `/details` 同步到宿主 `pluginRoute` 查询参数,并由订阅回调切换内容。 + +`request` 的 `path` 必须相对于本插件 `apiBase`,例如 `summary`、`reports/month`;不能是完整 URL、以 `/` 开头、包含 `..` 或自行拼接查询串。查询参数使用 `params: { page: 1 }`。方法使用大写 `GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS`;`GET/HEAD` 不允许 `data`。可在第二个参数传入 `{ signal: controller.signal }` 取消请求。 + +返回值与宿主请求客户端的 JSON 返回值一致。本例 API 直接返回 JSON 对象;若自己的 API 使用 `{ code, data, msg }`,桥保留该结构,不额外替插件解包 `data`。JSON 请求和文件元数据每条最多 64 KiB,所有请求合计最多 8 个待处理任务,JSON 默认 15 秒、文件默认 120 秒超时。SSE 通过 `stream` 单独接收,不支持任意字节流或 WebSocket 代理。 + +```javascript +const controller = new AbortController() +const options = { + signal: controller.signal, + onProgress: ({ loaded, total }) => console.log(loaded, total), +} +const file = document.getElementById('file').files[0] +const inspection = await client.upload({ path: 'files/inspect', file }, options) +const report = await client.download({ path: 'files/report' }, options) +``` + +单文件最多 10 MiB;`upload` 接受 File/Blob,宿主重建 multipart,默认字段名 `file`、方法 POST,可使用 `fieldName/filename/fields/params`,其中 `fields` 的值只能是字符串。后端 `files.py` 检查权限及实际字节数,返回 SHA-256 摘要,不保存内容。`download` 返回 Blob,由页面决定保存名称并释放对象 URL。进度的 `total` 可能为 `null`;上传字节数可能包含 multipart 开销,进度到达 100% 后仍应等待 Promise 完成。页面已提供取消按钮、错误反馈和重试入口。旧宿主未公布 `capabilities.files.version=1` 时,SDK 会明确拒绝文件传输。 + +## SSE 实时事件 + +页面“接收实时事件”通过 `client.stream({ path: 'events', params: { count: 10 }, lastEventId }, { signal, onEvent })` 逐条显示最近 5 条事件;“停止接收”取消连接,“继续接收”传回最后成功处理的 id。正常完成后重新开始,页面重载会清空游标。`events.py` 的 GET `/api/events` 要求 `bundle_demo:view` 权限,默认每 0.5 秒发送一条 `tick`,最多 20 条。游标是事件编号;补发仅演示后续编号,重新生成服务端时间,不代表持久化历史重放。 + +`onEvent` 接收 `{ event, data, id }`,其中 `data` 为字符串,可返回 Promise。宿主等待处理完成才发送下一条,回调失败或超过 15 秒会关闭连接。每页最多 2 条流、每条最多 5 分钟,单事件最多 64 KiB、累计最多 10 MiB;超限或异常会拒绝返回的 Promise,正常 EOF 则完成。SDK 不自动重连;按业务实现退避、去重及恢复,并为重试创建新的 AbortController。恢复用 `lastEventId` 只接受最多 1024 个可打印 ASCII 字符。旧宿主未公布 `capabilities.streams.version=1` 时会明确拒绝,需要更新宿主前端。完整调用示例见[浏览器 SDK](../../../../docs/plugin_development.md#73-bundle-浏览器-sdk)。 + +## 配置读取与状态 + +安装后在管理页修改“页面欢迎语”,保存后回到本插件点击“刷新状态”。查询接口调用 `await context.host.read_config()`,从新的数据库会话取得当前插件的配置快照,并仅向页面返回公开的欢迎语字段;配置中的敏感值不能直接交给浏览器。 + +`host.config` 和 `host.config_revision` 始终保留启动快照,按需读取不会修改它们。管理页展示保存版本、各进程启动版本和最近按需读取版本;“待重启”针对启动快照,不否定本示例主动读取的欢迎语已经更新。连接池等启动资源需要插件自行应用或按部署流程重启。状态只有已观测进程的证据,不代表全部 worker 已经一致,详见[配置生效状态](../../../../docs/plugin_development.md#81-启动快照按需读取与生效状态)。 + +## 会话、权限与传输加密 + +`PluginFrame` 用宿主 Bearer 调用 `POST /plugin/runtime/bundle_demo/session`,取得最长 300 秒的短期会话,浏览器保存路径受限的 `HttpOnly; SameSite=Strict` Cookie,HTTPS 下添加 `Secure`。Redis 条目绑定用户、插件、版本、origin 与主会话摘要,不复制主 token;主登录剩余有效期更短时使用更短期限。多标签续期复用同一会话,不相互替换 CSRF。 + +UI、静态文件、API 每次访问均经过宿主门禁并回查主登录和当前权限。退出、主登录替换、插件停用或版本改变会使后续请求失效。具体 API 继续使用 `plugin_endpoint(..., permission='bundle_demo:view')` 或 `request.state.plugin_context.require_permission(...)`;仅隐藏菜单不能代替 API 权限。 + +子页面通过 `postMessage` 发送受校验的 JSON、文件或事件消息;父页面的 JSON/文件使用已有 `request` 客户端,SSE 使用增量 fetch 适配器。两者禁用主 Bearer,自动发送插件 Cookie 并添加 `X-Plugin-CSRF`。写请求同时校验精确同源 Origin。主 token、Cookie 和 CSRF 不进入 bridge 消息或插件 URL。Cookie 也不能替代其他宿主接口的 Bearer。 + +只读 `/apps/bundle_demo/ui/` 使用普通 HTTPS,HTML 当前 `Cache-Control: no-store`,其他静态文件 `no-cache`;资源仍需认证。`/apps/bundle_demo/api/...` 与会话签发接口继续遵守宿主传输加密,不能把 `/apps` 整段排除。真实加密测试已覆盖 JSON 信封、Cookie、CSRF、完整 API AAD 以及代理前缀。 + +文件上传下载不使用 JSON 加密信封,依赖 HTTPS 传输。若启用 `required` 模式,须在已有 `TRANSPORT_CRYPTO_EXCLUDE_PATHS` 列表中追加 `/apps/bundle_demo/api/files/inspect,/apps/bundle_demo/api/files/report` 两个精确端点;后端标准化路径不含代理前缀。保留现有例外,不排除整个插件 API;未经配置的文件请求仍会被拒绝。插件身份、权限、Origin 和 CSRF 校验在例外端点上仍然执行。反向代理也应设置符合业务要求的文件大小限制。 + +同源插件属于可信代码。iframe 仅隔离布局和依赖;它不是阻止恶意插件读取同源数据的安全沙箱。现有主 token 在宿主前端可读,“桥不传 token”不等于不可信同源代码无法接触宿主凭证。 + +SSE 同样依赖 HTTPS,不支持 JSON 加密信封;当前策略会加密事件端点时,需在既有例外列表中追加精确路径 `/apps/bundle_demo/api/events`(不含代理前缀),保留其他 API 的加密。宿主会先检查策略,拒绝未配置的流请求,不会自动降级。反向代理须关闭该接口的响应缓冲并配置超时,示例同时返回 `X-Accel-Buffering: no`。宿主退出、重载和会话失效会取消当前页面的流。 + +ASGI 网关对已建立的 SSE/WebSocket 按 15 秒间隔复核登录、插件启用状态和权限,单次复核设置 5 秒超时,不续期主登录。权限减配、会话失效、查询失败或超时会关闭连接。SSE 的保留事件 `ruoyi.plugin.closed` 由宿主适配器拦截,页面显示“实时连接已由宿主关闭”,不会误报正常完成;已处理游标仍保留,重新连接须通过当前身份和权限检查。业务不要使用该保留事件名。更新后端时应同步更新宿主前端;旧适配器不能识别关闭通知。示例默认 10 条事件约 5 秒即完成,通常不会触发周期复核;长时间运行的业务应按[生命周期说明](../../../../docs/plugin_development.md#67-asgi-子应用与生命周期)处理取消与资源释放。 + +## 代理前缀与验证 + +默认 `VITE_APP_PLUGIN_BASE` 为空,浏览器从当前源访问 `/apps/` 和 `/plugin/runtime/`。宿主 Vite 已提供这两条保留原路径、Host 的开发代理,Docker Nginx 示例也已更新。 + +如果部署使用 `/prefix`,同时设置后端 `APP_ROOT_PATH=/prefix` 和宿主 `VITE_APP_PLUGIN_BASE=/prefix`,代理入口对应为 `/prefix/apps/`、`/prefix/plugin/runtime/`,转发时保留前缀与外部 Host。Cookie Path 和注入的运行基址会包含 `/prefix`;请求 AAD 剥离该前缀后仍保留 `/apps/bundle_demo/api/...`,不能缩短为 `/api/...`。可信代理和 ASGI 服务器须正确传递外部 HTTPS scheme,否则 Origin 或 Secure Cookie 行为会不一致。 + +从后端目录可执行定向验证: + +```bash +python -m pytest tests/plugins/core/runtime/test_bundle.py tests/plugins/core/runtime/test_browser_session.py tests/plugins/core/runtime/test_bundle_transport.py tests/plugins/core/runtime/test_bundle_files.py tests/plugins/core/runtime/test_bundle_events.py tests/plugins/core/runtime/test_connections.py tests/plugins/core/runtime/test_connection_sessions.py tests/plugins/core/runtime/test_configuration.py tests/module_admin/service/test_login_plugin_routes.py -q +npm --prefix ../ruoyi-fastapi-frontend run test:plugin +``` + +测试覆盖静态边界、Cookie/CSRF/退出、真实 RSA/AES 协议、文件大小、`root_path`、流连接复核及运行时关闭顺序;浏览器会话测试替换真实账号查询,配置读取测试使用隔离 SQLite 验证保存、解密及重启版本。实际角色配置、TLS/Nginx、多 worker 和 ASGI 服务器的优雅停机阶段仍需部署验收。网关的周期关闭与协作式取消不能强制终止忽略取消的 Python 代码,也不提供跨 worker 的即时撤销广播。 diff --git a/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/__init__.py b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/__init__.py new file mode 100644 index 0000000..be42793 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/__init__.py @@ -0,0 +1,67 @@ +from fastapi import FastAPI, Request +from pydantic import BaseModel, Field + +from plugins.core.sdk import PluginDefinition, PluginHostContext, PluginRequestContext, plugin_endpoint +from utils.time_util import TimezoneUtil + +from .events import register_event_routes +from .files import register_file_routes + + +class EchoInput(BaseModel): + """ + 用于验证 POST 链路的回显请求参数。 + """ + + message: str = Field(min_length=1, max_length=80) + + +async def summary(context: PluginRequestContext) -> dict[str, str]: + snapshot = await context.host.read_config() + return { + 'pluginId': context.host.plugin_id, + 'userName': context.user.user.user_name, + 'serverTime': TimezoneUtil.utc_now().isoformat(), + 'greeting': str(snapshot.values.get('greeting', '')), + 'configRevision': snapshot.revision, + } + + +def create_plugin(host: PluginHostContext) -> PluginDefinition: + """ + 声明仅提供相对 API 的子应用,页面资源由宿主交付。 + + :param host: 宿主提供的插件上下文 + :return: 插件能力声明 + """ + + def create_app(context: PluginHostContext) -> FastAPI: + """ + 创建独立前端示例需要的查询和回显接口。 + + :param context: 宿主提供的插件上下文 + :return: 插件 ASGI 子应用 + """ + app = FastAPI(title='独立前端示例', docs_url=None, redoc_url=None) + register_file_routes(app) + register_event_routes(app) + app.add_api_route( + '/api/summary', + plugin_endpoint(summary, permission='bundle_demo:view'), + summary='获取插件页面概览接口', + description='用于获取当前插件标识、登录用户名和服务器时间', + response_model=dict[str, str], + ) + + @app.post( + '/api/echo', + summary='回显插件页面消息接口', + description='用于回显插件页面提交的消息并返回服务器时间', + ) + async def echo(payload: EchoInput, request: Request) -> dict[str, str]: + request.state.plugin_context.require_permission('bundle_demo:view') + return {'message': payload.message, 'serverTime': TimezoneUtil.utc_now().isoformat()} + + return app + + return PluginDefinition(app_factory=create_app) diff --git a/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/events.py b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/events.py new file mode 100644 index 0000000..7c26244 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/events.py @@ -0,0 +1,54 @@ +import asyncio +import json +from collections.abc import AsyncIterator +from typing import Annotated + +from fastapi import FastAPI, HTTPException, Query, Request, status +from starlette.responses import StreamingResponse + +from utils.time_util import TimezoneUtil + +MAX_CURSOR_LENGTH = 2 + + +def register_event_routes(app: FastAPI, *, permission: str = 'bundle_demo:view', interval: float = 0.5) -> None: + """ + 注册有限时长的事件流示例,通过数字游标演示显式恢复接收。 + + :param app: 当前插件的 ASGI 子应用 + :param permission: 访问事件流所需的接口权限 + :param interval: 示例事件发送间隔,单位为秒 + :return: None + """ + + @app.get( + '/api/events', + summary='接收插件示例实时事件接口', + description='返回最多 20 条编号事件;Last-Event-ID 指定最后收到的编号,重连仅补发后续编号', + response_class=StreamingResponse, + ) + async def events(request: Request, count: Annotated[int, Query(ge=1, le=20)] = 10) -> StreamingResponse: + request.state.plugin_context.require_permission(permission) + cursor = request.headers.get('last-event-id', '0') + if not cursor.isascii() or not cursor.isdigit() or len(cursor) > MAX_CURSOR_LENGTH or int(cursor) > count: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, detail='事件游标无效') + + async def generate() -> AsyncIterator[str]: + """ + 逐条生成有界示例事件,由 ASGI 响应负责取消断开的请求。 + + :return: 已编码的 SSE 事件片段 + """ + for index in range(int(cursor) + 1, count + 1): + await asyncio.sleep(interval) + data = json.dumps( + {'index': index, 'total': count, 'serverTime': TimezoneUtil.utc_now().isoformat()}, + ensure_ascii=False, + ) + yield f'id: {index}\nevent: tick\ndata: {data}\n\n' + + return StreamingResponse( + generate(), + media_type='text/event-stream', + headers={'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no'}, + ) diff --git a/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/files.py b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/files.py new file mode 100644 index 0000000..3262684 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/files.py @@ -0,0 +1,87 @@ +import hashlib + +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import Response +from pydantic import BaseModel, Field +from starlette import status +from starlette.datastructures import UploadFile +from starlette.exceptions import HTTPException as StarletteHTTPException +from starlette.formparsers import MultiPartException +from starlette.types import Message + +MAX_FILE_BYTES = 10 * 1024 * 1024 +MAX_FORM_BYTES = 64 * 1024 + + +class FileInspection(BaseModel): + """ + 返回文件元数据与摘要,不保存上传内容。 + """ + + filename: str = Field(description='客户端提交的文件名,仅作展示') + size: int = Field(description='文件实际字节数') + sha256: str = Field(description='文件内容的 SHA-256 摘要') + + +def register_file_routes(app: FastAPI, *, permission: str = 'bundle_demo:view') -> None: + """ + 注册有界上传检查和固定报表下载接口,便于验证文件桥的真实 HTTP 链路。 + + :param app: 插件所属的子应用 + :param permission: 两个文件接口要求的插件权限 + :return: None + """ + + @app.post( + '/api/files/inspect', + summary='检查插件上传文件接口', + description='校验权限后接收最多 10 MiB 的单文件,返回摘要且不持久化内容', + ) + async def inspect_file(request: Request) -> FileInspection: + request.state.plugin_context.require_permission(permission) + received = 0 + + async def limited_receive() -> Message: + """ + 累计实际请求体大小,在解析 multipart 之前执行硬上限检查。 + + :return: 当前 ASGI 消息 + """ + nonlocal received + message = await request.receive() + received += len(message.get('body', b'')) + if received > MAX_FILE_BYTES + MAX_FORM_BYTES: + # 使用解析器异常以关闭已创建的临时文件,再在外层转换为 413。 + raise MultiPartException('上传请求超过大小限制') + return message + + bounded_request = Request(request.scope, limited_receive) + try: + async with bounded_request.form(max_files=1, max_fields=16, max_part_size=MAX_FORM_BYTES) as form: + file = form.get('file') + if not isinstance(file, UploadFile): + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, '缺少 file 文件字段') + if file.size is None or file.size > MAX_FILE_BYTES: + raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, '文件不能超过 10 MiB') + digest = hashlib.sha256() + while content := await file.read(MAX_FORM_BYTES): + digest.update(content) + return FileInspection(filename=file.filename or 'upload.bin', size=file.size, sha256=digest.hexdigest()) + except StarletteHTTPException as exc: + if received > MAX_FILE_BYTES + MAX_FORM_BYTES: + raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, '上传请求超过大小限制') from exc + raise + + @app.get( + '/api/files/report', + summary='下载插件示例报表接口', + description='校验插件权限后下载固定 CSV 示例,响应不包含用户或配置数据', + response_class=Response, + ) + async def download_report(request: Request) -> Response: + request.state.plugin_context.require_permission(permission) + return Response( + '\ufeffname,value\nmode,authenticated-plugin\n', + media_type='text/csv; charset=utf-8', + headers={'Content-Disposition': 'attachment; filename="plugin-report.csv"', 'Cache-Control': 'no-store'}, + ) diff --git a/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/plugin.yaml b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/plugin.yaml new file mode 100644 index 0000000..197ae75 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/plugin.yaml @@ -0,0 +1,37 @@ +manifestVersion: 2 +id: bundle_demo +name: 独立前端示例 +version: 1.0.0 +description: 独立构建前端通过宿主会话桥调用受保护的 ASGI 接口。 +backend: + runtime: python + integration: asgi + module: plugins.bundle_demo + entrypoint: plugins.bundle_demo:create_plugin +frontend: + delivery: + type: bundle + bundle: + directory: web/dist + entry: index.html + spaFallback: true + menus: + - name: 独立前端示例 + path: bundle_demo + component: PluginFrame + type: C + icon: component + perms: bundle_demo:view +permissions: + - code: bundle_demo:view + name: 访问独立前端示例 +compatibility: + hostApiVersion: ^1.4.0 + pythonVersion: '>=3.10' +config: + items: + - key: greeting + label: 页面欢迎语 + type: string + default: 欢迎使用独立前端插件 + description: 示例查询接口每次主动读取此项,保存后刷新页面即可查看新文案。 diff --git a/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/.gitignore b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/.prettierrc.json b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/.prettierrc.json new file mode 100644 index 0000000..b615018 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "printWidth": 100, + "semi": false, + "singleQuote": true, + "trailingComma": "es5", + "endOfLine": "lf" +} diff --git a/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/dev.html b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/dev.html new file mode 100644 index 0000000..23d5a92 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/dev.html @@ -0,0 +1,82 @@ + + + + + + 插件本地开发 + + + +
+

插件本地开发 · 模拟宿主

+

使用本地模拟用户和内存数据,不连接真实后端。此页面只供开发使用,不进入生产构建。

+
+ + + + + + 等待连接 +
+
+ + + + diff --git a/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/dev/host.js b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/dev/host.js new file mode 100644 index 0000000..39fc1b8 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/dev/host.js @@ -0,0 +1,51 @@ +import { createPluginHostBridge, validatePluginSession } from '@ruoyi/plugin-bridge' +import { createMockPluginRequest, createMockPluginStream } from './mockRequest.js' + +const frame = document.getElementById('plugin-frame') +let route = '/' +const bridge = createPluginHostBridge({ + pluginId: 'bundle_demo', + session: validatePluginSession( + { + pluginId: 'bundle_demo', + bridgeVersion: 1, + uiBase: '/apps/bundle_demo/ui/', + apiBase: '/apps/bundle_demo/api/', + csrfToken: 'local-mock-session-no-credentials', + expiresIn: 3600, + }, + 'bundle_demo' + ), + getTarget: () => frame.contentWindow, + request: createMockPluginRequest({ + getDelay: () => document.getElementById('delay').value, + shouldFail: () => document.getElementById('failure').checked, + }), + stream: createMockPluginStream({ + getDelay: () => document.getElementById('delay').value, + shouldFail: () => document.getElementById('failure').checked, + }), + getContext: () => ({ + theme: { mode: document.getElementById('theme').value }, + language: 'zh-CN', + timeZone: 'Asia/Shanghai', + route, + }), + onRoute: (value) => { + route = value + bridge.updateRoute(route) + }, + onReady: () => { + document.getElementById('status').textContent = '模拟连接已建立' + }, +}) +frame.src = '/index.html' +document.getElementById('theme').addEventListener('change', () => bridge.updatePreferences()) +document + .getElementById('reload') + .addEventListener('click', () => frame.contentWindow.location.reload()) +document.getElementById('logout').addEventListener('click', () => { + bridge.destroy({ logout: true }) + document.getElementById('status').textContent = '模拟会话已退出;刷新整个页面可重新连接' +}) +window.addEventListener('pagehide', () => bridge.destroy(), { once: true }) diff --git a/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/dev/mockRequest.js b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/dev/mockRequest.js new file mode 100644 index 0000000..303bdb7 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/dev/mockRequest.js @@ -0,0 +1,114 @@ +/** + * 创建仅供本地开发的内存请求适配器,不发送网络请求或保存文件。 + * + * @param {Object} options 延迟和故障注入选项 + * @returns {Function} 可传入宿主桥的异步请求函数 + */ +export function createMockPluginRequest({ getDelay = () => 300, shouldFail = () => false } = {}) { + const pause = (signal) => + new Promise((resolve, reject) => { + if (signal?.aborted) return reject(new Error('请求已取消')) + const cancel = () => { + clearTimeout(timer) + reject(new Error('请求已取消')) + } + const timer = setTimeout( + () => { + signal?.removeEventListener('abort', cancel) + resolve() + }, + Math.max(0, Math.min(5000, Number(getDelay()) || 0)) + ) + signal?.addEventListener('abort', cancel, { once: true }) + }) + return async (config) => { + const prefix = '/apps/bundle_demo/api/' + if (!config.url.startsWith(prefix)) throw new Error('模拟宿主只处理当前插件 API') + const path = config.url.slice(prefix.length) + await pause(config.signal) + if (shouldFail()) throw new Error('模拟接口失败') + if (config.method === 'get' && path === 'summary') { + return { + pluginId: 'bundle_demo', + userName: '本地模拟用户', + serverTime: new Date().toISOString(), + greeting: '本地模拟连接已就绪,文件仅在浏览器内处理。', + } + } + if (config.method === 'post' && path === 'echo') { + return { + message: config.data.message, + serverTime: new Date().toISOString(), + } + } + if (config.method === 'post' && path === 'files/inspect') { + const file = config.data.get('file') + if (!(file instanceof Blob)) throw new Error('缺少文件') + config.onUploadProgress?.({ + loaded: Math.floor(file.size / 2), + total: file.size, + }) + await pause(config.signal) + config.onUploadProgress?.({ loaded: file.size, total: file.size }) + const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer()) + return { + filename: file.name, + size: file.size, + sha256: Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, '0') + ).join(''), + } + } + if (config.method === 'get' && path === 'files/report') { + const report = new Blob(['\uFEFFname,value\nmode,local-mock\n'], { + type: 'text/csv;charset=utf-8', + }) + config.onDownloadProgress?.({ loaded: report.size, total: report.size }) + return report + } + throw new Error('模拟接口未实现') + } +} + +/** 创建逐条产出事件的本地适配器,复用延迟和故障开关,支持取消与游标恢复。 */ +export function createMockPluginStream({ getDelay = () => 300, shouldFail = () => false } = {}) { + return async function* (config) { + if (config.url !== '/apps/bundle_demo/api/events') throw new Error('模拟实时接口未实现') + const count = Number(config.params?.count ?? 10) + const cursorText = config.lastEventId || '0' + const cursor = Number(cursorText) + if ( + !Number.isInteger(count) || + count < 1 || + count > 20 || + !/^[0-9]{1,2}$/.test(cursorText) || + !Number.isInteger(cursor) || + cursor < 0 || + cursor > count + ) + throw new Error('模拟事件参数无效') + for (let index = cursor + 1; index <= count; index++) { + await new Promise((resolve, reject) => { + if (config.signal.aborted) return reject(new Error('已取消')) + const cancel = () => { + clearTimeout(timer) + reject(new Error('已取消')) + } + const timer = setTimeout( + () => { + config.signal.removeEventListener('abort', cancel) + resolve() + }, + Math.max(100, Math.min(5000, Number(getDelay()) || 0)) + ) + config.signal.addEventListener('abort', cancel, { once: true }) + }) + if (shouldFail()) throw new Error('模拟实时接口失败') + yield { + event: 'tick', + id: String(index), + data: JSON.stringify({ index, total: count, serverTime: new Date().toISOString() }), + } + } + } +} diff --git a/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/index.html b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/index.html new file mode 100644 index 0000000..0465941 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/index.html @@ -0,0 +1,98 @@ + + + + + + 独立前端示例 + + +
+
+
+

插件应用

+

独立前端示例

+

查看当前会话,并验证页面与服务端的通信。

+
+ +
+

正在连接管理平台…

+
+
+

当前用户

+

等待连接

+
+
+

显示时区

+

等待连接

+
+
+

服务端时间

+

等待连接

+
+
+
+ +
+

发送一条测试消息

+

消息仅用于回显,不保存到业务数据库。

+
+ +
+ + +
+
+

+ 发送后将在这里显示结果。 +

+
+

文件传输

+

上传仅检查文件摘要,不保存内容。单文件最多 10 MiB。

+
+ +
+ + +
+
+
+ + +
+ +

+ 等待选择文件或下载报表。 +

+
+
+

实时事件

+

逐条接收服务端状态。停止或断开后,可从上次收到的位置继续。

+
+ + +
+

尚未开始接收。

+
    +
    +
    + +
    +
    + + + diff --git a/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/package.json b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/package.json new file mode 100644 index 0000000..7a338ee --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/package.json @@ -0,0 +1,13 @@ +{ + "name": "ruoyi-plugin-bundle-demo", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1 --open /dev.html", + "build": "vite build" + }, + "devDependencies": { + "vite": "6.4.3" + } +} diff --git a/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/src/main.js b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/src/main.js new file mode 100644 index 0000000..a3539d3 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/src/main.js @@ -0,0 +1,220 @@ +import { createPluginClient } from '@ruoyi/plugin-bridge' +import './style.css' + +// 统一按 ID 获取示例页面中的固定元素。 +const element = (id) => document.getElementById(id) +let client +let context = {} +let transferController +let streamController +let lastEventId = '' + +/** 逐条显示事件,保留游标供用户主动恢复,并限制页面中的历史数量。 */ +async function receiveEvents() { + if (streamController) return + const controller = new AbortController() + streamController = controller + const start = element('start-stream') + const stop = element('stop-stream') + const status = element('stream-status') + const list = element('stream-events') + if (!lastEventId) list.replaceChildren() + start.disabled = true + stop.disabled = false + status.textContent = '正在连接实时事件…' + try { + await client.stream( + { path: 'events', params: { count: 10 }, lastEventId }, + { + signal: controller.signal, + onEvent: (event) => { + const value = JSON.parse(event.data) + lastEventId = event.id + const item = document.createElement('li') + item.textContent = `事件 ${value.index} / ${value.total} · ${value.serverTime}` + list.append(item) + while (list.children.length > 5) list.firstElementChild.remove() + status.textContent = `已收到 ${value.index} / ${value.total} 条事件。` + }, + } + ) + status.textContent = '本次事件接收完成,可重新开始。' + lastEventId = '' + } catch (error) { + status.textContent = controller.signal.aborted + ? '已停止接收,可从上次位置继续。' + : `${error.message},可继续接收。` + } finally { + streamController = null + start.textContent = lastEventId ? '继续接收' : '接收实时事件' + start.disabled = false + stop.disabled = true + } +} +element('start-stream').addEventListener('click', receiveEvents) +element('stop-stream').addEventListener('click', () => streamController?.abort()) + +/** + * 传输期间禁止重复提交,完成或失败后允许重试。 + */ +async function transfer(kind) { + if (transferController) return + transferController = new AbortController() + for (const id of ['upload', 'download', 'file']) element(id).disabled = true + element('cancel-transfer').disabled = false + const progress = element('file-progress') + const resultElement = element('file-result') + const controls = ['upload', 'download', 'file'].map(element) + const cancelButton = element('cancel-transfer') + progress.hidden = false + progress.removeAttribute('value') + resultElement.textContent = '正在传输…' + const options = { + signal: transferController.signal, + onProgress: ({ loaded, total }) => { + if (total) progress.value = Math.round((loaded * 100) / total) + else progress.removeAttribute('value') + resultElement.textContent = total + ? `已传输 ${loaded} / ${total} 字节,等待服务端处理…` + : `已传输 ${loaded} 字节,等待服务端处理…` + }, + } + try { + if (kind === 'upload') { + const file = element('file').files[0] + if (!file) throw new Error('请先选择文件') + const result = await client.upload({ path: 'files/inspect', file }, options) + resultElement.textContent = `已检查 ${result.filename}(${result.size} 字节),SHA-256:${result.sha256}` + } else { + const blob = await client.download({ path: 'files/report' }, options) + const url = URL.createObjectURL(blob) + const link = Object.assign(document.createElement('a'), { + href: url, + download: 'plugin-report.csv', + }) + document.body.append(link) + link.click() + link.remove() + setTimeout(() => URL.revokeObjectURL(url), 1000) + resultElement.textContent = `报表已接收(${blob.size} 字节),已交给浏览器保存。` + } + progress.value = 100 + } catch (error) { + resultElement.textContent = error.message + progress.hidden = true + } finally { + transferController = null + for (const control of controls) control.disabled = false + cancelButton.disabled = true + } +} +element('file-form').addEventListener('submit', (event) => { + event.preventDefault() + transfer('upload') +}) +element('download').addEventListener('click', () => transfer('download')) +element('cancel-transfer').addEventListener('click', () => transferController?.abort()) + +/** + * 更新连接提示,并区分错误状态。 + */ +const showNotice = (text, error = false) => { + if (!element('notice')) return + element('notice').textContent = text + element('notice').classList.toggle('error', error) +} +/** + * 同步请求忙碌状态与刷新按钮,供页面和辅助技术读取。 + */ +const setBusy = (busy) => { + element('workspace').setAttribute('aria-busy', String(busy)) + if (element('refresh')) element('refresh').disabled = busy +} +/** + * 应用宿主推送的主题、语言、时区和插件内部路由。 + */ +const applyContext = (next) => { + context = { ...context, ...next } + document.documentElement.dataset.theme = context.theme?.mode || 'light' + document.documentElement.lang = context.language || 'zh-CN' + element('time-zone').textContent = context.timeZone || 'UTC' + const details = context.route === '/details' + element('overview-panel').hidden = details + element('details-panel').hidden = !details + element('overview').setAttribute('aria-current', details ? 'false' : 'page') + element('details').setAttribute('aria-current', details ? 'page' : 'false') +} +/** + * 通过宿主桥接会话查询当前用户和服务端时间。 + */ +const refresh = async () => { + setBusy(true) + try { + const result = await client.request({ method: 'GET', path: 'summary' }) + element('user-name').textContent = result.userName + element('server-time').textContent = new Intl.DateTimeFormat('zh-CN', { + timeZone: context.timeZone || 'UTC', + dateStyle: 'short', + timeStyle: 'medium', + }).format(new Date(result.serverTime)) + showNotice(result.greeting || '已连接管理平台,当前会话有效。') + } catch (error) { + showNotice(`${error.message},可点击“刷新状态”重试。`, true) + } finally { + setBusy(false) + } +} +element('refresh').addEventListener('click', refresh) +element('overview').addEventListener('click', () => client?.navigate('/')) +element('details').addEventListener('click', () => client?.navigate('/details')) +// POST 请求同样交给宿主桥接层处理身份和传输策略,页面不保存管理员 token。 +element('echo-form').addEventListener('submit', async (event) => { + event.preventDefault() + const button = element('send') + const result = element('echo-result') + button.disabled = true + result.textContent = '正在发送…' + try { + const response = await client.request({ + method: 'POST', + path: 'echo', + data: { message: element('message').value }, + }) + result.textContent = `服务端已收到:${response.message}` + } catch (error) { + result.textContent = error.message + } finally { + button.disabled = false + } +}) +/** + * 建立宿主会话,并订阅偏好、路由、刷新和退出登录事件。 + */ +async function connect() { + try { + const config = JSON.parse(element('ruoyi-plugin-config').textContent) + client = createPluginClient({ pluginId: config.pluginId }) + client.subscribe(({ type, payload }) => { + if (type === 'preferences' || type === 'route') applyContext(payload) + if (type === 'refresh') refresh() + if (type === 'logout') { + transferController?.abort() + streamController?.abort() + element('workspace').replaceChildren( + Object.assign(document.createElement('p'), { + textContent: '登录已结束,请重新登录管理平台。', + }) + ) + } + }) + applyContext(await client.ready) + await refresh() + } catch (error) { + showNotice(error.message || '请从管理平台打开插件页面。', true) + setBusy(false) + if (element('send')) element('send').disabled = true + } +} +connect() +// 页面离开时释放桥接层的事件监听和待处理请求。 +window.addEventListener('pagehide', () => client?.destroy(), { once: true }) diff --git a/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/src/style.css b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/src/style.css new file mode 100644 index 0000000..3ba28a2 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/src/style.css @@ -0,0 +1,220 @@ +:root { + font-family: 'Microsoft YaHei', system-ui, sans-serif; + color: #303133; + background: #f5f7fa; + font-size: 16px; + line-height: 1.6; + color-scheme: light; + --surface: #fff; + --muted: #606266; + --border: #dcdfe6; + --primary: #2563b9; +} +* { + box-sizing: border-box; +} +body { + margin: 0; +} +main { + max-width: 1120px; + margin: auto; + padding: 32px; +} +header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + margin-bottom: 24px; +} +h1 { + font-size: 26px; + line-height: 1.35; + margin: 4px 0 12px; + font-weight: 600; +} +h2 { + font-size: 16px; + margin: 0 0 8px; + font-weight: 600; +} +p { + margin: 0; +} +.eyebrow { + color: var(--primary); + font-size: 13px; +} +.description { + color: var(--muted); + font-size: 14px; + max-width: 760px; + margin-bottom: 12px; +} +.notice { + border: 1px solid #b3d8c5; + color: #205a3d; + background: #eef8f1; + border-radius: 5px; + padding: 12px 16px; + margin-bottom: 24px; + font-size: 14px; +} +.notice.error { + color: #9b3232; + background: #fef0f0; + border-color: #f4bebe; +} +.facts { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 20px; + margin-bottom: 24px; +} +article, +.panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + padding: 24px; +} +article h2 { + color: var(--muted); + font-size: 13px; + font-weight: 400; +} +article p { + overflow-wrap: anywhere; + font-size: 17px; +} +nav { + display: flex; + gap: 12px; + padding-bottom: 20px; + margin-bottom: 24px; + border-bottom: 1px solid var(--border); +} +nav button { + color: var(--muted); + background: transparent; + border-color: transparent; +} +nav button[aria-current='page'] { + color: var(--primary); + background: #eaf2fd; +} +button { + min-height: 44px; + padding: 8px 18px; + border: 1px solid var(--primary); + border-radius: 5px; + background: var(--primary); + color: #fff; + font: inherit; + font-size: 14px; + cursor: pointer; + white-space: nowrap; +} +button:hover { + filter: brightness(0.93); +} +button:disabled { + cursor: wait; + opacity: 0.6; +} +button:focus-visible, +input:focus-visible { + outline: 3px solid #8db7ed; + outline-offset: 3px; +} +label { + display: block; + font-size: 14px; + margin: 20px 0 8px; +} +.input-row { + display: flex; + gap: 12px; + max-width: 700px; +} +input { + border: 1px solid var(--border); + border-radius: 5px; + padding: 10px 12px; + min-width: 0; + flex: 1; + font: inherit; + color: inherit; + background: var(--surface); +} +.result { + color: var(--muted); + min-height: 48px; + padding-top: 20px; + font-size: 14px; + overflow-wrap: anywhere; +} +.file-panel { + margin-top: 24px; + padding-top: 24px; + border-top: 1px solid var(--border); +} +.file-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-top: 16px; +} +progress { + width: 100%; + margin-top: 16px; + accent-color: var(--primary); +} +.stream-events { + padding-left: 24px; + color: var(--muted); + font-size: 14px; + min-height: 120px; + overflow-wrap: anywhere; +} +:root[data-theme='dark'] { + background: #141414; + color: #e5eaf3; + --surface: #1d1e1f; + --muted: #a3a6ad; + --border: #414243; + --primary: #75b4ff; + color-scheme: dark; +} +:root[data-theme='dark'] button { + color: #141414; +} +:root[data-theme='dark'] nav button { + color: var(--muted); +} +:root[data-theme='dark'] nav button[aria-current='page'] { + background: #203958; + color: #b6d7ff; +} +@media (max-width: 680px) { + main { + padding: 20px 16px; + } + header { + align-items: flex-start; + flex-direction: column; + gap: 12px; + } + .facts { + grid-template-columns: 1fr; + gap: 12px; + } + article, + .panel { + padding: 20px; + } + .input-row { + flex-direction: column; + } +} diff --git a/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/vite.config.js b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/vite.config.js new file mode 100644 index 0000000..7b87f88 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/vite.config.js @@ -0,0 +1,37 @@ +import { fileURLToPath, URL } from 'node:url' + +// 桥接 SDK 没有外部依赖,独立插件仓库可复制该模块并核对宿主版本兼容性。 +export default { + base: './', + plugins: [ + { + name: 'local-plugin-config', + apply: 'serve', + transformIndexHtml(html, context) { + if (context.path !== '/index.html' && context.path !== '/') return html + return { + html, + tags: [ + { + tag: 'script', + attrs: { id: 'ruoyi-plugin-config', type: 'application/json' }, + children: JSON.stringify({ pluginId: 'bundle_demo' }), + injectTo: 'head', + }, + ], + } + }, + }, + ], + resolve: { + alias: { + '@ruoyi/plugin-bridge': fileURLToPath( + new URL( + '../../../../../../ruoyi-fastapi-frontend/src/utils/pluginBridge.js', + import.meta.url + ) + ), + }, + }, + build: { sourcemap: false }, +} diff --git a/ruoyi-fastapi-backend/plugins/examples/python/task_demo/README.md b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/README.md new file mode 100644 index 0000000..b66d25f --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/README.md @@ -0,0 +1,98 @@ +# 持久化任务示例 + +`task_demo` 是 manifest v2 的 Python ASGI + 独立 bundle 业务示例,当前版本 `1.1.0`。页面提供新增、编辑、删除、状态筛选和分页;数据保存在宿主默认数据源的 `ruoyi_plugin_task_demo` 表中。它演示插件级共享任务清单:有查看权限的用户看到同一组任务,未实现个人、部门或租户数据隔离。 + +源码位于 `plugins/examples/python/task_demo`,示例目录不参与运行时插件扫描。下面的命令均在后端根目录、已激活并安装宿主依赖的 Python 3.10–3.13 环境执行,适用于 Windows PowerShell、macOS 和 Linux;前端构建使用 Node.js 22。 + +## 构建与测试 + +```bash +npm --prefix plugins/examples/python/task_demo/web install --no-audit --no-fund --package-lock=false +npm --prefix plugins/examples/python/task_demo/web test +npm --prefix plugins/examples/python/task_demo/web run build +python plugins/examples/python/task_demo/build_release.py --output target/task-demo-1.1.0 +python -m pytest tests/plugins/examples/test_task_demo.py tests/plugins/core/deployment/test_task_delivery.py -q +``` + +每次使用不存在的新输出目录。交付和签名输入为 `target/task-demo-1.1.0/task_demo`,包含清单、三个 Python 业务模块、MySQL/PostgreSQL 各两份 SQL 和 `web/dist`;不包含开发页面、测试、依赖目录、构建脚本或文件清单。Python 后端仍以源码交付。 + +`release-files.json` 使用 `schemaVersion: 1` 和显式 `files` 数组列出业务 `.py`、`.sql` 文件;`plugin.yaml` 和已构建的 `web/dist` 自动纳入。新增模块或迁移时同时更新这份清单与 `plugin.yaml` 中的相应声明。构建器不展开通配符、不递归收集 Python 源码,不导入插件、不执行 npm,也不生成签名;缺失文件、非法路径、链接和开发资源会在建立输出目录前被拒绝。旧工程可保留已有构建器;该清单是当前生成脚本的输入,不会自动升级旧脚本。 + +示例前端通过 Vite 别名引用同一仓库的宿主桥接 SDK。需要把示例移到独立仓库时,按[脚手架与 SDK 溯源说明](../../../../docs/plugin_development.md#161-桥接-sdk-溯源与显式更新)使用 `python-bundle` 工程的 `web/vendor` 及构建配置,再迁入业务代码。 + +## 本地页面开发 + +```bash +npm --prefix plugins/examples/python/task_demo/web run dev +``` + +打开开发服务的 `/dev.html`,可预览任务清单、主题、只读状态和失败反馈。开发宿主使用内存数据,刷新页面会重置,不能验证真实数据库事务或宿主权限。生产构建只包含业务页面,不包含此开发宿主;正式访问应从宿主菜单打开 `PluginFrame`。 + +## 安装与权限 + +开发环境可将构建出的 `task_demo` 目录复制到后端 `plugins/task_demo`,然后执行: + +```bash +ruoyi plugin check task_demo --env=dev +ruoyi plugin install task_demo --env=dev --yes +ruoyi plugin enable task_demo --env=dev --yes +``` + +重启后端后,为角色授予 `task_demo:view`,重新获取菜单即可查看任务;需要增删改时另授予 `task_demo:write`。页面根据接口返回的 `canWrite` 显示只读状态,每个业务接口仍独立校验权限。写权限不隐含查看权限,完整页面操作应授予两者。 + +首次引入 bundle 支持时需构建包含 `PluginFrame` 的宿主前端;之后更新此插件只需重建插件页面。子页面通过 `createPluginClient` 调用自身 API,无独立登录,不读取或传递管理员 token。JSON 请求遵循宿主的插件会话、CSRF 和传输加密策略,无需为本示例增加加密例外。 + +## 接口与事务 + +以下路径相对于 `/apps/task_demo/api`。桥接 SDK 中使用 `path: 'tasks'`,查询字段使用 `params`。 + +| 方法与路径 | 权限 | 返回或行为 | +| --- | --- | --- | +| `GET /info` | `task_demo:view` | 插件版本与 `canWrite` | +| `GET /tasks` | `task_demo:view` | `items/total/page/pageSize/canWrite`;支持 `status=todo/done` | +| `GET /tasks/{id}` | `task_demo:view` | 单条任务,不存在返回 404 | +| `POST /tasks` | `task_demo:write` | 新建任务,返回保存后的任务 | +| `PUT /tasks/{id}` | `task_demo:write` | 替换可编辑字段,返回保存后的任务 | +| `DELETE /tasks/{id}` | `task_demo:write` | 删除任务,返回 `deleted: true` | + +请求体包含 `title`(必填、去除两端空白后非空、最长 120 字符)、`description`(默认空、最长 500 字符)、`status`(默认 `todo`,可选 `done`)和 `priority`(默认 `normal`,可选 `high`)。PUT 同样要求标题,未提供的可选字段使用默认值。未知字段和非法输入返回 422,无权限返回 403。ID 由服务器生成;响应时间使用固定毫秒精度的 UTC RFC3339 字符串。数据库列遵循宿主时间规范:MySQL `DATETIME(3)`、PostgreSQL `TIMESTAMP(3) WITH TIME ZONE`,Python 使用公共 `DbUtcDateTime` 类型完成 UTC 转换。 + +分页默认每页 10 条,最多 20 条;字段长度与页大小共同限制 JSON 体积,适配桥接的 64 KiB 消息上限。列表使用稳定排序。所有读写通过 `context.transaction()` 取得独立会话,成功退出提交,异常或取消回滚;不复用宿主鉴权会话,不访问宿主业务 DAO,不在启动或请求中自动建表。修改采取后写覆盖,不提供版本冲突检测;需要并发协同编辑时应扩展版本字段和冲突处理。 + +## 迁移与升级 + +| 版本 | 迁移 | 数据变化 | +| --- | --- | --- | +| `1.0.0` 数据结构 | `001_init.sql` | 建立任务表 | +| `1.1.0` | `002_priority.sql` | 增加 `priority`,既有行默认 `normal` | + +清单同时列出两种数据库的脚本,宿主根据默认数据源类型选择本次方言,按声明顺序运行。首次安装执行两次迁移;从已安装的 1.0 数据结构升级仅执行 002,成功迁移的校验值与历史用于防止重复执行。不要修改已经执行过的 001;后续变更应新增文件并提高插件版本。 + +002 使用 `ALTER TABLE ADD COLUMN`,依靠宿主迁移历史实现只执行一次,不能脱离历史反复手工执行。MySQL DDL 可能隐式提交;中断后应按[迁移恢复手册](../../../../docs/plugin_migration_failure_runbook.md)确认实际结构和历史状态,不能假定失败自动恢复旧结构。代码回滚不撤销数据库迁移。 + +本地测试执行真实 SQLite SQL 和事务,覆盖 CRUD、读写权限、分页、校验、回滚以及旧行保留;将 SQLite 不支持的 PostgreSQL 时间类型替换为 `DATETIME`,迁移文件与校验值保持原样。交付测试从源码构建目录、签名、导入、维护准备及选择目标,再从签名制品加载当前业务代码。SQLite 仅允许单个写事务,因此本地交付夹具的宿主维护会话使用自动提交,并适配宿主菜单自增主键,业务 CRUD 仍用正常事务;它不验证宿主维护事务的数据库原子性。其 1.0 包是仅用于迁移验收的结构夹具,不提供历史页面或历史业务 API;页面资源在本地交付测试中是最小 HTML 夹具。真实数据库 CI 使用原始 SQL 和正常宿主事务,不进行以上适配。 + +真实 MySQL/PostgreSQL 交付验收接入 `scripts/plugin_release_integration.py` 和发布集成 CI:先构建本示例页面,再使用隔离数据库和 Redis 执行相同维护流程,验证旧行保留、当前 CRUD 和只读拒绝。它注入已认证请求上下文,通过 ASGI HTTP 适配器访问业务 API;真实浏览器登录、Cookie/CSRF/TLS 由独立网络 smoke 覆盖。配置工作流不等于已通过远端 CI,应查看对应提交的实际运行结果。 + +## 签名制品与维护发布 + +先按[签名交付手册](../../../../docs/plugin_development.md#15-v2-签名制品与维护发布)启用制品功能,配置外部信任公钥并授权 `task_demo`。签名私钥应位于源码和交付目录之外,以下路径为示例: + +```bash +ruoyi plugin artifact build target/task-demo-1.1.0/task_demo target/task_demo-1.1.0.rpk --key-file ../signing-keys/publisher.pem --key-id publisher --env=dev --output=json +ruoyi plugin artifact verify target/task_demo-1.1.0.rpk --env=prod --output=json +ruoyi plugin artifact import target/task_demo-1.1.0.rpk --env=prod --allow-prod --yes --output=json +ruoyi plugin release plan task_demo DIGEST --env=prod --output=json +``` + +`DIGEST` 替换为实际导入结果。停止全部宿主 worker,在独立维护进程完成准备,然后读取最新 generation 并选择目标: + +```bash +ruoyi plugin release prepare task_demo DIGEST --maintenance --env=prod --allow-prod --yes --output=json +ruoyi plugin release status --plugin-id task_demo --env=prod --output=json +ruoyi plugin release select task_demo DIGEST --expected-generation GENERATION --expected-workers 1 --maintenance --env=prod --allow-prod --yes --output=json +``` + +`GENERATION` 使用准备后最新 status 中的值,worker 数按实际部署调整。首装需确认启用状态;升级保留已有开关,不会自动启用已停用插件。启用操作及其代际要求见发布手册。重启全部 worker 后检查实际目标、健康状态及业务数据,不能以选择目标成功代替运行验收。 + +同名 `plugins/task_demo` 目录不能与签名制品共存;维护时先备份并移出目录插件。不可变制品只保存程序与静态资源,业务数据始终保存在数据库。健康检查验证包含新增优先级字段的任务表可读,缺表或缺列不会被报告为健康。 diff --git a/ruoyi-fastapi-backend/plugins/examples/python/task_demo/__init__.py b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/__init__.py new file mode 100644 index 0000000..5231452 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/__init__.py @@ -0,0 +1,133 @@ +from types import SimpleNamespace +from typing import Any + +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel, ValidationError +from sqlalchemy import select + +from plugins.core.runtime.health import PluginHealthContext +from plugins.core.sdk import PluginDefinition, PluginHostContext, PluginRequestContext +from plugins.core.sdk.asgi import plugin_endpoint + +from . import service +from .models import TaskIdentifier, TaskInput, TaskQuery, tasks + +VERSION = '1.1.0' +_runtime = SimpleNamespace(session_factory=None) + + +def _validate(model: type[BaseModel], values: dict[str, Any]) -> Any: + """ + 校验 SDK 请求快照中的路径和查询参数。 + + :param model: 目标参数模型 + :param values: 原始参数字段 + :return: 已校验的模型;无效输入返回 HTTP 422 + """ + try: + return model.model_validate(values) + except ValidationError as exc: + raise HTTPException( + status_code=422, + detail=exc.errors(include_input=False, include_context=False, include_url=False), + ) from exc + + +def _task_id(context: PluginRequestContext) -> str: + """ + 读取 SDK 已提取的任务路径参数。 + + :param context: 含 HTTP 请求快照的宿主上下文 + :return: 已校验的任务标识 + """ + return _validate(TaskIdentifier, {'id': context.request.path_params['task_id']}).id + + +async def info(context: PluginRequestContext) -> dict[str, Any]: + return {'pluginId': context.host.plugin_id, 'version': VERSION, 'canWrite': service.can_write(context)} + + +async def list_tasks(context: PluginRequestContext) -> dict[str, Any]: + if any(len(values) != 1 for values in context.request.query.values()): + raise HTTPException(status_code=422, detail='查询参数不能重复') + query = _validate(TaskQuery, {key: values[0] for key, values in context.request.query.items()}) + return await service.list_tasks(context, query) + + +async def get_task(context: PluginRequestContext) -> dict[str, Any]: + return await service.get_task(context, _task_id(context)) + + +async def create_task(context: PluginRequestContext) -> dict[str, Any]: + return await service.create_task(context, context.request.body) + + +async def update_task(context: PluginRequestContext) -> dict[str, Any]: + return await service.update_task(context, _task_id(context), context.request.body) + + +async def delete_task(context: PluginRequestContext) -> dict[str, bool]: + return await service.delete_task(context, _task_id(context)) + + +def create_plugin(host: PluginHostContext) -> PluginDefinition: + """ + 声明使用宿主事务能力的任务插件,不在启动时创建表。 + + :param host: 宿主提供的插件能力 + :return: 仅挂载相对 API 的 ASGI 插件声明 + """ + # 仅保存宿主授予的会话工厂,健康检查自行开关会话,不保留请求会话。 + _runtime.session_factory = host.session_factory + + def create_app(context: PluginHostContext) -> FastAPI: + """ + 创建任务管理子应用,各接口独立声明读写权限。 + + :param context: 宿主提供的插件能力 + :return: 任务管理 ASGI 子应用 + """ + app = FastAPI(title='持久化任务示例', docs_url=None, redoc_url=None, openapi_url=None) + app.add_api_route('/api/info', plugin_endpoint(info, permission='task_demo:view'), methods=['GET']) + app.add_api_route('/api/tasks', plugin_endpoint(list_tasks, permission='task_demo:view'), methods=['GET']) + app.add_api_route( + '/api/tasks/{task_id}', plugin_endpoint(get_task, permission='task_demo:view'), methods=['GET'] + ) + app.add_api_route( + '/api/tasks', + plugin_endpoint(create_task, permission='task_demo:write', body_model=TaskInput, max_body_bytes=8192), + methods=['POST'], + status_code=201, + ) + app.add_api_route( + '/api/tasks/{task_id}', + plugin_endpoint(update_task, permission='task_demo:write', body_model=TaskInput, max_body_bytes=8192), + methods=['PUT'], + ) + app.add_api_route( + '/api/tasks/{task_id}', plugin_endpoint(delete_task, permission='task_demo:write'), methods=['DELETE'] + ) + return app + + return PluginDefinition(app_factory=create_app) + + +async def health(context: PluginHealthContext) -> dict[str, Any]: + """ + 只读验证任务表及当前版本新增字段,不执行 DDL 或修复。 + + :param context: 宿主健康检查上下文 + :return: 明确包含布尔 ok 的健康结果 + """ + statement = select(tasks).limit(1) + try: + if context.query_db is not None: + await context.query_db.execute(statement) + else: + if _runtime.session_factory is None: + return {'ok': False, 'status': 'unavailable', 'message': '宿主未提供数据库检查会话'} + async with _runtime.session_factory() as db: + await db.execute(statement) + except Exception: + return {'ok': False, 'status': 'unhealthy', 'message': '任务表或 priority 字段不可读,请检查迁移记录'} + return {'ok': True, 'status': 'healthy', 'message': '任务表及 priority 字段可读'} diff --git a/ruoyi-fastapi-backend/plugins/examples/python/task_demo/build_release.py b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/build_release.py new file mode 100644 index 0000000..4032fd0 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/build_release.py @@ -0,0 +1,233 @@ +import argparse +import json +import shutil +import stat +import unicodedata +from pathlib import Path, PureWindowsPath +from typing import Any + +PLUGIN_ID = 'task_demo' +BUNDLE = True +SOURCE = Path(__file__).resolve().parent +RELEASE_MANIFEST = 'release-files.json' +MANIFEST_VERSION = 1 +MAX_MANIFEST_BYTES = 64 * 1024 +MAX_SOURCE_FILES = 1024 +MAX_PATH_LENGTH = 1024 +MAX_PATH_COMPONENTS = 32 +MAX_COMPONENT_LENGTH = 255 +FIRST_PRINTABLE_CHARACTER = 32 +DELETE_CHARACTER = 127 +SOURCE_EXTENSIONS = {'.py', '.sql'} +FORBIDDEN_DIRECTORIES = { + 'tests', + 'test', + 'keys', + 'vendor', + 'node_modules', + '__pycache__', + 'build', + 'dist', + 'target', + 'web', +} +FORBIDDEN_FILES = {'build_release.py', 'conftest.py', 'setup.py'} +WEB_EXTENSIONS = { + '.html', + '.js', + '.css', + '.svg', + '.png', + '.jpg', + '.jpeg', + '.gif', + '.webp', + '.ico', + '.woff', + '.woff2', + '.ttf', +} +REPARSE_POINT = getattr(stat, 'FILE_ATTRIBUTE_REPARSE_POINT', 0x400) + + +def checked_file(path: Path) -> Path: + """ + 校验交付文件位于源码目录内,且不含链接、重解析点或硬链接。 + + :param path: 待交付的源码文件路径 + :return: 通过检查的源码文件路径 + """ + for part in (path, *path.parents): + if part == SOURCE: + break + metadata = part.lstat() + if part.is_symlink() or getattr(metadata, 'st_file_attributes', 0) & REPARSE_POINT: + raise ValueError(f'交付资源不能是链接或重解析点:{part}') + resolved = path.resolve() + if not resolved.is_relative_to(SOURCE) or not path.is_file(): + raise ValueError(f'交付文件必须位于源码目录:{path}') + if path.stat().st_nlink > 1: + raise ValueError(f'交付文件不能是硬链接:{path}') + return path + + +def checked_relative_path(value: str) -> tuple[str, ...]: + """ + 校验明确的跨平台相对路径,不接受通配符、隐藏组件或设备路径。 + + :param value: 清单或 bundle 中的相对路径 + :return: 未经折叠或重写的路径组件 + """ + if not isinstance(value, str): + raise ValueError('交付路径必须为字符串') + parts = value.split('/') + if ( + not value + or len(value) > MAX_PATH_LENGTH + or len(parts) > MAX_PATH_COMPONENTS + or unicodedata.normalize('NFC', value) != value + or any( + not part + or part.startswith('.') + or part.endswith((' ', '.')) + or len(part) > MAX_COMPONENT_LENGTH + or PureWindowsPath(part).is_reserved() + or any( + char in '<>:"\\|?*[]' or ord(char) < FIRST_PRINTABLE_CHARACTER or ord(char) == DELETE_CHARACTER + for char in part + ) + for part in parts + ) + ): + raise ValueError(f'交付清单包含不安全路径:{value!r}') + return tuple(parts) + + +def checked_inventory(names: list[str]) -> None: + """ + 拒绝重复文件、文件与目录冲突以及任一级目录的大小写歧义。 + + :param names: 全部交付文件的相对路径 + :return: None + """ + spellings: dict[str, str] = {} + files: set[str] = set() + directories: set[str] = set() + for name in names: + parts = checked_relative_path(name) + key = name.casefold() + if key in files: + raise ValueError(f'交付清单包含重复文件或大小写冲突:{name}') + files.add(key) + for count in range(1, len(parts) + 1): + prefix = '/'.join(parts[:count]) + if spellings.setdefault(prefix.casefold(), prefix) != prefix: + raise ValueError(f'交付清单包含目录大小写冲突:{name}') + if count < len(parts): + directories.add(prefix.casefold()) + if files & directories: + raise ValueError('交付清单存在文件与父目录冲突') + + +def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + """ + 解析 JSON 对象时拒绝重复字段,避免不同工具对同一清单作不同解释。 + + :param pairs: JSON 对象按原始顺序出现的字段 + :return: 不包含重复字段的对象 + """ + result = {} + for key, value in pairs: + if key in result: + raise ValueError(f'交付清单包含重复字段:{key}') + result[key] = value + return result + + +def source_resources() -> list[Path]: + """ + 按有大小和数量上限的显式清单读取 Python、SQL 资源,不扫描业务源码。 + + :return: 已通过路径、类型和链接检查的源码文件列表 + """ + path = checked_file(SOURCE / RELEASE_MANIFEST) + with path.open('rb') as source: + content = source.read(MAX_MANIFEST_BYTES + 1) + if len(content) > MAX_MANIFEST_BYTES: + raise ValueError('release-files.json 超过 64 KiB 上限') + manifest = json.loads(content.decode('utf-8-sig'), object_pairs_hook=unique_object) + if ( + not isinstance(manifest, dict) + or set(manifest) != {'schemaVersion', 'files'} + or type(manifest['schemaVersion']) is not int + or manifest['schemaVersion'] != MANIFEST_VERSION + ): + raise ValueError('release-files.json 必须包含 schemaVersion: 1 和 files') + names = manifest['files'] + if not isinstance(names, list) or not 1 <= len(names) <= MAX_SOURCE_FILES: + raise ValueError('交付清单 files 必须为包含 1 至 1024 项的列表') + checked_inventory(names) + for name in names: + parts = name.split('/') + filename = parts[-1].casefold() + if ( + Path(name).suffix not in SOURCE_EXTENSIONS + or any(part.casefold() in FORBIDDEN_DIRECTORIES for part in parts[:-1]) + or filename in FORBIDDEN_FILES + or filename.startswith('test_') + or filename.endswith('_test.py') + ): + raise ValueError(f'仅允许业务 .py/.sql 文件,不能包含测试、密钥或开发资源:{name}') + return [checked_file(SOURCE / name) for name in names] + + +def bundle_resources() -> list[Path]: + """ + 收集已构建 bundle,保留原有静态扩展名、隐藏文件和链接限制。 + + :return: 已通过检查的 web/dist 文件列表 + """ + dist = SOURCE / 'web' / 'dist' + checked_file(dist / 'index.html') + resources = [] + for path in sorted(dist.rglob('*')): + if path.is_symlink() or getattr(path.lstat(), 'st_file_attributes', 0) & REPARSE_POINT: + raise ValueError(f'bundle 不允许链接:{path}') + if not path.is_file(): + continue + if path.suffix.lower() not in WEB_EXTENSIONS: + raise ValueError(f'bundle 存在非交付文件;不要包含源码、source map 或密钥:{path}') + resources.append(checked_file(path)) + if not any(path.relative_to(dist).as_posix() == 'index.html' for path in resources): + raise ValueError('bundle 入口必须使用准确的 index.html 文件名') + return resources + + +def main() -> None: + """ + 按显式文件清单组装新交付目录,不安装依赖、导入插件或生成签名。 + + :return: None + """ + parser = argparse.ArgumentParser( + description='按 release-files.json 组装交付文件;不安装依赖、不导入插件、不生成签名。' + ) + parser.add_argument('--output', type=Path, required=True, help='全新输出目录(其中创建插件ID目录)') + output = parser.parse_args().output.resolve() + if output.exists() or output.is_relative_to(SOURCE) or SOURCE.is_relative_to(output): + parser.error('输出目录必须不存在,且不得与源码目录重叠') + resources = [checked_file(SOURCE / 'plugin.yaml'), *source_resources()] + if BUNDLE: + resources.extend(bundle_resources()) + checked_inventory([path.relative_to(SOURCE).as_posix() for path in resources]) + plugin = output / PLUGIN_ID + plugin.mkdir(parents=True) + for resource in resources: + target = plugin / resource.relative_to(SOURCE) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(resource, target) + print(json.dumps({'plugin': str(plugin), 'signed': False}, ensure_ascii=False)) + + +if __name__ == '__main__': + main() diff --git a/ruoyi-fastapi-backend/plugins/examples/python/task_demo/migrations/mysql/001_init.sql b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/migrations/mysql/001_init.sql new file mode 100644 index 0000000..76cc2cb --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/migrations/mysql/001_init.sql @@ -0,0 +1,9 @@ +CREATE TABLE ruoyi_plugin_task_demo ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + title VARCHAR(120) NOT NULL, + description VARCHAR(500) NOT NULL DEFAULT '', + status VARCHAR(4) NOT NULL DEFAULT 'todo', + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + CONSTRAINT ck_task_demo_status CHECK (status IN ('todo', 'done')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/ruoyi-fastapi-backend/plugins/examples/python/task_demo/migrations/mysql/002_priority.sql b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/migrations/mysql/002_priority.sql new file mode 100644 index 0000000..a6ad865 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/migrations/mysql/002_priority.sql @@ -0,0 +1,3 @@ +ALTER TABLE ruoyi_plugin_task_demo + ADD COLUMN priority VARCHAR(6) NOT NULL DEFAULT 'normal', + ADD CONSTRAINT ck_task_demo_priority CHECK (priority IN ('normal', 'high')); diff --git a/ruoyi-fastapi-backend/plugins/examples/python/task_demo/migrations/postgresql/001_init.sql b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/migrations/postgresql/001_init.sql new file mode 100644 index 0000000..40fa33f --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/migrations/postgresql/001_init.sql @@ -0,0 +1,9 @@ +CREATE TABLE ruoyi_plugin_task_demo ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + title VARCHAR(120) NOT NULL, + description VARCHAR(500) NOT NULL DEFAULT '', + status VARCHAR(4) NOT NULL DEFAULT 'todo', + created_at TIMESTAMP(3) WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP(3) WITH TIME ZONE NOT NULL, + CONSTRAINT ck_task_demo_status CHECK (status IN ('todo', 'done')) +); diff --git a/ruoyi-fastapi-backend/plugins/examples/python/task_demo/migrations/postgresql/002_priority.sql b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/migrations/postgresql/002_priority.sql new file mode 100644 index 0000000..f47a35f --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/migrations/postgresql/002_priority.sql @@ -0,0 +1,3 @@ +ALTER TABLE ruoyi_plugin_task_demo + ADD COLUMN priority VARCHAR(6) NOT NULL DEFAULT 'normal' + CONSTRAINT ck_task_demo_priority CHECK (priority IN ('normal', 'high')); diff --git a/ruoyi-fastapi-backend/plugins/examples/python/task_demo/models.py b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/models.py new file mode 100644 index 0000000..b95ecef --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/models.py @@ -0,0 +1,62 @@ +from typing import Annotated, Literal + +from pydantic import BaseModel, ConfigDict, Field, StringConstraints, field_validator +from sqlalchemy import CheckConstraint, Column, MetaData, String, Table + +from common.types import DbUtcDateTime + +MIN_PRINTABLE_CODEPOINT = 32 +metadata = MetaData() +tasks = Table( + 'ruoyi_plugin_task_demo', + metadata, + Column('id', String(32), primary_key=True), + Column('title', String(120), nullable=False), + Column('description', String(500), nullable=False, server_default=''), + Column('status', String(4), nullable=False, server_default='todo'), + Column('created_at', DbUtcDateTime, nullable=False), + Column('updated_at', DbUtcDateTime, nullable=False), + Column('priority', String(6), nullable=False, server_default='normal'), + CheckConstraint("status IN ('todo', 'done')", name='ck_task_demo_status'), + CheckConstraint("priority IN ('normal', 'high')", name='ck_task_demo_priority'), +) + + +class TaskInput(BaseModel): + """任务创建和完整更新的输入字段。""" + + model_config = ConfigDict(extra='forbid') + + title: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=120)] + description: str = Field(default='', max_length=500) + status: Literal['todo', 'done'] = 'todo' + priority: Literal['normal', 'high'] = 'normal' + + @field_validator('title', 'description') + @classmethod + def reject_control_characters(cls, value: str) -> str: + """ + 拒绝不可显示控制字符,使最大分页仍处于浏览器桥响应上限内。 + + :param value: 标题或描述文本 + :return: 已校验的文本 + """ + if any(ord(character) < MIN_PRINTABLE_CODEPOINT and character not in '\n\r\t' for character in value): + raise ValueError('文本不能包含不可显示的控制字符') + return value + + +class TaskQuery(BaseModel): + """限制单次响应体积,并提供稳定的分页和状态筛选。""" + + model_config = ConfigDict(extra='forbid') + + page: int = Field(default=1, ge=1, le=1_000_000) + page_size: int = Field(default=10, ge=1, le=20, alias='pageSize') + status: Literal['todo', 'done'] | None = None + + +class TaskIdentifier(BaseModel): + """用于路径参数的插件任务标识。""" + + id: str = Field(pattern=r'^[0-9a-f]{32}$') diff --git a/ruoyi-fastapi-backend/plugins/examples/python/task_demo/plugin.yaml b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/plugin.yaml new file mode 100644 index 0000000..5c85237 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/plugin.yaml @@ -0,0 +1,43 @@ +manifestVersion: 2 +id: task_demo +name: 持久化任务示例 +version: 1.1.0 +description: 独立前端与 ASGI 接口结合,演示显式事务、读写分权及无损字段迁移。 +backend: + runtime: python + integration: asgi + module: plugins.task_demo + entrypoint: plugins.task_demo:create_plugin + health: + checker: plugins.task_demo:health + migrations: + - migrations/mysql/001_init.sql + - migrations/mysql/002_priority.sql + - migrations/postgresql/001_init.sql + - migrations/postgresql/002_priority.sql +frontend: + delivery: + type: bundle + buildRequired: false + bundle: + directory: web/dist + entry: index.html + spaFallback: true + menus: + - name: 持久化任务示例 + path: task_demo + component: PluginFrame + type: C + icon: list + perms: task_demo:view +permissions: + - code: task_demo:view + name: 查看任务 + - code: task_demo:write + name: 编辑任务 +compatibility: + hostApiVersion: ^1.4.0 + pythonVersion: '>=3.10' + databases: + - mysql + - postgresql diff --git a/ruoyi-fastapi-backend/plugins/examples/python/task_demo/release-files.json b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/release-files.json new file mode 100644 index 0000000..aa2c78d --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/release-files.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": 1, + "files": [ + "__init__.py", + "models.py", + "service.py", + "migrations/mysql/001_init.sql", + "migrations/mysql/002_priority.sql", + "migrations/postgresql/001_init.sql", + "migrations/postgresql/002_priority.sql" + ] +} diff --git a/ruoyi-fastapi-backend/plugins/examples/python/task_demo/service.py b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/service.py new file mode 100644 index 0000000..80036c7 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/service.py @@ -0,0 +1,134 @@ +from collections.abc import Mapping +from typing import Any +from uuid import uuid4 + +from fastapi import HTTPException +from sqlalchemy import delete, func, insert, select, update + +from plugins.core.sdk import PluginRequestContext +from utils.time_util import TimezoneUtil + +from .models import TaskQuery, tasks + + +def can_write(context: PluginRequestContext) -> bool: + """ + 判断当前用户是否能够修改任务。 + + :param context: 宿主提供的请求身份 + :return: 是否具有写权限或管理员通配权限 + """ + return bool({'task_demo:write', '*:*:*'} & context.permissions) + + +def _task_payload(row: Mapping[str, Any]) -> dict[str, Any]: + """ + 将数据库任务转换为跨语言接口字段及 UTC 时间。 + + :param row: 数据库任务记录 + :return: 可序列化的任务对象 + """ + return { + 'id': row['id'], + 'title': row['title'], + 'description': row['description'], + 'status': row['status'], + 'priority': row['priority'], + 'createdAt': TimezoneUtil.format_rfc3339(row['created_at']), + 'updatedAt': TimezoneUtil.format_rfc3339(row['updated_at']), + } + + +async def list_tasks(context: PluginRequestContext, query: TaskQuery) -> dict[str, Any]: + """ + 在独立事务中读取筛选后的总数与当前页。 + + :param context: 宿主提供的请求身份 + :param query: 已校验的分页与状态参数 + :return: 任务分页结果和当前用户写权限 + """ + filters = [tasks.c.status == query.status] if query.status else [] + async with context.transaction() as transaction: + db = transaction.transaction_session() + total = await db.scalar(select(func.count()).select_from(tasks).where(*filters)) + result = await db.execute( + select(tasks) + .where(*filters) + .order_by(tasks.c.created_at.desc(), tasks.c.id.desc()) + .offset((query.page - 1) * query.page_size) + .limit(query.page_size) + ) + items = [_task_payload(row) for row in result.mappings()] + return { + 'items': items, + 'total': total, + 'page': query.page, + 'pageSize': query.page_size, + 'canWrite': can_write(context), + } + + +async def get_task(context: PluginRequestContext, task_id: str) -> dict[str, Any]: + """ + 在独立事务中读取指定任务。 + + :param context: 宿主提供的请求身份 + :param task_id: 已校验的任务标识 + :return: 任务对象;不存在时返回 HTTP 404 + """ + async with context.transaction() as transaction: + result = await transaction.transaction_session().execute(select(tasks).where(tasks.c.id == task_id)) + row = result.mappings().one_or_none() + if row is None: + raise HTTPException(status_code=404, detail='任务不存在') + return _task_payload(row) + + +async def create_task(context: PluginRequestContext, values: dict[str, Any]) -> dict[str, Any]: + """ + 在独立事务中写入任务,成功提交后返回任务对象。 + + :param context: 宿主提供的请求身份 + :param values: 已校验的任务输入 + :return: 新建的任务对象 + """ + now = TimezoneUtil.to_utc_milliseconds(TimezoneUtil.utc_now()) + row = {**values, 'id': uuid4().hex, 'created_at': now, 'updated_at': now} + async with context.transaction() as transaction: + await transaction.transaction_session().execute(insert(tasks).values(**row)) + return _task_payload(row) + + +async def update_task(context: PluginRequestContext, task_id: str, values: dict[str, Any]) -> dict[str, Any]: + """ + 在独立事务中完整更新任务,保留创建时间。 + + :param context: 宿主提供的请求身份 + :param task_id: 已校验的任务标识 + :param values: 已校验的任务输入 + :return: 更新后的任务对象;不存在时返回 HTTP 404 + """ + async with context.transaction() as transaction: + db = transaction.transaction_session() + result = await db.execute( + update(tasks).where(tasks.c.id == task_id).values(**values, updated_at=TimezoneUtil.utc_now()) + ) + if result.rowcount == 0: + raise HTTPException(status_code=404, detail='任务不存在') + result = await db.execute(select(tasks).where(tasks.c.id == task_id)) + return _task_payload(result.mappings().one()) + + +async def delete_task(context: PluginRequestContext, task_id: str) -> dict[str, bool]: + """ + 在独立事务中删除指定任务。 + + :param context: 宿主提供的请求身份 + :param task_id: 已校验的任务标识 + :return: 删除确认;不存在时返回 HTTP 404 + """ + async with context.transaction() as transaction: + result = await transaction.transaction_session().execute(delete(tasks).where(tasks.c.id == task_id)) + if result.rowcount == 0: + raise HTTPException(status_code=404, detail='任务不存在') + return {'deleted': True} diff --git a/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/.gitignore b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/.gitignore new file mode 100644 index 0000000..3ff38cc --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.vite/ diff --git a/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/.prettierrc.json b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/.prettierrc.json new file mode 100644 index 0000000..b615018 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "printWidth": 100, + "semi": false, + "singleQuote": true, + "trailingComma": "es5", + "endOfLine": "lf" +} diff --git a/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/dev.html b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/dev.html new file mode 100644 index 0000000..fb7553c --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/dev.html @@ -0,0 +1,122 @@ + + + + + + 任务清单 · 本地模拟宿主 + + + +
    +

    任务清单 · 本地模拟宿主

    +

    + 使用模拟权限与内存数据,不连接真实后端。刷新整个页面会重置任务。此页仅用于开发,不进入生产构建。 +

    +
    + + + + + + + + + 等待连接 +
    +
    + + + + diff --git a/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/dev/host.js b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/dev/host.js new file mode 100644 index 0000000..a586d4e --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/dev/host.js @@ -0,0 +1,52 @@ +import { createPluginHostBridge, validatePluginSession } from '@ruoyi/plugin-bridge' +import { createMockTaskApi } from './mockRequest.js' + +const element = (id) => document.getElementById(id) +const frame = element('plugin-frame') +const mock = createMockTaskApi({ + getDelay: () => element('delay').value, + shouldFail: () => element('failure').checked, + getCanWrite: () => !element('read-only').checked, +}) +const bridge = createPluginHostBridge({ + pluginId: 'task_demo', + session: validatePluginSession( + { + pluginId: 'task_demo', + bridgeVersion: 1, + uiBase: '/apps/task_demo/ui/', + apiBase: '/apps/task_demo/api/', + csrfToken: 'local-mock-session-no-credentials', + expiresIn: 3600, + }, + 'task_demo' + ), + getTarget: () => frame.contentWindow, + request: mock.request, + getContext: () => ({ + theme: { mode: element('theme').value }, + language: 'zh-CN', + timeZone: 'Asia/Shanghai', + route: '/', + }), + onReady: () => { + element('status').textContent = '模拟连接已建立' + }, +}) +frame.src = '/index.html' +element('theme').addEventListener('change', () => bridge.updatePreferences()) +element('read-only').addEventListener('change', () => bridge.refresh()) +element('reload').addEventListener('click', () => frame.contentWindow.location.reload()) +element('reset').addEventListener('click', () => { + mock.reset() + bridge.refresh() +}) +element('empty').addEventListener('click', () => { + mock.clear() + bridge.refresh() +}) +element('logout').addEventListener('click', () => { + bridge.destroy({ logout: true }) + element('status').textContent = '模拟会话已退出;刷新整个页面可重新连接' +}) +window.addEventListener('pagehide', () => bridge.destroy(), { once: true }) diff --git a/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/dev/mockRequest.js b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/dev/mockRequest.js new file mode 100644 index 0000000..f02ce8c --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/dev/mockRequest.js @@ -0,0 +1,155 @@ +const seedTitles = [ + '确认本周交付范围', + '整理需求评审记录', + '补充接口使用示例', + '核对页面文案', + '准备演示环境', + '验证异常处理流程', + '完善操作说明', + '检查移动端布局', + '同步项目里程碑', + '回顾已完成事项', + '确认验收清单', + '整理下一阶段待办', +] + +function seedTasks() { + return seedTitles.map((title, index) => ({ + id: (index + 1).toString(16).padStart(32, '0'), + title, + description: index % 3 === 0 ? '与团队核对完成标准,并记录需要继续跟进的事项。' : '', + status: index % 4 === 0 ? 'done' : 'todo', + priority: index % 3 === 2 ? 'high' : 'normal', + createdAt: new Date(Date.UTC(2026, 9, 1, 2, index * 10)).toISOString(), + updatedAt: new Date(Date.UTC(2026, 9, 2, 2, index * 10)).toISOString(), + })) +} + +function validateTask(data) { + const title = typeof data?.title === 'string' ? data.title.trim() : '' + if (!title || title.length > 120) throw new Error('任务标题需为 1–120 个字符') + if (typeof data?.description !== 'string' || data.description.length > 500) + throw new Error('任务说明不能超过 500 个字符') + if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(title + data.description)) + throw new Error('文本不能包含不可显示的控制字符') + if (!['todo', 'done'].includes(data.status)) throw new Error('任务状态无效') + if (!['normal', 'high'].includes(data.priority)) throw new Error('任务优先级无效') + return { + title, + description: data.description, + status: data.status, + priority: data.priority, + } +} + +/** + * 本地内存 API:使用与生产接口一致的分页、筛选、详情和写入契约。 + * 只供 dev.html 使用;重载整个页面会重置数据,不请求真实服务、不保存凭据。 + */ +export function createMockTaskApi({ + getDelay = () => 300, + shouldFail = () => false, + getCanWrite = () => true, +} = {}) { + let tasks = seedTasks() + let nextId = tasks.length + 1 + const pause = (signal) => + new Promise((resolve, reject) => { + if (signal?.aborted) return reject(new Error('请求已取消')) + const cancel = () => { + clearTimeout(timer) + reject(new Error('请求已取消')) + } + const timer = setTimeout( + () => { + signal?.removeEventListener('abort', cancel) + resolve() + }, + Math.max(0, Math.min(5000, Number(getDelay()) || 0)) + ) + signal?.addEventListener('abort', cancel, { once: true }) + }) + + async function request(config) { + const prefix = '/apps/task_demo/api/' + if (!config.url?.startsWith(prefix)) throw new Error('模拟宿主只处理当前插件 API') + const path = config.url.slice(prefix.length) + const method = String(config.method || 'get').toUpperCase() + await pause(config.signal) + if (config.signal?.aborted) throw new Error('请求已取消') + if (shouldFail()) throw new Error('模拟请求失败,请关闭“模拟请求失败”后重试。') + const canWrite = getCanWrite() === true + if (method === 'GET' && path === 'info') + return { pluginId: 'task_demo', version: '1.1.0', canWrite } + if (method === 'GET' && path === 'tasks') { + const page = Number(config.params?.page ?? 1) + const pageSize = Number(config.params?.pageSize ?? 10) + const status = config.params?.status || '' + if ( + !Number.isInteger(page) || + page < 1 || + page > 1000000 || + !Number.isInteger(pageSize) || + pageSize < 1 || + pageSize > 20 + ) + throw new Error('分页参数无效') + if (status && !['todo', 'done'].includes(status)) throw new Error('筛选状态无效') + const filtered = tasks + .filter((task) => !status || task.status === status) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id)) + return { + items: structuredClone(filtered.slice((page - 1) * pageSize, page * pageSize)), + total: filtered.length, + page, + pageSize, + canWrite, + } + } + const match = /^tasks\/([0-9a-f]{32})$/.exec(path) + const index = match ? tasks.findIndex((task) => task.id === match[1]) : -1 + if (method === 'GET' && match) { + if (index === -1) throw new Error('任务不存在或已被删除') + return structuredClone(tasks[index]) + } + if (!['POST', 'PUT', 'DELETE'].includes(method)) throw new Error('模拟接口未实现') + // UI 隐藏按钮之外,再次在请求处理层检查写权限。 + if (!canWrite) throw new Error('当前会话没有写入权限') + if (method === 'POST' && path === 'tasks') { + const data = validateTask(config.data) + const now = new Date().toISOString() + const task = { + id: (nextId++).toString(16).padStart(32, '0'), + ...data, + createdAt: now, + updatedAt: now, + } + tasks.push(task) + return structuredClone(task) + } + if (match && (method === 'PUT' || method === 'DELETE')) { + if (index === -1) throw new Error('任务不存在或已被删除') + if (method === 'DELETE') { + tasks.splice(index, 1) + return { deleted: true } + } + tasks[index] = { + ...tasks[index], + ...validateTask(config.data), + updatedAt: new Date().toISOString(), + } + return structuredClone(tasks[index]) + } + throw new Error('模拟接口未实现') + } + return { + request, + reset() { + tasks = seedTasks() + nextId = tasks.length + 1 + }, + clear() { + tasks = [] + }, + } +} diff --git a/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/index.html b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/index.html new file mode 100644 index 0000000..2faf14d --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/index.html @@ -0,0 +1,277 @@ + + + + + + + 任务清单 + + + +
    + + + +

    + +
    +
    +
    +

    全部任务

    + 等待加载 +
    + +
    +
    +
    + + + +
    +

    点击任务标题查看详情

    +
    + + + +
    +
    + + 正在连接工作区… +
    + + + + + + + + + + + + + + +
    + +
    +

    尚未加载任务

    +
    + + + + — + +
    +
    +
    +
    + + +
    +
    +

    新任务

    +

    新增任务

    +
    + +
    +
    +
    + + + +
    +
    +
    + 0 / 120 +
    + +

    用一句话描述要完成的事情。

    +
    +
    +
    + 0 / 500 +
    + +
    +
    +
    + +
    +
    + +
    +
    +
    + +
    +
    +

    +
    + +
    +
    +
    +
    + + +
    +

    删除任务

    +

    确认删除这条任务?

    +

    +

    删除后无法恢复,请确认这条任务已不再需要。

    + +
    +
    +
    + +
    +
    +
    + + + diff --git a/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/package.json b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/package.json new file mode 100644 index 0000000..c442de7 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/package.json @@ -0,0 +1,14 @@ +{ + "name": "ruoyi-plugin-task-demo", + "version": "1.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1 --open /dev.html", + "build": "vite build", + "test": "node --test tests/mockRequest.test.js" + }, + "devDependencies": { + "vite": "6.4.3" + } +} diff --git a/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/src/main.js b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/src/main.js new file mode 100644 index 0000000..618d5e9 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/src/main.js @@ -0,0 +1,508 @@ +import { createPluginClient } from '@ruoyi/plugin-bridge' +import './style.css' + +const element = (id) => document.getElementById(id) +const filters = [...element('status-filters').querySelectorAll('button')] +const state = { + items: [], + total: 0, + page: 1, + pageSize: 10, + status: '', + connected: false, + ended: false, + canWrite: false, + loading: true, + loaded: false, + listError: false, + mutating: false, + editorLoading: false, + editorFailed: false, + editorTask: null, + deleteTask: null, +} +let client +let context = {} +let listController +let detailController +let listGeneration = 0 +let detailGeneration = 0 + +function showNotice(message, error = false) { + element('notice').textContent = message + element('notice').classList.toggle('is-error', error) +} + +function formatTime(value) { + const date = new Date(value) + if (!value || Number.isNaN(date.getTime())) return '—' + try { + return new Intl.DateTimeFormat(context.language || 'zh-CN', { + timeZone: context.timeZone || 'UTC', + dateStyle: 'short', + timeStyle: 'short', + }).format(date) + } catch { + return date.toISOString().slice(0, 16).replace('T', ' ') + ' UTC' + } +} + +function setEditorControls() { + const writable = state.connected && !state.ended && state.canWrite + const blocked = state.editorLoading || state.editorFailed || state.mutating + element('task-fields').disabled = blocked + element('task-title').readOnly = !writable + element('task-description').readOnly = !writable + element('task-status').disabled = !writable + element('task-priority').disabled = !writable + element('form-read-only').hidden = writable || state.editorLoading + element('save-task').hidden = !writable + element('save-task').disabled = !writable || blocked + element('close-dialog').disabled = state.mutating + element('cancel-dialog').disabled = state.mutating + element('cancel-dialog').textContent = writable ? '取消' : '关闭' + element('dialog-heading').textContent = writable + ? state.editorTask + ? '编辑任务' + : '新增任务' + : '查看任务' + element('save-task').textContent = state.mutating + ? '正在保存…' + : state.editorTask + ? '保存修改' + : '新增任务' +} + +function updateControls() { + const available = state.connected && !state.ended + const idle = available && !state.loading && !state.mutating + element('access-mode').textContent = state.ended + ? '会话已结束' + : !state.connected + ? '未连接' + : state.canWrite + ? '可编辑 · 共享任务' + : '只读 · 共享任务' + element('read-only-notice').hidden = !available || state.canWrite + element('create-task').disabled = !idle || !state.canWrite + element('empty-create').disabled = !idle || !state.canWrite + element('empty-create').hidden = !state.canWrite || Boolean(state.status) + element('refresh').disabled = !idle + element('retry').disabled = !idle + element('page-size').disabled = !idle + element('previous-page').disabled = !idle || state.page <= 1 + element('next-page').disabled = !idle || state.page * state.pageSize >= state.total + for (const button of filters) { + button.disabled = !idle + button.setAttribute('aria-pressed', String(button.dataset.status === state.status)) + } + element('list-region').setAttribute('aria-busy', String(state.loading)) + element('loading-state').hidden = !state.loading + element('loading-message').textContent = state.connected ? '正在加载任务…' : '正在连接工作区…' + element('task-table').hidden = !state.items.length + element('empty-state').hidden = + state.loading || state.listError || !state.loaded || Boolean(state.items.length) + element('empty-heading').textContent = state.status ? '没有符合条件的任务' : '还没有任务' + element('empty-description').textContent = state.status + ? '试试其他状态,或查看全部任务。' + : state.canWrite + ? '新增第一条任务,开始记录团队待办。' + : '当前还没有共享任务,请稍后刷新查看。' + element('clear-filter').hidden = !state.status + element('list-heading').textContent = + state.status === 'todo' ? '待完成任务' : state.status === 'done' ? '已完成任务' : '全部任务' + element('task-count').textContent = state.loaded ? `${state.total} 条` : '等待加载' + const start = state.total ? (state.page - 1) * state.pageSize + 1 : 0 + const end = Math.min(state.page * state.pageSize, state.total) + element('page-summary').textContent = state.loaded + ? `显示 ${start}–${end} 条,共 ${state.total} 条任务` + : '尚未加载任务' + element('page-number').textContent = state.loaded + ? `${state.page} / ${Math.max(1, Math.ceil(state.total / state.pageSize))}` + : '—' + for (const button of element('task-rows').querySelectorAll('button')) button.disabled = !idle + element('cancel-delete').disabled = state.mutating + element('confirm-delete').disabled = !available || !state.canWrite || state.mutating + element('confirm-delete').textContent = state.mutating ? '正在删除…' : '确认删除' + setEditorControls() +} + +function node(tag, className, text) { + const result = document.createElement(tag) + if (className) result.className = className + if (text !== undefined) result.textContent = text + return result +} + +// 用户内容始终写入文本节点,任务标题和说明不会作为 HTML 执行。 +function renderRows() { + const rows = state.items.map((task) => { + const row = node('tr', task.priority === 'high' ? 'is-priority' : '') + const titleCell = node('td') + const title = node('button', 'task-title-button', task.title) + title.type = 'button' + title.addEventListener('click', () => openEditor(task.id)) + titleCell.append(title) + if (task.description) titleCell.append(node('p', 'task-description', task.description)) + titleCell.append(node('span', 'task-id', `#${task.id}`)) + const status = node('td') + status.dataset.label = '状态' + status.append( + node( + 'span', + `status-badge status-${task.status}`, + task.status === 'done' ? '已完成' : '待完成' + ) + ) + const priority = node('td') + priority.dataset.label = '优先级' + priority.append( + node( + 'span', + task.priority === 'high' ? 'priority-label priority-high' : 'priority-label', + task.priority === 'high' ? '优先处理' : '普通' + ) + ) + const updated = node('td') + updated.dataset.label = '更新时间' + updated.append(node('span', 'updated-time', formatTime(task.updatedAt))) + const actions = node('td') + const group = node('div', 'row-actions') + if (state.canWrite) { + const edit = node('button', 'row-button', '编辑') + edit.type = 'button' + edit.setAttribute('aria-label', `编辑任务:${task.title}`) + edit.addEventListener('click', () => openEditor(task.id)) + const remove = node('button', 'row-button row-delete', '删除') + remove.type = 'button' + remove.setAttribute('aria-label', `删除任务:${task.title}`) + remove.addEventListener('click', () => openDelete(task)) + group.append(edit, remove) + } else group.append(node('span', 'read-only-label', '仅查看')) + actions.append(group) + row.append(titleCell, status, priority, updated, actions) + return row + }) + element('task-rows').replaceChildren(...rows) +} + +async function loadTasks({ reloadInfo = false } = {}) { + if (!state.connected || state.ended) return + listController?.abort() + const controller = new AbortController() + listController = controller + const generation = ++listGeneration + state.loading = true + state.listError = false + element('list-error').hidden = true + updateControls() + try { + if (reloadInfo) { + const info = await client.request( + { method: 'GET', path: 'info' }, + { signal: controller.signal } + ) + if (generation !== listGeneration || state.ended) return + state.canWrite = info.canWrite === true + renderRows() + updateControls() + } + const params = { page: state.page, pageSize: state.pageSize } + if (state.status) params.status = state.status + const result = await client.request( + { method: 'GET', path: 'tasks', params }, + { signal: controller.signal } + ) + if (generation !== listGeneration || state.ended) return + state.canWrite = result.canWrite === true + state.total = result.total + const lastPage = Math.max(1, Math.ceil(result.total / state.pageSize)) + if (state.page > lastPage) { + state.page = lastPage + return await loadTasks() + } + state.items = result.items + state.loaded = true + renderRows() + } catch (error) { + if (controller.signal.aborted || generation !== listGeneration || state.ended) return + state.listError = true + element('list-error-message').textContent = `任务加载失败。${error.message || '请稍后重试。'}` + element('list-error').hidden = false + } finally { + if (generation === listGeneration) { + state.loading = false + listController = null + updateControls() + } + } +} + +function updateCounts() { + element('title-count').textContent = `${element('task-title').value.length} / 120` + element('description-count').textContent = `${element('task-description').value.length} / 500` + element('task-title').setCustomValidity('') +} + +function fillEditor(task = {}) { + element('task-title').value = task.title || '' + element('task-description').value = task.description || '' + element('task-status').value = task.status || 'todo' + element('task-priority').value = task.priority || 'normal' + element('task-timestamps').hidden = !task.id + element('task-timestamps').textContent = task.id + ? `创建于 ${formatTime(task.createdAt)} · 更新于 ${formatTime(task.updatedAt)}` + : '' + updateCounts() +} + +async function openEditor(id) { + if (!state.connected || state.ended || state.mutating || (!id && !state.canWrite)) return + detailController?.abort() + const generation = ++detailGeneration + state.editorTask = id ? { id } : null + state.editorLoading = Boolean(id) + state.editorFailed = false + element('dialog-kicker').textContent = id ? `共享任务 #${id}` : '新任务' + element('form-error').hidden = true + element('save-state').textContent = '' + element('detail-loading').hidden = !id + fillEditor() + setEditorControls() + element('task-dialog').showModal() + if (!id) { + element('task-title').focus() + return + } + const controller = new AbortController() + detailController = controller + try { + const task = await client.request( + { method: 'GET', path: `tasks/${id}` }, + { signal: controller.signal } + ) + if (controller.signal.aborted || generation !== detailGeneration || state.ended) return + state.editorTask = task + fillEditor(task) + } catch (error) { + if (controller.signal.aborted || generation !== detailGeneration || state.ended) return + state.editorFailed = true + element('form-error').textContent = `任务详情加载失败。${error.message || '请关闭后重试。'}` + element('form-error').hidden = false + element('form-error').focus() + } finally { + if (generation === detailGeneration) { + state.editorLoading = false + element('detail-loading').hidden = true + setEditorControls() + } + } +} + +function closeEditor() { + if (state.mutating) return + detailController?.abort() + detailGeneration++ + element('task-dialog').close() +} + +async function checkWriteAccess() { + try { + const info = await client.request({ method: 'GET', path: 'info' }) + if (!state.ended) { + state.canWrite = info.canWrite === true + renderRows() + } + } catch { + /* 保存原始错误;后续刷新仍可重新读取权限。 */ + } +} + +async function saveTask(event) { + event.preventDefault() + if (state.mutating || state.editorLoading || state.editorFailed || !state.canWrite || state.ended) + return + const title = element('task-title').value.trim() + if (!title) element('task-title').setCustomValidity('请输入任务标题,不能只包含空格。') + if (!element('task-form').reportValidity()) return + const data = { + title, + description: element('task-description').value, + status: element('task-status').value, + priority: element('task-priority').value, + } + const id = state.editorTask?.id + state.mutating = true + element('form-error').hidden = true + element('save-state').textContent = '正在保存,请稍候…' + updateControls() + let saved = false + try { + await client.request({ + method: id ? 'PUT' : 'POST', + path: id ? `tasks/${id}` : 'tasks', + data, + }) + if (state.ended) return + saved = true + element('task-dialog').close() + const filteredOut = state.status && state.status !== data.status + showNotice( + `${id ? '任务已更新。' : '任务已新增。'}${filteredOut ? '该任务不在当前状态筛选中,可切换到“全部”查看。' : ''}` + ) + if (!id) state.page = 1 + } catch (error) { + if (state.ended) return + element('form-error').textContent = + `保存失败,填写内容已保留。${error.message || '请稍后重试。'}` + element('form-error').hidden = false + element('form-error').focus() + await checkWriteAccess() + } finally { + state.mutating = false + element('save-state').textContent = '' + updateControls() + } + if (saved) await loadTasks() +} + +function openDelete(task) { + if (!state.canWrite || state.mutating || state.ended) return + state.deleteTask = task + element('delete-title').textContent = task.title + element('delete-error').hidden = true + element('delete-dialog').showModal() + element('cancel-delete').focus() +} + +async function deleteTask() { + if (!state.deleteTask || !state.canWrite || state.mutating || state.ended) return + state.mutating = true + element('delete-error').hidden = true + updateControls() + let deleted = false + try { + await client.request({ + method: 'DELETE', + path: `tasks/${state.deleteTask.id}`, + }) + if (state.ended) return + deleted = true + element('delete-dialog').close() + showNotice('任务已删除。') + } catch (error) { + if (state.ended) return + element('delete-error').textContent = `删除失败。${error.message || '请稍后重试。'}` + element('delete-error').hidden = false + element('delete-error').focus() + await checkWriteAccess() + } finally { + state.mutating = false + updateControls() + } + if (deleted) await loadTasks() +} + +function setStatus(status) { + state.status = status + state.page = 1 + reloadQuery() +} + +function reloadQuery() { + // 切换查询条件后清除旧结果,避免把上一种筛选误认为当前列表。 + state.items = [] + state.loaded = false + renderRows() + loadTasks() +} + +function applyContext(next) { + context = { ...context, ...next } + document.documentElement.dataset.theme = context.theme?.mode === 'dark' ? 'dark' : 'light' + document.documentElement.lang = context.language || 'zh-CN' + renderRows() + updateControls() +} + +function endSession() { + state.ended = true + state.canWrite = false + state.loading = false + listController?.abort() + detailController?.abort() + listGeneration++ + detailGeneration++ + element('task-dialog').close() + element('delete-dialog').close() + showNotice('会话已结束,请重新登录管理平台后打开任务清单。', true) + renderRows() + updateControls() +} + +for (const button of filters) + button.addEventListener('click', () => setStatus(button.dataset.status)) +element('clear-filter').addEventListener('click', () => setStatus('')) +for (const id of ['create-task', 'empty-create']) + element(id).addEventListener('click', () => openEditor()) +for (const id of ['refresh', 'retry']) + element(id).addEventListener('click', () => loadTasks({ reloadInfo: true })) +element('previous-page').addEventListener('click', () => { + state.page-- + reloadQuery() +}) +element('next-page').addEventListener('click', () => { + state.page++ + reloadQuery() +}) +element('page-size').addEventListener('change', () => { + state.pageSize = Number(element('page-size').value) + state.page = 1 + reloadQuery() +}) +for (const id of ['close-dialog', 'cancel-dialog']) + element(id).addEventListener('click', closeEditor) +element('task-dialog').addEventListener('cancel', (event) => { + event.preventDefault() + closeEditor() +}) +element('delete-dialog').addEventListener('cancel', (event) => { + if (state.mutating) event.preventDefault() +}) +element('cancel-delete').addEventListener('click', () => { + if (!state.mutating) element('delete-dialog').close() +}) +element('confirm-delete').addEventListener('click', deleteTask) +element('task-form').addEventListener('submit', saveTask) +for (const id of ['task-title', 'task-description']) + element(id).addEventListener('input', updateCounts) + +async function connect() { + try { + const config = JSON.parse(element('ruoyi-plugin-config')?.textContent || '{}') + if (config.pluginId !== 'task_demo') throw new Error('请从管理平台或本地模拟宿主打开任务清单。') + client = createPluginClient({ pluginId: config.pluginId }) + client.subscribe(({ type, payload }) => { + if (type === 'preferences') applyContext(payload) + if (type === 'refresh') loadTasks({ reloadInfo: true }) + if (type === 'logout') endSession() + }) + applyContext(await client.ready) + state.connected = true + await loadTasks({ reloadInfo: true }) + } catch (error) { + state.loading = false + showNotice(error.message || '连接失败,请重新打开任务清单。', true) + updateControls() + } +} +connect() +window.addEventListener( + 'pagehide', + () => { + listController?.abort() + detailController?.abort() + client?.destroy() + }, + { once: true } +) diff --git a/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/src/style.css b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/src/style.css new file mode 100644 index 0000000..1204683 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/src/style.css @@ -0,0 +1,952 @@ +:root { + font-family: 'Segoe UI', 'Microsoft YaHei', 'PingFang SC', sans-serif; + color: var(--ink); + background: var(--canvas); + color-scheme: light; + --canvas: #f3f6f7; + --surface: #ffffff; + --surface-subtle: #f7f9fa; + --ink: #172b36; + --muted: #516574; + --border: #d6e0e4; + --control-border: #879aa4; + --accent: #176b62; + --accent-hover: #10564e; + --on-accent: #ffffff; + --accent-soft: #e6f3f0; + --pending: #805300; + --pending-soft: #fff3d0; + --complete: #136849; + --complete-soft: #e0f3e9; + --danger: #b42318; + --danger-hover: #8f1d14; + --danger-soft: #fff0ee; + --on-danger: #ffffff; + --scrim: rgb(10 25 35 / 48%); + --shadow: 0 18px 60px rgb(18 41 53 / 18%); + --mono: 'Cascadia Mono', Consolas, monospace; +} + +:root[data-theme='dark'] { + color-scheme: dark; + --canvas: #111c25; + --surface: #172631; + --surface-subtle: #1c2e3a; + --ink: #e8f0f5; + --muted: #aec0c9; + --border: #344854; + --control-border: #6c8390; + --accent: #79d8c6; + --accent-hover: #a5e7db; + --on-accent: #0d3028; + --accent-soft: #193c38; + --pending: #f4cd79; + --pending-soft: #43391e; + --complete: #8ce0b8; + --complete-soft: #1e3d32; + --danger: #ffad9f; + --danger-hover: #ffccbf; + --danger-soft: #432a29; + --on-danger: #431b15; + --scrim: rgb(3 10 15 / 68%); + --shadow: 0 18px 60px rgb(0 0 0 / 35%); +} + +* { + box-sizing: border-box; +} +body { + margin: 0; + font-size: 14px; + line-height: 1.6; +} +button, +input, +textarea, +select { + font: inherit; +} +button, +select { + cursor: pointer; + touch-action: manipulation; +} +button { + color: inherit; +} +button:disabled, +select:disabled { + cursor: not-allowed; + opacity: 0.55; +} +button, +input, +textarea, +select { + transition: + background-color 160ms ease, + border-color 160ms ease, + color 160ms ease; +} +:focus-visible { + outline: 3px solid var(--accent); + outline-offset: 3px; +} +[hidden] { + display: none !important; +} +h1, +h2, +h3, +p { + margin: 0; +} +svg { + display: block; + flex: 0 0 auto; +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} +.skip-link { + position: fixed; + top: 8px; + left: 16px; + z-index: 5; + padding: 10px 16px; + transform: translateY(-180%); + color: var(--on-accent); + background: var(--accent); + border-radius: 6px; +} +.skip-link:focus { + transform: translateY(0); +} +.workspace { + width: min(100%, 1280px); + margin: 0 auto; + padding: 32px clamp(16px, 3vw, 40px) 40px; +} +.page-header { + display: flex; + gap: 24px; + justify-content: space-between; + align-items: center; + margin-bottom: 28px; +} +.page-heading { + display: flex; + align-items: flex-start; + gap: 16px; +} +.page-mark { + display: grid; + place-items: center; + width: 48px; + height: 48px; + border-radius: 12px; + background: var(--accent-soft); + color: var(--accent); + margin-top: 6px; +} +.page-mark svg { + width: 28px; + height: 28px; +} +.eyebrow { + margin-bottom: 4px; + color: var(--muted); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.08em; +} +h1 { + font-family: 'Microsoft YaHei UI', 'PingFang SC', sans-serif; + font-size: 28px; + line-height: 1.35; + font-weight: 650; + letter-spacing: 0.01em; +} +.page-description { + margin-top: 8px; + color: var(--muted); +} +.header-actions { + display: flex; + align-items: center; + gap: 18px; + flex-shrink: 0; +} +.access-mode { + color: var(--muted); + font-size: 13px; + white-space: nowrap; +} +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + min-height: 42px; + padding: 9px 16px; + border: 1px solid transparent; + border-radius: 7px; + font-weight: 600; + white-space: nowrap; +} +.button-primary { + color: var(--on-accent); + background: var(--accent); + border-color: var(--accent); +} +.button-primary:not(:disabled):hover { + background: var(--accent-hover); + border-color: var(--accent-hover); +} +.button-secondary { + color: var(--ink); + background: var(--surface); + border-color: var(--control-border); +} +.button-secondary:not(:disabled):hover { + background: var(--surface-subtle); + border-color: var(--accent); +} +.button-danger { + background: var(--danger); + color: var(--on-danger); +} +.button-danger:not(:disabled):hover { + background: var(--danger-hover); +} +.icon { + width: 18px; + height: 18px; +} +.read-only-notice { + margin-bottom: 16px; + padding: 12px 16px; + color: var(--pending); + background: var(--pending-soft); + border-radius: 7px; +} +.notice { + margin-bottom: 16px; + padding: 12px 16px; + color: var(--accent); + background: var(--accent-soft); + border-radius: 7px; + overflow-wrap: anywhere; +} +.notice:empty { + display: none; +} +.notice.is-error { + color: var(--danger); + background: var(--danger-soft); +} +.list-panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; +} +.list-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 22px 24px 16px; +} +.list-heading-row { + display: flex; + align-items: center; + gap: 10px; +} +.list-heading-row h2 { + font-size: 18px; + font-weight: 650; +} +.task-count { + font-family: var(--mono); + color: var(--muted); + font-size: 12px; + padding: 2px 7px; + background: var(--surface-subtle); + border: 1px solid var(--border); + border-radius: 5px; + white-space: nowrap; +} +.filter-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 0 24px 16px; +} +.status-filters { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 4px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-subtle); +} +.filter-button { + min-height: 36px; + padding: 6px 17px; + border: 1px solid transparent; + border-radius: 5px; + background: transparent; + color: var(--muted); + white-space: nowrap; +} +.filter-button[aria-pressed='true'] { + color: var(--accent); + background: var(--surface); + border-color: var(--border); + font-weight: 650; +} +.filter-button:not(:disabled):hover { + color: var(--accent); +} +.list-hint { + color: var(--muted); + font-size: 12px; +} +.list-region { + min-height: 240px; + position: relative; +} +.loading-state { + display: flex; + justify-content: center; + align-items: center; + gap: 10px; + padding: 30px 20px; + color: var(--muted); +} +.loading-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent); + animation: loading-pulse 1s ease-in-out infinite alternate; +} +@keyframes loading-pulse { + from { + opacity: 0.3; + } + to { + opacity: 1; + } +} +.list-region[aria-busy='true'] .task-table { + opacity: 0.55; +} +.task-table { + width: 100%; + table-layout: fixed; + border-collapse: collapse; + text-align: left; +} +.task-table th { + padding: 12px 16px; + background: var(--surface-subtle); + color: var(--muted); + font-size: 12px; + font-weight: 600; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} +.task-table th:first-child { + width: 43%; + padding-left: 24px; +} +.task-table th:nth-child(2) { + width: 12%; +} +.task-table th:nth-child(3) { + width: 12%; +} +.task-table th:nth-child(4) { + width: 19%; +} +.task-table th:nth-child(5) { + width: 14%; +} +.task-table td { + padding: 18px 16px; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} +.task-table td:first-child { + padding-left: 24px; + position: relative; +} +.task-table tr:last-child td { + border-bottom: 0; +} +.task-table tbody tr:hover { + background: var(--surface-subtle); +} +.task-table .is-priority td:first-child::before { + content: ''; + position: absolute; + left: 0; + top: 18px; + bottom: 18px; + width: 3px; + border-radius: 0 3px 3px 0; + background: var(--pending); +} +.task-title-button { + display: block; + max-width: 100%; + padding: 0; + border: 0; + background: transparent; + color: var(--ink); + font-size: 15px; + font-weight: 600; + line-height: 1.6; + text-align: left; + overflow-wrap: anywhere; +} +.task-title-button:not(:disabled):hover { + color: var(--accent); + text-decoration: underline; + text-underline-offset: 3px; +} +.task-description { + margin-top: 4px; + color: var(--muted); + font-size: 13px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + overflow-wrap: anywhere; +} +.task-id { + display: inline-block; + margin-top: 6px; + font: 11px/1.5 var(--mono); + color: var(--muted); +} +.status-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 8px; + font-size: 12px; + font-weight: 600; + border-radius: 5px; + white-space: nowrap; +} +.status-badge::before { + content: ''; + width: 5px; + height: 5px; + border-radius: 50%; + background: currentColor; +} +.status-todo { + color: var(--pending); + background: var(--pending-soft); +} +.status-done { + color: var(--complete); + background: var(--complete-soft); +} +.priority-label { + color: var(--muted); + font-size: 13px; + white-space: nowrap; +} +.priority-high { + color: var(--pending); + font-weight: 600; +} +.updated-time { + color: var(--muted); + font: 12px/1.7 var(--mono); + overflow-wrap: anywhere; +} +.row-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; +} +.row-button { + min-height: 32px; + padding: 3px 0; + color: var(--accent); + background: transparent; + border: 0; + font-size: 13px; + font-weight: 600; +} +.row-button:not(:disabled):hover { + text-decoration: underline; + text-underline-offset: 3px; +} +.row-delete { + color: var(--danger); +} +.actions-heading { + text-align: right; +} +.read-only-label { + font-size: 12px; + color: var(--muted); + white-space: nowrap; +} +.empty-state { + display: grid; + justify-items: center; + gap: 10px; + padding: 42px 24px 52px; + text-align: center; +} +.empty-icon { + width: 56px; + height: 56px; + color: var(--muted); + margin-bottom: 4px; +} +.empty-state h3 { + font-size: 17px; +} +.empty-state p { + color: var(--muted); + margin-bottom: 8px; +} +.error-panel { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin: 0 24px 16px; + padding: 14px 16px; + border: 1px solid var(--danger); + border-radius: 7px; + color: var(--danger); + background: var(--danger-soft); + overflow-wrap: anywhere; +} +.pagination { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + padding: 16px 24px; + border-top: 1px solid var(--border); +} +.pagination p { + color: var(--muted); + font-size: 13px; +} +.page-controls { + display: flex; + align-items: center; + gap: 9px; +} +.page-size-label { + color: var(--muted); + font-size: 12px; +} +.page-controls select { + width: 79px; + min-height: 38px; +} +.page-controls .button { + min-height: 38px; + padding: 7px 12px; + font-size: 13px; +} +.page-number { + min-width: 34px; + text-align: center; + color: var(--muted); + font: 12px/1.5 var(--mono); +} +input, +textarea, +select { + width: 100%; + border: 1px solid var(--control-border); + border-radius: 6px; + background: var(--surface); + color: var(--ink); + padding: 10px 12px; +} +input::placeholder, +textarea::placeholder { + color: var(--muted); + opacity: 0.85; +} +textarea { + resize: vertical; + min-height: 120px; +} +input:read-only, +textarea:read-only { + background: var(--surface-subtle); +} +.dialog { + width: min(600px, calc(100% - 32px)); + max-height: calc(100dvh - 32px); + padding: 0; + border: 1px solid var(--border); + border-radius: 12px; + color: var(--ink); + background: var(--surface); + box-shadow: var(--shadow); + overflow: auto; +} +.dialog::backdrop { + background: var(--scrim); +} +.dialog-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + padding: 24px 24px 18px; + border-bottom: 1px solid var(--border); +} +.dialog h2 { + font-size: 21px; + line-height: 1.4; +} +.icon-button { + width: 40px; + min-height: 40px; + display: grid; + place-items: center; + padding: 0; + color: var(--muted); + background: transparent; + border: 1px solid transparent; + border-radius: 7px; +} +.icon-button:not(:disabled):hover { + color: var(--ink); + background: var(--surface-subtle); + border-color: var(--border); +} +.dialog-body { + padding: 22px 24px 24px; +} +fieldset { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} +.field { + display: grid; + gap: 8px; + margin-bottom: 20px; +} +.field > label, +.field-label label { + font-weight: 600; +} +.field-label { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 16px; +} +.field-count { + color: var(--muted); + font: 11px/1.5 var(--mono); + white-space: nowrap; +} +.field-hint { + color: var(--muted); + font-size: 12px; +} +.required-mark { + color: var(--danger); +} +.field-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} +.field-grid .field { + margin: 0; +} +.form-error { + padding: 12px 14px; + margin-bottom: 18px; + color: var(--danger); + border: 1px solid var(--danger); + background: var(--danger-soft); + border-radius: 6px; + overflow-wrap: anywhere; +} +.inline-notice { + padding: 10px 12px; + color: var(--muted); + background: var(--surface-subtle); + border-radius: 6px; + margin-bottom: 18px; +} +.task-timestamps { + margin-top: 20px; + padding-top: 14px; + border-top: 1px solid var(--border); + color: var(--muted); + font-size: 12px; +} +.dialog-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 18px 24px; + border-top: 1px solid var(--border); + background: var(--surface-subtle); +} +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-left: auto; +} +.save-state { + color: var(--muted); + font-size: 12px; +} +.delete-dialog { + width: min(460px, calc(100% - 32px)); +} +.delete-task-title { + padding: 12px 14px; + margin: 18px 0 12px; + background: var(--surface-subtle); + border-left: 3px solid var(--danger); + border-radius: 0 6px 6px 0; + font-weight: 600; + overflow-wrap: anywhere; +} +.muted-text { + color: var(--muted); +} +.delete-dialog .form-error { + margin-top: 16px; + margin-bottom: 0; +} + +@media (max-width: 960px) { + .task-table th:first-child { + width: 37%; + } + .task-table th:nth-child(2), + .task-table th:nth-child(3) { + width: 13%; + } + .task-table th:nth-child(4) { + width: 22%; + } + .task-table th:nth-child(5) { + width: 15%; + } + .task-table th, + .task-table td { + padding-left: 10px; + padding-right: 10px; + } + .pagination { + align-items: flex-start; + flex-direction: column; + } + .page-controls { + width: 100%; + justify-content: flex-end; + } +} + +@media (max-width: 640px) { + body { + font-size: 15px; + } + .workspace { + padding-top: 22px; + } + .page-header { + flex-direction: column; + align-items: stretch; + gap: 20px; + margin-bottom: 20px; + } + .page-heading { + gap: 12px; + } + .page-mark { + width: 42px; + height: 42px; + } + h1 { + font-size: 25px; + } + .page-description { + font-size: 13px; + } + .header-actions { + justify-content: space-between; + } + .button, + .filter-button, + .page-controls .button, + .page-controls select, + .row-button { + min-height: 44px; + } + .list-toolbar { + padding: 18px 16px 12px; + } + .list-heading-row { + gap: 6px; + } + .list-heading-row h2 { + font-size: 17px; + } + .list-toolbar .button { + padding: 8px 10px; + font-size: 12px; + } + .filter-row { + padding: 0 16px 16px; + } + .status-filters { + width: 100%; + } + .filter-button { + flex: 1; + padding-left: 10px; + padding-right: 10px; + } + .list-hint { + display: none; + } + .task-table, + .task-table tbody { + display: block; + } + .task-table thead { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + } + .task-table tr { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px 14px; + padding: 18px 16px; + border-top: 1px solid var(--border); + } + .task-table td { + display: block; + padding: 0 !important; + border: 0; + } + .task-table td:first-child, + .task-table td:last-child { + grid-column: 1 / -1; + } + .task-table td:nth-child(4) { + grid-column: 1 / -1; + } + .task-table td[data-label]::before { + content: attr(data-label); + display: inline-block; + margin-right: 8px; + color: var(--muted); + font-size: 12px; + } + .task-table .is-priority td:first-child::before { + left: -16px; + top: 0; + bottom: 0; + } + .task-title-button { + font-size: 16px; + min-height: 32px; + } + .row-actions { + gap: 20px; + margin-top: -6px; + } + .row-button { + padding: 6px 4px; + } + .pagination { + padding: 16px; + gap: 12px; + } + .page-controls { + gap: 6px; + justify-content: space-between; + flex-wrap: wrap; + } + .page-size-label { + display: none; + } + .page-controls select { + width: 72px; + padding: 8px; + } + .page-controls .button { + padding: 8px 10px; + } + .page-number { + min-width: 28px; + } + .error-panel { + margin-left: 16px; + margin-right: 16px; + align-items: flex-start; + flex-direction: column; + } + .dialog-header { + padding: 20px 18px 16px; + } + .dialog-body { + padding: 20px 18px; + } + .dialog-footer { + padding: 16px 18px; + flex-wrap: wrap; + } + .field-grid { + gap: 12px; + } + input, + textarea, + select { + font-size: 16px; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation: none !important; + transition: none !important; + } +} diff --git a/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/tests/mockRequest.test.js b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/tests/mockRequest.test.js new file mode 100644 index 0000000..71e86e6 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/tests/mockRequest.test.js @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createMockTaskApi } from '../dev/mockRequest.js' + +const draft = { + title: '测试任务', + description: '保留完整说明', + status: 'todo', + priority: 'normal', +} +const call = (api, method, path, extra = {}) => + api.request({ + method, + url: `/apps/task_demo/api/${path}`, + ...extra, + }) + +test('内存数据支持分页、筛选、完整 CRUD 和响应副本隔离', async () => { + const api = createMockTaskApi({ getDelay: () => 0 }) + const first = await call(api, 'get', 'tasks', { + params: { page: 1, pageSize: 10 }, + }) + const second = await call(api, 'get', 'tasks', { + params: { page: 2, pageSize: 10 }, + }) + assert.equal(first.total, 12) + assert.equal(first.items.length, 10) + assert.equal(second.items.length, 2) + assert.equal(new Set([...first.items, ...second.items].map((task) => task.id)).size, 12) + const created = await call(api, 'post', 'tasks', { data: draft }) + assert.equal(created.title, draft.title) + created.title = '修改响应不能污染存储' + assert.equal((await call(api, 'get', `tasks/${created.id}`)).title, draft.title) + const updated = await call(api, 'put', `tasks/${created.id}`, { + data: { ...draft, status: 'done', priority: 'high' }, + }) + assert.equal(updated.createdAt, created.createdAt) + const done = await call(api, 'get', 'tasks', { params: { status: 'done' } }) + assert.ok(done.items.every((task) => task.status === 'done')) + assert.ok(done.items.some((task) => task.id === created.id && task.priority === 'high')) + assert.deepEqual(await call(api, 'delete', `tasks/${created.id}`), { + deleted: true, + }) + await assert.rejects(call(api, 'get', `tasks/${created.id}`), /不存在/) +}) + +test('只读权限在请求层拒绝所有写操作并保留数据', async () => { + let writable = true + const api = createMockTaskApi({ + getDelay: () => 0, + getCanWrite: () => writable, + }) + const task = await call(api, 'post', 'tasks', { data: draft }) + writable = false + assert.equal((await call(api, 'get', 'info')).canWrite, false) + assert.equal((await call(api, 'get', 'tasks')).canWrite, false) + for (const [method, path] of [ + ['post', 'tasks'], + ['put', `tasks/${task.id}`], + ['delete', `tasks/${task.id}`], + ]) { + await assert.rejects(call(api, method, path, { data: draft }), /写入权限/) + } + assert.equal((await call(api, 'get', 'tasks')).total, 13) + assert.equal((await call(api, 'get', `tasks/${task.id}`)).title, draft.title) +}) + +test('失败、取消及无效输入不会产生写入副作用', async () => { + let fail = true + const api = createMockTaskApi({ getDelay: () => 10, shouldFail: () => fail }) + await assert.rejects(call(api, 'post', 'tasks', { data: draft }), /模拟请求失败/) + fail = false + const controller = new AbortController() + const pending = call(api, 'post', 'tasks', { + data: draft, + signal: controller.signal, + }) + controller.abort() + await assert.rejects(pending, /取消/) + for (const data of [ + { ...draft, title: ' ' }, + { ...draft, description: 'x'.repeat(501) }, + { ...draft, status: 'invalid' }, + ]) { + await assert.rejects(call(api, 'post', 'tasks', { data })) + } + assert.equal((await call(api, 'get', 'tasks')).total, 12) + api.clear() + assert.equal((await call(api, 'get', 'tasks')).total, 0) + api.reset() + assert.equal((await call(api, 'get', 'tasks')).total, 12) +}) diff --git a/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/vite.config.js b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/vite.config.js new file mode 100644 index 0000000..32ac176 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/vite.config.js @@ -0,0 +1,37 @@ +import { fileURLToPath, URL } from 'node:url' + +// 开发配置只在本地服务注入,生产页面由宿主注入真实插件标识与部署基址。 +export default { + base: './', + plugins: [ + { + name: 'local-task-plugin-config', + apply: 'serve', + transformIndexHtml(html, context) { + if (context.path !== '/index.html' && context.path !== '/') return html + return { + html, + tags: [ + { + tag: 'script', + attrs: { id: 'ruoyi-plugin-config', type: 'application/json' }, + children: JSON.stringify({ pluginId: 'task_demo' }), + injectTo: 'head', + }, + ], + } + }, + }, + ], + resolve: { + alias: { + '@ruoyi/plugin-bridge': fileURLToPath( + new URL( + '../../../../../../ruoyi-fastapi-frontend/src/utils/pluginBridge.js', + import.meta.url + ) + ), + }, + }, + build: { sourcemap: false }, +} diff --git a/ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/Cargo.lock b/ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/Cargo.lock new file mode 100644 index 0000000..ed5615f --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/Cargo.lock @@ -0,0 +1,224 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "700d18fa267b73b9b521fd7e13580e2f446916f176cacee1ab63fcc8191f1655" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-async-runtimes" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3ef68daa7316a3fac65e5e18b2203f010346de1c1c53456811a2624673ab046" +dependencies = [ + "futures-channel", + "futures-util", + "once_cell", + "pin-project-lite", + "pyo3", + "tokio", +] + +[[package]] +name = "pyo3-build-config" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b3fc0c4d08f6bb10e71fe39dfb9e2f59c6eb6854e22ec8092f50c69a4499adb" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc0b8e19df29aad7086cf977bb0c2a2f143e30567eb113e9cf72b62ca698330" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6100e8a4b5eba53afaa5ed078364851a0b2499c553a44c31026b929049b49dc6" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6143877a16e82b5a727b7127ff4cd86858a24a28d745d72f43e6f227c7b1bdb3" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ruoyi-plugin-rust-demo" +version = "1.0.0" +dependencies = [ + "pyo3", + "pyo3-async-runtimes", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" diff --git a/ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/Cargo.toml b/ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/Cargo.toml new file mode 100644 index 0000000..1d4326b --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "ruoyi-plugin-rust-demo" +version = "1.0.0" +edition = "2021" +publish = false + +[lib] +name = "_native" +crate-type = ["cdylib"] + +[dependencies] +pyo3 = { version = "0.29.2", features = ["abi3-py310"] } +pyo3-async-runtimes = { version = "0.29.0", features = ["tokio-runtime"] } + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = "symbols" diff --git a/ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/README.md b/ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/README.md new file mode 100644 index 0000000..bb2b29a --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/README.md @@ -0,0 +1,119 @@ +# Rust 原生插件示例 + +本示例将业务实现编译成 PyO3 扩展,通过现有插件平台加载。默认创建 ASGI 子应用,也提供 Router 入口。Rust 可以调用已有 Python 工具,并通过宿主 SDK 调用用户资料服务。交付 ZIP 包含清单、原生扩展和包元数据,不包含 `.rs`、Cargo 文件或 Python 业务源码。 + +示例要求 Host API `^1.3.0`,新增 `POST /apps/rust_demo/api/echo/item-7?tag=first&tag=second`。使用宿主 Bearer 认证和 `rust_demo:view` 权限,发送 `{"message":"你好"}` JSON,即可收到 `pathParams`、保留同名多值的 `query`、`body` 和 `requestId`。Router 入口对应 `/rust_demo/api/echo/{item_id}`。Rust 从 `context.request.to_payload()` 读取宿主数据快照;SDK 负责体积限制、JSON 解析、权限及超时,不把认证头传入快照。 + +创建自有项目可使用 `ruoyi plugin create --template rust-asgi` 或 `--template rust-bundle`,详见[脚手架与完整构建](../../../../docs/plugin_development.md#16-v2-项目脚手架与完整构建)。原生构建器也支持 bundle 组合:先独立构建清单指定的前端目录,再使用同一 `--source` / `--output` 命令组装;本示例本身仍为无前端的 API 示例。 + +## 构建 + +在仓库的 `ruoyi-fastapi-backend` 目录执行,先按 [CLI 环境准备](../../../../docs/cli_usage.md#22-安装依赖)激活已安装宿主依赖的 Python 环境。下文 `python` 使用当前环境,通用命令适用于 Windows PowerShell、macOS 和 Linux。 + +构建端需要 Rust 及目标平台编译工具:Windows 使用 Rust MSVC 工具链、Visual Studio Build Tools 的 C++ 工具和 Windows SDK;macOS 使用 Xcode Command Line Tools;Linux 使用 GCC 或 Clang 等编译工具。部署端使用构建好的匹配平台制品,无需安装 Rust。 + +```bash +python -m pip install 'maturin>=1.15,<2' +rustup toolchain install 1.99.0 --profile minimal --component rustfmt --component clippy +rustup default 1.99.0 +python scripts/build_native_plugin.py --output target/native-package +``` + +若刚安装 Rust,重新打开终端使 Cargo 路径生效。构建命令使用当前 Python,使用已提交的 `Cargo.lock` 执行 release 构建;首次构建需取得锁定的 Rust 依赖。输出目录必须不存在,每次构建使用新目录。 + +输出布局: + +```text +target/native-package/ + wheels/ruoyi_plugin_rust_demo-1.0.0---.whl + rust_demo/ + plugin.yaml + native/ + ruoyi_plugin_rust_demo/__init__.py + ruoyi_plugin_rust_demo/_native. + ruoyi_plugin_rust_demo-1.0.0.dist-info/... + rust_demo-1.0.0.zip +``` + +构建器按当前解释器的 wheel tag 优先级选择发行包,校验名称、版本、Python 要求、已安装依赖及完整 RECORD SHA256,再展开至新目录。拒绝目录越界、链接、大小写冲突、未登记文件和超限解压。部署需选择与目标操作系统、架构和 Python 匹配的制品;Windows wheel 不能在 Linux 使用。本例声明 `abi3-py310`,仍需对实际运行平台分别验证。该构建脚本输出的目录 ZIP 不含发布者签名;签名及维护发布由下面的独立 CLI 完成。 + +## 本地集成验证 + +先指定已构建的交付目录。Windows PowerShell: + +```powershell +$env:RUOYI_NATIVE_PLUGIN_DIR = (Resolve-Path 'target/native-package/rust_demo').Path +``` + +macOS / Linux(Bash、Zsh): + +```bash +export RUOYI_NATIVE_PLUGIN_DIR="$(pwd)/target/native-package/rust_demo" +``` + +然后运行测试和 Rust 检查(三种平台通用): + +```bash +python -m pytest tests/plugins/native tests/plugins/core/native tests/plugins/core/runtime/test_host_services.py tests/plugins/core/runtime/test_job_dispatcher.py -q +cargo fmt --manifest-path plugins/examples/rust/rust_demo/Cargo.toml --check +cargo clippy --manifest-path plugins/examples/rust/rust_demo/Cargo.toml --locked -- -D warnings +``` + +测试直接加载编译后的扩展,覆盖 ASGI、Router、原生 future、Python 协程桥、权限、异常、取消、lifespan 和真实 APScheduler 单次分发。数据库与登录使用替身;测试不会安装、启用插件或写入实际业务数据库。不设置环境变量时,真实原生测试明确跳过。 + +仓库的 `.github/workflows/native-plugin.yml` 配置 Windows/Linux、Python 3.10、3.11、3.12、3.13 构建测试矩阵。应以目标平台对应的实际构建与测试结果确认兼容性。 + +## 签名制品与维护发布 + +签名输入应是构建后的 `target/native-package/rust_demo`,其中已有 `native/` 扩展及发行包元数据;不要对包含 Cargo 工程的本示例源码目录或整个 `target/native-package` 打包。发布者先准备位于插件目录之外的 Ed25519 PKCS8 PEM 私钥;下列密钥路径仅为示例。 + +```bash +ruoyi plugin artifact build target/native-package/rust_demo target/rust_demo-1.0.0.rpk --key-file ../signing-keys/publisher.pem --key-id publisher --env=dev --output=json +ruoyi plugin artifact verify target/rust_demo-1.0.0.rpk --env=prod --output=json +ruoyi plugin artifact import target/rust_demo-1.0.0.rpk --env=prod --allow-prod --yes --output=json +``` + +宿主必须预先启用制品功能,在外部信任文件中配置该 keyId 的真实公钥并授权 `rust_demo`。包内公钥不能自行建立信任。导入不执行插件,也不安装或激活;它将完整内容保存到 `/rust_demo///payload/rust_demo`,后续使用重新检查当前信任与文件摘要。已有 `plugins/rust_demo` 目录会与制品部署冲突,迁移时应在维护窗口备份并移出。 + +取得导入返回的精确 digest 后,按[开发手册第 15 节](../../../../docs/plugin_development.md#15-v2-签名制品与维护发布)执行 plan、停机、prepare、读取 generation、select、重启与 status 检查。不能用相同版本号猜测准备完成的 digest;同版本但缺少对应准备证据时应提升版本。不要覆盖已加载二进制或直接修改不可变目录,运行期输出文件与缓存应写到制品目录之外。 + +代码回滚只选择上一制品并重启,要求旧代码兼容当前数据库,不执行数据库降级。相同内容重签不会改变 digest 或替换存储中的签名;旧签名撤销后应发布新版本/新内容,或在停机维护中显式处理旧对象及其索引引用。当前没有热卸载或自动清理制品的 CLI。 + +## 在开发环境安装 + +1. 将构建输出中的 `rust_demo` 目录复制至后端 `plugins/rust_demo`。部署的是构建产物目录,不是此源码目录。 +2. 执行 `ruoyi plugin check rust_demo --env=dev`。 +3. 执行 `ruoyi plugin install rust_demo --env=dev --yes` 和 `ruoyi plugin enable rust_demo --env=dev --yes`,然后重启后端。 +4. 在现有角色权限管理中授予 `rust_demo:view` 和 `rust_demo:profile`,或使用具有通配权限的管理员。 +5. 携带宿主 `Authorization: Bearer ` 调用下列接口。启用传输加密时仍须遵循宿主协议。 + +| 接口 | 行为 | +| --- | --- | +| `GET /apps/rust_demo/api/info` | Rust 调用已有 `TimezoneUtil.utc_now()` 并通过 Rust future 返回结果 | +| `GET /apps/rust_demo/api/profile` | Rust 调用 `host.service('users.current_profile.v1')`,返回当前登录用户的限定资料 DTO | + +用户服务在调用边界核对插件身份、用户和 `rust_demo:profile` 权限,每次调用建立并关闭独立数据库会话,再复用现有 `UserService.user_profile_services()`。返回值不包含密码等内部字段。服务在宿主 Python 事件循环上执行,保留上下文与取消传播。需要业务事务时应另外设计明确的服务接口。 + +现有服务模式生命周期限制继续适用。生产变更应通过维护部署流程完成;不要覆盖已加载的二进制,升级后重启进程。 + +## Router 变体 + +在自己的插件清单中将 `backend.integration` 改为 `router`,删除 `backend.asgi`,将入口改为 `ruoyi_plugin_rust_demo._native:create_router_plugin`。重新构建或在未激活的交付目录调整清单,然后重启。接口前缀改为 `/rust_demo/api/`。同一插件 ID 只启用一种接入方式。 + +两种模式均使用 SDK 的 `plugin_endpoint(callback, permission=...)`,由宿主注入 `PluginRequestContext` 并执行权限检查。原生函数不需要让 FastAPI 直接反射参数签名。 + +## 定时任务 + +示例清单声明 `heartbeat`,默认 `enabled: false`,只记录一条日志并返回任务 ID。可通过现有任务管理的状态操作启用;此默认值不会赋予任何用户身份或业务权限。 + +v2 任务使用 `default` 异步执行器,`timeoutSeconds` 默认 30 秒。调度器保存稳定 Python 分发函数和 `[plugin_id, job_id, version]`,回调参数仍来自当前清单。执行前检查当前 worker 已就绪、版本一致、任务仍存在且插件已启用;随后传入 `PluginTaskContext(host, job_id)`,等待 Rust 或 Python awaitable。后台上下文没有请求用户,不能调用要求请求身份的用户资料接口。 + +进程关闭先停止分发,整批取消当前插件的任务并等待最多 5 秒,再关闭子应用资源。超时后继续其他清理,保留残留任务并阻止同名任务重新绑定,直到旧任务全部退出;迟到成功按取消记录。异步插件必须配合取消,等待上限不能强制终止阻塞的原生代码,也不能撤销已经执行的写操作。生命周期重试及关闭失败隔离见[开发手册](../../../../docs/plugin_development.md#67-asgi-子应用与生命周期)。不支持 v2 进程池任务,不提供原生模块热重载。多 worker 的调度选主继续沿用现有调度器,跨进程部署仍需单独验收。 + +## SDK 与使用说明 + +本例依赖 `Host API ^1.1.0`,`PluginDefinition.api_version` 仍为协议版本 `1`。宿主导出 `PluginHostContext`、`PluginRequestContext`、`PluginTaskContext`、`plugin_endpoint`、`plugin_lifespan` 和 `await_plugin_callback`。 + +通用清单、显式入口和 SDK 边界见[插件开发手册](../../../../docs/plugin_development.md#510-v2-清单与能力组合)。独立前端 bundle、PluginFrame 和浏览器专用会话桥的使用方式见 [Python bundle 示例](../../python/bundle_demo/README.md);本 Rust 示例只提供 API,需要页面时可使用 `rust-bundle` 脚手架,并分别构建、验证原生后端和前端。签名制品与维护发布方式见上文,真实数据库与多 worker 的验收方法见[正式验收说明](../../../../docs/plugin_development.md#17-本地真实服务验收与-ci)。原生插件运行于宿主进程,应按可信扩展管理。 + +参考:[PyO3 调用 Python](https://pyo3.rs/main/python-from-rust/calling-existing-code)、[PyO3 异步互操作](https://pyo3.rs/main/ecosystem/async-await)、[Maturin](https://www.maturin.rs/)。 diff --git a/ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/plugin.yaml b/ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/plugin.yaml new file mode 100644 index 0000000..543d6e5 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/plugin.yaml @@ -0,0 +1,35 @@ +manifestVersion: 2 +id: rust_demo +name: Rust 原生 ASGI 示例 +version: 1.0.0 +description: 通过 PyO3 调用宿主 Python SDK 和用户资料服务。 +backend: + runtime: native + integration: asgi + module: ruoyi_plugin_rust_demo + entrypoint: ruoyi_plugin_rust_demo._native:create_plugin + native: + distribution: ruoyi-plugin-rust-demo + asgi: + mountPath: /apps/rust_demo + lifespan: managed + health: + checker: ruoyi_plugin_rust_demo._native:health + jobs: + - id: heartbeat + name: Rust 后台任务示例 + callable: ruoyi_plugin_rust_demo._native.heartbeat + cronExpression: '0 */5 * * * ?' + enabled: false + timeoutSeconds: 10 +frontend: + delivery: + type: none +permissions: + - code: rust_demo:view + name: 查看 Rust 示例 + - code: rust_demo:profile + name: 通过 Rust 示例查看本人资料 +compatibility: + hostApiVersion: ^1.3.0 + pythonVersion: '>=3.10' diff --git a/ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/pyproject.toml b/ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/pyproject.toml new file mode 100644 index 0000000..0d10f7a --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/pyproject.toml @@ -0,0 +1,14 @@ +[build-system] +requires = ["maturin>=1.15,<2"] +build-backend = "maturin" + +[project] +name = "ruoyi-plugin-rust-demo" +version = "1.0.0" +requires-python = ">=3.10" +description = "RuoYi native ASGI plugin example" + +[tool.maturin] +python-source = "python" +module-name = "ruoyi_plugin_rust_demo._native" +bindings = "pyo3" diff --git a/ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/python/ruoyi_plugin_rust_demo/__init__.py b/ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/python/ruoyi_plugin_rust_demo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/src/lib.rs b/ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/src/lib.rs new file mode 100644 index 0000000..246ada0 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/src/lib.rs @@ -0,0 +1,194 @@ +//! 交付 wheel 包含编译后的原生扩展,Rust 源码保留在开发工程中。 +use pyo3::prelude::*; +use pyo3::types::PyDict; + +/// 声明 ASGI 子应用工厂,资源初始化由宿主驱动的生命周期负责。 +#[pyfunction] +fn create_plugin<'py>(py: Python<'py>, _host: Bound<'py, PyAny>) -> PyResult> { + let kwargs = PyDict::new(py); + kwargs.set_item("app_factory", wrap_pyfunction!(create_app, py)?)?; + py.import("plugins.core.sdk")? + .getattr("PluginDefinition")? + .call((), Some(&kwargs)) +} + +/// 演示以显式路由声明方式接入宿主,并使用独立权限保护接口。 +#[pyfunction] +fn create_router_plugin<'py>( + py: Python<'py>, + _host: Bound<'py, PyAny>, +) -> PyResult> { + let router = py.import("fastapi")?.getattr("APIRouter")?.call0()?; + add_routes(py, &router, "/rust_demo")?; + let kwargs = PyDict::new(py); + kwargs.set_item("routers", vec![router])?; + py.import("plugins.core.sdk")? + .getattr("PluginDefinition")? + .call((), Some(&kwargs)) +} + +/// 为当前 worker 创建子应用,并绑定原生启动、关闭回调和业务路由。 +#[pyfunction] +fn create_app<'py>(py: Python<'py>, host: Bound<'py, PyAny>) -> PyResult> { + let sdk = py.import("plugins.core.sdk")?; + let lifespan = sdk.getattr("plugin_lifespan")?.call1(( + host, + wrap_pyfunction!(startup, py)?, + wrap_pyfunction!(shutdown, py)?, + ))?; + let kwargs = PyDict::new(py); + kwargs.set_item("title", "Rust native plugin")?; + kwargs.set_item("lifespan", lifespan)?; + kwargs.set_item("docs_url", py.None())?; + kwargs.set_item("redoc_url", py.None())?; + let app = py + .import("fastapi")? + .getattr("FastAPI")? + .call((), Some(&kwargs))?; + add_routes(py, &app, "")?; + Ok(app) +} + +/// 通过宿主 SDK 包装接口权限,供子应用和显式路由两种模式复用。 +fn add_routes(py: Python<'_>, app: &Bound<'_, PyAny>, prefix: &str) -> PyResult<()> { + let sdk = py.import("plugins.core.sdk")?; + for (path, permission, callback, summary, description, method, json_body) in [ + ( + "/api/info", + "rust_demo:view", + wrap_pyfunction!(info, py)?, + "获取原生插件信息接口", + "用于获取当前插件标识、宿主API版本、运行引擎和服务器时间", + "GET", + false, + ), + ( + "/api/profile", + "rust_demo:profile", + wrap_pyfunction!(profile, py)?, + "获取原生插件当前用户信息接口", + "用于通过宿主服务获取当前登录用户的基本信息", + "GET", + false, + ), + ( + "/api/echo/{item_id}", + "rust_demo:view", + wrap_pyfunction!(echo, py)?, + "验证原生插件请求上下文接口", + "返回路径参数、多值查询参数、JSON请求体和宿主追踪标识", + "POST", + true, + ), + ] { + let options = PyDict::new(py); + options.set_item("permission", permission)?; + options.set_item("json_body", json_body)?; + let endpoint = sdk + .getattr("plugin_endpoint")? + .call((callback,), Some(&options))?; + let route_options = PyDict::new(py); + route_options.set_item("summary", summary)?; + route_options.set_item("description", description)?; + route_options.set_item("response_model", py.get_type::())?; + route_options.set_item("methods", vec![method])?; + app.call_method( + "add_api_route", + (format!("{prefix}{path}"), endpoint), + Some(&route_options), + )?; + } + Ok(()) +} + +/// 记录插件启动,并通过 Python 可等待对象返回就绪状态。 +#[pyfunction] +fn startup<'py>(py: Python<'py>, host: Bound<'py, PyAny>) -> PyResult> { + host.getattr("logger")? + .call_method1("info", ("Rust plugin started",))?; + pyo3_async_runtimes::tokio::future_into_py(py, async { + Python::attach(|py| { + let state = PyDict::new(py); + state.set_item("nativeReady", true)?; + Ok(state.unbind()) + }) + }) +} + +/// 演示插件关闭回调;插件只负责释放自己创建的资源。 +#[pyfunction] +fn shutdown<'py>(py: Python<'py>, host: Bound<'py, PyAny>) -> PyResult> { + host.getattr("logger")? + .call_method1("info", ("Rust plugin stopped",))?; + pyo3_async_runtimes::tokio::future_into_py(py, async { Ok(()) }) +} + +/// 通过可等待对象返回健康结果,供宿主统一处理原生异步回调。 +#[pyfunction] +fn health<'py>(py: Python<'py>, _context: Bound<'py, PyAny>) -> PyResult> { + pyo3_async_runtimes::tokio::future_into_py(py, async { Ok(true) }) +} + +#[pyfunction] +fn info<'py>(py: Python<'py>, context: Bound<'py, PyAny>) -> PyResult> { + let host = context.getattr("host")?; + let plugin_id: String = host.getattr("plugin_id")?.extract()?; + let api_version: String = host.getattr("api_version")?.extract()?; + // 在当前 Python 调用线程中复用宿主时间工具,再将普通字符串交给异步任务。 + let timestamp: String = py + .import("utils.time_util")? + .getattr("TimezoneUtil")? + .call_method0("utc_now")? + .call_method0("isoformat")? + .extract()?; + pyo3_async_runtimes::tokio::future_into_py(py, async move { + Python::attach(|py| { + let result = PyDict::new(py); + result.set_item("pluginId", plugin_id)?; + result.set_item("hostApiVersion", api_version)?; + result.set_item("engine", "rust")?; + result.set_item("serverTime", timestamp)?; + Ok(result.unbind()) + }) + }) +} + +#[pyfunction] +fn profile<'py>(_py: Python<'py>, context: Bound<'py, PyAny>) -> PyResult> { + // 请求取消和数据库会话生命周期继续由宿主事件循环管理,不能阻塞等待或调用 asyncio.run。 + context + .getattr("host")? + .call_method1("service", ("users.current_profile.v1",))? + .call1((context,)) +} + +#[pyfunction] +fn echo<'py>(py: Python<'py>, context: Bound<'py, PyAny>) -> PyResult> { + let result = context.getattr("request")?.call_method0("to_payload")?; + result.set_item("requestId", context.getattr("request_id")?)?; + let payload = result.unbind(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { Ok(payload) }) +} + +/// 演示原生后台任务读取宿主任务上下文并返回执行结果。 +#[pyfunction] +fn heartbeat<'py>(py: Python<'py>, context: Bound<'py, PyAny>) -> PyResult> { + let job_id: String = context.getattr("job_id")?.extract()?; + context + .getattr("host")? + .getattr("logger")? + .call_method1("info", (format!("Rust background job: {job_id}"),))?; + pyo3_async_runtimes::tokio::future_into_py(py, async move { Ok(job_id) }) +} + +/// 导出清单和宿主运行时需要的 Python 扩展入口。 +#[pymodule] +fn _native(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_function(wrap_pyfunction!(create_plugin, module)?)?; + module.add_function(wrap_pyfunction!(create_router_plugin, module)?)?; + module.add_function(wrap_pyfunction!(create_app, module)?)?; + module.add_function(wrap_pyfunction!(health, module)?)?; + module.add_function(wrap_pyfunction!(profile, module)?)?; + module.add_function(wrap_pyfunction!(heartbeat, module)?)?; + Ok(()) +} diff --git a/ruoyi-fastapi-backend/pyproject.toml b/ruoyi-fastapi-backend/pyproject.toml index b4e7eeb..19973ac 100644 --- a/ruoyi-fastapi-backend/pyproject.toml +++ b/ruoyi-fastapi-backend/pyproject.toml @@ -16,3 +16,6 @@ pythonpath = ["."] [tool.setuptools.packages.find] include = ["cli*"] + +[tool.setuptools.package-data] +"cli.runtime.plugin.scaffold" = ["assets/*.tmpl", "assets/native/*.tmpl", "assets/web/*.tmpl", "assets/python/*.tmpl"] diff --git a/ruoyi-fastapi-backend/scripts/build_native_plugin.py b/ruoyi-fastapi-backend/scripts/build_native_plugin.py new file mode 100644 index 0000000..c328a43 --- /dev/null +++ b/ruoyi-fastapi-backend/scripts/build_native_plugin.py @@ -0,0 +1,194 @@ +import argparse +import json +import shutil +import stat +import subprocess +import sys +from pathlib import Path +from typing import Any +from zipfile import ZIP_DEFLATED, ZipFile + +BACKEND_ROOT = Path(__file__).resolve().parents[1] + + +def _resource_roots(source: Path, manifest: Any, output: Path) -> list[Path]: + """ + 检查清单声明的资源根路径及前端入口,在构建前拒绝缺失产物。 + + :param source: 原生插件源码根目录 + :param manifest: 已解析的插件清单 + :param output: 待创建的构建输出目录 + :return: 通过路径检查的部署资源根路径 + """ + from plugins.core.artifacts.package import is_link_or_reparse # noqa: PLC0415 + from plugins.core.artifacts.schema import safe_artifact_path # noqa: PLC0415 + + resources = {str(item) for item in manifest.backend.migrations} + resources.update(str(item) for item in manifest.backend.seeds) + if (source / 'plugin.lock.yaml').exists(): + resources.add('plugin.lock.yaml') + if manifest.frontend.delivery.type == 'bundle': + bundle = manifest.frontend.bundle + resources.add(bundle.directory) + if not (source / bundle.directory / bundle.entry).is_file(): + raise ValueError('缺少 bundle HTML 入口,请先独立构建前端后再组装制品') + + roots = [] + for relative in sorted(resources): + safe_artifact_path(relative) + resource = source / relative + # 逐级检查,不能先 resolve 后丢失中间目录的链接信息。 + current = source + for component in Path(relative).parts: + current /= component + if is_link_or_reparse(current.lstat()): + raise ValueError(f'交付资源不能包含链接或重解析点:{relative}') + if resource.is_dir() and output.is_relative_to(resource.resolve()): + raise ValueError('输出目录不能位于待交付资源目录内') + roots.append(resource) + return roots + + +def collect_deployment_resources(source: Path, manifest: Any, output: Path) -> dict[str, Path]: + """ + 收集清单资源和已构建 bundle,拒绝开发工程、敏感文件及链接。 + + :param source: 原生插件源码根目录 + :param manifest: 已解析的插件清单 + :param output: 待创建的构建输出目录 + :return: 插件内相对路径与对应源码文件的映射 + """ + from plugins.core.artifacts.package import ( # noqa: PLC0415 + CACHE_DIRECTORIES, + FORBIDDEN_DIRECTORIES, + FORBIDDEN_FILES, + FORBIDDEN_SUFFIXES, + is_link_or_reparse, + ) + from plugins.core.artifacts.schema import ( # noqa: PLC0415 + DEFAULT_LIMITS, + safe_artifact_path, + validate_path_inventory, + ) + + files: dict[str, Path] = {} + total_bytes = 0 + native_root = Path(manifest.backend.native.module_root) + for resource in _resource_roots(source, manifest, output): + pending = [resource] + while pending: + item = pending.pop() + name = item.relative_to(source).as_posix() + path = safe_artifact_path(name) + if any(part.casefold() in CACHE_DIRECTORIES for part in path.parts): + continue + if ( + any(part.casefold() in FORBIDDEN_DIRECTORIES for part in path.parts) + or path.name.casefold() in FORBIDDEN_FILES + or path.suffix.casefold() in FORBIDDEN_SUFFIXES + or any(part.casefold() == '.env' or part.casefold().startswith('.env.') for part in path.parts) + ): + raise ValueError(f'交付资源包含开发工程或敏感配置:{name}') + info = item.lstat() + if is_link_or_reparse(info) or not item.resolve().is_relative_to(source): + raise ValueError(f'交付资源不能包含链接或越界路径:{name}') + if stat.S_ISDIR(info.st_mode): + pending.extend(item.iterdir()) + continue + if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1: + raise ValueError(f'交付资源不能包含硬链接或特殊文件:{name}') + if name.casefold() == 'plugin.yaml' or Path(name).is_relative_to(native_root): + raise ValueError(f'交付资源不能覆盖清单或原生发行包:{name}') + if name in files: + continue + files[name] = item + total_bytes += info.st_size + if ( + len(files) > DEFAULT_LIMITS.max_files + or info.st_size > DEFAULT_LIMITS.max_file_bytes + or total_bytes > DEFAULT_LIMITS.max_total_bytes + ): + raise ValueError('交付资源超过制品文件数量或大小限制') + validate_path_inventory(tuple(files)) + return files + + +def main() -> None: + """ + 构建原生 wheel 并组装可离线交付的插件目录与 ZIP。 + + :return: None + """ + parser = argparse.ArgumentParser( + description='构建、静态验证并组装原生插件;使用当前 Python 环境,不写入运行中的 plugins。' + ) + parser.add_argument('--source', type=Path, default=BACKEND_ROOT / 'plugins' / 'examples' / 'rust' / 'rust_demo') + parser.add_argument('--output', type=Path, required=True, help='全新输出目录(其中创建 /)') + arguments = parser.parse_args() + source = arguments.source.resolve() + output = arguments.output.resolve() + if output.exists(): + parser.error('输出目录已存在,请选择全新目录,避免覆盖正在使用的二进制') + sys.path.insert(0, str(BACKEND_ROOT)) + from plugins.core.discovery.scanner import PluginScanner # noqa: PLC0415 + from plugins.core.native.wheel import install_native_wheel, select_native_wheel # noqa: PLC0415 + from plugins.core.validation.structure import PluginStructureChecker # noqa: PLC0415 + + manifest = PluginScanner(source.parent).load_manifest(source / 'plugin.yaml').manifest + if manifest.runtime_kind != 'native': + parser.error('清单必须声明 native 运行时') + if manifest.frontend.delivery.type not in {'none', 'bundle'}: + parser.error('原生交付只支持 none 或已独立构建的 bundle 前端') + if not (source / 'Cargo.lock').is_file(): + parser.error('缺少 Cargo.lock,请先在源码目录执行 cargo generate-lockfile 并提交锁文件') + collect_deployment_resources(source, manifest, output) + output.mkdir(parents=True) + wheels = output / 'wheels' + subprocess.run( + [ + sys.executable, + '-m', + 'maturin', + 'build', + '--release', + '--locked', + '--strip', + '--interpreter', + sys.executable, + '--manifest-path', + str(source / 'Cargo.toml'), + '--out', + str(wheels), + ], + cwd=source, + check=True, + ) + wheel = select_native_wheel(wheels, manifest) + plugin = output / manifest.id + install_native_wheel(wheel, plugin / manifest.backend.native.module_root, manifest) + shutil.copyfile(source / 'plugin.yaml', plugin / 'plugin.yaml') + # 复制预检过的部署文件;前端必须已构建,不隐式运行 npm。 + # Cargo 构建可能耗时较长,复制前再次检查资源路径。 + resources = collect_deployment_resources(source, manifest, output) + for relative, resource in sorted(resources.items()): + target = (plugin / relative).resolve() + if not resource.is_relative_to(source) or not target.is_relative_to(plugin): + raise ValueError('交付资源不能越过插件目录') + if target.exists(): + raise ValueError(f'交付资源重复或覆盖原生目录:{relative}') + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(resource, target) + discovered = PluginScanner(output).load_manifest(plugin / 'plugin.yaml') + checked = PluginStructureChecker(BACKEND_ROOT).check(discovered) + if not checked.ok: + raise ValueError(';'.join(item.message for item in checked.failed_items)) + archive_path = output / f'{manifest.id}-{manifest.version}.zip' + with ZipFile(archive_path, 'x', compression=ZIP_DEFLATED) as archive: + for item in sorted(plugin.rglob('*')): + if item.is_file(): + archive.write(item, item.relative_to(output).as_posix()) + print(json.dumps({'wheel': str(wheel), 'plugin': str(plugin), 'archive': str(archive_path)}, ensure_ascii=False)) + + +if __name__ == '__main__': + main() diff --git a/ruoyi-fastapi-backend/scripts/plugin_release_integration.py b/ruoyi-fastapi-backend/scripts/plugin_release_integration.py new file mode 100644 index 0000000..e22519d --- /dev/null +++ b/ruoyi-fastapi-backend/scripts/plugin_release_integration.py @@ -0,0 +1,1237 @@ +from __future__ import annotations + +import argparse +import asyncio +import base64 +import json +import multiprocessing +import os +import re +import sys +import tempfile +import time +from contextlib import asynccontextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any +from uuid import uuid4 + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +PLUGIN_ID = 'release_it_demo' +TTL_SECONDS = 12 +OPT_IN = 'RUOYI_PLUGIN_IT_ALLOW' +EXPECTED_WORKER_COUNT = 2 + + +@dataclass(frozen=True) +class RunSpec: + """ + 可跨进程序列化的隔离运行信息,不包含连接密码。 + """ + + engine: str + run_id: str + root: str + + @property + def database(self) -> str: + """ + 获取仅属于当前验收运行的数据库名称。 + + :return: 隔离数据库名称 + """ + return f'ruoyi_plugin_it_{self.run_id}' + + @property + def redis_prefix(self) -> str: + """ + 获取仅属于当前验收运行的 Redis 键前缀。 + + :return: 隔离 Redis 键前缀 + """ + return f'ruoyi_plugin_it:{self.run_id}:' + + def validate(self) -> None: + """ + 校验数据库类型、运行标识和绝对输出路径。 + + :return: None + """ + if self.engine not in {'mysql', 'postgresql'} or not re.fullmatch(r'[0-9a-f]{32}', self.run_id): + raise ValueError('Invalid isolated integration run identity') + if not Path(self.root).is_absolute(): + raise ValueError('Integration output root must be absolute') + + +def require_opt_in(engine: str) -> None: + """ + 在导入服务依赖前检查显式授权和连接凭据环境变量。 + + :param engine: 验收使用的数据库引擎 + :return: None + """ + if os.environ.get(OPT_IN) != '1': + raise ValueError(f'Real-service tests are disabled; explicitly set {OPT_IN}=1') + if engine not in {'mysql', 'postgresql'}: + raise ValueError('Select mysql or postgresql explicitly') + db_variable = 'RUOYI_IT_MYSQL_PASSWORD' if engine == 'mysql' else 'RUOYI_IT_PG_PASSWORD' + for variable in (db_variable, 'RUOYI_IT_REDIS_PASSWORD'): + if variable not in os.environ: + raise ValueError(f'Set {variable} in the process environment (empty is allowed)') + + +def safe_error(exc: BaseException) -> str: + """ + 移除名称含 PASSWORD 或 SECRET 的环境变量值,并限制错误输出长度。 + + :param exc: 需要脱敏的异常 + :return: 可安全输出的异常说明 + """ + message = f'{type(exc).__name__}: {exc}' + for name, value in os.environ.items(): + if value and ('PASSWORD' in name.upper() or 'SECRET' in name.upper()): + message = message.replace(value, '') + return message[:6000] + + +def check(condition: bool, message: str) -> None: + """ + 检查验收条件,不成立时抛出带说明的断言异常。 + + :param condition: 预期成立的验收条件 + :param message: 条件不成立时的错误说明 + :return: None + """ + if not condition: + raise AssertionError(message) + + +def emit(phase: str, **details: Any) -> None: + """ + 输出一条可及时刷新的 JSON 验收进度。 + + :param phase: 验收阶段标识 + :param details: 当前阶段的结构化结果 + :return: None + """ + print(json.dumps({'phase': phase, **details}, ensure_ascii=False), flush=True) + + +def configure_process(spec: RunSpec) -> None: + """ + 配置隔离环境并禁用默认应用连接,避免意外使用业务配置。 + + :param spec: 当前验收的隔离运行信息,不包含连接密码 + :return: None + """ + require_opt_in(spec.engine) + spec.validate() + if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) + os.environ['APP_ENV'] = f'plugin_it_{spec.run_id}' + os.environ['APP_DEFAULT_ENABLED_PLUGINS'] = '' + os.environ['DB_DEFAULT_SOURCE'] = 'primary' + os.environ['DB_SOURCES'] = json.dumps( + { + 'primary': { + 'db_type': spec.engine, + 'db_host': '127.0.0.1', + 'db_port': 1, + 'db_username': 'integration_injection_required', + 'db_password': 'unused', + 'db_database': spec.database, + 'db_echo': False, + 'db_connect_timeout': 1, + } + } + ) + os.environ['LOG_FILE_ENABLED'] = 'false' + os.environ['LOG_FILE_BASE_DIR'] = str(Path(spec.root) / 'logs') + sys.dont_write_bytecode = True + from utils.log_util import logger # noqa: PLC0415 + + # 验收只输出已限制长度并脱敏的进度和错误记录。 + logger.remove() + + +def database_engine(spec: RunSpec, *, admin: bool = False) -> Any: + """ + 从验收专用环境变量创建异步数据库连接池。 + + :param spec: 当前验收的隔离运行信息,不包含连接密码 + :param admin: 是否连接管理数据库以创建或删除隔离数据库 + :return: 隔离验收使用的异步数据库连接池 + """ + from sqlalchemy import URL # noqa: PLC0415 + from sqlalchemy.ext.asyncio import create_async_engine # noqa: PLC0415 + + spec.validate() + prefix = 'RUOYI_IT_MYSQL' if spec.engine == 'mysql' else 'RUOYI_IT_PG' + mysql = spec.engine == 'mysql' + url = URL.create( + 'mysql+asyncmy' if mysql else 'postgresql+asyncpg', + username=os.environ.get(f'{prefix}_USER', 'root' if mysql else 'postgres'), + password=os.environ[f'{prefix}_PASSWORD'], + host=os.environ.get(f'{prefix}_HOST', '127.0.0.1'), + port=int(os.environ.get(f'{prefix}_PORT', '3306' if mysql else '5432')), + database=('information_schema' if mysql else 'postgres') if admin else spec.database, + ) + connect_args = ( + {'connect_timeout': 10, 'init_command': "SET time_zone = '+00:00'", 'charset': 'utf8mb4'} + if mysql + else {'timeout': 10, 'server_settings': {'timezone': 'UTC'}} + ) + options = {'isolation_level': 'AUTOCOMMIT'} if admin else {} + return create_async_engine( + url, echo=False, hide_parameters=True, pool_size=3, max_overflow=2, connect_args=connect_args, **options + ) + + +def redis_client() -> Any: + """ + 从验收专用环境变量创建异步 Redis 客户端。 + + :return: 隔离验收使用的 Redis 客户端 + """ + from redis.asyncio import Redis # noqa: PLC0415 + + return Redis( + host=os.environ.get('RUOYI_IT_REDIS_HOST', '127.0.0.1'), + port=int(os.environ.get('RUOYI_IT_REDIS_PORT', '6379')), + db=int(os.environ.get('RUOYI_IT_REDIS_DB', '2')), + username=os.environ.get('RUOYI_IT_REDIS_USER') or None, + password=os.environ['RUOYI_IT_REDIS_PASSWORD'] or None, + decode_responses=True, + socket_connect_timeout=10, + socket_timeout=10, + ) + + +def wire_runtime(spec: RunSpec, engine: Any) -> tuple[Any, Any]: + """ + 替换资源工厂和 Redis 命名空间,保留真实 DAO、锁与续租逻辑。 + + :param spec: 当前验收的隔离运行信息,不包含连接密码 + :param engine: 隔离数据库的异步连接池 + :return: 制品部署配置和异步会话工厂 + """ + from sqlalchemy.ext.asyncio import async_sessionmaker # noqa: PLC0415 + + from config.database import DataSourceRegistry # noqa: PLC0415 + from config.get_redis import RedisUtil # noqa: PLC0415 + from plugins.core.deployment.config import PluginDeploymentConfig # noqa: PLC0415 + from plugins.core.runtime.service.lifecycle_lock import RedisPluginLifecycleLock # noqa: PLC0415 + + sessions = async_sessionmaker(engine, expire_on_commit=False) + + @asynccontextmanager + async def isolated_session(*args: Any, **kwargs: Any) -> AsyncGenerator[Any, None]: + """ + 仅开放当前验收的默认数据库会话,拒绝命名业务数据源。 + + :param args: 宿主会话工厂的位置参数,验收时禁止指定命名数据源 + :param kwargs: 宿主会话工厂的关键字参数 + :return: 异步上下文中的隔离数据库会话 + """ + if args or kwargs.get('name') is not None: + raise ValueError('Named application data sources are forbidden in integration acceptance') + async with sessions() as session: + yield session + + async def isolated_redis(*args: Any, **kwargs: Any) -> Any: + """ + 为宿主资源工厂返回验收专用 Redis 客户端。 + + :param args: 兼容宿主 Redis 工厂的位置参数,验收不使用这些参数 + :param kwargs: 兼容宿主 Redis 工厂的关键字参数,验收不使用这些参数 + :return: 验收专用 Redis 客户端 + """ + return redis_client() + + DataSourceRegistry.session = isolated_session + RedisUtil.create_redis_pool = staticmethod(isolated_redis) + RedisPluginLifecycleLock._build_lock_key = staticmethod(lambda: f'{spec.redis_prefix}lifecycle:global') + root = Path(spec.root) + config = PluginDeploymentConfig( + root / 'host', root / 'store', root / 'trusted.json', heartbeat_seconds=1, worker_ttl_seconds=TTL_SECONDS + ) + return config, sessions + + +async def create_isolated_database(spec: RunSpec, admin: Any) -> None: + """ + 创建全新的隔离数据库,名称冲突时直接失败。 + + :param spec: 当前验收的隔离运行信息,不包含连接密码 + :param admin: 用于创建和删除隔离数据库的管理连接池 + :return: None + """ + spec.validate() + quote = '`' if spec.engine == 'mysql' else '"' + suffix = ' CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci' if spec.engine == 'mysql' else '' + async with admin.connect() as connection: + # 不使用 IF NOT EXISTS,名称冲突必须失败,避免接管已有数据库。 + await connection.exec_driver_sql(f'CREATE DATABASE {quote}{spec.database}{quote}{suffix}') + + +async def drop_isolated_database(spec: RunSpec, admin: Any) -> None: + """ + 删除隔离数据库;调用方必须先确认本轮创建成功。 + + :param spec: 当前验收的隔离运行信息,不包含连接密码 + :param admin: 用于创建和删除隔离数据库的管理连接池 + :return: None + """ + spec.validate() + quote = '`' if spec.engine == 'mysql' else '"' + async with admin.connect() as connection: + await connection.exec_driver_sql(f'DROP DATABASE {quote}{spec.database}{quote}') + + +async def clear_own_redis_keys(spec: RunSpec) -> int: + """ + 只清理当前运行前缀下的 Redis 键,并验证没有残留。 + + :param spec: 当前验收的隔离运行信息,不包含连接密码 + :return: 删除的 Redis 键数量 + """ + spec.validate() + client = redis_client() + removed = 0 + try: + async for key in client.scan_iter(match=f'{spec.redis_prefix}*', count=100): + check(key.startswith(spec.redis_prefix), 'Refusing Redis cleanup outside this run prefix') + removed += await client.delete(key) + check(not [key async for key in client.scan_iter(match=f'{spec.redis_prefix}*')], 'Redis keys remain') + finally: + await client.aclose() + return removed + + +async def prepare_schema(spec: RunSpec, engine: Any) -> None: + """ + 从初始化 SQL 提取所需宿主表和索引,在隔离空库中创建并验证发布表。 + + :param spec: 当前验收的隔离运行信息,不包含连接密码 + :param engine: 隔离数据库的异步连接池 + :return: None + """ + from sqlalchemy import select # noqa: PLC0415 + + from plugins.core.lifecycle.script import PluginLifecycleScriptHelper # noqa: PLC0415 + from plugins.core.management.entity.do.release_models import ( # noqa: PLC0415 + SysPluginArtifact, + SysPluginRelease, + SysPluginWorker, + ) + + required_tables = { + 'sys_menu', + 'sys_job', + 'sys_plugin', + 'sys_plugin_config', + 'sys_plugin_menu', + 'sys_plugin_migration', + 'sys_plugin_operation_log', + 'sys_plugin_artifact', + 'sys_plugin_release', + 'sys_plugin_worker', + } + initial_filename = 'ruoyi-fastapi.sql' if spec.engine == 'mysql' else 'ruoyi-fastapi-pg.sql' + initial_statements = PluginLifecycleScriptHelper.split_sql_statements( + (BACKEND_ROOT / 'sql' / initial_filename).read_text('utf-8') + ) + table_statements = {} + index_statements = [] + for statement in initial_statements: + match = re.match(r'CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(\w+)\s*\(', statement, re.IGNORECASE) + if match and match[1].lower() in required_tables: + table_name = match[1].lower() + check(table_name not in table_statements, f'Duplicate host table DDL: {table_name}') + table_statements[table_name] = statement + index_match = re.match( + r'CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?\w+\s+ON\s+(\w+)\s*\(', + statement, + re.IGNORECASE, + ) + if index_match and index_match[1].lower() in required_tables: + index_statements.append(statement) + check(table_statements.keys() == required_tables, f'Incomplete host table DDL in {initial_filename}') + + async with engine.begin() as connection: + # 只执行所需宿主表和索引的创建语句,不执行删除、种子数据或其他语句。 + for statement in table_statements.values(): + await connection.exec_driver_sql(statement) + for statement in index_statements: + await connection.exec_driver_sql(statement) + # 发布表必须由初始化 SQL 创建,不能由 ORM 补建。 + for model in (SysPluginArtifact, SysPluginRelease, SysPluginWorker): + check(not (await connection.execute(select(model))).all(), f'{model.__tablename__} is not empty') + emit( + 'schema_ddl', + engine=spec.engine, + script=initial_filename, + tables=len(table_statements), + indexes=len(index_statements), + ormTables=3, + ) + + +def make_artifacts(spec: RunSpec) -> tuple[Path, Path]: + """ + 生成两个包含真实迁移的签名测试制品和临时信任配置。 + + :param spec: 当前验收的隔离运行信息,不包含连接密码 + :return: 初始版本和升级版本的制品路径 + """ + from cryptography.hazmat.primitives import serialization # noqa: PLC0415 + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey # noqa: PLC0415 + + from plugins.core.artifacts import build_artifact # noqa: PLC0415 + + root = Path(spec.root) + (root / 'host' / 'plugins').mkdir(parents=True) + private_key = Ed25519PrivateKey.generate() + public = private_key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + (root / 'trusted.json').write_text( + json.dumps( + { + 'schemaVersion': 1, + 'keys': [ + {'keyId': 'integration', 'publicKey': base64.b64encode(public).decode(), 'pluginIds': [PLUGIN_ID]} + ], + } + ), + encoding='utf-8', + ) + archives = [] + for version in ('1.0.0', '2.0.0'): + source = root / f'source-{version}' / PLUGIN_ID + (source / 'migrations').mkdir(parents=True) + migrations = ' - migrations/001_probe.sql\n' + (source / 'migrations' / '001_probe.sql').write_text( + 'CREATE TABLE plugin_it_probe (id INTEGER NOT NULL PRIMARY KEY, value VARCHAR(32) NOT NULL);\n' + "INSERT INTO plugin_it_probe (id, value) VALUES (1, 'installed_v1');\n", + encoding='utf-8', + ) + if version == '2.0.0': + migrations += ' - migrations/002_upgrade.sql\n' + (source / 'migrations' / '002_upgrade.sql').write_text( + "INSERT INTO plugin_it_probe (id, value) VALUES (2, 'upgraded_v2');\n", encoding='utf-8' + ) + (source / 'plugin.yaml').write_text( + f'manifestVersion: 2\nid: {PLUGIN_ID}\nname: Real service acceptance\nversion: {version}\n' + f'backend:\n runtime: python\n integration: asgi\n module: plugins.{PLUGIN_ID}\n' + f' entrypoint: plugins.{PLUGIN_ID}:create_plugin\n migrations:\n{migrations}' + 'frontend:\n delivery:\n type: none\n', + encoding='utf-8', + ) + (source / '__init__.py').write_text( + 'import os\nfrom contextlib import asynccontextmanager\nfrom fastapi import FastAPI\n' + 'from plugins.core.sdk import PluginDefinition\n' + f'LOADED_VERSION = {version!r}\n' + 'def create_plugin(host):\n assert not host.startup_write_enabled\n' + ' return PluginDefinition(app_factory=create_app)\n' + 'def create_app(host):\n @asynccontextmanager\n async def lifespan(app):\n' + ' if os.environ.get("RUOYI_PLUGIN_IT_FAIL_WORKER") == "1":\n' + ' raise RuntimeError("intentional integration lifespan failure")\n' + ' yield\n return FastAPI(lifespan=lifespan)\n', + encoding='utf-8', + ) + archive = root / f'{version}.rpk' + build_artifact(source, archive, private_key, 'integration') + archives.append(archive) + return archives[0], archives[1] + + +async def test_real_lock(spec: RunSpec) -> None: + """ + 验证真实 Redis 锁的互斥、自动续租和按所有者释放行为。 + + :param spec: 当前验收的隔离运行信息,不包含连接密码 + :return: None + """ + from plugins.core.runtime.service.lifecycle_lock import RedisPluginLifecycleLock # noqa: PLC0415 + + client = redis_client() + key = f'{spec.redis_prefix}lifecycle:global' + lock = RedisPluginLifecycleLock(expire_seconds=3) + try: + async with lock.lock(PLUGIN_ID, 'renewal_check') as held: + check(held.acquired, 'Initial real Redis lock acquisition failed') + await asyncio.sleep(4.2) + check(await client.ttl(key) > 0, 'Real lock renewal did not keep the lease alive') + async with RedisPluginLifecycleLock().lock(PLUGIN_ID, 'contender') as second: + check(not second.acquired, 'NX lock allowed a competing holder') + check(await client.get(key) is None, 'Owned lock was not released') + async with lock.lock(PLUGIN_ID, 'compare_delete_check') as held: + check(held.acquired, 'Compare-delete setup could not acquire lock') + await client.set(key, 'replacement-owner', ex=10) + check(await client.get(key) == 'replacement-owner', 'Lua release deleted a replacement owner') + await client.delete(key) + finally: + await client.aclose() + emit('redis_lock', nx=True, renewal=True, compareDelete=True) + + +async def maintenance_process(spec: RunSpec, action: str, digest: str | None) -> dict[str, Any]: + """ + 在独立进程中准备、选择或回滚目标制品。 + + :param spec: 当前验收的隔离运行信息,不包含连接密码 + :param action: 当前命令需要执行的操作 + :param digest: 已导入制品的 SHA256 摘要 + :return: 维护操作结果 + """ + from plugins.core.deployment.service import PluginDeploymentService # noqa: PLC0415 + from plugins.core.management.dao.release_dao import PluginReleaseDao # noqa: PLC0415 + + engine = database_engine(spec) + config, sessions = wire_runtime(spec, engine) + service = PluginDeploymentService(config, session_factory=sessions) + try: + if action == 'prepare': + return await service.prepare(PLUGIN_ID, str(digest), maintenance=True, actor='real-service-it') + async with sessions() as db: + release = await PluginReleaseDao.get_release(db, PLUGIN_ID) + generation = release.generation + if action == 'select': + return await service.select( + PLUGIN_ID, + str(digest), + expected_generation=generation, + expected_workers=2, + maintenance=True, + actor='real-service-it', + ) + if action == 'rollback': + return await service.rollback( + PLUGIN_ID, + expected_generation=generation, + schema_compatible=True, + maintenance=True, + actor='real-service-it', + ) + raise ValueError('Unknown isolated maintenance action') + finally: + await engine.dispose() + + +async def worker_process(spec: RunSpec, queue: Any, stop: Any, launch: str, fail: bool) -> None: + """ + 启动真实插件运行流程,上报实际加载结果并等待独立停止事件。 + + :param spec: 当前验收的隔离运行信息,不包含连接密码 + :param queue: 仅传递结果和脱敏错误的进程队列 + :param stop: 当前 worker 独享的停止事件 + :param launch: 本轮 worker 启动标识 + :param fail: 是否让当前 worker 在插件启动时主动失败 + :return: None + """ + from fastapi import FastAPI # noqa: PLC0415 + + from plugins.core.deployment.worker import PluginReleaseWorker # noqa: PLC0415 + from plugins.core.management.service.startup_gateway import ( # noqa: PLC0415 + PluginManagementRouteStateGateway, + PluginManagementStartupGateway, + ) + from plugins.core.runtime.application import PluginApplicationRuntime # noqa: PLC0415 + from plugins.core.runtime.bootstrap import PluginRuntimeBuilder # noqa: PLC0415 + from plugins.core.runtime.service.lifecycle_lock import RedisPluginLifecycleLock # noqa: PLC0415 + from plugins.core.runtime.startup import PluginRuntimeStartupManager # noqa: PLC0415 + + os.environ['RUOYI_PLUGIN_IT_FAIL_WORKER'] = '1' if fail else '0' + engine = database_engine(spec) + config, sessions = wire_runtime(spec, engine) + client = redis_client() + app = FastAPI() + app.state.redis = client + builder = PluginRuntimeBuilder(config.backend_root) + manager = PluginRuntimeStartupManager( + builder, + management_gateway=PluginManagementStartupGateway(), + route_state_gateway=PluginManagementRouteStateGateway(), + default_enabled_builtin_plugin_ids=set(), + ) + worker = PluginReleaseWorker(config, session_factory=sessions) + runtime = PluginApplicationRuntime( + manager, + ready_key=f'{spec.redis_prefix}ready', + startup_generation=launch, + lifecycle_lock=RedisPluginLifecycleLock(expire_seconds=6), + release_worker=worker, + ready_wait_timeout_seconds=60, + ready_wait_interval_seconds=0.1, + ) + runtime.bind_app(app) + + async def create_tables() -> None: + # 表结构已经准备完成,此处只通过真实 Redis 统计启动写入回调的执行次数。 + """ + 通过 Redis 计数验证只有启动写入进程执行此回调。 + + :return: None + """ + await client.incr(f'{spec.redis_prefix}writer:{launch}') + + started = False + try: + await runtime.startup(app, create_tables=create_tables) + started = True + target = worker.targets[0] + module = sys.modules[f'plugins.{PLUGIN_ID}'] + loaded = app.state.plugin_explicit_runtime.loaded[PLUGIN_ID] + await app.state.plugin_diagnostics_reporter.publish() + queue.put( + { + 'ok': True, + 'workerId': worker.worker_id, + 'version': module.LOADED_VERSION, + 'modulePath': module.__file__, + 'active': loaded.active, + 'digest': target.digest, + 'artifactGeneration': target.generation, + 'writer': bool(app.state.plugin_startup_write_enabled), + 'failed': fail, + 'generation': app.state.plugin_startup_generation, + } + ) + await asyncio.to_thread(stop.wait) + finally: + if started: + await runtime.shutdown(app) + await client.aclose() + await engine.dispose() + + +def child_entry(spec: RunSpec, queue: Any, action: str, payload: Any, stop: Any = None) -> None: + """ + 执行隔离子进程入口,参数和结果队列均不传递连接密码。 + + :param spec: 当前验收的隔离运行信息,不包含连接密码 + :param queue: 仅传递结果和脱敏错误的进程队列 + :param action: 当前命令需要执行的操作 + :param payload: 当前子进程操作需要的参数 + :param stop: 当前 worker 独享的停止事件 + :return: None + """ + try: + configure_process(spec) + if action == 'worker': + asyncio.run(worker_process(spec, queue, stop, payload['launch'], payload['fail'])) + else: + result = asyncio.run(maintenance_process(spec, action, payload)) + queue.put({'ok': True, 'result': result}) + except BaseException as exc: + queue.put({'ok': False, 'action': action, 'error': safe_error(exc)}) + raise SystemExit(1) from None + + +async def run_maintenance(spec: RunSpec, action: str, digest: str | None = None) -> dict[str, Any]: + """ + 启动独立维护进程并回收进程与队列资源。 + + :param spec: 当前验收的隔离运行信息,不包含连接密码 + :param action: 当前命令需要执行的操作 + :param digest: 已导入制品的 SHA256 摘要 + :return: 维护进程返回的操作结果 + """ + context = multiprocessing.get_context('spawn') + queue = context.Queue() + process = context.Process(target=child_entry, args=(spec, queue, action, digest)) + process.start() + try: + result = await asyncio.to_thread(queue.get, True, 90) + check(result['ok'], str(result)) + await asyncio.to_thread(process.join, 20) + check(process.exitcode == 0, f'Maintenance process did not exit cleanly: {process.exitcode}') + return result['result'] + finally: + if process.is_alive(): + process.terminate() + await asyncio.to_thread(process.join, 10) + queue.close() + queue.join_thread() + + +class Workers: + """ + 负责全部验收 worker 的启动与回收,包括启动未完成的进程组。 + """ + + def __init__( + self, spec: RunSpec, *, one_fails: bool = False, retired_worker_ids: frozenset[str] = frozenset() + ) -> None: + """ + 创建两个 worker 及各自独立的停止事件。 + + :param spec: 当前验收的隔离运行信息,不包含连接密码 + :param one_fails: 是否让第二个 worker 主动触发启动失败 + :param retired_worker_ids: 已确认退出但其诊断快照仍可能保留的本轮 worker 身份 + :return: None + """ + self.spec = spec + self.retired_worker_ids = retired_worker_ids + self.launch = uuid4().hex + self.context = multiprocessing.get_context('spawn') + self.queue = self.context.Queue() + # 强制终止正在 Event.wait() 的进程可能残留 Condition 等待状态。 + # 每个 worker 使用独立停止事件,避免影响其他进程的正常退出。 + self.stops = [self.context.Event() for _ in range(EXPECTED_WORKER_COUNT)] + self.processes = [ + self.context.Process( + target=child_entry, + args=( + spec, + self.queue, + 'worker', + {'launch': self.launch, 'fail': one_fails and index == 1}, + self.stops[index], + ), + ) + for index in range(EXPECTED_WORKER_COUNT) + ] + self.reports: list[dict[str, Any]] = [] + self.killed: set[int] = set() + + async def __aenter__(self) -> Workers: + """ + 启动全部 worker,并验证身份、发布代际和单写入进程约束。 + + :return: 已启动并收集上报结果的 worker 管理器 + """ + from plugins.core.runtime.diagnostics_store import ( # noqa: PLC0415 + diagnostics_namespace, + read_runtime_diagnostics, + ) + + try: + for process in self.processes: + process.start() + self.reports = [await asyncio.to_thread(self.queue.get, True, 90) for _ in self.processes] + check(all(item.get('ok') for item in self.reports), str(self.reports)) + check(len({item['workerId'] for item in self.reports}) == EXPECTED_WORKER_COUNT, 'Worker IDs collided') + check(len({item['generation'] for item in self.reports}) == 1, 'Workers used different startup generations') + check(sum(item['writer'] for item in self.reports) == 1, 'Expected one startup writer and one reader') + client = redis_client() + try: + check( + await client.get(f'{self.spec.redis_prefix}writer:{self.launch}') == '1', + 'Writer callback ran more than once', + ) + diagnostics = await read_runtime_diagnostics( + client, diagnostics_namespace(f'{self.spec.redis_prefix}ready'), PLUGIN_ID + ) + check(diagnostics['scope'] == 'reporting_workers', 'Runtime diagnostics are unavailable') + check(diagnostics['currentWorkerId'] is None, 'Maintenance reader fabricated a local worker') + observed = {item['workerId']: item for item in diagnostics['workers']} + check( + set(observed) - self.retired_worker_ids == {item['workerId'] for item in self.reports}, + 'Runtime diagnostics do not match the live child processes', + ) + for report in self.reports: + actual = observed[report['workerId']]['plugins'] + check(len(actual) == 1, 'Runtime diagnostics omitted the loaded plugin') + check( + actual[0]['version'] == report['version'] + and actual[0]['digest'] == report['digest'] + and actual[0]['generation'] == report['artifactGeneration'] + and actual[0]['active'] == report['active'] + and actual[0]['ready'] == report['active'], + 'Runtime diagnostics disagree with the actual loaded plugin', + ) + emit( + 'runtime_diagnostics', + launch=self.launch, + observedWorkers=len(observed), + currentWorkers=len(self.reports), + retainedWorkerSnapshots=len(set(observed) & self.retired_worker_ids), + actualStateVerified=True, + ) + finally: + await client.aclose() + return self + except BaseException: + await self.close() + raise + + async def kill_failed_worker(self) -> None: + # 预设失败的是第二个进程,不能根据队列上报顺序判断进程身份。 + """ + 终止预设失败的第二个 worker,供心跳过期验收使用。 + + :return: None + """ + process = self.processes[1] + self.killed.add(1) + process.terminate() + await asyncio.to_thread(process.join, 10) + check(not process.is_alive(), 'Failed worker did not terminate') + + async def close(self) -> None: + """ + 停止仍存活的 worker 并关闭队列,异常退出时给出验收错误。 + + :return: None + """ + for index, process in enumerate(self.processes): + if index not in self.killed and process.pid is not None and process.is_alive(): + self.stops[index].set() + bad_exits = [] + for index, process in enumerate(self.processes): + if process.pid is None: + continue + await asyncio.to_thread(process.join, 20) + if process.is_alive(): + process.terminate() + await asyncio.to_thread(process.join, 10) + bad_exits.append(index) + elif index not in self.killed and process.exitcode != 0: + bad_exits.append(index) + self.queue.close() + self.queue.join_thread() + check(not bad_exits, f'Workers failed to stop cleanly: {bad_exits}') + + async def __aexit__(self, *args: Any) -> None: + """ + 离开上下文时回收全部 worker 和队列。 + + :param args: 异步上下文管理协议传入的异常信息 + :return: None + """ + await self.close() + + +async def release_state(service: Any) -> dict[str, Any]: + """ + 读取测试插件唯一的聚合发布状态。 + + :param service: 连接到隔离数据库的发布服务 + :return: 测试插件的发布状态 + """ + result = await service.status(PLUGIN_ID) + check(len(result['releases']) == 1, 'Expected exactly one integration release') + return result['releases'][0] + + +async def verify_release_wait( + service: Any, + selected: dict[str, Any], + scenario: str, + reason: str, + *, + timeout_seconds: float = 0, +) -> dict[str, Any]: + """ + 使用维护命令返回的固定目标验证等待结果,禁止从最新状态重新取得目标。 + + :param service: 连接到隔离数据库的发布服务 + :param selected: 选择或回滚操作返回的制品摘要和新发布代际 + :param scenario: 本次验收场景的输出标识 + :param reason: 预期的机器可读结果原因 + :param timeout_seconds: 等待时限,零表示只检查一次 + :return: 本次等待返回的完整结果 + """ + target = { + 'pluginId': PLUGIN_ID, + 'digest': selected['digest'], + 'generation': selected['generation'], + 'expectedWorkers': EXPECTED_WORKER_COUNT, + } + result = await service.wait( + PLUGIN_ID, + target['digest'], + expected_generation=target['generation'], + expected_workers=EXPECTED_WORKER_COUNT, + timeout_seconds=timeout_seconds, + poll_interval_seconds=0.1, + ) + check(result['operation'] == 'release_wait', 'Wait returned a different operation') + check(result['target'] == target, f'Wait changed the pinned target in {scenario}') + check(result['reason'] == reason, f'Unexpected wait reason in {scenario}: {result}') + check(result['ok'] is (reason == 'converged'), f'Wait success flag disagrees with reason in {scenario}') + check(result['attempts'] >= 1, f'Wait did not observe the real database in {scenario}') + if timeout_seconds == 0: + check(result['attempts'] == 1, f'Zero-timeout wait polled more than once in {scenario}') + emit('release_wait', scenario=scenario, reason=reason, attempts=result['attempts'], targetPinned=True) + return result + + +def cli_wait_environment(spec: RunSpec) -> dict[str, str]: + """ + 为真实 CLI 子进程构造隔离连接配置,凭据仅保存在子进程环境中。 + + :param spec: 当前验收的隔离运行信息,不包含连接密码 + :return: 指向当前隔离数据库和制品目录的子进程环境 + """ + require_opt_in(spec.engine) + spec.validate() + mysql = spec.engine == 'mysql' + prefix = 'RUOYI_IT_MYSQL' if mysql else 'RUOYI_IT_PG' + child_env = os.environ.copy() + child_env.update( + APP_ENV=f'plugin_it_{spec.run_id}', + DB_DEFAULT_SOURCE='primary', + DB_SOURCES=json.dumps( + { + 'primary': { + 'db_type': spec.engine, + 'db_host': os.environ.get(f'{prefix}_HOST', '127.0.0.1'), + 'db_port': int(os.environ.get(f'{prefix}_PORT', '3306' if mysql else '5432')), + 'db_username': os.environ.get(f'{prefix}_USER', 'root' if mysql else 'postgres'), + 'db_password': os.environ[f'{prefix}_PASSWORD'], + 'db_database': spec.database, + 'db_echo': False, + 'db_connect_timeout': 10, + } + } + ), + PLUGIN_ARTIFACT_ENABLED='true', + PLUGIN_ARTIFACT_STORE=str(Path(spec.root) / 'store'), + PLUGIN_ARTIFACT_TRUST_FILE=str(Path(spec.root) / 'trusted.json'), + PLUGIN_ARTIFACT_HEARTBEAT_SECONDS='1', + PLUGIN_ARTIFACT_WORKER_TTL_SECONDS=str(TTL_SECONDS), + LOG_FILE_ENABLED='false', + LOG_FILE_BASE_DIR=str(Path(spec.root) / 'logs'), + PYTHONIOENCODING='utf-8', + PYTHONDONTWRITEBYTECODE='1', + ) + return child_env + + +async def verify_release_wait_cli(spec: RunSpec, selected: dict[str, Any], scenario: str, reason: str) -> None: + """ + 冷启动完整 CLI,使用真实隔离数据库验证单份 JSON 与操作系统退出码。 + + :param spec: 当前验收的隔离运行信息,不包含连接密码 + :param selected: 选择操作返回的固定制品摘要和发布代际 + :param scenario: 本次验收场景的输出标识 + :param reason: 预期的机器可读结果原因 + :return: None + """ + child_env = cli_wait_environment(spec) + command = [ + sys.executable, + '-m', + 'cli.main', + 'plugin', + 'release', + 'wait', + PLUGIN_ID, + selected['digest'], + '--generation', + selected['generation'], + '--expected-workers', + str(EXPECTED_WORKER_COUNT), + '--timeout', + '0', + '--interval', + '0.1', + '--env', + child_env['APP_ENV'], + '--output', + 'json', + ] + process = await asyncio.create_subprocess_exec( + *command, cwd=str(BACKEND_ROOT), env=child_env, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + try: + stdout, _stderr = await asyncio.wait_for(process.communicate(), timeout=30) + finally: + if process.returncode is None: + process.kill() + await process.wait() + try: + result = json.loads(stdout) + except (UnicodeError, ValueError): + raise AssertionError('Cold release wait CLI did not return a single JSON document') from None + check(isinstance(result, dict), 'Cold release wait CLI did not return a JSON object') + expected_exit = 0 if reason == 'converged' else 50 + check( + process.returncode == expected_exit, f'Cold release wait CLI exited {process.returncode}, not {expected_exit}' + ) + check(result.get('operation') == 'release_wait', 'Cold release wait CLI returned another operation') + check(result.get('reason') == reason, f'Cold release wait CLI returned an unexpected reason in {scenario}') + check(result.get('ok') is (reason == 'converged'), 'Cold release wait CLI returned an inconsistent success flag') + check( + result.get('target') + == { + 'pluginId': PLUGIN_ID, + 'digest': selected['digest'], + 'generation': selected['generation'], + 'expectedWorkers': EXPECTED_WORKER_COUNT, + }, + 'Cold release wait CLI changed the pinned target', + ) + check(result.get('attempts') == 1, 'Cold release wait CLI did not perform exactly one database observation') + emit( + 'release_wait_cli', + scenario=scenario, + reason=reason, + exitCode=expected_exit, + jsonDocument=True, + targetPinned=True, + ) + + +async def expect_maintenance_blocked(service: Any, digest: str) -> None: + """ + 验证存在存活 worker 时维护准备会被拒绝。 + + :param service: 连接到隔离数据库的发布服务 + :param digest: 已导入制品的 SHA256 摘要 + :return: None + """ + try: + await service.prepare(PLUGIN_ID, digest, maintenance=True) + except ValueError as exc: + check('存活' in str(exc), f'Maintenance failed for the wrong reason: {safe_error(exc)}') + else: + raise AssertionError('Maintenance preparation ignored live host workers') + + +async def verify_migrations(sessions: Any, installed: str, rows: int) -> None: + """ + 核对数据准备版本、迁移探针记录和维护审计。 + + :param sessions: 隔离数据库的异步会话工厂 + :param installed: 预期数据库安装版本 + :param rows: 预期迁移探针数据和迁移历史的记录数 + :return: None + """ + from sqlalchemy import select, text # noqa: PLC0415 + + from plugins.core.management.dao.dao import PluginDao # noqa: PLC0415 + from plugins.core.management.entity.do.models import SysPluginMigration, SysPluginOperationLog # noqa: PLC0415 + + async with sessions() as db: + plugin = await PluginDao.get_plugin_by_id(db, PLUGIN_ID) + check(plugin.installed_version == installed, f'Unexpected installedVersion: {plugin.installed_version}') + check(await db.scalar(text('SELECT COUNT(*) FROM plugin_it_probe')) == rows, 'Migration probe rows changed') + migrations = (await db.scalars(select(SysPluginMigration))).all() + check( + len(migrations) == rows and all(item.status == 'success' for item in migrations), + 'Migration evidence missing', + ) + logs = (await db.scalars(select(SysPluginOperationLog))).all() + check(any(item.operation == 'install' for item in logs), 'Real maintenance install audit missing') + if installed == '2.0.0': + check(any(item.operation == 'upgrade' for item in logs), 'Real maintenance upgrade audit missing') + + +async def exercise_release_recovery(spec: RunSpec, service: Any, target: dict[str, Any]) -> None: + """ + 验证失败进程、过期缺员与替换双进程对同一固定目标的等待结果。 + + :param spec: 当前验收的隔离运行信息,不包含连接密码 + :param service: 连接到隔离数据库的发布服务 + :param target: 回滚后固定的制品摘要和新发布代际 + :return: None + """ + async with Workers(spec, one_fails=True) as workers: + state = await release_state(service) + check( + state['status'] == 'partial' and state['healthyWorkers'] == 1 and state['failedWorkers'] == 1, + f'Failure reported as healthy: {state}', + ) + await verify_release_wait(service, target, 'partial_with_failure', 'worker_failed', timeout_seconds=3) + await workers.kill_failed_worker() + await asyncio.sleep(TTL_SECONDS + 1) + state = await release_state(service) + check( + state['status'] == 'partial' and state['healthyWorkers'] == 1 and state['staleWorkers'] >= 1, + f'Expired process was counted live: {state}', + ) + check(state['liveWorkers'] == 1, 'Dead worker still contributes to live count') + await verify_release_wait(service, target, 'stale_missing_worker', 'not_converged') + await verify_release_wait(service, target, 'stale_missing_worker_timeout', 'timeout', timeout_seconds=0.3) + emit('partial_and_stale', healthyWorkers=1, liveWorkers=1, staleWorkers=state['staleWorkers']) + # 发布心跳与运行诊断有独立 TTL;已终止进程的诊断快照可能比发布存活报告保留更久。 + retired_worker_ids = frozenset(item['workerId'] for item in workers.reports if item['failed']) + async with Workers(spec, retired_worker_ids=retired_worker_ids): + result = await verify_release_wait( + service, target, 'replacement_with_old_stale_worker', 'converged', timeout_seconds=3 + ) + state = await release_state(service) + check( + state['status'] == 'active' + and state['healthyWorkers'] == EXPECTED_WORKER_COUNT + and state['liveWorkers'] == EXPECTED_WORKER_COUNT + and state['staleWorkers'] >= 1, + f'Replacement workers did not converge with historical stale reports: {state}', + ) + check(result['lastObserved']['staleWorkers'] >= 1, 'Wait lost the historical stale worker evidence') + emit('replacement_active', healthyWorkers=EXPECTED_WORKER_COUNT, staleWorkers=state['staleWorkers']) + + +async def exercise_release(spec: RunSpec, engine: Any) -> None: + """ + 依次验收发布、升级、代码回滚以及失败和过期 worker 状态。 + + :param spec: 当前验收的隔离运行信息,不包含连接密码 + :param engine: 隔离数据库的异步连接池 + :return: None + """ + from plugins.core.deployment.service import PluginDeploymentService # noqa: PLC0415 + + config, sessions = wire_runtime(spec, engine) + service = PluginDeploymentService(config, session_factory=sessions) + archive1, archive2 = make_artifacts(spec) + digest1 = (await service.catalog.import_artifact(archive1))['digest'] + digest2 = (await service.catalog.import_artifact(archive2))['digest'] + await test_real_lock(spec) + await run_maintenance(spec, 'prepare', digest1) + await verify_migrations(sessions, '1.0.0', 1) + initial_target = await run_maintenance(spec, 'select', digest1) + await verify_release_wait(service, initial_target, 'before_workers', 'not_converged') + await verify_release_wait(service, initial_target, 'before_workers_timeout', 'timeout', timeout_seconds=0.3) + await verify_release_wait_cli(spec, initial_target, 'before_workers', 'not_converged') + async with Workers(spec) as workers: + await verify_release_wait(service, initial_target, 'initial_active', 'converged', timeout_seconds=3) + await verify_release_wait_cli(spec, initial_target, 'initial_active', 'converged') + state = await release_state(service) + check( + state['status'] == 'active' and state['healthyWorkers'] == EXPECTED_WORKER_COUNT, + f'Initial release not active: {state}', + ) + check( + all( + item['version'] == '1.0.0' and item['digest'] == digest1 and item['active'] for item in workers.reports + ), + 'Wrong initial runtime code', + ) + check( + all(Path(item['modulePath']).is_relative_to(config.store_root) for item in workers.reports), + 'Worker loaded outside artifact store', + ) + await expect_maintenance_blocked(service, digest2) + emit('initial_active', workers=2, writer=1, reader=1, maintenanceBlocked=True) + await run_maintenance(spec, 'prepare', digest2) + await verify_migrations(sessions, '2.0.0', 2) + upgrade_target = await run_maintenance(spec, 'select', digest2) + await verify_release_wait(service, initial_target, 'upgrade_rejects_previous_target', 'target_changed') + async with Workers(spec) as workers: + await verify_release_wait(service, upgrade_target, 'upgrade_active', 'converged', timeout_seconds=3) + state = await release_state(service) + check(state['status'] == 'active', f'Upgrade did not converge: {state}') + check( + all(item['version'] == '2.0.0' and item['digest'] == digest2 for item in workers.reports), + 'Upgrade reused old code', + ) + emit('upgrade_active', codeVersion='2.0.0', installedVersion=state['installedVersion']) + rollback_target = await run_maintenance(spec, 'rollback') + await verify_migrations(sessions, '2.0.0', 2) + async with Workers(spec) as workers: + await verify_release_wait(service, rollback_target, 'rollback_active', 'converged', timeout_seconds=3) + await verify_release_wait(service, upgrade_target, 'rollback_rejects_upgrade_target', 'target_changed') + await verify_release_wait(service, initial_target, 'rollback_rejects_old_generation', 'target_changed') + state = await release_state(service) + check( + state['status'] == 'active' and state['installedVersion'] == '2.0.0', f'Rollback lost schema state: {state}' + ) + check( + all(item['version'] == '1.0.0' and item['digest'] == digest1 for item in workers.reports), + 'Rollback did not load prior artifact', + ) + emit('rollback_active', codeVersion='1.0.0', installedVersion='2.0.0', migrationRows=2) + await exercise_release_recovery(spec, service, rollback_target) + await service.catalog.get(digest1) + await service.catalog.get(digest2) + check(not list(config.store_root.rglob('*.pyc')), 'Artifact runtime produced bytecode inside immutable store') + check(f'plugins.{PLUGIN_ID}' not in sys.modules, 'Supervisor imported plugin runtime code') + + +async def run(spec: RunSpec) -> None: + """ + 创建隔离资源并执行完整验收,结束时回收本轮资源。 + + :param spec: 当前验收的隔离运行信息,不包含连接密码 + :return: None + """ + task_source = BACKEND_ROOT / 'plugins' / 'examples' / 'python' / 'task_demo' + if not (task_source / 'web' / 'dist' / 'index.html').is_file(): + raise ValueError('请先按 task_demo/README.md 安装前端依赖并构建 web/dist,再运行真实发布验收') + configure_process(spec) + admin = database_engine(spec, admin=True) + engine = None + created = False + cleanup_errors = [] + try: + await create_isolated_database(spec, admin) + created = True + emit('isolated_database_created', engine=spec.engine, database=spec.database, redisPrefix=spec.redis_prefix) + engine = database_engine(spec) + await prepare_schema(spec, engine) + await exercise_release(spec, engine) + from plugins.core.deployment.service import PluginDeploymentService # noqa: PLC0415 + from scripts.plugin_task_integration import exercise_task_delivery # noqa: PLC0415 + + config, sessions = wire_runtime(spec, engine) + task_result = await exercise_task_delivery( + PluginDeploymentService(config, session_factory=sessions), + sessions, + task_source, + Path(spec.root) / 'task-delivery', + ) + emit('task_delivery', **task_result) + finally: + if engine is not None: + try: + await engine.dispose() + except Exception as exc: + cleanup_errors.append(safe_error(exc)) + try: + removed = await clear_own_redis_keys(spec) + emit('redis_cleanup', removedKeys=removed) + except Exception as exc: + cleanup_errors.append(safe_error(exc)) + if created: + try: + await drop_isolated_database(spec, admin) + emit('database_cleanup', database=spec.database, dropped=True) + except Exception as exc: + cleanup_errors.append(safe_error(exc)) + try: + await admin.dispose() + except Exception as exc: + cleanup_errors.append(safe_error(exc)) + if cleanup_errors: + raise RuntimeError(f'Isolated resources require cleanup: {cleanup_errors}') + + +def main(argv: list[str] | None = None) -> int: + """ + 校验显式授权后运行验收,并输出脱敏的退出结果。 + + :param argv: 命令行参数;未传入时读取当前进程参数 + :return: 进程退出码 + """ + parser = argparse.ArgumentParser( + description=( + '需显式授权的真实 Redis 与 MySQL/PostgreSQL 插件发布验收脚本。\n\n' + '运行前须按 plugins/examples/python/task_demo/README.md 构建任务示例的 web/dist。\n' + '仅用于研发验收;通过 RUOYI_PLUGIN_IT_ALLOW=1 显式开启。导入本模块不会加载宿主配置、\n' + '建立连接或读取应用的 .env 文件。' + ) + ) + parser.add_argument('--engine', required=True, choices=('mysql', 'postgresql')) + args = parser.parse_args(argv) + try: + require_opt_in(args.engine) + started = time.monotonic() + with tempfile.TemporaryDirectory(prefix='ruoyi_plugin_it_') as directory: + spec = RunSpec(args.engine, uuid4().hex, str(Path(directory).resolve())) + asyncio.run(run(spec)) + emit('passed', engine=args.engine, elapsedSeconds=round(time.monotonic() - started, 2)) + return 0 + except BaseException as exc: + emit('failed', error=safe_error(exc)) + return 1 + + +if __name__ == '__main__': + multiprocessing.freeze_support() + raise SystemExit(main()) diff --git a/ruoyi-fastapi-backend/scripts/plugin_task_integration.py b/ruoyi-fastapi-backend/scripts/plugin_task_integration.py new file mode 100644 index 0000000..c5a001e --- /dev/null +++ b/ruoyi-fastapi-backend/scripts/plugin_task_integration.py @@ -0,0 +1,215 @@ +import asyncio +import base64 +import json +import shutil +import subprocess +import sys +from datetime import datetime, timezone +from http import HTTPStatus +from pathlib import Path +from typing import Any + +TASK_PLUGIN_ID = 'task_demo' +LEGACY_TASK_ID = '0123456789abcdef0123456789abcdef' +LEGACY_CREATED_AT = datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc) +EXPECTED_MIGRATIONS = 2 + + +def build_task_artifacts(source: Path, root: Path, trust_file: Path) -> tuple[Path, Path]: + """ + 运行示例交付脚本,签名当前版本及仅用于迁移验收的旧结构夹具。 + + :param source: 包含已构建 web/dist 的任务示例源码目录 + :param root: 本次验收专用且尚不存在的输出目录 + :param trust_file: 本次验收使用的临时发布者公钥文件 + :return: 旧结构夹具和当前业务插件的签名制品路径 + """ + import yaml # noqa: PLC0415 + from cryptography.hazmat.primitives import serialization # noqa: PLC0415 + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey # noqa: PLC0415 + + from plugins.core.artifacts import build_artifact # noqa: PLC0415 + + if root.exists(): + raise ValueError('Task integration output must be a new directory') + result = subprocess.run( + [sys.executable, str(source / 'build_release.py'), '--output', str(root / 'package')], + capture_output=True, + text=True, + encoding='utf-8', + errors='replace', + timeout=60, + check=False, + ) + if result.returncode: + raise RuntimeError(f'Task package build failed: {result.stderr[-4000:]}') + current = root / 'package' / TASK_PLUGIN_ID + manifest = yaml.safe_load((current / 'plugin.yaml').read_text(encoding='utf-8')) + baseline = root / 'baseline' / TASK_PLUGIN_ID + baseline.mkdir(parents=True) + manifest['version'] = '1.0.0' + manifest['frontend'] = {'delivery': {'type': 'none'}} + manifest['backend'].pop('health', None) + manifest['backend']['migrations'] = [ + item for item in manifest['backend']['migrations'] if item.endswith('/001_init.sql') + ] + for name in manifest['backend']['migrations']: + destination = baseline / name + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(current / name, destination) + (baseline / 'plugin.yaml').write_text(yaml.safe_dump(manifest, allow_unicode=True), encoding='utf-8') + # 此夹具仅提供 1.0 数据结构;不启动旧业务 API,也不冒充历史发布包。 + (baseline / '__init__.py').write_text( + 'from fastapi import FastAPI\nfrom plugins.core.sdk import PluginDefinition\n' + 'def create_plugin(host):\n return PluginDefinition(app_factory=lambda context: FastAPI())\n', + encoding='utf-8', + ) + key = Ed25519PrivateKey.generate() + public = key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + trust = json.loads(trust_file.read_text(encoding='utf-8')) + trust['keys'].append( + {'keyId': 'task-integration', 'publicKey': base64.b64encode(public).decode(), 'pluginIds': [TASK_PLUGIN_ID]} + ) + trust_file.write_text(json.dumps(trust), encoding='utf-8') + baseline_archive, current_archive = root / 'baseline.rpk', root / 'current.rpk' + build_artifact(baseline, baseline_archive, key, 'task-integration') + build_artifact(current, current_archive, key, 'task-integration') + return baseline_archive, current_archive + + +async def exercise_task_delivery(service: Any, sessions: Any, source: Path, root: Path) -> dict[str, Any]: + """ + 从受控交付目录验证签名、维护迁移、目标选择、数据保留和真实业务事务。 + + :param service: 已注入隔离数据库及生命周期锁的发布服务 + :param sessions: 隔离数据库的异步会话工厂 + :param source: 包含已构建页面的 task_demo 源码目录 + :param root: 当前任务验收专用的新输出目录 + :return: 可用于 CI 输出的验收结果摘要 + """ + from sqlalchemy import select, text # noqa: PLC0415 + + from plugins.core.management.entity.do.models import SysPluginMigration # noqa: PLC0415 + + baseline, current = await asyncio.to_thread(build_task_artifacts, source, root, service.config.trust_file) + first = await service.catalog.import_artifact(baseline) + first_prepared = await service.prepare(TASK_PLUGIN_ID, first['digest'], maintenance=True, actor='task-it') + await service.select( + TASK_PLUGIN_ID, first['digest'], expected_generation=first_prepared['generation'], maintenance=True + ) + async with sessions() as db, db.begin(): + await db.execute( + text( + 'INSERT INTO ruoyi_plugin_task_demo (id, title, description, status, created_at, updated_at) ' + 'VALUES (:id, :title, :description, :status, :created, :updated)' + ), + { + 'id': LEGACY_TASK_ID, + 'title': '升级前的任务', + 'description': '保留已有业务数据', + 'status': 'todo', + 'created': LEGACY_CREATED_AT, + 'updated': LEGACY_CREATED_AT, + }, + ) + latest = await service.catalog.import_artifact(current) + prepared = await service.prepare(TASK_PLUGIN_ID, latest['digest'], maintenance=True, actor='task-it') + await service.prepare(TASK_PLUGIN_ID, latest['digest'], maintenance=True, actor='task-it') + selected = await service.select( + TASK_PLUGIN_ID, latest['digest'], expected_generation=prepared['generation'], maintenance=True + ) + async with sessions() as db: + history = ( + await db.scalars(select(SysPluginMigration).where(SysPluginMigration.plugin_id == TASK_PLUGIN_ID)) + ).all() + if len(history) != EXPECTED_MIGRATIONS or any( + item.status != 'success' or item.attempt_count != 1 for item in history + ): + raise AssertionError('Task migrations must succeed exactly once per database dialect') + artifact = await service.catalog.get(latest['digest']) + plugin = service.catalog.discovered(artifact) + await exercise_task_api(plugin, sessions) + await service.catalog.get(latest['digest']) + if list(plugin.backend_path.rglob('*.pyc')): + raise AssertionError('Task runtime wrote bytecode into the immutable artifact') + return { + 'pluginId': TASK_PLUGIN_ID, + 'version': artifact.version, + 'digest': latest['digest'], + 'generation': selected['generation'], + 'migrations': len(history), + 'retainedRows': 1, + 'crud': True, + 'readOnlyDenied': True, + } + + +async def exercise_task_api(plugin: Any, sessions: Any) -> None: + """ + 从签名交付目录加载当前业务 API,注入权限并验证持久化 CRUD。 + + :param plugin: 从签名制品目录发现的任务插件 + :param sessions: 隔离数据库会话工厂 + :return: None + """ + from httpx import ASGITransport, AsyncClient # noqa: PLC0415 + + from plugins.core.runtime.entrypoint import PluginEntrypointLoader # noqa: PLC0415 + from plugins.core.sdk import PluginHostContext, PluginRequestContext # noqa: PLC0415 + + host = PluginHostContext(TASK_PLUGIN_ID, plugin.backend_path, session_factory=sessions) + definition = PluginEntrypointLoader(plugin).load(host) + app = definition.app_factory(host) + permissions = frozenset({'task_demo:view', 'task_demo:write'}) + + @app.middleware('http') + async def authenticated_fixture(request: Any, call_next: Any) -> Any: + request.state.plugin_context = PluginRequestContext(host, user=None, permissions=permissions) + return await call_next(request) + + async with AsyncClient(transport=ASGITransport(app=app), base_url='http://task-integration') as client: + old = await client.get(f'/api/tasks/{LEGACY_TASK_ID}') + old.raise_for_status() + retained = old.json() + if ( + retained['title'] != '升级前的任务' + or retained['description'] != '保留已有业务数据' + or retained['status'] != 'todo' + or retained['priority'] != 'normal' + or datetime.fromisoformat(retained['createdAt'].replace('Z', '+00:00')) != LEGACY_CREATED_AT + ): + raise AssertionError('Task migration changed existing business data') + permissions = frozenset({'task_demo:view'}) + denied_writes = [ + await client.post('/api/tasks', json={'title': '禁止的写入'}), + await client.put(f'/api/tasks/{LEGACY_TASK_ID}', json={'title': '禁止的改动'}), + await client.delete(f'/api/tasks/{LEGACY_TASK_ID}'), + ] + if any(response.status_code != HTTPStatus.FORBIDDEN for response in denied_writes): + raise AssertionError('Read-only role was allowed to mutate a task') + if (await client.get(f'/api/tasks/{LEGACY_TASK_ID}')).json() != retained: + raise AssertionError('Denied writes changed the legacy task') + permissions = frozenset({'task_demo:view', 'task_demo:write'}) + created = await client.post('/api/tasks', json={'title': '升级后新增', 'priority': 'high'}) + created.raise_for_status() + task_id = created.json()['id'] + updated = await client.put( + f'/api/tasks/{task_id}', + json={'title': '升级后更新', 'description': '事务已提交', 'status': 'done', 'priority': 'high'}, + ) + updated.raise_for_status() + saved = await client.get(f'/api/tasks/{task_id}') + saved.raise_for_status() + expected_fields = {'title': '升级后更新', 'description': '事务已提交', 'status': 'done', 'priority': 'high'} + if any(saved.json()[key] != value for key, value in expected_fields.items()): + raise AssertionError('Task update did not persist every editable field') + page = await client.get('/api/tasks', params={'page': 1, 'pageSize': 1, 'status': 'done'}) + page.raise_for_status() + if page.json()['total'] != 1 or page.json()['items'][0]['id'] != task_id: + raise AssertionError('Task filtering or pagination lost committed changes') + deleted = await client.delete(f'/api/tasks/{task_id}') + deleted.raise_for_status() + if (await client.get(f'/api/tasks/{task_id}')).status_code != HTTPStatus.NOT_FOUND: + raise AssertionError('Task deletion was not committed') + if (await client.get('/api/tasks')).json()['total'] != 1: + raise AssertionError('CRUD changed the legacy task or committed an unauthorized write') diff --git a/ruoyi-fastapi-backend/scripts/verify_timezone_database.py b/ruoyi-fastapi-backend/scripts/verify_timezone_database.py index 7fc90ca..d3b05fc 100644 --- a/ruoyi-fastapi-backend/scripts/verify_timezone_database.py +++ b/ruoyi-fastapi-backend/scripts/verify_timezone_database.py @@ -18,7 +18,8 @@ from utils.time_util import TimezoneUtil BACKEND = Path(__file__).resolve().parents[1] MILLISECOND_PRECISION = 3 -MAIN_INSTANT_COLUMNS = 73 +# 包含插件制品、发布目标和 worker 状态表新增的 6 个 UTC 时刻字段。 +MAIN_INSTANT_COLUMNS = 79 PLUGIN_INSTANT_COLUMNS = 4 TIMEZONE_LENGTH = 64 ASYNC_PROBE_ID = 99 @@ -320,7 +321,7 @@ def main() -> None: report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8') print( f'{source.db_type} {report["version"]}: passed; database={target.db_database}; ' - f'{MAIN_INSTANT_COLUMNS}+4 time columns, 13 roundtrips, 3 generated CRUD cases' + f'{MAIN_INSTANT_COLUMNS}+{PLUGIN_INSTANT_COLUMNS} time columns, 13 roundtrips, 3 generated CRUD cases' ) diff --git a/ruoyi-fastapi-backend/sql/ruoyi-fastapi-pg.sql b/ruoyi-fastapi-backend/sql/ruoyi-fastapi-pg.sql index 71d0a91..c0ed66c 100644 --- a/ruoyi-fastapi-backend/sql/ruoyi-fastapi-pg.sql +++ b/ruoyi-fastapi-backend/sql/ruoyi-fastapi-pg.sql @@ -1646,6 +1646,97 @@ comment on column sys_plugin_operation_log.result is '完整执行结果JSON'; comment on column sys_plugin_operation_log.create_time is '创建时间'; comment on column sys_plugin_operation_log.remark is '备注'; +-- ---------------------------- +-- 36、插件已验证制品表 +-- ---------------------------- +create table if not exists sys_plugin_artifact ( + digest varchar(64) not null, + plugin_id varchar(64) not null, + version varchar(32) not null, + key_id varchar(128) not null, + relative_path varchar(512) not null, + manifest_json text not null, + created_by varchar(64) default null, + create_time timestamp(3) with time zone, + primary key (digest) +); +create index if not exists idx_sys_plugin_artifact_plugin on sys_plugin_artifact (plugin_id, version); +comment on table sys_plugin_artifact is '插件已验证制品表'; +comment on column sys_plugin_artifact.digest is '制品SHA256'; +comment on column sys_plugin_artifact.plugin_id is '插件ID'; +comment on column sys_plugin_artifact.version is '制品版本'; +comment on column sys_plugin_artifact.key_id is '签名公钥标识'; +comment on column sys_plugin_artifact.relative_path is '制品存储内不可变相对目录'; +comment on column sys_plugin_artifact.manifest_json is '验证后的清单JSON'; +comment on column sys_plugin_artifact.created_by is '导入者'; +comment on column sys_plugin_artifact.create_time is '创建时间'; + +-- ---------------------------- +-- 37、插件目标发布表 +-- ---------------------------- +create table if not exists sys_plugin_release ( + plugin_id varchar(64) not null, + target_digest varchar(64) default null, + previous_digest varchar(64) default null, + prepared_digest varchar(64) default null, + generation varchar(32) not null, + expected_workers integer not null default 1, + prepare_status varchar(16) not null default 'idle', + prepared_version varchar(32) default null, + last_error text default null, + create_by varchar(64) default null, + update_by varchar(64) default null, + create_time timestamp(3) with time zone, + update_time timestamp(3) with time zone, + primary key (plugin_id), + constraint ck_sys_plugin_release_workers check (expected_workers >= 1), + constraint ck_sys_plugin_release_prepare check (prepare_status in ('idle', 'preparing', 'prepared', 'failed')) +); +comment on table sys_plugin_release is '插件目标发布表'; +comment on column sys_plugin_release.plugin_id is '插件ID'; +comment on column sys_plugin_release.target_digest is '目标制品SHA256'; +comment on column sys_plugin_release.previous_digest is '上一次目标制品SHA256'; +comment on column sys_plugin_release.prepared_digest is '维护准备成功的制品SHA256'; +comment on column sys_plugin_release.generation is '目标发布代际UUID'; +comment on column sys_plugin_release.expected_workers is '预期宿主worker数量'; +comment on column sys_plugin_release.prepare_status is '维护准备状态'; +comment on column sys_plugin_release.prepared_version is '维护确认的数据结构安装版本'; +comment on column sys_plugin_release.last_error is '维护准备错误'; +comment on column sys_plugin_release.create_by is '创建者'; +comment on column sys_plugin_release.update_by is '更新者'; +comment on column sys_plugin_release.create_time is '创建时间'; +comment on column sys_plugin_release.update_time is '更新时间'; + +-- ---------------------------- +-- 38、插件worker加载状态表 +-- ---------------------------- +create table if not exists sys_plugin_worker ( + worker_id varchar(32) not null, + plugin_id varchar(64) not null, + artifact_digest varchar(64) default null, + version varchar(32) default null, + generation varchar(32) default null, + state varchar(16) not null, + heartbeat_time timestamp(3) with time zone not null, + error text default null, + create_time timestamp(3) with time zone, + update_time timestamp(3) with time zone, + primary key (worker_id, plugin_id), + constraint ck_sys_plugin_worker_state check (state in ('starting', 'ready', 'failed', 'stopped')) +); +create index if not exists idx_sys_plugin_worker_plugin on sys_plugin_worker (plugin_id, heartbeat_time); +comment on table sys_plugin_worker is '插件worker加载状态表'; +comment on column sys_plugin_worker.worker_id is '宿主进程UUID'; +comment on column sys_plugin_worker.plugin_id is '插件ID或__runtime__'; +comment on column sys_plugin_worker.artifact_digest is '实际加载的制品SHA256'; +comment on column sys_plugin_worker.version is '实际加载的制品版本'; +comment on column sys_plugin_worker.generation is '实际加载的发布代际UUID'; +comment on column sys_plugin_worker.state is 'worker状态'; +comment on column sys_plugin_worker.heartbeat_time is 'UTC心跳时间'; +comment on column sys_plugin_worker.error is '加载或运行错误'; +comment on column sys_plugin_worker.create_time is '创建时间'; +comment on column sys_plugin_worker.update_time is '更新时间'; + -- ---------------------------- -- 统一认证中心相关表清理 -- ---------------------------- @@ -1667,7 +1758,7 @@ drop table if exists sys_oauth_client; drop table if exists sys_identity_subject; -- ---------------------------- --- 36、统一认证主体关联表 +-- 39、统一认证主体关联表 -- ---------------------------- create table sys_identity_subject ( identity_id bigserial not null, @@ -1717,7 +1808,7 @@ select user_id, from seeded_users; -- ---------------------------- --- 37、OAuth客户端表 +-- 40、OAuth客户端表 -- ---------------------------- create table sys_oauth_client ( client_pk bigserial not null, @@ -1779,7 +1870,7 @@ comment on column sys_oauth_client.update_time is '更新时间'; comment on column sys_oauth_client.remark is '备注'; -- ---------------------------- --- 38、OAuth客户端密钥表 +-- 41、OAuth客户端密钥表 -- ---------------------------- create table sys_oauth_client_secret ( secret_id varchar(36) not null, @@ -1813,7 +1904,7 @@ comment on column sys_oauth_client_secret.revoked_by is '撤销者'; comment on column sys_oauth_client_secret.revoked_at is '撤销时间'; -- ---------------------------- --- 39、OAuth客户端URI表 +-- 42、OAuth客户端URI表 -- ---------------------------- create table sys_oauth_client_uri ( uri_id bigserial not null, @@ -1840,7 +1931,7 @@ comment on column sys_oauth_client_uri.status is '状态(0正常 1停用)'; comment on column sys_oauth_client_uri.create_time is '创建时间'; -- ---------------------------- --- 40、OAuth资源服务器表 +-- 43、OAuth资源服务器表 -- ---------------------------- create table sys_oauth_resource ( resource_pk bigserial not null, @@ -1882,7 +1973,7 @@ comment on column sys_oauth_resource.update_time is '更新时间'; comment on column sys_oauth_resource.remark is '备注'; -- ---------------------------- --- 41、OAuth权限范围表 +-- 44、OAuth权限范围表 -- ---------------------------- create table sys_oauth_scope ( scope_pk bigserial not null, @@ -1934,7 +2025,7 @@ insert into sys_oauth_scope values(6, 'dept', '部门', 'identity', null, '["dep insert into sys_oauth_scope values(7, 'offline_access', '离线访问', 'identity', null, '[]'::jsonb, 1, 1, '0', 'system', current_timestamp, 'system', current_timestamp, '允许签发 Refresh Token'); -- ---------------------------- --- 42、OAuth客户端和权限范围关联表 +-- 45、OAuth客户端和权限范围关联表 -- ---------------------------- create table sys_oauth_client_scope ( client_pk bigint not null, @@ -1957,7 +2048,7 @@ comment on column sys_oauth_client_scope.claim_filter is 'Client Claim 过滤策 comment on column sys_oauth_client_scope.create_time is '创建时间'; -- ---------------------------- --- 43、OAuth客户端和资源服务器关联表 +-- 46、OAuth客户端和资源服务器关联表 -- ---------------------------- create table sys_oauth_client_resource ( client_pk bigint not null, @@ -1976,7 +2067,7 @@ comment on column sys_oauth_client_resource.is_default is '是否默认 Resource comment on column sys_oauth_client_resource.create_time is '创建时间'; -- ---------------------------- --- 44、用户应用访问控制表 +-- 47、用户应用访问控制表 -- ---------------------------- create table sys_oauth_access_policy ( user_id bigint not null, @@ -1998,7 +2089,7 @@ comment on column sys_oauth_access_policy.update_by is '操作人'; comment on column sys_oauth_access_policy.update_time is '操作时间'; -- ---------------------------- --- 45、OAuth授权记录表 +-- 48、OAuth授权记录表 -- ---------------------------- create table sys_oauth_grant ( grant_id varchar(36) not null, @@ -2039,7 +2130,7 @@ comment on column sys_oauth_grant.revoke_reason is '撤销原因'; comment on column sys_oauth_grant.last_used_at is '最近使用时间'; -- ---------------------------- --- 46、OIDC单点登录会话表 +-- 49、OIDC单点登录会话表 -- ---------------------------- create table sys_sso_session ( sid varchar(36) not null, @@ -2087,7 +2178,7 @@ comment on column sys_sso_session.revoke_reason is '撤销原因'; comment on column sys_sso_session.create_time is '创建时间'; -- ---------------------------- --- 47、SSO会话与参与应用关联表 +-- 50、SSO会话与参与应用关联表 -- ---------------------------- create table sys_sso_session_client ( sid varchar(36) not null, @@ -2106,7 +2197,7 @@ comment on column sys_sso_session_client.create_time is '首次授权时间'; comment on column sys_sso_session_client.last_used_at is '最近授权时间'; -- ---------------------------- --- 48、OAuth刷新令牌表 +-- 51、OAuth刷新令牌表 -- ---------------------------- create table sys_oauth_refresh_token ( token_id varchar(36) not null, @@ -2168,7 +2259,7 @@ comment on column sys_oauth_refresh_token.revoke_reason is '撤销原因'; comment on column sys_oauth_refresh_token.reuse_detected_at is '重放检测时间'; -- ---------------------------- --- 49、OIDC签名密钥表 +-- 52、OIDC签名密钥表 -- ---------------------------- create table sys_oidc_signing_key ( key_pk bigserial not null, @@ -2210,7 +2301,7 @@ comment on column sys_oidc_signing_key.create_time is '创建时间'; comment on column sys_oidc_signing_key.remark is '备注'; -- ---------------------------- --- 50、OAuth审计日志表 +-- 53、OAuth审计日志表 -- ---------------------------- create table sys_oauth_audit_log ( event_id bigserial not null, @@ -2257,7 +2348,7 @@ comment on column sys_oauth_audit_log.detail is '脱敏扩展详情'; comment on column sys_oauth_audit_log.create_time is '事件时间'; -- ---------------------------- --- 51、OAuth审计归档表 +-- 54、OAuth审计归档表 -- ---------------------------- create table sys_oauth_audit_archive ( event_id bigint not null, diff --git a/ruoyi-fastapi-backend/sql/ruoyi-fastapi.sql b/ruoyi-fastapi-backend/sql/ruoyi-fastapi.sql index 1860623..2c36a4d 100644 --- a/ruoyi-fastapi-backend/sql/ruoyi-fastapi.sql +++ b/ruoyi-fastapi-backend/sql/ruoyi-fastapi.sql @@ -1179,6 +1179,63 @@ create table sys_plugin_operation_log ( primary key (operation_id) ) engine=innodb comment = '插件批量操作审计日志表'; +-- ---------------------------- +-- 36、插件已验证制品表 +-- ---------------------------- +create table if not exists sys_plugin_artifact ( + digest varchar(64) not null comment '制品SHA256', + plugin_id varchar(64) not null comment '插件ID', + version varchar(32) not null comment '制品版本', + key_id varchar(128) not null comment '签名公钥标识', + relative_path varchar(512) not null comment '制品存储内不可变相对目录', + manifest_json text not null comment '验证后的清单JSON', + created_by varchar(64) default null comment '导入者', + create_time datetime(3) default null comment '创建时间', + primary key (digest), + key idx_sys_plugin_artifact_plugin (plugin_id, version) +) engine=innodb comment='插件已验证制品表'; + +-- ---------------------------- +-- 37、插件目标发布表 +-- ---------------------------- +create table if not exists sys_plugin_release ( + plugin_id varchar(64) not null comment '插件ID', + target_digest varchar(64) default null comment '目标制品SHA256', + previous_digest varchar(64) default null comment '上一次目标制品SHA256', + prepared_digest varchar(64) default null comment '维护准备成功的制品SHA256', + generation varchar(32) not null comment '目标发布代际UUID', + expected_workers int not null default 1 comment '预期宿主worker数量', + prepare_status varchar(16) not null default 'idle' comment '维护准备状态', + prepared_version varchar(32) default null comment '维护确认的数据结构安装版本', + last_error text default null comment '维护准备错误', + create_by varchar(64) default null comment '创建者', + update_by varchar(64) default null comment '更新者', + create_time datetime(3) default null comment '创建时间', + update_time datetime(3) default null comment '更新时间', + primary key (plugin_id), + constraint ck_sys_plugin_release_workers check (expected_workers >= 1), + constraint ck_sys_plugin_release_prepare check (prepare_status in ('idle', 'preparing', 'prepared', 'failed')) +) engine=innodb comment='插件目标发布表'; + +-- ---------------------------- +-- 38、插件worker加载状态表 +-- ---------------------------- +create table if not exists sys_plugin_worker ( + worker_id varchar(32) not null comment '宿主进程UUID', + plugin_id varchar(64) not null comment '插件ID或__runtime__', + artifact_digest varchar(64) default null comment '实际加载的制品SHA256', + version varchar(32) default null comment '实际加载的制品版本', + generation varchar(32) default null comment '实际加载的发布代际UUID', + state varchar(16) not null comment 'worker状态', + heartbeat_time datetime(3) not null comment 'UTC心跳时间', + error text default null comment '加载或运行错误', + create_time datetime(3) default null comment '创建时间', + update_time datetime(3) default null comment '更新时间', + primary key (worker_id, plugin_id), + key idx_sys_plugin_worker_plugin (plugin_id, heartbeat_time), + constraint ck_sys_plugin_worker_state check (state in ('starting', 'ready', 'failed', 'stopped')) +) engine=innodb comment='插件worker加载状态表'; + -- ---------------------------- -- 统一认证中心相关表清理 -- ---------------------------- @@ -1200,7 +1257,7 @@ drop table if exists sys_oauth_client; drop table if exists sys_identity_subject; -- ---------------------------- --- 36、统一认证主体关联表 +-- 39、统一认证主体关联表 -- ---------------------------- create table sys_identity_subject ( identity_id bigint not null auto_increment comment '内部主键', @@ -1225,7 +1282,7 @@ insert into sys_identity_subject (user_id, subject_id, auth_version, create_by, select user_id, uuid(), 1, 'initial-sql', UTC_TIMESTAMP(3) from sys_user; -- ---------------------------- --- 37、OAuth客户端表 +-- 40、OAuth客户端表 -- ---------------------------- create table sys_oauth_client ( client_pk bigint not null auto_increment comment '内部主键', @@ -1260,7 +1317,7 @@ create table sys_oauth_client ( ) engine=innodb comment = 'OAuth客户端表'; -- ---------------------------- --- 38、OAuth客户端密钥表 +-- 41、OAuth客户端密钥表 -- ---------------------------- create table sys_oauth_client_secret ( secret_id varchar(36) not null comment 'Secret ID', @@ -1281,7 +1338,7 @@ create table sys_oauth_client_secret ( ) engine=innodb comment = 'OAuth客户端密钥表'; -- ---------------------------- --- 39、OAuth客户端URI表 +-- 42、OAuth客户端URI表 -- ---------------------------- create table sys_oauth_client_uri ( uri_id bigint not null auto_increment comment 'URI 主键', @@ -1299,7 +1356,7 @@ create table sys_oauth_client_uri ( ) engine=innodb comment = 'OAuth客户端URI表'; -- ---------------------------- --- 40、OAuth资源服务器表 +-- 43、OAuth资源服务器表 -- ---------------------------- create table sys_oauth_resource ( resource_pk bigint not null auto_increment comment '内部主键', @@ -1325,7 +1382,7 @@ create table sys_oauth_resource ( ) engine=innodb comment = 'OAuth资源服务器表'; -- ---------------------------- --- 41、OAuth权限范围表 +-- 44、OAuth权限范围表 -- ---------------------------- create table sys_oauth_scope ( scope_pk bigint not null auto_increment comment '内部主键', @@ -1361,7 +1418,7 @@ insert into sys_oauth_scope values(6, 'dept', '部门', 'identity', null, json_a insert into sys_oauth_scope values(7, 'offline_access', '离线访问', 'identity', null, json_array(), 1, 1, '0', 'system', UTC_TIMESTAMP(3), 'system', UTC_TIMESTAMP(3), '允许签发 Refresh Token'); -- ---------------------------- --- 42、OAuth客户端和权限范围关联表 +-- 45、OAuth客户端和权限范围关联表 -- ---------------------------- create table sys_oauth_client_scope ( client_pk bigint not null comment 'Client 主键', @@ -1377,7 +1434,7 @@ create table sys_oauth_client_scope ( ) engine=innodb comment = 'OAuth客户端和权限范围关联表'; -- ---------------------------- --- 43、OAuth客户端和资源服务器关联表 +-- 46、OAuth客户端和资源服务器关联表 -- ---------------------------- create table sys_oauth_client_resource ( client_pk bigint not null comment 'Client 主键', @@ -1391,7 +1448,7 @@ create table sys_oauth_client_resource ( ) engine=innodb comment = 'OAuth客户端和资源服务器关联表'; -- ---------------------------- --- 44、用户应用访问控制表 +-- 47、用户应用访问控制表 -- ---------------------------- create table sys_oauth_access_policy ( user_id bigint not null comment '用户ID', @@ -1406,7 +1463,7 @@ create table sys_oauth_access_policy ( ) engine=innodb comment = 'OAuth用户应用访问控制表'; -- ---------------------------- --- 45、OAuth授权记录表 +-- 48、OAuth授权记录表 -- ---------------------------- create table sys_oauth_grant ( grant_id varchar(36) not null comment 'Grant ID', @@ -1433,7 +1490,7 @@ create table sys_oauth_grant ( ) engine=innodb comment = 'OAuth授权记录表'; -- ---------------------------- --- 46、OIDC单点登录会话表 +-- 49、OIDC单点登录会话表 -- ---------------------------- create table sys_sso_session ( sid varchar(36) not null comment 'OIDC Session ID', @@ -1462,7 +1519,7 @@ create table sys_sso_session ( ) engine=innodb comment = 'OIDC单点登录会话表'; -- ---------------------------- --- 47、SSO会话与参与应用关联表 +-- 50、SSO会话与参与应用关联表 -- ---------------------------- create table sys_sso_session_client ( sid varchar(36) not null comment 'SSO Session ID', @@ -1476,7 +1533,7 @@ create table sys_sso_session_client ( ) engine=innodb comment = 'SSO会话参与应用'; -- ---------------------------- --- 48、OAuth刷新令牌表 +-- 51、OAuth刷新令牌表 -- ---------------------------- create table sys_oauth_refresh_token ( token_id varchar(36) not null comment 'Token ID', @@ -1516,7 +1573,7 @@ create table sys_oauth_refresh_token ( ) engine=innodb comment = 'OAuth刷新令牌表'; -- ---------------------------- --- 49、OIDC签名密钥表 +-- 52、OIDC签名密钥表 -- ---------------------------- create table sys_oidc_signing_key ( key_pk bigint not null auto_increment comment '内部主键', @@ -1542,7 +1599,7 @@ create table sys_oidc_signing_key ( ) engine=innodb comment = 'OIDC签名密钥表'; -- ---------------------------- --- 50、OAuth审计日志表 +-- 53、OAuth审计日志表 -- ---------------------------- create table sys_oauth_audit_log ( event_id bigint not null auto_increment comment '事件ID', @@ -1571,7 +1628,7 @@ create table sys_oauth_audit_log ( ) engine=innodb comment = 'OAuth审计日志表'; -- ---------------------------- --- 51、OAuth审计归档表 +-- 54、OAuth审计归档表 -- ---------------------------- create table sys_oauth_audit_archive ( event_id bigint not null comment '原事件ID', diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_contract_app_ops.py b/ruoyi-fastapi-backend/tests/cli/root/test_contract_app_ops.py index cd92322..810333d 100644 --- a/ruoyi-fastapi-backend/tests/cli/root/test_contract_app_ops.py +++ b/ruoyi-fastapi-backend/tests/cli/root/test_contract_app_ops.py @@ -1,6 +1,9 @@ +import os import subprocess +import sys from collections.abc import Callable from pathlib import Path +from textwrap import dedent from cli.exit_codes import DEPENDENCY_ERROR, SUCCESS from cli.runtime.plugin.scaffold import PluginFrontendVersionResolver @@ -544,18 +547,64 @@ def test_plugin_install_dry_run_json_output_has_stable_contract( def test_plugin_upgrade_dry_run_json_output_has_stable_contract( - run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + backend_dir: Path, parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], ) -> None: - completed = run_cli_command('plugin', 'upgrade', 'ai', '--dry-run', '--yes', '--env=dev', '--output=json') - payload = parse_json_stdout(completed) + # 成功契约需要已确认的目录插件来源;CI 不应连接宿主数据库来决定测试结果。 + script = dedent( + """ + import runpy + import sys + from types import SimpleNamespace + from unittest.mock import AsyncMock, Mock, patch - assert completed.returncode == SUCCESS + from plugins.core.management.service.gateway import PluginManagementRuntimeGateway + + state = SimpleNamespace(plugin_id='ai', source='local', installed_version='0.0.1') + read_state = AsyncMock(return_value=state) + read_history = AsyncMock(return_value=None) + database = Mock(side_effect=AssertionError('Contract tests must not open database sessions')) + sys.argv[0] = 'ruoyi' + with ( + patch.object(PluginManagementRuntimeGateway, 'get_plugin_state', read_state), + patch.object(PluginManagementRuntimeGateway, 'get_plugin_migration', read_history), + patch.object(PluginManagementRuntimeGateway, 'get_async_session_local', database), + ): + try: + runpy.run_module('cli.main', run_name='__main__') + finally: + read_state.assert_any_await('ai') + database.assert_not_called() + """ + ) + completed = subprocess.run( + [ + sys.executable, + '-c', + script, + 'plugin', + 'upgrade', + 'ai', + '--dry-run', + '--yes', + '--env=dev', + '--output=json', + ], + cwd=backend_dir, + capture_output=True, + text=True, + encoding='utf-8', + env={**os.environ, 'PYTHONIOENCODING': 'utf-8', 'LOG_FILE_ENABLED': 'false'}, + check=False, + timeout=60, + ) + assert completed.returncode == SUCCESS, completed.stderr + payload = parse_json_stdout(completed) assert payload['ok'] is True assert payload['pluginId'] == 'ai' assert payload['dryRun'] is True - assert 'installedVersion' in payload + assert payload['installedVersion'] == '0.0.1' assert 'currentVersion' in payload - assert 'needsUpgrade' in payload - assert 'databaseAvailable' in payload + assert payload['needsUpgrade'] is True + assert payload['databaseAvailable'] is True assert isinstance(payload['actions'], list) diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_guards.py b/ruoyi-fastapi-backend/tests/cli/root/test_guards.py index 51ad432..fa42e2f 100644 --- a/ruoyi-fastapi-backend/tests/cli/root/test_guards.py +++ b/ruoyi-fastapi-backend/tests/cli/root/test_guards.py @@ -40,6 +40,15 @@ def test_dangerous_command_rules_cover_expected_commands() -> None: 'gen export', 'gen sync-db', 'plugin install', + 'plugin artifact import', + 'plugin artifact prune', + 'plugin artifact rotate-signature', + 'plugin artifact reconcile', + 'plugin release prepare', + 'plugin release select', + 'plugin release rollback', + 'plugin release enable', + 'plugin release disable', 'plugin install-deps', 'plugin upgrade', 'plugin batch', diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py new file mode 100644 index 0000000..ba48c56 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py @@ -0,0 +1,518 @@ +import asyncio +import importlib +import json +import os +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest +import typer +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from typer.testing import CliRunner + +from cli.context import CliContext +from cli.core import CliContextFactory, CliExecutionService +from cli.exit_codes import ARGUMENT_ERROR, GUARD_REJECTED +from cli.groups.plugin.artifact_controller import PluginArtifactCommandController +from cli.groups.plugin.commands.artifact import register_artifact_commands + +BACKEND_ROOT = Path(__file__).resolve().parents[3] +DIGEST = 'a' * 64 +GENERATION = 'b' * 32 +EXPECTED_WORKERS = 3 + + +@pytest.fixture(autouse=True) +def preserve_environment(monkeypatch: pytest.MonkeyPatch) -> None: + """ + 隔离测试对当前应用环境的修改。 + """ + monkeypatch.setenv('APP_ENV', os.environ.get('APP_ENV', 'dev')) + + +class CapturingController: + """ + 记录命令转发参数的控制器替身。 + """ + + def __init__(self) -> None: + self.calls: list[tuple[str, tuple[Any, ...], dict[str, Any]]] = [] + + def __getattr__(self, name: str) -> Any: + def capture(*args: Any, **kwargs: Any) -> None: + self.calls.append((name, args, kwargs)) + + return capture + + +def app_for(controller: Any) -> typer.Typer: + """ + 构建仅包含被测插件命令的 Typer 应用。 + """ + app = typer.Typer() + register_artifact_commands(app, lambda: controller) + return app + + +@pytest.mark.parametrize( + ('arguments', 'method', 'options'), + [ + ( + ['artifact', 'build', 'source', 'output.rpk', '--key-file', 'signing.pem', '--key-id', 'publisher'], + 'build_artifact', + {'key_file': Path('signing.pem'), 'key_id': 'publisher', 'password_env': None}, + ), + (['artifact', 'verify', 'output.rpk'], 'verify_artifact', {}), + ( + ['artifact', 'import', 'output.rpk', '--dry-run'], + 'import_artifact', + {'allow_prod': False, 'yes': False, 'dry_run': True}, + ), + (['artifact', 'list', '--plugin-id', 'demo'], 'list_artifacts', {'plugin_id': 'demo'}), + (['artifact', 'inspect'], 'maintain_artifacts', {'options': {'keep_last': 2, 'min_age_days': 7}}), + ( + ['artifact', 'prune'], + 'maintain_artifacts', + { + 'options': {'keep_last': 2, 'min_age_days': 7, 'expected_plan': None, 'maintenance': False}, + 'allow_prod': False, + 'yes': False, + 'dry_run': True, + }, + ), + ( + ['artifact', 'prune', '--execute', '--expected-plan', DIGEST, '--maintenance'], + 'maintain_artifacts', + { + 'options': {'keep_last': 2, 'min_age_days': 7, 'expected_plan': DIGEST, 'maintenance': True}, + 'allow_prod': False, + 'yes': False, + 'dry_run': False, + }, + ), + ( + ['artifact', 'rotate-signature', 'next.rpk', '--expected-key-id', 'old'], + 'maintain_artifacts', + { + 'options': {'archive': Path('next.rpk'), 'expected_key_id': 'old', 'maintenance': False}, + 'allow_prod': False, + 'yes': False, + }, + ), + ( + ['artifact', 'reconcile'], + 'maintain_artifacts', + { + 'options': {'maintenance': False}, + 'allow_prod': False, + 'yes': False, + }, + ), + (['release', 'plan', 'demo', DIGEST], 'plan_release', {}), + ( + ['release', 'prepare', 'demo', DIGEST, '--maintenance', '--yes'], + 'prepare_release', + {'maintenance': True, 'allow_prod': False, 'yes': True, 'dry_run': False}, + ), + ( + [ + 'release', + 'select', + 'demo', + DIGEST, + '--expected-generation', + GENERATION, + '--expected-workers', + '3', + '--maintenance', + '--yes', + '--allow-prod', + ], + 'select_release', + { + 'expected_generation': GENERATION, + 'expected_workers': 3, + 'maintenance': True, + 'allow_prod': True, + 'yes': True, + }, + ), + (['release', 'status', '--plugin-id', 'demo'], 'release_status', {'plugin_id': 'demo'}), + ( + ['release', 'wait', 'demo', DIGEST, '--generation', GENERATION, '--expected-workers', '3'], + 'wait_release', + { + 'expected_generation': GENERATION, + 'expected_workers': 3, + 'timeout_seconds': 300, + 'poll_interval_seconds': 2, + }, + ), + ( + ['release', 'enable', 'demo', '--expected-generation', GENERATION, '--maintenance', '--yes'], + 'set_release_enabled', + {'enabled': True, 'expected_generation': GENERATION, 'maintenance': True, 'allow_prod': False, 'yes': True}, + ), + ( + ['release', 'disable', 'demo', '--expected-generation', GENERATION, '--maintenance', '--yes'], + 'set_release_enabled', + { + 'enabled': False, + 'expected_generation': GENERATION, + 'maintenance': True, + 'allow_prod': False, + 'yes': True, + }, + ), + ( + [ + 'release', + 'rollback', + 'demo', + '--expected-generation', + GENERATION, + '--schema-compatible', + '--maintenance', + ], + 'rollback_release', + { + 'expected_generation': GENERATION, + 'schema_compatible': True, + 'maintenance': True, + 'allow_prod': False, + 'yes': False, + }, + ), + ], +) +def test_cli_dispatches_artifact_and_release_parameters( + arguments: list[str], method: str, options: dict[str, Any] +) -> None: + """ + 验证制品与发布命令将参数完整转交控制器。 + """ + controller = CapturingController() + result = CliRunner().invoke(app_for(controller), [*arguments, '--env', 'test', '--output', 'json']) + assert result.exit_code == 0, result.output + name, args, kwargs = controller.calls[0] + assert name == method + assert args[-2:] == ('test', 'json') + assert kwargs == options + + +@pytest.mark.parametrize( + 'arguments', + [ + ['release', 'select', 'demo', DIGEST], + ['release', 'select', 'demo', DIGEST, '--expected-generation', GENERATION, '--expected-workers', '0'], + ['release', 'select', 'demo', DIGEST, '--expected-generation', GENERATION, '--dry-run'], + ['release', 'rollback', 'demo', '--expected-generation', GENERATION, '--dry-run'], + ['release', 'enable', 'demo'], + ['release', 'disable', 'demo', '--expected-generation', GENERATION, '--dry-run'], + ['artifact', 'build', 'source', 'target.rpk', '--key-file', 'signing.pem'], + ], +) +def test_cli_rejects_missing_concurrency_token_and_unsupported_flags(arguments: list[str]) -> None: + """ + 验证缺失发布代际和不支持的选项会被拒绝。 + """ + controller = CapturingController() + result = CliRunner().invoke(app_for(controller), arguments) + assert result.exit_code == ARGUMENT_ERROR + assert not controller.calls + + +@pytest.mark.parametrize( + 'help_arguments', + [ + ['artifact', '--help'], + ['release', '--help'], + ['release', 'select', '--help'], + ['release', 'wait', '--help'], + ['artifact', 'prune', '--help'], + ], +) +def test_help_keeps_config_crypto_database_and_plugin_code_unloaded(help_arguments: list[str]) -> None: + """ + 验证帮助查询不加载配置、加密、数据库或插件代码。 + """ + observed = ( + 'config.env', + 'config.database', + 'cryptography', + 'plugins.core.artifacts', + 'plugins.core.deployment.config', + 'plugins.core.deployment.service', + 'plugins.core.runtime.service', + 'cli.groups.plugin.artifact_controller', + ) + script = ( + 'import importlib,json,sys; from typer.testing import CliRunner; ' + "m=importlib.import_module('cli.groups.plugin.command'); " + f'r=CliRunner().invoke(m.app,{help_arguments!r}); ' + f'print(json.dumps({{"exit":r.exit_code,"loaded":{{name:name in sys.modules for name in {observed!r}}}}}))' + ) + result = subprocess.run( + [sys.executable, '-c', script], cwd=BACKEND_ROOT, capture_output=True, text=True, check=True + ) + payload = json.loads(result.stdout) + assert payload['exit'] == 0 + assert not any(payload['loaded'].values()) + + +class RecordingContextFactory: + """ + 记录上下文创建顺序的工厂替身。 + """ + + def __init__(self, events: list[str]) -> None: + self.events = events + + def build_regular(self, env: str, output: str, allow_prod: bool, yes: bool, dry_run: bool) -> CliContext: + assert os.environ['APP_ENV'] == env + self.events.append('context') + return CliContext(env=env, output=output, allow_prod=allow_prod, yes=yes, dry_run=dry_run) + + def build_dangerous( + self, env: str, output: str, allow_prod: bool, yes: bool, dry_run: bool, *, command_name: str + ) -> CliContext: + self.events.append(command_name) + return self.build_regular(env, output, allow_prod, yes, dry_run) + + +class RecordingExecution: + """ + 记录命令结果的执行服务替身。 + """ + + def __init__(self) -> None: + self.payload: dict[str, Any] = {} + + @staticmethod + def run_async(coroutine: Any) -> Any: + return asyncio.run(coroutine) + + def complete_payload(self, ctx: CliContext, payload: dict[str, Any]) -> None: + self.payload = payload + + +def quiet_context_factory() -> CliContextFactory: + """ + 创建不初始化日志输出的测试上下文工厂。 + """ + return CliContextFactory( + runtime_state=SimpleNamespace(suppress_logs=lambda: None, suppress_sqlalchemy_logs=lambda: None) + ) + + +def test_production_mutations_are_guarded_before_loading_runtime() -> None: + """ + 验证生产写入保护先于运行时加载执行。 + """ + + def forbidden_config() -> None: + pytest.fail('rejected CLI must not construct runtime') + + controller = PluginArtifactCommandController( + context_factory=quiet_context_factory(), + execution_service=CliExecutionService(), + config_factory=forbidden_config, + ) + app = app_for(controller) + for arguments in ( + ['artifact', 'import', 'target.rpk'], + ['artifact', 'prune', '--execute', '--maintenance'], + ['artifact', 'rotate-signature', 'next.rpk', '--expected-key-id', 'old', '--maintenance'], + ['artifact', 'reconcile', '--maintenance'], + ['release', 'prepare', 'demo', DIGEST, '--maintenance'], + ['release', 'select', 'demo', DIGEST, '--expected-generation', GENERATION, '--maintenance'], + ['release', 'rollback', 'demo', '--expected-generation', GENERATION, '--maintenance', '--schema-compatible'], + ['release', 'enable', 'demo', '--expected-generation', GENERATION, '--maintenance'], + ['release', 'disable', 'demo', '--expected-generation', GENERATION, '--maintenance'], + ): + result = CliRunner().invoke(app, [*arguments, '--env', 'prod', '--yes', '--output', 'json']) + assert result.exit_code == GUARD_REJECTED + assert json.loads(result.stdout)['ok'] is False + + +def test_dry_run_is_readonly_and_environment_precedes_runtime() -> None: + """ + 验证预演保持只读,且先设置环境再创建运行时。 + """ + events: list[str] = [] + execution = RecordingExecution() + + def config() -> object: + assert events == ['context'] + assert os.environ['APP_ENV'] == 'prod' + events.append('config') + return object() + + class Catalog: + async def import_artifact(self, path: Path, *, actor: str, dry_run: bool) -> dict[str, Any]: + assert actor + assert dry_run + return {'ok': True, 'dryRun': dry_run} + + controller = PluginArtifactCommandController( + context_factory=RecordingContextFactory(events), + execution_service=execution, + config_factory=config, + catalog_factory=lambda _: Catalog(), + ) + controller.import_artifact(Path('artifact.rpk'), 'prod', 'json', allow_prod=False, yes=False, dry_run=True) + assert execution.payload == {'ok': True, 'dryRun': True, 'env': 'prod'} + assert events == ['context', 'config'] + + +def test_controller_forwards_maintenance_cas_and_schema_compatibility() -> None: + """ + 验证维护、并发校验与数据兼容选项完整传递。 + """ + events: list[str] = [] + execution = RecordingExecution() + calls: list[tuple[str, tuple[Any, ...], dict[str, Any]]] = [] + + class Service: + def __getattr__(self, name: str) -> Any: + async def call(*args: Any, **kwargs: Any) -> dict[str, Any]: + calls.append((name, args, kwargs)) + return {'ok': True} + + return call + + controller = PluginArtifactCommandController( + context_factory=RecordingContextFactory(events), + execution_service=execution, + config_factory=object, + service_factory=lambda _: Service(), + ) + controller.select_release( + 'demo', + DIGEST, + 'dev', + 'json', + expected_generation=GENERATION, + expected_workers=EXPECTED_WORKERS, + maintenance=True, + allow_prod=False, + yes=True, + ) + assert calls[-1][0:2] == ('select', ('demo', DIGEST)) + assert calls[-1][2]['expected_generation'] == GENERATION + assert calls[-1][2]['expected_workers'] == EXPECTED_WORKERS + assert calls[-1][2]['maintenance'] is True + controller.rollback_release( + 'demo', + 'dev', + 'json', + expected_generation=GENERATION, + schema_compatible=True, + maintenance=True, + allow_prod=False, + yes=True, + ) + assert calls[-1][0:2] == ('rollback', ('demo',)) + assert calls[-1][2]['schema_compatible'] is True + assert calls[-1][2]['expected_generation'] == GENERATION + assert events[0] == 'plugin release select' + assert events[-2] == 'plugin release rollback' + + +def test_offline_build_encrypted_key_never_imports_plugin_or_requires_feature_config( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """ + 验证离线构建使用加密私钥且不导入插件或部署配置。 + """ + source = tmp_path / 'artifact_demo' + source.mkdir() + (source / 'plugin.yaml').write_text( + 'manifestVersion: 2\nid: artifact_demo\nname: Demo\nversion: 1.0.0\n' + 'backend:\n module: plugins.artifact_demo\n entrypoint: plugins.artifact_demo:create_plugin\n', + encoding='utf-8', + ) + (source / '__init__.py').write_text('raise RuntimeError("must not import")\n', encoding='utf-8') + secret = 'test-only-signing-key-password' + monkeypatch.setenv('TEST_ARTIFACT_KEY_PASSWORD', secret) + signing_key = Ed25519PrivateKey.generate() + key_data = signing_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.BestAvailableEncryption(secret.encode()), + ) + key_file = tmp_path / 'publisher.pem' + key_file.write_bytes(key_data) + execution = RecordingExecution() + + def forbidden_config() -> None: + pytest.fail('offline build must not require feature config') + + controller = PluginArtifactCommandController( + context_factory=RecordingContextFactory([]), execution_service=execution, config_factory=forbidden_config + ) + output = tmp_path / 'demo.rpk' + controller.build_artifact( + source, output, 'dev', 'json', key_file=key_file, key_id='publisher', password_env='TEST_ARTIFACT_KEY_PASSWORD' + ) + assert execution.payload['ok'], execution.payload + verified = importlib.import_module('plugins.core.artifacts').verify_artifact( + output, {'publisher': signing_key.public_key()} + ) + assert verified.plugin_id == 'artifact_demo' + assert 'plugins.artifact_demo' not in sys.modules + assert secret not in json.dumps(execution.payload) + assert key_data not in output.read_bytes() + original = output.read_bytes() + controller.build_artifact( + source, output, 'dev', 'json', key_file=key_file, key_id='publisher', password_env='TEST_ARTIFACT_KEY_PASSWORD' + ) + assert execution.payload['ok'] is False + assert output.read_bytes() == original + inside_key = source / 'publisher.pem' + inside_key.write_bytes(key_data) + controller.build_artifact(source, tmp_path / 'unsafe.rpk', 'dev', 'json', key_file=inside_key, key_id='publisher') + assert execution.payload['ok'] is False + assert '之外' in execution.payload['message'] + assert not (tmp_path / 'unsafe.rpk').exists() + + +@pytest.mark.parametrize('enabled', [True, False]) +def test_controller_enablement_forwards_maintenance_and_cas(enabled: bool) -> None: + """ + 验证制品启停传递维护确认与发布代际。 + """ + recorded: dict[str, Any] = {} + + class Enablement: + @staticmethod + async def set_enabled(plugin_id: str, **kwargs: Any) -> dict[str, Any]: + recorded.update(pluginId=plugin_id, **kwargs) + return {'ok': True, 'restartRequired': True} + + execution = RecordingExecution() + controller = PluginArtifactCommandController( + context_factory=RecordingContextFactory([]), + execution_service=execution, + config_factory=object, + enablement_factory=lambda _: Enablement(), + ) + controller.set_release_enabled( + 'demo', + 'dev', + 'json', + enabled=enabled, + expected_generation=GENERATION, + maintenance=True, + allow_prod=False, + yes=True, + ) + assert recorded['pluginId'] == 'demo' + assert recorded['enabled'] is enabled + assert recorded['expected_generation'] == GENERATION + assert recorded['maintenance'] is True + assert execution.payload['restartRequired'] is True diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_create_templates.py b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_create_templates.py new file mode 100644 index 0000000..4fd5c4e --- /dev/null +++ b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_create_templates.py @@ -0,0 +1,49 @@ +from typing import Any + +import pytest +import typer +from typer.testing import CliRunner + +from cli.groups.plugin.commands.developer import register_developer_commands +from cli.groups.plugin.options import PluginCreateCommandOptions + + +@pytest.mark.parametrize('template', ['python-asgi', 'python-bundle', 'rust-asgi', 'rust-bundle']) +def test_create_cli_forwards_v2_template_without_changing_default_options(template: str) -> None: + """ + 验证 v2 模板参数传递且不改变既有默认选项。 + """ + recorded: list[tuple[Any, ...]] = [] + + class Controller: + @staticmethod + def create_plugin(*args: Any) -> None: + recorded.append(args) + + app = typer.Typer() + register_developer_commands(app, Controller) + result = CliRunner().invoke( + app, ['create', 'invoice_ops_27', '--template', template, '--dry-run', '--output', 'json'] + ) + assert result.exit_code == 0, result.output + plugin_id, env, output, options = recorded[0] + assert (plugin_id, env, output) == ('invoice_ops_27', 'dev', 'json') + assert options == PluginCreateCommandOptions(template=template, dry_run=True) + + +def test_create_cli_defaults_to_existing_v1_full_stack_template() -> None: + """ + 验证未指定模板时继续生成原有 v1 完整模板。 + """ + recorded: list[PluginCreateCommandOptions] = [] + + class Controller: + @staticmethod + def create_plugin(plugin_id: str, env: str, output: str, options: PluginCreateCommandOptions) -> None: + recorded.append(options) + + app = typer.Typer() + register_developer_commands(app, Controller) + result = CliRunner().invoke(app, ['create', 'legacy_demo']) + assert result.exit_code == 0 + assert recorded == [PluginCreateCommandOptions()] diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_diagnostics.py b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_diagnostics.py new file mode 100644 index 0000000..d031a2e --- /dev/null +++ b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_diagnostics.py @@ -0,0 +1,46 @@ +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from cli.groups.plugin.payload import PluginCommandPayloadAdapter +from cli.groups.plugin.presenter import PluginCommandPresenter +from cli.runtime.plugin.service import CliPluginRuntimeService + + +@pytest.mark.asyncio +async def test_cli_diagnose_appends_runtime_without_changing_static_result(monkeypatch: pytest.MonkeyPatch) -> None: + read = AsyncMock(return_value={'state': 'unavailable', 'observedTotals': None}) + monkeypatch.setattr('plugins.core.runtime.diagnostics_query.read_cli_runtime_diagnostics', read) + runtime = CliPluginRuntimeService(runtime_environment=SimpleNamespace(get_backend_dir=lambda: '/backend')) + runtime._core_runtime = SimpleNamespace(diagnose_plugin=AsyncMock(return_value={'ok': True, 'info': {'x': 1}})) + payload = await runtime.diagnose_plugin_with_runtime('demo') + assert payload['ok'] is True and payload['info'] == {'x': 1} + assert payload['runtime']['state'] == 'unavailable' + read.assert_awaited_once_with('demo', backend_root=Path('/backend')) + + +def test_runtime_shared_contract_and_unknown_text_are_preserved() -> None: + payload = { + 'ok': True, + 'runtime': { + 'state': 'partial', + 'scope': 'reporting_workers', + 'observedWorkers': 0, + 'expectedWorkers': 1, + 'missingWorkers': 1, + 'staleSnapshots': 1, + 'invalidSnapshots': 0, + 'sampleTtlSeconds': 60, + 'release': {'summary': {'pluginId': 'demo', 'status': 'pending_restart', 'enabled': True}}, + 'workers': [{'workerId': 'a' * 32, 'freshness': 'stale', 'observation': 'stale', 'actual': None}], + }, + } + adapted = PluginCommandPayloadAdapter.adapt(payload) + assert adapted['runtime'] == payload['runtime'] + text = PluginCommandPresenter().build_diagnose_text(adapted) + assert 'runtime_state: partial' in text and 'freshness=stale' in text + assert 'jobs=unknown' in text and 'sse=unknown' in text and 'websocket=unknown' in text + assert 'ready=unknown' in text and 'worker_pending_cleanup=unknown' in text + assert 'jobs=0' not in text diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_release_wait.py b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_release_wait.py new file mode 100644 index 0000000..96c5395 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_release_wait.py @@ -0,0 +1,311 @@ +import asyncio +import json +import os +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, Mock + +import pytest +import typer +from typer.testing import CliRunner + +from cli.core import CliContextFactory, CliExecutionService +from cli.exit_codes import ARGUMENT_ERROR, RUNTIME_ERROR, SUCCESS +from cli.groups.plugin.artifact_controller import PluginArtifactCommandController +from cli.groups.plugin.commands.artifact import register_artifact_commands + +DIGEST = 'a' * 64 +GENERATION = 'b' * 32 +TARGET = {'pluginId': 'demo', 'digest': DIGEST, 'generation': GENERATION, 'expectedWorkers': 3} + + +@pytest.fixture(autouse=True) +def preserve_environment(monkeypatch: pytest.MonkeyPatch) -> None: + """ + 恢复命令运行前的环境选择,避免影响其他 CLI 测试。 + + :param monkeypatch: pytest 环境隔离工具 + :return: None + """ + monkeypatch.setenv('APP_ENV', os.environ.get('APP_ENV', 'dev')) + + +def result_payload(reason: str = 'converged') -> dict[str, Any]: + """ + 构造服务已经完成的等待结果,验证 CLI 保留其字段和退出语义。 + + :param reason: 等待服务返回的最终原因 + :return: 固定结构的等待结果 + """ + return { + 'ok': reason == 'converged', + 'operation': 'release_wait', + 'reason': reason, + 'message': '等待结果', + 'target': dict(TARGET), + 'lastObserved': {'pluginId': 'demo', 'targetDigest': DIGEST, 'generation': GENERATION}, + 'attempts': 1, + 'elapsedSeconds': 0.125, + 'timeoutSeconds': 0, + 'intervalSeconds': 0.25, + } + + +def controller_for(service: Any, *, config_factory: Any = object) -> PluginArtifactCommandController: + """ + 创建使用真实输出与异步执行器的控制器,隔离部署依赖及日志。 + + :param service: 只读发布服务替身 + :param config_factory: 可记录或拒绝部署配置加载的工厂 + :return: 不连接外部服务的被测控制器 + """ + context = CliContextFactory( + runtime_state=SimpleNamespace(suppress_logs=lambda: None, suppress_sqlalchemy_logs=lambda: None) + ) + return PluginArtifactCommandController( + context_factory=context, + execution_service=CliExecutionService(), + config_factory=config_factory, + service_factory=lambda _: service, + ) + + +def app_for(controller: PluginArtifactCommandController) -> typer.Typer: + """ + 创建包含制品发布命令的隔离应用。 + + :param controller: 被测制品命令控制器 + :return: Typer 测试应用 + """ + app = typer.Typer() + register_artifact_commands(app, lambda: controller) + return app + + +def wait_arguments(*overrides: str) -> list[str]: + """ + 提供无需维护确认的生产环境单次等待命令。 + + :param overrides: 附加或覆盖的 CLI 选项 + :return: 命令行参数列表 + """ + return [ + 'release', + 'wait', + 'demo', + DIGEST, + '--generation', + GENERATION, + '--expected-workers', + '3', + '--timeout', + '0', + '--interval', + '0.25', + '--env', + 'prod', + '--output', + 'json', + *overrides, + ] + + +@pytest.mark.parametrize( + 'reason', + [ + 'converged', + 'not_converged', + 'timeout', + 'target_missing', + 'target_changed', + 'target_disabled', + 'worker_failed', + 'unavailable', + 'invalid_observation', + ], +) +def test_wait_outputs_exactly_one_final_json_and_preserves_service_exit_semantics(reason: str) -> None: + """ + 成功、超时和各种失败都只输出一个最终对象,并使用统一退出码。 + + :param reason: 服务的终止原因 + :return: None + """ + payload = result_payload(reason) + wait = AsyncMock(return_value=payload) + result = CliRunner().invoke(app_for(controller_for(SimpleNamespace(wait=wait))), wait_arguments()) + + assert result.exit_code == (SUCCESS if payload['ok'] else RUNTIME_ERROR), result.output + assert json.loads(result.stdout) == payload + assert result.stderr == '' + wait.assert_awaited_once_with( + 'demo', + DIGEST, + expected_generation=GENERATION, + expected_workers=3, + timeout_seconds=0.0, + poll_interval_seconds=0.25, + ) + assert os.environ['APP_ENV'] == 'prod' + + +@pytest.mark.parametrize( + 'arguments', + [ + ['release', 'wait'], + ['release', 'wait', 'demo'], + ['release', 'wait', 'demo', DIGEST, '--expected-workers', '3'], + ['release', 'wait', 'demo', DIGEST, '--generation', GENERATION], + wait_arguments('--expected-workers', '0'), + wait_arguments('--expected-workers', '1.5'), + wait_arguments('--expected-workers', 'true'), + wait_arguments('--timeout', '-1'), + wait_arguments('--maintenance'), + wait_arguments('--allow-prod'), + wait_arguments('--yes'), + wait_arguments('--dry-run'), + ], +) +def test_wait_requires_all_pins_and_rejects_write_options_before_loading_service(arguments: list[str]) -> None: + """ + 缺少固定目标、无效计数或写入选项均由参数层拒绝。 + + :param arguments: 缺少必填项或包含不受支持选项的命令行 + :return: None + """ + config = Mock(side_effect=AssertionError('参数错误时不能加载配置')) + result = CliRunner().invoke(app_for(controller_for(SimpleNamespace(), config_factory=config)), arguments) + assert result.exit_code == ARGUMENT_ERROR + config.assert_not_called() + + +@pytest.mark.parametrize( + 'overrides', + [ + {'plugin_id': '../demo'}, + {'plugin_id': 'd'}, + {'digest': 'a' * 63}, + {'digest': 'A' * 64}, + {'expected_generation': 'b' * 31}, + {'expected_generation': 'g' * 32}, + {'expected_workers': True}, + {'expected_workers': 1.5}, + {'expected_workers': 0}, + {'timeout_seconds': float('nan')}, + {'timeout_seconds': float('inf')}, + {'timeout_seconds': -1}, + {'timeout_seconds': True}, + {'poll_interval_seconds': float('nan')}, + {'poll_interval_seconds': float('inf')}, + {'poll_interval_seconds': float('-inf')}, + {'poll_interval_seconds': 0}, + {'poll_interval_seconds': -1}, + {'poll_interval_seconds': False}, + ], +) +def test_wait_controller_validates_before_context_and_service_creation( + overrides: dict[str, Any], capsys: pytest.CaptureFixture[str] +) -> None: + """ + 非法目标或非有限时间参数不能触发配置读取,也不会输出 NaN 或 Infinity。 + + :param overrides: 替换正常调用的无效参数 + :param capsys: 标准输出捕获器 + :return: None + """ + config = Mock(side_effect=AssertionError('不能读取配置')) + controller = controller_for(SimpleNamespace(), config_factory=config) + controller._context = Mock(side_effect=AssertionError('不能创建运行上下文')) + arguments = { + 'plugin_id': 'demo', + 'digest': DIGEST, + 'env': 'prod', + 'output': 'json', + 'expected_generation': GENERATION, + 'expected_workers': 3, + 'timeout_seconds': 0, + 'poll_interval_seconds': 0.25, + **overrides, + } + with pytest.raises(typer.Exit) as exited: + controller.wait_release(**arguments) + assert exited.value.exit_code == ARGUMENT_ERROR + output = capsys.readouterr() + payload = json.loads(output.out) + assert payload['ok'] is False and payload['reason'] == 'invalid_arguments' + assert payload['attempts'] == 0 and payload['lastObserved'] is None + assert 'NaN' not in output.out and 'Infinity' not in output.out + assert output.err == '' + config.assert_not_called() + controller._context.assert_not_called() + + +@pytest.mark.parametrize('option', ['--timeout', '--interval']) +@pytest.mark.parametrize('value', ['nan', 'inf', '-inf']) +def test_wait_cli_rejects_non_finite_durations(option: str, value: str) -> None: + """ + 命令行浮点解析不能让 NaN 或无穷大进入等待服务。 + + :param option: 超时或查询间隔选项 + :param value: 非有限数值文本 + :return: None + """ + config = Mock(side_effect=AssertionError('不能读取配置')) + result = CliRunner().invoke( + app_for(controller_for(SimpleNamespace(), config_factory=config)), wait_arguments(option, value) + ) + assert result.exit_code == ARGUMENT_ERROR, result.output + config.assert_not_called() + + +@pytest.mark.parametrize('exception', [asyncio.CancelledError, KeyboardInterrupt]) +def test_wait_cli_cancellation_is_redacted_and_does_not_invent_progress(exception: type[BaseException]) -> None: + """ + 取消和用户中断都返回固定结果,不泄露异常文本或伪造已查询次数。 + + :param exception: 异步取消或键盘中断异常类型 + :return: None + """ + wait = AsyncMock(side_effect=exception('private-cancellation-detail')) + result = CliRunner().invoke(app_for(controller_for(SimpleNamespace(wait=wait))), wait_arguments()) + assert result.exit_code == RUNTIME_ERROR, result.output + payload = json.loads(result.stdout) + assert set(payload) == set(result_payload()) + assert payload['reason'] == 'cancelled' and payload['ok'] is False + assert payload['target'] == TARGET + assert payload['attempts'] is None and payload['lastObserved'] is None + assert payload['elapsedSeconds'] >= 0 + assert 'private-cancellation-detail' not in result.output + assert result.stderr == '' + + +def test_wait_initialization_failure_is_redacted_and_uses_final_result_schema() -> None: + """ + 部署配置初始化失败不能暴露连接凭据,仍返回同一最终结果结构。 + + :return: None + """ + config = Mock(side_effect=RuntimeError('private-database-password')) + result = CliRunner().invoke(app_for(controller_for(SimpleNamespace(), config_factory=config)), wait_arguments()) + assert result.exit_code == RUNTIME_ERROR + payload = json.loads(result.stdout) + assert set(payload) == set(result_payload()) + assert payload['reason'] == 'unavailable' + assert payload['attempts'] is None and payload['lastObserved'] is None + assert 'private-database-password' not in result.output + assert result.stderr == '' + + +def test_release_status_query_success_keeps_existing_ok_meaning() -> None: + """ + 查询未就绪发布仍表示查询成功,不改变已有 status 命令的退出契约。 + + :return: None + """ + payload = {'ok': True, 'releases': [{'pluginId': 'demo', 'status': 'pending_restart'}], 'workers': []} + service = SimpleNamespace(status=AsyncMock(return_value=payload)) + result = CliRunner().invoke( + app_for(controller_for(service)), ['release', 'status', '--plugin-id', 'demo', '--output', 'json'] + ) + assert result.exit_code == SUCCESS + assert json.loads(result.stdout) == {**payload, 'env': 'dev'} diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_sdk_commands.py b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_sdk_commands.py new file mode 100644 index 0000000..49cd357 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_sdk_commands.py @@ -0,0 +1,149 @@ +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest +import typer +from typer.testing import CliRunner + +from cli.exit_codes import ARGUMENT_ERROR, DEPENDENCY_ERROR, SUCCESS +from cli.groups.plugin.commands.sdk import register_sdk_commands + +BACKEND = Path(__file__).resolve().parents[3] +FRONTEND = BACKEND.parent / 'ruoyi-fastapi-frontend' + + +def sdk_app() -> typer.Typer: + """ + 构造独立 SDK 命令入口,保留真实参数解析和结果输出。 + + :return: 包含 sdk 子命令的 Typer 应用 + """ + app = typer.Typer() + register_sdk_commands(app) + return app + + +@pytest.fixture +def project(tmp_path: Path) -> Path: + """ + 创建未跟踪 SDK 的旧 bundle 工程,避免读取业务插件。 + + :param tmp_path: 隔离测试目录 + :return: 工程根目录 + """ + root = tmp_path / 'example' + vendor = root / 'web' / 'vendor' + vendor.mkdir(parents=True) + (root / 'plugin.yaml').write_text( + 'manifestVersion: 2\nid: example\nname: Example\nversion: 1.0.0\n' + 'backend:\n module: plugins.example\n entrypoint: plugins.example:create_plugin\n integration: asgi\n' + 'frontend:\n delivery:\n type: bundle\n', + encoding='utf-8', + ) + (root / '__init__.py').write_text('raise RuntimeError("must never import this project")\n', encoding='utf-8') + for name in ('pluginBridge.js', 'pluginBridge.d.ts'): + (vendor / name).write_text('old untracked SDK\n', encoding='utf-8') + return root + + +def invoke_sdk(operation: str, project: Path, *options: str) -> tuple[int, dict]: + """ + 使用真实离线 SDK 服务执行命令并解析结构化结果。 + + :param operation: check 或 update + :param project: 待检查工程目录 + :param options: 额外 CLI 选项 + :return: 退出码和 JSON 结果 + """ + result = CliRunner().invoke( + sdk_app(), ['sdk', operation, str(project), '--frontend-root', str(FRONTEND), '--output', 'json', *options] + ) + assert result.stdout, result.exception + return result.exit_code, json.loads(result.stdout) + + +def test_sdk_cli_requires_explicit_force_for_untracked_copy_and_dry_run_does_not_write(project: Path) -> None: + """ + 验证旧工程纳管前可预演,默认拒绝写入且强制更新只修改 SDK。 + + :param project: 未跟踪 SDK 的隔离工程 + :return: None + """ + before = {path.relative_to(project): path.read_bytes() for path in project.rglob('*') if path.is_file()} + code, payload = invoke_sdk('check', project) + assert code == DEPENDENCY_ERROR and payload['status'] == 'untracked' + code, payload = invoke_sdk('update', project) + assert code == DEPENDENCY_ERROR and not payload['ok'] + code, payload = invoke_sdk('update', project, '--force', '--dry-run') + assert code == SUCCESS and payload['dryRun'] + assert before == {path.relative_to(project): path.read_bytes() for path in project.rglob('*') if path.is_file()} + code, payload = invoke_sdk('update', project, '--force') + assert code == SUCCESS and payload['ok'] + assert (project / '__init__.py').read_bytes() == before[Path('__init__.py')] + assert (project / '.plugin-sdk-backups').is_dir() + code, payload = invoke_sdk('check', project) + assert code == SUCCESS and payload['status'] == 'current' + assert payload['sdkVersion'] == payload['currentSdkVersion'] == '1.0.0' + + +def test_sdk_cli_bad_source_is_json_and_nonzero(project: Path, tmp_path: Path) -> None: + """ + 验证来源 SDK 缺失时返回单份 JSON 和非零状态。 + + :param project: 独立测试工程 + :param tmp_path: 不包含 SDK 的来源目录 + :return: None + """ + result = CliRunner().invoke( + sdk_app(), ['sdk', 'check', str(project), '--frontend-root', str(tmp_path), '--output', 'json'] + ) + assert result.exit_code != SUCCESS + assert json.loads(result.stdout)['ok'] is False + + +def test_sdk_cli_invalid_output_is_argument_error(project: Path) -> None: + """ + 验证 SDK 子命令复用 CLI 输出格式约束。 + + :param project: 独立测试工程 + :return: None + """ + result = CliRunner().invoke(sdk_app(), ['sdk', 'check', str(project), '--output', 'xml']) + assert result.exit_code == ARGUMENT_ERROR + + +def test_sdk_check_cold_process_does_not_load_host_configuration_or_project(project: Path) -> None: + """ + 验证实际根命令能在无宿主配置条件下离线运行,不导入业务或数据库。 + + :param project: 包含禁止执行代码的独立工程 + :return: None + """ + script = """ +import json, sys +from typer.testing import CliRunner +from cli.main import CLI_MAIN_RUNNER +app = CLI_MAIN_RUNNER.build_cli() +result = CliRunner().invoke(app, ['plugin', 'sdk', 'check', sys.argv[1], '--output', 'json']) +for name in ('config.env', 'config.database', 'plugins.core.runtime.explicit', 'plugins.example'): + assert name not in sys.modules, name +print(json.dumps({'exitCode': result.exit_code, 'payload': json.loads(result.stdout)})) +""" + environment = {**os.environ, 'RUOYI_PLUGIN_FRONTEND_ROOT': str(FRONTEND), 'DB_SOURCES': 'invalid-do-not-read'} + result = subprocess.run( + [sys.executable, '-X', 'utf8', '-c', script, str(project)], + cwd=BACKEND, + env=environment, + capture_output=True, + text=True, + encoding='utf-8', + timeout=30, + check=False, + ) + assert result.returncode == SUCCESS, result.stderr + observed = json.loads(result.stdout) + assert observed['exitCode'] == DEPENDENCY_ERROR + assert observed['payload']['status'] == 'untracked' diff --git a/ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_scaffold_v2.py b/ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_scaffold_v2.py new file mode 100644 index 0000000..b1ea580 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_scaffold_v2.py @@ -0,0 +1,627 @@ +import ast +import hashlib +import json +import os +import runpy +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from plugins.core.discovery.scanner import PluginScanner +from plugins.core.manifest.schema import EXPLICIT_MANIFEST_VERSION +from plugins.core.validation.structure import PluginStructureChecker + +from .conftest import build_runtime + +BACKEND_ROOT = Path(__file__).resolve().parents[4] +FRONTEND_ROOT = BACKEND_ROOT.parent / 'ruoyi-fastapi-frontend' +PLUGIN_ID = 'invoice_ops_27' +TEMPLATES = ('python-asgi', 'python-bundle', 'rust-asgi', 'rust-bundle') + + +def generate(tmp_path: Path, template: str, **kwargs: object) -> tuple[dict, Path]: + """ + 在临时宿主目录中生成指定 v2 模板。 + """ + backend = tmp_path / 'backend' + payload = build_runtime(backend, frontend_root=FRONTEND_ROOT).create_plugin(PLUGIN_ID, template=template, **kwargs) + assert payload['ok'], payload + return payload, backend / 'plugin-projects' / PLUGIN_ID + + +def run_builder(source: Path, output: Path) -> subprocess.CompletedProcess[str]: + """ + 通过独立 Python 进程执行生成的交付构建脚本。 + """ + return subprocess.run( + [sys.executable, '-X', 'utf8', str(source / 'build_release.py'), '--output', str(output)], + cwd=BACKEND_ROOT, + capture_output=True, + text=True, + encoding='utf-8', + check=False, + ) + + +def write_bundle(source: Path) -> None: + """ + 写入最小已构建前端资源,供离线交付测试使用。 + """ + dist = source / 'web' / 'dist' + (dist / 'assets').mkdir(parents=True) + (dist / 'index.html').write_text('built', encoding='utf-8') + (dist / 'assets' / 'app.js').write_text('export const built = true\n', encoding='utf-8') + + +def write_release_files(source: Path, names: list[object]) -> None: + """ + 为隔离工程写入受控交付清单,允许传入非法项供拒绝路径测试。 + + :param source: 隔离插件工程根目录 + :param names: 清单中的完整文件列表 + :return: None + """ + (source / 'release-files.json').write_text( + json.dumps({'schemaVersion': 1, 'files': names}, ensure_ascii=False), encoding='utf-8' + ) + + +@pytest.mark.parametrize('template', TEMPLATES) +def test_v2_templates_create_independent_projects_without_importing_or_installing( + tmp_path: Path, template: str +) -> None: + """ + 验证四类模板生成独立工程且不安装或导入插件。 + """ + payload, source = generate(tmp_path, template) + plugin = PluginScanner(source.parent).load_manifest(source / 'plugin.yaml') + native = template.startswith('rust-') + bundle = template.endswith('-bundle') + assert plugin.manifest.manifest_version == EXPLICIT_MANIFEST_VERSION + assert plugin.manifest.runtime_kind == ('native' if native else 'python') + assert plugin.manifest.integration_kind == 'asgi' + assert plugin.manifest.frontend.delivery.type == ('bundle' if bundle else 'none') + assert plugin.manifest.id == PLUGIN_ID + assert plugin.manifest.backend.asgi.mount_path == f'/apps/{PLUGIN_ID}' + assert payload['sourceDir'] == source.as_posix() + assert payload['frontendVersion'] is None + assert not (tmp_path / 'backend' / 'plugins').exists() + assert f'plugins.{PLUGIN_ID}' not in sys.modules + assert f'ruoyi_plugin_{PLUGIN_ID}' not in sys.modules + for path in source.rglob('*'): + if not path.is_file(): + continue + content = path.read_text(encoding='utf-8') + assert '__PLUGIN_ID__' not in content + assert 'rust_demo' not in content + assert 'bundle_demo' not in content + if path.suffix == '.py': + ast.parse(content) + if native: + distribution = 'ruoyi-plugin-invoice-ops-27' + assert plugin.manifest.backend.module == f'ruoyi_plugin_{PLUGIN_ID}' + for name in ('Cargo.toml', 'Cargo.lock', 'pyproject.toml'): + assert f'name = "{distribution}"' in (source / name).read_text(encoding='utf-8') + assert f'permission", "{PLUGIN_ID}:view"' in (source / 'src' / 'lib.rs').read_text(encoding='utf-8') + else: + assert plugin.manifest.backend.entrypoint == f'plugins.{PLUGIN_ID}:create_plugin' + if bundle: + for name in ('pluginBridge.js', 'pluginBridge.d.ts'): + assert (source / 'web' / 'vendor' / name).read_text(encoding='utf-8') == ( + FRONTEND_ROOT / 'src' / 'utils' / name + ).read_text(encoding='utf-8') + package = json.loads((source / 'web' / 'package.json').read_text(encoding='utf-8')) + assert package['scripts']['build'] == 'vite build' + assert package['scripts']['dev'] == 'vite --host 127.0.0.1 --open /dev.html' + assert (source / 'web' / 'dev.html').is_file() + assert f'/apps/{PLUGIN_ID}/api/info' in (source / 'web' / 'dev.js').read_text(encoding='utf-8') + assert "apply: 'serve'" in (source / 'web' / 'vite.config.js').read_text(encoding='utf-8') + assert plugin.manifest.frontend.menus[0].component == 'PluginFrame' + assert plugin.manifest.frontend.menus[0].perms == f'{PLUGIN_ID}:view' + else: + assert not (source / 'web').exists() + assert not list(source.rglob('pluginBridge.sdk.json')) + + +@pytest.mark.parametrize('template', ['python-bundle', 'rust-bundle']) +def test_bundle_templates_record_sdk_identity_and_source_hashes(tmp_path: Path, template: str) -> None: + """ + 验证 bundle 工程包含独立 SDK 版本、协议能力及与实际副本一致的来源摘要。 + """ + _, source = generate(tmp_path, template) + vendor = source / 'web' / 'vendor' + sdk = json.loads((vendor / 'pluginBridge.sdk.json').read_text(encoding='utf-8')) + assert sdk['schemaVersion'] == 1 + assert sdk['sdkVersion'] == '1.0.0' + assert sdk['bridgeVersion'] == 1 + assert sdk['capabilities'] == {'files': 1, 'streams': 1} + assert sdk['hashAlgorithm'] == 'sha256-utf8-lf' + assert sdk['source'] == {'project': 'RuoYi-Vue3-FastAPI', 'directory': 'ruoyi-fastapi-frontend/src/utils'} + assert set(sdk['files']) == {'pluginBridge.js', 'pluginBridge.d.ts'} + for name in ('pluginBridge.js', 'pluginBridge.d.ts'): + content = (vendor / name).read_text(encoding='utf-8') + assert sdk['files'][name] == hashlib.sha256(content.encode('utf-8')).hexdigest() + + +def test_bundle_sdk_source_hashes_are_stable_across_crlf_checkout(tmp_path: Path) -> None: + """ + 验证使用 CRLF 的宿主源码副本生成工程后,SDK 溯源摘要仍与规范化内容一致。 + """ + frontend = tmp_path / 'frontend' + sdk_source = frontend / 'src' / 'utils' + sdk_source.mkdir(parents=True) + for name in ('pluginBridge.js', 'pluginBridge.d.ts', 'pluginBridge.sdk.json'): + content = (FRONTEND_ROOT / 'src' / 'utils' / name).read_text(encoding='utf-8') + (sdk_source / name).write_bytes(content.replace('\n', '\r\n').encode('utf-8')) + backend = tmp_path / 'backend' + payload = build_runtime(backend, frontend_root=frontend).create_plugin(PLUGIN_ID, template='python-bundle') + assert payload['ok'], payload + vendor = backend / 'plugin-projects' / PLUGIN_ID / 'web' / 'vendor' + sdk = json.loads((vendor / 'pluginBridge.sdk.json').read_text(encoding='utf-8')) + for name in ('pluginBridge.js', 'pluginBridge.d.ts'): + expected = (FRONTEND_ROOT / 'src' / 'utils' / name).read_text(encoding='utf-8') + assert (vendor / name).read_text(encoding='utf-8') == expected + assert sdk['files'][name] == hashlib.sha256(expected.encode('utf-8')).hexdigest() + + +@pytest.mark.parametrize('template', TEMPLATES) +def test_v2_template_readme_matches_browser_capabilities(tmp_path: Path, template: str) -> None: + """ + 验证生成说明区分浏览器 SDK 能力与模板自带接口,并保留协商和传输限制。 + """ + _, source = generate(tmp_path, template) + readme = (source / 'README.md').read_text(encoding='utf-8') + assert ( + f'ruoyi plugin release wait {PLUGIN_ID} DIGEST --generation SELECTED_GENERATION --expected-workers 1' in readme + ) + assert '不要复用上面输入的旧 `GENERATION`' in readme + assert '`--timeout 0` 只查询并断言一次' in readme + if template.endswith('-bundle'): + assert 'JSON 请求和响应每条最多 64 KiB' in readme + assert '`upload` / `download`' in readme + assert '`capabilities.files.version=1`' in readme + assert '单次最多 10 MiB' in readme + assert '`stream`' in readme + assert '`capabilities.streams.version=1`' in readme + assert '每个页面最多 2 条连接' in readme + assert '不自动重连' in readme + assert '本模板仅生成 `/api/info`,文件和事件接口需自行实现并校验权限' in readme + assert '`TRANSPORT_CRYPTO_EXCLUDE_PATHS`' in readme + assert '不支持上传、流式响应' not in readme + assert '`web/vendor/pluginBridge.sdk.json`' in readme + assert f'ruoyi plugin sdk check plugin-projects/{PLUGIN_ID} --output json' in readme + assert f'ruoyi plugin sdk update plugin-projects/{PLUGIN_ID} --dry-run' in readme + assert f'ruoyi plugin sdk update plugin-projects/{PLUGIN_ID}\n' in readme + assert '只有 `current` 状态返回成功退出码' in readme + assert '修改过或没有溯源记录的旧副本默认拒绝覆盖' in readme + assert '`--force`' in readme and '`.plugin-sdk-backups/`' in readme + assert '协议兼容不等于业务兼容' in readme + assert '不联网、不安装依赖、不更新业务源码或运行中的插件' in readme + assert f'更新后必须重新运行 `npm --prefix plugin-projects/{PLUGIN_ID}/web run build`' in readme + else: + assert '子应用不提供独立登录' in readme + assert 'capabilities.files' not in readme + assert 'capabilities.streams' not in readme + assert 'pluginBridge.sdk.json' not in readme + assert 'ruoyi plugin sdk' not in readme + + +@pytest.mark.parametrize('template', TEMPLATES) +def test_v2_template_dry_run_and_conflict_never_overwrite(tmp_path: Path, template: str) -> None: + """ + 验证预演不写文件,目录冲突时不覆盖已有内容。 + """ + payload, source = generate(tmp_path, template, dry_run=True) + assert payload['dryRun'] is True + assert not source.exists() + source.mkdir(parents=True) + marker = source / 'do-not-overwrite.txt' + marker.write_text('keep', encoding='utf-8') + payload = build_runtime(tmp_path / 'backend', frontend_root=FRONTEND_ROOT).create_plugin( + PLUGIN_ID, template=template + ) + assert payload['ok'] is False + assert marker.read_text(encoding='utf-8') == 'keep' + assert not (source / 'plugin.yaml').exists() + + +@pytest.mark.parametrize('template', TEMPLATES) +def test_v2_python_templates_describe_explicit_release_files(tmp_path: Path, template: str) -> None: + """ + 验证仅 Python 模板生成受控交付清单,并说明模块和迁移的扩展方法。 + + :param tmp_path: 隔离宿主目录 + :param template: 待检查的 v2 模板 + :return: None + """ + _, source = generate(tmp_path, template) + config = source / 'release-files.json' + readme = (source / 'README.md').read_text(encoding='utf-8') + if template.startswith('python-'): + assert json.loads(config.read_text(encoding='utf-8')) == {'schemaVersion': 1, 'files': ['__init__.py']} + assert '`release-files.json`' in readme + assert '`models.py`、`migrations/mysql/001_init.sql`' in readme + assert '`plugin.yaml` 固定纳入' in readme + assert '未列出的私有文件不会自动复制' in readme + else: + assert not config.exists() + assert 'release-files.json' not in readme + + +@pytest.mark.parametrize( + ('template', 'options', 'error'), + [ + ('python-asgi', {'backend': False}, '--frontend-only'), + ('rust-bundle', {'frontend': False}, '--backend-only'), + ('python-bundle', {'frontend_version': 'vue2'}, '--frontend-version'), + ], +) +def test_v2_templates_reject_incompatible_legacy_options( + tmp_path: Path, template: str, options: dict, error: str +) -> None: + """ + 验证 v2 模板拒绝不兼容的旧版选项。 + """ + payload = build_runtime(tmp_path / 'backend', frontend_root=FRONTEND_ROOT).create_plugin( + PLUGIN_ID, template=template, **options + ) + assert payload['ok'] is False + assert error in payload['error'] + assert not (tmp_path / 'backend' / 'plugin-projects').exists() + + +def test_bundle_template_requires_verified_host_sdk_and_no_test_is_honored(tmp_path: Path) -> None: + """ + 验证 bundle 需要宿主桥接 SDK,且遵守不生成测试的选项。 + """ + missing = build_runtime(tmp_path / 'backend').create_plugin(PLUGIN_ID, template='python-bundle') + assert missing['ok'] is False + assert '桥接 SDK' in missing['error'] + assert not (tmp_path / 'backend' / 'plugin-projects').exists() + payload, source = generate(tmp_path, 'python-bundle', test=False) + assert payload['test'] is False + assert not (source / 'tests').exists() + + +@pytest.mark.parametrize('template', ['python-asgi', 'python-bundle']) +def test_generated_python_release_passes_structure_and_generated_contract_tests(tmp_path: Path, template: str) -> None: + """ + 验证生成的 Python 交付通过结构检查与其自身合约测试。 + """ + _, source = generate(tmp_path, template) + if template.endswith('-bundle'): + write_bundle(source) + (source / 'private.pem').write_text('never copy this', encoding='utf-8') + output = tmp_path / 'release' + built = run_builder(source, output) + assert built.returncode == 0, built.stderr + delivered = output / PLUGIN_ID + plugin = PluginScanner(output).load_manifest(delivered / 'plugin.yaml') + check = PluginStructureChecker(BACKEND_ROOT).check(plugin) + assert check.ok, check.failed_items + assert not (delivered / 'tests').exists() + assert not (delivered / 'build_release.py').exists() + assert not (delivered / 'web' / 'src').exists() + assert not (delivered / 'private.pem').exists() + environment = {**os.environ, 'RUOYI_PLUGIN_DIRECTORY': str(delivered)} + tested = subprocess.run( + [ + sys.executable, + '-X', + 'utf8', + '-m', + 'pytest', + '-c', + str(BACKEND_ROOT / 'pyproject.toml'), + '--confcutdir', + str(source), + str(source / 'tests'), + '-q', + ], + cwd=BACKEND_ROOT, + env=environment, + capture_output=True, + text=True, + encoding='utf-8', + check=False, + ) + assert tested.returncode == 0, tested.stdout + tested.stderr + again = run_builder(source, output) + assert again.returncode != 0 + assert (delivered / '__init__.py').read_bytes() == (source / '__init__.py').read_bytes() + + +@pytest.mark.parametrize( + 'invalid_file', ['assets/app.js.map', 'assets/private.pem', '.env', 'assets/entry.ts', 'assets/.private.js'] +) +def test_python_bundle_packaging_rejects_nonrelease_content(tmp_path: Path, invalid_file: str) -> None: + """ + 验证 Python 交付拒绝源码、密钥和其他非交付资源。 + """ + _, source = generate(tmp_path, 'python-bundle') + write_bundle(source) + (source / 'web' / 'dist' / invalid_file).write_text('must not package', encoding='utf-8') + output = tmp_path / 'unsafe' + assert run_builder(source, output).returncode != 0 + assert not output.exists() + + +def test_python_bundle_packaging_rejects_missing_build_and_overlapping_output(tmp_path: Path) -> None: + """ + 验证 Python 交付拒绝缺失前端构建及输出目录重叠。 + """ + _, source = generate(tmp_path, 'python-bundle') + output = tmp_path / 'missing-build' + assert run_builder(source, output).returncode != 0 + assert not output.exists() + write_bundle(source) + assert run_builder(source, source / 'release').returncode != 0 + assert not (source / 'release').exists() + + +def test_python_release_rejects_hardlinked_resources(tmp_path: Path) -> None: + """ + 验证 Python 交付拒绝硬链接资源。 + """ + _, source = generate(tmp_path, 'python-asgi') + os.link(source / '__init__.py', tmp_path / 'entry-copy.py') + output = tmp_path / 'hardlink-release' + assert run_builder(source, output).returncode != 0 + assert not output.exists() + + +@pytest.mark.parametrize('template', ['python-asgi', 'python-bundle']) +def test_python_release_copies_declared_modules_and_sql_without_importing(tmp_path: Path, template: str) -> None: + """ + 验证新增模块和 SQL 按清单交付,未声明的私有文件不会混入且业务入口不会执行。 + + :param tmp_path: 隔离构建目录 + :param template: Python API 或 bundle 模板 + :return: None + """ + _, source = generate(tmp_path, template) + names = ['__init__.py', 'models.py', 'api/__init__.py', 'api/reports.py', 'migrations/mysql/001_init.sql'] + for name in names: + path = source / name + path.parent.mkdir(parents=True, exist_ok=True) + content = 'select 1;\n' if path.suffix == '.sql' else 'raise RuntimeError("must never import delivery files")\n' + path.write_text(content, encoding='utf-8') + for name in ('private.py', 'credentials.sql', 'keys/secret.py', 'tests/test_private.py', '.env', 'vendor/debug.py'): + path = source / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('do not deliver\n', encoding='utf-8') + write_release_files(source, names) + expected = {'plugin.yaml', *names} + if template.endswith('-bundle'): + write_bundle(source) + expected.update({'web/dist/index.html', 'web/dist/assets/app.js'}) + output = tmp_path / 'controlled-release' + result = run_builder(source, output) + assert result.returncode == 0, result.stderr + delivered = output / PLUGIN_ID + assert {path.relative_to(delivered).as_posix() for path in delivered.rglob('*') if path.is_file()} == expected + for name in expected: + assert (delivered / name).read_bytes() == (source / name).read_bytes() + + +@pytest.mark.parametrize( + ('name', 'reason'), + [ + ('../outside.py', '不安全路径'), + ('/absolute.py', '不安全路径'), + ('C:/drive.py', '不安全路径'), + (r'package\module.py', '不安全路径'), + ('package//module.py', '不安全路径'), + ('./module.py', '不安全路径'), + ('.private/module.py', '不安全路径'), + ('module.py ', '不安全路径'), + ('CON.py', '不安全路径'), + ('*.py', '不安全路径'), + ('[ab].py', '不安全路径'), + ('tests/helper.py', '仅允许业务'), + ('Tests/helper.sql', '仅允许业务'), + ('keys/secret.py', '仅允许业务'), + ('vendor/helper.py', '仅允许业务'), + ('web/vendor/helper.py', '仅允许业务'), + ('node_modules/helper.py', '仅允许业务'), + ('build_release.py', '仅允许业务'), + ('test_business.py', '仅允许业务'), + ('private.pem', '仅允许业务'), + ('plugin.yaml', '仅允许业务'), + ('package', '仅允许业务'), + (None, '必须为字符串'), + ], +) +def test_python_release_rejects_unsafe_declared_paths_before_output(tmp_path: Path, name: object, reason: str) -> None: + """ + 验证显式声明仍不能绕过路径、资源类型和开发目录限制。 + + :param tmp_path: 隔离源码及输出目录 + :param name: 清单中的非法资源路径 + :param reason: 预期的拒绝原因片段 + :return: None + """ + _, source = generate(tmp_path, 'python-asgi') + write_release_files(source, ['__init__.py', name]) + output = tmp_path / 'unsafe-release' + result = run_builder(source, output) + assert result.returncode != 0 + assert reason in result.stderr + assert not output.exists() + + +@pytest.mark.parametrize( + 'names', + [ + ['__init__.py', '__init__.py'], + ['models.py', 'Models.py'], + ['Package/one.py', 'package/two.py'], + ['module.py', 'module.py/child.py'], + ], +) +def test_python_release_rejects_duplicate_and_conflicting_inventory(tmp_path: Path, names: list[str]) -> None: + """ + 验证重复文件、大小写目录歧义及文件目录冲突在访问资源前被拒绝。 + + :param tmp_path: 隔离源码及输出目录 + :param names: 存在重复或冲突的完整清单 + :return: None + """ + _, source = generate(tmp_path, 'python-asgi') + write_release_files(source, names) + output = tmp_path / 'collision-release' + result = run_builder(source, output) + assert result.returncode != 0 + assert '冲突' in result.stderr + assert not output.exists() + + +@pytest.mark.parametrize( + 'content', + [ + 'not json', + '[]', + '{"schemaVersion": true, "files": ["__init__.py"]}', + '{"schemaVersion": 2, "files": ["__init__.py"]}', + '{"schemaVersion": 1, "files": [], "extra": true}', + '{"schemaVersion": 1, "files": []}', + '{"schemaVersion": 1, "files": "__init__.py"}', + '{"schemaVersion": 1, "files": [], "files": ["__init__.py"]}', + json.dumps({'schemaVersion': 1, 'files': ['__init__.py'] * 1025}), + ' ' * (64 * 1024 + 1), + ], + ids=[ + 'invalid-json', + 'not-object', + 'bool-version', + 'wrong-version', + 'extra-field', + 'empty-list', + 'not-list', + 'duplicate-json-key', + 'too-many-files', + 'oversized-config', + ], +) +def test_python_release_rejects_invalid_or_oversized_config(tmp_path: Path, content: str) -> None: + """ + 验证清单结构、重复字段和资源上限,拒绝时不创建输出目录。 + + :param tmp_path: 隔离源码及输出目录 + :param content: 非法交付清单文本 + :return: None + """ + _, source = generate(tmp_path, 'python-asgi') + (source / 'release-files.json').write_text(content, encoding='utf-8') + output = tmp_path / 'invalid-config-release' + assert run_builder(source, output).returncode != 0 + assert not output.exists() + + +@pytest.mark.parametrize('missing', ['release-files.json', 'module.py', 'directory.py']) +def test_python_release_requires_existing_config_and_regular_declared_files(tmp_path: Path, missing: str) -> None: + """ + 验证缺失清单、缺失资源及伪装为 .py 的目录均不会留下半成品输出。 + + :param tmp_path: 隔离源码及输出目录 + :param missing: 缺失清单或不可用的声明资源 + :return: None + """ + _, source = generate(tmp_path, 'python-asgi') + if missing == 'release-files.json': + (source / missing).unlink() + else: + write_release_files(source, ['__init__.py', missing]) + if missing == 'directory.py': + (source / missing).mkdir() + output = tmp_path / 'missing-release' + assert run_builder(source, output).returncode != 0 + assert not output.exists() + + +@pytest.mark.parametrize('kind', ['file', 'directory']) +def test_python_release_rejects_linked_declared_resources(tmp_path: Path, kind: str) -> None: + """ + 验证文件或父目录符号链接不能将工程外内容引入交付。 + + :param tmp_path: 隔离源码、外部文件及输出目录 + :param kind: 文件链接或目录链接 + :return: None + """ + _, source = generate(tmp_path, 'python-asgi') + outside = tmp_path / 'outside' + outside.mkdir() + (outside / 'module.py').write_text('private = True\n', encoding='utf-8') + try: + if kind == 'file': + (source / 'module.py').symlink_to(outside / 'module.py') + name = 'module.py' + else: + (source / 'package').symlink_to(outside, target_is_directory=True) + name = 'package/module.py' + except OSError as exc: + pytest.skip(f'当前文件系统不允许创建符号链接:{exc}') + write_release_files(source, ['__init__.py', name]) + output = tmp_path / 'linked-release' + assert run_builder(source, output).returncode != 0 + assert not output.exists() + + +def test_python_release_rejects_reparse_resource_before_output(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """ + 验证 Windows 重解析标志独立于符号链接权限,且校验失败发生在输出创建之前。 + + :param tmp_path: 隔离源码及输出目录 + :param monkeypatch: 模拟文件系统重解析标志和命令参数 + :return: None + """ + _, source = generate(tmp_path, 'python-asgi') + namespace = runpy.run_path(str(source / 'build_release.py')) + original_lstat = Path.lstat + resource = source / '__init__.py' + + def reparse_lstat(path: Path) -> object: + """ + 仅向被检查资源注入重解析标志,其余文件沿用真实元数据。 + + :param path: 待查询的文件系统路径 + :return: 原始 stat 或包含重解析标志的模拟对象 + """ + metadata = original_lstat(path) + if path == resource: + return SimpleNamespace(st_mode=metadata.st_mode, st_file_attributes=0x400) + return metadata + + monkeypatch.setattr(Path, 'lstat', reparse_lstat) + output = tmp_path / 'reparse-release' + monkeypatch.setattr(sys, 'argv', ['build_release.py', '--output', str(output)]) + with pytest.raises(ValueError, match='重解析点'): + namespace['main']() + assert not output.exists() + + +def test_task_demo_release_builder_matches_scaffold_template() -> None: + """ + 验证任务示例和 Python 脚手架共用同一独立构建逻辑,防止安全约束漂移。 + + :return: None + """ + example = BACKEND_ROOT / 'plugins' / 'examples' / 'python' / 'task_demo' + template = BACKEND_ROOT / 'cli' / 'runtime' / 'plugin' / 'scaffold' / 'assets' / 'python' / 'build_release.py.tmpl' + expected = template.read_text(encoding='utf-8').replace('__PLUGIN_ID__', 'task_demo').replace('__BUNDLE__', 'True') + assert (example / 'build_release.py').read_text(encoding='utf-8') == expected + assert json.loads((example / 'release-files.json').read_text(encoding='utf-8')) == { + 'schemaVersion': 1, + 'files': [ + '__init__.py', + 'models.py', + 'service.py', + 'migrations/mysql/001_init.sql', + 'migrations/mysql/002_priority.sql', + 'migrations/postgresql/001_init.sql', + 'migrations/postgresql/002_priority.sql', + ], + } diff --git a/ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_sdk.py b/ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_sdk.py new file mode 100644 index 0000000..1bbce3f --- /dev/null +++ b/ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_sdk.py @@ -0,0 +1,494 @@ +import hashlib +import json +import os +import stat +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest +import yaml + +from cli.runtime.plugin.sdk import HASH_ALGORITHM, SDK_FILES, SDK_METADATA, SDK_SOURCE, VENDOR_FILES, PluginBridgeSdk + + +def write_source( + frontend: Path, *, version: str = '1.0.0', wire: int = 1, capabilities: dict[str, int] | None = None +) -> PluginBridgeSdk: + """ + 写入不执行 JavaScript 的最小宿主 SDK 测试源。 + + :param frontend: 临时宿主前端目录 + :param version: SDK 版本 + :param wire: 通信协议版本 + :param capabilities: 声明的可选能力版本 + :return: 指向测试源的 SDK 管理器 + """ + directory = frontend / 'src' / 'utils' + directory.mkdir(parents=True, exist_ok=True) + (directory / SDK_FILES[0]).write_text( + f"export const PLUGIN_BRIDGE_SDK_VERSION = '{version}'\nexport const PLUGIN_BRIDGE_VERSION = {wire}\n", + encoding='utf-8', + ) + (directory / SDK_FILES[1]).write_text( + f"export const PLUGIN_BRIDGE_SDK_VERSION: '{version}'\nexport const PLUGIN_BRIDGE_VERSION: {wire}\n", + encoding='utf-8', + ) + metadata = { + 'schemaVersion': 1, + 'sdkVersion': version, + 'bridgeVersion': wire, + 'capabilities': capabilities if capabilities is not None else {'files': 1, 'streams': 1}, + } + (directory / SDK_METADATA).write_text(json.dumps(metadata), encoding='utf-8') + return PluginBridgeSdk(frontend) + + +def write_project(project: Path, sdk: PluginBridgeSdk) -> Path: + """ + 创建含业务文件和 SDK 副本的独立 bundle 工程。 + + :param project: 临时工程目录 + :param sdk: 用于生成初始副本的宿主 SDK + :return: 创建后的工程目录 + """ + vendor = project / 'web' / 'vendor' + vendor.mkdir(parents=True) + manifest = { + 'manifestVersion': 2, + 'id': 'sdk_demo', + 'name': 'SDK Demo', + 'version': '1.0.0', + 'backend': { + 'module': 'plugins.sdk_demo', + 'entrypoint': 'plugins.sdk_demo:create_plugin', + 'integration': 'asgi', + }, + 'frontend': {'delivery': {'type': 'bundle'}, 'bundle': {'directory': 'web/dist', 'entry': 'index.html'}}, + } + (project / 'plugin.yaml').write_text(yaml.safe_dump(manifest), encoding='utf-8') + (project / 'web' / 'business.js').write_text('user-business-code\n', encoding='utf-8') + for name, content in sdk.build_vendor_files().items(): + (vendor / name).write_bytes(content.encode('utf-8')) + return project + + +def snapshot(project: Path) -> dict[str, bytes]: + """ + 收集测试工程全部文件以确认预演和拒绝操作没有副作用。 + + :param project: 临时工程目录 + :return: 相对文件名与原始字节的映射 + """ + return {str(path.relative_to(project)): path.read_bytes() for path in project.rglob('*') if path.is_file()} + + +@pytest.fixture +def outdated(tmp_path: Path) -> tuple[PluginBridgeSdk, Path]: + """ + 创建完整且版本较旧的 SDK 副本。 + + :param tmp_path: pytest 的独立临时目录 + :return: 当前宿主 SDK 及包含旧副本的工程 + """ + frontend = tmp_path / 'frontend' + old = write_source(frontend, version='0.9.0', capabilities={'files': 1}) + project = write_project(tmp_path / 'project', old) + return write_source(frontend), project + + +def test_build_vendor_records_normalized_hashes_and_versions(tmp_path: Path) -> None: + """SDK 来源记录固定且可复现,CRLF 与 BOM 不改变内容摘要。""" + frontend = tmp_path / 'frontend' + sdk = write_source(frontend) + expected = sdk.build_vendor_files() + for name in VENDOR_FILES: + path = frontend / 'src' / 'utils' / name + text = path.read_text(encoding='utf-8') + path.write_bytes(b'\xef\xbb\xbf' + text.replace('\n', '\r\n').encode('utf-8')) + actual = sdk.build_vendor_files() + assert actual == expected + assert set(actual) == set(VENDOR_FILES) + metadata = json.loads(actual[SDK_METADATA]) + assert metadata['hashAlgorithm'] == HASH_ALGORITHM + assert metadata['source'] == SDK_SOURCE + assert metadata['sdkVersion'] == '1.0.0' + assert metadata['files'] == {name: hashlib.sha256(actual[name].encode('utf-8')).hexdigest() for name in SDK_FILES} + project = write_project(tmp_path / 'project', sdk) + for name in VENDOR_FILES: + path = project / 'web' / 'vendor' / name + path.write_bytes(path.read_bytes().replace(b'\n', b'\r\n')) + assert sdk.check(project)['status'] == 'current' + + +@pytest.mark.parametrize('target', ['pluginBridge.js', 'pluginBridge.d.ts', 'pluginBridge.sdk.json']) +def test_invalid_source_version_cannot_be_used(tmp_path: Path, target: str) -> None: + """宿主源三份版本声明必须一致,强制模式不能绕过源错误。""" + frontend = tmp_path / 'frontend' + sdk = write_source(frontend) + project = write_project(tmp_path / 'project', sdk) + before = snapshot(project) + path = frontend / 'src' / 'utils' / target + path.write_text(path.read_text(encoding='utf-8').replace('1.0.0', '9.0.0'), encoding='utf-8') + with pytest.raises(ValueError, match='版本声明'): + sdk.build_vendor_files() + assert sdk.check(project)['status'] == 'invalid' + assert not sdk.update(project, force=True)['ok'] + assert snapshot(project) == before + + +@pytest.mark.parametrize( + ('field', 'value'), + [('schemaVersion', True), ('bridgeVersion', True), ('sdkVersion', 'latest'), ('capabilities', {'files': True})], +) +def test_invalid_source_metadata_types_fail_closed(tmp_path: Path, field: str, value: Any) -> None: + """布尔值不能代替版本整数,无效来源元数据不能生成副本。""" + sdk = write_source(tmp_path) + path = tmp_path / 'src' / 'utils' / SDK_METADATA + metadata = json.loads(path.read_text(encoding='utf-8')) + metadata[field] = value + path.write_text(json.dumps(metadata), encoding='utf-8') + with pytest.raises(ValueError): + sdk.build_vendor_files() + + +def test_outdated_optional_capability_remains_protocol_compatible(outdated: tuple[PluginBridgeSdk, Path]) -> None: + """旧版缺少 streams 时仍兼容通信协议,但 CI 检查不会误报为最新副本。""" + sdk, project = outdated + report = sdk.check(project) + assert not report['ok'] + assert report['status'] == 'outdated' + assert report['sdkVersion'] == '0.9.0' + assert report['currentSdkVersion'] == '1.0.0' + assert report['protocolCompatible'] is True + assert report['updateAvailable'] is True + + +def test_update_previews_then_backs_up_exact_bytes_and_writes_metadata_last( + outdated: tuple[PluginBridgeSdk, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """预演零写入,实际更新先备份且最后替换来源记录,不改业务文件。""" + sdk, project = outdated + before = snapshot(project) + preview = sdk.update(project, dry_run=True) + assert preview['ok'] and preview['dryRun'] and not preview['updated'] + assert snapshot(project) == before + assert not (project / '.plugin-sdk-backups').exists() + calls = [] + replace = os.replace + + def record_replace(source: Path, target: Path) -> None: + """记录原子替换顺序以验证元数据只在代码之后发布。""" + calls.append(target.name) + replace(source, target) + + monkeypatch.setattr(os, 'replace', record_replace) + report = sdk.update(project) + assert report['ok'] and report['updated'] and report['status'] == 'current' + assert calls == list(VENDOR_FILES) + backup = Path(report['backupDir']) + for name in VENDOR_FILES: + assert (backup / name).read_bytes() == before[str(Path('web/vendor') / name)] + assert (project / 'web/business.js').read_bytes() == before[str(Path('web/business.js'))] + assert not (project / '.plugin-sdk.lock').exists() + no_op = sdk.update(project) + assert no_op['ok'] and not no_op['updated'] and no_op['backupDir'] is None + assert len(list((project / '.plugin-sdk-backups').iterdir())) == 1 + + +@pytest.mark.parametrize('state', ['modified', 'untracked', 'invalid', 'missing_file']) +def test_local_changes_require_force_and_are_backed_up(outdated: tuple[PluginBridgeSdk, Path], state: str) -> None: + """本地改动、缺文件及来源记录损坏只允许显式备份后覆盖。""" + sdk, project = outdated + vendor = project / 'web/vendor' + if state == 'modified': + (vendor / SDK_FILES[0]).write_bytes(b'local edits\r\n') + elif state == 'untracked': + (vendor / SDK_METADATA).unlink() + elif state == 'invalid': + (vendor / SDK_METADATA).write_bytes(b'{broken') + else: + (vendor / SDK_FILES[1]).unlink() + before = snapshot(project) + expected = 'modified' if state == 'missing_file' else state + assert sdk.check(project)['status'] == expected + assert not sdk.update(project)['ok'] + assert snapshot(project) == before + assert sdk.update(project, force=True, dry_run=True)['ok'] + assert snapshot(project) == before + report = sdk.update(project, force=True) + assert report['ok'] and report['status'] == 'current' + backup = Path(report['backupDir']) + for name in VENDOR_FILES: + relative = str(Path('web/vendor') / name) + assert (backup / name).exists() == (relative in before) + if relative in before: + assert (backup / name).read_bytes() == before[relative] + + +def test_protocol_change_is_not_force_updatable(tmp_path: Path) -> None: + """不同通信协议需要人工迁移,不能借 --force 越过该边界。""" + frontend = tmp_path / 'frontend' + sdk = write_source(frontend) + project = write_project(tmp_path / 'project', sdk) + write_source(frontend, wire=2) + before = snapshot(project) + report = sdk.check(project) + assert report['status'] == 'incompatible' and report['protocolCompatible'] is False + assert not sdk.update(project, force=True)['ok'] + assert snapshot(project) == before + + +@pytest.mark.parametrize('metadata_state', ['tracked', 'untracked', 'invalid']) +def test_actual_modified_wire_cannot_be_hidden_by_metadata( + outdated: tuple[PluginBridgeSdk, Path], metadata_state: str +) -> None: + """实际导出协议不同即拒绝强制更新,不依赖旧记录是否存在或完整。""" + sdk, project = outdated + vendor = project / 'web/vendor' + path = vendor / SDK_FILES[0] + path.write_text( + path.read_text(encoding='utf-8').replace('PLUGIN_BRIDGE_VERSION = 1', 'PLUGIN_BRIDGE_VERSION = 2'), + encoding='utf-8', + ) + if metadata_state == 'untracked': + (vendor / SDK_METADATA).unlink() + elif metadata_state == 'invalid': + (vendor / SDK_METADATA).write_bytes(b'{broken') + before = snapshot(project) + report = sdk.check(project) + assert report['status'] == 'incompatible' + assert report['protocolCompatible'] is False + assert not sdk.update(project, force=True)['ok'] + assert snapshot(project) == before + + +def test_metadata_wire_conflict_cannot_be_force_ignored(outdated: tuple[PluginBridgeSdk, Path]) -> None: + """元数据声明不同协议时,不能仅凭代码的当前协议导出宣布兼容。""" + sdk, project = outdated + path = project / 'web/vendor' / SDK_METADATA + metadata = json.loads(path.read_text(encoding='utf-8')) + metadata['bridgeVersion'] = 2 + path.write_text(json.dumps(metadata), encoding='utf-8') + before = snapshot(project) + assert sdk.check(project)['protocolCompatible'] is False + assert not sdk.update(project, force=True)['ok'] + assert snapshot(project) == before + + +def test_unrecognizable_actual_wire_remains_unknown(outdated: tuple[PluginBridgeSdk, Path]) -> None: + """实际声明无法静态识别时不再使用旧元数据伪报兼容。""" + sdk, project = outdated + path = project / 'web/vendor' / SDK_FILES[0] + path.write_text('export const PLUGIN_BRIDGE_VERSION = getVersion()\n', encoding='utf-8') + report = sdk.check(project) + assert report['status'] == 'modified' + assert report['protocolCompatible'] is None + assert sdk.update(project, force=True)['ok'] + + +def test_legacy_untracked_wire_one_without_sdk_version_can_be_adopted(outdated: tuple[PluginBridgeSdk, Path]) -> None: + """早期没有独立 SDK 版本常量的协议 v1 副本仍能显式备份纳管。""" + sdk, project = outdated + vendor = project / 'web/vendor' + (vendor / SDK_METADATA).unlink() + for name in SDK_FILES: + path = vendor / name + text = path.read_text(encoding='utf-8') + path.write_text( + '\n'.join(line for line in text.splitlines() if 'PLUGIN_BRIDGE_SDK_VERSION' not in line), encoding='utf-8' + ) + report = sdk.check(project) + assert report['status'] == 'untracked' and report['protocolCompatible'] is True + assert not sdk.update(project)['ok'] + assert sdk.update(project, force=True)['ok'] + + +@pytest.mark.parametrize('manifest', [{}, {'manifestVersion': 1}, {'manifestVersion': True}, {'manifestVersion': 2}]) +def test_invalid_project_is_never_force_updatable( + outdated: tuple[PluginBridgeSdk, Path], manifest: dict[str, Any] +) -> None: + """强制模式不允许更新非 v2 bundle 工程。""" + sdk, project = outdated + (project / 'plugin.yaml').write_text(yaml.safe_dump(manifest), encoding='utf-8') + before = snapshot(project) + assert sdk.check(project)['status'] == 'invalid' + assert not sdk.update(project, force=True)['ok'] + assert snapshot(project) == before + + +def test_existing_lock_is_preserved_and_prevents_update(outdated: tuple[PluginBridgeSdk, Path]) -> None: + """其他更新持有的锁不会被覆盖或自动清理。""" + sdk, project = outdated + (project / '.plugin-sdk.lock').write_text('other updater', encoding='utf-8') + before = snapshot(project) + report = sdk.update(project) + assert not report['ok'] and '锁已存在' in report['message'] + assert snapshot(project) == before + + +@pytest.mark.parametrize('failure_file', list(VENDOR_FILES)) +def test_failed_update_restores_originals_and_keeps_backup( + outdated: tuple[PluginBridgeSdk, Path], monkeypatch: pytest.MonkeyPatch, failure_file: str +) -> None: + """任一替换失败均恢复原有三个文件,包括原始换行字节。""" + sdk, project = outdated + before = snapshot(project) + replace = os.replace + failed = False + + def fail_once(source: Path, target: Path) -> None: + """模拟磁盘替换失败一次,使后续回滚仍可写回原内容。""" + nonlocal failed + if target.name == failure_file and not failed: + failed = True + raise OSError('simulated replacement failure') + replace(source, target) + + monkeypatch.setattr(os, 'replace', fail_once) + report = sdk.update(project) + assert not report['ok'] and not report['updated'] + assert Path(report['backupDir']).is_dir() + for name in VENDOR_FILES: + assert (project / 'web/vendor' / name).read_bytes() == before[str(Path('web/vendor') / name)] + assert sdk.check(project)['status'] == 'outdated' + assert not (project / '.plugin-sdk.lock').exists() + assert not list((project / 'web/vendor').glob('*.tmp')) + + +def test_final_content_verification_failure_rolls_back( + outdated: tuple[PluginBridgeSdk, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """文件系统未报错但最终内容不匹配时仍回滚,不能宣布更新成功。""" + sdk, project = outdated + before = snapshot(project) + replace = os.replace + corrupted = False + + def corrupt_once(source: Path, target: Path) -> None: + """模拟元数据发布后发现实际文件字节不同。""" + nonlocal corrupted + replace(source, target) + if target.name == SDK_METADATA and not corrupted: + corrupted = True + (target.parent / SDK_FILES[0]).write_bytes(b'simulated corrupted bytes') + + monkeypatch.setattr(os, 'replace', corrupt_once) + report = sdk.update(project) + assert not report['ok'] and not report['updated'] + assert '内容校验失败' in report['message'] + for name in VENDOR_FILES: + assert (project / 'web/vendor' / name).read_bytes() == before[str(Path('web/vendor') / name)] + assert sdk.check(project)['status'] == 'outdated' + + +def test_untracked_creation_failure_removes_new_files( + outdated: tuple[PluginBridgeSdk, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """纳管空 vendor 失败后删除本次新增 SDK 文件,不留下半套副本。""" + sdk, project = outdated + for name in VENDOR_FILES: + (project / 'web/vendor' / name).unlink() + replace = os.replace + + def fail_metadata(source: Path, target: Path) -> None: + """模拟首次创建副本时发布元数据失败。""" + if target.name == SDK_METADATA: + raise OSError('metadata write failed') + replace(source, target) + + monkeypatch.setattr(os, 'replace', fail_metadata) + report = sdk.update(project, force=True) + assert not report['ok'] + assert not list((project / 'web/vendor').iterdir()) + assert not (project / '.plugin-sdk.lock').exists() + assert sdk.check(project)['status'] == 'untracked' + + +def test_rollback_failure_reports_backup_and_continues_other_files( + outdated: tuple[PluginBridgeSdk, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """恢复其中一个文件失败时继续处理其他文件,并明确提示使用备份。""" + sdk, project = outdated + before = snapshot(project) + replace = os.replace + + def fail_types(source: Path, target: Path) -> None: + """模拟类型声明文件持续无法替换。""" + if target.name == SDK_FILES[1]: + raise OSError('type file unavailable') + replace(source, target) + + monkeypatch.setattr(os, 'replace', fail_types) + report = sdk.update(project) + assert not report['ok'] and '回滚未完成' in report['message'] + backup = Path(report['backupDir']) + assert str(backup) in report['message'] + for name in VENDOR_FILES: + assert (backup / name).read_bytes() == before[str(Path('web/vendor') / name)] + assert (project / 'web/vendor' / SDK_FILES[0]).read_bytes() == before[str(Path('web/vendor') / SDK_FILES[0])] + assert not (project / '.plugin-sdk.lock').exists() + + +@pytest.mark.parametrize('relative', ['web', 'web/vendor', 'web/vendor/pluginBridge.js', '.plugin-sdk-backups']) +def test_windows_reparse_components_are_rejected( + outdated: tuple[PluginBridgeSdk, Path], monkeypatch: pytest.MonkeyPatch, relative: str +) -> None: + """无需创建链接权限也覆盖 Windows 重解析点及父路径拒绝逻辑。""" + sdk, project = outdated + before = snapshot(project) + rejected = project / relative + lstat = Path.lstat + + def reparse_lstat(path: Path) -> Any: + """将指定路径模拟为 Windows 重解析点。""" + if path == rejected: + return SimpleNamespace(st_mode=stat.S_IFDIR, st_file_attributes=0x400) + return lstat(path) + + monkeypatch.setattr(stat, 'FILE_ATTRIBUTE_REPARSE_POINT', 0x400, raising=False) + monkeypatch.setattr(Path, 'lstat', reparse_lstat) + report = sdk.update(project, force=True) + assert not report['ok'] and '重解析点' in report['message'] + assert snapshot(project) == before + + +def test_metadata_cannot_choose_target_paths(outdated: tuple[PluginBridgeSdk, Path]) -> None: + """篡改摘要键不能让更新读取或覆盖固定三文件以外的路径。""" + sdk, project = outdated + metadata_path = project / 'web/vendor' / SDK_METADATA + metadata = json.loads(metadata_path.read_text(encoding='utf-8')) + metadata['files']['../business.js'] = metadata['files'].pop(SDK_FILES[0]) + metadata_path.write_text(json.dumps(metadata), encoding='utf-8') + before = (project / 'web/business.js').read_bytes() + assert sdk.check(project)['status'] == 'invalid' + assert not sdk.update(project)['ok'] + assert sdk.update(project, force=True)['ok'] + assert (project / 'web/business.js').read_bytes() == before + + +@pytest.mark.parametrize('target_name', ['pluginBridge.js', 'pluginBridge.sdk.json', '.plugin-sdk-backups']) +def test_symlinks_are_rejected_without_touching_target( + outdated: tuple[PluginBridgeSdk, Path], tmp_path: Path, target_name: str +) -> None: + """SDK 文件和备份路径中的符号链接不能作为可强制覆盖的普通副本。""" + sdk, project = outdated + directory = target_name == '.plugin-sdk-backups' + target = tmp_path / 'outside' + if directory: + target.mkdir() + else: + target.write_bytes(b'outside unchanged') + link = project / target_name if directory else project / 'web/vendor' / target_name + if link.exists(): + link.unlink() + try: + link.symlink_to(target, target_is_directory=directory) + except OSError as exc: + pytest.skip(f'当前平台不允许创建测试符号链接:{exc}') + assert not sdk.update(project, force=True)['ok'] + if directory: + assert not list(target.iterdir()) + else: + assert target.read_bytes() == b'outside unchanged' + assert link.is_symlink() diff --git a/ruoyi-fastapi-backend/tests/config/test_lifecycle.py b/ruoyi-fastapi-backend/tests/config/test_lifecycle.py index 8427ace..6a965e5 100644 --- a/ruoyi-fastapi-backend/tests/config/test_lifecycle.py +++ b/ruoyi-fastapi-backend/tests/config/test_lifecycle.py @@ -4,6 +4,7 @@ from types import SimpleNamespace from unittest.mock import AsyncMock import pytest +from sqlalchemy import Column, Integer, MetaData, Table from config.database import Base from config.lifecycle import init_create_table @@ -23,3 +24,20 @@ async def test_init_create_table_uses_registry_connection(monkeypatch: pytest.Mo await init_create_table(log_success_enabled=False) connection.run_sync.assert_awaited_once_with(Base.metadata.create_all) + + +@pytest.mark.asyncio +async def test_startup_excludes_artifact_tables_from_implicit_schema_creation(monkeypatch: pytest.MonkeyPatch) -> None: + metadata = MetaData() + platform_table = Table('platform', metadata, Column('id', Integer, primary_key=True)) + Table('signed_plugin_data', metadata, Column('id', Integer, primary_key=True), info={'plugin_artifact': 'demo'}) + connection = SimpleNamespace(run_sync=AsyncMock()) + + @asynccontextmanager + async def connection_context() -> AsyncGenerator[object, None]: + yield connection + + monkeypatch.setattr('config.lifecycle.DataSourceRegistry', SimpleNamespace(connection=connection_context)) + monkeypatch.setattr('config.lifecycle.Base', SimpleNamespace(metadata=metadata)) + await init_create_table(stage='plugin_entities', log_success_enabled=False) + connection.run_sync.assert_awaited_once_with(metadata.create_all, tables=[platform_table]) diff --git a/ruoyi-fastapi-backend/tests/module_admin/service/test_login_plugin_routes.py b/ruoyi-fastapi-backend/tests/module_admin/service/test_login_plugin_routes.py new file mode 100644 index 0000000..4897366 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/module_admin/service/test_login_plugin_routes.py @@ -0,0 +1,133 @@ +from unittest.mock import AsyncMock, patch + +import pytest +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from module_admin.dao.user_dao import UserDao +from module_admin.entity.do.menu_do import SysMenu +from module_admin.service.login_service import LoginService +from plugins.core.management.entity.do.models import SysPlugin, SysPluginMenu +from plugins.core.management.service.router_metadata import PluginRouterMetadataService + + +def _menu(menu_id: int, *, parent_id: int = 0, component: str = 'PluginFrame', menu_type: str = 'C') -> SysMenu: + return SysMenu( + menu_id=menu_id, + parent_id=parent_id, + menu_name=f'Menu {menu_id}', + order_num=menu_id, + path=f'unrelated-route-{menu_id}', + component=component, + query='{"pluginId":"untrusted-query-value"}', + is_frame=1, + is_cache=1, + menu_type=menu_type, + visible='0', + status='0', + ) + + +def _plugin(plugin_id: str, **overrides: object) -> SysPlugin: + values = { + 'plugin_id': plugin_id, + 'plugin_name': plugin_id, + 'version': '1.0.0', + 'installed_version': '1.0.0', + 'enabled': '0', + 'status': 'installed', + **overrides, + } + return SysPlugin(**values) + + +@pytest.mark.asyncio +async def test_plugin_metadata_uses_menu_ownership_for_nested_and_root_pages() -> None: + engine = create_async_engine('sqlite+aiosqlite:///:memory:') + try: + async with engine.begin() as connection: + await connection.run_sync(SysPlugin.__table__.create) + await connection.run_sync(SysPluginMenu.__table__.create) + session_factory = async_sessionmaker(engine) + async with session_factory() as db: + db.add(_plugin('demo')) + db.add_all( + [ + SysPluginMenu(plugin_id='demo', menu_id=11, menu_key='root'), + SysPluginMenu(plugin_id='demo', menu_id=12, menu_key='nested'), + SysPluginMenu(plugin_id='demo', menu_id=14, menu_key='source'), + ] + ) + await db.commit() + menus = [ + _menu(10, component='Layout', menu_type='M'), + _menu(11), + _menu(12, parent_id=10), + _menu(13, parent_id=10), + _menu(14, parent_id=10, component='plugin:demo/source'), + ] + with patch.object(UserDao, 'get_user_by_id', new=AsyncMock(return_value={'user_menu_info': menus})): + routers = await LoginService.get_current_user_routers(1, db) + + nested = routers[0]['children'] + assert nested[0]['component'] == 'PluginFrame' + assert nested[0]['meta']['pluginId'] == 'demo' + assert 'pluginId' not in nested[1]['meta'] + assert 'pluginId' not in nested[2]['meta'] + assert 'pluginId' not in routers[0]['meta'] + root = routers[1] + assert root['component'] == 'Layout' + assert root['meta'] is None + assert root['children'][0]['component'] == 'PluginFrame' + assert root['children'][0]['meta']['pluginId'] == 'demo' + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_menu_ownership_rejects_inactive_uninstalled_and_ambiguous_plugins() -> None: + engine = create_async_engine('sqlite+aiosqlite:///:memory:') + try: + async with engine.begin() as connection: + await connection.run_sync(SysPlugin.__table__.create) + await connection.run_sync(SysPluginMenu.__table__.create) + session_factory = async_sessionmaker(engine) + async with session_factory() as db: + db.add_all( + [ + _plugin('ready'), + _plugin('disabled', enabled='1'), + _plugin('failed', status='error'), + _plugin('uninstalled', installed_version=None, status='discovered'), + _plugin('other'), + _plugin('upgrading', status='pending_upgrade'), + ] + ) + db.add_all( + [ + SysPluginMenu(plugin_id=plugin_id, menu_id=menu_id, menu_key=str(menu_id)) + for menu_id, plugin_id in ( + (1, 'ready'), + (2, 'disabled'), + (3, 'failed'), + (4, 'uninstalled'), + (5, 'ready'), + (5, 'other'), + (6, 'upgrading'), + (99, 'ready'), + ) + ] + ) + await db.commit() + ownership = await PluginRouterMetadataService.get_menu_plugin_ids(db, [_menu(i) for i in range(1, 8)]) + + assert ownership == {1: 'ready', 6: 'upgrading'} + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_ordinary_menus_do_not_query_plugin_tables() -> None: + query_db = AsyncMock() + + assert await PluginRouterMetadataService.get_menu_plugin_ids(query_db, [_menu(1, component='system/user')]) == {} + query_db.execute.assert_not_awaited() diff --git a/ruoyi-fastapi-backend/tests/module_plugin/controller/test_plugin_diagnostics_controller.py b/ruoyi-fastapi-backend/tests/module_plugin/controller/test_plugin_diagnostics_controller.py new file mode 100644 index 0000000..9bed857 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/module_plugin/controller/test_plugin_diagnostics_controller.py @@ -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'} diff --git a/ruoyi-fastapi-backend/tests/module_plugin/controller/test_plugin_metrics_controller.py b/ruoyi-fastapi-backend/tests/module_plugin/controller/test_plugin_metrics_controller.py new file mode 100644 index 0000000..644214b --- /dev/null +++ b/ruoyi-fastapi-backend/tests/module_plugin/controller/test_plugin_metrics_controller.py @@ -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() diff --git a/ruoyi-fastapi-backend/tests/module_plugin/controller/test_plugin_release_controller.py b/ruoyi-fastapi-backend/tests/module_plugin/controller/test_plugin_release_controller.py new file mode 100644 index 0000000..7a3057d --- /dev/null +++ b/ruoyi-fastapi-backend/tests/module_plugin/controller/test_plugin_release_controller.py @@ -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 diff --git a/ruoyi-fastapi-backend/tests/plugins/core/artifacts/conftest.py b/ruoyi-fastapi-backend/tests/plugins/core/artifacts/conftest.py new file mode 100644 index 0000000..7002fa1 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/artifacts/conftest.py @@ -0,0 +1,53 @@ +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey + +from plugins.core.artifacts import build_artifact + + +@pytest.fixture +def artifact_source(tmp_path: Path) -> Path: + """构造包含部署文件和禁止导入哨兵的临时插件目录。""" + source = tmp_path / 'artifact_demo' + source.mkdir() + (source / 'plugin.yaml').write_text( + 'manifestVersion: 2\n' + 'id: artifact_demo\n' + 'name: Artifact demonstration\n' + 'version: 1.0.0\n' + 'backend:\n' + ' runtime: python\n' + ' integration: router\n' + ' module: plugins.artifact_demo\n' + ' entrypoint: plugins.artifact_demo:create_plugin\n' + 'frontend:\n' + ' delivery:\n' + ' type: none\n', + encoding='utf-8', + ) + (source / '__init__.py').write_text('raise RuntimeError("artifact code must never execute")\n', encoding='utf-8') + (source / 'LICENSE').write_text('Temporary test fixture.\n', encoding='utf-8') + (source / 'sql').mkdir() + (source / 'sql' / 'schema.sql').write_text('SELECT 1;\n', encoding='utf-8') + return source + + +@pytest.fixture +def signing_key() -> Ed25519PrivateKey: + """生成仅用于当前测试的临时 Ed25519 私钥。""" + return Ed25519PrivateKey.generate() + + +@pytest.fixture +def trusted_keys(signing_key: Ed25519PrivateKey) -> dict[str, Ed25519PublicKey]: + """提供与临时签名私钥对应的宿主可信公钥映射。""" + return {'publisher': signing_key.public_key()} + + +@pytest.fixture +def artifact_path(tmp_path: Path, artifact_source: Path, signing_key: Ed25519PrivateKey) -> Path: + """构建供验证和存储测试使用的签名制品。""" + output = tmp_path / 'sample.rpk' + build_artifact(artifact_source, output, signing_key, 'publisher') + return output diff --git a/ruoyi-fastapi-backend/tests/plugins/core/artifacts/test_package.py b/ruoyi-fastapi-backend/tests/plugins/core/artifacts/test_package.py new file mode 100644 index 0000000..ffe1306 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/artifacts/test_package.py @@ -0,0 +1,453 @@ +import base64 +import hashlib +import json +import os +import stat +import struct +import sys +from dataclasses import replace +from pathlib import Path +from zipfile import ZIP_BZIP2, ZIP_STORED, ZipFile, ZipInfo + +import pytest +import yaml +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey + +from plugins.core.artifacts import ArtifactLimits, build_artifact, verify_artifact +from plugins.core.artifacts import package as artifact_package +from plugins.core.artifacts.schema import ArtifactMetadata, safe_artifact_path, strict_json + +EXPECTED_ARTIFACT_FILE_COUNT = 4 + + +def archive_files(path: Path) -> dict[str, bytes]: + """读取测试容器中的全部文件,供定向篡改用例使用。""" + with ZipFile(path) as archive: + return {name: archive.read(name) for name in archive.namelist()} + + +def write_archive(path: Path, files: dict[str, bytes], compression: int = ZIP_STORED) -> Path: + """按测试给定内容重建 ZIP 容器。""" + with ZipFile(path, 'w', compression=compression) as archive: + for name, data in files.items(): + archive.writestr(name, data) + return path + + +def signed_files(artifact_path: Path, signing_key: Ed25519PrivateKey, *, manifest: bytes) -> dict[str, bytes]: + """生成测试所需的规范元数据及有效签名文档。""" + files = archive_files(artifact_path) + name = 'payload/artifact_demo/plugin.yaml' + files[name] = manifest + metadata = json.loads(files['artifacts.json']) + for item in metadata['files']: + if item['path'] == name: + item.update(size=len(manifest), sha256=hashlib.sha256(manifest).hexdigest()) + canonical = ArtifactMetadata.model_validate(metadata).canonical_bytes() + files['artifacts.json'] = canonical + signature = json.loads(files['signature.json']) + signature['signature'] = base64.b64encode(signing_key.sign(canonical)).decode() + files['signature.json'] = json.dumps(signature).encode() + return files + + +def test_roundtrip_is_static_and_canonical( + artifact_source: Path, + artifact_path: Path, + tmp_path: Path, + signing_key: Ed25519PrivateKey, + trusted_keys: dict[str, Ed25519PublicKey], +) -> None: + """验证构建和校验保持规范摘要且不导入插件代码。""" + first = verify_artifact(artifact_path, trusted_keys) + second = build_artifact(artifact_source, tmp_path / 'second.rpk', signing_key, 'publisher') + assert first.digest == second.digest == hashlib.sha256(first.metadata.canonical_bytes()).hexdigest() + assert first.plugin_id == 'artifact_demo' + assert first.version == '1.0.0' + assert first.key_id == 'publisher' + assert len(first.files) == EXPECTED_ARTIFACT_FILE_COUNT + assert 'plugins.artifact_demo' not in sys.modules + files = archive_files(artifact_path) + metadata = json.loads(files['artifacts.json']) + files['artifacts.json'] = json.dumps(dict(reversed(list(metadata.items()))), indent=4).encode() + reformatted = write_archive(tmp_path / 'reformatted.rpk', files) + assert verify_artifact(reformatted, trusted_keys).digest == first.digest + + +@pytest.mark.parametrize('encoding', ['raw', 'base64', 'pem', 'object']) +def test_external_public_key_encodings( + artifact_path: Path, + signing_key: Ed25519PrivateKey, + encoding: str, +) -> None: + """验证宿主可信公钥支持规定的输入编码。""" + key = signing_key.public_key() + encodings = { + 'raw': key.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw), + 'base64': base64.b64encode( + key.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + ).decode(), + 'pem': key.public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo).decode(), + 'object': key, + } + assert verify_artifact(artifact_path, {'publisher': encodings[encoding]}).key_id == 'publisher' + + +@pytest.mark.parametrize('encoding', ['raw', 'base64', 'pem']) +def test_private_key_encodings_are_only_used_for_signing( + artifact_source: Path, + tmp_path: Path, + signing_key: Ed25519PrivateKey, + encoding: str, +) -> None: + """验证不同私钥编码可用于签名且不会泄露到制品。""" + raw = signing_key.private_bytes( + serialization.Encoding.Raw, serialization.PrivateFormat.Raw, serialization.NoEncryption() + ) + encodings = { + 'raw': raw, + 'base64': base64.b64encode(raw).decode(), + 'pem': signing_key.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption() + ), + } + result = build_artifact(artifact_source, tmp_path / 'encoded.rpk', encodings[encoding], 'publisher') + assert set(archive_files(result.artifact_path)) == {'artifacts.json', 'signature.json'} | { + item.path for item in result.files + } + + +@pytest.mark.parametrize('keys', ['missing', 'wrong']) +def test_untrusted_or_wrong_key_rejected(artifact_path: Path, keys: str) -> None: + """验证未信任或错误的签名密钥无法通过校验。""" + trust = {} if keys == 'missing' else {'publisher': Ed25519PrivateKey.generate().public_key()} + with pytest.raises(ValueError, match=r'密钥|签名验证失败'): + verify_artifact(artifact_path, trust) + + +@pytest.mark.parametrize( + 'mutation', ['payload', 'metadata', 'missing', 'extra', 'signature', 'algorithm', 'self-trust'] +) +def test_tampered_archives_rejected( + artifact_path: Path, + trusted_keys: dict[str, Ed25519PublicKey], + tmp_path: Path, + mutation: str, +) -> None: + """验证元数据、签名及文件内容的篡改均被拒绝。""" + files = archive_files(artifact_path) + if mutation == 'payload': + files['payload/artifact_demo/LICENSE'] = b'Forgery of equal size.\n' + elif mutation == 'metadata': + metadata = json.loads(files['artifacts.json']) + metadata['version'] = '1.0.1' + files['artifacts.json'] = json.dumps(metadata).encode() + elif mutation == 'missing': + del files['payload/artifact_demo/LICENSE'] + elif mutation == 'extra': + files['payload/artifact_demo/extra.txt'] = b'not signed' + else: + signature = json.loads(files['signature.json']) + if mutation == 'signature': + signature['signature'] = base64.b64encode(b'x' * 64).decode() + elif mutation == 'algorithm': + signature['algorithm'] = 'RSA' + else: + signature['publicKey'] = base64.b64encode(b'x' * 32).decode() + files['signature.json'] = json.dumps(signature).encode() + with pytest.raises(ValueError): + verify_artifact(write_archive(tmp_path / 'tampered.rpk', files), trusted_keys) + + +@pytest.mark.parametrize( + 'raw', [b'{"a":1,"a":2}', b'{"a":{"b":1,"b":2}}', b'{"a":NaN}', b'{"a":Infinity}', b'[]', b'\xff'] +) +def test_strict_json_rejects_ambiguous_documents(raw: bytes) -> None: + """验证 JSON 重复键和非法常量不能进入签名文档。""" + with pytest.raises(ValueError): + strict_json(raw) + + +@pytest.mark.parametrize('value', [True, 1.0, '1', 2]) +def test_metadata_version_is_exact_integer_one(artifact_path: Path, value: object) -> None: + """验证协议版本只接受整数1。""" + metadata = json.loads(archive_files(artifact_path)['artifacts.json']) + metadata['schemaVersion'] = value + with pytest.raises(ValueError): + ArtifactMetadata.model_validate(metadata) + + +@pytest.mark.parametrize('field,value', [('size', True), ('size', -1), ('sha256', 'F' * 64), ('unexpected', 1)]) +def test_file_records_reject_coercion_and_unknown_fields(artifact_path: Path, field: str, value: object) -> None: + """验证文件记录不接受隐式类型转换或未知字段。""" + metadata = json.loads(archive_files(artifact_path)['artifacts.json']) + metadata['files'][0][field] = value + with pytest.raises(ValueError): + ArtifactMetadata.model_validate(metadata) + + +@pytest.mark.parametrize('value', ['1.0.RC1', '1.0.0.', 'a' * 33, '../1', '1/2', 'con']) +def test_versions_are_portable_and_database_bounded(artifact_path: Path, value: str) -> None: + """验证版本字符串满足路径可移植性与数据库长度限制。""" + metadata = json.loads(archive_files(artifact_path)['artifacts.json']) + metadata['version'] = value + with pytest.raises(ValueError): + ArtifactMetadata.model_validate(metadata) + + +@pytest.mark.parametrize( + 'path', + [ + '/absolute', + '../escape', + 'a/../../b', + 'a\\b', + 'C:/escape', + 'a/CON', + 'a/nul.txt', + 'a:stream', + 'a//b', + 'a/./b', + 'a/end.', + 'a/end ', + 'a/\x00b', + 'a/\x1fb', + 'a/\x7fb', + 'a/e\u0301', + 'a/' + 'b' * 256, + ], +) +def test_paths_reject_cross_platform_ambiguity(path: str) -> None: + """验证路径校验拒绝跨平台歧义和目录穿越。""" + with pytest.raises(ValueError, match='不安全路径'): + safe_artifact_path(path) + + +@pytest.mark.parametrize( + 'names', + [ + ('../escape',), + ('/absolute',), + ('a\\b',), + ('a:stream',), + ('a/CON',), + ('Foo/x', 'foo/y'), + ('a', 'a/b'), + ('a/',), + ('payload//',), + ], +) +def test_zip_inventory_rejects_unsafe_extra_paths( + artifact_path: Path, + tmp_path: Path, + trusted_keys: dict[str, Ed25519PublicKey], + names: tuple[str, ...], +) -> None: + """验证 ZIP 中额外的不安全路径不能绕过清单检查。""" + files = archive_files(artifact_path) + files.update(dict.fromkeys(names, b'')) + with pytest.raises(ValueError): + verify_artifact(write_archive(tmp_path / 'unsafe.rpk', files), trusted_keys) + + +@pytest.mark.parametrize( + 'mode,windows_attributes', [(stat.S_IFLNK | 0o777, 0), (stat.S_IFIFO | 0o600, 0), (stat.S_IFREG | 0o600, 0x400)] +) +def test_zip_special_files_rejected( + artifact_path: Path, + tmp_path: Path, + trusted_keys: dict[str, Ed25519PublicKey], + mode: int, + windows_attributes: int, +) -> None: + """验证 ZIP 不接受链接、重解析点或特殊文件。""" + target = tmp_path / 'special.rpk' + files = archive_files(artifact_path) + with ZipFile(target, 'w') as archive: + for name, data in files.items(): + item = ZipInfo(name) + item.create_system = 3 + item.external_attr = (mode << 16) | windows_attributes + archive.writestr(item, data) + with pytest.raises(ValueError, match='特殊文件'): + verify_artifact(target, trusted_keys) + + +def test_duplicate_zip_member_rejected(artifact_path: Path, trusted_keys: dict[str, Ed25519PublicKey]) -> None: + """验证重复的压缩条目被拒绝。""" + with ZipFile(artifact_path, 'a') as archive, pytest.warns(UserWarning, match='Duplicate'): + archive.writestr('payload/artifact_demo/LICENSE', b'duplicate') + with pytest.raises(ValueError, match='重复'): + verify_artifact(artifact_path, trusted_keys) + + +def test_zip_null_name_rejected(artifact_path: Path, trusted_keys: dict[str, Ed25519PublicKey]) -> None: + """验证包含空字符导致截断的文件名被拒绝。""" + artifact_path.write_bytes(artifact_path.read_bytes().replace(b'/LICENSE', b'/LI\x00ENSE')) + with pytest.raises(ValueError, match='截断'): + verify_artifact(artifact_path, trusted_keys) + + +def test_zip_unsupported_compression_rejected( + artifact_path: Path, + trusted_keys: dict[str, Ed25519PublicKey], + tmp_path: Path, +) -> None: + """验证不支持的压缩算法被拒绝。""" + target = write_archive(tmp_path / 'bzip2.rpk', archive_files(artifact_path), ZIP_BZIP2) + with pytest.raises(ValueError, match='压缩算法'): + verify_artifact(target, trusted_keys) + + +def test_zip_encryption_flag_rejected(artifact_path: Path, trusted_keys: dict[str, Ed25519PublicKey]) -> None: + """验证设置加密标记的压缩条目被拒绝。""" + data = bytearray(artifact_path.read_bytes()) + central = data.index(b'PK\x01\x02') + struct.pack_into(' None: + """验证 CRC 错误会使制品校验失败。""" + target = write_archive(tmp_path / 'crc.rpk', archive_files(artifact_path)) + with ZipFile(target) as archive: + entry = archive.getinfo('payload/artifact_demo/LICENSE') + offset = entry.header_offset + 30 + len(entry.filename.encode()) + len(entry.extra) + data = bytearray(target.read_bytes()) + data[offset] ^= 1 + target.write_bytes(data) + with pytest.raises(ValueError, match='CRC'): + verify_artifact(target, trusted_keys) + + +@pytest.mark.parametrize( + 'limit', + [ + 'max_files', + 'max_file_bytes', + 'max_total_bytes', + 'max_archive_bytes', + 'max_metadata_bytes', + 'max_signature_bytes', + 'max_manifest_bytes', + 'max_central_directory_bytes', + 'max_compression_ratio', + ], +) +def test_all_resource_limits_apply(artifact_path: Path, trusted_keys: dict[str, Ed25519PublicKey], limit: str) -> None: + """验证容器、元数据及展开文件的各项资源上限生效。""" + with pytest.raises(ValueError): + verify_artifact(artifact_path, trusted_keys, limits=replace(ArtifactLimits(), **{limit: 1})) + + +@pytest.mark.parametrize('mutation', ['underreported', 'directory-size', 'zip64', 'multidisk', 'truncated']) +def test_central_directory_is_bounded_before_zipfile_allocates( + artifact_path: Path, + trusted_keys: dict[str, Ed25519PublicKey], + mutation: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """验证在 ZipFile 分配条目对象前限制中央目录和实际文件数量。""" + data = bytearray(artifact_path.read_bytes()) + end = len(data) - 22 + limits = ArtifactLimits() + if mutation == 'underreported': + struct.pack_into(' None: + pytest.fail('ZipFile must not allocate an unbounded inventory before preflight') + + monkeypatch.setattr(artifact_package, 'ZipFile', allocation_forbidden) + with pytest.raises(ValueError): + verify_artifact(artifact_path, trusted_keys, limits=limits) + + +@pytest.mark.parametrize('mutation', ['v1', 'source', 'identity', 'version']) +def test_signed_manifest_must_match_supported_deployment_protocol( + artifact_path: Path, + signing_key: Ed25519PrivateKey, + trusted_keys: dict[str, Ed25519PublicKey], + tmp_path: Path, + mutation: str, +) -> None: + """验证签名清单的身份与版本匹配且采用受支持的交付协议。""" + manifest = yaml.safe_load(archive_files(artifact_path)['payload/artifact_demo/plugin.yaml']) + if mutation == 'v1': + manifest['manifestVersion'] = 1 + elif mutation == 'source': + manifest['frontend'] = {'delivery': {'type': 'source'}} + elif mutation == 'identity': + manifest['id'] = 'another_plugin' + manifest['backend']['module'] = 'plugins.another_plugin' + manifest['backend']['entrypoint'] = 'plugins.another_plugin:create_plugin' + else: + manifest['version'] = '2.0.0' + files = signed_files(artifact_path, signing_key, manifest=yaml.safe_dump(manifest).encode()) + with pytest.raises(ValueError): + verify_artifact(write_archive(tmp_path / 'protocol.rpk', files), trusted_keys) + + +@pytest.mark.parametrize( + 'filename', + [ + 'Cargo.toml', + 'web/package.json', + 'native/src/lib.rs', + 'web/src/App.vue', + '.env', + 'node_modules/a.js', + '.git/config', + ], +) +def test_builder_rejects_source_projects( + artifact_source: Path, tmp_path: Path, signing_key: Ed25519PrivateKey, filename: str +) -> None: + """验证构建器拒绝将源码工程文件打入部署制品。""" + extra = artifact_source / filename + extra.parent.mkdir(parents=True, exist_ok=True) + extra.write_bytes(b'source') + with pytest.raises(ValueError, match='预构建'): + build_artifact(artifact_source, tmp_path / 'invalid.rpk', signing_key, 'publisher') + assert not (tmp_path / 'invalid.rpk').exists() + + +def test_builder_ignores_python_caches(artifact_source: Path, tmp_path: Path, signing_key: Ed25519PrivateKey) -> None: + """验证构建器只忽略允许的 Python 与测试缓存。""" + (artifact_source / '__pycache__').mkdir() + (artifact_source / '__pycache__' / 'old.pyc').write_bytes(b'cache') + result = build_artifact(artifact_source, tmp_path / 'no-cache.rpk', signing_key, 'publisher') + assert all('__pycache__' not in item.path and not item.path.endswith('.pyc') for item in result.files) + + +def test_builder_never_overwrites_or_writes_inside_source( + artifact_source: Path, + artifact_path: Path, + signing_key: Ed25519PrivateKey, +) -> None: + """验证构建器不覆盖输出且不向源目录内写入制品。""" + before = artifact_path.read_bytes() + with pytest.raises(FileExistsError): + build_artifact(artifact_source, artifact_path, signing_key, 'publisher') + assert artifact_path.read_bytes() == before + with pytest.raises(ValueError, match='源插件目录'): + build_artifact(artifact_source, artifact_source / 'self.rpk', signing_key, 'publisher') + + +def test_builder_rejects_hardlinks(artifact_source: Path, tmp_path: Path, signing_key: Ed25519PrivateKey) -> None: + """验证构建器拒绝已建立额外硬链接的部署文件。""" + os.link(artifact_source / 'LICENSE', tmp_path / 'linked-license') + with pytest.raises(ValueError, match='硬链接'): + build_artifact(artifact_source, tmp_path / 'hardlink.rpk', signing_key, 'publisher') diff --git a/ruoyi-fastapi-backend/tests/plugins/core/artifacts/test_store.py b/ruoyi-fastapi-backend/tests/plugins/core/artifacts/test_store.py new file mode 100644 index 0000000..e94d79d --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/artifacts/test_store.py @@ -0,0 +1,357 @@ +import multiprocessing +import os +import sys +from concurrent.futures import ProcessPoolExecutor +from pathlib import Path +from typing import Any + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey + +from plugins.core.artifacts.package import verify_artifact +from plugins.core.artifacts.schema import StoredArtifact, VerifiedArtifact +from plugins.core.artifacts.store import ArtifactStore + + +def _parallel_import(root: str, artifact_path: str, public_key: bytes, gate: Any) -> tuple[str, str]: + """在独立进程中同步校验起点,使并发导入争用同一制品目标。""" + checked = False + + def check_candidate(candidate: StoredArtifact) -> None: + nonlocal checked + assert (candidate.plugin_path / 'plugin.yaml').is_file() + if not checked: + checked = True + gate.wait(timeout=30) + + stored = ArtifactStore(root).import_artifact( + artifact_path, {'publisher': public_key}, validate_candidate=check_candidate + ) + return str(stored.root_path), stored.digest + + +def _assert_staging_empty(root: Path) -> None: + """检查本次导入没有遗留暂存目录。""" + staging = root / '.staging' + assert staging.is_dir() + assert list(staging.iterdir()) == [] + + +def _file_snapshot(root: Path) -> dict[str, tuple[bytes, int]]: + """记录存储文件内容和修改时间以比较幂等导入结果。""" + return { + path.relative_to(root).as_posix(): (path.read_bytes(), path.stat().st_mtime_ns) + for path in root.rglob('*') + if path.is_file() + } + + +def test_roundtrip_verifies_root_plugin_path_and_digest_without_importing_code( + tmp_path: Path, + artifact_path: Path, + artifact_source: Path, + trusted_keys: dict[str, Ed25519PublicKey], +) -> None: + """验证不同定位方式均重新校验制品且不导入插件代码。""" + store = ArtifactStore(tmp_path / 'store') + expected = verify_artifact(artifact_path, trusted_keys) + stored = store.import_artifact(artifact_path, trusted_keys) + + assert stored.root_path == store.root / expected.plugin_id / expected.version / expected.digest + assert stored.plugin_path == stored.root_path / 'payload' / expected.plugin_id + assert (stored.plugin_path / '__init__.py').read_bytes() == (artifact_source / '__init__.py').read_bytes() + assert f'plugins.{expected.plugin_id}' not in sys.modules + for reference in ( + stored.root_path, + stored.plugin_path, + stored.digest, + stored.root_path.relative_to(store.root), + stored.plugin_path.relative_to(store.root), + ): + verified = store.verify_stored(reference, trusted_keys) + assert verified.root_path == stored.root_path + assert verified.plugin_path == stored.plugin_path + assert verified.digest == expected.digest + _assert_staging_empty(store.root) + + +def test_repeated_import_keeps_existing_bytes_and_modification_times( + tmp_path: Path, artifact_path: Path, trusted_keys: dict[str, Ed25519PublicKey] +) -> None: + """验证重复导入保持已有文件内容和修改时间。""" + store = ArtifactStore(tmp_path / 'store') + first = store.import_artifact(artifact_path, trusted_keys) + before = _file_snapshot(first.root_path) + checked_paths: list[Path] = [] + + def check_candidate(candidate: StoredArtifact) -> None: + checked_paths.append(candidate.root_path) + + second = store.import_artifact(artifact_path, trusted_keys, validate_candidate=check_candidate) + + assert second.root_path == first.root_path + assert _file_snapshot(first.root_path) == before + staged_check, existing_check = checked_paths + assert existing_check == first.root_path + assert staged_check != first.root_path + _assert_staging_empty(store.root) + + +def test_corrupted_existing_object_is_rejected_without_overwrite( + tmp_path: Path, artifact_path: Path, trusted_keys: dict[str, Ed25519PublicKey] +) -> None: + """验证损坏的已有对象被拒绝且不会被自动覆盖。""" + store = ArtifactStore(tmp_path / 'store') + stored = store.import_artifact(artifact_path, trusted_keys) + payload = stored.plugin_path / '__init__.py' + payload.write_bytes(b'corrupted existing object') + before = _file_snapshot(stored.root_path) + + with pytest.raises(ValueError, match=r'SHA256|大小'): + store.import_artifact(artifact_path, trusted_keys) + + assert _file_snapshot(stored.root_path) == before + _assert_staging_empty(store.root) + + +def test_current_trust_is_required_for_existing_objects_and_reimports( + tmp_path: Path, artifact_path: Path, trusted_keys: dict[str, Ed25519PublicKey] +) -> None: + """验证已有对象读取和重复导入均遵循当前信任配置。""" + store = ArtifactStore(tmp_path / 'store') + stored = store.import_artifact(artifact_path, trusted_keys) + before = _file_snapshot(stored.root_path) + + with pytest.raises(ValueError, match=r'未受信任|撤销'): + store.verify_stored(stored.digest, {}) + with pytest.raises(ValueError, match=r'未受信任|撤销'): + store.import_artifact(artifact_path, {}) + + assert _file_snapshot(stored.root_path) == before + _assert_staging_empty(store.root) + + +@pytest.mark.parametrize('failure', ['reject', 'raise', 'mutate']) +def test_candidate_failure_never_publishes_or_removes_unrelated_staging( + tmp_path: Path, + artifact_path: Path, + trusted_keys: dict[str, Ed25519PublicKey], + failure: str, +) -> None: + """验证候选检查失败不会发布制品或删除无关暂存目录。""" + store = ArtifactStore(tmp_path / 'store') + unrelated = store.root / '.staging' / 'unrelated-import' + unrelated.mkdir(parents=True) + sentinel = unrelated / 'keep.txt' + sentinel.write_text('keep', encoding='utf-8') + expected = verify_artifact(artifact_path, trusted_keys) + + def check_candidate(candidate: StoredArtifact) -> bool: + if failure == 'raise': + raise RuntimeError('platform validation failed') + if failure == 'mutate': + (candidate.plugin_path / '__init__.py').write_bytes(b'mutated by validator') + return True + return False + + error = RuntimeError if failure == 'raise' else ValueError + with pytest.raises(error): + store.import_artifact(artifact_path, trusted_keys, validate_candidate=check_candidate) + + assert not (store.root / expected.plugin_id / expected.version / expected.digest).exists() + assert list((store.root / '.staging').iterdir()) == [unrelated] + assert sentinel.read_text(encoding='utf-8') == 'keep' + + +def test_async_candidate_validation_is_rejected_instead_of_being_skipped( + tmp_path: Path, + artifact_path: Path, + trusted_keys: dict[str, Ed25519PublicKey], + recwarn: pytest.WarningsRecorder, +) -> None: + """验证异步候选回调会被明确拒绝。""" + store = ArtifactStore(tmp_path / 'store') + executed = False + + async def check_candidate(candidate: StoredArtifact) -> bool: + nonlocal executed + executed = True + return False + + with pytest.raises(TypeError, match='同步'): + store.import_artifact(artifact_path, trusted_keys, validate_candidate=check_candidate) + + assert not executed + assert not any(issubclass(warning.category, RuntimeWarning) for warning in recwarn) + assert not list(store.root.glob('*/*/' + '?' * 64)) + _assert_staging_empty(store.root) + + +def test_extraction_failure_cleans_only_its_private_directory( + tmp_path: Path, + artifact_path: Path, + trusted_keys: dict[str, Ed25519PublicKey], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """验证展开失败仅清理本次导入独占的目录。""" + store = ArtifactStore(tmp_path / 'store') + sentinel = tmp_path / 'keep.txt' + sentinel.write_text('keep', encoding='utf-8') + + def fail_extract(snapshot: Path, candidate: Path, verified: VerifiedArtifact) -> None: + assert snapshot.is_file() + assert verified.plugin_id + candidate.mkdir() + (candidate / 'partial.txt').write_bytes(b'partial') + raise OSError('disk full') + + monkeypatch.setattr(store, '_extract', fail_extract) + with pytest.raises(OSError, match='disk full'): + store.import_artifact(artifact_path, trusted_keys) + + assert sentinel.read_text(encoding='utf-8') == 'keep' + _assert_staging_empty(store.root) + + +@pytest.mark.parametrize('mutation', ['extra_file', 'empty_directory', 'cache_file', 'missing_file']) +def test_stored_inventory_rejects_untracked_and_missing_content( + tmp_path: Path, + artifact_path: Path, + trusted_keys: dict[str, Ed25519PublicKey], + mutation: str, +) -> None: + """验证已存储目录拒绝新增、缺失或未登记内容。""" + store = ArtifactStore(tmp_path / 'store') + stored = store.import_artifact(artifact_path, trusted_keys) + if mutation == 'extra_file': + (stored.plugin_path / 'untracked.py').write_bytes(b'pass') + elif mutation == 'empty_directory': + (stored.plugin_path / 'unexpected').mkdir() + elif mutation == 'cache_file': + cache = stored.plugin_path / '__pycache__' + cache.mkdir() + (cache / 'module.pyc').write_bytes(b'cache must not be ignored by stored verification') + else: + (stored.plugin_path / '__init__.py').unlink() + + with pytest.raises(ValueError, match=r'额外|缺失|多余'): + store.verify_stored(stored.root_path, trusted_keys) + + +def test_stored_files_cannot_gain_external_hardlinks( + tmp_path: Path, artifact_path: Path, trusted_keys: dict[str, Ed25519PublicKey] +) -> None: + """验证存储文件增加外部硬链接后无法继续通过校验。""" + store = ArtifactStore(tmp_path / 'store') + stored = store.import_artifact(artifact_path, trusted_keys) + payload = stored.plugin_path / '__init__.py' + external = tmp_path / 'external-hardlink.py' + try: + os.link(payload, external) + except OSError as exc: + pytest.skip(f'当前文件系统不支持创建硬链接:{exc}') + + with pytest.raises(ValueError, match=r'硬链接|链接'): + store.verify_stored(stored.root_path, trusted_keys) + assert external.read_bytes() == payload.read_bytes() + + +def test_import_verifies_the_private_snapshot_after_source_is_replaced( + tmp_path: Path, + artifact_path: Path, + trusted_keys: dict[str, Ed25519PublicKey], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """验证源容器替换后导入仍只使用先前建立的私有快照。""" + expected = verify_artifact(artifact_path, trusted_keys) + store = ArtifactStore(tmp_path / 'store') + snapshot = store._snapshot + + def snapshot_then_replace_source(source: Path, target: Path) -> None: + snapshot(source, target) + source.write_bytes(b'original upload was replaced after snapshot') + + monkeypatch.setattr(store, '_snapshot', snapshot_then_replace_source) + stored = store.import_artifact(artifact_path, trusted_keys) + + assert stored.digest == expected.digest + assert store.verify_stored(stored.root_path, trusted_keys).digest == expected.digest + with pytest.raises(ValueError, match='ZIP'): + verify_artifact(artifact_path, trusted_keys) + _assert_staging_empty(store.root) + + +def test_store_rejects_outside_paths_and_wrong_digest_layout( + tmp_path: Path, artifact_path: Path, trusted_keys: dict[str, Ed25519PublicKey] +) -> None: + """验证存储拒绝外部路径及摘要目录布局错误。""" + store = ArtifactStore(tmp_path / 'store') + stored = store.import_artifact(artifact_path, trusted_keys) + with pytest.raises(ValueError, match='不属于'): + store.verify_stored(tmp_path / 'outside', trusted_keys) + + wrong_name = '0' * 64 if stored.digest != '0' * 64 else '1' * 64 + renamed = stored.root_path.with_name(wrong_name) + stored.root_path.rename(renamed) + with pytest.raises(ValueError, match=r'身份|digest'): + store.verify_stored(renamed, trusted_keys) + + +def test_concurrent_processes_publish_one_complete_object( + tmp_path: Path, + artifact_path: Path, + signing_key: Ed25519PrivateKey, + trusted_keys: dict[str, Ed25519PublicKey], +) -> None: + """验证多个进程并发导入只发布一个完整对象。""" + root = tmp_path / 'store' + key_bytes = signing_key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + context = multiprocessing.get_context('spawn') + with context.Manager() as manager: + gate = manager.Barrier(2) + with ProcessPoolExecutor(max_workers=2, mp_context=context) as executor: + futures = [ + executor.submit(_parallel_import, str(root), str(artifact_path), key_bytes, gate) for _ in range(2) + ] + results = [future.result(timeout=60) for future in futures] + + assert results[0] == results[1] + stored = ArtifactStore(root).verify_stored(results[0][1], trusted_keys) + assert str(stored.root_path) == results[0][0] + assert list((root / stored.plugin_id / stored.version).iterdir()) == [stored.root_path] + _assert_staging_empty(root) + + +def test_symlink_store_root_is_rejected(tmp_path: Path) -> None: + """验证存储根目录不能是符号链接。""" + real_root = tmp_path / 'real-store' + real_root.mkdir() + linked_root = tmp_path / 'linked-store' + try: + linked_root.symlink_to(real_root, target_is_directory=True) + except OSError as exc: + pytest.skip(f'当前环境无符号链接创建权限:{exc}') + + with pytest.raises(ValueError, match=r'链接|重解析'): + ArtifactStore(linked_root) + assert list(real_root.iterdir()) == [] + + +def test_stored_symlink_is_rejected_without_touching_external_target( + tmp_path: Path, artifact_path: Path, trusted_keys: dict[str, Ed25519PublicKey] +) -> None: + """验证制品内符号链接被拒绝且不会修改外部目标。""" + store = ArtifactStore(tmp_path / 'store') + stored = store.import_artifact(artifact_path, trusted_keys) + external = tmp_path / 'external.py' + external.write_bytes(b'external target') + linked = stored.plugin_path / 'external.py' + try: + linked.symlink_to(external) + except OSError as exc: + pytest.skip(f'当前环境无符号链接创建权限:{exc}') + + with pytest.raises(ValueError, match=r'链接|重解析'): + store.verify_stored(stored.root_path, trusted_keys) + assert external.read_bytes() == b'external target' diff --git a/ruoyi-fastapi-backend/tests/plugins/core/deployment/conftest.py b/ruoyi-fastapi-backend/tests/plugins/core/deployment/conftest.py new file mode 100644 index 0000000..2c49863 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/deployment/conftest.py @@ -0,0 +1,131 @@ +import base64 +import json +from collections.abc import AsyncIterator +from pathlib import Path + +import pytest +import pytest_asyncio +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from config.database import Base +from module_admin.entity.do.job_do import SysJob +from module_admin.entity.do.menu_do import SysMenu +from plugins.core.artifacts import build_artifact +from plugins.core.deployment.config import PluginDeploymentConfig +from plugins.core.management.entity.do.models import ( + SysPlugin, + SysPluginConfig, + SysPluginMenu, + SysPluginMigration, + SysPluginOperationLog, +) +from plugins.core.management.entity.do.release_models import SysPluginArtifact, SysPluginRelease, SysPluginWorker +from plugins.core.runtime.service.lifecycle_lock import NoopPluginLifecycleLock + + +@pytest.fixture(autouse=True) +def isolate_catalog_lifecycle_lock(monkeypatch: pytest.MonkeyPatch) -> None: + """ + 为临时数据库测试显式替换导入锁,避免连接开发环境 Redis。 + + :param monkeypatch: pytest 替换工具 + :return: None + """ + monkeypatch.setattr('plugins.core.deployment.catalog.RedisPluginLifecycleLock', NoopPluginLifecycleLock) + + +@pytest_asyncio.fixture +async def deployment_sessions(tmp_path: Path) -> AsyncIterator[async_sessionmaker[AsyncSession]]: + """创建包含发布及必要宿主表的临时 SQLite 会话工厂。""" + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "deployment.sqlite"}') + async with engine.begin() as connection: + await connection.run_sync( + Base.metadata.create_all, + tables=[ + item.__table__ + for item in ( + SysPlugin, + SysPluginArtifact, + SysPluginRelease, + SysPluginWorker, + SysPluginConfig, + SysPluginMenu, + SysPluginMigration, + SysPluginOperationLog, + SysMenu, + SysJob, + ) + ], + ) + try: + yield async_sessionmaker(engine, expire_on_commit=False) + finally: + await engine.dispose() + + +@pytest.fixture +def deployment_key() -> Ed25519PrivateKey: + """生成发布测试使用的临时签名私钥。""" + return Ed25519PrivateKey.generate() + + +@pytest.fixture +def deployment_config(tmp_path: Path, deployment_key: Ed25519PrivateKey) -> PluginDeploymentConfig: + """构造独立存储目录与受限发布者公钥配置。""" + backend = tmp_path / 'host' + (backend / 'plugins').mkdir(parents=True) + trust_file = tmp_path / 'trusted-publishers.json' + public_key = deployment_key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + trust_file.write_text( + json.dumps( + { + 'schemaVersion': 1, + 'keys': [ + { + 'keyId': 'publisher', + 'publicKey': base64.b64encode(public_key).decode(), + 'pluginIds': ['artifact_demo'], + } + ], + } + ), + encoding='utf-8', + ) + return PluginDeploymentConfig(backend, tmp_path / 'store', trust_file) + + +@pytest.fixture +def deployment_source(tmp_path: Path) -> Path: + """创建用于验证静态维护边界的临时部署目录。""" + source = tmp_path / 'artifact_demo' + source.mkdir() + (source / 'plugin.yaml').write_text( + 'manifestVersion: 2\n' + 'id: artifact_demo\n' + 'name: Signed deployment fixture\n' + 'version: 1.0.0\n' + 'backend:\n' + ' module: plugins.artifact_demo\n' + ' entrypoint: plugins.artifact_demo:create_plugin\n' + 'frontend:\n' + ' delivery:\n' + ' type: none\n', + encoding='utf-8', + ) + (source / '__init__.py').write_text( + 'raise RuntimeError("Static release checks must never import plugin code")\n' + 'def create_plugin(context):\n' + ' raise RuntimeError("The injected maintenance runtime owns lifecycle execution")\n', + encoding='utf-8', + ) + return source + + +@pytest.fixture +def deployment_archive(tmp_path: Path, deployment_source: Path, deployment_key: Ed25519PrivateKey) -> Path: + """构建发布服务测试使用的签名制品。""" + path = tmp_path / 'deployment.rpk' + build_artifact(deployment_source, path, deployment_key, 'publisher') + return path diff --git a/ruoyi-fastapi-backend/tests/plugins/core/deployment/test_convergence.py b/ruoyi-fastapi-backend/tests/plugins/core/deployment/test_convergence.py new file mode 100644 index 0000000..027e971 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/deployment/test_convergence.py @@ -0,0 +1,477 @@ +import asyncio +from copy import deepcopy +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock + +import pytest + +from plugins.core.deployment import convergence +from plugins.core.deployment.state import aggregate_release + +PLUGIN_ID = 'demo' +DIGEST = 'a' * 64 +GENERATION = 'b' * 32 +NOW = datetime(2026, 10, 8, tzinfo=timezone.utc) +WORKER = 'c' * 32 +OTHER = 'd' * 32 +EXTRA = 'e' * 32 +EXPECTED_WORKERS = 2 + + +def worker(worker_id: str, plugin_id: str = '__runtime__', **changes: object) -> dict: + """创建与数据库只读 status 一致的宿主或插件报告。""" + return { + 'workerId': worker_id, + 'pluginId': plugin_id, + 'state': 'ready', + 'heartbeatTime': NOW.isoformat(), + 'digest': DIGEST if plugin_id == PLUGIN_ID else None, + 'generation': GENERATION if plugin_id == PLUGIN_ID else None, + 'version': '1.0.0' if plugin_id == PLUGIN_ID else None, + 'error': None, + **changes, + } + + +def ready(*worker_ids: str) -> list[dict]: + """生成指定 worker 的宿主与插件配对就绪报告。""" + return [worker(worker_id, plugin_id) for worker_id in worker_ids for plugin_id in ('__runtime__', PLUGIN_ID)] + + +def payload( + reports: list[dict] | None = None, + *, + digest: str | None = DIGEST, + generation: str = GENERATION, + expected: int = 2, + enabled: bool = True, +) -> dict: + """使用既有聚合器生成读取时刻的摘要,后续测试单独推进返回时刻。""" + reports = reports or [] + summary = aggregate_release( + { + 'plugin_id': PLUGIN_ID, + 'target_digest': digest, + 'generation': generation, + 'expected_workers': expected, + 'prepare_status': 'prepared', + }, + [ + { + 'worker_id': item['workerId'], + 'plugin_id': item['pluginId'], + 'state': item['state'], + 'heartbeat_time': datetime.fromisoformat(item['heartbeatTime']), + 'artifact_digest': item['digest'], + 'generation': item['generation'], + } + for item in reports + ], + now=NOW, + enabled=enabled, + ).to_payload() + summary['enabled'] = enabled + return {'ok': True, 'releases': [summary], 'workers': deepcopy(reports)} + + +class Clock: + """推进轮询时钟而不执行真实等待,不替换 asyncio 事件循环自身时钟。""" + + def __init__(self) -> None: + self.now = 0.0 + self.delays: list[float] = [] + + def monotonic(self) -> float: + return self.now + + async def sleep(self, delay: float) -> None: + self.delays.append(delay) + self.now += delay + + +@pytest.fixture(autouse=True) +def clock(monkeypatch: pytest.MonkeyPatch) -> Clock: + """隔离单调时钟与返回时刻,确保超时测试不消耗真实秒数。""" + value = Clock() + monkeypatch.setattr(convergence, 'monotonic', value.monotonic) + monkeypatch.setattr(convergence, 'sleep', value.sleep) + monkeypatch.setattr(convergence.TimezoneUtil, 'utc_now', lambda: NOW) + return value + + +async def wait(reader: object, **changes: object) -> dict: + """使用固定身份调用被测等待器,同时允许单项参数边界覆盖。""" + options = { + 'plugin_id': PLUGIN_ID, + 'digest': DIGEST, + 'expected_generation': GENERATION, + 'expected_workers': 2, + 'worker_ttl_seconds': 60, + 'timeout_seconds': 10, + 'poll_interval_seconds': 2, + **changes, + } + return await convergence.wait_for_release(reader, **options) + + +@pytest.mark.asyncio +async def test_assertion_accepts_every_live_worker_and_capacity_lower_bound_without_writing(clock: Clock) -> None: + state = payload(ready(WORKER, OTHER, EXTRA)) + state['releases'][0].update(prepareStatus='failed', preparedDigest='f' * 64, lastError='private-password') + state['workers'][0]['error'] = 'private-worker-error' + reader = AsyncMock(return_value=state) + + result = await wait(reader, timeout_seconds=0) + + assert result['ok'] is True and result['reason'] == 'converged' + assert result['target'] == {'pluginId': PLUGIN_ID, 'digest': DIGEST, 'generation': GENERATION, 'expectedWorkers': 2} + assert result['operation'] == 'release_wait' and result['attempts'] == 1 + assert result['lastObserved']['liveWorkers'] > result['target']['expectedWorkers'] + assert result['lastObserved']['prepareStatus'] == 'failed' + assert 'private-' not in str(result) and 'lastError' not in result['lastObserved'] + assert set(result) == { + 'ok', + 'operation', + 'reason', + 'message', + 'target', + 'lastObserved', + 'attempts', + 'elapsedSeconds', + 'timeoutSeconds', + 'intervalSeconds', + } + reader.assert_awaited_once_with(PLUGIN_ID) + assert clock.delays == [] + + +@pytest.mark.asyncio +async def test_wait_reads_new_snapshots_until_all_workers_converge(clock: Clock) -> None: + reader = AsyncMock(side_effect=[payload(), payload(ready(WORKER)), payload(ready(WORKER, OTHER))]) + result = await wait(reader) + expected_attempts = 3 + expected_elapsed = 4 + assert result['reason'] == 'converged' and result['attempts'] == expected_attempts + assert result['elapsedSeconds'] == expected_elapsed + assert clock.delays == [2, 2] + assert all(call.args == (PLUGIN_ID,) for call in reader.await_args_list) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'reports', + [ + ready(WORKER), + [worker(WORKER, PLUGIN_ID), worker(OTHER, PLUGIN_ID)], + [*ready(WORKER, OTHER), worker(EXTRA), worker(EXTRA, PLUGIN_ID, generation='f' * 32)], + ], +) +async def test_assertion_rejects_short_capacity_orphans_and_extra_mismatched_live_worker(reports: list[dict]) -> None: + result = await wait(AsyncMock(return_value=payload(reports)), timeout_seconds=0) + assert result['reason'] == 'not_converged' and result['ok'] is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'changes', + [ + {'digest': 'f' * 64}, + {'generation': 'f' * 32}, + {'expected': 3}, + ], +) +async def test_changed_target_generation_or_recorded_capacity_is_never_followed(changes: dict, clock: Clock) -> None: + reader = AsyncMock(side_effect=[payload(**changes), payload(ready(WORKER, OTHER))]) + result = await wait(reader) + assert result['reason'] == 'target_changed' and result['ok'] is False + assert result['target']['digest'] == DIGEST and result['target']['generation'] == GENERATION + assert result['target']['expectedWorkers'] == EXPECTED_WORKERS + assert reader.await_count == 1 and not clock.delays + + +@pytest.mark.asyncio +@pytest.mark.parametrize('state', [{'ok': True, 'releases': [], 'workers': []}, payload(digest=None)]) +async def test_missing_release_or_target_stops_immediately(state: dict) -> None: + result = await wait(AsyncMock(return_value=state)) + assert result['reason'] == 'target_missing' and result['attempts'] == 1 + + +@pytest.mark.asyncio +async def test_disabled_target_cannot_pass_active_acceptance() -> None: + reports = ready(WORKER, OTHER) + for row in reports: + if row['pluginId'] == PLUGIN_ID: + row['state'] = 'stopped' + result = await wait(AsyncMock(return_value=payload(reports, enabled=False))) + assert result['reason'] == 'target_disabled' + assert result['lastObserved']['status'] == 'disabled' + + +@pytest.mark.asyncio +@pytest.mark.parametrize('failed_plugin_id', ['__runtime__', PLUGIN_ID]) +async def test_any_live_worker_failure_stops_even_when_summary_is_partial(failed_plugin_id: str) -> None: + reports = ready(WORKER, OTHER) + next(row for row in reports if row['workerId'] == OTHER and row['pluginId'] == failed_plugin_id)['state'] = 'failed' + result = await wait(AsyncMock(return_value=payload(reports))) + assert result['reason'] == 'worker_failed' + assert result['lastObserved']['status'] == 'partial' + + +@pytest.mark.asyncio +async def test_historical_stale_and_stopped_hosts_do_not_block_replaced_cluster() -> None: + reports = [ + *ready(WORKER, OTHER), + worker(EXTRA, heartbeatTime=(NOW - timedelta(seconds=61)).isoformat()), + worker(EXTRA, PLUGIN_ID), + ] + result = await wait(AsyncMock(return_value=payload(reports)), timeout_seconds=0) + assert result['reason'] == 'converged' and result['lastObserved']['staleWorkers'] == 1 + + +@pytest.mark.asyncio +async def test_status_that_expires_before_return_cannot_pass(monkeypatch: pytest.MonkeyPatch) -> None: + reports = ready(WORKER, OTHER) + for row in reports: + row['heartbeatTime'] = (NOW - timedelta(seconds=59)).isoformat() + state = payload(reports) + assert state['releases'][0]['status'] == 'active' + monkeypatch.setattr(convergence.TimezoneUtil, 'utc_now', lambda: NOW + timedelta(seconds=2)) + result = await wait(AsyncMock(return_value=state), timeout_seconds=0) + assert result['reason'] == 'not_converged' + assert result['lastObserved']['healthyWorkers'] == 0 + assert result['lastObserved']['missingWorkers'] == EXPECTED_WORKERS + assert result['lastObserved']['status'] == 'pending_restart' + + +@pytest.mark.asyncio +async def test_future_heartbeat_is_excluded_from_ready_capacity() -> None: + reports = ready(WORKER, OTHER) + for row in reports: + row['heartbeatTime'] = (NOW + timedelta(seconds=1)).isoformat() + result = await wait(AsyncMock(return_value=payload(reports)), timeout_seconds=0) + assert result['reason'] == 'not_converged' + assert result['lastObserved']['healthyWorkers'] == 0 + + +@pytest.mark.asyncio +async def test_database_failure_stops_with_last_observation_without_private_exception() -> None: + reader = AsyncMock(side_effect=[payload(ready(WORKER)), ConnectionError('private-db-password')]) + result = await wait(reader) + assert result['reason'] == 'unavailable' and result['attempts'] == len(reader.await_args_list) + assert result['lastObserved']['status'] == 'partial' + assert 'private-db-password' not in str(result) + + +@pytest.mark.asyncio +async def test_polling_stops_on_total_deadline_and_sleep_never_overshoots(clock: Clock) -> None: + result = await wait(AsyncMock(return_value=payload()), timeout_seconds=5) + expected_attempts = 3 + expected_elapsed = 5 + assert result['reason'] == 'timeout' and result['attempts'] == expected_attempts + assert result['elapsedSeconds'] == expected_elapsed + assert clock.delays == [2, 2, 1] + + +@pytest.mark.asyncio +async def test_active_result_returned_after_deadline_is_still_timeout(clock: Clock) -> None: + async def reader(plugin_id: str) -> dict: + clock.now = 6 + return payload(ready(WORKER, OTHER)) + + result = await wait(reader, timeout_seconds=5) + assert result['reason'] == 'timeout' and result['ok'] is False + assert result['lastObserved']['status'] == 'active' + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('timeout', 'read_timeout', 'reason'), [(0, 10, 'unavailable'), (4, 4, 'timeout'), (20, 10, 'unavailable')] +) +async def test_read_timeout_is_bounded_and_classified_by_budget_source( + monkeypatch: pytest.MonkeyPatch, clock: Clock, timeout: float, read_timeout: float, reason: str +) -> None: + async def time_out(tasks: set, *, timeout: float) -> tuple[set, set]: + assert timeout == read_timeout + clock.now += timeout + return set(), tasks + + monkeypatch.setattr(convergence, 'wait', time_out) + result = await wait(AsyncMock(return_value=payload()), timeout_seconds=timeout) + assert result['reason'] == reason and result['attempts'] == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize('swallow_cancel', [False, True]) +async def test_timer_before_monotonic_deadline_cannot_become_unavailable_or_late_success( + clock: Clock, swallow_cancel: bool +) -> None: + cleaned = asyncio.Event() + + async def reader(plugin_id: str) -> dict: + try: + await asyncio.Future() + except asyncio.CancelledError: + if swallow_cancel: + return payload(ready(WORKER, OTHER)) + raise + finally: + cleaned.set() + + result = await wait(reader, timeout_seconds=0.01) + assert result['reason'] == 'timeout' and result['ok'] is False + assert result['lastObserved'] is None and cleaned.is_set() + # 模拟 Windows 定时器已触发但粗粒度 monotonic 尚未更新的时刻。 + assert clock.now == 0 + + +@pytest.mark.asyncio +async def test_reader_own_timeout_is_unavailable_even_when_total_budget_limits_read() -> None: + reader = AsyncMock(side_effect=asyncio.TimeoutError('private-query-timeout')) + result = await wait(reader, timeout_seconds=0.01) + assert result['reason'] == 'unavailable' and result['ok'] is False + assert 'private-query-timeout' not in str(result) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('phase', ['read', 'sleep']) +async def test_cancellation_propagates_and_does_not_continue_polling( + monkeypatch: pytest.MonkeyPatch, phase: str +) -> None: + entered = asyncio.Event() + finished = asyncio.Event() + + async def blocked(value: object) -> dict: + entered.set() + try: + await asyncio.Future() + finally: + finished.set() + + reader = blocked if phase == 'read' else AsyncMock(return_value=payload()) + if phase == 'sleep': + monkeypatch.setattr(convergence, 'sleep', blocked) + task = asyncio.create_task(wait(reader)) + await entered.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert finished.is_set() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'changes', + [ + {'plugin_id': '../demo'}, + {'digest': 'A' * 64}, + {'expected_generation': 'bad'}, + {'expected_workers': 0}, + {'expected_workers': True}, + {'expected_workers': 1.5}, + {'timeout_seconds': -1}, + {'timeout_seconds': float('nan')}, + {'timeout_seconds': float('inf')}, + {'timeout_seconds': True}, + {'poll_interval_seconds': 0}, + {'poll_interval_seconds': float('inf')}, + ], +) +async def test_invalid_pins_or_time_limits_do_not_query(changes: dict) -> None: + reader = AsyncMock() + with pytest.raises(ValueError): + await wait(reader, **changes) + reader.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'changes', + [ + {'status': []}, + {'prepareStatus': []}, + {'healthyWorkers': True}, + {'healthyWorkers': 3}, + {'restartRequired': True}, + {'status': 'no_target'}, + {'status': 'failed'}, + {'status': 'pending_restart', 'restartRequired': True}, + {'missingWorkers': 1}, + {'status': 'partial', 'restartRequired': True, 'healthyWorkers': 1, 'missingWorkers': 0}, + {'expectedWorkers': False}, + ], +) +async def test_contradictory_or_malformed_summary_never_becomes_success(changes: dict) -> None: + state = payload(ready(WORKER, OTHER)) + state['releases'][0].update(changes) + result = await wait(AsyncMock(return_value=state)) + assert result['reason'] == 'invalid_observation' and result['ok'] is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize('field', ['targetDigest', 'generation', 'liveWorkers', 'enabled']) +async def test_missing_summary_field_returns_structured_failure(field: str) -> None: + state = payload(ready(WORKER, OTHER)) + state['releases'][0].pop(field) + result = await wait(AsyncMock(return_value=state)) + assert result['reason'] == 'invalid_observation' + + +@pytest.mark.asyncio +@pytest.mark.parametrize('collection', ['releases', 'workers']) +async def test_duplicate_summary_or_worker_rows_are_rejected(collection: str) -> None: + state = payload(ready(WORKER, OTHER)) + state[collection].append(deepcopy(state[collection][0])) + result = await wait(AsyncMock(return_value=state)) + assert result['reason'] == 'invalid_observation' + + +@pytest.mark.asyncio +@pytest.mark.parametrize('field', ['digest', 'generation', 'version', 'heartbeatTime', 'state', 'workerId']) +async def test_missing_raw_worker_fields_cannot_pass_via_summary(field: str) -> None: + state = payload(ready(WORKER, OTHER)) + state['workers'][0].pop(field) + result = await wait(AsyncMock(return_value=state)) + assert result['reason'] == 'invalid_observation' + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'changes', + [ + {'state': []}, + {'heartbeatTime': 'not-a-time'}, + {'heartbeatTime': NOW.replace(tzinfo=None).isoformat()}, + {'digest': DIGEST}, + {'pluginId': 'other_demo'}, + ], +) +async def test_invalid_worker_state_identity_or_naive_time_is_fail_closed(changes: dict) -> None: + state = payload(ready(WORKER, OTHER)) + state['workers'][0].update(changes) + result = await wait(AsyncMock(return_value=state)) + assert result['reason'] == 'invalid_observation' + + +@pytest.mark.asyncio +async def test_target_change_after_pending_observation_keeps_original_pin() -> None: + reader = AsyncMock(side_effect=[payload(), payload(generation='f' * 32), payload(ready(WORKER, OTHER))]) + result = await wait(reader) + expected_attempts = 2 + assert result['reason'] == 'target_changed' and result['attempts'] == expected_attempts + assert result['target']['generation'] == GENERATION + assert result['lastObserved']['generation'] == 'f' * 32 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('state', 'reason'), + [ + (None, 'invalid_observation'), + ({'ok': False, 'message': 'private-error'}, 'unavailable'), + ({'ok': True, 'releases': 'private-error'}, 'invalid_observation'), + ], +) +async def test_unavailable_or_malformed_payload_is_structured_and_redacted(state: object, reason: str) -> None: + result = await wait(AsyncMock(return_value=state)) + assert result['reason'] == reason and result['lastObserved'] is None + assert 'private-error' not in str(result) diff --git a/ruoyi-fastapi-backend/tests/plugins/core/deployment/test_deployment_catalog.py b/ruoyi-fastapi-backend/tests/plugins/core/deployment/test_deployment_catalog.py new file mode 100644 index 0000000..f56cabb --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/deployment/test_deployment_catalog.py @@ -0,0 +1,213 @@ +import json +import sys +from dataclasses import replace +from pathlib import Path +from typing import Any + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from plugins.core.deployment.catalog import PluginArtifactCatalog +from plugins.core.deployment.config import PluginDeploymentConfig +from plugins.core.management.dao.release_dao import PluginReleaseDao +from plugins.core.management.entity.do.models import SysPlugin +from plugins.core.management.entity.do.release_models import SysPluginArtifact, SysPluginRelease + + +@pytest.mark.asyncio +async def test_import_indexes_immutable_object_without_installing_or_selecting( + deployment_sessions: async_sessionmaker[AsyncSession], + deployment_config: PluginDeploymentConfig, + deployment_archive: Path, +) -> None: + """验证制品导入仅登记不可变对象,不安装插件或选择目标。""" + catalog = PluginArtifactCatalog(deployment_config, session_factory=deployment_sessions) + imported = await catalog.import_artifact(deployment_archive, actor='publisher-test') + stored = await catalog.get(imported['digest']) + stat = (stored.plugin_path / 'plugin.yaml').stat() + repeated = await catalog.import_artifact(deployment_archive, actor='another-actor') + assert imported['digest'] == repeated['digest'] == stored.digest + assert (stored.plugin_path / 'plugin.yaml').stat().st_mtime_ns == stat.st_mtime_ns + assert 'plugins.artifact_demo' not in sys.modules + assert (await catalog.list_artifacts('artifact_demo'))['artifacts'][0]['digest'] == stored.digest + async with deployment_sessions() as db: + record = await db.get(SysPluginArtifact, stored.digest) + assert record.created_by == 'publisher-test' + assert record.relative_path == f'artifact_demo/1.0.0/{stored.digest}' + assert (await db.scalars(select(SysPlugin))).all() == [] + assert (await db.scalars(select(SysPluginRelease))).all() == [] + + +@pytest.mark.asyncio +async def test_dry_run_writes_neither_store_nor_database( + deployment_sessions: async_sessionmaker[AsyncSession], + deployment_config: PluginDeploymentConfig, + deployment_archive: Path, +) -> None: + """验证只读预检不写入制品存储或数据库。""" + catalog = PluginArtifactCatalog(deployment_config, session_factory=deployment_sessions) + result = await catalog.import_artifact(deployment_archive, dry_run=True) + assert result['ok'] and result['dryRun'] + assert not deployment_config.store_root.exists() + assert (await catalog.list_artifacts())['artifacts'] == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('change', ['disabled-key', 'removed-key', 'scope', 'source-conflict', 'payload']) +async def test_every_catalog_read_rechecks_current_trust_scope_source_conflicts_and_content( + deployment_sessions: async_sessionmaker[AsyncSession], + deployment_config: PluginDeploymentConfig, + deployment_archive: Path, + change: str, +) -> None: + """验证每次目录读取均检查当前信任、授权、源码冲突及文件内容。""" + catalog = PluginArtifactCatalog(deployment_config, session_factory=deployment_sessions) + result = await catalog.import_artifact(deployment_archive) + stored = await catalog.get(result['digest']) + trust = json.loads(deployment_config.trust_file.read_text()) + if change == 'disabled-key': + trust['keys'][0]['enabled'] = False + elif change == 'removed-key': + trust['keys'] = [] + elif change == 'scope': + trust['keys'][0]['pluginIds'] = ['other_plugin'] + elif change == 'source-conflict': + (deployment_config.backend_root / 'plugins' / 'artifact_demo').mkdir() + else: + (stored.plugin_path / '__init__.py').write_text('raise RuntimeError("tampered")\n') + deployment_config.trust_file.write_text(json.dumps(trust), encoding='utf-8') + with pytest.raises(ValueError): + await catalog.get(result['digest']) + + +@pytest.mark.asyncio +async def test_import_rejects_source_conflict_before_publishing( + deployment_sessions: async_sessionmaker[AsyncSession], + deployment_config: PluginDeploymentConfig, + deployment_archive: Path, +) -> None: + """验证发布不可变对象前拒绝同名源码目录冲突。""" + (deployment_config.backend_root / 'plugins' / 'artifact_demo').mkdir() + catalog = PluginArtifactCatalog(deployment_config, session_factory=deployment_sessions) + with pytest.raises(ValueError, match='源码目录'): + await catalog.import_artifact(deployment_archive) + assert not (deployment_config.store_root / 'artifact_demo').exists() + assert (await catalog.list_artifacts())['artifacts'] == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'field,value', [('plugin_id', 'wrong_plugin'), ('relative_path', '../outside'), ('key_id', 'other-key')] +) +async def test_database_metadata_never_authorizes_an_unverified_path( + deployment_sessions: async_sessionmaker[AsyncSession], + deployment_config: PluginDeploymentConfig, + deployment_archive: Path, + field: str, + value: str, +) -> None: + """验证数据库索引字段不能授予未经验证路径的访问权限。""" + catalog = PluginArtifactCatalog(deployment_config, session_factory=deployment_sessions) + result = await catalog.import_artifact(deployment_archive) + async with deployment_sessions() as db: + record = await db.get(SysPluginArtifact, result['digest']) + setattr(record, field, value) + await db.commit() + with pytest.raises(ValueError): + await catalog.get(result['digest']) + + +@pytest.mark.asyncio +async def test_database_failure_leaves_valid_object_for_idempotent_retry( + deployment_sessions: async_sessionmaker[AsyncSession], + deployment_config: PluginDeploymentConfig, + deployment_archive: Path, +) -> None: + """验证索引写入失败后保留完整对象以支持幂等重试。""" + + class FailingDao(PluginReleaseDao): + @classmethod + async def register_artifact(cls, db: AsyncSession, **kwargs: Any) -> SysPluginArtifact: + await super().register_artifact(db, **kwargs) + raise RuntimeError('index transaction failed') + + failing = PluginArtifactCatalog(deployment_config, session_factory=deployment_sessions, dao=FailingDao) + with pytest.raises(RuntimeError, match='index transaction failed'): + await failing.import_artifact(deployment_archive) + catalog = PluginArtifactCatalog(deployment_config, session_factory=deployment_sessions) + assert (await catalog.list_artifacts())['artifacts'] == [] + objects = list(deployment_config.store_root.glob('artifact_demo/1.0.0/*')) + assert len(objects) == 1 + initial = (objects[0] / 'artifacts.json').stat().st_mtime_ns + result = await catalog.import_artifact(deployment_archive) + assert result['digest'] == objects[0].name + assert (objects[0] / 'artifacts.json').stat().st_mtime_ns == initial + + +@pytest.mark.parametrize('scope', [['*'], ['artifact_demo']]) +def test_external_publisher_authorization_accepts_explicit_scope( + deployment_config: PluginDeploymentConfig, scope: list[str] +) -> None: + """验证发布者授权仅接受明确配置的插件范围。""" + data = json.loads(deployment_config.trust_file.read_text()) + data['keys'][0]['pluginIds'] = scope + deployment_config.trust_file.write_text(json.dumps(data), encoding='utf-8') + deployment_config.authorize_publisher('publisher', 'artifact_demo') + assert list(deployment_config.trusted_keys()) == ['publisher'] + + +@pytest.mark.parametrize( + 'mutation', + [ + 'duplicate-json', + 'duplicate-id', + 'duplicate-scope', + 'invalid-scope', + 'schema-bool', + 'enabled-string', + 'self-trust', + ], +) +def test_trust_document_rejects_ambiguous_or_invalid_configuration( + deployment_config: PluginDeploymentConfig, mutation: str +) -> None: + """验证外部信任文档拒绝歧义字段与非法配置。""" + data = json.loads(deployment_config.trust_file.read_text()) + if mutation == 'duplicate-id': + data['keys'].append(data['keys'][0]) + elif mutation == 'duplicate-scope': + data['keys'][0]['pluginIds'] = ['artifact_demo', 'artifact_demo'] + elif mutation == 'invalid-scope': + data['keys'][0]['pluginIds'] = ['../artifact_demo'] + elif mutation == 'schema-bool': + data['schemaVersion'] = True + elif mutation == 'enabled-string': + data['keys'][0]['enabled'] = 'true' + elif mutation == 'self-trust': + data['trustEmbeddedKeys'] = True + raw = json.dumps(data) + if mutation == 'duplicate-json': + raw = raw.replace('"schemaVersion": 1', '"schemaVersion": 1, "schemaVersion": 1') + deployment_config.trust_file.write_text(raw, encoding='utf-8') + with pytest.raises(ValueError): + deployment_config.read_publishers() + + +@pytest.mark.parametrize('location', ['backend', 'ancestor', 'plugins', 'plugin-child']) +def test_store_cannot_overlap_host_source_tree(deployment_config: PluginDeploymentConfig, location: str) -> None: + """验证制品存储不能与宿主源码目录重叠。""" + paths = { + 'backend': deployment_config.backend_root, + 'ancestor': deployment_config.backend_root.parent, + 'plugins': deployment_config.backend_root / 'plugins', + 'plugin-child': deployment_config.backend_root / 'plugins' / 'artifact_store', + } + with pytest.raises(ValueError): + replace(deployment_config, store_root=paths[location]).require_enabled() + + +def test_disabled_deployment_rejects_usage(deployment_config: PluginDeploymentConfig) -> None: + """验证未启用发布功能时拒绝使用目录服务。""" + with pytest.raises(ValueError, match='未启用'): + replace(deployment_config, enabled=False).trusted_keys() diff --git a/ruoyi-fastapi-backend/tests/plugins/core/deployment/test_enablement.py b/ruoyi-fastapi-backend/tests/plugins/core/deployment/test_enablement.py new file mode 100644 index 0000000..ab227cf --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/deployment/test_enablement.py @@ -0,0 +1,347 @@ +import sys +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import pytest +import pytest_asyncio +import yaml +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from module_admin.entity.do.job_do import SysJob +from module_admin.entity.do.menu_do import SysMenu +from plugins.core.artifacts import build_artifact +from plugins.core.deployment.config import PluginDeploymentConfig +from plugins.core.deployment.enablement import PluginArtifactEnablementService +from plugins.core.discovery.scanner import DiscoveredPlugin +from plugins.core.management.dao.release_dao import PluginReleaseConflictError, PluginReleaseDao +from plugins.core.management.entity.do.models import SysPlugin, SysPluginMenu, SysPluginOperationLog +from plugins.core.management.entity.do.release_models import SysPluginRelease +from plugins.core.runtime.job_dispatcher import DISPATCH_TARGET +from plugins.core.runtime.service.lifecycle_lock import NoopPluginLifecycleLock +from tests.plugins.core.deployment.test_service import ( + PLUGIN_ID, + MaintenanceFactory, + MaintenanceRuntime, + import_prepare_select, + new_archive, +) +from utils.time_util import TimezoneUtil + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + +PLUGIN_MENU_ID = 101 +OTHER_MENU_ID = 102 +FIRST_JOB_ID = 201 + + +class EnablementRuntime(MaintenanceRuntime): + """模拟启停预检结果及并发状态变化的维护运行时。""" + + async def set_plugin_enabled(self, plugin_id: str, *, enabled: bool, dry_run: bool) -> dict[str, Any]: + """返回测试指定的启停预检结果。""" + owner: EnablementFactory = self.owner + owner.checks.append((plugin_id, enabled, dry_run)) + if owner.before_check is not None: + await owner.before_check() + return {'ok': not owner.reject, 'message': 'injected dependency rejection'} + + +class EnablementFactory(MaintenanceFactory): + """记录启停运行时创建信息的测试工厂。""" + + def __init__(self, sessions: async_sessionmaker[AsyncSession]) -> None: + """初始化预检结果与回调记录。""" + super().__init__(sessions) + self.checks: list[tuple[str, bool, bool]] = [] + self.before_check: Callable[[], Awaitable[None]] | None = None + self.reject = False + + def __call__(self, config: PluginDeploymentConfig, plugins: list[DiscoveredPlugin]) -> EnablementRuntime: + """根据测试配置创建启停维护运行时。""" + return EnablementRuntime(self, plugins) + + +@pytest.fixture +def enablement_factory(deployment_sessions: async_sessionmaker[AsyncSession]) -> EnablementFactory: + """提供可注入预检行为的维护运行时工厂。""" + return EnablementFactory(deployment_sessions) + + +@pytest.fixture +def enablement_service( + deployment_sessions: async_sessionmaker[AsyncSession], + deployment_config: PluginDeploymentConfig, + enablement_factory: EnablementFactory, + monkeypatch: pytest.MonkeyPatch, +) -> PluginArtifactEnablementService: + """创建使用临时数据库和测试维护运行时的启停服务。""" + monkeypatch.setattr(sys, 'dont_write_bytecode', sys.dont_write_bytecode) + return PluginArtifactEnablementService( + deployment_config, + session_factory=deployment_sessions, + lifecycle_lock=NoopPluginLifecycleLock(), + runtime_factory=enablement_factory, + ) + + +@pytest.fixture +def resource_archive(deployment_source: Path, deployment_key: Ed25519PrivateKey, tmp_path: Path) -> Path: + """构建带菜单与任务声明的签名测试制品。""" + manifest = yaml.safe_load((deployment_source / 'plugin.yaml').read_text(encoding='utf-8')) + manifest['backend']['jobs'] = [ + { + 'id': job_id, + 'name': job_id, + 'callable': f'plugins.{PLUGIN_ID}.{job_id}', + 'cronExpression': '0 */5 * * * ?', + 'enabled': enabled, + } + for job_id, enabled in [('heartbeat', True), ('dormant', False)] + ] + (deployment_source / 'plugin.yaml').write_text(yaml.safe_dump(manifest), encoding='utf-8') + archive = tmp_path / 'resources.rpk' + build_artifact(deployment_source, archive, deployment_key, 'publisher') + return archive + + +async def _seed_resources(sessions: async_sessionmaker[AsyncSession], *, disabled: bool = False) -> None: + """在临时数据库中写入启停测试需要的声明资源。""" + status = '1' if disabled else '0' + async with sessions() as db: + plugin = await db.get(SysPlugin, PLUGIN_ID) + plugin.enabled = status + db.add(SysMenu(menu_id=PLUGIN_MENU_ID, menu_name='Owned menu', status=status)) + db.add(SysMenu(menu_id=OTHER_MENU_ID, menu_name='Unrelated menu', status='0')) + db.add(SysPluginMenu(plugin_id=PLUGIN_ID, menu_id=PLUGIN_MENU_ID, menu_key='owned-menu')) + for index, (job_id, job_status, remark) in enumerate( + [ + ('heartbeat', status, f'[plugin-job] {PLUGIN_ID}:heartbeat Declared task'), + ('dormant', '1', f'[plugin-job] {PLUGIN_ID}:dormant Disabled declaration'), + ('manual', '0', 'A user task sharing the prefix'), + ], + start=FIRST_JOB_ID, + ): + db.add( + SysJob( + job_id=index, + job_name=f'{PLUGIN_ID}:{job_id}', + job_group='default', + invoke_target=DISPATCH_TARGET, + job_args=[PLUGIN_ID, job_id, plugin.installed_version], + job_kwargs={}, + cron_expression='0 */5 * * * ?', + time_zone='Asia/Shanghai', + status=job_status, + remark=remark, + ) + ) + await db.commit() + + +@pytest_asyncio.fixture +async def selected_release( + enablement_service: PluginArtifactEnablementService, + resource_archive: Path, + deployment_sessions: async_sessionmaker[AsyncSession], +) -> dict[str, Any]: + """准备已完成维护且已选择目标的发布记录。""" + selected = await import_prepare_select(enablement_service, resource_archive) + await _seed_resources(deployment_sessions) + return selected + + +async def _snapshot(sessions: async_sessionmaker[AsyncSession]) -> dict[str, Any]: + """读取发布状态、插件状态及声明资源的完整比较快照。""" + async with sessions() as db: + plugin = await db.get(SysPlugin, PLUGIN_ID) + release = await db.get(SysPluginRelease, PLUGIN_ID) + return { + 'plugin': (plugin.enabled, plugin.status, plugin.version, plugin.installed_version), + 'release': ( + release.generation, + release.target_digest, + release.previous_digest, + release.prepared_digest, + release.prepared_version, + release.prepare_status, + ), + 'menus': dict((await db.execute(select(SysMenu.menu_id, SysMenu.status))).all()), + 'jobs': dict((await db.execute(select(SysJob.job_name, SysJob.status))).all()), + 'audit': ( + await db.execute(select(SysPluginOperationLog.operation_id, SysPluginOperationLog.operation)) + ).all(), + } + + +@pytest.mark.asyncio +async def test_disable_and_enable_commit_resources_generation_and_preserve_previous_digest( + enablement_service: PluginArtifactEnablementService, + enablement_factory: EnablementFactory, + resource_archive: Path, + deployment_source: Path, + deployment_key: Ed25519PrivateKey, + deployment_sessions: async_sessionmaker[AsyncSession], + tmp_path: Path, +) -> None: + """验证启停原子更新资源及代际,同时保留上一制品摘要。""" + first = await import_prepare_select(enablement_service, resource_archive) + upgraded = new_archive(deployment_source, tmp_path / 'upgrade.rpk', deployment_key, '2.0.0') + second = await import_prepare_select(enablement_service, upgraded) + await _seed_resources(deployment_sessions) + + disabled = await enablement_service.set_enabled( + PLUGIN_ID, enabled=False, expected_generation=second['generation'], maintenance=True, actor='operator' + ) + snapshot = await _snapshot(deployment_sessions) + assert disabled['restartRequired'] and disabled['operation'] == 'release_disable' + assert disabled['generation'] != second['generation'] + assert snapshot['plugin'] == ('1', 'installed', '2.0.0', '2.0.0') + assert snapshot['menus'] == {PLUGIN_MENU_ID: '1', OTHER_MENU_ID: '0'} + assert snapshot['jobs'] == {f'{PLUGIN_ID}:heartbeat': '1', f'{PLUGIN_ID}:dormant': '1', f'{PLUGIN_ID}:manual': '0'} + + enabled = await enablement_service.set_enabled( + PLUGIN_ID, enabled=True, expected_generation=disabled['generation'], maintenance=True, actor='operator' + ) + snapshot = await _snapshot(deployment_sessions) + assert enabled['generation'] != disabled['generation'] + assert snapshot['plugin'] == ('0', 'installed', '2.0.0', '2.0.0') + assert snapshot['release'] == ( + enabled['generation'], + second['digest'], + first['digest'], + second['digest'], + '2.0.0', + 'prepared', + ) + assert snapshot['menus'] == {PLUGIN_MENU_ID: '0', OTHER_MENU_ID: '0'} + assert snapshot['jobs'] == {f'{PLUGIN_ID}:heartbeat': '0', f'{PLUGIN_ID}:dormant': '1', f'{PLUGIN_ID}:manual': '0'} + assert [operation for _, operation in snapshot['audit']][-2:] == ['release_disable', 'release_enable'] + assert enablement_factory.checks == [(PLUGIN_ID, False, True), (PLUGIN_ID, True, True)] + assert f'plugins.{PLUGIN_ID}' not in sys.modules + + +@pytest.mark.asyncio +@pytest.mark.parametrize('gate', ['maintenance', 'live_worker', 'stale_generation', 'precheck']) +async def test_enablement_gates_preserve_all_resources_and_release_state( + enablement_service: PluginArtifactEnablementService, + enablement_factory: EnablementFactory, + selected_release: dict[str, Any], + deployment_sessions: async_sessionmaker[AsyncSession], + gate: str, +) -> None: + """验证维护门禁失败时资源和发布状态均保持原值。""" + if gate == 'live_worker': + async with deployment_sessions() as db: + await PluginReleaseDao.upsert_worker_report( + db, + worker_id='a' * 32, + plugin_id='__runtime__', + state='ready', + heartbeat_time=TimezoneUtil.utc_now(), + ) + await db.commit() + if gate == 'precheck': + enablement_factory.reject = True + before = await _snapshot(deployment_sessions) + messages = { + 'maintenance': 'maintenance', + 'live_worker': '存活的宿主 worker', + 'stale_generation': '发布代际', + 'precheck': 'injected dependency rejection', + } + with pytest.raises(ValueError, match=messages[gate]): + await enablement_service.set_enabled( + PLUGIN_ID, + enabled=False, + expected_generation='f' * 32 if gate == 'stale_generation' else selected_release['generation'], + maintenance=gate != 'maintenance', + ) + assert await _snapshot(deployment_sessions) == before + assert enablement_factory.checks == ([(PLUGIN_ID, False, True)] if gate == 'precheck' else []) + + +@pytest.mark.asyncio +async def test_audit_failure_rolls_back_generation_plugin_menu_and_job_updates( + enablement_service: PluginArtifactEnablementService, + selected_release: dict[str, Any], + deployment_sessions: async_sessionmaker[AsyncSession], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """验证审计失败会回滚代际、插件、菜单及任务更新。""" + before = await _snapshot(deployment_sessions) + + async def failed_audit(db: AsyncSession, payload: dict[str, Any], actor: str | None) -> None: + assert (await db.get(SysPlugin, PLUGIN_ID)).enabled == '1' + assert (await db.get(SysMenu, PLUGIN_MENU_ID)).status == '1' + assert (await db.get(SysJob, FIRST_JOB_ID)).status == '1' + assert (await db.get(SysPluginRelease, PLUGIN_ID)).generation == payload['generation'] + raise RuntimeError('audit unavailable') + + monkeypatch.setattr(enablement_service, '_audit', failed_audit) + with pytest.raises(RuntimeError, match='audit unavailable'): + await enablement_service.set_enabled( + PLUGIN_ID, enabled=False, expected_generation=selected_release['generation'], maintenance=True + ) + assert await _snapshot(deployment_sessions) == before + + +@pytest.mark.asyncio +async def test_job_ownership_failure_rolls_back_prior_menu_and_plugin_writes( + enablement_service: PluginArtifactEnablementService, + selected_release: dict[str, Any], + deployment_sessions: async_sessionmaker[AsyncSession], +) -> None: + """验证任务归属冲突会回滚先前的菜单和插件写入。""" + disabled = await enablement_service.set_enabled( + PLUGIN_ID, enabled=False, expected_generation=selected_release['generation'], maintenance=True + ) + async with deployment_sessions() as db: + job = await db.get(SysJob, FIRST_JOB_ID) + job.remark = 'Existing user-owned task must not be overwritten' + await db.commit() + before = await _snapshot(deployment_sessions) + + with pytest.raises(ValueError, match='非插件任务重名'): + await enablement_service.set_enabled( + PLUGIN_ID, enabled=True, expected_generation=disabled['generation'], maintenance=True + ) + assert await _snapshot(deployment_sessions) == before + + +@pytest.mark.asyncio +@pytest.mark.parametrize('changed_field', ['generation', 'target_digest', 'installed_version']) +async def test_precheck_race_cannot_apply_enablement_against_changed_preparation( + enablement_service: PluginArtifactEnablementService, + enablement_factory: EnablementFactory, + selected_release: dict[str, Any], + deployment_sessions: async_sessionmaker[AsyncSession], + changed_field: str, +) -> None: + """验证预检后的准备证据变化会阻止启停提交。""" + after_concurrent_change: dict[str, Any] = {} + + async def concurrent_change() -> None: + async with deployment_sessions() as db: + if changed_field == 'installed_version': + await db.execute( + update(SysPlugin).where(SysPlugin.plugin_id == PLUGIN_ID).values(installed_version='2.0.0') + ) + else: + value = 'f' * 32 if changed_field == 'generation' else None + await db.execute( + update(SysPluginRelease) + .where(SysPluginRelease.plugin_id == PLUGIN_ID) + .values(**{changed_field: value}) + ) + await db.commit() + after_concurrent_change.update(await _snapshot(deployment_sessions)) + + enablement_factory.before_check = concurrent_change + with pytest.raises(PluginReleaseConflictError): + await enablement_service.set_enabled( + PLUGIN_ID, enabled=False, expected_generation=selected_release['generation'], maintenance=True + ) + assert await _snapshot(deployment_sessions) == after_concurrent_change diff --git a/ruoyi-fastapi-backend/tests/plugins/core/deployment/test_maintenance.py b/ruoyi-fastapi-backend/tests/plugins/core/deployment/test_maintenance.py new file mode 100644 index 0000000..5c18714 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/deployment/test_maintenance.py @@ -0,0 +1,381 @@ +import base64 +import json +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from datetime import timedelta +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +import pytest_asyncio +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from sqlalchemy import delete, select + +from plugins.core.artifacts import build_artifact +from plugins.core.deployment.maintenance import PluginArtifactMaintenanceService +from plugins.core.management.dao.release_dao import PluginReleaseDao +from plugins.core.management.entity.do.models import SysPluginOperationLog +from plugins.core.management.entity.do.release_models import SysPluginArtifact, SysPluginRelease, SysPluginWorker +from plugins.core.runtime.service.lifecycle_lock import NoopPluginLifecycleLock, PluginLifecycleLockResult +from utils.time_util import TimezoneUtil + + +@pytest_asyncio.fixture +async def maintenance(deployment_config: Any, deployment_sessions: Any, deployment_archive: Path) -> Any: + """ + 创建只使用临时文件和 SQLite 的维护服务。 + + :param deployment_config: 隔离部署配置 + :param deployment_sessions: 临时数据库会话 + :param deployment_archive: 测试制品 + :return: 服务与已导入制品 + """ + service = PluginArtifactMaintenanceService( + deployment_config, session_factory=deployment_sessions, lifecycle_lock=NoopPluginLifecycleLock() + ) + imported = await service.catalog.import_artifact(deployment_archive) + stored = await service.catalog.get(imported['digest']) + return service, stored + + +@pytest.fixture +def rotated_archive(deployment_config: Any, deployment_source: Path, tmp_path: Path) -> Path: + """ + 生成内容相同、密钥不同的可信制品。 + + :param deployment_config: 隔离信任配置 + :param deployment_source: 插件源目录 + :param tmp_path: 临时目录 + :return: 新签名制品路径 + """ + key = Ed25519PrivateKey.generate() + trust = json.loads(deployment_config.trust_file.read_text()) + trust['keys'].append( + { + 'keyId': 'publisher-next', + 'pluginIds': ['artifact_demo'], + 'publicKey': base64.b64encode( + key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + ).decode(), + } + ) + deployment_config.trust_file.write_text(json.dumps(trust), encoding='utf-8') + path = tmp_path / 'rotated.rpk' + build_artifact(deployment_source, path, key, 'publisher-next') + return path + + +@pytest.mark.asyncio +async def test_prune_defaults_are_readonly_and_keep_recent(maintenance: Any) -> None: + """ + 验证默认预演保留最近导入对象。 + + :param maintenance: 维护服务与制品 + :return: None + """ + service, stored = maintenance + plan = await service.prune() + assert plan['dryRun'] and not plan['candidates'] + assert set(plan['objects'][0]['protectedBy']) == {'keep_last', 'min_age'} + assert stored.root_path.exists() and not service._pending() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('reference', ['target_digest', 'previous_digest', 'prepared_digest', 'worker']) +async def test_prune_protects_release_and_stale_worker_references(maintenance: Any, reference: str) -> None: + """ + 验证发布、回滚和过期但未停止的进程引用均阻止清理。 + + :param maintenance: 维护服务与制品 + :param reference: 受保护引用类型 + :return: None + """ + service, stored = maintenance + async with service.session_factory() as db: + if reference == 'worker': + db.add( + SysPluginWorker( + worker_id=uuid4().hex, + plugin_id=stored.plugin_id, + artifact_digest=stored.digest, + state='ready', + heartbeat_time=TimezoneUtil.utc_now() - timedelta(days=30), + ) + ) + else: + db.add(SysPluginRelease(plugin_id=stored.plugin_id, generation=uuid4().hex, **{reference: stored.digest})) + await db.commit() + plan = await service.prune(keep_last=0, min_age_days=0) + assert plan['candidates'] == [] + assert plan['objects'][0]['protectedBy'] == ['worker_reference' if reference == 'worker' else reference] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('orphan', [False, True]) +async def test_prune_removes_only_reviewed_objects_and_audits(maintenance: Any, orphan: bool) -> None: + """ + 验证已登记和孤儿对象均须按计划清理,并写入审计。 + + :param maintenance: 维护服务与制品 + :param orphan: 是否模拟索引未提交的孤儿对象 + :return: None + """ + service, stored = maintenance + if orphan: + async with service.session_factory() as db: + await db.execute(delete(SysPluginArtifact)) + await db.commit() + plan = await service.prune(keep_last=0, min_age_days=0) + assert plan['candidates'] == [stored.digest] + result = await service.prune( + keep_last=0, min_age_days=0, expected_plan=plan['planId'], dry_run=False, maintenance=True, actor='test' + ) + assert result['removed'] == [stored.digest] + assert not stored.root_path.exists() and not service._pending() + async with service.session_factory() as db: + assert await db.get(SysPluginArtifact, stored.digest) is None + assert len((await db.scalars(select(SysPluginOperationLog))).all()) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize('failure', ['changed-plan', 'missing-maintenance', 'live-worker']) +async def test_prune_guards_before_mutating(maintenance: Any, failure: str) -> None: + """ + 验证计划、维护确认和存活进程检查均早于文件变更。 + + :param maintenance: 维护服务与制品 + :param failure: 待验证的拒绝原因 + :return: None + """ + service, stored = maintenance + plan = await service.prune(keep_last=0, min_age_days=0) + if failure == 'live-worker': + async with service.session_factory() as db: + db.add( + SysPluginWorker( + worker_id=uuid4().hex, plugin_id='__runtime__', state='ready', heartbeat_time=TimezoneUtil.utc_now() + ) + ) + await db.commit() + with pytest.raises(ValueError): + await service.prune( + keep_last=0, + min_age_days=0, + expected_plan='changed' if failure == 'changed-plan' else plan['planId'], + dry_run=False, + maintenance=failure != 'missing-maintenance', + ) + assert stored.root_path.exists() and not service._pending() + + +@pytest.mark.asyncio +async def test_tampered_objects_are_reported_and_never_pruned(maintenance: Any) -> None: + """ + 验证损坏内容不会自动获得删除资格。 + + :param maintenance: 维护服务与制品 + :return: None + """ + service, stored = maintenance + (stored.plugin_path / '__init__.py').write_text('tampered') + plan = await service.prune(keep_last=0, min_age_days=0) + assert plan['objects'][0]['status'] == 'invalid' + assert plan['candidates'] == [] + + +@pytest.mark.asyncio +async def test_interrupted_prune_restores_before_commit_and_finishes_after_commit( + maintenance: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + 验证事务失败后恢复原目录,提交成功后恢复继续清理。 + + :param maintenance: 维护服务与制品 + :param monkeypatch: 故障注入工具 + :return: None + """ + service, stored = maintenance + plan = await service.prune(keep_last=0, min_age_days=0) + with monkeypatch.context() as patcher: + patcher.setattr(service, '_audit', AsyncMock(side_effect=RuntimeError('audit failed'))) + with pytest.raises(RuntimeError): + await service.prune( + keep_last=0, min_age_days=0, expected_plan=plan['planId'], dry_run=False, maintenance=True + ) + with pytest.raises(ValueError, match='reconcile'): + await service.catalog.get(stored.digest) + with pytest.raises(ValueError, match='reconcile'): + service.catalog.require_no_pending_maintenance() + assert (await service.reconcile(maintenance=True))['restored'] == [stored.digest] + assert (await service.catalog.get(stored.digest)).digest == stored.digest + plan = await service.prune(keep_last=0, min_age_days=0) + with monkeypatch.context() as patcher: + patcher.setattr(service, '_remove_quarantine', lambda *args: (_ for _ in ()).throw(OSError('interrupted'))) + with pytest.raises(OSError): + await service.prune( + keep_last=0, min_age_days=0, expected_plan=plan['planId'], dry_run=False, maintenance=True + ) + await service.reconcile(maintenance=True) + assert not service._pending() and not service._quarantine(stored.digest).exists() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('revoke_old', [False, True]) +async def test_rotation_preserves_content_digest_and_accepts_revoked_old_key( + maintenance: Any, + rotated_archive: Path, + revoke_old: bool, +) -> None: + """ + 验证新签名可独立证明现有内容,旧密钥撤销不阻止恢复可信签名。 + + :param maintenance: 维护服务与制品 + :param rotated_archive: 新密钥签名制品 + :param revoke_old: 是否撤销旧密钥 + :return: None + """ + service, stored = maintenance + original = (stored.plugin_path / '__init__.py').read_bytes() + if revoke_old: + trust = json.loads(service.config.trust_file.read_text()) + trust['keys'][0]['enabled'] = False + service.config.trust_file.write_text(json.dumps(trust), encoding='utf-8') + result = await service.rotate_signature(rotated_archive, expected_key_id='publisher', maintenance=True) + assert result['digest'] == stored.digest and result['keyId'] == 'publisher-next' + assert (await service.catalog.get(stored.digest)).key_id == 'publisher-next' + assert (stored.plugin_path / '__init__.py').read_bytes() == original + assert not service._pending() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('failure', ['tampered', 'wrong-key', 'publisher-scope']) +async def test_rotation_refuses_invalid_content_or_identity( + maintenance: Any, rotated_archive: Path, failure: str +) -> None: + """ + 验证内容、索引 CAS 和发布者授权均在签名替换前检查。 + + :param maintenance: 维护服务与制品 + :param rotated_archive: 新签名制品 + :param failure: 故障类型 + :return: None + """ + service, stored = maintenance + signature = (stored.root_path / 'signature.json').read_bytes() + if failure == 'tampered': + (stored.plugin_path / '__init__.py').write_text('tampered') + if failure == 'publisher-scope': + trust = json.loads(service.config.trust_file.read_text()) + trust['keys'][1]['pluginIds'] = ['other_plugin'] + service.config.trust_file.write_text(json.dumps(trust), encoding='utf-8') + with pytest.raises(ValueError): + await service.rotate_signature( + rotated_archive, expected_key_id='wrong' if failure == 'wrong-key' else 'publisher', maintenance=True + ) + assert (stored.root_path / 'signature.json').read_bytes() == signature + assert not service._pending() + + +@pytest.mark.asyncio +async def test_failed_rotation_reconciles_to_committed_key( + maintenance: Any, + rotated_archive: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + 验证签名替换后事务失败可恢复旧签名并再次成功轮换。 + + :param maintenance: 维护服务与制品 + :param rotated_archive: 新签名制品 + :param monkeypatch: 故障注入工具 + :return: None + """ + service, stored = maintenance + with monkeypatch.context() as patcher: + patcher.setattr(service, '_audit', AsyncMock(side_effect=RuntimeError('audit failed'))) + with pytest.raises(RuntimeError): + await service.rotate_signature(rotated_archive, expected_key_id='publisher', maintenance=True) + assert json.loads((stored.root_path / 'signature.json').read_text())['keyId'] == 'publisher-next' + await service.reconcile(maintenance=True) + assert (await service.catalog.get(stored.digest)).key_id == 'publisher' + await service.rotate_signature(rotated_archive, expected_key_id='publisher', maintenance=True) + assert (await service.catalog.get(stored.digest)).key_id == 'publisher-next' + + +@pytest.mark.asyncio +async def test_reconcile_rejects_path_escape(maintenance: Any) -> None: + """ + 验证篡改维护日志不能指向存储外路径。 + + :param maintenance: 维护服务与制品 + :return: None + """ + service, stored = maintenance + service._write_journal({'digest': stored.digest, 'relativePath': '../outside', 'action': 'prune'}) + with pytest.raises(ValueError, match='路径'): + await service.reconcile(maintenance=True) + assert stored.root_path.exists() + + +@pytest.mark.asyncio +async def test_import_holds_lifecycle_lock_until_index_commit(maintenance: Any, deployment_archive: Path) -> None: + """ + 验证导入在写索引和提交完成前不会释放与清理共用的生命周期锁。 + + :param maintenance: 维护服务与制品 + :param deployment_archive: 已验证测试制品 + :return: None + """ + service, stored = maintenance + events = [] + + class TrackingLock: + """ + 记录文件发布和索引写入是否在同一锁范围内。 + """ + + @asynccontextmanager + async def lock(self, plugin_id: str, operation: str) -> AsyncIterator[PluginLifecycleLockResult]: + """ + 记录导入锁进入与退出顺序。 + + :param plugin_id: 生命周期操作命名空间 + :param operation: 操作类型 + :return: 已获取锁的上下文 + """ + assert (plugin_id, operation) == ('__artifacts__', 'artifact_import') + events.append('locked') + yield PluginLifecycleLockResult(acquired=True) + async with service.session_factory() as db: + assert await db.get(SysPluginArtifact, stored.digest) is not None + events.append('released') + + class TrackingDao(PluginReleaseDao): + """ + 记录索引登记时锁仍有效。 + """ + + @classmethod + async def register_artifact(cls, db: Any, **values: Any) -> SysPluginArtifact: + """ + 保留真实数据库写入,仅添加锁范围断言。 + + :param db: 测试数据库会话 + :param values: 制品身份和索引数据 + :return: 已登记的制品记录 + """ + assert events == ['locked'] + events.append('registered') + return await super().register_artifact(db, **values) + + async with service.session_factory() as db: + await db.execute(delete(SysPluginArtifact)) + await db.commit() + service.catalog.lifecycle_lock = TrackingLock() + service.catalog.dao = TrackingDao + await service.catalog.import_artifact(deployment_archive) + assert events == ['locked', 'registered', 'released'] diff --git a/ruoyi-fastapi-backend/tests/plugins/core/deployment/test_release_state.py b/ruoyi-fastapi-backend/tests/plugins/core/deployment/test_release_state.py new file mode 100644 index 0000000..f1532c3 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/deployment/test_release_state.py @@ -0,0 +1,166 @@ +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace + +import pytest + +from plugins.core.deployment.state import aggregate_release + +NOW = datetime(2026, 10, 2, tzinfo=timezone.utc) +DIGEST = 'a' * 64 +GENERATION = 'b' * 32 + + +def release(expected_workers: int = 1, **overrides: object) -> SimpleNamespace: + """构造发布目标及预期进程数量的测试记录。""" + return SimpleNamespace( + **{ + 'plugin_id': 'demo', + 'target_digest': DIGEST, + 'generation': GENERATION, + 'expected_workers': expected_workers, + 'prepare_status': 'prepared', + **overrides, + } + ) + + +def host(worker_id: str, **overrides: object) -> SimpleNamespace: + """构造宿主进程存活报告。""" + return SimpleNamespace( + **{'worker_id': worker_id, 'plugin_id': '__runtime__', 'state': 'ready', 'heartbeat_time': NOW, **overrides} + ) + + +def plugin(worker_id: str, **overrides: object) -> SimpleNamespace: + """构造插件实际加载状态报告。""" + return SimpleNamespace( + **{ + 'worker_id': worker_id, + 'plugin_id': 'demo', + 'state': 'ready', + 'heartbeat_time': NOW, + 'artifact_digest': DIGEST, + 'generation': GENERATION, + **overrides, + } + ) + + +def test_active_requires_every_live_host_and_expected_capacity() -> None: + """验证全部存活宿主就绪且达到预期数量后才能报告 active。""" + reports = [host('one'), plugin('one'), host('two')] + summary = aggregate_release(release(), reports, now=NOW) + assert summary.status == 'partial' + assert summary.healthy_workers == 1 + assert summary.missing_workers == 1 + assert summary.restart_required + summary = aggregate_release(release(2), [*reports, plugin('two')], now=NOW) + assert summary.status == 'active' + assert not summary.restart_required + summary = aggregate_release(release(3), [*reports, plugin('two')], now=NOW) + assert summary.status == 'partial' + assert summary.missing_workers == 1 + + +def test_report_without_host_cannot_claim_active() -> None: + """验证缺少宿主报告的插件行不能使发布状态变为 active。""" + summary = aggregate_release(release(), [plugin('orphan')], now=NOW) + assert summary.status == 'pending_restart' + assert summary.healthy_workers == 0 + assert summary.live_workers == 0 + + +@pytest.mark.parametrize('difference', [{'artifact_digest': 'c' * 64}, {'generation': 'd' * 32}]) +def test_old_generation_or_digest_requires_restart(difference: dict[str, object]) -> None: + """验证旧代际或错误制品摘要要求重启。""" + summary = aggregate_release(release(), [host('one'), plugin('one', **difference)], now=NOW) + assert summary.status == 'pending_restart' + assert summary.mismatch_workers == 1 + assert summary.restart_required + + +def test_stale_and_stopped_workers_do_not_block_fully_replaced_cluster() -> None: + """验证陈旧或已停止进程不会阻挡新一代集群正常收敛。""" + reports = [ + host('old', heartbeat_time=NOW - timedelta(seconds=61)), + plugin('old'), + host('stopped', state='stopped'), + plugin('stopped'), + host('new'), + plugin('new'), + ] + summary = aggregate_release(release(), reports, now=NOW) + assert summary.status == 'active' + assert summary.stale_workers == 1 + assert summary.live_workers == 1 + summary = aggregate_release(release(2), reports, now=NOW) + assert summary.status == 'partial' + assert summary.missing_workers == 1 + + +def test_expired_plugin_report_under_live_host_is_missing() -> None: + """验证存活宿主下的过期插件报告按缺失状态处理。""" + reports = [host('one'), plugin('one', heartbeat_time=NOW - timedelta(seconds=61))] + summary = aggregate_release(release(), reports, now=NOW) + assert summary.status == 'pending_restart' + assert summary.stale_workers == 1 + assert summary.missing_workers == 1 + assert summary.healthy_workers == 0 + + +def test_plugin_failure_is_failed_or_partial_and_preparation_failure_is_separate() -> None: + """验证插件运行失败与维护准备失败分别汇总。""" + failed = [host('one'), plugin('one', state='failed')] + summary = aggregate_release(release(), failed, now=NOW) + assert summary.status == 'failed' + assert summary.failed_workers == 1 + summary = aggregate_release(release(), [*failed, host('two'), plugin('two')], now=NOW) + assert summary.status == 'partial' + summary = aggregate_release( + release(prepare_status='failed', last_error='new artifact preparation failed'), + [host('one'), plugin('one')], + now=NOW, + ) + assert summary.status == 'active' + assert summary.to_payload()['prepareStatus'] == 'failed' + + +def test_starting_host_and_stopped_plugin_are_not_ready() -> None: + """验证正在启动的宿主及已停止的插件不能计为就绪。""" + summary = aggregate_release(release(), [host('one', state='starting'), plugin('one')], now=NOW) + assert summary.status == 'pending_restart' + + +def test_disabled_target_converges_only_after_all_live_workers_acknowledge_stop() -> None: + """验证全部存活进程确认当前代际停止后才汇总为已停用。""" + reports = [host('one'), plugin('one', state='stopped')] + summary = aggregate_release(release(), reports, now=NOW, enabled=False) + assert summary.status == 'disabled' + assert summary.disabled_workers == 1 + assert summary.healthy_workers == 0 + assert not summary.restart_required + summary = aggregate_release(release(), [*reports, host('two'), plugin('two')], now=NOW, enabled=False) + assert summary.status == 'partial' + assert summary.restart_required + summary = aggregate_release(release(2), reports, now=NOW, enabled=False) + assert summary.status == 'partial' + summary = aggregate_release( + release(), [host('one'), plugin('one', state='stopped', generation='old')], now=NOW, enabled=False + ) + assert summary.status == 'pending_restart' + assert summary.mismatch_workers == 1 + summary = aggregate_release(release(), [host('one'), plugin('one', state='stopped')], now=NOW) + assert summary.status == 'pending_restart' + + +def test_no_target_and_future_heartbeats_do_not_claim_active() -> None: + """验证无目标或未来时间心跳不能产生 active 状态。""" + assert aggregate_release(release(target_digest=None), [], now=NOW).status == 'no_target' + assert not aggregate_release(release(target_digest=None), [], now=NOW).restart_required + summary = aggregate_release( + release(), [host('one', heartbeat_time=NOW + timedelta(seconds=61)), plugin('one')], now=NOW + ) + assert summary.status == 'pending_restart' + assert summary.stale_workers == 1 + with pytest.raises(ValueError, match='时区'): + aggregate_release(release(), [], now=NOW.replace(tzinfo=None)) diff --git a/ruoyi-fastapi-backend/tests/plugins/core/deployment/test_service.py b/ruoyi-fastapi-backend/tests/plugins/core/deployment/test_service.py new file mode 100644 index 0000000..06655fa --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/deployment/test_service.py @@ -0,0 +1,565 @@ +import sys +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from datetime import timedelta +from pathlib import Path +from typing import Any + +import pytest +import yaml +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from sqlalchemy import event, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from config.database import DataSourceRegistry +from plugins.core.artifacts import build_artifact +from plugins.core.deployment.config import PluginDeploymentConfig +from plugins.core.deployment.service import PluginDeploymentService +from plugins.core.discovery.scanner import DiscoveredPlugin +from plugins.core.management.dao.release_dao import PluginReleaseConflictError, PluginReleaseDao +from plugins.core.management.entity.do.models import SysPlugin, SysPluginOperationLog +from plugins.core.management.entity.do.release_models import SysPluginRelease +from plugins.core.runtime.service.lifecycle_lock import NoopPluginLifecycleLock, PluginLifecycleLockResult +from utils.time_util import TimezoneUtil + +PLUGIN_ID = 'artifact_demo' + + +class MaintenanceRuntime: + """模拟安装、升级及预检结果的维护运行时。""" + + def __init__(self, owner: 'MaintenanceFactory', plugins: list[DiscoveredPlugin]) -> None: + """保存测试制品快照、数据库工厂及故障配置。""" + self.owner = owner + self.plugins = {plugin.manifest.id: plugin for plugin in plugins} + + async def install_plugin(self, plugin_id: str, **kwargs: Any) -> dict[str, Any]: + """按测试配置模拟插件安装。""" + return await self.execute('install', plugin_id, **kwargs) + + async def upgrade_plugin(self, plugin_id: str, **kwargs: Any) -> dict[str, Any]: + """按测试配置模拟插件升级。""" + return await self.execute('upgrade', plugin_id, **kwargs) + + async def check_plugin_async(self, plugin_id: str) -> dict[str, Any]: + """返回当前测试指定的静态预检结果。""" + self.owner.calls.append(('check', True, self.plugins[plugin_id].manifest.version)) + return {'ok': self.owner.failure != 'check', 'message': 'static dependency check'} + + async def execute( + self, operation: str, plugin_id: str, *, dry_run: bool = False, operated_by: str | None = None + ) -> dict[str, Any]: + """模拟维护写入及失败场景,更新临时安装状态。""" + plugin = self.plugins[plugin_id] + self.owner.calls.append((operation, dry_run, plugin.manifest.version)) + if dry_run: + return {'ok': True, 'message': 'static lifecycle plan'} + if self.owner.failure == 'raise': + raise RuntimeError('injected migration error') + if self.owner.failure == 'result': + return {'ok': False, 'message': 'lifecycle rejected candidate'} + if self.owner.failure != 'no-version': + async with self.owner.sessions() as db: + row = await db.get(SysPlugin, plugin_id) + if row is None: + row = SysPlugin( + plugin_id=plugin_id, plugin_name=plugin.manifest.name, version=plugin.manifest.version + ) + db.add(row) + row.version = row.installed_version = plugin.manifest.version + row.status = 'installed' + await db.commit() + if self.owner.failure == 'tamper': + (plugin.backend_path / '__init__.py').write_text('raise RuntimeError("modified by hook")\n') + return {'ok': True, 'message': f'{operation} complete', 'operatedBy': operated_by} + + +class MaintenanceFactory: + """记录维护运行时创建情况并提供可控故障的测试工厂。""" + + def __init__(self, sessions: async_sessionmaker[AsyncSession]) -> None: + """初始化测试数据库和维护故障配置。""" + self.sessions = sessions + self.calls: list[tuple[str, bool, str]] = [] + self.failure: str | None = None + + def __call__(self, config: PluginDeploymentConfig, plugins: list[DiscoveredPlugin]) -> MaintenanceRuntime: + """为当前候选制品创建测试维护运行时。""" + return MaintenanceRuntime(self, plugins) + + +@pytest.fixture +def maintenance_factory(deployment_sessions: async_sessionmaker[AsyncSession]) -> MaintenanceFactory: + """提供可记录维护操作的运行时工厂。""" + return MaintenanceFactory(deployment_sessions) + + +@pytest.fixture +def service( + deployment_sessions: async_sessionmaker[AsyncSession], + deployment_config: PluginDeploymentConfig, + maintenance_factory: MaintenanceFactory, + monkeypatch: pytest.MonkeyPatch, +) -> PluginDeploymentService: + """创建使用临时数据库与测试生命周期锁的发布服务。""" + monkeypatch.setattr(sys, 'dont_write_bytecode', sys.dont_write_bytecode) + return PluginDeploymentService( + deployment_config, + session_factory=deployment_sessions, + lifecycle_lock=NoopPluginLifecycleLock(), + runtime_factory=maintenance_factory, + ) + + +async def import_prepare_select(service: PluginDeploymentService, archive: Path) -> dict[str, Any]: + """依次导入、维护准备并选择测试制品。""" + imported = await service.catalog.import_artifact(archive) + prepared = await service.prepare(PLUGIN_ID, imported['digest'], maintenance=True, actor='operator') + return await service.select( + PLUGIN_ID, + imported['digest'], + maintenance=True, + expected_generation=prepared['generation'], + actor='operator', + ) + + +def new_archive(source: Path, destination: Path, key: Ed25519PrivateKey, version: str) -> Path: + """为指定版本创建新的签名测试制品。""" + manifest = yaml.safe_load((source / 'plugin.yaml').read_text()) + manifest['version'] = version + (source / 'plugin.yaml').write_text(yaml.safe_dump(manifest), encoding='utf-8') + build_artifact(source, destination, key, 'publisher') + return destination + + +@pytest.mark.asyncio +async def test_install_plan_prepare_select_and_pending_restart_are_distinct( + service: PluginDeploymentService, + deployment_archive: Path, + deployment_sessions: async_sessionmaker[AsyncSession], + maintenance_factory: MaintenanceFactory, +) -> None: + """验证安装计划、维护准备、目标选择和等待重启状态相互独立。""" + imported = await service.catalog.import_artifact(deployment_archive) + digest = imported['digest'] + planned = await service.plan(PLUGIN_ID, digest) + assert planned['ok'] and planned['operation'] == 'install' + assert planned['generation'] is None + assert maintenance_factory.calls == [('install', True, '1.0.0')] + async with deployment_sessions() as db: + assert await db.get(SysPlugin, PLUGIN_ID) is None + assert await db.get(SysPluginRelease, PLUGIN_ID) is None + with pytest.raises(ValueError, match='maintenance'): + await service.prepare(PLUGIN_ID, digest) + prepared = await service.prepare(PLUGIN_ID, digest, maintenance=True, actor='operator') + async with deployment_sessions() as db: + row = await db.get(SysPluginRelease, PLUGIN_ID) + assert row.target_digest is None + assert row.prepared_digest == digest + assert row.prepared_version == '1.0.0' + assert (await db.get(SysPlugin, PLUGIN_ID)).installed_version == '1.0.0' + again = await service.prepare(PLUGIN_ID, digest, maintenance=True) + assert again['ok'] + assert maintenance_factory.calls.count(('install', False, '1.0.0')) == 1 + reuse = await service.plan(PLUGIN_ID, digest) + assert reuse['ok'] and reuse['preparationMatches'] and reuse['operation'] == 'reuse_prepared' + selected = await service.select(PLUGIN_ID, digest, expected_generation=prepared['generation'], maintenance=True) + assert selected['generation'] != prepared['generation'] + assert selected['restartRequired'] + status = await service.status(PLUGIN_ID) + assert status['releases'][0]['status'] == 'pending_restart' + assert status['releases'][0]['targetDigest'] == digest + assert status['releases'][0]['installedVersion'] == '1.0.0' + assert status['workers'] == [] + assert 'plugins.artifact_demo' not in sys.modules + async with deployment_sessions() as db: + logs = (await db.scalars(select(SysPluginOperationLog))).all() + assert len(logs) == 1 and logs[0].operation == 'release_select' + + +@pytest.mark.asyncio +async def test_same_version_digest_is_not_guessed_from_installed_version( + service: PluginDeploymentService, + deployment_archive: Path, + deployment_source: Path, + deployment_key: Ed25519PrivateKey, + deployment_sessions: async_sessionmaker[AsyncSession], + tmp_path: Path, +) -> None: + """验证同版本制品必须具有匹配摘要的准备证据。""" + selected = await import_prepare_select(service, deployment_archive) + (deployment_source / 'new-file.txt').write_text('Different build with the same version') + changed = new_archive(deployment_source, tmp_path / 'different.rpk', deployment_key, '1.0.0') + imported = await service.catalog.import_artifact(changed) + assert imported['digest'] != selected['digest'] + plan = await service.plan(PLUGIN_ID, imported['digest']) + assert not plan['ok'] and not plan['preparationMatches'] + assert '不能猜测' in plan['message'] + with pytest.raises(ValueError, match='同版本'): + await service.prepare(PLUGIN_ID, imported['digest'], maintenance=True) + with pytest.raises(ValueError, match='尚未完成维护准备'): + await service.select( + PLUGIN_ID, imported['digest'], maintenance=True, expected_generation=selected['generation'] + ) + async with deployment_sessions() as db: + assert (await db.get(SysPluginRelease, PLUGIN_ID)).target_digest == selected['digest'] + + +@pytest.mark.asyncio +async def test_plan_same_version_without_any_preparation_proof_is_not_ready( + service: PluginDeploymentService, + deployment_archive: Path, + deployment_sessions: async_sessionmaker[AsyncSession], +) -> None: + """验证同版本缺少准备证据时预检不能判定可直接发布。""" + imported = await service.catalog.import_artifact(deployment_archive) + async with deployment_sessions() as db: + db.add(SysPlugin(plugin_id=PLUGIN_ID, plugin_name='existing', version='1.0.0', installed_version='1.0.0')) + await db.commit() + result = await service.plan(PLUGIN_ID, imported['digest']) + assert not result['ok'] and not result['preparationMatches'] + with pytest.raises(ValueError, match='同版本'): + await service.prepare(PLUGIN_ID, imported['digest'], maintenance=True) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('failure', ['raise', 'result', 'no-version', 'tamper']) +async def test_failed_preparation_clears_proof_and_preserves_previous_target( + service: PluginDeploymentService, + deployment_archive: Path, + deployment_source: Path, + deployment_key: Ed25519PrivateKey, + deployment_sessions: async_sessionmaker[AsyncSession], + maintenance_factory: MaintenanceFactory, + tmp_path: Path, + failure: str, +) -> None: + """验证维护准备失败清除证据但保留原发布目标。""" + selected = await import_prepare_select(service, deployment_archive) + upgrade = new_archive(deployment_source, tmp_path / 'upgrade.rpk', deployment_key, '2.0.0') + imported = await service.catalog.import_artifact(upgrade) + maintenance_factory.failure = failure + with pytest.raises((ValueError, RuntimeError)): + await service.prepare(PLUGIN_ID, imported['digest'], maintenance=True) + async with deployment_sessions() as db: + row = await db.get(SysPluginRelease, PLUGIN_ID) + assert row.target_digest == selected['digest'] + assert row.generation == selected['generation'] + assert row.prepare_status == 'failed' + assert row.prepared_digest is None and row.prepared_version is None + assert row.last_error + + +@pytest.mark.asyncio +async def test_upgrade_and_explicit_code_rollback_preserve_newer_installed_schema( + service: PluginDeploymentService, + deployment_archive: Path, + deployment_source: Path, + deployment_key: Ed25519PrivateKey, + deployment_sessions: async_sessionmaker[AsyncSession], + maintenance_factory: MaintenanceFactory, + tmp_path: Path, +) -> None: + """验证代码回滚保留升级后的数据结构安装版本。""" + first = await import_prepare_select(service, deployment_archive) + upgrade = new_archive(deployment_source, tmp_path / 'upgrade.rpk', deployment_key, '2.0.0') + second = await import_prepare_select(service, upgrade) + assert (await service.plan(PLUGIN_ID, first['digest']))['operation'] == 'rollback_required' + assert not (await service.plan(PLUGIN_ID, first['digest']))['ok'] + with pytest.raises(ValueError, match='撤销数据库迁移'): + await service.rollback(PLUGIN_ID, expected_generation=second['generation'], maintenance=True) + before = list(maintenance_factory.calls) + rolled = await service.rollback( + PLUGIN_ID, + expected_generation=second['generation'], + maintenance=True, + schema_compatible=True, + ) + assert rolled['digest'] == first['digest'] + assert rolled['version'] == '1.0.0' + assert rolled['installedVersion'] == '2.0.0' + assert rolled['generation'] != second['generation'] + assert maintenance_factory.calls == [*before, ('check', True, '1.0.0')] + async with deployment_sessions() as db: + plugin = await db.get(SysPlugin, PLUGIN_ID) + release = await db.get(SysPluginRelease, PLUGIN_ID) + assert plugin.installed_version == '2.0.0' + assert plugin.version == '1.0.0' + assert release.target_digest == first['digest'] + assert release.previous_digest == second['digest'] + assert release.prepared_version == '2.0.0' + assert release.prepared_digest == first['digest'] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('operation', ['prepare', 'select', 'rollback']) +async def test_live_host_worker_blocks_all_maintenance_mutations( + service: PluginDeploymentService, + deployment_archive: Path, + deployment_sessions: async_sessionmaker[AsyncSession], + operation: str, +) -> None: + """验证存活宿主进程阻止维护发布写操作。""" + selected = await import_prepare_select(service, deployment_archive) + async with deployment_sessions() as db: + await PluginReleaseDao.upsert_worker_report( + db, + worker_id='1' * 32, + plugin_id='__runtime__', + state='ready', + heartbeat_time=TimezoneUtil.utc_now(), + ) + await db.commit() + with pytest.raises(ValueError, match='存活的宿主 worker'): + if operation == 'prepare': + await service.prepare(PLUGIN_ID, selected['digest'], maintenance=True) + elif operation == 'select': + await service.select( + PLUGIN_ID, selected['digest'], maintenance=True, expected_generation=selected['generation'] + ) + else: + await service.rollback( + PLUGIN_ID, maintenance=True, schema_compatible=True, expected_generation=selected['generation'] + ) + status = await service.status(PLUGIN_ID) + assert status['workers'][0]['heartbeatTime'] + assert status['releases'][0]['targetDigest'] == selected['digest'] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('state', ['stopped', 'expired']) +async def test_stopped_or_expired_host_reports_do_not_block_maintenance( + service: PluginDeploymentService, + deployment_archive: Path, + deployment_sessions: async_sessionmaker[AsyncSession], + state: str, +) -> None: + """验证已停止或过期宿主报告不阻止维护操作。""" + now = TimezoneUtil.utc_now() + async with deployment_sessions() as db: + await PluginReleaseDao.upsert_worker_report( + db, + worker_id='2' * 32, + plugin_id='__runtime__', + state='stopped' if state == 'stopped' else 'ready', + heartbeat_time=now + if state == 'stopped' + else now - timedelta(seconds=service.config.worker_ttl_seconds + 1), + ) + await db.commit() + result = await import_prepare_select(service, deployment_archive) + assert result['ok'] + + +@pytest.mark.asyncio +async def test_generation_conflict_and_audit_failure_cannot_commit_target_change( + service: PluginDeploymentService, + deployment_archive: Path, + deployment_sessions: async_sessionmaker[AsyncSession], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """验证代际冲突和审计失败均不能提交目标变更。""" + imported = await service.catalog.import_artifact(deployment_archive) + prepared = await service.prepare(PLUGIN_ID, imported['digest'], maintenance=True) + with pytest.raises(PluginReleaseConflictError): + await service.select(PLUGIN_ID, imported['digest'], maintenance=True, expected_generation='f' * 32) + + async def failed_audit(*args: Any, **kwargs: Any) -> None: + raise RuntimeError('audit transaction failed') + + monkeypatch.setattr(service, '_audit', failed_audit) + with pytest.raises(RuntimeError, match='audit transaction failed'): + await service.select( + PLUGIN_ID, imported['digest'], maintenance=True, expected_generation=prepared['generation'] + ) + async with deployment_sessions() as db: + row = await db.get(SysPluginRelease, PLUGIN_ID) + assert row.target_digest is None and row.generation == prepared['generation'] + assert row.prepared_digest == imported['digest'] + + +@pytest.mark.asyncio +async def test_rollback_resource_and_target_changes_share_a_transaction( + service: PluginDeploymentService, + deployment_archive: Path, + deployment_source: Path, + deployment_key: Ed25519PrivateKey, + deployment_sessions: async_sessionmaker[AsyncSession], + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """验证回滚时声明资源与代码目标在同一事务中更新。""" + first = await import_prepare_select(service, deployment_archive) + upgrade = new_archive(deployment_source, tmp_path / 'upgrade.rpk', deployment_key, '2.0.0') + second = await import_prepare_select(service, upgrade) + + async def failed_audit(*args: Any, **kwargs: Any) -> None: + raise RuntimeError('rollback audit failed') + + monkeypatch.setattr(service, '_audit', failed_audit) + with pytest.raises(RuntimeError, match='rollback audit failed'): + await service.rollback( + PLUGIN_ID, expected_generation=second['generation'], maintenance=True, schema_compatible=True + ) + async with deployment_sessions() as db: + plugin = await db.get(SysPlugin, PLUGIN_ID) + row = await db.get(SysPluginRelease, PLUGIN_ID) + assert plugin.version == plugin.installed_version == '2.0.0' + assert row.generation == second['generation'] + assert row.target_digest == second['digest'] and row.previous_digest == first['digest'] + assert row.prepared_digest == second['digest'] + + +@pytest.mark.asyncio +async def test_already_loaded_namespace_cannot_prepare_another_artifact( + service: PluginDeploymentService, + deployment_archive: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """验证已加载插件命名空间的进程不能准备其他制品。""" + imported = await service.catalog.import_artifact(deployment_archive) + monkeypatch.setitem(sys.modules, 'plugins.artifact_demo.old_module', object()) + with pytest.raises(ValueError, match='已加载插件模块'): + await service.prepare(PLUGIN_ID, imported['digest'], maintenance=True) + + +@pytest.mark.asyncio +async def test_lifecycle_lock_denial_is_not_bypassed( + service: PluginDeploymentService, deployment_archive: Path +) -> None: + """验证无法取得生命周期锁时不执行维护操作。""" + + class DeniedLock: + @asynccontextmanager + async def lock(self, plugin_id: str, operation: str) -> AsyncGenerator[PluginLifecycleLockResult, None]: + yield PluginLifecycleLockResult(False, 'maintenance already running') + + imported = await service.catalog.import_artifact(deployment_archive) + service.lifecycle_lock = DeniedLock() + with pytest.raises(ValueError, match='maintenance already running'): + await service.prepare(PLUGIN_ID, imported['digest'], maintenance=True) + + +@pytest.mark.asyncio +async def test_real_maintenance_runtime_prepares_minimal_signed_artifact_in_sqlite( + deployment_config: PluginDeploymentConfig, + deployment_archive: Path, + deployment_sessions: async_sessionmaker[AsyncSession], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """验证真实维护网关完成资源同步、安装状态和审计写入且不导入插件模块。""" + monkeypatch.setattr(DataSourceRegistry, 'session', staticmethod(deployment_sessions)) + monkeypatch.setattr(sys, 'dont_write_bytecode', sys.dont_write_bytecode) + actual = PluginDeploymentService( + deployment_config, + session_factory=deployment_sessions, + lifecycle_lock=NoopPluginLifecycleLock(), + ) + imported = await actual.catalog.import_artifact(deployment_archive) + planned = await actual.plan(PLUGIN_ID, imported['digest']) + assert planned['ok'], planned + async with deployment_sessions() as db: + assert await db.get(SysPlugin, PLUGIN_ID) is None + assert await db.get(SysPluginRelease, PLUGIN_ID) is None + prepared = await actual.prepare(PLUGIN_ID, imported['digest'], maintenance=True, actor='smoke-test') + assert prepared['ok'] + async with deployment_sessions() as db: + plugin = await db.get(SysPlugin, PLUGIN_ID) + release = await db.get(SysPluginRelease, PLUGIN_ID) + assert plugin.installed_version == '1.0.0' + assert plugin.source == 'artifact' + assert plugin.backend_path == f'artifact:{imported["digest"]}' + assert release.prepare_status == 'prepared' + assert release.prepared_digest == imported['digest'] + assert release.target_digest is None + logs = (await db.scalars(select(SysPluginOperationLog))).all() + assert len(logs) == 1 and logs[0].operation == 'install' + assert 'plugins.artifact_demo' not in sys.modules + assert not list(deployment_config.store_root.rglob('*.pyc')) + assert (await actual.catalog.get(imported['digest'])).digest == imported['digest'] + + +@pytest.mark.asyncio +async def test_status_reports_disabled_only_after_worker_confirms_current_generation( + service: PluginDeploymentService, + deployment_archive: Path, + deployment_sessions: async_sessionmaker[AsyncSession], +) -> None: + """验证进程确认当前代际停止后才显示已停用状态。""" + selected = await import_prepare_select(service, deployment_archive) + async with deployment_sessions() as db: + plugin = await db.get(SysPlugin, PLUGIN_ID) + plugin.enabled = '1' + await PluginReleaseDao.upsert_worker_report( + db, + worker_id='d' * 32, + plugin_id='__runtime__', + state='ready', + heartbeat_time=TimezoneUtil.utc_now(), + ) + await db.commit() + pending = (await service.status(PLUGIN_ID))['releases'][0] + assert not pending['enabled'] + assert pending['status'] == 'pending_restart' and pending['restartRequired'] + async with deployment_sessions() as db: + await PluginReleaseDao.upsert_worker_report( + db, + worker_id='d' * 32, + plugin_id=PLUGIN_ID, + state='stopped', + heartbeat_time=TimezoneUtil.utc_now(), + artifact_digest=selected['digest'], + version=selected['version'], + generation=selected['generation'], + ) + await db.commit() + confirmed = (await service.status(PLUGIN_ID))['releases'][0] + assert confirmed['status'] == 'disabled' + assert confirmed['disabledWorkers'] == 1 + assert not confirmed['restartRequired'] + + +@pytest.mark.asyncio +async def test_wait_assertion_uses_existing_release_reports_without_database_writes( + service: PluginDeploymentService, + deployment_archive: Path, + deployment_sessions: async_sessionmaker[AsyncSession], +) -> None: + """完整服务通过真实查询验收固定代际,期间不写发布、worker 或审计记录。""" + selected = await import_prepare_select(service, deployment_archive) + async with deployment_sessions() as db: + for plugin_id in ('__runtime__', PLUGIN_ID): + await PluginReleaseDao.upsert_worker_report( + db, + worker_id='d' * 32, + plugin_id=plugin_id, + state='ready', + heartbeat_time=TimezoneUtil.utc_now(), + artifact_digest=selected['digest'] if plugin_id == PLUGIN_ID else None, + generation=selected['generation'] if plugin_id == PLUGIN_ID else None, + version=selected['version'] if plugin_id == PLUGIN_ID else None, + ) + await db.commit() + statements = [] + engine = deployment_sessions.kw['bind'].sync_engine + + def record_statement( + connection: object, cursor: object, statement: str, parameters: object, context: object, executemany: bool + ) -> None: + """记录验收阶段 SQL,不修改当前数据库连接。""" + statements.append(statement) + + event.listen(engine, 'before_cursor_execute', record_statement) + try: + result = await service.wait( + PLUGIN_ID, + selected['digest'], + expected_generation=selected['generation'], + expected_workers=1, + timeout_seconds=0, + ) + finally: + event.remove(engine, 'before_cursor_execute', record_statement) + assert result['reason'] == 'converged' and result['ok'] is True + assert statements and all(statement.lstrip().upper().startswith('SELECT') for statement in statements) diff --git a/ruoyi-fastapi-backend/tests/plugins/core/deployment/test_task_delivery.py b/ruoyi-fastapi-backend/tests/plugins/core/deployment/test_task_delivery.py new file mode 100644 index 0000000..1b5ac37 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/deployment/test_task_delivery.py @@ -0,0 +1,83 @@ +import sys +from pathlib import Path + +import pytest +from sqlalchemy import Integer, MetaData +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from config.database import DataSourceRegistry +from config.env import DataBaseConfig +from module_admin.entity.do.menu_do import SysMenu +from plugins.core.deployment.config import PluginDeploymentConfig +from plugins.core.deployment.service import PluginDeploymentService +from plugins.core.lifecycle.migration import PluginMigrationRunner +from plugins.core.runtime.service.lifecycle_lock import NoopPluginLifecycleLock +from scripts.plugin_task_integration import EXPECTED_MIGRATIONS, exercise_task_delivery + +SOURCE = Path(__file__).resolve().parents[4] / 'plugins' / 'examples' / 'python' / 'task_demo' + + +@pytest.mark.asyncio +async def test_task_signed_delivery_preserves_data_and_exercises_crud( + deployment_sessions: async_sessionmaker[AsyncSession], + deployment_config: PluginDeploymentConfig, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + 在 SQLite 中适配时间类型后执行 DDL,验证真实维护流程及交付包业务接口。 + + :param deployment_sessions: 临时宿主表的数据库会话工厂 + :param deployment_config: 临时制品存储和信任配置 + :param tmp_path: 本次测试的独立目录 + :param monkeypatch: pytest 环境隔离工具 + :return: None + """ + import shutil # noqa: PLC0415 + + # SQLite 只有 INTEGER PRIMARY KEY 才会生成自增值,宿主生产表使用 BIGINT。 + sqlite_menu = SysMenu.__table__.to_metadata(MetaData()) + sqlite_menu.c.menu_id.type = Integer() + async with deployment_sessions.kw['bind'].begin() as connection: + await connection.run_sync(SysMenu.__table__.drop) + await connection.run_sync(sqlite_menu.create) + # SQLite 不能同时持有生命周期 UoW 与迁移历史的两个写事务;只对宿主维护会话使用自动提交。 + # 业务 CRUD 保留下面传入的常规事务会话,真实数据库 CI 不使用此适配。 + host_sessions = async_sessionmaker( + deployment_sessions.kw['bind'].execution_options(isolation_level='AUTOCOMMIT'), expire_on_commit=False + ) + monkeypatch.setattr(DataSourceRegistry, 'session', host_sessions) + monkeypatch.setattr(DataBaseConfig.default_source, 'db_type', 'postgresql') + monkeypatch.setattr(sys, 'dont_write_bytecode', True) + original_loader = PluginMigrationRunner._load_sql_statements + + def sqlite_statements(runner: PluginMigrationRunner, path: Path) -> list[str]: + """ + 仅替换 SQLite 不支持的 PostgreSQL 时间列语法,保留原文件与校验值。 + + :param runner: 本次迁移运行器 + :param path: 原始迁移文件 + :return: 保持业务 DDL 不变的 SQLite 可执行语句 + """ + return [ + statement.replace('TIMESTAMP(3) WITH TIME ZONE', 'DATETIME') for statement in original_loader(runner, path) + ] + + monkeypatch.setattr(PluginMigrationRunner, '_load_sql_statements', sqlite_statements) + source = tmp_path / 'source' + shutil.copytree(SOURCE, source, ignore=shutil.ignore_patterns('node_modules', 'dist', '__pycache__')) + dist = source / 'web' / 'dist' + dist.mkdir(parents=True) + (dist / 'index.html').write_text('Task fixture', encoding='utf-8') + service = PluginDeploymentService( + deployment_config, session_factory=host_sessions, lifecycle_lock=NoopPluginLifecycleLock() + ) + try: + result = await exercise_task_delivery(service, deployment_sessions, source, tmp_path / 'delivery') + assert result['version'] == '1.1.0' + assert result['migrations'] == EXPECTED_MIGRATIONS and result['retainedRows'] == 1 + assert result['crud'] and result['readOnlyDenied'] + finally: + for name in list(sys.modules): + if name == 'plugins.task_demo' or name.startswith('plugins.task_demo.'): + monkeypatch.delitem(sys.modules, name) diff --git a/ruoyi-fastapi-backend/tests/plugins/core/deployment/test_worker.py b/ruoyi-fastapi-backend/tests/plugins/core/deployment/test_worker.py new file mode 100644 index 0000000..f6a495a --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/deployment/test_worker.py @@ -0,0 +1,615 @@ +import asyncio +import base64 +import json +import multiprocessing +import os +import shutil +import sys +from collections.abc import AsyncGenerator, AsyncIterator +from contextlib import asynccontextmanager +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock + +import pytest +import pytest_asyncio +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from fastapi import FastAPI +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from config.database import Base +from plugins.core.artifacts import build_artifact +from plugins.core.deployment.catalog import PluginArtifactCatalog +from plugins.core.deployment.config import PluginDeploymentConfig +from plugins.core.deployment.service import PluginDeploymentService +from plugins.core.deployment.state import aggregate_release +from plugins.core.deployment.worker import PluginReleaseWorker +from plugins.core.discovery.registry import PluginRegistry +from plugins.core.management.dao.dao import PluginDao +from plugins.core.management.dao.release_dao import PluginReleaseDao +from plugins.core.management.entity.do.models import SysPlugin +from plugins.core.management.entity.do.release_models import SysPluginArtifact, SysPluginRelease, SysPluginWorker +from plugins.core.runtime.application import PluginApplicationRuntime +from plugins.core.runtime.bootstrap import PluginRuntimeBuilder +from plugins.core.runtime.explicit import ExplicitPluginRuntime +from plugins.core.runtime.service.lifecycle_lock import NoopPluginLifecycleLock +from plugins.core.runtime.startup import PluginRuntimeStartupManager +from utils.time_util import TimezoneUtil + + +@pytest_asyncio.fixture +async def deployment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[SimpleNamespace]: + """构造真实签名目录与临时 SQLite 发布数据库。""" + backend = tmp_path / 'backend' + (backend / 'plugins').mkdir(parents=True) + key = Ed25519PrivateKey.generate() + public = base64.b64encode( + key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + ).decode() + trust = tmp_path / 'trusted.json' + trust.write_text( + json.dumps( + {'schemaVersion': 1, 'keys': [{'keyId': 'test', 'publicKey': public, 'pluginIds': ['release_demo']}]} + ), + encoding='utf-8', + ) + config = PluginDeploymentConfig(backend, tmp_path / 'store', trust, heartbeat_seconds=1, worker_ttl_seconds=6) + database = tmp_path / 'workers.sqlite' + engine = create_async_engine(f'sqlite+aiosqlite:///{database}') + async with engine.begin() as connection: + await connection.run_sync( + Base.metadata.create_all, + tables=[ + SysPlugin.__table__, + SysPluginArtifact.__table__, + SysPluginRelease.__table__, + SysPluginWorker.__table__, + ], + ) + sessions = async_sessionmaker(engine, expire_on_commit=False) + monkeypatch.setattr(sys, 'dont_write_bytecode', sys.dont_write_bytecode) + try: + yield SimpleNamespace(config=config, sessions=sessions, key=key, root=tmp_path, database=database) + finally: + for name in list(sys.modules): + if name == 'plugins.release_demo' or name.startswith('plugins.release_demo.'): + sys.modules.pop(name) + await engine.dispose() + + +async def publish(deployment: SimpleNamespace, version: str = '1.0.0', *, fail: bool = False) -> str: + """登记指定版本制品并建立测试需要的目标发布记录。""" + source = deployment.root / f'source-{version}' / 'release_demo' + source.mkdir(parents=True) + (source / 'plugin.yaml').write_text( + f'manifestVersion: 2\nid: release_demo\nname: Release test\nversion: {version}\n' + 'backend:\n runtime: python\n integration: asgi\n module: plugins.release_demo\n' + ' entrypoint: plugins.release_demo:create_plugin\n' + 'frontend:\n delivery:\n type: none\n', + encoding='utf-8', + ) + (source / '__init__.py').write_text( + 'from contextlib import asynccontextmanager\n' + 'from fastapi import FastAPI\n' + 'from plugins.core.sdk import PluginDefinition\n' + f'LOADED_VERSION = {version!r}\n' + 'def create_plugin(host):\n' + ' assert not host.startup_write_enabled\n' + ' return PluginDefinition(app_factory=create_app)\n' + 'def create_app(host):\n' + ' @asynccontextmanager\n' + ' async def lifespan(app):\n' + + (' raise RuntimeError("broken lifespan")\n' if fail else '') + + ' yield\n' + ' return FastAPI(lifespan=lifespan)\n', + encoding='utf-8', + ) + package = deployment.root / f'v{version}.rpk' + build_artifact(source, package, deployment.key, 'test') + catalog = PluginArtifactCatalog(deployment.config, session_factory=deployment.sessions) + result = await catalog.import_artifact(package) + async with deployment.sessions() as db: + plugin = await PluginDao.get_plugin_by_id(db, 'release_demo') + if plugin is None: + db.add( + SysPlugin( + plugin_id='release_demo', + plugin_name='Release', + version=version, + installed_version=version, + source='artifact', + status='installed', + enabled='0', + ) + ) + else: + plugin.version = plugin.installed_version = version + await db.flush() + release = await PluginReleaseDao.set_preparation_status( + db, 'release_demo', status='prepared', prepared_digest=result['digest'], prepared_version=version + ) + await PluginReleaseDao.select_target( + db, + 'release_demo', + target_digest=result['digest'], + expected_generation=release.generation, + expected_workers=2, + ) + await db.commit() + return result['digest'] + + +async def activate_worker(config: PluginDeploymentConfig, sessions: Any) -> tuple[Any, Any]: + """创建进程发布快照并实际准备和激活插件入口。""" + app = FastAPI() + builder = PluginRuntimeBuilder(config.backend_root) + app.state.plugin_runtime_builder = builder + worker = PluginReleaseWorker(config, session_factory=sessions) + await worker.start(app, builder, NoopPluginLifecycleLock()) + async with sessions() as db: + registry = PluginRegistry.build(builder.discover_plugins(), await PluginDao.get_plugin_list(db)) + app.state.plugin_registry = registry + runtime = ExplicitPluginRuntime(SimpleNamespace()) + app.state.plugin_explicit_runtime = runtime + for plugin in registry.list_enabled_plugins(): + runtime.prepare(plugin, app, startup_write_enabled=True) + try: + await runtime.activate(plugin.plugin_id, app) + except RuntimeError: + pass + await worker.ready() + return worker, runtime + + +async def summary(deployment: SimpleNamespace, plugin_id: str = 'release_demo') -> dict[str, Any]: + """读取发布记录和宿主报告并汇总当前集群状态。""" + async with deployment.sessions() as db: + release = await PluginReleaseDao.get_release(db, plugin_id) + reports = await PluginReleaseDao.list_worker_reports(db) + return aggregate_release(release, reports, now=TimezoneUtil.utc_now(), ttl_seconds=6).to_payload() + + +@pytest.mark.asyncio +async def test_real_entrypoint_ready_reports_and_shutdown(deployment: SimpleNamespace) -> None: + """验证真实入口激活、就绪上报和关闭后的状态变化。""" + digest = await publish(deployment) + worker, runtime = await activate_worker(deployment.config, deployment.sessions) + try: + state = await summary(deployment) + assert state['status'] == 'partial' + assert state['healthyWorkers'] == 1 + assert sys.modules['plugins.release_demo'].LOADED_VERSION == '1.0.0' + assert worker.targets[0].digest == digest + await worker.catalog.get(digest) # 导入入口和 lifespan 后不可变目录仍逐字节匹配。 + assert not list(deployment.config.store_root.rglob('__pycache__')) + finally: + await runtime.shutdown() + await worker.stop() + assert (await summary(deployment))['status'] == 'pending_restart' + + +@pytest.mark.asyncio +async def test_failed_lifespan_never_reports_ready(deployment: SimpleNamespace) -> None: + """验证应用生命周期失败不会被上报为插件就绪。""" + await publish(deployment, fail=True) + worker, runtime = await activate_worker(deployment.config, deployment.sessions) + try: + state = await summary(deployment) + assert state['status'] == 'failed' + assert state['failedWorkers'] == 1 + assert state['healthyWorkers'] == 0 + finally: + await runtime.shutdown() + await worker.stop() + + +@pytest.mark.asyncio +async def test_missing_preparation_and_source_conflict_never_fall_back(deployment: SimpleNamespace) -> None: + """验证准备证据缺失或源码冲突时不会回退加载源码插件。""" + await publish(deployment) + async with deployment.sessions() as db: + await PluginReleaseDao.set_preparation_status(db, 'release_demo', status='failed', last_error='failed prepare') + await db.commit() + worker = PluginReleaseWorker(deployment.config, session_factory=deployment.sessions) + builder = PluginRuntimeBuilder(deployment.config.backend_root) + app = FastAPI() + try: + await worker.start(app, builder, NoopPluginLifecycleLock()) + assert builder.discover_plugins() == [] + assert '维护准备' in worker.errors['release_demo'] + assert 'plugins.release_demo' not in sys.modules + await worker.ready() + assert (await summary(deployment))['status'] == 'failed' + finally: + await worker.stop() + + +@pytest.mark.asyncio +async def test_tampered_selected_artifact_is_isolated(deployment: SimpleNamespace) -> None: + """验证已选择制品被篡改后会被隔离而不导入。""" + digest = await publish(deployment) + catalog = PluginArtifactCatalog(deployment.config, session_factory=deployment.sessions) + artifact = await catalog.get(digest) + (artifact.plugin_path / '__init__.py').write_text('raise RuntimeError("must not execute")', encoding='utf-8') + worker = PluginReleaseWorker(deployment.config, session_factory=deployment.sessions) + builder = PluginRuntimeBuilder(deployment.config.backend_root) + try: + await worker.start(FastAPI(), builder, NoopPluginLifecycleLock()) + assert builder.discover_plugins() == [] + assert worker.errors['release_demo'] + assert 'plugins.release_demo' not in sys.modules + finally: + await worker.stop() + + +@pytest.mark.asyncio +async def test_imported_unselected_id_cannot_fall_back_to_a_source_directory(deployment: SimpleNamespace) -> None: + """验证已导入但未选目标的插件ID不能回退到同名源码目录。""" + await publish(deployment) + async with deployment.sessions() as db: + release = await PluginReleaseDao.get_release(db, 'release_demo') + release.target_digest = None + await db.commit() + shutil.copytree( + deployment.root / 'source-1.0.0' / 'release_demo', deployment.config.backend_root / 'plugins' / 'release_demo' + ) + worker = PluginReleaseWorker(deployment.config, session_factory=deployment.sessions) + builder = PluginRuntimeBuilder(deployment.config.backend_root) + try: + await worker.start(FastAPI(), builder, NoopPluginLifecycleLock()) + assert not worker.targets + assert builder.discover_plugins() == [] + assert any('同时存在' in item.error_message for item in builder.discovery_errors) + assert 'plugins.release_demo' not in sys.modules + finally: + await worker.stop() + + +@pytest.mark.asyncio +async def test_disabled_snapshot_reports_stopped_without_executing_entrypoint(deployment: SimpleNamespace) -> None: + """验证停用快照只报告停止状态且不执行入口。""" + await publish(deployment) + async with deployment.sessions() as db: + plugin = await PluginDao.get_plugin_by_id(db, 'release_demo') + plugin.enabled = '1' + release = await PluginReleaseDao.get_release(db, 'release_demo') + release.expected_workers = 1 + await db.commit() + worker, runtime = await activate_worker(deployment.config, deployment.sessions) + try: + assert 'plugins.release_demo' not in sys.modules + status = await PluginDeploymentService(deployment.config, session_factory=deployment.sessions).status( + 'release_demo' + ) + assert status['releases'][0]['status'] == 'disabled' + assert status['releases'][0]['disabledWorkers'] == 1 + assert not status['releases'][0]['restartRequired'] + finally: + await runtime.shutdown() + await worker.stop() + + +@pytest.mark.asyncio +async def test_snapshot_change_rejected_and_barrier_generation_changes(deployment: SimpleNamespace) -> None: + """验证发布快照变化被拒绝且启动屏障代际随目标变化。""" + await publish(deployment) + worker = PluginReleaseWorker(deployment.config, session_factory=deployment.sessions) + try: + await worker.start(FastAPI(), PluginRuntimeBuilder(deployment.config.backend_root), NoopPluginLifecycleLock()) + before = worker.generation('fixed-app-release') + await publish(deployment, '2.0.0') + with pytest.raises(RuntimeError, match='变化'): + await worker.assert_snapshot_current() + second = PluginReleaseWorker(deployment.config, session_factory=deployment.sessions) + try: + await second.start( + FastAPI(), PluginRuntimeBuilder(deployment.config.backend_root), NoopPluginLifecycleLock() + ) + assert second.generation('fixed-app-release') != before + finally: + await second.stop() + finally: + await worker.stop() + + +@pytest.mark.asyncio +async def test_starting_host_blocks_maintenance_even_without_targets(deployment: SimpleNamespace) -> None: + """验证没有插件目标的启动中宿主仍会阻止维护。""" + worker = PluginReleaseWorker(deployment.config, session_factory=deployment.sessions) + service = PluginDeploymentService(deployment.config, session_factory=deployment.sessions) + try: + await worker.start(FastAPI(), PluginRuntimeBuilder(deployment.config.backend_root), NoopPluginLifecycleLock()) + with pytest.raises(ValueError, match='存活'): + await service._require_quiescent() + finally: + await worker.stop() + await service._require_quiescent() + + +@pytest.mark.asyncio +async def test_heartbeat_refreshes_actual_snapshot_and_failed_host_is_not_active(deployment: SimpleNamespace) -> None: + """验证心跳刷新实际快照且失败宿主不能形成 active 状态。""" + await publish(deployment) + worker, runtime = await activate_worker(deployment.config, deployment.sessions) + try: + await asyncio.sleep(1.1) + assert worker.task is not None and not worker.task.done() + await worker.stop(failed=True) + assert (await summary(deployment))['status'] == 'failed' + finally: + await runtime.shutdown() + await worker.stop() + + +@pytest.mark.asyncio +async def test_real_application_startup_does_not_run_artifact_install_or_resource_writes( + deployment: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + """验证真实应用启动不会再次执行制品安装或全局资源写入。""" + await publish(deployment) + monkeypatch.setattr('config.database.DataSourceRegistry.session', deployment.sessions) + + class ReadOnlyGateway: + list_plugins = staticmethod(PluginDao.get_plugin_list) + install_plugin_resources = AsyncMock( + side_effect=AssertionError('startup must not reinstall artifact resources') + ) + upsert_discovered_plugin = AsyncMock(side_effect=AssertionError('startup must not install artifacts')) + + gateway = ReadOnlyGateway() + builder = PluginRuntimeBuilder(deployment.config.backend_root) + manager = PluginRuntimeStartupManager( + builder, + management_gateway=gateway, + route_state_gateway=SimpleNamespace(), + default_enabled_builtin_plugin_ids={'release_demo'}, + ) + worker = PluginReleaseWorker(deployment.config, session_factory=deployment.sessions) + runtime = PluginApplicationRuntime(manager, release_worker=worker, startup_generation='app-release') + app = FastAPI() + app.state.redis = SimpleNamespace(get=AsyncMock(return_value=None), set=AsyncMock(), delete=AsyncMock()) + create_tables = AsyncMock() + try: + await runtime.startup(app, create_tables=create_tables) + assert app.state.plugin_startup_generation != 'app-release' + assert app.state.plugin_release_worker is worker + assert app.state.plugin_explicit_runtime.loaded['release_demo'].active + gateway.upsert_discovered_plugin.assert_not_awaited() + gateway.install_plugin_resources.assert_not_awaited() + assert (await summary(deployment))['healthyWorkers'] == 1 + assert not await manager.requires_startup_write() + finally: + await runtime.shutdown(app) + + +@pytest.mark.asyncio +async def test_worker_failure_does_not_mark_global_plugin_error(deployment: SimpleNamespace) -> None: + """验证单个进程失败不会覆盖插件的全局安装状态。""" + await publish(deployment) + worker, runtime = await activate_worker(deployment.config, deployment.sessions) + app = worker.app + app.state.plugin_release_worker = worker + gateway = SimpleNamespace(mark_plugin_error=AsyncMock(side_effect=AssertionError('must remain per-worker'))) + manager = PluginRuntimeStartupManager(management_gateway=gateway) + try: + await manager.mark_plugin_runtime_error(app, 'release_demo', 'one worker failed') + await worker.report() + assert (await summary(deployment))['status'] == 'failed' + async with deployment.sessions() as db: + plugin = await PluginDao.get_plugin_by_id(db, 'release_demo') + assert plugin.status == 'installed' + assert plugin.enabled == '0' + finally: + await runtime.shutdown() + await worker.stop() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('failure_mode', ['writer', 'reader']) +async def test_failed_artifact_stays_disabled_after_source_failure_reloads_registry( + deployment: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, failure_mode: str +) -> None: + """验证源码失败触发注册表重载后,失败制品仍保持禁用。""" + await publish(deployment) + monkeypatch.setattr('config.database.DataSourceRegistry.session', deployment.sessions) + builder = PluginRuntimeBuilder(deployment.config.backend_root) + gateway = SimpleNamespace( + list_plugins=PluginDao.get_plugin_list, + mark_plugin_error=AsyncMock(return_value=SimpleNamespace(is_success=True)), + ) + manager = PluginRuntimeStartupManager(builder, management_gateway=gateway) + app = FastAPI() + worker = PluginReleaseWorker(deployment.config, session_factory=deployment.sessions) + if failure_mode == 'writer': + app.state.plugin_release_worker = worker + run_hook = AsyncMock() + monkeypatch.setattr(manager, 'run_single_plugin_hook', run_hook) + try: + await worker.start(app, builder, NoopPluginLifecycleLock()) + await manager.load_registry_from_database(app) + assert app.state.plugin_registry.get_plugin('release_demo').enabled + + if failure_mode == 'writer': + await manager.mark_plugin_runtime_error(app, 'release_demo', 'entrypoint preparation failed') + assert worker.errors['release_demo'] == 'entrypoint preparation failed' + else: + manager.disable_runtime_plugins(app, {'release_demo'}) + assert not hasattr(app.state, 'plugin_release_worker') + assert not app.state.plugin_registry.get_plugin('release_demo').enabled + failed_registry = app.state.plugin_registry + + await manager.mark_plugin_runtime_error(app, 'source_demo', 'source entity import failed') + assert app.state.plugin_registry is not failed_registry + assert not app.state.plugin_registry.get_plugin('release_demo').enabled + gateway.mark_plugin_error.assert_awaited_once() + assert gateway.mark_plugin_error.await_args.args[1] == 'source_demo' + + await manager.run_enabled_plugin_hooks(app, 'on_startup') + run_hook.assert_not_awaited() + finally: + await worker.stop() + + +def _run_process(config: PluginDeploymentConfig, database: str, ready: Any, stop: Any) -> None: + """在独立进程中实际激活制品并报告加载路径和版本。""" + + async def run() -> None: + engine = create_async_engine(f'sqlite+aiosqlite:///{database}') + sessions = async_sessionmaker(engine, expire_on_commit=False) + worker = runtime = None + try: + worker, runtime = await activate_worker(config, sessions) + target = worker.targets[0] + loaded = runtime.loaded[target.plugin_id] + module_name = loaded.plugin.discovered_plugin.manifest.backend.entrypoint.split(':')[0] + module = sys.modules[module_name] + ready.put( + { + 'workerId': worker.worker_id, + 'version': getattr(module, 'LOADED_VERSION', worker.versions[target.plugin_id]), + 'modulePath': module.__file__, + 'active': loaded.active, + } + ) + await asyncio.to_thread(stop.wait, 45) + except BaseException as exc: + ready.put({'error': repr(exc)}) + raise + finally: + if runtime: + await runtime.shutdown() + if worker: + await worker.stop() + await engine.dispose() + + asyncio.run(run()) + + +@asynccontextmanager +async def running_processes(deployment: SimpleNamespace, count: int = 1) -> AsyncGenerator[list[dict[str, Any]], None]: + """启动指定数量的独立测试进程并在退出时完整回收。""" + context = multiprocessing.get_context('spawn') + ready = context.Queue() + stop = context.Event() + processes = [ + context.Process(target=_run_process, args=(deployment.config, str(deployment.database), ready, stop)) + for _ in range(count) + ] + try: + for process in processes: + process.start() + reports = [await asyncio.to_thread(ready.get, True, 30) for _ in processes] + yield reports + finally: + stop.set() + for process in processes: + await asyncio.to_thread(process.join, 15) + if process.is_alive(): + process.terminate() + await asyncio.to_thread(process.join, 5) + ready.close() + ready.join_thread() + assert all(process.exitcode == 0 for process in processes) + + +@pytest.mark.asyncio +async def test_two_spawned_processes_load_only_target_and_converge(deployment: SimpleNamespace) -> None: + """验证两个独立进程只加载目标制品并汇总为就绪。""" + await publish(deployment) + expected_worker_count = 2 + async with running_processes(deployment, expected_worker_count) as reports: + assert all(item.get('version') == '1.0.0' for item in reports), reports + assert reports[0]['workerId'] != reports[1]['workerId'] + state = await summary(deployment) + assert state['status'] == 'active' + assert state['healthyWorkers'] == state['liveWorkers'] == expected_worker_count + assert (await summary(deployment))['status'] == 'pending_restart' + + +@pytest.mark.asyncio +async def test_restarted_process_loads_upgrade_then_rollback_without_downgrading_schema( + deployment: SimpleNamespace, +) -> None: + """验证新进程加载升级及回滚代码时不倒退数据结构安装版本。""" + first = await publish(deployment) + async with running_processes(deployment) as reports: + assert reports[0]['version'] == '1.0.0' + await publish(deployment, '2.0.0') # 模拟绕过维护守卫的外部目标变化,旧进程不能冒充新版本。 + state = await summary(deployment) + assert state['mismatchWorkers'] == 1 + assert state['healthyWorkers'] == 0 + async with running_processes(deployment) as reports: + assert reports[0]['version'] == '2.0.0' + async with deployment.sessions() as db: + release = await PluginReleaseDao.get_release(db, 'release_demo') + generation = release.generation + await PluginReleaseDao.set_preparation_status( + db, + 'release_demo', + status='prepared', + prepared_digest=first, + prepared_version='2.0.0', + expected_generation=generation, + ) + await PluginReleaseDao.select_target( + db, + 'release_demo', + target_digest=first, + expected_generation=generation, + expected_workers=1, + ) + await db.commit() + async with running_processes(deployment) as reports: + assert reports[0]['version'] == '1.0.0' + assert (await summary(deployment))['status'] == 'active' + async with deployment.sessions() as db: + assert (await PluginDao.get_plugin_by_id(db, 'release_demo')).installed_version == '2.0.0' + + +@pytest.mark.asyncio +@pytest.mark.skipif(not os.environ.get('RUOYI_NATIVE_PLUGIN_DIR'), reason='需要预先构建的真实 Rust 制品') +async def test_signed_native_artifact_loads_from_store_in_clean_process(deployment: SimpleNamespace) -> None: + """验证干净进程从不可变存储实际加载签名原生制品。""" + trust = json.loads(deployment.config.trust_file.read_text(encoding='utf-8')) + trust['keys'][0]['pluginIds'] = ['rust_demo'] + deployment.config.trust_file.write_text(json.dumps(trust), encoding='utf-8') + package = deployment.root / 'rust.rpk' + build_artifact(Path(os.environ['RUOYI_NATIVE_PLUGIN_DIR']), package, deployment.key, 'test') + catalog = PluginArtifactCatalog(deployment.config, session_factory=deployment.sessions) + result = await catalog.import_artifact(package) + async with deployment.sessions() as db: + db.add( + SysPlugin( + plugin_id='rust_demo', + plugin_name='Rust', + version=result['version'], + installed_version=result['version'], + status='installed', + source='artifact', + enabled='0', + ) + ) + await db.flush() + release = await PluginReleaseDao.set_preparation_status( + db, + 'rust_demo', + status='prepared', + prepared_digest=result['digest'], + prepared_version=result['version'], + ) + await PluginReleaseDao.select_target( + db, + 'rust_demo', + target_digest=result['digest'], + expected_generation=release.generation, + ) + await db.commit() + async with running_processes(deployment) as reports: + assert reports[0].get('active'), reports + native_path = Path(reports[0]['modulePath']) + assert native_path.is_relative_to(deployment.config.store_root) + assert native_path.suffix in {'.pyd', '.so'} + assert (await summary(deployment, 'rust_demo'))['status'] == 'active' + await catalog.get(result['digest']) diff --git a/ruoyi-fastapi-backend/tests/plugins/core/management/test_artifact_views.py b/ruoyi-fastapi-backend/tests/plugins/core/management/test_artifact_views.py new file mode 100644 index 0000000..ef4a459 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/management/test_artifact_views.py @@ -0,0 +1,151 @@ +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from plugins.core.discovery.scanner import DiscoveredPlugin +from plugins.core.management.dao.dao import PluginDao +from plugins.core.management.entity.do.models import SysPlugin +from plugins.core.management.entity.vo.schemas import PluginPageQueryModel +from plugins.core.management.service import release_view +from plugins.core.management.service.service import PluginService +from plugins.core.manifest.schema import PluginManifestFactory + +DIGEST = 'a' * 64 +PREVIOUS_DIGEST = 'b' * 64 +GENERATION = 'c' * 32 + + +def artifact_record() -> SysPlugin: + """构造管理视图使用的制品来源插件记录。""" + return SysPlugin( + plugin_id='demo', + plugin_name='Demo', + version='2.0.0', + installed_version='2.0.0', + source='artifact', + enabled='0', + status='installed', + backend_path=f'artifact:{DIGEST}', + ) + + +@pytest.mark.asyncio +async def test_disabled_feature_preserves_artifact_identity_and_never_queries_release_tables( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """验证功能关闭时保留制品身份且不查询发布表。""" + plugin = artifact_record() + monkeypatch.setattr(PluginDao, 'get_plugin_by_id', AsyncMock(return_value=plugin)) + monkeypatch.setattr(PluginDao, 'get_plugin_list', AsyncMock(return_value=[plugin])) + monkeypatch.setattr(release_view.PluginDeploymentConfig, 'from_settings', lambda _: SimpleNamespace(enabled=False)) + get_release = AsyncMock(side_effect=AssertionError('feature disabled must not query release tables')) + monkeypatch.setattr(release_view.PluginReleaseDao, 'get_release', get_release) + backend_plugins = tmp_path / 'backend' / 'plugins' + model = await PluginService.plugin_detail_services(object(), 'demo', backend_root=backend_plugins) + assert model.source == 'artifact' + assert model.installed_version == '2.0.0' + assert model.release['status'] == 'unavailable' + assert model.capability['runtimeManageable'] is False + assert 'purge' in model.capability['blockedOperations'] + items = await PluginService.get_plugin_page_list_services( + object(), PluginPageQueryModel(), is_page=False, backend_root=backend_plugins + ) + assert len(items) == 1 + assert items[0]['source'] == 'artifact' + get_release.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('verified', [True, False]) +async def test_release_view_separates_selected_code_schema_and_worker_activation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, verified: bool +) -> None: + """验证视图区分目标代码版本、安装版本和进程激活状态。""" + release = SimpleNamespace( + plugin_id='demo', + target_digest=DIGEST, + previous_digest=PREVIOUS_DIGEST, + prepared_digest=DIGEST, + prepared_version='2.0.0', + generation=GENERATION, + expected_workers=2, + prepare_status='prepared', + last_error=None, + ) + monkeypatch.setattr( + release_view.PluginDeploymentConfig, + 'from_settings', + lambda _: SimpleNamespace(enabled=True, worker_ttl_seconds=60), + ) + monkeypatch.setattr(release_view.PluginReleaseDao, 'get_release', AsyncMock(return_value=release)) + monkeypatch.setattr(release_view.PluginReleaseDao, 'list_worker_reports', AsyncMock(return_value=[])) + manifest = PluginManifestFactory.create( + { + 'manifestVersion': 2, + 'id': 'demo', + 'name': 'Demo', + 'version': '1.0.0', + 'backend': {'module': 'plugins.demo', 'entrypoint': 'plugins.demo:create_plugin'}, + } + ) + discovered = DiscoveredPlugin( + manifest=manifest, + backend_path=tmp_path / 'immutable' / DIGEST, + manifest_path=tmp_path / 'immutable' / DIGEST / 'plugin.yaml', + artifact_digest=DIGEST, + artifact_generation=GENERATION, + ) + + class Catalog: + def __init__(self, config: object) -> None: + pass + + @staticmethod + def reject_source_conflict(plugin_id: str) -> None: + assert plugin_id == 'demo' + + @staticmethod + async def get(digest: str) -> object: + assert digest == DIGEST + if not verified: + raise ValueError('制品摘要校验失败') + return SimpleNamespace(plugin_id='demo', version='1.0.0') + + @staticmethod + def discovered(artifact: object, generation: str) -> DiscoveredPlugin: + assert generation == GENERATION + return discovered + + monkeypatch.setattr(release_view, 'PluginArtifactCatalog', Catalog) + model = await release_view.build_artifact_plugin_view( + object(), artifact_record(), tmp_path / 'backend' / 'plugins', None, PluginService._build_plugin_model + ) + assert model.source == 'artifact' + assert model.installed_version == '2.0.0' + assert model.status == 'installed' + assert model.capability['runtimeManageable'] is False + assert model.release['targetDigest'] == DIGEST + assert model.release['preparedDigest'] == DIGEST + assert model.release['previousDigest'] == PREVIOUS_DIGEST + assert model.release['preparedVersion'] == '2.0.0' + assert model.release['generation'] == GENERATION + assert model.release['status'] == 'pending_restart' + assert model.release['healthyWorkers'] == 0 + assert model.release['restartRequired'] is True + if verified: + assert model.version == '1.0.0' + assert model.release['targetVersion'] == '1.0.0' + assert model.backend_path == f'artifact:{DIGEST}' + else: + assert '摘要校验失败' in model.release['verificationError'] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('method', ['build_plugin_purge_plan_by_id_services', '_purge_plugin_metadata_by_id']) +async def test_artifact_metadata_cannot_be_purged_as_orphan(monkeypatch: pytest.MonkeyPatch, method: str) -> None: + """验证制品插件不会被当作孤儿记录允许清理。""" + monkeypatch.setattr(PluginDao, 'get_plugin_by_id', AsyncMock(return_value=artifact_record())) + with pytest.raises(ValueError, match='制品插件不能'): + await getattr(PluginService, method)(object(), 'demo') diff --git a/ruoyi-fastapi-backend/tests/plugins/core/management/test_plugin_state.py b/ruoyi-fastapi-backend/tests/plugins/core/management/test_plugin_state.py index 3a81d8d..fbc3b99 100644 --- a/ruoyi-fastapi-backend/tests/plugins/core/management/test_plugin_state.py +++ b/ruoyi-fastapi-backend/tests/plugins/core/management/test_plugin_state.py @@ -5,7 +5,7 @@ from types import SimpleNamespace import pytest from openpyxl import load_workbook -from sqlalchemy import text +from sqlalchemy import text, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine @@ -38,6 +38,7 @@ from plugins.core.management.service.gateway import PluginManagementRuntimeGatew from plugins.core.management.service.logs import PluginOperationLogBuilder from plugins.core.management.service.service import PluginService from plugins.core.manifest.schema import PluginManifest +from utils.time_util import TimezoneUtil INITIAL_MENU_ID = 100 UPDATED_MENU_ID = 101 @@ -2262,9 +2263,12 @@ async def test_plugin_operation_log_export_services_returns_filtered_rows() -> N dry_run=False, continue_on_error=False, ) + # 固定在 UTC 与上海日期不同的时刻,导出筛选使用应用业务日期。 + created_at = datetime(2026, 10, 1, 16, 5, tzinfo=timezone.utc) + await session.execute(update(SysPluginOperationLog).values(create_time=created_at)) await session.commit() - today = datetime.now(timezone.utc).strftime('%Y-%m-%d') + today = TimezoneUtil.to_business_time(created_at).strftime('%Y-%m-%d') export_list = await PluginService.get_plugin_operation_log_export_list_services( session, PluginOperationLogExportQueryModel( diff --git a/ruoyi-fastapi-backend/tests/plugins/core/management/test_release_dao.py b/ruoyi-fastapi-backend/tests/plugins/core/management/test_release_dao.py new file mode 100644 index 0000000..d512ab1 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/management/test_release_dao.py @@ -0,0 +1,301 @@ +import asyncio +from collections.abc import AsyncIterator +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest +import pytest_asyncio +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from config.database import Base +from plugins.core.management.dao.release_dao import PluginReleaseConflictError, PluginReleaseDao +from plugins.core.management.entity.do.models import SysPlugin +from plugins.core.management.entity.do.release_models import SysPluginArtifact, SysPluginRelease, SysPluginWorker + +DIGEST_A = 'a' * 64 +DIGEST_B = 'b' * 64 +WORKER_ID = '1' * 32 +EXPECTED_WORKERS = 2 +NOW = datetime(2026, 10, 2, tzinfo=timezone.utc) + + +@pytest_asyncio.fixture +async def sessions(tmp_path: Path) -> AsyncIterator[async_sessionmaker[AsyncSession]]: + """创建仅含发布相关表的临时数据库会话工厂。""" + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "releases.sqlite"}') + async with engine.begin() as connection: + await connection.run_sync( + Base.metadata.create_all, + tables=[ + SysPlugin.__table__, + SysPluginArtifact.__table__, + SysPluginRelease.__table__, + SysPluginWorker.__table__, + ], + ) + try: + yield async_sessionmaker(engine, expire_on_commit=False) + finally: + await engine.dispose() + + +async def register(db: AsyncSession, digest: str = DIGEST_A, version: str = '1.0.0') -> SysPluginArtifact: + """为测试登记指定摘要与版本的制品索引。""" + return await PluginReleaseDao.register_artifact( + db, + digest=digest, + plugin_id='demo', + version=version, + key_id='publisher', + relative_path=f'demo/{version}/{digest}', + manifest_json='{"id":"demo"}', + created_by='maintainer', + ) + + +async def prepare(db: AsyncSession) -> SysPluginRelease: + """为测试建立制品与已安装版本匹配的准备证据。""" + await register(db) + db.add(SysPlugin(plugin_id='demo', plugin_name='Demo', version='1.0.0', installed_version='1.0.0')) + await db.flush() + return await PluginReleaseDao.set_preparation_status( + db, 'demo', status='prepared', prepared_digest=DIGEST_A, prepared_version='1.0.0' + ) + + +@pytest.mark.asyncio +async def test_artifact_is_immutable_and_same_version_can_have_multiple_digests( + sessions: async_sessionmaker[AsyncSession], +) -> None: + """验证制品记录不可覆盖且同一版本允许多个摘要。""" + async with sessions() as db: + original = await register(db) + again = await PluginReleaseDao.register_artifact( + db, + digest=DIGEST_A, + plugin_id='demo', + version='1.0.0', + key_id='publisher', + relative_path=f'demo/1.0.0/{DIGEST_A}', + manifest_json='{ "id": "demo" }', + created_by='another-operator', + ) + assert again is original + assert again.created_by == 'maintainer' + await register(db, DIGEST_B) + assert [row.digest for row in await PluginReleaseDao.list_artifacts(db, 'demo')] == [DIGEST_A, DIGEST_B] + with pytest.raises(ValueError, match='拒绝覆盖'): + await register(db, DIGEST_A, '2.0.0') + + +@pytest.mark.asyncio +async def test_all_dao_writes_remain_in_callers_transaction(sessions: async_sessionmaker[AsyncSession]) -> None: + """验证所有数据访问写操作由调用方事务统一提交或回滚。""" + async with sessions() as db: + await register(db) + await PluginReleaseDao.ensure_release(db, 'demo') + await PluginReleaseDao.upsert_worker_report( + db, worker_id=WORKER_ID, plugin_id='__runtime__', state='ready', heartbeat_time=NOW + ) + await db.rollback() + async with sessions() as db: + assert await PluginReleaseDao.list_artifacts(db) == [] + assert await PluginReleaseDao.list_releases(db) == [] + assert await PluginReleaseDao.list_worker_reports(db) == [] + + +@pytest.mark.asyncio +async def test_select_requires_exact_preparation_and_never_changes_installed_version( + sessions: async_sessionmaker[AsyncSession], +) -> None: + """验证选择目标要求完整准备证据且不更改安装版本。""" + async with sessions() as db: + release = await prepare(db) + old_generation = release.generation + await register(db, DIGEST_B) + with pytest.raises(ValueError, match='尚未完成维护准备'): + await PluginReleaseDao.select_target(db, 'demo', target_digest=DIGEST_B, expected_generation=old_generation) + selected = await PluginReleaseDao.select_target( + db, 'demo', target_digest=DIGEST_A, expected_generation=old_generation, expected_workers=EXPECTED_WORKERS + ) + assert selected.target_digest == DIGEST_A + assert selected.previous_digest is None + assert selected.generation != old_generation + assert selected.expected_workers == EXPECTED_WORKERS + assert (await db.get(SysPlugin, 'demo')).installed_version == '1.0.0' + with pytest.raises(PluginReleaseConflictError): + await PluginReleaseDao.select_target(db, 'demo', target_digest=DIGEST_A, expected_generation=old_generation) + with pytest.raises(PluginReleaseConflictError): + await PluginReleaseDao.set_preparation_status( + db, 'demo', status='failed', last_error='late result', expected_generation=old_generation + ) + await db.refresh(selected) + assert selected.prepare_status == 'prepared' + + +@pytest.mark.asyncio +async def test_prepare_failure_preserves_target_and_clears_preparation_proof( + sessions: async_sessionmaker[AsyncSession], +) -> None: + """验证准备失败保留运行目标并清除旧准备证据。""" + async with sessions() as db: + release = await prepare(db) + await PluginReleaseDao.select_target(db, 'demo', target_digest=DIGEST_A, expected_generation=release.generation) + generation = release.generation + await PluginReleaseDao.set_preparation_status(db, 'demo', status='failed', last_error='migration failed') + assert release.target_digest == DIGEST_A + assert release.generation == generation + assert release.prepared_digest is None + assert release.prepared_version is None + assert release.last_error == 'migration failed' + assert (await db.get(SysPlugin, 'demo')).installed_version == '1.0.0' + + +@pytest.mark.asyncio +async def test_explicit_code_rollback_can_keep_newer_data_schema(sessions: async_sessionmaker[AsyncSession]) -> None: + """验证显式代码回滚可以保留较新的数据结构版本。""" + async with sessions() as db: + release = await prepare(db) + await PluginReleaseDao.select_target(db, 'demo', target_digest=DIGEST_A, expected_generation=release.generation) + await register(db, DIGEST_B, '2.0.0') + plugin = await db.get(SysPlugin, 'demo') + plugin.installed_version = '2.0.0' + await db.flush() + with pytest.raises(ValueError, match='安装版本不一致'): + await PluginReleaseDao.select_target( + db, 'demo', target_digest=DIGEST_A, expected_generation=release.generation + ) + await PluginReleaseDao.set_preparation_status( + db, 'demo', status='prepared', prepared_digest=DIGEST_B, prepared_version='2.0.0' + ) + await PluginReleaseDao.select_target(db, 'demo', target_digest=DIGEST_B, expected_generation=release.generation) + assert release.previous_digest == DIGEST_A + await PluginReleaseDao.set_preparation_status( + db, 'demo', status='prepared', prepared_digest=DIGEST_A, prepared_version='2.0.0' + ) + await PluginReleaseDao.select_target(db, 'demo', target_digest=DIGEST_A, expected_generation=release.generation) + assert release.target_digest == DIGEST_A + assert release.previous_digest == DIGEST_B + await db.refresh(plugin) + assert plugin.installed_version == '2.0.0' + + +@pytest.mark.asyncio +async def test_competing_target_selections_only_one_generation_wins(sessions: async_sessionmaker[AsyncSession]) -> None: + """验证并发目标选择只有一个操作能提交匹配代际。""" + async with sessions() as db: + release = await prepare(db) + generation = release.generation + await db.commit() + + async def select_once() -> str: + async with sessions() as db: + try: + row = await PluginReleaseDao.select_target( + db, 'demo', target_digest=DIGEST_A, expected_generation=generation + ) + await db.commit() + return row.generation + except PluginReleaseConflictError: + await db.rollback() + return 'conflict' + + results = await asyncio.gather(select_once(), select_once()) + assert results.count('conflict') == 1 + async with sessions() as db: + release = await PluginReleaseDao.get_release(db, 'demo') + assert release.generation in results + assert release.generation != generation + + +@pytest.mark.asyncio +async def test_reselecting_target_preserves_previous_release(sessions: async_sessionmaker[AsyncSession]) -> None: + """验证重复选择同一目标不会丢失上一发布制品。""" + async with sessions() as db: + release = await prepare(db) + await PluginReleaseDao.select_target(db, 'demo', target_digest=DIGEST_A, expected_generation=release.generation) + await register(db, DIGEST_B) + await PluginReleaseDao.set_preparation_status( + db, 'demo', status='prepared', prepared_digest=DIGEST_B, prepared_version='1.0.0' + ) + await PluginReleaseDao.select_target(db, 'demo', target_digest=DIGEST_B, expected_generation=release.generation) + generation = release.generation + await PluginReleaseDao.select_target(db, 'demo', target_digest=DIGEST_B, expected_generation=generation) + assert release.target_digest == DIGEST_B + assert release.previous_digest == DIGEST_A + assert release.generation != generation + + +@pytest.mark.asyncio +async def test_reports_do_not_change_target_and_old_heartbeats_cannot_resurrect_stopped_worker( + sessions: async_sessionmaker[AsyncSession], +) -> None: + """验证进程报告不修改目标且旧心跳不能恢复已停止进程。""" + async with sessions() as db: + release = await prepare(db) + generation = release.generation + await PluginReleaseDao.upsert_worker_report( + db, worker_id=WORKER_ID, plugin_id='__runtime__', state='ready', heartbeat_time=NOW + ) + await PluginReleaseDao.upsert_worker_report( + db, + worker_id=WORKER_ID, + plugin_id='demo', + state='stopped', + heartbeat_time=NOW + timedelta(seconds=15), + artifact_digest=DIGEST_A, + generation=generation, + version='1.0.0', + ) + row = await PluginReleaseDao.upsert_worker_report( + db, worker_id=WORKER_ID, plugin_id='demo', state='ready', heartbeat_time=NOW + ) + assert row.state == 'stopped' + assert row.artifact_digest == DIGEST_A + assert row.heartbeat_time == NOW + timedelta(seconds=15) + await db.refresh(release) + assert release.target_digest is None + assert release.generation == generation + assert {row.plugin_id for row in await PluginReleaseDao.list_worker_reports(db, 'demo')} == { + 'demo', + '__runtime__', + } + + +@pytest.mark.asyncio +async def test_database_rejects_invalid_worker_and_release_states(sessions: async_sessionmaker[AsyncSession]) -> None: + """验证数据库约束拒绝非法进程和发布状态。""" + async with sessions() as db: + db.add(SysPluginRelease(plugin_id='demo', generation=WORKER_ID, expected_workers=0, prepare_status='idle')) + with pytest.raises(IntegrityError): + await db.flush() + await db.rollback() + db.add(SysPluginRelease(plugin_id='demo', generation=WORKER_ID, expected_workers=1, prepare_status='unknown')) + with pytest.raises(IntegrityError): + await db.flush() + await db.rollback() + db.add(SysPluginWorker(worker_id=WORKER_ID, plugin_id='demo', state='active', heartbeat_time=NOW)) + with pytest.raises(IntegrityError): + await db.flush() + + +@pytest.mark.asyncio +async def test_worker_reports_require_utc_and_separate_host_from_plugin( + sessions: async_sessionmaker[AsyncSession], +) -> None: + """验证报告时间规范及宿主与插件字段边界。""" + async with sessions() as db: + with pytest.raises(ValueError, match='时区'): + await PluginReleaseDao.upsert_worker_report( + db, worker_id=WORKER_ID, plugin_id='demo', state='ready', heartbeat_time=NOW.replace(tzinfo=None) + ) + with pytest.raises(ValueError, match='宿主worker'): + await PluginReleaseDao.upsert_worker_report( + db, + worker_id=WORKER_ID, + plugin_id='__runtime__', + state='ready', + heartbeat_time=NOW, + artifact_digest=DIGEST_A, + ) diff --git a/ruoyi-fastapi-backend/tests/plugins/core/native/test_wheel.py b/ruoyi-fastapi-backend/tests/plugins/core/native/test_wheel.py new file mode 100644 index 0000000..0c540cc --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/native/test_wheel.py @@ -0,0 +1,181 @@ +import base64 +import csv +import hashlib +import io +import stat +import sys +from importlib.machinery import EXTENSION_SUFFIXES +from pathlib import Path +from zipfile import ZipFile + +import pytest +from packaging.tags import sys_tags + +from plugins.core.discovery.scanner import DiscoveredPlugin +from plugins.core.manifest.schema import PluginManifest, PluginManifestFactory +from plugins.core.native import wheel as wheel_tools +from plugins.core.native.wheel import install_native_wheel, select_native_wheel +from plugins.core.runtime.entrypoint import PluginEntrypointLoader + +MODULE = 'ruoyi_plugin_wheel_test' +METADATA = f'{MODULE}-1.0.0.dist-info' + + +@pytest.fixture +def manifest() -> PluginManifest: + """创建原生 wheel 测试使用的插件清单。""" + return PluginManifestFactory.create( + { + 'manifestVersion': 2, + 'id': 'wheel_test', + 'name': 'Wheel test', + 'version': '1.0.0', + 'backend': { + 'runtime': 'native', + 'module': MODULE, + 'entrypoint': f'{MODULE}._native:create_plugin', + 'native': {'distribution': 'ruoyi-plugin-wheel-test'}, + }, + } + ) + + +def make_wheel(root: Path, *, extra: dict[str, bytes] | None = None, tag: str | None = None) -> Path: + """构造包含可控元数据、文件内容和 RECORD 的原生测试 wheel。""" + selected_tag = tag or str(next(sys_tags())) + path = root / f'{MODULE}-1.0.0-{selected_tag}.whl' + files = { + f'{MODULE}/__init__.py': b'raise RuntimeError("static validation must not import")', + f'{MODULE}/_native{EXTENSION_SUFFIXES[0]}': b'not-an-executable-test-fixture', + f'{METADATA}/METADATA': b'Name: ruoyi-plugin-wheel-test\nVersion: 1.0.0\nRequires-Python: >=3.10\n', + f'{METADATA}/WHEEL': f'Wheel-Version: 1.0\nRoot-Is-Purelib: false\nTag: {selected_tag}\n'.encode(), + **(extra or {}), + } + record = io.StringIO() + writer = csv.writer(record) + for name, data in files.items(): + digest = base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b'=').decode('ascii') + writer.writerow([name, f'sha256={digest}', len(data)]) + writer.writerow([f'{METADATA}/RECORD', '', '']) + files[f'{METADATA}/RECORD'] = record.getvalue().encode() + with ZipFile(path, 'w') as archive: + for name, data in files.items(): + archive.writestr(name, data) + return path + + +def test_install_yields_runtime_loadable_metadata_without_import(tmp_path: Path, manifest: PluginManifest) -> None: + """验证安装生成可加载的元数据布局且不导入插件。""" + wheel = make_wheel(tmp_path) + make_wheel(tmp_path, tag='cp20-cp20-unsupported_platform') + assert select_native_wheel(tmp_path, manifest) == wheel + plugin_root = tmp_path / 'wheel_test' + install_native_wheel(wheel, plugin_root / 'native', manifest) + discovered = DiscoveredPlugin(manifest, plugin_root, plugin_root / 'plugin.yaml') + location = PluginEntrypointLoader(discovered).check_entrypoint() + assert location.path.is_file() + assert MODULE not in sys.modules + + +@pytest.mark.parametrize( + 'name', + [ + '../escaped', + '/absolute', + f'{MODULE}/../escape', + f'{MODULE}/sub\\escape', + f'{MODULE}/CON', + 'other_module/file.py', + ], +) +def test_unsafe_archive_rejected_before_creating_destination( + tmp_path: Path, manifest: PluginManifest, name: str +) -> None: + """验证不安全容器在创建安装目录前被拒绝。""" + wheel = make_wheel(tmp_path, extra={name: b'bad'}) + destination = tmp_path / 'native' + with pytest.raises(ValueError): + install_native_wheel(wheel, destination, manifest) + assert not destination.exists() + assert not (tmp_path.parent / 'escaped').exists() + + +@pytest.mark.parametrize('mutation', ['tamper', 'unlisted', 'symlink', 'case_collision']) +def test_record_and_file_types_are_enforced(tmp_path: Path, manifest: PluginManifest, mutation: str) -> None: + """验证 RECORD 完整性与文件类型限制生效。""" + wheel = make_wheel(tmp_path) + with ZipFile(wheel) as archive: + entries = [(item, archive.read(item)) for item in archive.infolist()] + with ZipFile(wheel, 'w') as archive: + for item, content in entries: + payload = content + if mutation == 'tamper' and item.filename.endswith('__init__.py'): + payload = b'changed' + if mutation == 'symlink' and item.filename.endswith('__init__.py'): + item.external_attr = (stat.S_IFLNK | 0o777) << 16 + archive.writestr(item, payload) + if mutation == 'unlisted': + archive.writestr(f'{MODULE}/hidden.py', b'pass') + if mutation == 'case_collision': + archive.writestr(f'{MODULE}/__INIT__.py', b'pass') + with pytest.raises(ValueError): + install_native_wheel(wheel, tmp_path / 'native', manifest) + assert not (tmp_path / 'native').exists() + + +def test_install_never_overwrites_existing_directory(tmp_path: Path, manifest: PluginManifest) -> None: + """验证 wheel 安装不会覆盖已存在的目录。""" + wheel = make_wheel(tmp_path) + destination = tmp_path / 'native' + destination.mkdir() + sentinel = destination / 'keep.txt' + sentinel.write_text('keep', encoding='utf-8') + with pytest.raises(FileExistsError): + install_native_wheel(wheel, destination, manifest) + assert sentinel.read_text(encoding='utf-8') == 'keep' + + +def test_archive_size_limit(tmp_path: Path, manifest: PluginManifest, monkeypatch: pytest.MonkeyPatch) -> None: + """验证 wheel 文件数量和大小限制生效。""" + wheel = make_wheel(tmp_path) + monkeypatch.setattr(wheel_tools, 'MAX_WHEEL_BYTES', 1) + with pytest.raises(ValueError, match='超过限制'): + install_native_wheel(wheel, tmp_path / 'native', manifest) + + +def test_missing_dependency_is_not_installed(tmp_path: Path, manifest: PluginManifest) -> None: + """验证缺失依赖只会导致失败而不会触发安装。""" + wheel = make_wheel( + tmp_path, + extra={ + f'{METADATA}/METADATA': b'Name: ruoyi-plugin-wheel-test\nVersion: 1.0.0\n' + b'Requires-Dist: ruoyi-wheel-test-nonexistent-dependency==1.0\n' + }, + ) + with pytest.raises(ValueError, match='缺少 wheel 依赖'): + install_native_wheel(wheel, tmp_path / 'native', manifest) + + +def test_incompatible_wheel_has_no_selection(tmp_path: Path, manifest: PluginManifest) -> None: + """验证不兼容 wheel 不能被选择为安装候选。""" + make_wheel(tmp_path, tag='cp20-cp20-unsupported_platform') + with pytest.raises(ValueError, match='未找到'): + select_native_wheel(tmp_path, manifest) + + +def test_extract_failure_cleans_only_new_directory( + tmp_path: Path, manifest: PluginManifest, monkeypatch: pytest.MonkeyPatch +) -> None: + """验证展开失败仅清理本次创建的新目录。""" + wheel = make_wheel(tmp_path) + sentinel = tmp_path / 'keep.txt' + sentinel.touch() + + def fail_copy(*args: object, **kwargs: object) -> None: + raise OSError('disk full') + + monkeypatch.setattr(wheel_tools.shutil, 'copyfileobj', fail_copy) + with pytest.raises(OSError, match='disk full'): + install_native_wheel(wheel, tmp_path / 'native', manifest) + assert not (tmp_path / 'native').exists() + assert sentinel.exists() diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/fakes/gateway.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/fakes/gateway.py index cb001f5..fd77294 100644 --- a/ruoyi-fastapi-backend/tests/plugins/core/runtime/fakes/gateway.py +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/fakes/gateway.py @@ -102,12 +102,15 @@ class FakePluginRuntimeGateway: def __init__(self) -> None: """初始化测试用插件运行时适配器。""" self.session_local = FakeSessionLocal() + self.lifecycle_uows: list[FakePluginLifecycleUnitOfWork] = [] self.completed_process = CompletedProcess(args=[], returncode=0, stdout='1 passed\n', stderr='') self.commands: list[tuple[list[str], str, int | None]] = [] def open_lifecycle_unit_of_work(self) -> FakePluginLifecycleUnitOfWork: """打开测试生命周期主事务工作单元。""" - return FakePluginLifecycleUnitOfWork(self) + unit_of_work = FakePluginLifecycleUnitOfWork(self) + self.lifecycle_uows.append(unit_of_work) + return unit_of_work def get_async_session_local(self) -> FakeSessionLocal: """获取测试会话工厂。""" @@ -123,9 +126,8 @@ class FakePluginRuntimeGateway: return await FakePluginService.get_plugin_list_services(session) async def get_plugin_state(self, plugin_id: str) -> SimpleNamespace | None: - """获取测试插件状态。""" - async with self.session_local() as session: - return await FakePluginService.plugin_detail_services(session, plugin_id) + """读取内存状态,不在生命周期事务记录中创建只读会话。""" + return await FakePluginService.plugin_detail_services(None, plugin_id) @staticmethod def build_operation_log_export_query(export_limit: int) -> SimpleNamespace: diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_artifact_guards.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_artifact_guards.py new file mode 100644 index 0000000..2af5b8b --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_artifact_guards.py @@ -0,0 +1,175 @@ +import asyncio +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from plugins.core.validation.dependency_policy import DependencyInstallPolicyConfig +from tests.plugins.core.runtime.fakes import ( + FakePluginRuntimeGateway, + FakePluginService, + build_runtime_with_gateway, + write_manifest, +) + +EXPECTED_DATABASE_OPERATIONS = 2 + + +@pytest.mark.asyncio +@pytest.mark.parametrize('operation', ['install', 'upgrade', 'enable', 'disable', 'uninstall', 'purge']) +@pytest.mark.parametrize('dry_run', [False, True]) +async def test_database_artifact_source_blocks_legacy_lifecycle_without_source_directory( + tmp_path: Path, operation: str, dry_run: bool +) -> None: + gateway = FakePluginRuntimeGateway() + FakePluginService.detail_plugin = SimpleNamespace(plugin_id='demo', source='artifact') + runtime = build_runtime_with_gateway(tmp_path / 'backend', gateway) + if operation in {'enable', 'disable'}: + payload = await runtime.set_plugin_enabled('demo', enabled=operation == 'enable', dry_run=dry_run) + else: + payload = await getattr(runtime, f'{operation}_plugin')('demo', dry_run=dry_run) + assert payload['ok'] is False + assert payload['status'] == 'blocked' + assert payload['operation'] == operation + assert payload['capability']['runtimeManageable'] is False + assert gateway.lifecycle_uows == [] + assert gateway.session_local.sessions == [] + assert FakePluginService.marked_errors == [] + assert FakePluginService.operation_logs == [] + assert FakePluginService.purge_by_id_called_with is None + + +@pytest.mark.asyncio +async def test_state_query_failure_cannot_authorize_orphan_purge(tmp_path: Path) -> None: + class FailingGateway(FakePluginRuntimeGateway): + async def get_plugin_state(self, plugin_id: str) -> None: + raise RuntimeError('state database unavailable') + + gateway = FailingGateway() + payload = await build_runtime_with_gateway(tmp_path / 'backend', gateway).purge_plugin('demo') + assert payload['status'] == 'blocked' + assert '无法确认插件来源' in payload['message'] + assert gateway.session_local.sessions == [] + + +@pytest.mark.asyncio +async def test_config_read_resolves_selected_artifact_without_source_directory(tmp_path: Path) -> None: + selected = object() + + class ArtifactGateway(FakePluginRuntimeGateway): + async def get_selected_artifact_plugin(self, plugin_id: str, backend_root: Path) -> object: + assert plugin_id == 'demo' + assert backend_root == tmp_path / 'backend' + return selected + + async def get_plugin_config(self, discovered_plugin: object, *, reveal_secret: bool = False) -> list: + assert discovered_plugin is selected + assert reveal_secret is False + return [] + + async def set_plugin_config(self, discovered_plugin: object, values: dict, **kwargs: object) -> list: + assert discovered_plugin is selected + assert values == {'provider': 'local'} + return [] + + FakePluginService.detail_plugin = SimpleNamespace(plugin_id='demo', source='artifact') + runtime = build_runtime_with_gateway(tmp_path / 'backend', ArtifactGateway()) + result = await runtime.get_plugin_config('demo') + assert result['ok'] is True + assert result['configs'] == [] + result = await runtime.set_plugin_config('demo', {'provider': 'local'}) + assert result['ok'] is True + + +@pytest.mark.parametrize('method', ['install_plugin_dependencies', 'install_plugin_dependencies_from_cli']) +def test_artifact_source_blocks_dependency_install_even_when_same_id_source_exists(tmp_path: Path, method: str) -> None: + backend = tmp_path / 'backend' + write_manifest( + backend / 'plugins' / 'demo', + 'id: demo\nname: Demo\nversion: 1.0.0\nbackend:\n module: plugins.demo\n', + ) + gateway = FakePluginRuntimeGateway() + FakePluginService.detail_plugin = SimpleNamespace(plugin_id='demo', source='artifact') + payload = getattr(build_runtime_with_gateway(backend, gateway), method)('demo', confirmed=True) + assert payload['status'] == 'blocked' + assert '签名制品' in payload['message'] + assert gateway.commands == [] + assert gateway.session_local.sessions == [] + + +def test_dependency_install_fails_closed_when_source_query_fails(tmp_path: Path) -> None: + class FailingGateway(FakePluginRuntimeGateway): + async def get_plugin_state(self, plugin_id: str) -> None: + raise RuntimeError('database unavailable') + + gateway = FailingGateway() + payload = build_runtime_with_gateway(tmp_path, gateway).install_plugin_dependencies_from_cli('demo', confirmed=True) + assert payload['status'] == 'blocked' + assert gateway.commands == [] + + +@pytest.mark.asyncio +async def test_sync_dependency_install_never_runs_nested_event_loop(tmp_path: Path) -> None: + class UnexpectedQueryGateway(FakePluginRuntimeGateway): + async def get_plugin_state(self, plugin_id: str) -> None: + pytest.fail('synchronous wrapper must not query within the running loop') + + gateway = UnexpectedQueryGateway() + payload = build_runtime_with_gateway(tmp_path, gateway).install_plugin_dependencies_from_cli('demo', confirmed=True) + assert payload['status'] == 'blocked' + assert '事件循环' in payload['message'] + assert gateway.commands == [] + + +@pytest.mark.asyncio +async def test_configuration_never_falls_back_to_conflicting_source(tmp_path: Path) -> None: + backend = tmp_path / 'backend' + write_manifest( + backend / 'plugins' / 'demo', + 'id: demo\nname: Demo\nversion: 1.0.0\nbackend:\n module: plugins.demo\n', + ) + + class ConflictingGateway(FakePluginRuntimeGateway): + async def get_selected_artifact_plugin(self, plugin_id: str, backend_root: Path) -> None: + raise ValueError('制品与源码目录冲突') + + FakePluginService.detail_plugin = SimpleNamespace(plugin_id='demo', source='artifact') + gateway = ConflictingGateway() + runtime = build_runtime_with_gateway(backend, gateway) + for payload in (await runtime.get_plugin_config('demo'), await runtime.set_plugin_config('demo', {'key': 'value'})): + assert payload['ok'] is False + assert '冲突' in str(payload) + assert gateway.session_local.sessions == [] + + +@pytest.mark.parametrize('method', ['install_plugin_dependencies', 'install_plugin_dependencies_from_cli']) +def test_dependency_source_check_and_audit_share_one_event_loop(tmp_path: Path, method: str) -> None: + backend = tmp_path / 'backend' + write_manifest( + backend / 'plugins' / 'demo', + 'id: demo\nname: Demo\nversion: 1.0.0\nbackend:\n module: plugins.demo\n' + 'dependencies:\n python:\n - missing-python\n', + ) + loops: list[asyncio.AbstractEventLoop] = [] + + class LoopAffineGateway(FakePluginRuntimeGateway): + async def get_plugin_state(self, plugin_id: str) -> None: + loops.append(asyncio.get_running_loop()) + + async def add_plugin_operation_log(self, payload: dict, *, dry_run: bool, continue_on_error: bool) -> None: + current_loop = asyncio.get_running_loop() + if loops[0] is not current_loop: + raise RuntimeError('shared async database pool was reused from a different event loop') + loops.append(current_loop) + await super().add_plugin_operation_log(payload, dry_run=dry_run, continue_on_error=continue_on_error) + + gateway = LoopAffineGateway() + payload = getattr(build_runtime_with_gateway(backend, gateway), method)( + 'demo', policy_config=DependencyInstallPolicyConfig(mode='explicit', env='dev'), confirmed=True + ) + assert payload['ok'] is True + assert len(gateway.commands) == 1 + assert len(loops) == EXPECTED_DATABASE_OPERATIONS + assert loops[0] is loops[1] + assert loops[0].is_closed() + assert len(FakePluginService.operation_logs) == 1 diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_install_config_health.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_install_config_health.py index e063514..a60b75b 100644 --- a/ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_install_config_health.py +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_install_config_health.py @@ -1106,6 +1106,40 @@ config: assert result['audit']['items'][0]['operation'] == 'install' +def test_plugin_runtime_diagnose_artifact_without_local_source_keeps_database_snapshot(tmp_path: Path) -> None: + """制品不在源码扫描目录时仍能诊断,不将缺少 manifest 误当插件不存在。""" + backend_root = tmp_path / 'backend' + (backend_root / 'plugins').mkdir(parents=True) + FakePluginService.plugin_list = [ + SimpleNamespace( + plugin_id='demo', + installed_version='2.0.0', + enabled='0', + status='installed', + last_error=None, + source='artifact', + backend_path=None, + frontend_path=None, + ) + ] + FakePluginService.operation_logs = [] + FakePluginService.detail_plugin = FakePluginService.plugin_list[0] + runtime = build_runtime_with_gateway(backend_root, FakePluginRuntimeGateway()) + + result = asyncio.run(runtime.diagnose_plugin('demo')) + + assert result['ok'] is False + assert result['info']['scope'] == 'database_only' + assert result['info']['installedVersion'] == '2.0.0' + assert result['info']['database']['enabled'] == '0' + assert result['check']['supported'] is False + assert result['check']['ok'] is False + assert result['menuPlan']['supported'] is False + assert result['config']['supported'] is False + assert result['config']['ok'] is False + assert result['audit']['available'] is True + + def test_plugin_runtime_health_plugin_returns_checker_result(tmp_path: Path) -> None: """校验插件运行时可以执行健康检查。""" backend_root = tmp_path / 'backend' diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_lifecycle_lock.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_lifecycle_lock.py index 1273593..ab0c33b 100644 --- a/ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_lifecycle_lock.py +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_lifecycle_lock.py @@ -1,5 +1,5 @@ import asyncio -from collections.abc import AsyncIterator +from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from unittest.mock import AsyncMock, patch @@ -24,7 +24,7 @@ class DeniedLifecycleLock: self.calls: list[tuple[str, str]] = [] @asynccontextmanager - async def lock(self, plugin_id: str, operation: str) -> AsyncIterator[PluginLifecycleLockResult]: + async def lock(self, plugin_id: str, operation: str) -> AsyncGenerator[PluginLifecycleLockResult, None]: """返回未获取锁结果。""" self.calls.append((plugin_id, operation)) yield PluginLifecycleLockResult(acquired=False, message='插件正在操作中') @@ -36,7 +36,7 @@ class FailingLifecycleLock: """ @asynccontextmanager - async def lock(self, plugin_id: str, operation: str) -> AsyncIterator[PluginLifecycleLockResult]: + async def lock(self, plugin_id: str, operation: str) -> AsyncGenerator[PluginLifecycleLockResult, None]: """不应被调用的锁。""" raise AssertionError('dry_run 不应获取生命周期锁') yield PluginLifecycleLockResult(acquired=True) diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_upgrade_enable_purge.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_upgrade_enable_purge.py index 4d0704e..987d3bb 100644 --- a/ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_upgrade_enable_purge.py +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_upgrade_enable_purge.py @@ -400,7 +400,7 @@ compatibility: assert result['manifestOk'] is False assert result['manifestIssues'][0]['kind'] == 'compatibility_unsatisfied' assert FakePluginService.upsert_called is False - assert gateway.session_local.sessions[0].committed is False + assert gateway.lifecycle_uows == [] def test_plugin_runtime_upgrade_plugin_rejects_uninstalled_plugin(tmp_path: Path) -> None: @@ -425,7 +425,7 @@ backend: assert result['ok'] is False assert result['message'] == '插件尚未安装,升级已中止' assert result['installed'] is False - assert gateway.session_local.sessions[0].committed is False + assert gateway.lifecycle_uows == [] assert FakePluginService.marked_errors == [('demo', '插件尚未安装,升级已中止')] @@ -458,7 +458,7 @@ backend: assert result['needsUpgrade'] is False assert result['actions'] == [] assert FakePluginService.upsert_called is False - assert gateway.session_local.sessions[0].committed is False + assert gateway.lifecycle_uows == [] def test_plugin_runtime_upgrade_plugin_skips_older_source_version(tmp_path: Path) -> None: @@ -491,7 +491,7 @@ backend: assert result['installedVersion'] == '1.10.0' assert result['currentVersion'] == '1.2.0' assert FakePluginService.upsert_called is False - assert gateway.session_local.sessions[0].committed is False + assert gateway.lifecycle_uows == [] def test_plugin_runtime_upgrade_plugin_persists_upgrade(tmp_path: Path) -> None: diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_application.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_application.py index a986de3..aebea95 100644 --- a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_application.py +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_application.py @@ -1,5 +1,5 @@ import json -from collections.abc import AsyncIterator +from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from pathlib import Path from unittest.mock import AsyncMock, MagicMock, call, patch @@ -61,7 +61,7 @@ class FakeLifecycleLock: self.calls: list[tuple[str, str]] = [] @asynccontextmanager - async def lock(self, plugin_id: str, operation: str) -> AsyncIterator[PluginLifecycleLockResult]: + async def lock(self, plugin_id: str, operation: str) -> AsyncGenerator[PluginLifecycleLockResult, None]: """返回预设锁结果。""" self.calls.append((plugin_id, operation)) yield PluginLifecycleLockResult(acquired=self.acquired) @@ -181,6 +181,31 @@ def test_startup_generation_changes_with_plugin_source(tmp_path: Path) -> None: assert first != second +@pytest.mark.parametrize( + 'relative_path', + [ + 'python/asgi_demo/__init__.py', + 'python/bundle_demo/plugin.yaml', + 'rust/rust_demo/target/release/_native.pyd', + ], +) +def test_startup_generation_ignores_example_sources_and_build_outputs(tmp_path: Path, relative_path: str) -> None: + """校验示例源码、清单及构建产物变化不会改变宿主启动代际。""" + plugin_file = tmp_path / 'plugins' / 'demo' / 'hooks.py' + plugin_file.parent.mkdir(parents=True) + plugin_file.write_text('VERSION = 1\n', encoding='utf-8') + resolver = PluginStartupGenerationResolver(tmp_path, release_id='') + original = resolver.resolve() + + example_file = tmp_path / 'plugins' / 'examples' / relative_path + example_file.parent.mkdir(parents=True) + example_file.write_text('example version 1\n', encoding='utf-8') + assert resolver.resolve() == original + + example_file.write_text('example version 2\n', encoding='utf-8') + assert resolver.resolve() == original + + @pytest.mark.asyncio async def test_startup_writer_creates_plugin_tables_installs_resources_and_marks_ready() -> None: """校验启动写入 worker 会执行插件二次建表、安装资源并标记 ready。""" diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_asgi.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_asgi.py new file mode 100644 index 0000000..03b41e6 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_asgi.py @@ -0,0 +1,264 @@ +import asyncio +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager, suppress +from pathlib import Path + +import httpx +import pytest +from fastapi import FastAPI, Request +from starlette import status +from starlette.types import Message, Receive, Scope, Send + +from plugins.core.runtime.asgi import PluginGatewayASGI, PluginLifespanManager +from plugins.core.sdk import PluginHostContext, PluginRequestContext + + +@pytest.mark.asyncio +async def test_mounted_app_lifespan_state_root_path_and_shutdown(tmp_path: Path) -> None: + events = [] + + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncGenerator[dict[str, str], None]: + events.append('start') + yield {'resource': 'ready'} + events.append('stop') + + child = FastAPI(lifespan=lifespan) + + @child.get('/api/state') + async def state(request: Request) -> dict[str, str]: + return { + 'resource': request.state.resource, + 'plugin': request.state.plugin_context.host.plugin_id, + 'root_path': request.scope['root_path'], + } + + async def authorize(scope: Scope) -> PluginRequestContext: + return PluginRequestContext(PluginHostContext('demo', tmp_path), user=None) + + manager = PluginLifespanManager(child) + parent = FastAPI() + parent.mount('/apps/demo', PluginGatewayASGI(child, manager, authorize)) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=parent), base_url='http://test') as client: + assert (await client.get('/apps/demo/api/state')).status_code == status.HTTP_503_SERVICE_UNAVAILABLE + await manager.startup() + await manager.startup() + response = await client.get('/apps/demo/api/state') + assert response.json() == {'resource': 'ready', 'plugin': 'demo', 'root_path': '/apps/demo'} + await manager.shutdown() + await manager.shutdown() + assert (await client.get('/apps/demo/api/state')).status_code == status.HTTP_503_SERVICE_UNAVAILABLE + assert events == ['start', 'stop'] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('failure', [PermissionError, LookupError]) +async def test_gateway_rejects_http_and_websocket_without_invoking_plugin(failure: type[Exception]) -> None: + reached = [] + + async def child(scope: Scope, receive: Receive, send: Send) -> None: + reached.append(scope) + + async def authorize(scope: Scope) -> PluginRequestContext: + raise failure() + + manager = PluginLifespanManager(child, managed=False) + await manager.startup() + gateway = PluginGatewayASGI(child, manager, authorize) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=gateway), base_url='http://test') as client: + response = await client.get('/ui/index.html') + assert response.status_code == (403 if failure is PermissionError else 401) + messages = [] + + async def send(message: Message) -> None: + messages.append(message) + + await gateway({'type': 'websocket', 'path': '/ws'}, None, send) + assert messages[0]['type'] == 'websocket.close' + assert not reached + + +@pytest.mark.asyncio +async def test_failed_or_unsupported_lifespan_cleans_up() -> None: + stopped = asyncio.Event() + + async def failed(scope: Scope, receive: Receive, send: Send) -> None: + try: + await receive() + await send({'type': 'lifespan.startup.failed', 'message': 'broken resource'}) + await asyncio.Event().wait() + finally: + stopped.set() + + manager = PluginLifespanManager(failed) + with pytest.raises(RuntimeError, match='broken resource'): + await manager.startup() + assert stopped.is_set() + assert not manager.started + + async def unsupported(scope: Scope, receive: Receive, send: Send) -> None: + return + + with pytest.raises(RuntimeError, match='确认前退出'): + await PluginLifespanManager(unsupported).startup() + + +@pytest.mark.asyncio +async def test_lifespan_timeout_cancels_task() -> None: + stopped = asyncio.Event() + + async def hangs(scope: Scope, receive: Receive, send: Send) -> None: + try: + await asyncio.Event().wait() + finally: + stopped.set() + + manager = PluginLifespanManager(hangs, timeout=0.02) + with pytest.raises(TimeoutError): + await manager.startup() + assert stopped.is_set() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('phase', ['startup', 'shutdown']) +async def test_lifespan_timeout_is_bounded_and_retry_uses_fresh_protocol_state(phase: str) -> None: + """拒绝残留任务重启,退出后重试不读取旧任务的迟到消息和状态。""" + release = asyncio.Event() + attempts = [] + + async def child(scope: Scope, receive: Receive, send: Send) -> None: + attempts.append(scope['state']) + await receive() + if len(attempts) > 1: + scope['state']['attempt'] = len(attempts) + await send({'type': 'lifespan.startup.complete'}) + await receive() + await send({'type': 'lifespan.shutdown.complete'}) + return + if phase == 'shutdown': + await send({'type': 'lifespan.startup.complete'}) + await receive() + while not release.is_set(): + with suppress(asyncio.CancelledError): + await release.wait() + scope['state']['stale'] = True + await send({'type': 'lifespan.startup.failed', 'message': 'late response'}) + + manager = PluginLifespanManager(child, timeout=0.01, cancel_timeout=0.01) + if phase == 'shutdown': + await manager.startup() + operation = asyncio.create_task(getattr(manager, phase)()) + try: + await asyncio.wait({operation}, timeout=1) + assert operation.done(), '生命周期取消等待必须有界' + with pytest.raises(TimeoutError): + await operation + assert not manager.ready and manager.has_pending_task + with pytest.raises(RuntimeError, match='尚未退出'): + await manager.startup() + release.set() + await asyncio.wait({manager._task}, timeout=1) + await manager.startup() + assert manager.ready and manager.state == {'attempt': 2} + assert attempts[0] is not attempts[1] + await manager.shutdown() + await manager.shutdown() + finally: + release.set() + await asyncio.gather(operation, return_exceptions=True) + await manager.shutdown() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('phase', ['startup', 'shutdown']) +async def test_lifespan_self_cancellation_is_protocol_error(phase: str) -> None: + async def child(scope: Scope, receive: Receive, send: Send) -> None: + await receive() + if phase == 'shutdown': + await send({'type': 'lifespan.startup.complete'}) + await receive() + await send({'type': 'lifespan.shutdown.complete'}) + raise asyncio.CancelledError + + manager = PluginLifespanManager(child) + if phase == 'shutdown': + await manager.startup() + with pytest.raises(RuntimeError, match='自行取消'): + await getattr(manager, phase)() + assert not manager.ready and not manager.has_pending_task + + +@pytest.mark.asyncio +async def test_lifespan_accepts_callable_returning_future() -> None: + async def child(scope: Scope, receive: Receive, send: Send) -> None: + await receive() + await send({'type': 'lifespan.startup.complete'}) + await receive() + await send({'type': 'lifespan.shutdown.complete'}) + + def app(scope: Scope, receive: Receive, send: Send) -> asyncio.Future[None]: + return asyncio.create_task(child(scope, receive, send)) + + manager = PluginLifespanManager(app) + await manager.startup() + assert manager.ready + await manager.shutdown() + assert not manager.has_pending_task + + +@pytest.mark.asyncio +async def test_host_cancellation_propagates_and_releases_lifespan_task() -> None: + opened, stopped = asyncio.Event(), asyncio.Event() + + async def child(scope: Scope, receive: Receive, send: Send) -> None: + opened.set() + try: + await asyncio.Event().wait() + finally: + stopped.set() + + manager = PluginLifespanManager(child) + task = asyncio.create_task(manager.startup()) + await opened.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert stopped.is_set() and not manager.has_pending_task + + +@pytest.mark.parametrize('timeout', [0, -1, float('inf'), float('nan')]) +def test_lifespan_rejects_invalid_time_limits(timeout: float) -> None: + with pytest.raises(ValueError, match='大于零'): + PluginLifespanManager(None, timeout=timeout) + with pytest.raises(ValueError, match='大于零'): + PluginLifespanManager(None, cancel_timeout=timeout) + + +@pytest.mark.asyncio +async def test_concurrent_shutdown_waits_for_startup_and_sends_one_shutdown() -> None: + opened, release = asyncio.Event(), asyncio.Event() + messages = [] + + async def child(scope: Scope, receive: Receive, send: Send) -> None: + messages.append((await receive())['type']) + opened.set() + await release.wait() + await send({'type': 'lifespan.startup.complete'}) + messages.append((await receive())['type']) + await send({'type': 'lifespan.shutdown.complete'}) + + manager = PluginLifespanManager(child, timeout=1) + starting = asyncio.create_task(manager.startup()) + await opened.wait() + stopping = [asyncio.create_task(manager.shutdown()) for _ in range(2)] + try: + await asyncio.sleep(0) + assert not manager.ready + release.set() + await asyncio.wait_for(asyncio.gather(starting, *stopping), timeout=1) + assert messages == ['lifespan.startup', 'lifespan.shutdown'] + assert not manager.ready and not manager.has_pending_task + finally: + release.set() + await asyncio.gather(starting, *stopping, return_exceptions=True) + await manager.shutdown() diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_browser_session.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_browser_session.py new file mode 100644 index 0000000..a8943e5 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_browser_session.py @@ -0,0 +1,377 @@ +import asyncio +import json +import sys +import time +from collections.abc import AsyncGenerator, AsyncIterator +from contextlib import asynccontextmanager +from pathlib import Path +from types import SimpleNamespace +from typing import Annotated +from unittest.mock import AsyncMock + +import httpx +import pytest +import pytest_asyncio +import yaml +from fastapi import Depends, FastAPI, HTTPException, Request +from starlette import status + +from common.aspect.db_session import get_db_session_provider +from common.enums import RedisInitKeyConfig +from config.env import AppConfig, TransportCryptoConfig +from exceptions.exception import AuthException +from middlewares.transport_crypto_middleware import TransportCryptoMiddleware +from module_admin.dao.user_dao import UserDao +from module_admin.entity.do.user_do import SysUser +from module_admin.service.login_service import LoginService, oauth2_scheme +from module_plugin.controller.plugin_browser_controller import plugin_browser_controller +from plugins.core.discovery.registry import RegisteredPlugin +from plugins.core.discovery.scanner import PluginScanner +from plugins.core.runtime.browser_session import COOKIE_NAME_PREFIX, SESSION_KEY_PREFIX +from plugins.core.runtime.explicit import ExplicitPluginRuntime +from utils.jwt_util import JwtUtil + + +class MemoryRedis: + """实现本协议使用的原子 NX/expire,并允许明确模拟到期。""" + + def __init__(self) -> None: + self.values: dict[str, str] = {} + self.deadlines: dict[str, float] = {} + + async def get(self, key: str) -> str | None: + if self.deadlines.get(key, float('inf')) <= time.time(): + self.values.pop(key, None) + return self.values.get(key) + + async def set(self, key: str, value: str, *, ex: int = 300, nx: bool = False) -> bool: + if nx and await self.get(key) is not None: + return False + self.values[key] = value + self.deadlines[key] = time.time() + ex + return True + + async def expire(self, key: str, seconds: int) -> bool: + if await self.get(key) is None: + return False + self.deadlines[key] = time.time() + seconds + return True + + +SOURCE = """ +from fastapi import FastAPI, Request +from plugins.core.sdk import PluginDefinition, plugin_endpoint + +async def info(ctx): + return {'pluginId': ctx.host.plugin_id, 'userId': ctx.user.user.user_id} + +def create_plugin(host): + def create_app(context): + app = FastAPI() + app.add_api_route('/api/info', plugin_endpoint(info, permission='browser_test:view')) + @app.post('/api/echo') + async def echo(request: Request): + request.state.plugin_context.require_permission('browser_test:view') + return await request.json() + return app + return PluginDefinition(app_factory=create_app) +""" + + +@pytest_asyncio.fixture +async def browser(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[SimpleNamespace]: + directory = tmp_path / 'plugins' / 'browser_test' + (directory / 'web' / 'dist' / 'assets').mkdir(parents=True) + (directory / 'web' / 'dist' / 'index.html').write_text('Bundle') + (directory / 'web' / 'dist' / 'assets' / 'app.js').write_text('export const plugin = true') + (directory / '__init__.py').write_text(SOURCE) + data = { + 'manifestVersion': 2, + 'id': 'browser_test', + 'name': 'Browser test', + 'version': '1.0.0', + 'backend': { + 'module': 'plugins.browser_test', + 'entrypoint': 'plugins.browser_test:create_plugin', + 'integration': 'asgi', + }, + 'frontend': {'delivery': {'type': 'bundle'}, 'bundle': {}}, + 'permissions': ['browser_test:view'], + } + (directory / 'plugin.yaml').write_text(yaml.safe_dump(data)) + plugin = PluginScanner(directory.parent).load_manifest(directory / 'plugin.yaml') + app = FastAPI() + app.state.redis = redis = MemoryRedis() + runtime = ExplicitPluginRuntime(SimpleNamespace(is_plugin_enabled=AsyncMock(return_value=True))) + app.state.plugin_explicit_runtime = runtime + user = SimpleNamespace(user=SimpleNamespace(user_id=7), permissions=['browser_test:view']) + token = JwtUtil.encode({'user_id': '7', 'session_id': 'test-session', 'exp': int(time.time()) + 1800}) + main_key = f'{RedisInitKeyConfig.ACCESS_TOKEN.key}:test-session' + await redis.set(main_key, token, ex=1800) + monkeypatch.setattr(AppConfig, 'app_same_time_login', True) + monkeypatch.setattr(AppConfig, 'app_root_path', '') + monkeypatch.setattr(TransportCryptoConfig, 'transport_crypto_enabled', False) + + async def current_user(token_value: str = Depends(oauth2_scheme)) -> object: + if token_value != token: + raise HTTPException(status_code=401) + return user + + async def session_dependency() -> AsyncIterator[object]: + yield object() + + @asynccontextmanager + async def session() -> AsyncGenerator[object, None]: + yield object() + + app.dependency_overrides[LoginService.get_current_user] = current_user + app.dependency_overrides[get_db_session_provider(None)] = session_dependency + login = AsyncMock(return_value=user) + monkeypatch.setattr(LoginService, 'get_current_user', login) + monkeypatch.setattr(LoginService, 'get_current_user_for_plugin_session', login) + monkeypatch.setattr('plugins.core.runtime.explicit.DataSourceRegistry.session', session) + app.include_router(plugin_browser_controller) + + @app.get('/system/private') + async def private(token_value: Annotated[str, Depends(oauth2_scheme)]) -> dict: + return {'authenticated': bool(token_value)} + + app.add_middleware(TransportCryptoMiddleware) + runtime.prepare(RegisteredPlugin(plugin, None, True, 'installed'), app, startup_write_enabled=False) + await runtime.activate('browser_test', app) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='https://test') as client: + try: + yield SimpleNamespace( + app=app, + client=client, + redis=redis, + token=token, + main_key=main_key, + runtime=runtime, + user=user, + login=login, + plugin=plugin, + ) + finally: + await runtime.shutdown() + sys.modules.pop('plugins.browser_test', None) + package = sys.modules.get('plugins') + if package is not None and hasattr(package, 'browser_test'): + delattr(package, 'browser_test') + + +async def issue(browser: SimpleNamespace) -> httpx.Response: + return await browser.client.post( + '/plugin/runtime/browser_test/session', + headers={ + 'Authorization': f'Bearer {browser.token}', + 'Origin': 'https://test', + }, + ) + + +@pytest.mark.asyncio +async def test_issued_cookie_loads_ui_and_api_but_never_host_api(browser: SimpleNamespace) -> None: + client = browser.client + assert (await client.get('/apps/browser_test/ui/')).status_code == status.HTTP_401_UNAUTHORIZED + response = await issue(browser) + assert response.status_code == status.HTTP_200_OK, response.text + cookie = response.headers['set-cookie'] + assert 'HttpOnly' in cookie and 'Secure' in cookie and 'SameSite=strict' in cookie + assert 'Path=/apps/browser_test/' in cookie + assert browser.token not in response.text and browser.token not in cookie + records = [raw for key, raw in browser.redis.values.items() if key.startswith(SESSION_KEY_PREFIX)] + assert all(browser.token not in raw for raw in records) + page = await client.get('/apps/browser_test/ui/reports/month', headers={'Accept': 'text/html'}) + assert page.status_code == status.HTTP_200_OK + assert 'ruoyi-plugin-config' in page.text and browser.token not in page.text + assert (await client.get('/apps/browser_test/ui/assets/app.js')).status_code == status.HTTP_200_OK + api = await client.get('/apps/browser_test/api/info') + assert api.json()['userId'] == browser.user.user.user_id + assert browser.login.await_args.kwargs['token'] == browser.token + # 即使手工把路径受限 Cookie 附到宿主端点,也不能替代主 Bearer。 + raw_cookie = f'{COOKIE_NAME_PREFIX}browser_test=' + client.cookies.get(f'{COOKIE_NAME_PREFIX}browser_test') + assert ( + await client.get('/system/private', headers={'Cookie': raw_cookie}) + ).status_code == status.HTTP_401_UNAUTHORIZED + assert ( + await client.post('/plugin/runtime/browser_test/session', headers={'Cookie': raw_cookie}) + ).status_code == status.HTTP_401_UNAUTHORIZED + + +@pytest.mark.asyncio +async def test_multitab_renewal_reuses_cookie_and_csrf(browser: SimpleNamespace) -> None: + first, second = await asyncio.gather(issue(browser), issue(browser)) + assert first.json()['data']['csrfToken'] == second.json()['data']['csrfToken'] + assert first.cookies == second.cookies + assert len([key for key in browser.redis.values if key.startswith(SESSION_KEY_PREFIX)]) == 1 + + +@pytest.mark.asyncio +async def test_cookie_writes_require_origin_and_csrf(browser: SimpleNamespace) -> None: + response = await issue(browser) + csrf = response.json()['data']['csrfToken'] + for headers in ( + {}, + {'Origin': 'https://evil.test', 'X-Plugin-CSRF': csrf}, + {'Origin': 'https://test'}, + {'Origin': 'https://test', 'X-Plugin-CSRF': 'wrong'}, + ): + result = await browser.client.post('/apps/browser_test/api/echo', json={'value': 'safe'}, headers=headers) + assert result.status_code == status.HTTP_403_FORBIDDEN + result = await browser.client.post( + '/apps/browser_test/api/echo', json={'value': 'safe'}, headers={'Origin': 'https://test', 'X-Plugin-CSRF': csrf} + ) + assert result.json() == {'value': 'safe'} + + +@pytest.mark.asyncio +@pytest.mark.parametrize('reason', ['logout', 'replaced', 'expired', 'disabled', 'permission', 'version', 'nonce']) +async def test_session_fails_closed_after_binding_or_access_changes(browser: SimpleNamespace, reason: str) -> None: + await issue(browser) + key = next(key for key in browser.redis.values if key.startswith(SESSION_KEY_PREFIX)) + if reason == 'logout': + browser.redis.values.pop(browser.main_key) + elif reason == 'replaced': + browser.redis.values[browser.main_key] = 'new-main-token' + elif reason == 'expired': + browser.redis.deadlines[key] = 0 + elif reason == 'disabled': + browser.runtime.route_state_gateway.is_plugin_enabled.return_value = False + elif reason == 'permission': + browser.user.permissions = [] + elif reason == 'version': + browser.plugin.manifest.version = '2.0.0' + else: + record = json.loads(browser.redis.values[key]) + record['nonce'] = 'x' * 43 + browser.redis.values[key] = json.dumps(record) + response = await browser.client.get('/apps/browser_test/api/info') + assert response.status_code in {status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN} + page = await browser.client.get('/apps/browser_test/ui/', headers={'Accept': 'text/html'}) + assert page.status_code in {status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN} + + +@pytest.mark.asyncio +async def test_cross_origin_issuance_and_unknown_plugin_rejected(browser: SimpleNamespace) -> None: + headers = {'Authorization': f'Bearer {browser.token}', 'Origin': 'https://evil.test'} + assert ( + await browser.client.post('/plugin/runtime/browser_test/session', headers=headers) + ).status_code == status.HTTP_403_FORBIDDEN + headers['Origin'] = 'https://test' + assert ( + await browser.client.post('/plugin/runtime/unknown/session', headers=headers) + ).status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.asyncio +async def test_required_encryption_bypasses_only_readonly_registered_ui( + browser: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + await issue(browser) + monkeypatch.setattr(TransportCryptoConfig, 'transport_crypto_enabled', True) + monkeypatch.setattr(TransportCryptoConfig, 'transport_crypto_mode', 'required') + monkeypatch.setattr(TransportCryptoConfig, 'transport_crypto_enabled_paths', '') + monkeypatch.setattr(TransportCryptoConfig, 'transport_crypto_exclude_paths', '') + for method in ('record_plain_request', 'record_required_rejected', 'record_plain_response'): + monkeypatch.setattr(f'middlewares.transport_crypto_middleware.TransportCryptoMonitorUtil.{method}', AsyncMock()) + page = await browser.client.get('/apps/browser_test/ui/', headers={'Accept': 'text/html'}) + assert page.status_code == status.HTTP_200_OK + assert (await browser.client.get('/apps/browser_test/ui/assets/app.js')).status_code == status.HTTP_200_OK + for path in ( + '/apps/browser_test/api/info', + '/apps/unknown/ui/', + '/plugin/runtime/browser_test/session', + '/apps/browser_test/uievil', + ): + response = await browser.client.get(path) + assert response.headers['x-transport-crypto-status'] == 'required_missing' + response = await browser.client.post('/apps/browser_test/ui/') + assert response.headers['x-transport-crypto-status'] == 'required_missing' + + +@pytest.mark.asyncio +async def test_renewal_retries_if_record_expires_and_another_tab_replaces_it( + browser: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + first = await issue(browser) + key = next(key for key in browser.redis.values if key.startswith(SESSION_KEY_PREFIX)) + replacement = json.loads(browser.redis.values[key]) + replacement.update(nonce='n' * 43, csrf='c' * 43) + expire = browser.redis.expire + replaced = False + + async def replace_before_expire(record_key: str, seconds: int) -> bool: + nonlocal replaced + if not replaced: + replaced = True + browser.redis.values.pop(record_key) + # 另一标签在旧 GET 与 EXPIRE 之间成功写入新的 nonce/CSRF。 + assert await browser.redis.set(record_key, json.dumps(replacement), ex=seconds, nx=True) + return await expire(record_key, seconds) + + monkeypatch.setattr(browser.redis, 'expire', replace_before_expire) + renewed = await issue(browser) + assert renewed.status_code == status.HTTP_200_OK + assert renewed.json()['data']['csrfToken'] == replacement['csrf'] + assert renewed.json()['data']['csrfToken'] != first.json()['data']['csrfToken'] + cookie = renewed.cookies.get(f'{COOKIE_NAME_PREFIX}browser_test') + assert cookie.endswith('.' + replacement['nonce']) + assert (await browser.client.get('/apps/browser_test/api/info')).status_code == status.HTTP_200_OK + + +@pytest.mark.asyncio +@pytest.mark.parametrize('same_time_login', [True, False]) +@pytest.mark.parametrize('logout_after_read', [True, False]) +async def test_real_plugin_login_resolution_does_not_refresh_or_restore_main_session( + monkeypatch: pytest.MonkeyPatch, same_time_login: bool, logout_after_read: bool +) -> None: + monkeypatch.setattr(AppConfig, 'app_same_time_login', same_time_login) + redis = MemoryRedis() + app = FastAPI() + app.state.redis = redis + request = Request({'type': 'http', 'app': app}) + user_id = 7 + token = JwtUtil.encode({'user_id': str(user_id), 'session_id': 'read-only-session', 'exp': int(time.time()) + 1800}) + subject = 'read-only-session' if same_time_login else str(user_id) + key = f'{RedisInitKeyConfig.ACCESS_TOKEN.key}:{subject}' + await redis.set(key, token, ex=1800) + deadline = redis.deadlines[key] + original_get = redis.get + + async def get_with_concurrent_logout(record_key: str) -> str | None: + value = await original_get(record_key) + if record_key == key and logout_after_read: + redis.values.pop(key, None) + return value + + set_spy = AsyncMock(wraps=redis.set) + monkeypatch.setattr(redis, 'get', get_with_concurrent_logout) + monkeypatch.setattr(redis, 'set', set_spy) + monkeypatch.setattr( + UserDao, + 'get_user_by_id', + AsyncMock( + return_value={ + 'user_basic_info': SysUser( + user_id=user_id, user_name='plugin-user', nick_name='Plugin', time_zone='auto' + ), + 'user_role_info': [], + 'user_post_info': [], + 'user_dept_info': None, + 'user_menu_info': [SimpleNamespace(perms='demo:view')], + } + ), + ) + # 不替换 _resolve_current_user,验证真实 JWT、用户模型和权限构造路径。 + user = await LoginService.get_current_user_for_plugin_session(request, token, object()) + assert user.user.user_id == user_id + assert user.permissions == ['demo:view'] + set_spy.assert_not_awaited() + assert redis.deadlines[key] == deadline + if logout_after_read: + assert key not in redis.values + with pytest.raises(AuthException): + await LoginService.get_current_user_for_plugin_session(request, token, object()) + else: + assert redis.values[key] == token diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_bundle.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_bundle.py new file mode 100644 index 0000000..9c3e803 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_bundle.py @@ -0,0 +1,281 @@ +import json +import re +from pathlib import Path +from urllib.parse import urljoin + +import httpx +import pytest +from fastapi import FastAPI +from starlette import status +from starlette.types import Message, Receive, Scope, Send + +from plugins.core.manifest.v2 import FrontendBundleManifest +from plugins.core.runtime.asgi import PluginGatewayASGI, PluginLifespanManager +from plugins.core.runtime.bundle import PluginBundleASGI +from plugins.core.sdk import PluginRequestContext + +_HTML_HEADERS = {'Accept': 'text/html,application/xhtml+xml;q=0.9'} +_CONFIG_SCRIPT = re.compile(r'') + + +@pytest.fixture +def bundle_root(tmp_path: Path) -> Path: + root = tmp_path / 'web' / 'dist' + (root / 'assets').mkdir(parents=True) + (root / 'empty').mkdir() + (root / 'index.html').write_text( + '首页', + encoding='utf-8', + ) + (root / 'assets' / 'main.js').write_text('window.pluginLoaded = true;', encoding='utf-8') + (root / 'assets' / 'main.css').write_text('body { color: red; }', encoding='utf-8') + (root / 'assets' / 'code.wasm').write_bytes(b'\x00asm') + (root / '.env').write_text('SECRET=hidden', encoding='utf-8') + (tmp_path / 'private.txt').write_text('must not escape', encoding='utf-8') + return root + + +def _mount_bundle(plugin_root: Path, *, spa_fallback: bool = True) -> FastAPI: + child = FastAPI() + + @child.get('/api/ping') + async def ping() -> dict[str, str]: + return {'source': 'child-api'} + + parent = FastAPI() + parent.mount( + '/apps/demo', + PluginBundleASGI(child, 'demo', plugin_root, FrontendBundleManifest(spaFallback=spa_fallback)), + ) + return parent + + +@pytest.mark.asyncio +async def test_entry_deep_link_and_head_have_safe_injected_proxy_configuration(bundle_root: Path) -> None: + app = _mount_bundle(bundle_root.parents[1]) + transport = httpx.ASGITransport(app=app, root_path='/gateway') + async with httpx.AsyncClient(transport=transport, base_url='https://test') as client: + for path in ('/ui/', '/ui/index.html', '/ui/reports/monthly', '/ui/reports/monthly/'): + response = await client.get('/gateway/apps/demo' + path, headers=_HTML_HEADERS) + assert response.status_code == status.HTTP_200_OK + match = _CONFIG_SCRIPT.search(response.text) + assert match is not None + assert json.loads(match.group(1)) == { + 'pluginId': 'demo', + 'uiBase': '/gateway/apps/demo/ui/', + 'apiBase': '/gateway/apps/demo/api/', + } + assert response.text.index('ruoyi-plugin-config') < response.text.index('src="./assets/main.js"') + base = re.search(r'', response.text) + assert base is not None + assert base.group(1) == '/gateway/apps/demo/ui/' + assert urljoin(base.group(1), './assets/main.js') == '/gateway/apps/demo/ui/assets/main.js' + assert response.headers['cache-control'] == 'no-store' + assert response.headers['content-type'] == 'text/html; charset=utf-8' + assert response.headers['x-frame-options'] == 'SAMEORIGIN' + assert response.headers['content-security-policy'] == "frame-ancestors 'self'" + assert response.headers['x-content-type-options'] == 'nosniff' + head = await client.head('/gateway/apps/demo' + path, headers=_HTML_HEADERS) + assert head.status_code == status.HTTP_200_OK + assert head.content == b'' + assert head.headers['content-length'] == str(len(response.content)) + + +@pytest.mark.asyncio +async def test_ui_redirect_retains_effective_mount_prefix_and_query(bundle_root: Path) -> None: + transport = httpx.ASGITransport(app=_mount_bundle(bundle_root.parents[1]), root_path='/gateway') + async with httpx.AsyncClient(transport=transport, base_url='https://test') as client: + response = await client.get('/gateway/apps/demo/ui?view=reports%2Fmonthly') + assert response.status_code == status.HTTP_307_TEMPORARY_REDIRECT + assert response.headers['location'] == '/gateway/apps/demo/ui/?view=reports%2Fmonthly' + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('path', 'media_type'), + [('assets/main.js', 'text/javascript'), ('assets/main.css', 'text/css'), ('assets/code.wasm', 'application/wasm')], +) +async def test_static_resources_have_explicit_mime_and_head_support( + bundle_root: Path, path: str, media_type: str +) -> None: + transport = httpx.ASGITransport(app=_mount_bundle(bundle_root.parents[1])) + async with httpx.AsyncClient(transport=transport, base_url='https://test') as client: + response = await client.get('/apps/demo/ui/' + path) + head = await client.head('/apps/demo/ui/' + path) + assert response.status_code == status.HTTP_200_OK + assert response.content == (bundle_root / path).read_bytes() + assert response.headers['content-type'].split(';')[0] == media_type + assert response.headers['x-content-type-options'] == 'nosniff' + assert response.headers['cache-control'] == 'no-cache' + assert head.content == b'' + assert head.headers['content-length'] == response.headers['content-length'] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'path', + [ + 'assets/missing.js', + 'assets/missing', + 'api/missing', + 'ws/missing', + 'docs/missing', + 'missing.json', + 'missing.html', + 'empty', + 'empty/', + '.env', + '%2e%2e/private.txt', + '%2e%2e%5cprivate.txt', + 'C%3A/private.txt', + 'assets/main.js/', + 'reports//monthly', + 'assets/main.js%00', + ], +) +async def test_missing_resources_directories_and_unsafe_paths_never_fall_back(bundle_root: Path, path: str) -> None: + transport = httpx.ASGITransport(app=_mount_bundle(bundle_root.parents[1])) + async with httpx.AsyncClient(transport=transport, base_url='https://test') as client: + response = await client.get('/apps/demo/ui/' + path, headers=_HTML_HEADERS) + assert response.status_code == status.HTTP_404_NOT_FOUND + assert b'ruoyi-plugin-config' not in response.content + assert b'must not escape' not in response.content + + +@pytest.mark.asyncio +@pytest.mark.parametrize('accept', ['*/*', 'application/json', 'text/html;q=0', 'text/html;q=invalid']) +async def test_html_navigation_requires_explicit_acceptable_html(bundle_root: Path, accept: str) -> None: + transport = httpx.ASGITransport(app=_mount_bundle(bundle_root.parents[1])) + async with httpx.AsyncClient(transport=transport, base_url='https://test') as client: + entry = await client.get('/apps/demo/ui/', headers={'Accept': accept}) + missing = await client.get('/apps/demo/ui/reports/monthly', headers={'Accept': accept}) + assert entry.status_code == status.HTTP_406_NOT_ACCEPTABLE + assert missing.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.asyncio +async def test_no_fallback_mode_and_unsupported_methods_do_not_return_html(bundle_root: Path) -> None: + transport = httpx.ASGITransport(app=_mount_bundle(bundle_root.parents[1], spa_fallback=False)) + async with httpx.AsyncClient(transport=transport, base_url='https://test') as client: + missing = await client.get('/apps/demo/ui/reports', headers=_HTML_HEADERS) + for path in ('/ui/', '/ui/reports', '/ui/assets/main.js'): + response = await client.post('/apps/demo' + path, headers=_HTML_HEADERS) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + assert response.headers['allow'] == 'GET, HEAD' + assert not response.content + assert missing.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.asyncio +async def test_api_unknown_routes_and_non_http_scopes_are_delegated(bundle_root: Path) -> None: + transport = httpx.ASGITransport(app=_mount_bundle(bundle_root.parents[1])) + async with httpx.AsyncClient(transport=transport, base_url='https://test') as client: + response = await client.get('/apps/demo/api/ping', headers=_HTML_HEADERS) + missing = await client.get('/apps/demo/api/missing', headers=_HTML_HEADERS) + assert response.json() == {'source': 'child-api'} + assert missing.status_code == status.HTTP_404_NOT_FOUND + assert b'ruoyi-plugin-config' not in missing.content + reached = [] + + async def child(scope: Scope, receive: Receive, send: Send) -> None: + reached.append(scope) + + bundle = PluginBundleASGI(child, 'demo', bundle_root.parents[1], FrontendBundleManifest()) + for scope in ({'type': 'lifespan'}, {'type': 'websocket', 'path': '/ui/events'}): + await bundle(scope, None, None) + assert reached[-1] is scope + + +@pytest.mark.asyncio +async def test_outer_gateway_authentication_covers_ui_and_assets(bundle_root: Path) -> None: + async def child(scope: Scope, receive: Receive, send: Send) -> None: + pytest.fail('unauthorized request reached the child') + + async def deny(scope: Scope) -> PluginRequestContext: + raise LookupError('unauthenticated') + + manager = PluginLifespanManager(child, managed=False) + await manager.startup() + bundle = PluginBundleASGI(child, 'demo', bundle_root.parents[1], FrontendBundleManifest()) + parent = FastAPI() + parent.mount('/apps/demo', PluginGatewayASGI(bundle, manager, deny)) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=parent), base_url='https://test') as client: + for path in ('/ui', '/ui/', '/ui/index.html', '/ui/assets/main.js', '/ui/reports'): + response = await client.get('/apps/demo' + path, headers=_HTML_HEADERS) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert b'ruoyi-plugin-config' not in response.content + await manager.shutdown() + + +@pytest.mark.asyncio +async def test_symlink_escape_is_rejected_at_configuration_and_request_time(bundle_root: Path) -> None: + outside = bundle_root.parents[1] / 'private.txt' + try: + (bundle_root / 'leak.txt').symlink_to(outside) + except OSError as exc: + pytest.skip(f'当前文件系统不允许创建符号链接:{exc}') + transport = httpx.ASGITransport(app=_mount_bundle(bundle_root.parents[1])) + async with httpx.AsyncClient(transport=transport, base_url='https://test') as client: + response = await client.get('/apps/demo/ui/leak.txt', headers=_HTML_HEADERS) + assert response.status_code == status.HTTP_404_NOT_FOUND + outside_html = outside.with_suffix('.html') + outside_html.write_text('private', encoding='utf-8') + (bundle_root / 'escape.html').symlink_to(outside_html) + with pytest.raises(ValueError, match='越界'): + PluginBundleASGI(FastAPI(), 'demo', bundle_root.parents[1], FrontendBundleManifest(entry='escape.html')) + + +@pytest.mark.asyncio +async def test_configuration_json_cannot_close_script_and_has_no_request_secrets(bundle_root: Path) -> None: + child = FastAPI() + bundle = PluginBundleASGI( + child, 'demo', bundle_root.parents[1], FrontendBundleManifest() + ) + messages = [] + + async def send(message: Message) -> None: + messages.append(message) + + scope = { + 'type': 'http', + 'method': 'GET', + 'path': '/apps/demo/ui/', + 'root_path': '/apps/demo', + 'headers': [(b'accept', b'text/html'), (b'cookie', b'secret-session=top-secret')], + 'query_string': b'token=must-not-be-injected', + } + await bundle(scope, None, send) + body = b''.join(message.get('body', b'') for message in messages).decode() + match = _CONFIG_SCRIPT.search(body) + assert match is not None + assert json.loads(match.group(1))['pluginId'] == bundle.plugin_id + assert '' not in body + assert 'top-secret' not in body + assert 'must-not-be-injected' not in body + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'html', + [ + '', + 'no head', + 'no html or head element', + ], +) +async def test_runtime_base_is_first_and_preserves_doctype(bundle_root: Path, html: str) -> None: + (bundle_root / 'index.html').write_text(html, encoding='utf-8') + transport = httpx.ASGITransport(app=_mount_bundle(bundle_root.parents[1])) + async with httpx.AsyncClient(transport=transport, base_url='https://test') as client: + response = await client.get('/apps/demo/ui/deep/link', headers=_HTML_HEADERS) + assert response.status_code == status.HTTP_200_OK + assert response.text.startswith('') + assert re.search(r'', response.text).group(1) == '/apps/demo/ui/' + + +def test_missing_bundle_or_entry_is_rejected_before_activation(tmp_path: Path) -> None: + with pytest.raises(ValueError, match='目录不存在'): + PluginBundleASGI(FastAPI(), 'demo', tmp_path, FrontendBundleManifest()) + (tmp_path / 'web' / 'dist').mkdir(parents=True) + with pytest.raises(ValueError, match='入口不存在'): + PluginBundleASGI(FastAPI(), 'demo', tmp_path, FrontendBundleManifest()) diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_bundle_events.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_bundle_events.py new file mode 100644 index 0000000..ce78620 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_bundle_events.py @@ -0,0 +1,132 @@ +import asyncio +from types import SimpleNamespace + +import httpx +import pytest +from starlette import status +from starlette.middleware.gzip import GZipMiddleware + +from config.env import TransportCryptoConfig +from plugins.examples.python.bundle_demo.events import register_event_routes +from tests.plugins.core.runtime.test_browser_session import browser, issue # noqa: F401 +from tests.plugins.core.runtime.test_bundle_transport import _decrypt_response, _envelope, crypto # noqa: F401 + + +@pytest.mark.asyncio +async def test_authenticated_events_are_incremental_and_disconnect_stops_response( + browser: SimpleNamespace, # noqa: F811 +) -> None: + """事件经过真实门禁、传输和压缩中间件后仍逐条送出,客户端断开时提前结束。""" + app = browser.app + app.add_middleware(GZipMiddleware, minimum_size=1) + register_event_routes( + browser.runtime.loaded['browser_test'].lifespan.app, permission='browser_test:view', interval=0.01 + ) + issued = await issue(browser) + cookie = issued.headers['set-cookie'].split(';')[0] + count = 20 + incoming: asyncio.Queue = asyncio.Queue() + await incoming.put({'type': 'http.request', 'body': b'', 'more_body': False}) + first_event = asyncio.Event() + chunks = [] + + async def send(message: dict) -> None: + """ + 保存实际 ASGI 响应片段并通知首条事件已到达。 + + :param message: ASGI 响应消息 + :return: None + """ + if message['type'] == 'http.response.body' and message.get('body'): + chunks.append(message['body']) + first_event.set() + + task = asyncio.create_task( + app( + { + 'type': 'http', + 'asgi': {'version': '3.0', 'spec_version': '2.3'}, + 'http_version': '1.1', + 'method': 'GET', + 'scheme': 'https', + 'server': ('test', 443), + 'client': ('127.0.0.1', 12345), + 'path': '/apps/browser_test/api/events', + 'query_string': f'count={count}'.encode(), + 'root_path': '', + 'headers': [(b'host', b'test'), (b'cookie', cookie.encode()), (b'accept-encoding', b'gzip')], + }, + incoming.get, + send, + ) + ) + try: + await asyncio.wait_for(first_event.wait(), timeout=2) + assert not task.done() + assert chunks[0].startswith(b'id: 1\n') + await incoming.put({'type': 'http.disconnect'}) + await asyncio.wait_for(task, timeout=2) + assert len(chunks) < count + finally: + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_event_stream_authentication_cursor_and_bounded_input(browser: SimpleNamespace) -> None: # noqa: F811 + """真实门禁保护事件流,游标补发后续编号,非法查询在打开流前拒绝。""" + register_event_routes( + browser.runtime.loaded['browser_test'].lifespan.app, permission='browser_test:view', interval=0 + ) + await issue(browser) + path = '/apps/browser_test/api/events' + response = await browser.client.get(path, params={'count': 3}, headers={'Last-Event-ID': '1'}) + assert response.status_code == status.HTTP_200_OK + assert response.headers['content-type'].startswith('text/event-stream') + assert response.headers['cache-control'] == 'no-store' + assert response.headers['x-accel-buffering'] == 'no' + assert 'id: 1\n' not in response.text + assert [line for line in response.text.splitlines() if line.startswith('id:')] == ['id: 2', 'id: 3'] + assert 'id: 2\n' in response.text and 'id: 3\n' in response.text + assert (await browser.client.get(path, params={'count': 1}, headers={'Last-Event-ID': '1'})).text == '' + for cursor in ('-1', '1.5', 'abc', '21', '001'): + rejected = await browser.client.get(path, headers={'Last-Event-ID': cursor}) + assert rejected.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + for count in (0, 21): + assert ( + await browser.client.get(path, params={'count': count}) + ).status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=browser.app), base_url='https://test') as anonymous: + assert (await anonymous.get(path)).status_code == status.HTTP_401_UNAUTHORIZED + browser.user.permissions = [] + assert (await browser.client.get(path)).status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.asyncio +async def test_required_transport_needs_explicit_event_endpoint_exclusion( + browser: SimpleNamespace, # noqa: F811 + crypto: SimpleNamespace, # noqa: F811 + monkeypatch: pytest.MonkeyPatch, +) -> None: + """仅配置事件流接口例外后允许明文 SSE,普通 JSON 接口仍强制加密。""" + register_event_routes( + browser.runtime.loaded['browser_test'].lifespan.app, permission='browser_test:view', interval=0 + ) + session_path = '/plugin/runtime/browser_test/session' + envelope, key = _envelope(crypto, 'POST', session_path, {}) + issued = await browser.client.post( + session_path, + json=envelope, + headers={'Authorization': f'Bearer {browser.token}', 'Origin': 'https://test', 'X-Transport-Encrypt': '1'}, + ) + assert _decrypt_response(issued, key, method='POST', path=session_path)['data']['csrfToken'] + path = '/apps/browser_test/api/events' + blocked = await browser.client.get(path) + assert blocked.headers['x-transport-crypto-status'] == 'required_missing' + monkeypatch.setattr(TransportCryptoConfig, 'transport_crypto_exclude_paths', path) + response = await browser.client.get(path, params={'count': 1}) + assert response.status_code == status.HTTP_200_OK and 'id: 1\n' in response.text + assert (await browser.client.get('/apps/browser_test/api/info')).headers[ + 'x-transport-crypto-status' + ] == 'required_missing' diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_bundle_files.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_bundle_files.py new file mode 100644 index 0000000..a3185a1 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_bundle_files.py @@ -0,0 +1,116 @@ +import hashlib +from types import SimpleNamespace + +import httpx +import pytest +from starlette import status + +from config.env import TransportCryptoConfig +from plugins.examples.python.bundle_demo.files import MAX_FILE_BYTES, MAX_FORM_BYTES, register_file_routes +from tests.plugins.core.runtime.test_browser_session import browser, issue # noqa: F401 +from tests.plugins.core.runtime.test_bundle_transport import _decrypt_response, _envelope, crypto # noqa: F401 + + +@pytest.mark.asyncio +async def test_file_routes_use_real_cookie_csrf_and_permission_checks(browser: SimpleNamespace) -> None: # noqa: F811 + """真实插件门禁保护 multipart 和二进制响应,接口不需要主 Bearer。""" + register_file_routes(browser.runtime.loaded['browser_test'].lifespan.app, permission='browser_test:view') + issued = await issue(browser) + headers = {'Origin': 'https://test', 'X-Plugin-CSRF': issued.json()['data']['csrfToken']} + path = '/apps/browser_test/api/files/inspect' + file = ('报告.txt', '文件内容'.encode(), 'text/plain') + rejected = await browser.client.post(path, files={'file': file}) + assert rejected.status_code == status.HTTP_403_FORBIDDEN + response = await browser.client.post(path, files={'file': file}, headers=headers) + assert response.status_code == status.HTTP_200_OK, response.text + assert response.json() == {'filename': file[0], 'size': len(file[1]), 'sha256': hashlib.sha256(file[1]).hexdigest()} + assert 'authorization' not in response.request.headers + report = await browser.client.get('/apps/browser_test/api/files/report') + assert report.headers['content-type'].startswith('text/csv') + assert report.headers['cache-control'] == 'no-store' + assert 'authenticated-plugin' in report.text + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=browser.app), base_url='https://test') as anonymous: + assert (await anonymous.get('/apps/browser_test/api/files/report')).status_code == status.HTTP_401_UNAUTHORIZED + browser.user.permissions = [] + assert (await browser.client.get('/apps/browser_test/api/files/report')).status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.asyncio +@pytest.mark.parametrize('extra_bytes', [1, MAX_FORM_BYTES * 2]) +async def test_file_limits_are_enforced_on_actual_received_bytes( + browser: SimpleNamespace, # noqa: F811 + extra_bytes: int, +) -> None: + """文件和整个 multipart 请求分别执行实际字节上限。""" + register_file_routes(browser.runtime.loaded['browser_test'].lifespan.app, permission='browser_test:view') + issued = await issue(browser) + response = await browser.client.post( + '/apps/browser_test/api/files/inspect', + files={'file': ('large.bin', b'x' * (MAX_FILE_BYTES + extra_bytes))}, + headers={'Origin': 'https://test', 'X-Plugin-CSRF': issued.json()['data']['csrfToken']}, + ) + assert response.status_code == status.HTTP_413_CONTENT_TOO_LARGE, response.text + + +@pytest.mark.asyncio +async def test_multiple_or_missing_upload_files_are_rejected(browser: SimpleNamespace) -> None: # noqa: F811 + """单文件接口不能被表单重复文件绕过。""" + register_file_routes(browser.runtime.loaded['browser_test'].lifespan.app, permission='browser_test:view') + issued = await issue(browser) + headers = {'Origin': 'https://test', 'X-Plugin-CSRF': issued.json()['data']['csrfToken']} + path = '/apps/browser_test/api/files/inspect' + multiple = await browser.client.post(path, files=[('file', ('a', b'1')), ('file', ('b', b'2'))], headers=headers) + assert multiple.status_code == status.HTTP_400_BAD_REQUEST + missing = await browser.client.post(path, data={'file': 'not a file'}, headers=headers) + assert missing.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + + +@pytest.mark.asyncio +async def test_required_transport_needs_explicit_file_endpoint_exclusions( + browser: SimpleNamespace, # noqa: F811 + crypto: SimpleNamespace, # noqa: F811 + monkeypatch: pytest.MonkeyPatch, +) -> None: + """文件接口例外必须由部署配置声明,普通 JSON API 仍强制加密。""" + register_file_routes(browser.runtime.loaded['browser_test'].lifespan.app, permission='browser_test:view') + session_path = '/plugin/runtime/browser_test/session' + envelope, key = _envelope(crypto, 'POST', session_path, {}) + issued = await browser.client.post( + '/plugin/runtime/browser_test/session', + json=envelope, + headers={'Authorization': f'Bearer {browser.token}', 'Origin': 'https://test', 'X-Transport-Encrypt': '1'}, + ) + assert issued.status_code == status.HTTP_200_OK + csrf = _decrypt_response(issued, key, method='POST', path=session_path)['data']['csrfToken'] + upload_path = '/apps/browser_test/api/files/inspect' + headers = {'Origin': 'https://test', 'X-Plugin-CSRF': csrf} + files = {'file': ('test.txt', b'test')} + report_path = '/apps/browser_test/api/files/report' + assert (await browser.client.get(report_path)).status_code == status.HTTP_400_BAD_REQUEST + blocked = await browser.client.post(upload_path, files=files, headers=headers) + assert blocked.headers['x-transport-crypto-status'] == 'required_missing' + monkeypatch.setattr(TransportCryptoConfig, 'transport_crypto_exclude_paths', f'{upload_path},{report_path}') + upload = await browser.client.post(upload_path, files=files, headers=headers) + assert upload.status_code == status.HTTP_200_OK + assert upload.json()['sha256'] == hashlib.sha256(b'test').hexdigest() + no_csrf = await browser.client.post(upload_path, files=files) + assert no_csrf.status_code == status.HTTP_403_FORBIDDEN + report = await browser.client.get(report_path) + assert report.status_code == status.HTTP_200_OK and 'authenticated-plugin' in report.text + regular = await browser.client.get('/apps/browser_test/api/info') + assert regular.headers['x-transport-crypto-status'] == 'required_missing' + + +@pytest.mark.asyncio +@pytest.mark.parametrize('size', [0, MAX_FILE_BYTES]) +async def test_empty_and_exact_limit_files_are_accepted(browser: SimpleNamespace, size: int) -> None: # noqa: F811 + """合法零字节文件和恰好达到上限的文件不会因表单开销被误拒。""" + register_file_routes(browser.runtime.loaded['browser_test'].lifespan.app, permission='browser_test:view') + issued = await issue(browser) + response = await browser.client.post( + '/apps/browser_test/api/files/inspect', + files={'file': ('boundary.bin', b'x' * size)}, + headers={'Origin': 'https://test', 'X-Plugin-CSRF': issued.json()['data']['csrfToken']}, + ) + assert response.status_code == status.HTTP_200_OK, response.text + assert response.json()['size'] == size diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_bundle_transport.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_bundle_transport.py new file mode 100644 index 0000000..8086416 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_bundle_transport.py @@ -0,0 +1,205 @@ +import base64 +import json +import os +import secrets +import time +from types import SimpleNamespace + +import httpx +import pytest +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding, rsa +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from starlette import status + +from config.env import AppConfig, TransportCryptoConfig +from tests.plugins.core.runtime.test_browser_session import browser # noqa: F401 +from utils.transport_crypto_util import TransportCryptoMonitorUtil, TransportKeyProvider + + +def _encode(value: bytes) -> str: + return base64.urlsafe_b64encode(value).decode().rstrip('=') + + +def _decode(value: str) -> bytes: + return base64.urlsafe_b64decode(value + '=' * (-len(value) % 4)) + + +def _json_bytes(value: object) -> bytes: + return json.dumps(value, ensure_ascii=False, separators=(',', ':')).encode() + + +@pytest.fixture +def crypto(browser: SimpleNamespace, monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace: # noqa: F811 + """只替换配置密钥和监控存储;保留真实解密、重放校验、Cookie 认证和加密响应。""" + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + private_pem = private_key.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption() + ).decode() + public_key = private_key.public_key() + public_pem = public_key.public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo + ).decode() + settings = { + 'transport_crypto_enabled': True, + 'transport_crypto_mode': 'required', + 'transport_crypto_enabled_paths': '', + 'transport_crypto_required_paths': '', + 'transport_crypto_exclude_paths': '', + 'transport_crypto_kid': 'plugin-transport-test', + 'transport_crypto_private_key': private_pem, + 'transport_crypto_public_key': public_pem, + 'transport_crypto_legacy_key_pairs': '[]', + 'transport_crypto_clock_skew_seconds': 300, + 'transport_crypto_replay_ttl_seconds': 300, + } + for name, value in settings.items(): + monkeypatch.setattr(TransportCryptoConfig, name, value) + monkeypatch.setattr(TransportKeyProvider, '_key_pairs', None) + # MemoryRedis 负责会话和原子 NX 防重放;监控使用现有进程内回退,避免要求 Redis pipeline。 + monkeypatch.setattr(TransportCryptoMonitorUtil, '_get_redis_client', lambda app: None) + return SimpleNamespace(public_key=public_key, kid=settings['transport_crypto_kid']) + + +def _envelope(crypto: SimpleNamespace, method: str, path: str, payload: object) -> tuple[dict, bytes]: + """按浏览器协议构造信封,每个请求使用独立 AES key、IV 和防重放 nonce。""" + aes_key = os.urandom(32) + iv = os.urandom(12) + aad = {'method': method, 'path': path} + encrypted_key = crypto.public_key.encrypt( + aes_key, padding.OAEP(mgf=padding.MGF1(hashes.SHA256()), algorithm=hashes.SHA256(), label=None) + ) + return { + 'v': '1', + 'alg': TransportCryptoConfig.transport_crypto_algorithm, + 'kid': crypto.kid, + 'ts': int(time.time()), + 'nonce': secrets.token_urlsafe(24), + 'ek': _encode(encrypted_key), + 'iv': _encode(iv), + 'ct': _encode(AESGCM(aes_key).encrypt(iv, _json_bytes(payload), _json_bytes(aad))), + 'aad': aad, + }, aes_key + + +def _decrypt_response(response: httpx.Response, aes_key: bytes, *, method: str, path: str) -> dict: + assert response.headers['x-body-encrypted'] == '1', response.text + envelope = response.json() + expected_aad = {'method': method, 'path': path, 'direction': 'response'} + assert envelope['aad'] == expected_aad + assert envelope['alg'] == 'AES_256_GCM' + plaintext = AESGCM(aes_key).decrypt(_decode(envelope['iv']), _decode(envelope['ct']), _json_bytes(expected_aad)) + return json.loads(plaintext) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('root_path', ['', '/prefix']) +async def test_encrypted_session_cookie_and_plugin_api_keep_outer_aad( + browser: SimpleNamespace, # noqa: F811 + crypto: SimpleNamespace, + monkeypatch: pytest.MonkeyPatch, + root_path: str, +) -> None: + monkeypatch.setattr(AppConfig, 'app_root_path', root_path) + transport = httpx.ASGITransport(app=browser.app, root_path=root_path) + async with httpx.AsyncClient(transport=transport, base_url='https://test') as client: + session_path = '/plugin/runtime/browser_test/session' + envelope, aes_key = _envelope(crypto, 'POST', session_path, {}) + issued = await client.post( + root_path + session_path, + json=envelope, + headers={ + 'Authorization': f'Bearer {browser.token}', + 'Origin': 'https://test', + 'X-Transport-Encrypt': '1', + }, + ) + assert issued.status_code == status.HTTP_200_OK, issued.text + data = _decrypt_response(issued, aes_key, method='POST', path=session_path)['data'] + assert data['uiBase'] == f'{root_path}/apps/browser_test/ui/' + assert data['apiBase'] == f'{root_path}/apps/browser_test/api/' + assert browser.token not in issued.text + assert issued.headers['cache-control'] == 'no-store' + cookies = issued.headers.get_list('set-cookie') + assert len(cookies) == 1 + assert f'Path={root_path}/apps/browser_test/' in cookies[0] + assert all(attribute in cookies[0] for attribute in ('HttpOnly', 'Secure', 'SameSite=strict')) + + info_path = '/apps/browser_test/api/info' + envelope, aes_key = _envelope(crypto, 'GET', info_path, {'query': '仅存在于密文'}) + info = await client.get( + root_path + info_path, + params={'__enc': _encode(_json_bytes(envelope))}, + headers={'X-Transport-Encrypt': '1'}, + ) + assert info.status_code == status.HTTP_200_OK, info.text + assert _decrypt_response(info, aes_key, method='GET', path=info_path) == { + 'pluginId': 'browser_test', + 'userId': browser.user.user.user_id, + } + assert 'authorization' not in info.request.headers + assert 'cookie' in info.request.headers + + echo_path = '/apps/browser_test/api/echo' + payload = {'secret': '插件请求与响应保密', 'number': 17} + envelope, aes_key = _envelope(crypto, 'POST', echo_path, payload) + echo = await client.post( + root_path + echo_path, + json=envelope, + headers={ + 'X-Transport-Encrypt': '1', + 'Origin': 'https://test', + 'X-Plugin-CSRF': data['csrfToken'], + }, + ) + assert echo.status_code == status.HTTP_200_OK, echo.text + assert _decrypt_response(echo, aes_key, method='POST', path=echo_path) == payload + assert payload['secret'] not in echo.text + + # 加密不能替代 CSRF;门禁拒绝响应也使用同一个请求协商的 AES 密钥。 + envelope, aes_key = _envelope(crypto, 'POST', echo_path, payload) + rejected = await client.post( + root_path + echo_path, + json=envelope, + headers={'X-Transport-Encrypt': '1', 'Origin': 'https://test'}, + ) + assert rejected.status_code == status.HTTP_403_FORBIDDEN + assert _decrypt_response(rejected, aes_key, method='POST', path=echo_path)['code'] == status.HTTP_403_FORBIDDEN + + # 同一个已认证 Cookie 对 API 仍需信封,对只读 UI 则使用普通 HTTPS。 + plain_api = await client.get(root_path + info_path) + assert plain_api.status_code == status.HTTP_400_BAD_REQUEST + assert plain_api.headers['x-transport-crypto-status'] == 'required_missing' + page = await client.get(f'{root_path}/apps/browser_test/ui/', headers={'Accept': 'text/html'}) + assert page.status_code == status.HTTP_200_OK + assert page.headers['content-type'].startswith('text/html') + assert 'x-body-encrypted' not in page.headers + assert data['uiBase'] in page.text and 'ruoyi-plugin-config' in page.text + asset = await client.get(f'{root_path}/apps/browser_test/ui/assets/app.js') + assert asset.status_code == status.HTTP_200_OK + assert 'x-body-encrypted' not in asset.headers + + async with httpx.AsyncClient(transport=transport, base_url='https://test') as anonymous: + page = await anonymous.get(f'{root_path}/apps/browser_test/ui/', headers={'Accept': 'text/html'}) + assert page.status_code == status.HTTP_401_UNAUTHORIZED + assert 'x-transport-crypto-status' not in page.headers + assert 'Bundle' not in page.text + + +@pytest.mark.asyncio +@pytest.mark.parametrize('wrong_path', ['/api/info', '/apps/other/api/info']) +async def test_plugin_api_rejects_aad_without_its_full_namespace( + browser: SimpleNamespace, # noqa: F811 + crypto: SimpleNamespace, + wrong_path: str, +) -> None: + envelope, _ = _envelope(crypto, 'GET', wrong_path, {}) + response = await browser.client.get( + '/apps/browser_test/api/info', + params={'__enc': _encode(_json_bytes(envelope))}, + headers={'X-Transport-Encrypt': '1'}, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.headers['x-transport-crypto-status'] == 'aad_mismatch' + browser.login.assert_not_awaited() diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_configuration.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_configuration.py new file mode 100644 index 0000000..6ad8a0a --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_configuration.py @@ -0,0 +1,211 @@ +import asyncio +import json +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +import pytest_asyncio +from fastapi import FastAPI +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from config.env import JwtConfig +from plugins.core.management.entity.do.models import SysPluginConfig +from plugins.core.management.entity.vo.schemas import PluginConfigUpdateModel +from plugins.core.management.service.service import PluginService +from plugins.core.manifest.schema import PluginConfigItemManifest +from plugins.core.runtime.configuration import ( + PluginConfigObservation, + PluginConfigReader, + build_config_status, + config_revision, +) +from plugins.core.runtime.explicit import ExplicitPluginRuntime +from plugins.core.runtime.metrics import PluginRuntimeMetrics +from plugins.core.runtime.metrics_store import PluginMetricsReporter +from plugins.core.runtime.startup import PluginRuntimeStartupManager +from plugins.core.sdk import PluginConfigSnapshot, PluginHostContext +from tests.plugins.core.runtime.test_explicit import clear_modules, write_plugin # noqa: F401 +from tests.plugins.core.runtime.test_metrics_store import MetricsRedis + + +@pytest_asyncio.fixture +async def config_sessions(tmp_path: Path) -> AsyncIterator[async_sessionmaker[AsyncSession]]: + """使用独立 SQLite 验证配置落库、解密和重启读取。""" + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "config.sqlite"}') + async with engine.begin() as db: + await db.run_sync(SysPluginConfig.__table__.create) + try: + yield async_sessionmaker(engine, expire_on_commit=False) + finally: + await engine.dispose() + + +def test_revision_is_stable_secret_sensitive_and_bound_to_host_key(monkeypatch: pytest.MonkeyPatch) -> None: + """相同配置顺序不影响版本,密文配置变动和宿主密钥变化会改变版本。""" + first = config_revision('config_demo', {'secret': 'first', 'label': 'value'}) + assert first == config_revision('config_demo', {'label': 'value', 'secret': 'first'}) + assert first != config_revision('config_demo', {'secret': 'second', 'label': 'value'}) + assert first != config_revision('other_demo', {'secret': 'first', 'label': 'value'}) + monkeypatch.setattr(JwtConfig, 'jwt_secret_key', 'different-test-key') + assert first != config_revision('config_demo', {'secret': 'first', 'label': 'value'}) + + +def test_snapshot_is_independent_and_hides_values_in_repr() -> None: + """调用方修改原始数据或快照嵌套值不会污染另一个快照。""" + values = {'private': {'list': ['secret-value']}} + snapshot = PluginConfigSnapshot(values, 'a' * 64) + values['private']['list'].append('later') + assert snapshot.values['private']['list'] == ['secret-value'] + with pytest.raises(TypeError): + snapshot.values['private'] = None + assert 'secret-value' not in repr(snapshot) + + +@pytest.mark.asyncio +async def test_missing_reader_fails_explicitly(tmp_path: Path) -> None: + """手动构造的旧上下文不能伪装成已完成最新配置读取。""" + with pytest.raises(RuntimeError, match='按需配置读取'): + await PluginHostContext('config_demo', tmp_path).read_config() + + +@pytest.mark.asyncio +async def test_database_changes_dynamic_reads_and_restart_snapshots_remain_distinct( + tmp_path: Path, config_sessions: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch +) -> None: + """真实配置保存与解密改变按需读取,直到下一次启动才更新启动版本。""" + plugin = write_plugin(tmp_path) + discovered = plugin.discovered_plugin + discovered.manifest.config.items = [ + PluginConfigItemManifest(key='greeting', default='before'), + PluginConfigItemManifest(key='api_key', default='private-first', secret=True), + ] + monkeypatch.setattr('plugins.core.runtime.startup.DataSourceRegistry.session', config_sessions) + app = FastAPI() + runtime = ExplicitPluginRuntime(SimpleNamespace(is_plugin_enabled=AsyncMock(return_value=True))) + initial = await PluginRuntimeStartupManager.load_explicit_plugin_config(plugin) + runtime.prepare(plugin, app, startup_write_enabled=False, config_values=initial) + await runtime.activate(plugin.plugin_id, app) + host = runtime.loaded[plugin.plugin_id].host + try: + initial_revision = host.config_revision + async with config_sessions() as db: + await PluginService.update_plugin_config_services( + db, discovered, PluginConfigUpdateModel(values={'greeting': 'after', 'api_key': 'private-second'}) + ) + await db.commit() + latest = await host.read_config() + assert dict(latest.values) == {'greeting': 'after', 'api_key': 'private-second'} + assert latest.revision != initial_revision + assert host.config['greeting'] == 'before' + assert host.config_revision == initial_revision + report = await runtime.metrics_reporter.read(plugin.plugin_id) + assert 'private-first' not in json.dumps(report) and 'private-second' not in json.dumps(report) + state = build_config_status(plugin.plugin_id, latest.revision, report) + assert state['state'] == 'restart_required' + assert state['scope'] == 'current_worker' + assert state['workers'][0]['lastReadRevision'] == latest.revision + assert state['workers'][0]['matchesDesired'] is False + async with config_sessions() as db: + masked = await PluginService.update_plugin_config_services( + db, discovered, PluginConfigUpdateModel(values={'api_key': '******'}) + ) + await db.commit() + assert next(item.value for item in masked if item.key == 'api_key') == '******' + assert (await host.read_config()).revision == latest.revision + finally: + await runtime.shutdown() + assert runtime.metrics.configurations[plugin.plugin_id].active is False + restarted = ExplicitPluginRuntime(SimpleNamespace(is_plugin_enabled=AsyncMock(return_value=True))) + restarted_app = FastAPI() + restarted.prepare( + plugin, + restarted_app, + startup_write_enabled=False, + config_values=await PluginRuntimeStartupManager.load_explicit_plugin_config(plugin), + ) + await restarted.activate(plugin.plugin_id, restarted_app) + try: + report = await restarted.metrics_reporter.read(plugin.plugin_id) + state = build_config_status(plugin.plugin_id, latest.revision, report) + assert state['state'] == 'observed_match' + assert state['workers'][0]['startupRevision'] == latest.revision + assert state['workers'][0]['lastReadRevision'] is None + finally: + await restarted.shutdown() + + +@pytest.mark.asyncio +async def test_remote_configuration_versions_do_not_claim_whole_cluster_activation() -> None: + """旧进程配置、无配置上报的旧宿主和 Redis 故障均保留可观测范围。""" + redis = MetricsRedis() + reporters = [] + for worker_id, revision in [('a', 'a'), ('b', 'b'), ('c', None)]: + metrics = PluginRuntimeMetrics(worker_id=worker_id * 32) + metrics.register('config_demo', '1.0.0', None, None) + if revision: + metrics.configurations['config_demo'] = PluginConfigObservation( + plugin_id='config_demo', + version='1.0.0', + active=True, + startup_revision=revision * 64, + ) + reporter = PluginMetricsReporter(metrics) + reporter.start(redis, 'config-test:metrics') + await reporter.publish() + reporters.append(reporter) + try: + report = await reporters[0].read('config_demo') + state = build_config_status('config_demo', 'a' * 64, report) + assert state['state'] == 'restart_required' + assert state['matchedWorkers'] == state['pendingWorkers'] == 1 + assert state['unknownWorkers'] == 1 + assert state['scope'] == 'reporting_workers' + redis.fail = True + local = build_config_status('config_demo', 'a' * 64, await reporters[0].read('config_demo')) + assert local['state'] == 'observed_match' and local['scope'] == 'current_worker' + assert local['clusterAvailable'] is False + unknown = build_config_status('config_demo', 'a' * 64, {'scope': 'unavailable'}) + assert unknown['state'] == 'unobserved' + finally: + redis.fail = False + for reporter in reporters: + await reporter.stop() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('cancelled', [False, True]) +async def test_failed_or_cancelled_read_keeps_previous_observation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, cancelled: bool +) -> None: + """读取失败或取消时关闭独立会话,且不能上报新的读取版本。""" + entered = asyncio.Event() + closed = [] + + @asynccontextmanager + async def session() -> AsyncIterator[object]: + try: + yield object() + finally: + closed.append(True) + + async def read(*args: object, **kwargs: object) -> list: + entered.set() + if cancelled: + await asyncio.Event().wait() + raise RuntimeError('database unavailable') + + monkeypatch.setattr(PluginService, 'get_plugin_config_services', read) + plugin = write_plugin(tmp_path).discovered_plugin + observation = PluginConfigObservation(plugin_id=plugin.manifest.id, version='1.0.0', startup_revision='a' * 64) + reader = PluginConfigReader(plugin, session, observation) + task = asyncio.create_task(reader.read()) + await asyncio.wait_for(entered.wait(), timeout=2) + if cancelled: + task.cancel() + with pytest.raises(asyncio.CancelledError if cancelled else RuntimeError): + await task + assert closed == [True] + assert observation.last_read_revision is observation.last_read_at is None diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_connection_sessions.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_connection_sessions.py new file mode 100644 index 0000000..e0baeb3 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_connection_sessions.py @@ -0,0 +1,238 @@ +import asyncio +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from fastapi import WebSocket +from starlette import status +from starlette.types import Message + +from module_admin.service.login_service import LoginService +from plugins.core.runtime.browser_session import SESSION_KEY_PREFIX +from plugins.examples.python.bundle_demo.events import register_event_routes +from tests.plugins.core.runtime.test_browser_session import browser, issue # noqa: F401 + + +async def start_stream(runtime_browser: SimpleNamespace, *, bearer: bool = False) -> SimpleNamespace: + """ + 通过完整挂载、插件门禁和真实 Cookie 记录接收首条事件。 + + :param runtime_browser: 隔离的宿主运行时和账号查询替身 + :param bearer: 是否使用主 Bearer 替代插件 Cookie + :return: 可观察逐条输出及主动断开的请求状态 + """ + loaded = runtime_browser.runtime.loaded['browser_test'] + loaded.gateway.connections.recheck_interval = 0.01 + register_event_routes(loaded.lifespan.app, permission='browser_test:view', interval=0.01) + response = await issue(runtime_browser) + cookie = response.headers['set-cookie'].split(';')[0] + headers = [(b'host', b'test')] + headers.append( + (b'authorization', f'Bearer {runtime_browser.token}'.encode()) if bearer else (b'cookie', cookie.encode()) + ) + result = SimpleNamespace(incoming=asyncio.Queue(), first=asyncio.Event(), chunks=[], messages=[]) + await result.incoming.put({'type': 'http.request', 'body': b'', 'more_body': False}) + + async def send(message: Message) -> None: + """保留流输出并在首个响应片段到达时继续测试。""" + result.messages.append(message) + if message['type'] == 'http.response.body' and message.get('body'): + result.chunks.append(message['body']) + result.first.set() + + result.task = asyncio.create_task( + runtime_browser.app( + { + 'type': 'http', + 'asgi': {'version': '3.0', 'spec_version': '2.3'}, + 'http_version': '1.1', + 'method': 'GET', + 'scheme': 'https', + 'server': ('test', 443), + 'client': ('127.0.0.1', 12345), + 'path': '/apps/browser_test/api/events', + 'query_string': b'count=20', + 'root_path': '', + 'headers': headers, + }, + result.incoming.get, + send, + ) + ) + await asyncio.wait_for(result.first.wait(), timeout=2) + assert result.chunks[0].startswith(b'id: 1\n') + assert not result.task.done() + return result + + +async def finish_stream(stream: SimpleNamespace) -> None: + """回收测试连接,保证失败分支也不会遗留请求任务。""" + await stream.incoming.put({'type': 'http.disconnect'}) + if not stream.task.done(): + stream.task.cancel() + await asyncio.gather(stream.task, return_exceptions=True) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('origin', ['https://test', 'https://outside.test']) +async def test_websocket_cookie_origin_and_live_login_revocation( + browser: SimpleNamespace, # noqa: F811 + origin: str, +) -> None: + """WebSocket 保留精确 Origin 门禁,已握手的连接在主登录失效后关闭。""" + loaded = browser.runtime.loaded['browser_test'] + loaded.gateway.connections.recheck_interval = 0.01 + opened = asyncio.Event() + messages = [] + incoming: asyncio.Queue = asyncio.Queue() + + @loaded.lifespan.app.websocket('/ws/events') + async def events(websocket: WebSocket) -> None: + websocket.state.plugin_context.require_permission('browser_test:view') + await websocket.accept() + opened.set() + await websocket.receive_text() + + response = await issue(browser) + cookie = response.headers['set-cookie'].split(';')[0] + await incoming.put({'type': 'websocket.connect'}) + + async def send(message: Message) -> None: + """保留 WebSocket 接受及关闭消息。""" + messages.append(message) + + task = asyncio.create_task( + browser.app( + { + 'type': 'websocket', + 'asgi': {'version': '3.0', 'spec_version': '2.3'}, + 'scheme': 'wss', + 'server': ('test', 443), + 'client': ('127.0.0.1', 12345), + 'path': '/apps/browser_test/ws/events', + 'query_string': b'', + 'root_path': '', + 'subprotocols': [], + 'headers': [(b'host', b'test'), (b'cookie', cookie.encode()), (b'origin', origin.encode())], + }, + incoming.get, + send, + ) + ) + try: + if origin == 'https://test': + await asyncio.wait_for(opened.wait(), timeout=1) + browser.redis.values.pop(browser.main_key) + await asyncio.wait_for(task, timeout=1) + assert opened.is_set() == (origin == 'https://test') + assert messages[-1]['type'] == 'websocket.close' + assert messages[-1]['code'] == status.WS_1008_POLICY_VIOLATION + assert not loaded.gateway.connections.connections + finally: + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('change', ['logout', 'replaced_login', 'cookie_expired', 'permission', 'disabled']) +async def test_live_cookie_connection_rechecks_real_session_records( + browser: SimpleNamespace, # noqa: F811 + change: str, +) -> None: + """实际 Cookie 门禁会复核登录记录、插件会话、权限和启用状态。""" + stream = await start_stream(browser) + try: + if change == 'logout': + browser.redis.values.pop(browser.main_key) + elif change == 'replaced_login': + browser.redis.values[browser.main_key] = 'different-token' + elif change == 'cookie_expired': + for key in list(browser.redis.values): + if key.startswith(SESSION_KEY_PREFIX): + browser.redis.values.pop(key) + elif change == 'permission': + browser.user.permissions = [] + else: + browser.runtime.route_state_gateway.is_plugin_enabled.return_value = False + await asyncio.wait_for(stream.task, timeout=2) + body = b''.join(stream.chunks) + assert b'event: ruoyi.plugin.closed' in body + assert ( + b'session_expired' if change in {'logout', 'replaced_login', 'cookie_expired'} else b'access_revoked' + ) in body + assert b'id: 20\n' not in body + assert browser.token.encode() not in body + assert not browser.runtime.loaded['browser_test'].gateway.connections.connections + finally: + await finish_stream(stream) + + +@pytest.mark.asyncio +async def test_runtime_closes_connections_before_lifespan_resources( + browser: SimpleNamespace, # noqa: F811 + monkeypatch: pytest.MonkeyPatch, +) -> None: + """显式运行时关闭先结束连接,再交给插件释放生命周期资源。""" + stream = await start_stream(browser) + loaded = browser.runtime.loaded['browser_test'] + original = loaded.lifespan.shutdown + closed = [] + + async def shutdown() -> None: + """在关闭资源前检查请求及连接登记已经回收。""" + assert stream.task.done() + assert not loaded.gateway.connections.connections + closed.append(True) + await original() + + monkeypatch.setattr(loaded.lifespan, 'shutdown', shutdown) + try: + await browser.runtime.shutdown() + assert closed == [True] + assert b'plugin_shutdown' in b''.join(stream.chunks) + finally: + await finish_stream(stream) + + +@pytest.mark.asyncio +async def test_recheck_uses_fresh_closed_db_sessions_and_does_not_refresh_bearer( + browser: SimpleNamespace, # noqa: F811 + monkeypatch: pytest.MonkeyPatch, +) -> None: + """周期复核使用独立短会话和不续期登录的查询,原 Bearer 仅在接入时使用普通登录查询。""" + sessions = SimpleNamespace(active=0, opened=0) + checked = asyncio.Event() + + @asynccontextmanager + async def session() -> AsyncIterator[object]: + """统计独立数据库会话在复核之间全部关闭。""" + sessions.active += 1 + sessions.opened += 1 + try: + yield object() + finally: + sessions.active -= 1 + + async def read_user(**kwargs: object) -> object: + """记录只读登录验证并返回固定账号。""" + checked.set() + return browser.user + + initial = AsyncMock(return_value=browser.user) + readonly = AsyncMock(side_effect=read_user) + monkeypatch.setattr('plugins.core.runtime.explicit.DataSourceRegistry.session', session) + monkeypatch.setattr(LoginService, 'get_current_user', initial) + monkeypatch.setattr(LoginService, 'get_current_user_for_plugin_session', readonly) + stream = await start_stream(browser, bearer=True) + try: + await asyncio.wait_for(checked.wait(), timeout=1) + await asyncio.sleep(0) + assert sessions.active == 0 and sessions.opened > 1 + initial.assert_awaited_once() + assert readonly.await_count > 0 + assert readonly.await_args.kwargs['token'] == browser.token + finally: + await finish_stream(stream) diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_connections.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_connections.py new file mode 100644 index 0000000..96cfb76 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_connections.py @@ -0,0 +1,312 @@ +import asyncio +import json +from pathlib import Path +from types import SimpleNamespace + +import httpx +import pytest +from starlette import status +from starlette.responses import JSONResponse +from starlette.types import Message, Receive, Scope, Send + +from plugins.core.runtime.asgi import PluginGatewayASGI, PluginLifespanManager +from plugins.core.runtime.connections import AUTH_RECHECK_KEY, CONNECTION_OUTCOME_KEY, STREAM_CLOSED_EVENT +from plugins.core.runtime.metrics import PluginObservedASGI, PluginRuntimeMetrics +from plugins.core.sdk import PluginHostContext, PluginRequestContext + + +async def open_connection(tmp_path: Path, *, kind: str = 'http', listen: bool = False) -> SimpleNamespace: + """ + 建立可控的真实网关连接,保留首条事件、取消及指标证据。 + + :param tmp_path: 测试资源目录 + :param kind: HTTP 事件流或 WebSocket + :param listen: 是否等待客户端断开消息 + :return: 当前连接的网关、任务和可变测试状态 + """ + case = SimpleNamespace( + calls=[], + messages=[], + permissions=frozenset({'demo:view', 'demo:edit'}), + user_id=7, + error=None, + delay=0, + ready=asyncio.Event(), + released=asyncio.Event(), + finish=asyncio.Event(), + incoming=asyncio.Queue(), + ) + host = PluginHostContext('demo', tmp_path) + + async def authorize(scope: Scope) -> PluginRequestContext: + """按当前测试状态返回权限快照或触发指定失败。""" + case.calls.append(dict(scope)) + if case.delay: + await asyncio.sleep(case.delay) + if case.error: + raise case.error + return PluginRequestContext( + host, SimpleNamespace(user=SimpleNamespace(user_id=case.user_id)), permissions=case.permissions + ) + + async def child(scope: Scope, receive: Receive, send: Send) -> None: + """发送首条业务消息,并在退出时记录清理结果。""" + case.context = scope['state']['plugin_context'] + try: + if kind == 'websocket': + await send({'type': 'websocket.accept'}) + await send({'type': 'websocket.send', 'text': 'first'}) + else: + await send( + {'type': 'http.response.start', 'status': 200, 'headers': [(b'content-type', b'text/event-stream')]} + ) + await send({'type': 'http.response.body', 'body': b'data: first\n\n', 'more_body': True}) + scope['path'] = '/changed-by-child' + case.ready.set() + if listen: + await receive() + await asyncio.Event().wait() + else: + await case.finish.wait() + if kind == 'websocket': + await send({'type': 'websocket.close', 'code': status.WS_1000_NORMAL_CLOSURE}) + else: + await send({'type': 'http.response.body', 'body': b'', 'more_body': False}) + finally: + case.released.set() + + async def send(message: Message) -> None: + """保留协议输出以验证单次关闭及敏感字段不外泄。""" + case.messages.append(message) + + manager = PluginLifespanManager(child, managed=False) + await manager.startup() + case.gateway = PluginGatewayASGI( + child, manager, authorize, recheck_interval=0.01, recheck_timeout=0.03, cancel_timeout=0.05 + ) + case.manager = manager + case.metrics = PluginRuntimeMetrics() + case.metrics.register('demo', '1.0.0', None, None) + observed = PluginObservedASGI(case.gateway, case.metrics, 'demo') + case.scope = {'type': kind, 'path': '/api/events', 'headers': [], 'method': 'GET'} + case.task = asyncio.create_task(observed(case.scope, case.incoming.get, send)) + await asyncio.wait_for(case.ready.wait(), timeout=1) + return case + + +async def finish_case(case: SimpleNamespace) -> None: + """回收用例中的连接和生命周期任务。""" + await case.gateway.drain() + if not case.task.done(): + case.task.cancel() + await asyncio.gather(case.task, return_exceptions=True) + await case.manager.shutdown() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('kind', ['http', 'websocket']) +@pytest.mark.parametrize( + ('change', 'reason', 'outcome'), + [ + ('login', 'session_expired', 'rejected'), + ('permission', 'access_revoked', 'rejected'), + ('reduced', 'access_revoked', 'rejected'), + ('identity', 'access_revoked', 'rejected'), + ('unavailable', 'authorization_unavailable', 'failed'), + ('timeout', 'authorization_timeout', 'failed'), + ('lifespan', 'authorization_unavailable', 'failed'), + ], +) +async def test_revalidation_terminates_stream_and_records_reason( + tmp_path: Path, kind: str, change: str, reason: str, outcome: str +) -> None: + """已接受的连接在权限或身份变化后关闭,指标不把已发送 200 的 SSE 计为成功。""" + case = await open_connection(tmp_path, kind=kind) + try: + if change == 'login': + case.error = LookupError('secret credential') + elif change == 'permission': + case.error = PermissionError('secret permission source') + elif change == 'reduced': + case.permissions = frozenset({'demo:view'}) + elif change == 'identity': + case.user_id += 1 + elif change == 'unavailable': + case.error = RuntimeError('secret database') + elif change == 'timeout': + case.delay = 10 + else: + case.manager.started = False + await asyncio.wait_for(case.task, timeout=1) + assert case.released.is_set() + assert not case.gateway.connections.connections + assert case.scope[CONNECTION_OUTCOME_KEY].reason == reason + if kind == 'http': + last = case.messages[-1] + assert last['more_body'] is False + assert f'event: {STREAM_CLOSED_EVENT}\n'.encode() in last['body'] + assert reason.encode() in last['body'] + else: + assert case.messages[-1]['type'] == 'websocket.close' + assert case.messages[-1]['code'] == ( + status.WS_1008_POLICY_VIOLATION if outcome == 'rejected' else status.WS_1011_INTERNAL_ERROR + ) + assert 'secret' not in str(case.messages) + sample = next(item for item in case.metrics.snapshot().series if item.operation == kind) + assert sample.active == 0 and sample.succeeded == 0 + assert getattr(sample, outcome) == 1 + assert sample.timed_out == int(change == 'timeout') + assert sample.last_error_type == f'PluginConnection:{reason}' + for call in case.calls[1:]: + assert call[AUTH_RECHECK_KEY] is True + assert call['path'] == '/api/events' + finally: + await finish_case(case) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('permissions', [frozenset({'demo:view', 'demo:edit', 'demo:extra'}), frozenset({'*:*:*'})]) +async def test_added_permissions_do_not_mutate_existing_context(tmp_path: Path, permissions: frozenset[str]) -> None: + """权限增加不会打断连接,也不会给原请求快照隐式增加权限。""" + case = await open_connection(tmp_path) + try: + original = case.context.permissions + case.permissions = permissions + await asyncio.sleep(0.06) + assert not case.task.done() + assert len(case.calls) > 1 + assert case.context.permissions == original + case.finish.set() + await asyncio.wait_for(case.task, timeout=1) + assert CONNECTION_OUTCOME_KEY not in case.scope + assert case.metrics.snapshot().series[0].succeeded == 1 + calls = len(case.calls) + await asyncio.sleep(0.03) + assert len(case.calls) == calls + assert not case.gateway.connections.connections + finally: + await finish_case(case) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('kind', ['http', 'websocket']) +async def test_client_disconnect_and_external_cancel_release_tasks(tmp_path: Path, kind: str) -> None: + """客户端断开或服务器取消时回收业务和复核任务,不再发送关闭消息。""" + for external_cancel in (False, True): + case = await open_connection(tmp_path, kind=kind, listen=True) + try: + sent = list(case.messages) + if external_cancel: + case.task.cancel() + with pytest.raises(asyncio.CancelledError): + await case.task + else: + await case.incoming.put({'type': f'{kind}.disconnect'}) + await asyncio.wait_for(case.task, timeout=1) + assert case.messages == sent + assert case.released.is_set() + assert not case.gateway.connections.connections + assert next(item for item in case.metrics.snapshot().series if item.operation == kind).cancelled == 1 + finally: + await finish_case(case) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('kind', ['http', 'websocket']) +async def test_drain_closes_connections_and_rejects_new_requests(tmp_path: Path, kind: str) -> None: + """先结束业务连接再返回关闭流程,后续请求得到未就绪响应。""" + case = await open_connection(tmp_path, kind=kind) + try: + await case.gateway.drain() + assert case.task.done() and case.released.is_set() + assert case.scope[CONNECTION_OUTCOME_KEY].reason == 'plugin_shutdown' + assert next(item for item in case.metrics.snapshot().series if item.operation == kind).cancelled == 1 + if kind == 'websocket': + assert case.messages[-1]['code'] == status.WS_1012_SERVICE_RESTART + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=case.gateway), base_url='http://test') as client: + assert (await client.get('/api/info')).status_code == status.HTTP_503_SERVICE_UNAVAILABLE + await case.gateway.drain() + finally: + await finish_case(case) + + +@pytest.mark.asyncio +async def test_regular_json_response_never_creates_revalidation_task(tmp_path: Path) -> None: + """普通 JSON 请求沿用一次鉴权,不创建周期复核或控制事件。""" + calls = [] + + async def child(scope: Scope, receive: Receive, send: Send) -> None: + await asyncio.sleep(0.03) + await JSONResponse({'ok': True})(scope, receive, send) + + async def authorize(scope: Scope) -> PluginRequestContext: + calls.append(scope) + return PluginRequestContext(PluginHostContext('demo', tmp_path), None) + + manager = PluginLifespanManager(child, managed=False) + await manager.startup() + gateway = PluginGatewayASGI(child, manager, authorize, recheck_interval=0.01) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=gateway), base_url='http://test') as client: + assert (await client.get('/api/info')).json() == {'ok': True} + assert len(calls) == 1 + assert not gateway.connections.connections + await manager.shutdown() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('external_cancel', [False, True]) +async def test_cancel_resistant_application_cannot_send_late_events(tmp_path: Path, external_cancel: bool) -> None: + """业务忽略首次取消也无法继续输出,关闭等待有界且迟到任务最终被回收。""" + opened, release, released, fenced = (asyncio.Event() for _ in range(4)) + messages = [] + + async def child(scope: Scope, receive: Receive, send: Send) -> None: + try: + await send( + {'type': 'http.response.start', 'status': 200, 'headers': [(b'content-type', b'text/event-stream')]} + ) + opened.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + await release.wait() + try: + await send({'type': 'http.response.body', 'body': b'secret late data', 'more_body': True}) + except asyncio.CancelledError: + fenced.set() + finally: + released.set() + + async def authorize(scope: Scope) -> PluginRequestContext: + return PluginRequestContext(PluginHostContext('demo', tmp_path), None) + + async def send(message: Message) -> None: + messages.append(message) + + manager = PluginLifespanManager(child, managed=False) + await manager.startup() + gateway = PluginGatewayASGI(child, manager, authorize, cancel_timeout=0.01) + task = asyncio.create_task(gateway({'type': 'http', 'headers': []}, None, send)) + try: + await asyncio.wait_for(opened.wait(), timeout=1) + if external_cancel: + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + else: + await asyncio.wait_for(gateway.drain(), timeout=1) + await task + assert gateway.connections._unfinished + assert 'secret' not in str(messages) + release.set() + await asyncio.wait_for(released.wait(), timeout=1) + await asyncio.sleep(0) + assert fenced.is_set() + assert not gateway.connections._unfinished + if not external_cancel: + assert json.loads(messages[-1]['body'].decode().split('data: ')[1])['reason'] == 'plugin_shutdown' + finally: + release.set() + await gateway.drain() + await asyncio.gather(task, return_exceptions=True) + await manager.shutdown() diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_diagnostics.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_diagnostics.py new file mode 100644 index 0000000..d294b25 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_diagnostics.py @@ -0,0 +1,393 @@ +import asyncio +import json +from contextlib import suppress +from copy import deepcopy +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI +from pydantic import ValidationError + +from plugins.core.discovery.registry import RegisteredPlugin +from plugins.core.discovery.scanner import DiscoveredPlugin +from plugins.core.manifest.schema import PluginManifestFactory +from plugins.core.runtime.asgi import PluginGatewayASGI, PluginLifespanManager +from plugins.core.runtime.configuration import PluginConfigObservation +from plugins.core.runtime.diagnostics import PluginRuntimeDiagnostic, PluginRuntimeDiagnosticSnapshot +from plugins.core.runtime.explicit import ExplicitPluginRuntime, LoadedExplicitPlugin +from plugins.core.runtime.health import PluginHealthChecker, PluginHealthResult +from plugins.core.runtime.task_cleanup import cancel_plugin_tasks, pending_plugin_tasks +from plugins.core.sdk import PluginDefinition, PluginHostContext, PluginRequestContext + + +def diagnostic_runtime(tmp_path: Path, plugin_id: str = 'diagnostic_demo') -> ExplicitPluginRuntime: + """ + 构造已加载且未执行回调的真实运行时容器。 + + :param tmp_path: 隔离测试资源目录 + :param plugin_id: 测试插件标识 + :return: 包含显式定义、实际制品身份及安全配置观测的运行时 + """ + manifest = PluginManifestFactory.create( + { + 'manifestVersion': 2, + 'id': plugin_id, + 'name': 'Diagnostic demo', + 'version': '1.2.0', + 'backend': {'module': f'plugins.{plugin_id}', 'entrypoint': f'plugins.{plugin_id}:create_plugin'}, + } + ) + discovered = DiscoveredPlugin(manifest, tmp_path, tmp_path / 'plugin.yaml', 'a' * 64, 'b' * 32) + host = PluginHostContext( + plugin_id, + tmp_path, + config={'password': 'private-config-value'}, + config_revision='c' * 64, + config_reader=AsyncMock(side_effect=AssertionError('sampling must not read configuration')), + ) + runtime = ExplicitPluginRuntime(SimpleNamespace()) + runtime.loaded[plugin_id] = LoadedExplicitPlugin( + RegisteredPlugin(discovered, None, True, 'installed'), host, PluginDefinition() + ) + runtime.metrics.register(plugin_id, manifest.version, discovered.artifact_digest, discovered.artifact_generation) + runtime.metrics.configurations[plugin_id] = PluginConfigObservation( + plugin_id=plugin_id, + version=manifest.version, + digest=discovered.artifact_digest, + generation=discovered.artifact_generation, + startup_revision='c' * 64, + last_read_revision='d' * 64, + last_read_at=123.0, + ) + return runtime + + +def test_snapshot_copies_actual_identity_config_and_most_recent_error(tmp_path: Path) -> None: + """快照使用实际加载身份且不保留配置明文、回调或后续可变对象。""" + runtime = diagnostic_runtime(tmp_path) + loaded = runtime.loaded['diagnostic_demo'] + loaded.active = True + runtime.metrics.identities['diagnostic_demo']['digest'] = 'e' * 64 + runtime.metrics.identities['diagnostic_demo']['generation'] = 'f' * 32 + first = runtime.metrics.begin('diagnostic_demo', 'http', 'old-request') + first.finish('failed', error_type='OldError') + first.series.last_error_at = 124.0 + second = runtime.metrics.begin('diagnostic_demo', 'job:refresh', 'latest-request') + second.finish('failed', error_type='NewError') + second.series.last_error_at = 125.0 + snapshot = runtime.diagnostic_snapshot() + plugin = snapshot.plugins[0] + assert snapshot.worker_id == runtime.metrics.worker_id + assert (plugin.version, plugin.digest, plugin.generation) == ('1.2.0', 'a' * 64, 'b' * 32) + assert plugin.active and plugin.ready and not plugin.closing + assert plugin.lifespan is None + assert plugin.activation_health.status == 'unknown' + assert plugin.activation_health.checked_at is None + assert plugin.last_error.model_dump(by_alias=True) == { + 'errorType': 'NewError', + 'requestId': 'latest-request', + 'at': 125.0, + } + assert plugin.config.startup_revision == 'c' * 64 + assert plugin.config.last_read_revision == 'd' * 64 + assert 'private-config-value' not in snapshot.model_dump_json() + loaded.host.config_reader.assert_not_called() + runtime.metrics.configurations['diagnostic_demo'].last_read_revision = 'e' * 64 + runtime.loaded['diagnostic_demo'].activation_health.status = 'unhealthy' + second.series.last_error_type = 'LaterError' + assert plugin.config.last_read_revision == 'd' * 64 + assert plugin.activation_health.status == 'unknown' + assert plugin.last_error.error_type == 'NewError' + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('raw_status', 'ok', 'expected'), + [ + ('healthy', True, 'healthy'), + ('private-status', False, 'unhealthy'), + ('unknown', False, 'unhealthy'), + ('error', True, 'healthy'), + ('timeout', False, 'timeout'), + ('error', False, 'error'), + ], +) +async def test_activation_health_is_cached_without_rechecking_or_exposing_text( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, raw_status: str, ok: bool, expected: str +) -> None: + """采样保留激活检查时间与固定状态,任意插件详情和异常正文不会进入快照。""" + runtime = diagnostic_runtime(tmp_path) + loaded = runtime.loaded['diagnostic_demo'] + loaded.plugin.discovered_plugin.manifest.backend.health.checker = 'plugins.diagnostic_demo:health' + result = PluginHealthResult( + 'diagnostic_demo', + ok, + raw_status, + 'private-health-message', + 'plugins.diagnostic_demo:health', + 3.5, + {'secret': 'private-health-details'}, + 'private-exception-body', + ) + checker = AsyncMock(return_value=result) + monkeypatch.setattr(PluginHealthChecker, 'check', checker) + if ok: + await runtime._check_health(loaded, FastAPI()) + else: + with pytest.raises(RuntimeError, match='健康检查'): + await runtime._check_health(loaded, FastAPI()) + first = runtime.diagnostic_snapshot().plugins[0].activation_health + second = runtime.diagnostic_snapshot().plugins[0].activation_health + assert first == second + assert first.status == expected + assert first.checked_at is not None + assert first.duration_ms == result.duration_ms + checker.assert_awaited_once() + payload = runtime.diagnostic_snapshot().model_dump_json() + for private in ('private-health-message', 'private-health-details', 'private-exception-body', 'private-status'): + assert private not in payload + + +@pytest.mark.asyncio +async def test_real_lifespan_exit_changes_readiness_without_marking_pending_cancellation(tmp_path: Path) -> None: + """正常 lifespan 任务计为存活,意外退出立即失去就绪且不虚构取消超时。""" + runtime = diagnostic_runtime(tmp_path) + loaded = runtime.loaded['diagnostic_demo'] + release = asyncio.Event() + + async def application(scope: dict, receive: object, send: object) -> None: + await receive() + await send({'type': 'lifespan.startup.complete'}) + await release.wait() + + loaded.lifespan = PluginLifespanManager(application, timeout=0.05, cancel_timeout=0.01) + try: + await loaded.lifespan.startup() + loaded.active = True + running = runtime.diagnostic_snapshot() + assert running.plugins[0].lifespan.task_active + assert running.plugins[0].ready + assert running.worker_pending_cleanup_tasks == 0 + release.set() + await asyncio.wait({loaded.lifespan._task}, timeout=1) + stopped = runtime.diagnostic_snapshot() + assert not stopped.plugins[0].lifespan.task_active + assert not stopped.plugins[0].ready + assert stopped.worker_pending_cleanup_tasks == 0 + finally: + release.set() + with suppress(RuntimeError): + await loaded.lifespan.shutdown() + + +@pytest.mark.asyncio +async def test_live_connection_counts_do_not_include_ordinary_http_or_auth_data(tmp_path: Path) -> None: + """真实 SSE、WebSocket 的打开和关闭改变计数,普通响应及身份内容不进入连接快照。""" + runtime = diagnostic_runtime(tmp_path) + loaded = runtime.loaded['diagnostic_demo'] + loaded.active = True + opened = {'http': asyncio.Event(), 'websocket': asyncio.Event()} + release = asyncio.Event() + + async def authorize(scope: dict) -> PluginRequestContext: + return PluginRequestContext(loaded.host, user=SimpleNamespace(private='private-user')) + + async def application(scope: dict, receive: object, send: object) -> None: + if scope['path'] == '/ordinary': + await send({'type': 'http.response.start', 'status': 200, 'headers': []}) + await send({'type': 'http.response.body', 'body': b'private-response'}) + return + if scope['type'] == 'websocket': + await send({'type': 'websocket.accept'}) + else: + await send( + {'type': 'http.response.start', 'status': 200, 'headers': [(b'content-type', b'text/event-stream')]} + ) + opened[scope['type']].set() + await release.wait() + + async def receive() -> dict: + await asyncio.Event().wait() + return {} + + async def send(message: dict) -> None: + return None + + loaded.lifespan = PluginLifespanManager(application, managed=False) + await loaded.lifespan.startup() + loaded.gateway = PluginGatewayASGI(application, loaded.lifespan, authorize, recheck_interval=60) + requests = [] + try: + for kind in ('http', 'websocket'): + scope = {'type': kind, 'path': '/private-path', 'headers': [(b'authorization', b'private-token')]} + requests.append(asyncio.create_task(loaded.gateway(scope, receive, send))) + await asyncio.gather(*(asyncio.wait_for(event.wait(), timeout=1) for event in opened.values())) + await loaded.gateway({'type': 'http', 'path': '/ordinary', 'headers': []}, receive, send) + first = runtime.diagnostic_snapshot() + assert first.plugins[0].connections.model_dump() == {'sse': 1, 'websocket': 1} + assert first.plugins[0].pending_connection_tasks == 0 + assert 'private-' not in first.model_dump_json() + release.set() + await asyncio.gather(*requests) + assert runtime.diagnostic_snapshot().plugins[0].connections.model_dump() == {'sse': 0, 'websocket': 0} + assert first.plugins[0].connections.sse == 1 + finally: + release.set() + await loaded.gateway.drain() + await asyncio.gather(*requests, return_exceptions=True) + await loaded.lifespan.shutdown() + + +@pytest.mark.asyncio +async def test_pending_cleanup_is_deduplicated_and_late_completion_disappears(tmp_path: Path) -> None: + """同一任务被多个宿主回收器保留时 worker 总数去重,采样不再次取消任务。""" + runtime = diagnostic_runtime(tmp_path) + loaded = runtime.loaded['diagnostic_demo'] + started, interrupted, release = (asyncio.Event() for _ in range(3)) + cancellation_count = 0 + + async def resistant() -> None: + nonlocal cancellation_count + started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + cancellation_count += 1 + interrupted.set() + await release.wait() + + task = asyncio.create_task(resistant()) + await started.wait() + manager = PluginGatewayASGI(AsyncMock(), PluginLifespanManager(AsyncMock(), managed=False), AsyncMock()).connections + loaded.gateway = SimpleNamespace(connections=manager) + try: + await cancel_plugin_tasks({task}, timeout=0.01) + await interrupted.wait() + # 模拟同一任务同时由通用回收器与连接回收器保留,诊断只能计算一次。 + manager._unfinished.add(task) + manager._unfinished.add(asyncio.current_task()) + snapshot = runtime.diagnostic_snapshot() + expected_pending = 2 + assert snapshot.worker_pending_cleanup_tasks == expected_pending + assert snapshot.plugins[0].pending_connection_tasks == expected_pending + assert pending_plugin_tasks({task}) == frozenset({task}) + assert cancellation_count == 1 + manager._unfinished.discard(asyncio.current_task()) + assert runtime.diagnostic_snapshot().worker_pending_cleanup_tasks == 1 + release.set() + await task + # 完成回调尚未运行时也必须过滤已退出任务,不能等待下一轮回调才减少计数。 + assert not pending_plugin_tasks({task}) + assert runtime.diagnostic_snapshot().worker_pending_cleanup_tasks == 0 + finally: + release.set() + manager._unfinished.clear() + await asyncio.gather(task, return_exceptions=True) + + +@pytest.mark.parametrize('invalid', ['duplicate', 'negative', 'extra', 'worker', 'capacity']) +def test_remote_snapshot_rejects_duplicate_ids_unbounded_or_invalid_fields(tmp_path: Path, invalid: str) -> None: + """远端不能通过重复插件、负计数或未声明字段污染宿主诊断。""" + payload = diagnostic_runtime(tmp_path).diagnostic_snapshot().model_dump(by_alias=True) + if invalid == 'duplicate': + payload['plugins'].append(payload['plugins'][0].copy()) + elif invalid == 'negative': + payload['plugins'][0]['pendingJobTasks'] = -1 + elif invalid == 'extra': + payload['plugins'][0]['errorMessage'] = 'must-not-be-accepted' + elif invalid == 'worker': + payload['workerId'] = 'not-a-worker' + else: + payload['plugins'] = [{**payload['plugins'][0], 'pluginId': f'demo_{index}'} for index in range(1025)] + with pytest.raises(ValidationError): + PluginRuntimeDiagnosticSnapshot.model_validate_json(json.dumps(payload)) + + +def test_capacity_reports_dropped_plugins_without_affecting_old_metrics( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """诊断达到容量上限时明确报告丢弃数,不改变旧版指标协议。""" + from plugins.core.runtime import diagnostics # noqa: PLC0415 + + runtime = diagnostic_runtime(tmp_path, 'first_demo') + other = diagnostic_runtime(tmp_path, 'second_demo') + runtime.loaded.update(other.loaded) + monkeypatch.setattr(diagnostics, 'MAX_DIAGNOSTIC_PLUGINS', 1) + snapshot = runtime.diagnostic_snapshot() + assert [plugin.plugin_id for plugin in snapshot.plugins] == ['first_demo'] + assert snapshot.dropped_plugins == 1 + previous = runtime.metrics.snapshot().model_dump(by_alias=True) + assert previous['schemaVersion'] == 1 + assert 'plugins' not in previous + assert 'workerPendingCleanupTasks' not in previous + + +def test_complete_wire_roundtrip_preserves_local_constructor_defaults() -> None: + """本地构造可用默认值,但发布完整 JSON 后远端必须验证全部字段。""" + plugin = PluginRuntimeDiagnostic( + plugin_id='diagnostic_demo', version='1.0.0', active=False, ready=False, closing=False + ) + local = PluginRuntimeDiagnosticSnapshot(worker_id='a' * 32, collected_at=123.0, plugins=[plugin]) + parsed = PluginRuntimeDiagnosticSnapshot.model_validate_json( + local.model_dump_json(by_alias=True), context={'require_complete_snapshot': True} + ) + assert parsed == local + assert local.schema_version == 1 + assert local.worker_pending_cleanup_tasks == 0 + assert local.plugins[0].active_jobs == 0 + assert local.plugins[0].activation_health.status == 'unknown' + assert local.plugins[0].config.startup_revision is None + + +def test_wire_snapshot_requires_every_nested_serialized_field(tmp_path: Path) -> None: + """逐个删除每层已声明字段,缺失项均不能被远端读取补成零或未知状态。""" + payload = diagnostic_runtime(tmp_path).diagnostic_snapshot().model_dump(by_alias=True) + payload['plugins'][0]['lifespan'] = {'managed': True, 'started': True, 'ready': True, 'taskActive': True} + payload['plugins'][0]['lastError'] = {'errorType': 'RuntimeError', 'requestId': None, 'at': 125.0} + context = {'require_complete_snapshot': True} + PluginRuntimeDiagnosticSnapshot.model_validate_json(json.dumps(payload), context=context) + objects = [ + (), + ('plugins', 0), + ('plugins', 0, 'lifespan'), + ('plugins', 0, 'connections'), + ('plugins', 0, 'activationHealth'), + ('plugins', 0, 'config'), + ('plugins', 0, 'lastError'), + ] + for path in objects: + original = payload + for component in path: + original = original[component] + for field in original: + incomplete = deepcopy(payload) + target = incomplete + for component in path: + target = target[component] + del target[field] + with pytest.raises(ValidationError, match='缺少协议字段'): + PluginRuntimeDiagnosticSnapshot.model_validate_json(json.dumps(incomplete), context=context) + + +@pytest.mark.parametrize('version', [True, False, 1.0, '1', 0, 2, None]) +def test_wire_schema_version_requires_integer_one(tmp_path: Path, version: object) -> None: + """JSON 布尔值或浮点数即使等于 1 也不代表受支持的协议版本。""" + payload = diagnostic_runtime(tmp_path).diagnostic_snapshot().model_dump(by_alias=True) + payload['schemaVersion'] = version + with pytest.raises(ValidationError, match='schemaVersion'): + PluginRuntimeDiagnosticSnapshot.model_validate_json( + json.dumps(payload), context={'require_complete_snapshot': True} + ) + + +def test_wire_requires_camel_case_alias_even_when_local_field_names_are_allowed(tmp_path: Path) -> None: + """远端不能用本地 Python 字段名绕过协议完整性,本地构造仍兼容这些字段名。""" + payload = diagnostic_runtime(tmp_path).diagnostic_snapshot().model_dump(by_alias=True) + payload['worker_pending_cleanup_tasks'] = payload.pop('workerPendingCleanupTasks') + assert PluginRuntimeDiagnosticSnapshot.model_validate(payload).worker_pending_cleanup_tasks == 0 + with pytest.raises(ValidationError, match='workerPendingCleanupTasks'): + PluginRuntimeDiagnosticSnapshot.model_validate_json( + json.dumps(payload), context={'require_complete_snapshot': True} + ) diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_diagnostics_query.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_diagnostics_query.py new file mode 100644 index 0000000..bc1d613 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_diagnostics_query.py @@ -0,0 +1,241 @@ +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from config.get_redis import RedisUtil +from plugins.core.runtime import diagnostics_query as query + +NOW = 1_800_000_000.0 +WORKER = 'a' * 32 +OTHER = 'b' * 32 +DIGEST = 'd' * 64 + + +def snapshot(worker_id: str = WORKER, **overrides: object) -> dict: + """构造真正加载但尚无请求的运行快照。""" + return { + 'workerId': worker_id, + 'collectedAt': NOW, + 'source': 'redis', + 'workerPendingCleanupTasks': 0, + 'droppedPlugins': 0, + 'plugins': [ + { + 'pluginId': 'demo', + 'version': '1.0.0', + 'digest': DIGEST, + 'generation': 'c' * 32, + 'ready': True, + 'closing': False, + 'activeJobs': 0, + 'pendingJobTasks': 0, + 'pendingConnectionTasks': 0, + 'connections': {'sse': 0, 'websocket': 0}, + } + ], + **overrides, + } + + +def report(*workers: dict, **overrides: object) -> dict: + """构造已通过报告器校验的集群观测结果。""" + return { + 'scope': 'reporting_workers', + 'clusterAvailable': True, + 'sampleTtlSeconds': 60, + 'workers': list(workers), + 'invalidSnapshots': 0, + 'staleSnapshots': 0, + 'staleWorkerIds': [], + 'invalidWorkerIds': [], + 'workerLimitReached': False, + **overrides, + } + + +def release(*worker_ids: str, expected: int = 1) -> dict: + """构造当前发布目标,故意与实际运行摘要不同以检验身份边界。""" + return { + 'available': True, + 'supported': True, + 'workerTtlSeconds': 60, + 'releases': [{'pluginId': 'demo', 'targetDigest': 'f' * 64, 'expectedWorkers': expected}], + 'workers': [ + { + 'workerId': worker_id, + 'pluginId': plugin_id, + 'state': 'ready', + 'digest': 'f' * 64, + 'generation': 'e' * 32, + 'heartbeatTime': datetime.fromtimestamp(NOW, timezone.utc).isoformat(), + } + for worker_id in worker_ids + for plugin_id in ('__runtime__', 'demo') + ], + } + + +def test_diagnostics_preserves_actual_identity_and_marks_missing_worker_unknown() -> None: + result = query.build_runtime_diagnostics('demo', report(snapshot()), release(WORKER, OTHER, expected=2), now=NOW) + first, second = result['workers'] + assert result['state'] == 'partial' + assert result['missingWorkers'] == 1 + assert first['actual']['digest'] == DIGEST + assert first['releaseReport']['digest'] == 'f' * 64 + assert first['actual']['activeJobs'] == 0 + assert second['actual'] is None and second['workerPendingCleanupTasks'] is None + assert second['freshness'] == 'missing' + assert result['observedTotals']['activeJobs'] == 0 + assert result['totalsScope'] == 'fresh_observed_workers' + + +def test_stale_and_invalid_workers_never_contribute_zero_or_old_actual_values() -> None: + result = query.build_runtime_diagnostics( + 'demo', + report(staleWorkerIds=[WORKER], staleSnapshots=1, invalidWorkerIds=[OTHER], invalidSnapshots=1), + release(WORKER, OTHER, expected=2), + now=NOW, + ) + assert result['observedTotals'] is None + assert result['observedWorkers'] == 0 + assert {worker['freshness'] for worker in result['workers']} == {'stale', 'invalid'} + assert all(worker['actual'] is None for worker in result['workers']) + + +def test_empty_fresh_worker_and_truncated_worker_are_distinct() -> None: + result = query.build_runtime_diagnostics('demo', report(snapshot(plugins=[])), release(WORKER), now=NOW) + assert result['workers'][0]['observation'] == 'not_loaded' + assert result['workers'][0]['actual'] is None + assert result['observedTotals'] is None + result = query.build_runtime_diagnostics( + 'demo', report(snapshot(plugins=[], droppedPlugins=1)), release(WORKER), now=NOW + ) + assert result['workers'][0]['observation'] == 'unknown' + assert result['state'] == 'partial' + + +def test_replaced_stale_host_does_not_make_current_coverage_incomplete() -> None: + deployment = release(WORKER, OTHER) + for row in deployment['workers']: + if row['workerId'] == OTHER: + row['heartbeatTime'] = datetime.fromtimestamp(NOW - 61, timezone.utc).isoformat() + result = query.build_runtime_diagnostics('demo', report(snapshot()), deployment, now=NOW) + assert result['state'] == 'observed' and result['missingWorkers'] == 0 + assert result['workers'][1]['actual'] is None + + +@pytest.mark.asyncio +async def test_unavailable_runtime_and_release_keep_independent_failure_states(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(query, '_read_release_status', AsyncMock(return_value={'supported': True, 'available': False})) + reporter = SimpleNamespace(read=AsyncMock(side_effect=RuntimeError('private-password'))) + result = await query.read_plugin_runtime_diagnostics('demo', reporter=reporter) + assert result['state'] == 'unavailable' + assert result['release']['available'] is False + assert result['observedTotals'] is None + assert 'private-password' not in str(result) + + +@pytest.mark.asyncio +async def test_cli_reads_and_closes_redis_without_publishing_local_snapshot(monkeypatch: pytest.MonkeyPatch) -> None: + redis = SimpleNamespace(aclose=AsyncMock()) + create = AsyncMock(return_value=redis) + read = AsyncMock(return_value={'state': 'unobserved'}) + monkeypatch.setattr(RedisUtil, 'create_redis_pool', create) + monkeypatch.setattr(query, 'read_plugin_runtime_diagnostics', read) + assert await query.read_cli_runtime_diagnostics('demo') == {'state': 'unobserved'} + create.assert_awaited_once_with(log_enabled=False) + read.assert_awaited_once_with('demo', redis=redis, backend_root=None) + redis.aclose.assert_awaited_once() + + +def test_current_worker_scope_is_explicitly_partial() -> None: + result = query.build_runtime_diagnostics( + 'demo', report(snapshot(), scope='current_worker', clusterAvailable=False), release(WORKER), now=NOW + ) + assert result['state'] == 'partial' + assert result['workers'][0]['source'] == 'redis' + + +def test_release_errors_are_not_copied_into_runtime_diagnostics() -> None: + deployment = release(WORKER) + deployment['releases'][0].update(prepareStatus='failed', lastError='fake-secret-in-preparation') + for row in deployment['workers']: + row.update(state='failed', error='fake-secret-in-worker', internalPath='/private/path') + result = query.build_runtime_diagnostics('demo', report(snapshot()), deployment, now=NOW) + assert 'fake-secret' not in str(result) and '/private/path' not in str(result) + assert result['release']['summary']['failureReason'] == 'preparation_failed' + assert result['workers'][0]['releaseReport']['failureReason'] == 'worker_failed' + + +def test_fresh_runtime_cache_of_stale_host_does_not_fill_expected_capacity() -> None: + deployment = release(WORKER, OTHER, expected=2) + deployment['workerTtlSeconds'] = 15 + for row in deployment['workers']: + if row['workerId'] == OTHER: + row['heartbeatTime'] = datetime.fromtimestamp(NOW - 20, timezone.utc).isoformat() + result = query.build_runtime_diagnostics( + 'demo', report(snapshot(), snapshot(OTHER, collectedAt=NOW - 20)), deployment, now=NOW + ) + assert result['missingWorkers'] == 1 + assert result['state'] == 'partial' + assert result['workers'][1]['freshness'] == 'fresh' + assert result['workers'][1]['host']['fresh'] is False + + +def test_no_expected_baseline_does_not_claim_zero_missing_workers() -> None: + result = query.build_runtime_diagnostics('demo', report(), {'supported': True, 'available': True}, now=NOW) + assert result['missingWorkers'] is None + assert result['observedTotals'] is None + + +def test_snapshot_expiring_during_release_query_is_not_counted_as_fresh() -> None: + cached = snapshot(collectedAt=NOW - 59) + cached['plugins'][0]['activeJobs'] = 7 + incoming = report(cached) + assert query.build_runtime_diagnostics('demo', incoming, release(WORKER), now=NOW)['freshWorkers'] == 1 + + result = query.build_runtime_diagnostics('demo', incoming, release(WORKER), now=NOW + 2) + + worker = result['workers'][0] + assert worker['freshness'] == worker['observation'] == 'stale' + assert worker['source'] == 'redis' + assert worker['collectedAt'] == cached['collectedAt'] + assert worker['ageSeconds'] == NOW + 2 - cached['collectedAt'] + assert worker['actual'] is None + assert worker['workerPendingCleanupTasks'] is None + assert worker['droppedPlugins'] is None + assert result['freshWorkers'] == result['observedWorkers'] == 0 + assert result['observedTotals'] is None + assert result['missingWorkers'] == result['staleSnapshots'] == 1 + assert incoming['staleSnapshots'] == 0 + + +@pytest.mark.parametrize('already_stale', [True, False]) +def test_new_expiration_updates_stale_count_without_counting_existing_ids_twice(already_stale: bool) -> None: + old_stale_ids = [WORKER, OTHER] if already_stale else [OTHER] + result = query.build_runtime_diagnostics( + 'demo', + report(snapshot(collectedAt=NOW - 61), staleWorkerIds=old_stale_ids, staleSnapshots=len(old_stale_ids)), + release(WORKER, OTHER, expected=2), + now=NOW, + ) + expected_stale = 2 + assert result['staleSnapshots'] == expected_stale + assert result['missingWorkers'] == expected_stale + assert result['observedTotals'] is None + + +@pytest.mark.parametrize( + ('age', 'ttl', 'fresh'), [(60, 60, True), (60.001, 60, False), (31, 30, False), (-5, 60, True), (-5.001, 60, False)] +) +def test_aggregation_rechecks_report_ttl_and_future_clock_skew(age: float, ttl: float, fresh: bool) -> None: + result = query.build_runtime_diagnostics( + 'demo', report(snapshot(collectedAt=NOW - age), sampleTtlSeconds=ttl), release(WORKER), now=NOW + ) + assert result['workers'][0]['freshness'] == ('fresh' if fresh else 'stale') + assert result['freshWorkers'] == int(fresh) + assert result['missingWorkers'] == int(not fresh) + assert result['staleSnapshots'] == int(not fresh) + assert (result['observedTotals'] is not None) is fresh diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_diagnostics_store.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_diagnostics_store.py new file mode 100644 index 0000000..a61d051 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_diagnostics_store.py @@ -0,0 +1,342 @@ +import asyncio +import json +import time +from collections.abc import AsyncIterator +from datetime import datetime, timezone +from fnmatch import fnmatchcase +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI +from redis._parsers.encoders import Encoder +from redis.client import NEVER_DECODE + +from plugins.core.runtime import diagnostics_store as module +from plugins.core.runtime.application import PluginApplicationRuntime +from plugins.core.runtime.diagnostics import PluginRuntimeDiagnostic, PluginRuntimeDiagnosticSnapshot +from plugins.core.runtime.diagnostics_query import build_runtime_diagnostics +from plugins.core.runtime.diagnostics_store import ( + DIAGNOSTICS_TTL_SECONDS, + PluginDiagnosticsReporter, + diagnostics_namespace, + read_runtime_diagnostics, +) +from plugins.core.runtime.explicit import ExplicitPluginRuntime +from plugins.core.runtime.route_guard import UnavailablePluginRouteStateGateway + +NAMESPACE = diagnostics_namespace('test-environment:ready') + + +class DiagnosticsRedis: + """提供隔离的快照读写,并记录查询过程中是否出现写入。""" + + def __init__(self) -> None: + self.values: dict[str | bytes, bytes] = {} + self.expirations: dict[str, int] = {} + self.writes = 0 + self.fail = False + self.encoder = Encoder('utf-8', 'strict', decode_responses=True) + + async def set(self, key: str, value: bytes, *, ex: int) -> None: + if self.fail: + raise ConnectionError('private-diagnostic-password') + self.writes += 1 + self.values[key] = value + self.expirations[key] = ex + + async def delete(self, key: str) -> None: + self.values.pop(key, None) + + async def scan_iter(self, *, match: str, count: int, **options: object) -> AsyncIterator[str | bytes]: + if self.fail: + raise ConnectionError('private-diagnostic-password') + for key in self.values: + raw_key = key.encode() if isinstance(key, str) else key + if fnmatchcase(raw_key, match.encode()): + yield raw_key if NEVER_DECODE in options else self.encoder.decode(raw_key) + + async def mget(self, keys: list[str]) -> list[bytes | None]: + return [self.values.get(key) for key in keys] + + async def execute_command(self, command: str, *keys: str, **options: object) -> list[bytes | str | None]: + assert command == 'MGET' + values = await self.mget(list(keys)) + return [value if value is None or NEVER_DECODE in options else self.encoder.decode(value) for value in values] + + +def snapshot(worker_id: str, *, pending: int = 0, collected_at: float | None = None) -> PluginRuntimeDiagnosticSnapshot: + """构造真正观测到零插件的宿主快照,不添加虚假的插件计数。""" + return PluginRuntimeDiagnosticSnapshot( + worker_id=worker_id, + collected_at=time.time() if collected_at is None else collected_at, + plugins=[], + worker_pending_cleanup_tasks=pending, + dropped_plugins=0, + ) + + +def store(redis: DiagnosticsRedis, value: PluginRuntimeDiagnosticSnapshot, namespace: str = NAMESPACE) -> None: + """放入独立序列化快照以模拟另一个 worker 的定期发布。""" + redis.values[f'{namespace}:{value.worker_id}'] = value.model_dump_json(by_alias=True).encode() + + +@pytest.mark.asyncio +async def test_reader_never_fabricates_local_worker_or_writes_and_isolates_namespace() -> None: + redis = DiagnosticsRedis() + store(redis, snapshot('a' * 32)) + store(redis, snapshot('b' * 32), diagnostics_namespace('other-environment')) + redis.values['test-environment:ready:metrics:legacy'] = b'old-schema-unchanged' + result = await read_runtime_diagnostics(redis, NAMESPACE, 'task_demo') + assert result['scope'] == 'reporting_workers' + assert result['currentWorkerId'] is None + assert result['observedWorkers'] == 1 + assert result['workers'][0]['workerId'] == 'a' * 32 + assert result['workers'][0]['plugins'] == [] + assert result['workers'][0]['source'] == 'redis' + assert redis.writes == 0 + unavailable = await read_runtime_diagnostics(None, NAMESPACE) + assert unavailable['scope'] == 'unavailable' + assert unavailable['workers'] == [] + + +@pytest.mark.asyncio +async def test_plugin_filter_retains_worker_scope_without_other_plugin_details() -> None: + redis = DiagnosticsRedis() + value = snapshot('a' * 32, pending=3) + value.plugins = [ + PluginRuntimeDiagnostic(plugin_id=plugin_id, version='1.0.0', active=True, ready=True, closing=False) + for plugin_id in ('task_demo', 'private_demo') + ] + store(redis, value) + result = await read_runtime_diagnostics(redis, NAMESPACE, 'task_demo') + assert [plugin['pluginId'] for plugin in result['workers'][0]['plugins']] == ['task_demo'] + assert result['workers'][0]['workerPendingCleanupTasks'] == value.worker_pending_cleanup_tasks + assert 'private_demo' not in json.dumps(result) + assert (await read_runtime_diagnostics(redis, NAMESPACE, 'unknown_demo'))['workers'][0]['plugins'] == [] + + +@pytest.mark.asyncio +async def test_local_real_snapshot_replaces_own_redis_copy_and_failure_is_explicit() -> None: + redis = DiagnosticsRedis() + store(redis, snapshot('a' * 32, pending=9)) + store(redis, snapshot('b' * 32, pending=2)) + reporter = PluginDiagnosticsReporter(lambda: snapshot('a' * 32, pending=1), 'a' * 32) + reporter.redis, reporter.namespace = redis, NAMESPACE + result = await reporter.read() + expected_workers = 2 + assert len(result['workers']) == expected_workers + assert result['workers'][0]['workerPendingCleanupTasks'] == 1 + assert result['workers'][0]['source'] == 'current_worker' + redis.fail = True + result = await reporter.read() + assert result['scope'] == 'current_worker' + assert result['clusterAvailable'] is False + assert result['observedWorkers'] == 1 + assert 'private-diagnostic-password' not in json.dumps(result) + assert (await read_runtime_diagnostics(redis, NAMESPACE))['workers'] == [] + + +@pytest.mark.asyncio +async def test_stale_future_invalid_and_identity_conflicts_never_supply_counters() -> None: + redis = DiagnosticsRedis() + store(redis, snapshot('a' * 32, collected_at=time.time() - DIAGNOSTICS_TTL_SECONDS - 1)) + store(redis, snapshot('b' * 32, collected_at=time.time() + 100)) + redis.values[f'{NAMESPACE}:{"c" * 32}'] = b'{private-malformed-content' + redis.values[f'{NAMESPACE}:{"d" * 32}'] = snapshot('e' * 32).model_dump_json().encode() + newer = snapshot('f' * 32).model_dump(by_alias=True) + newer['schemaVersion'] = 99 + redis.values[f'{NAMESPACE}:{"f" * 32}'] = json.dumps(newer).encode() + result = await read_runtime_diagnostics(redis, NAMESPACE) + assert result['workers'] == [] + assert result['staleWorkerIds'] == ['a' * 32, 'b' * 32] + assert result['invalidWorkerIds'] == ['c' * 32, 'd' * 32, 'f' * 32] + assert 'private-malformed-content' not in json.dumps(result) + + +@pytest.mark.asyncio +async def test_invalid_utf8_key_and_value_do_not_hide_healthy_workers() -> None: + redis = DiagnosticsRedis() + store(redis, snapshot('a' * 32)) + redis.values[f'{NAMESPACE}:{"b" * 32}'] = b'\xff' + redis.values[NAMESPACE.encode() + b':\xff'] = b'invalid-key' + result = await read_runtime_diagnostics(redis, NAMESPACE) + assert result['clusterAvailable'] is True + assert result['observedWorkers'] == 1 + assert result['workers'][0]['workerId'] == 'a' * 32 + expected_invalid = 2 + assert result['invalidSnapshots'] == expected_invalid + assert result['invalidWorkerIds'] == ['b' * 32] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'missing_field', + [ + 'schemaVersion', + 'workerPendingCleanupTasks', + 'plugins', + 'plugins.0.activeJobs', + 'plugins.0.connections.sse', + 'plugins.0.pendingConnectionTasks', + 'plugins.0.config.lastReadAt', + ], +) +async def test_incomplete_remote_snapshot_cannot_supply_default_zero_counters(missing_field: str) -> None: + redis = DiagnosticsRedis() + store(redis, snapshot('a' * 32)) + incomplete = snapshot('b' * 32) + incomplete.plugins = [ + PluginRuntimeDiagnostic(plugin_id='task_demo', version='1.0.0', active=True, ready=True, closing=False) + ] + payload = incomplete.model_dump(by_alias=True) + parts = missing_field.split('.') + node = payload + for part in parts[:-1]: + node = node[int(part)] if isinstance(node, list) else node[part] + node.pop(parts[-1]) + redis.values[f'{NAMESPACE}:{"b" * 32}'] = json.dumps(payload).encode() + result = await read_runtime_diagnostics(redis, NAMESPACE, 'task_demo') + assert result['invalidWorkerIds'] == ['b' * 32] + assert result['observedWorkers'] == 1 + assert result['workers'][0]['workerId'] == 'a' * 32 + assert result['workers'][0]['plugins'] == [] + + +@pytest.mark.asyncio +async def test_size_and_worker_limits_are_reported(monkeypatch: pytest.MonkeyPatch) -> None: + redis = DiagnosticsRedis() + monkeypatch.setattr(module, 'MAX_DIAGNOSTICS_SNAPSHOT_BYTES', 10) + store(redis, snapshot('a' * 32)) + assert (await read_runtime_diagnostics(redis, NAMESPACE))['invalidWorkerIds'] == ['a' * 32] + monkeypatch.setattr(module, 'MAX_DIAGNOSTICS_SNAPSHOT_BYTES', 512 * 1024) + monkeypatch.setattr(module, 'MAX_DIAGNOSTICS_WORKERS', 1) + store(redis, snapshot('b' * 32)) + result = await read_runtime_diagnostics(redis, NAMESPACE) + assert result['workerLimitReached'] is True + assert result['observedWorkers'] == 1 + + +@pytest.mark.asyncio +async def test_expired_during_read_is_unknown_and_io_timeout_is_bounded(monkeypatch: pytest.MonkeyPatch) -> None: + redis = DiagnosticsRedis() + store(redis, snapshot('a' * 32)) + with patch.object(redis, 'mget', AsyncMock(return_value=[None])): + result = await read_runtime_diagnostics(redis, NAMESPACE) + assert result['staleWorkerIds'] == ['a' * 32] + assert result['workers'] == [] + monkeypatch.setattr(module, 'DIAGNOSTICS_IO_TIMEOUT_SECONDS', 0.01) + + async def blocked_read(keys: list[str]) -> list[bytes | None]: + await asyncio.Event().wait() + return [] + + with patch.object(redis, 'mget', blocked_read): + result = await read_runtime_diagnostics(redis, NAMESPACE) + assert result['scope'] == 'unavailable' + assert result['workers'] == [] + + +@pytest.mark.asyncio +async def test_publication_failure_recovers_and_stop_only_deletes_owned_snapshot() -> None: + redis = DiagnosticsRedis() + reporter = PluginDiagnosticsReporter(lambda: snapshot('a' * 32), 'a' * 32) + reporter.start(redis, NAMESPACE) + try: + redis.fail = True + await reporter.publish() + assert reporter.publication_failed is True + redis.fail = False + await reporter.publish() + assert reporter.publication_failed is False + assert redis.expirations[reporter.key] == DIAGNOSTICS_TTL_SECONDS + store(redis, snapshot('b' * 32)) + redis.values['business:key'] = b'unchanged' + finally: + await reporter.stop() + assert reporter.task is None + assert set(redis.values) == {f'{NAMESPACE}:{"b" * 32}', 'business:key'} + + +@pytest.mark.asyncio +async def test_wrong_local_identity_cannot_be_published_or_appear_as_current_worker() -> None: + redis = DiagnosticsRedis() + reporter = PluginDiagnosticsReporter(lambda: snapshot('b' * 32), 'a' * 32) + reporter.redis, reporter.namespace = redis, NAMESPACE + await reporter.publish() + assert reporter.publication_failed is True + assert not redis.values + result = await reporter.read() + assert result['localSnapshotAvailable'] is False + assert result['workers'] == [] + + +@pytest.mark.asyncio +async def test_application_owns_diagnostics_lifecycle_even_when_shutdown_fails() -> None: + redis = DiagnosticsRedis() + app = FastAPI() + app.state.redis = redis + explicit = ExplicitPluginRuntime(UnavailablePluginRouteStateGateway()) + app.state.plugin_explicit_runtime = explicit + manager = MagicMock() + manager.shutdown = AsyncMock(side_effect=RuntimeError('shutdown failed')) + runtime = PluginApplicationRuntime(startup_manager=manager, ready_key='isolated:ready') + with patch.object(runtime, '_startup_coordinated', AsyncMock()): + await runtime.startup(app, create_tables=AsyncMock()) + reporter = app.state.plugin_diagnostics_reporter + await reporter.publish() + assert reporter.worker_id == explicit.metrics.worker_id + assert reporter.namespace == diagnostics_namespace('isolated:ready') + assert reporter.key in redis.values + try: + with pytest.raises(RuntimeError, match='shutdown failed'): + await asyncio.wait_for(runtime.shutdown(app), timeout=3) + assert reporter.task is None + assert reporter.key not in redis.values + finally: + await explicit.shutdown() + + +@pytest.mark.asyncio +async def test_empty_runtime_uses_release_worker_identity_without_fabricating_missing_worker() -> None: + app = FastAPI() + explicit = ExplicitPluginRuntime(UnavailablePluginRouteStateGateway()) + app.state.plugin_explicit_runtime = explicit + worker_id = 'b' * 32 + worker = SimpleNamespace( + worker_id=worker_id, + start=AsyncMock(), + ready=AsyncMock(), + stop=AsyncMock(), + generation=lambda base: 'isolated-generation', + ) + manager = MagicMock() + manager.shutdown = AsyncMock() + runtime = PluginApplicationRuntime(startup_manager=manager, release_worker=worker, startup_generation='test') + with patch.object(runtime, '_startup_coordinated', AsyncMock()): + await runtime.startup(app, create_tables=AsyncMock()) + try: + report = await app.state.plugin_diagnostics_reporter.read('task_demo') + assert explicit.metrics.worker_id == worker_id + assert report['currentWorkerId'] == worker_id + now = time.time() + release = { + 'supported': True, + 'available': True, + 'releases': [{'pluginId': 'task_demo', 'expectedWorkers': 1}], + 'workers': [ + { + 'workerId': worker_id, + 'pluginId': '__runtime__', + 'state': 'ready', + 'heartbeatTime': datetime.fromtimestamp(now - 1, timezone.utc).isoformat(), + } + ], + } + result = build_runtime_diagnostics('task_demo', report, release, now=now) + assert result['missingWorkers'] == 0 + assert len(result['workers']) == 1 + assert result['workers'][0]['observation'] == 'not_loaded' + assert result['workers'][0]['actual'] is None + finally: + await runtime.shutdown(app) diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_entrypoint.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_entrypoint.py new file mode 100644 index 0000000..9a2c9f7 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_entrypoint.py @@ -0,0 +1,135 @@ +import base64 +import hashlib +import sys +from collections.abc import Generator +from importlib.machinery import EXTENSION_SUFFIXES +from pathlib import Path + +import pytest +from packaging.tags import sys_tags + +from plugins.core.discovery.scanner import DiscoveredPlugin +from plugins.core.manifest.schema import PluginManifestFactory +from plugins.core.runtime.entrypoint import PluginEntrypointLoader +from plugins.core.sdk import PluginHostContext, await_plugin_callback + + +@pytest.fixture(autouse=True) +def isolate_modules() -> Generator[None, None, None]: + """测试模块使用独立 ID,避免影响现有插件测试。""" + yield + for name in list(sys.modules): + if name == 'plugins.entry_test' or name.startswith('plugins.entry_test.'): + sys.modules.pop(name, None) + if hasattr(sys.modules.get('plugins'), 'entry_test'): + delattr(sys.modules['plugins'], 'entry_test') + + +def make_plugin(tmp_path: Path, source: str, *, native: bool = False) -> DiscoveredPlugin: + root = tmp_path / 'plugins' / 'entry_test' + root.mkdir(parents=True, exist_ok=True) + module = 'ruoyi_plugin_entry_test' if native else 'plugins.entry_test' + backend = {'module': module, 'entrypoint': f'{module}.entry:create_plugin'} + if native: + backend.update(runtime='native', native={'distribution': 'ruoyi-plugin-entry-test'}) + manifest = PluginManifestFactory.create( + {'manifestVersion': 2, 'id': 'entry_test', 'name': 'Test', 'version': '1.0.0', 'backend': backend} + ) + (root / '__init__.py').touch() + (root / 'entry.py').write_text(source, encoding='utf-8') + return DiscoveredPlugin(manifest, root, root / 'plugin.yaml') + + +def test_python_factory_can_call_host_service(tmp_path: Path) -> None: + calls = [] + plugin = make_plugin( + tmp_path, + 'from plugins.core.sdk import PluginDefinition\n' + 'def create_plugin(host):\n' + ' host.service("record")("called")\n' + ' return PluginDefinition()\n', + ) + host = PluginHostContext(plugin.manifest.id, plugin.backend_path, services={'record': calls.append}) + definition = PluginEntrypointLoader(plugin).load(host) + assert definition.api_version == 1 + assert calls == ['called'] + + +def test_loader_rejects_other_version_in_module_cache(tmp_path: Path) -> None: + source = 'from plugins.core.sdk import PluginDefinition\ndef create_plugin(host): return PluginDefinition()\n' + first = make_plugin(tmp_path / 'first', source) + second = make_plugin(tmp_path / 'second', source) + PluginEntrypointLoader(first).load(PluginHostContext(first.manifest.id, first.backend_path)) + with pytest.raises(ImportError, match='必须重启'): + PluginEntrypointLoader(second).load(PluginHostContext(second.manifest.id, second.backend_path)) + + +def test_failed_import_removes_new_modules(tmp_path: Path) -> None: + plugin = make_plugin(tmp_path, 'raise RuntimeError("broken import")\n') + with pytest.raises(RuntimeError, match='broken import'): + PluginEntrypointLoader(plugin).load(PluginHostContext(plugin.manifest.id, plugin.backend_path)) + assert 'plugins.entry_test.entry' not in sys.modules + assert 'plugins.entry_test' not in sys.modules + + +@pytest.mark.parametrize( + 'source', + [ + 'def create_plugin(host): return object()\n', + 'async def create_plugin(host): return None\n', + 'from plugins.core.sdk import PluginDefinition\ndef create_plugin(host): return PluginDefinition(api_version=2)\n', + ], +) +def test_factory_protocol_is_checked(tmp_path: Path, source: str) -> None: + plugin = make_plugin(tmp_path, source) + with pytest.raises((ValueError, TypeError)): + PluginEntrypointLoader(plugin).load(PluginHostContext(plugin.manifest.id, plugin.backend_path)) + + +def test_native_precheck_validates_tags_record_and_hash_without_importing(tmp_path: Path) -> None: + plugin = make_plugin(tmp_path, '', native=True) + native_root = plugin.backend_path / 'native' + package = native_root / 'ruoyi_plugin_entry_test' + package.mkdir(parents=True) + (package / '__init__.py').write_text('raise RuntimeError("do not import")', encoding='utf-8') + library = package / f'entry{EXTENSION_SUFFIXES[0]}' + library.write_bytes(b'fixture-not-an-executable') + metadata = native_root / 'ruoyi_plugin_entry_test-1.0.0.dist-info' + metadata.mkdir() + (metadata / 'METADATA').write_text('Name: ruoyi-plugin-entry-test\nVersion: 1.0.0\n', encoding='utf-8') + (metadata / 'WHEEL').write_text(f'Wheel-Version: 1.0\nTag: {next(sys_tags())}\n', encoding='utf-8') + digest = base64.urlsafe_b64encode(hashlib.sha256(library.read_bytes()).digest()).rstrip(b'=').decode() + relative = library.relative_to(native_root).as_posix() + (metadata / 'RECORD').write_text(f'{relative},sha256={digest},{library.stat().st_size}\n', encoding='utf-8') + loader = PluginEntrypointLoader(plugin) + assert loader.check_entrypoint().path == library.resolve() + assert 'ruoyi_plugin_entry_test' not in sys.modules + library.write_bytes(b'tampered') + with pytest.raises(ValueError, match='SHA256'): + loader.check_entrypoint() + (metadata / 'WHEEL').write_text('Tag: cp20-cp20-unknown_platform\n', encoding='utf-8') + with pytest.raises(ValueError, match='ABI'): + loader.check_entrypoint() + + +@pytest.mark.asyncio +async def test_native_style_callable_returns_awaitable() -> None: + async def calculate(value: int) -> int: + return value + 1 + + class NativeStyleCallback: + def __call__(self, value: int) -> object: + return calculate(value) + + result = await await_plugin_callback(NativeStyleCallback(), 4) + assert result == 4 + 1 + with pytest.raises(TypeError, match='awaitable'): + await await_plugin_callback(lambda: 4) + + +def test_context_rejects_resource_escape_and_unknown_service(tmp_path: Path) -> None: + host = PluginHostContext('entry_test', tmp_path) + with pytest.raises(ValueError, match='根目录'): + host.resource('../outside') + with pytest.raises(LookupError, match='未提供'): + host.service('os.system') diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_explicit.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_explicit.py new file mode 100644 index 0000000..cd3cb63 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_explicit.py @@ -0,0 +1,472 @@ +import asyncio +import sys +from collections.abc import AsyncGenerator, Generator +from contextlib import asynccontextmanager, suppress +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import httpx +import pytest +import yaml +from fastapi import FastAPI +from starlette import status +from starlette.types import Receive, Scope, Send + +from common.aspect.db_session import get_db_session_provider +from module_admin.service.login_service import LoginService +from plugins.core.discovery.registry import PluginRegistry, RegisteredPlugin +from plugins.core.discovery.scanner import PluginScanner +from plugins.core.runtime.application import PluginApplicationRuntime +from plugins.core.runtime.asgi import PluginLifespanManager +from plugins.core.runtime.explicit import ExplicitPluginRuntime +from plugins.core.runtime.startup import PluginRuntimeStartupManager +from plugins.core.runtime.startup_coordination import PluginStartupGenerationResolver +from plugins.core.sdk import PluginDefinition + +ASGI_SOURCE = """ +from contextlib import asynccontextmanager +from fastapi import FastAPI, Request +from plugins.core.sdk import PluginDefinition + +events = [] + +@asynccontextmanager +async def lifespan(app): + events.append('start') + yield {'resource': 'ready'} + events.append('stop') + +def create_plugin(host): + def create_app(context): + app = FastAPI(lifespan=lifespan) + @app.get('/api/info') + async def info(request: Request): + ctx = request.state.plugin_context + ctx.require_permission('runtime_test:view') + return {'plugin': ctx.host.plugin_id, 'resource': request.state.resource} + return app + return PluginDefinition(app_factory=create_app) +""" + + +@pytest.fixture(autouse=True) +def clear_modules() -> Generator[None, None, None]: + yield + for name in list(sys.modules): + if name.startswith('plugins.runtime_test'): + sys.modules.pop(name, None) + package = sys.modules.get('plugins') + if package is not None: + for name in list(vars(package)): + if name.startswith('runtime_test'): + delattr(package, name) + + +def write_plugin( + root: Path, + *, + plugin_id: str = 'runtime_test', + source: str = ASGI_SOURCE, + dependencies: list[str] | None = None, + integration: str = 'asgi', +) -> RegisteredPlugin: + directory = root / 'plugins' / plugin_id + directory.mkdir(parents=True, exist_ok=True) + manifest = { + 'manifestVersion': 2, + 'id': plugin_id, + 'name': 'Test', + 'version': '1.0.0', + 'backend': { + 'module': f'plugins.{plugin_id}', + 'entrypoint': f'plugins.{plugin_id}:create_plugin', + 'integration': integration, + }, + 'permissions': [f'{plugin_id}:view'], + 'dependencies': {'plugins': dependencies or []}, + } + (directory / 'plugin.yaml').write_text(yaml.safe_dump(manifest), encoding='utf-8') + (directory / '__init__.py').write_text(source, encoding='utf-8') + discovered = PluginScanner(directory.parent).load_manifest(directory / 'plugin.yaml') + return RegisteredPlugin(discovered, None, True, 'installed') + + +@asynccontextmanager +async def fake_session() -> AsyncGenerator[object, None]: + yield object() + + +@pytest.mark.asyncio +async def test_main_runtime_mounts_authenticates_and_stops_subapplication(tmp_path: Path) -> None: + plugin = write_plugin(tmp_path) + app = FastAPI() + app.state.plugin_registry = PluginRegistry([plugin]) + app.state.redis = object() + gateway = SimpleNamespace(is_plugin_enabled=AsyncMock(return_value=True)) + manager = PluginRuntimeStartupManager(route_state_gateway=gateway, default_enabled_builtin_plugin_ids=set()) + login = AsyncMock(return_value=SimpleNamespace(permissions=['runtime_test:view'])) + with ( + patch('plugins.core.runtime.explicit.DataSourceRegistry.session', fake_session), + patch('plugins.core.runtime.explicit.LoginService.get_current_user', login), + ): + await manager.prepare_explicit_plugins(app, startup_write_enabled=False) + await manager.activate_explicit_plugins(app, startup_write_enabled=False) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client: + path = '/apps/runtime_test/api/info' + assert (await client.get(path)).status_code == status.HTTP_401_UNAUTHORIZED + response = await client.get(path, headers={'Authorization': 'Bearer test'}) + assert response.json() == {'plugin': 'runtime_test', 'resource': 'ready'} + login.assert_awaited_once() + assert login.call_args.kwargs['request'].app is app + login.return_value = SimpleNamespace(permissions=['unrelated:view']) + assert ( + await client.get(path, headers={'Authorization': 'Bearer test'}) + ).status_code == status.HTTP_403_FORBIDDEN + gateway.is_plugin_enabled.return_value = False + assert ( + await client.get(path, headers={'Authorization': 'Bearer test'}) + ).status_code == status.HTTP_403_FORBIDDEN + await manager.shutdown(app, startup_write_enabled=False) + assert sys.modules['plugins.runtime_test'].events == ['start', 'stop'] + + +@pytest.mark.asyncio +async def test_router_requests_are_observed_after_fastapi_registration(tmp_path: Path) -> None: + """Router 接入的实际路由也记录业务响应和接口权限拒绝。""" + source = """ +from fastapi import APIRouter, HTTPException, Request +from plugins.core.sdk import PluginDefinition +def create_plugin(host): + router = APIRouter(prefix='/runtime_test') + @router.get('/info') + async def info(request: Request): + return {'requestId': request.state.plugin_context.request_id} + @router.get('/denied') + async def denied(): + raise HTTPException(status_code=403, detail='denied') + return PluginDefinition(routers=(router,)) +""" + plugin = write_plugin(tmp_path, source=source, integration='router') + app = FastAPI() + app.dependency_overrides[get_db_session_provider(None)] = object + app.dependency_overrides[LoginService.get_current_user] = lambda: SimpleNamespace(permissions=['runtime_test:view']) + runtime = ExplicitPluginRuntime(SimpleNamespace(is_plugin_enabled=AsyncMock(return_value=True))) + runtime.prepare(plugin, app, startup_write_enabled=False) + try: + await runtime.activate('runtime_test', app) + assert '/runtime_test/info' in app.openapi()['paths'] + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client: + response = await client.get('/runtime_test/info') + assert response.status_code == status.HTTP_200_OK + assert response.json()['requestId'] + assert (await client.get('/runtime_test/denied')).status_code == status.HTTP_403_FORBIDDEN + metric = runtime.metrics.snapshot().series[0] + assert (metric.started, metric.active, metric.succeeded, metric.rejected) == (2, 0, 1, 1) + finally: + await runtime.shutdown() + + +def test_dependency_cycle_and_missing_dependency_do_not_block_independent_plugin(tmp_path: Path) -> None: + first = write_plugin(tmp_path, plugin_id='runtime_test_a', dependencies=['runtime_test_b']) + second = write_plugin(tmp_path, plugin_id='runtime_test_b', dependencies=['runtime_test_a']) + missing = write_plugin(tmp_path, plugin_id='runtime_test_missing', dependencies=['absent']) + independent = write_plugin(tmp_path, plugin_id='runtime_test_ok') + ordered, errors = ExplicitPluginRuntime.dependency_order(PluginRegistry([first, second, missing, independent])) + assert [plugin.plugin_id for plugin in ordered] == ['runtime_test_ok'] + assert set(errors) == {'runtime_test_a', 'runtime_test_b', 'runtime_test_missing'} + + +def test_dependency_order_starts_dependencies_first(tmp_path: Path) -> None: + consumer = write_plugin(tmp_path, plugin_id='runtime_test_consumer', dependencies=['runtime_test_base']) + base = write_plugin(tmp_path, plugin_id='runtime_test_base') + ordered, errors = ExplicitPluginRuntime.dependency_order(PluginRegistry([consumer, base])) + assert not errors + assert [plugin.plugin_id for plugin in ordered] == ['runtime_test_base', 'runtime_test_consumer'] + + +@pytest.mark.asyncio +async def test_failed_startup_leaves_no_mount_and_isolates_plugin(tmp_path: Path) -> None: + plugin = write_plugin( + tmp_path, source=ASGI_SOURCE.replace("events.append('start')", "raise RuntimeError('broken')") + ) + app = FastAPI() + app.state.plugin_registry = PluginRegistry([plugin]) + manager = PluginRuntimeStartupManager(default_enabled_builtin_plugin_ids=set()) + await manager.prepare_explicit_plugins(app, startup_write_enabled=False) + await manager.activate_explicit_plugins(app, startup_write_enabled=False) + assert not app.state.plugin_registry.get_plugin('runtime_test').enabled + assert all(getattr(route, 'path', '') != '/apps/runtime_test' for route in app.routes) + await manager.shutdown(app, startup_write_enabled=False) + + +@pytest.mark.asyncio +async def test_health_failure_after_startup_shuts_down_child(tmp_path: Path) -> None: + plugin = write_plugin(tmp_path, source=ASGI_SOURCE + '\nasync def check(context): return False\n') + plugin.discovered_plugin.manifest.backend.health.checker = 'plugins.runtime_test:check' + app = FastAPI() + runtime = ExplicitPluginRuntime(SimpleNamespace()) + runtime.prepare(plugin, app, startup_write_enabled=False) + with pytest.raises(RuntimeError, match='健康检查'): + await runtime.activate('runtime_test', app) + assert sys.modules['plugins.runtime_test'].events == ['start', 'stop'] + assert all(getattr(route, 'path', '') != '/apps/runtime_test' for route in app.routes) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('failure_at', ['metrics', 'connection', 'job', 'lifespan']) +async def test_shutdown_isolates_cleanup_failures_and_closes_in_reverse_order( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, failure_at: str +) -> None: + """一个清理步骤失败后继续其余资源及依赖插件,关闭开始即拒绝全部激活。""" + runtime = ExplicitPluginRuntime(SimpleNamespace()) + app = FastAPI() + for plugin_id in ['runtime_test_base', 'runtime_test_consumer']: + runtime.prepare(write_plugin(tmp_path, plugin_id=plugin_id), app, startup_write_enabled=False) + await runtime.activate(plugin_id, app) + order = [] + + async def step(name: str, *, fails: bool = False) -> None: + assert all(not loaded.active for loaded in runtime.loaded.values()) + with pytest.raises(RuntimeError, match='关闭阶段'): + await runtime.activate('runtime_test_base', app) + order.append(name) + if fails: + raise RuntimeError('cleanup failed') + + metrics_stop = AsyncMock(side_effect=lambda: None) + if failure_at == 'metrics': + metrics_stop.side_effect = RuntimeError('metrics failed') + monkeypatch.setattr(runtime.metrics_reporter, 'stop', metrics_stop) + consumer = runtime.loaded['runtime_test_consumer'] + actual_shutdown = consumer.lifespan.shutdown + + async def consumer_lifespan() -> None: + await actual_shutdown() + await step('consumer:lifespan', fails=failure_at == 'lifespan') + + async def consumer_connection() -> None: + await step('consumer:connection', fails=failure_at == 'connection') + + async def consumer_jobs(binding: object) -> None: + await step('consumer:job', fails=failure_at == 'job') + + monkeypatch.setattr(consumer.lifespan, 'shutdown', consumer_lifespan) + monkeypatch.setattr(consumer.gateway, 'drain', consumer_connection) + consumer.jobs = SimpleNamespace(tasks=set()) + monkeypatch.setattr('plugins.core.runtime.explicit.unbind_plugin_jobs', consumer_jobs) + try: + await runtime.shutdown() + assert order == ['consumer:connection', 'consumer:job', 'consumer:lifespan'] + assert sys.modules['plugins.runtime_test_base'].events == ['start', 'stop'] + assert sys.modules['plugins.runtime_test_consumer'].events == ['start', 'stop'] + assert all(not loaded.lifespan.ready for loaded in runtime.loaded.values()) + metrics_stop.assert_awaited_once() + finally: + await actual_shutdown() + await runtime.loaded['runtime_test_base'].lifespan.shutdown() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('integration', ['router', 'asgi']) +async def test_shutdown_during_health_check_cannot_publish_late_plugin( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, integration: str +) -> None: + """关闭与激活并发时,健康检查完成不能重新发布路由或恢复 active。""" + source = ( + ASGI_SOURCE + if integration == 'asgi' + else ( + 'from plugins.core.sdk import PluginDefinition\n' + 'from fastapi import APIRouter\n' + 'def create_plugin(host):\n' + ' router = APIRouter()\n' + ' router.add_api_route("/runtime_test/info", lambda: {})\n' + ' return PluginDefinition(routers=[router])\n' + ) + ) + plugin = write_plugin(tmp_path, source=source, integration=integration) + app = FastAPI() + runtime = ExplicitPluginRuntime(SimpleNamespace()) + runtime.prepare(plugin, app, startup_write_enabled=False) + entered, release = asyncio.Event(), asyncio.Event() + + async def check(*args: object) -> None: + entered.set() + await release.wait() + + monkeypatch.setattr(runtime, '_check_health', check) + activating = asyncio.create_task(runtime.activate('runtime_test', app)) + try: + await asyncio.wait_for(entered.wait(), timeout=1) + await runtime.shutdown() + release.set() + with pytest.raises(RuntimeError, match='关闭阶段'): + await activating + assert not runtime.loaded['runtime_test'].active + assert all('runtime_test' not in getattr(route, 'path', '') for route in app.routes) + if integration == 'asgi': + assert sys.modules['plugins.runtime_test'].events == ['start', 'stop'] + finally: + release.set() + await asyncio.gather(activating, return_exceptions=True) + await runtime.shutdown() + + +@pytest.mark.asyncio +async def test_failed_activation_cannot_replace_pending_lifespan( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """显式运行时保留启动失败的管理器,旧资源退出前不能创建第二个子应用。""" + runtime = ExplicitPluginRuntime(SimpleNamespace()) + app = FastAPI() + runtime.prepare(write_plugin(tmp_path), app, startup_write_enabled=False) + release = asyncio.Event() + attempts = [] + + async def child(scope: Scope, receive: Receive, send: Send) -> None: + attempts.append(scope) + await receive() + if len(attempts) == 1: + while not release.is_set(): + with suppress(asyncio.CancelledError): + await release.wait() + raise RuntimeError('old resource stopped') + await send({'type': 'lifespan.startup.complete'}) + await receive() + await send({'type': 'lifespan.shutdown.complete'}) + + loaded = runtime.loaded['runtime_test'] + loaded.definition = PluginDefinition(app_factory=lambda _: child) + monkeypatch.setattr( + 'plugins.core.runtime.explicit.PluginLifespanManager', + lambda child, **kwargs: PluginLifespanManager(child, timeout=0.01, cancel_timeout=0.01, **kwargs), + ) + activating = asyncio.create_task(runtime.activate('runtime_test', app)) + try: + await asyncio.wait({activating}, timeout=1) + assert activating.done() + with pytest.raises(TimeoutError): + await activating + assert loaded.lifespan.has_pending_task + with pytest.raises(RuntimeError, match='上一次生命周期尚未退出'): + await runtime.activate('runtime_test', app) + assert len(attempts) == 1 + release.set() + await asyncio.wait({loaded.lifespan._task}, timeout=1) + await runtime.activate('runtime_test', app) + assert loaded.active and loaded.lifespan.ready + assert [scope['type'] for scope in attempts] == ['lifespan', 'lifespan'] + assert sum(getattr(route, 'path', '') == '/apps/runtime_test' for route in app.routes) == 1 + finally: + release.set() + await asyncio.gather(activating, return_exceptions=True) + await runtime.shutdown() + + +@pytest.mark.asyncio +async def test_activation_cleanup_failure_preserves_original_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runtime = ExplicitPluginRuntime(SimpleNamespace()) + app = FastAPI() + plugin = write_plugin(tmp_path, source=ASGI_SOURCE.replace("events.append('stop')", "raise ValueError('cleanup')")) + runtime.prepare(plugin, app, startup_write_enabled=False) + monkeypatch.setattr(runtime, '_check_health', AsyncMock(side_effect=LookupError('primary health failure'))) + with pytest.raises(LookupError, match='primary health failure'): + await runtime.activate('runtime_test', app) + assert not runtime.loaded['runtime_test'].lifespan.has_pending_task + + +@pytest.mark.asyncio +async def test_cancelled_activation_closes_previously_started_children(tmp_path: Path) -> None: + first = write_plugin(tmp_path, plugin_id='runtime_test_first') + second = write_plugin(tmp_path, plugin_id='runtime_test_second') + app = FastAPI() + app.state.plugin_registry = PluginRegistry([first, second]) + manager = PluginRuntimeStartupManager(default_enabled_builtin_plugin_ids=set()) + await manager.prepare_explicit_plugins(app, startup_write_enabled=False) + runtime = app.state.plugin_explicit_runtime + activate = runtime.activate + + async def cancel_second(plugin_id: str, target: FastAPI) -> None: + if plugin_id == second.plugin_id: + raise asyncio.CancelledError + await activate(plugin_id, target) + + runtime.activate = cancel_second + with pytest.raises(asyncio.CancelledError): + await manager.activate_explicit_plugins(app, startup_write_enabled=False) + assert sys.modules['plugins.runtime_test_first'].events == ['start', 'stop'] + assert not runtime.loaded[first.plugin_id].lifespan.ready + + +@pytest.mark.asyncio +@pytest.mark.parametrize('failure', [ConnectionError('ready write failed'), asyncio.CancelledError()]) +async def test_ready_publication_failure_closes_started_children(tmp_path: Path, failure: BaseException) -> None: + plugin = write_plugin(tmp_path) + app = FastAPI() + app.state.plugin_registry = PluginRegistry([plugin]) + app.state.redis = SimpleNamespace( + get=AsyncMock(return_value=None), delete=AsyncMock(), set=AsyncMock(side_effect=failure) + ) + manager = PluginRuntimeStartupManager(default_enabled_builtin_plugin_ids=set()) + runtime = PluginApplicationRuntime(manager, startup_generation='test-release') + # 保留真实 ASGI 启停和 writer 协调,只替换数据库资源同步。 + with ( + patch.object(manager, 'prepare_enabled_plugins', manager.prepare_explicit_plugins), + patch.object(manager, 'activate_enabled_plugins', manager.activate_explicit_plugins), + pytest.raises(type(failure)), + ): + await runtime.startup(app, create_tables=AsyncMock()) + assert sys.modules['plugins.runtime_test'].events == ['start', 'stop'] + assert not app.state.plugin_explicit_runtime.loaded[plugin.plugin_id].lifespan.ready + + +@pytest.mark.asyncio +async def test_router_namespace_validation_is_atomic(tmp_path: Path) -> None: + source = """ +from fastapi import APIRouter +from plugins.core.sdk import PluginDefinition +def create_plugin(host): + valid = APIRouter() + invalid = APIRouter() + valid.add_api_route('/runtime_test/data', lambda: {}) + invalid.add_api_route('/system/data', lambda: {}) + return PluginDefinition(routers=[valid, invalid]) +""" + plugin = write_plugin(tmp_path, source=source, integration='router') + app = FastAPI() + runtime = ExplicitPluginRuntime(SimpleNamespace()) + runtime.prepare(plugin, app, startup_write_enabled=False) + with pytest.raises(ValueError, match='命名空间'): + await runtime.activate('runtime_test', app) + assert all(getattr(route, 'path', '') != '/runtime_test/data' for route in app.routes) + + +def test_generation_covers_native_and_bundle_content(tmp_path: Path) -> None: + plugin = write_plugin(tmp_path) + root = plugin.backend_path + resolver = PluginStartupGenerationResolver(tmp_path, release_id='') + first = resolver.resolve() + library = root / 'entry.pyd' + library.write_bytes(b'version1') + second = resolver.resolve() + assert first != second + library.write_bytes(b'version2') + assert second != resolver.resolve() + + path = root / 'plugin.yaml' + data = yaml.safe_load(path.read_text(encoding='utf-8')) + data['frontend'] = {'delivery': {'type': 'bundle'}, 'bundle': {}} + path.write_text(yaml.safe_dump(data), encoding='utf-8') + asset = root / 'web' / 'dist' / 'assets' / 'app.js' + asset.parent.mkdir(parents=True) + asset.write_text('version1', encoding='utf-8') + first = resolver.resolve() + asset.write_text('version2', encoding='utf-8') + second = resolver.resolve() + assert first != second + (root / 'web' / 'dist' / '__pycache__').mkdir() + (root / 'web' / 'dist' / '__pycache__' / 'cache.pyc').write_bytes(b'cache') + assert resolver.resolve() == second diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_health.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_health.py index 1a4421c..6711cee 100644 --- a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_health.py +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_health.py @@ -1,24 +1,59 @@ +import sys +from collections.abc import Generator from pathlib import Path +from types import SimpleNamespace import pytest +from fastapi import FastAPI +from plugins.core.discovery.registry import RegisteredPlugin from plugins.core.discovery.scanner import PluginScanner +from plugins.core.manifest.schema import EXPLICIT_MANIFEST_VERSION +from plugins.core.runtime.explicit import ExplicitPluginRuntime from plugins.core.runtime.health import PluginHealthChecker -def write_plugin_with_health(plugin_root: Path, health_content: str, checker_path: str = 'health:check') -> None: +@pytest.fixture(autouse=True) +def isolate_health_plugin_modules() -> Generator[None, None, None]: + """隔离测试插件模块,避免 v2 加载器复用其他临时目录的旧模块。""" + for name in list(sys.modules): + if name == 'plugins.demo_health' or name.startswith('plugins.demo_health.'): + sys.modules.pop(name, None) + yield + for name in list(sys.modules): + if name == 'plugins.demo_health' or name.startswith('plugins.demo_health.'): + sys.modules.pop(name, None) + package = sys.modules.get('plugins') + if package is not None and hasattr(package, 'demo_health'): + delattr(package, 'demo_health') + + +def write_plugin_with_health( + plugin_root: Path, + health_content: str, + checker_path: str | None = 'health:check', + *, + manifest_version: int = 1, +) -> None: """写入带健康检查的测试插件。""" plugin_root.mkdir(parents=True) + (plugin_root / '__init__.py').write_text('', encoding='utf-8') (plugin_root / 'health.py').write_text(health_content, encoding='utf-8') + entrypoint = '' + if manifest_version == EXPLICIT_MANIFEST_VERSION: + entrypoint = ' entrypoint: plugins.demo_health:create_plugin\n' + if checker_path == 'health:check': + checker_path = 'plugins.demo_health.health:check' (plugin_root / 'plugin.yaml').write_text( f""" +manifestVersion: {manifest_version} id: demo_health name: Demo Health version: 1.0.0 backend: module: plugins.demo_health - health: - checker: {checker_path} +{entrypoint} health: + checker: {checker_path or 'null'} """, encoding='utf-8', ) @@ -59,20 +94,13 @@ async def test_plugin_health_checker_normalizes_boolean_result(tmp_path: Path) - @pytest.mark.asyncio -async def test_plugin_health_checker_returns_unknown_when_checker_missing(tmp_path: Path) -> None: +@pytest.mark.parametrize('manifest_version', [1, 2]) +async def test_plugin_health_checker_returns_unknown_when_checker_missing( + tmp_path: Path, manifest_version: int +) -> None: """校验未声明健康检查时返回 unknown。""" plugin_root = tmp_path / 'plugins' / 'demo_health' - plugin_root.mkdir(parents=True) - (plugin_root / 'plugin.yaml').write_text( - """ -id: demo_health -name: Demo Health -version: 1.0.0 -backend: - module: plugins.demo_health -""", - encoding='utf-8', - ) + write_plugin_with_health(plugin_root, '', checker_path=None, manifest_version=manifest_version) discovered_plugin = PluginScanner(tmp_path / 'plugins').load_manifest(plugin_root / 'plugin.yaml') result = await PluginHealthChecker(discovered_plugin).check() @@ -131,3 +159,108 @@ async def test_plugin_health_checker_rejects_sync_checker_without_executing_it(t assert result.status == 'error' assert '必须使用 async def' in str(result.error) assert app == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('raw_result', [True, False, {'ok': True}, {'ok': False}]) +async def test_v2_health_checker_accepts_explicit_boolean_results(tmp_path: Path, raw_result: object) -> None: + """v2 支持布尔结果和显式 ok 字段,保留健康与不健康的区别。""" + plugin_root = tmp_path / 'plugins' / 'demo_health' + write_plugin_with_health(plugin_root, f'async def check(context):\n return {raw_result!r}\n', manifest_version=2) + plugin = PluginScanner(plugin_root.parent).load_manifest(plugin_root / 'plugin.yaml') + + result = await PluginHealthChecker(plugin).check() + + expected = raw_result['ok'] if isinstance(raw_result, dict) else raw_result + assert result.ok is expected + assert result.status == ('healthy' if expected else 'unhealthy') + assert result.error is None + + +@pytest.mark.asyncio +async def test_v2_health_checker_preserves_structured_result_details(tmp_path: Path) -> None: + """严格校验 ok 后仍保留插件的状态、说明和结构化检查详情。""" + plugin_root = tmp_path / 'plugins' / 'demo_health' + write_plugin_with_health( + plugin_root, + 'async def check(context):\n' + " return {'ok': False, 'status': 'degraded', 'message': '依赖不可用', 'details': {'database': False}}\n", + manifest_version=2, + ) + plugin = PluginScanner(plugin_root.parent).load_manifest(plugin_root / 'plugin.yaml') + + result = await PluginHealthChecker(plugin).check() + + assert result.ok is False + assert result.status == 'degraded' + assert result.message == '依赖不可用' + assert result.details == {'database': False} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'raw_result', + [None, 'unhealthy', 0, 1, [], {}, {'healthy': True}, {'ok': 'false'}, {'ok': 0}, {'ok': 1}, {'ok': None}], +) +async def test_v2_health_checker_rejects_invalid_results(tmp_path: Path, raw_result: object) -> None: + """v2 的缺字段、字符串及真假整数等结果不能误判为健康。""" + plugin_root = tmp_path / 'plugins' / 'demo_health' + write_plugin_with_health(plugin_root, f'async def check(context):\n return {raw_result!r}\n', manifest_version=2) + plugin = PluginScanner(plugin_root.parent).load_manifest(plugin_root / 'plugin.yaml') + + result = await PluginHealthChecker(plugin).check() + + assert result.ok is False + assert result.status == 'error' + assert 'v2 插件健康检查必须返回' in str(result.error) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('raw_result', 'expected'), + [(None, True), ('unhealthy', True), ({}, True), ({'ok': 'false'}, True), ({'healthy': False}, False)], +) +async def test_v1_health_checker_preserves_legacy_result_coercion( + tmp_path: Path, raw_result: object, expected: bool +) -> None: + """v1 继续保留无返回值、旧 healthy 字段及布尔转换的兼容行为。""" + plugin_root = tmp_path / 'plugins' / 'demo_health' + write_plugin_with_health(plugin_root, f'async def check(context):\n return {raw_result!r}\n') + plugin = PluginScanner(plugin_root.parent).load_manifest(plugin_root / 'plugin.yaml') + + result = await PluginHealthChecker(plugin).check() + + assert result.ok is expected + assert result.status == ('healthy' if expected else 'unhealthy') + assert result.error is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize('raw_result', [None, {'ok': 'false'}]) +async def test_v2_invalid_health_result_prevents_router_activation(tmp_path: Path, raw_result: object) -> None: + """无效健康结果必须阻止实际路由激活,不能只在检查接口报告错误。""" + plugin_root = tmp_path / 'plugins' / 'demo_health' + write_plugin_with_health(plugin_root, f'async def check(context):\n return {raw_result!r}\n', manifest_version=2) + (plugin_root / '__init__.py').write_text( + 'from fastapi import APIRouter\n' + 'from plugins.core.sdk import PluginDefinition\n' + 'def create_plugin(host):\n' + " router = APIRouter(prefix='/demo_health')\n" + " @router.get('/info')\n" + ' async def info():\n' + " return {'ready': True}\n" + ' return PluginDefinition(routers=(router,))\n', + encoding='utf-8', + ) + discovered = PluginScanner(plugin_root.parent).load_manifest(plugin_root / 'plugin.yaml') + plugin = RegisteredPlugin(discovered, None, True, 'installed') + app = FastAPI() + runtime = ExplicitPluginRuntime(SimpleNamespace()) + runtime.prepare(plugin, app, startup_write_enabled=False) + try: + with pytest.raises(RuntimeError, match='健康检查'): + await runtime.activate('demo_health', app) + assert not runtime.loaded['demo_health'].active + assert all(getattr(route, 'path', '') != '/demo_health/info' for route in app.routes) + finally: + await runtime.shutdown() diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_host_service_extensions.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_host_service_extensions.py new file mode 100644 index 0000000..91fe8ad --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_host_service_extensions.py @@ -0,0 +1,145 @@ +import json +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from plugins.core.runtime.host_services import MAX_CACHE_BYTES, build_host_services +from plugins.core.sdk import PluginHostContext, PluginRequestContext + + +def context_for(tmp_path: Path, plugin_id: str = 'sdk_demo', *, permissions: bool = True) -> PluginRequestContext: + """为字典与缓存服务提供绑定插件的请求身份。""" + return PluginRequestContext( + PluginHostContext(plugin_id, tmp_path), + SimpleNamespace(user=SimpleNamespace(user_id=7)), + frozenset({f'{plugin_id}:dict', f'{plugin_id}:cache'}) if permissions else frozenset(), + ) + + +@pytest.mark.asyncio +async def test_dictionary_service_returns_only_public_enabled_options(tmp_path: Path) -> None: + """调用宿主启用态字典查询,过滤空外连接结果并限制返回字段。""" + db = object() + + @asynccontextmanager + async def session() -> AsyncIterator[object]: + yield db + + row = SimpleNamespace( + dict_label='正常', dict_value='0', css_class='', list_class='success', is_default='Y', remark='private' + ) + service = build_host_services('sdk_demo', session)['dictionaries.items.v1'] + with patch( + 'module_admin.service.dict_service.DictDataService.query_dict_data_list_services', + new=AsyncMock(return_value=[row, None]), + ) as read: + result = await service(context_for(tmp_path), 'sys_normal_disable') + read.assert_awaited_once_with(db, 'sys_normal_disable') + assert result == [{'label': '正常', 'value': '0', 'cssClass': '', 'listClass': 'success', 'isDefault': True}] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('dict_type', ['', '*', 'sys_type OR 1=1', None, 'a' * 101]) +async def test_invalid_dictionary_type_never_queries_database(tmp_path: Path, dict_type: Any) -> None: + """空类型不能触发宿主的全量字典查询。""" + session = Mock(side_effect=AssertionError('database must not be touched')) + service = build_host_services('sdk_demo', session)['dictionaries.items.v1'] + with pytest.raises(ValueError, match='字典类型'): + await service(context_for(tmp_path), dict_type) + session.assert_not_called() + + +@pytest.mark.asyncio +async def test_cache_services_use_plugin_namespace_json_and_bounded_ttl(tmp_path: Path) -> None: + """同名键按插件隔离,缓存读写使用有界 JSON 和明确 TTL。""" + redis = SimpleNamespace( + set=AsyncMock(), get=AsyncMock(return_value=b'{"count":2}'), delete=AsyncMock(return_value=1) + ) + first = build_host_services('sdk_demo', Mock(), redis) + second = build_host_services('other_demo', Mock(), redis) + context = context_for(tmp_path) + other = context_for(tmp_path, 'other_demo') + await first['cache.set.v1'](context, 'shared:key', {'count': 2}, 60) + await second['cache.set.v1'](other, 'shared:key', {'count': 3}) + calls = redis.set.await_args_list + assert calls[0].args == ('plugin:sdk:cache:sdk_demo:shared:key', b'{"count":2}') + assert calls[0].kwargs == {'ex': 60} + assert calls[1].args[0] == 'plugin:sdk:cache:other_demo:shared:key' + assert calls[1].kwargs == {'ex': 300} + assert await first['cache.get.v1'](context, 'shared:key') == {'count': 2} + assert await first['cache.delete.v1'](context, 'shared:key') is True + redis.delete.assert_awaited_once_with('plugin:sdk:cache:sdk_demo:shared:key') + redis.get.return_value = None + assert await first['cache.get.v1'](context, 'missing') is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize('operation', ['cache.get.v1', 'cache.set.v1', 'cache.delete.v1', 'dictionaries.items.v1']) +@pytest.mark.parametrize('invalid', ['permission', 'foreign_plugin', 'user']) +async def test_host_services_reject_unauthorized_resource_access(tmp_path: Path, operation: str, invalid: str) -> None: + """插件、用户与接口权限检查必须先于外部资源调用。""" + redis = SimpleNamespace(get=AsyncMock(), set=AsyncMock(), delete=AsyncMock()) + session = Mock() + service = build_host_services('sdk_demo', session, redis)[operation] + context = context_for( + tmp_path, 'other_demo' if invalid == 'foreign_plugin' else 'sdk_demo', permissions=invalid != 'permission' + ) + if invalid == 'user': + context.user.user.user_id = 0 + arguments = [context, 'example'] + ([{}] if operation == 'cache.set.v1' else []) + with pytest.raises(PermissionError): + await service(*arguments) + session.assert_not_called() + redis.get.assert_not_awaited() + redis.set.assert_not_awaited() + redis.delete.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('ttl', [0, -1, 86401, True, 1.5]) +async def test_cache_rejects_unbounded_or_invalid_ttl(tmp_path: Path, ttl: Any) -> None: + """禁止永久缓存及含糊的 TTL 类型。""" + redis = SimpleNamespace(set=AsyncMock()) + service = build_host_services('sdk_demo', Mock(), redis)['cache.set.v1'] + with pytest.raises(ValueError, match='有效期'): + await service(context_for(tmp_path), 'valid', {}, ttl) + redis.set.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('key', ['', '*', '../foreign', 'bad key', 'a' * 129]) +async def test_cache_rejects_invalid_keys(tmp_path: Path, key: str) -> None: + """缓存接口不接受通配符、路径或超长键。""" + redis = SimpleNamespace(get=AsyncMock()) + with pytest.raises(ValueError, match='缓存键'): + await build_host_services('sdk_demo', Mock(), redis)['cache.get.v1'](context_for(tmp_path), key) + redis.get.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_cache_size_limits_apply_to_reads_and_writes(tmp_path: Path) -> None: + """受限缓存接口同时约束读写大小,并拒绝损坏的 JSON。""" + redis = SimpleNamespace(set=AsyncMock(), get=AsyncMock(return_value=b'x' * (MAX_CACHE_BYTES + 1))) + services = build_host_services('sdk_demo', Mock(), redis) + context = context_for(tmp_path) + with pytest.raises(ValueError, match='大小限制'): + await services['cache.set.v1'](context, 'valid', 'x' * MAX_CACHE_BYTES) + with pytest.raises(ValueError, match='大小限制'): + await services['cache.get.v1'](context, 'valid') + redis.get.return_value = json.dumps({'value': float('nan')}) + with pytest.raises(ValueError, match='有效 JSON'): + await services['cache.get.v1'](context, 'valid') + redis.set.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_missing_cache_client_reports_unavailable_capability(tmp_path: Path) -> None: + """未配置 Redis 时给出明确能力错误。""" + services = build_host_services('sdk_demo', Mock()) + with pytest.raises(RuntimeError, match='未提供 Redis'): + await services['cache.get.v1'](context_for(tmp_path), 'valid') diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_host_services.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_host_services.py new file mode 100644 index 0000000..c83821e --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_host_services.py @@ -0,0 +1,148 @@ +import asyncio +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import FastAPI, HTTPException +from starlette import status +from starlette.requests import Request + +from plugins.core.runtime.host_services import build_host_services +from plugins.core.sdk import PluginHostContext, PluginRequestContext, plugin_endpoint, plugin_lifespan + + +@pytest.mark.asyncio +async def test_profile_facade_calls_existing_service_for_own_user_and_limits_fields(tmp_path: Path) -> None: + events = [] + db = object() + + @asynccontextmanager + async def session() -> AsyncGenerator[object, None]: + events.append('open') + try: + yield db + finally: + events.append('close') + + services = build_host_services('demo', session) + host = PluginHostContext('demo', tmp_path, services=services) + context = PluginRequestContext(host, SimpleNamespace(user=SimpleNamespace(user_id=7)), frozenset({'demo:profile'})) + profile = SimpleNamespace( + data=SimpleNamespace(user_id=7, user_name='tester', nick_name='Tester', avatar='', password='never expose'), + post_group='Engineering', + role_group='User', + ) + with patch( + 'module_admin.service.user_service.UserService.user_profile_services', new=AsyncMock(return_value=profile) + ) as service: + result = await host.service('users.current_profile.v1')(context) + service.assert_awaited_once_with(db, 7) + assert events == ['open', 'close'] + assert result == { + 'userId': 7, + 'userName': 'tester', + 'nickName': 'Tester', + 'avatar': '', + 'postGroup': 'Engineering', + 'roleGroup': 'User', + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize('invalid', ['foreign_plugin', 'permission', 'identity']) +async def test_profile_facade_rejects_invalid_context_before_database(tmp_path: Path, invalid: str) -> None: + session = AsyncMock() + service = build_host_services('demo', session)['users.current_profile.v1'] + host = PluginHostContext('other' if invalid == 'foreign_plugin' else 'demo', tmp_path) + permissions = frozenset() if invalid == 'permission' else frozenset({'demo:profile'}) + context = PluginRequestContext( + host, SimpleNamespace(user=SimpleNamespace(user_id=0 if invalid == 'identity' else 7)), permissions + ) + with pytest.raises(PermissionError): + await service(context) + session.assert_not_called() + + +@pytest.mark.asyncio +async def test_endpoint_adapter_enforces_permission_and_waits_native_style_result(tmp_path: Path) -> None: + host = PluginHostContext('demo', tmp_path) + callback = AsyncMock(return_value={'ok': True}) + + def native_callback(context: PluginRequestContext) -> object: + return callback(context) + + endpoint = plugin_endpoint(native_callback, permission='demo:view') + context = PluginRequestContext(host, object(), frozenset({'demo:view'})) + request = Request( + { + 'type': 'http', + 'method': 'GET', + 'path': '/info', + 'query_string': b'', + 'headers': [], + 'state': {'plugin_context': context}, + } + ) + assert await endpoint(request) == {'ok': True} + callback.assert_awaited_once() + received = callback.await_args.args[0] + assert received.host is context.host + assert received.user is context.user + assert received.permissions == context.permissions + assert received.request.method == 'GET' + assert received.request.path == '/info' + assert received.request_id + request.state.plugin_context = PluginRequestContext(host, object()) + with pytest.raises(HTTPException) as denied: + await endpoint(request) + assert denied.value.status_code == status.HTTP_403_FORBIDDEN + assert callback.await_count == 1 + + +@pytest.mark.asyncio +async def test_endpoint_timeout_cancels_python_service(tmp_path: Path) -> None: + closed = asyncio.Event() + + async def slow(context: PluginRequestContext) -> None: + try: + await asyncio.Event().wait() + finally: + closed.set() + + context = PluginRequestContext(PluginHostContext('demo', tmp_path), object(), frozenset({'demo:view'})) + endpoint = plugin_endpoint(slow, permission='demo:view', timeout=0.01) + with pytest.raises(HTTPException) as timeout: + await endpoint( + Request( + { + 'type': 'http', + 'method': 'GET', + 'path': '/slow', + 'query_string': b'', + 'headers': [], + 'state': {'plugin_context': context}, + } + ) + ) + assert timeout.value.status_code == status.HTTP_504_GATEWAY_TIMEOUT + assert closed.is_set() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('startup_failure', [False, True]) +async def test_lifespan_adapter_closes_even_partial_startup(tmp_path: Path, startup_failure: bool) -> None: + host = PluginHostContext('demo', tmp_path) + startup = AsyncMock(return_value={'ready': True}, side_effect=RuntimeError('failed') if startup_failure else None) + shutdown = AsyncMock() + lifespan = plugin_lifespan(host, startup, shutdown) + if startup_failure: + with pytest.raises(RuntimeError, match='failed'): + async with lifespan(FastAPI()): + pytest.fail('failed startup cannot enter lifespan') + else: + async with lifespan(FastAPI()) as state: + assert state == {'ready': True} + shutdown.assert_awaited_once_with(host) diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_job_dispatcher.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_job_dispatcher.py new file mode 100644 index 0000000..81c223f --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_job_dispatcher.py @@ -0,0 +1,339 @@ +import asyncio +import inspect +import pickle +import sys +from collections.abc import AsyncGenerator, Iterator +from contextlib import asynccontextmanager, suppress +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +import yaml +from apscheduler.util import obj_to_ref, ref_to_obj +from fastapi import FastAPI + +from config.scheduler.job_adapter import JobAdapter +from plugins.core.discovery.registry import RegisteredPlugin +from plugins.core.discovery.scanner import PluginScanner +from plugins.core.lifecycle.jobs import PluginJobModelBuilder +from plugins.core.manifest.schema import PluginManifestFactory +from plugins.core.runtime.entrypoint import PluginEntrypointLoader +from plugins.core.runtime.explicit import ExplicitPluginRuntime, LoadedExplicitPlugin +from plugins.core.runtime.job_dispatcher import ( + DISPATCH_TARGET, + bind_plugin_jobs, + dispatch_plugin_job, + unbind_plugin_jobs, +) +from plugins.core.sdk import PluginHostContext, PluginTaskContext +from plugins.core.validation.structure import PluginStructureChecker + + +@pytest.fixture +def setup_plugin(tmp_path: Path) -> Iterator[SimpleNamespace]: + directory = tmp_path / 'plugins' / 'dispatch_test' + directory.mkdir(parents=True) + data = { + 'manifestVersion': 2, + 'id': 'dispatch_test', + 'name': 'Dispatch test', + 'version': '1.0.0', + 'backend': { + 'module': 'plugins.dispatch_test', + 'entrypoint': 'plugins.dispatch_test:create_plugin', + 'jobs': [ + { + 'id': 'tick', + 'callable': 'plugins.dispatch_test.tick', + 'cronExpression': '0 */5 * * * ?', + 'enabled': False, + 'jobStore': 'redis', + 'args': ['argument'], + 'kwargs': {'value': 'keyword'}, + } + ], + }, + } + (directory / 'plugin.yaml').write_text(yaml.safe_dump(data), encoding='utf-8') + (directory / '__init__.py').write_text( + 'from plugins.core.sdk import PluginDefinition\n' + 'def create_plugin(host):\n return PluginDefinition()\n' + 'def tick(context, *args, **kwargs):\n' + ' return context.host.service("test.task")(context, *args, **kwargs)\n', + encoding='utf-8', + ) + plugin = PluginScanner(directory.parent).load_manifest(directory / 'plugin.yaml') + service = AsyncMock(return_value='done') + sessions = [] + + @asynccontextmanager + async def session() -> AsyncGenerator[object, None]: + sessions.append('open') + try: + yield object() + finally: + sessions.append('close') + + host = PluginHostContext('dispatch_test', directory, services={'test.task': service}, session_factory=session) + gateway = SimpleNamespace(is_plugin_enabled=AsyncMock(return_value=True)) + runtime = ExplicitPluginRuntime(gateway) + runtime.metrics.register('dispatch_test', plugin.manifest.version, None, None) + definition = PluginEntrypointLoader(plugin).load(host) + runtime.loaded['dispatch_test'] = LoadedExplicitPlugin( + RegisteredPlugin(plugin, None, True, 'installed'), host, definition + ) + yield SimpleNamespace( + plugin=plugin, runtime=runtime, service=service, gateway=gateway, sessions=sessions, data=data + ) + sys.modules.pop('plugins.dispatch_test', None) + package = sys.modules.get('plugins') + if package is not None and hasattr(package, 'dispatch_test'): + delattr(package, 'dispatch_test') + + +@pytest.mark.asyncio +async def test_persistent_reference_restores_and_dispatches_on_host_loop(setup_plugin: SimpleNamespace) -> None: + state = setup_plugin + manifest = state.plugin.manifest + job = PluginJobModelBuilder.build(manifest.id, manifest.backend.jobs[0], manifest=manifest) + assert job.invoke_target == DISPATCH_TARGET + assert job.status == '1' # 清单默认停用;管理流程可单独启用,分发器不把此默认值当永久禁令。 + options = JobAdapter.prepare(job) + reference, args, kwargs = pickle.loads( + pickle.dumps((obj_to_ref(options['func']), options['args'], options['kwargs'])) + ) + callback = ref_to_obj(reference) + assert inspect.iscoroutinefunction(callback) + assert args == ['dispatch_test', 'tick', '1.0.0'] + assert kwargs == {} + await state.runtime.activate('dispatch_test', FastAPI()) + try: + assert await callback(*args, **kwargs) == 'done' + context = state.service.await_args.args[0] + assert isinstance(context, PluginTaskContext) + assert context.request_id + assert not hasattr(context, 'user') + assert state.service.await_args.args[1:] == ('argument',) + assert state.service.await_args.kwargs == {'value': 'keyword'} + assert state.sessions == ['open', 'close'] + metric = next(item for item in state.runtime.metrics.snapshot().series if item.operation == 'job:tick') + assert (metric.started, metric.active, metric.succeeded) == (1, 0, 1) + finally: + await state.runtime.shutdown() + + +@pytest.mark.asyncio +async def test_gates_reject_before_call_and_shutdown_unbinds(setup_plugin: SimpleNamespace) -> None: + state = setup_plugin + with pytest.raises(RuntimeError, match='未就绪'): + await dispatch_plugin_job('dispatch_test', 'tick', '1.0.0') + await state.runtime.activate('dispatch_test', FastAPI()) + try: + with pytest.raises(RuntimeError, match='版本不匹配'): + await dispatch_plugin_job('dispatch_test', 'tick', '0.9.0') + with pytest.raises(LookupError, match='未声明'): + await dispatch_plugin_job('dispatch_test', 'other', '1.0.0') + state.gateway.is_plugin_enabled.return_value = False + with pytest.raises(PermissionError, match='未启用'): + await dispatch_plugin_job('dispatch_test', 'tick', '1.0.0') + state.service.assert_not_called() + assert state.sessions == ['open', 'close'] + metric = next(item for item in state.runtime.metrics.snapshot().series if item.operation == 'job:tick') + assert (metric.started, metric.active, metric.rejected) == (1, 0, 1) + finally: + await state.runtime.shutdown() + with pytest.raises(RuntimeError, match='未就绪'): + await dispatch_plugin_job('dispatch_test', 'tick', '1.0.0') + + +@pytest.mark.asyncio +@pytest.mark.parametrize('during_gate', [False, True]) +async def test_shutdown_cancels_running_task_before_child_resources_close( + setup_plugin: SimpleNamespace, during_gate: bool +) -> None: + state = setup_plugin + started, closed = asyncio.Event(), asyncio.Event() + + async def block(*args: object, **kwargs: object) -> None: + started.set() + try: + await asyncio.Event().wait() + finally: + closed.set() + + if during_gate: + state.gateway.is_plugin_enabled.side_effect = block + else: + state.service.side_effect = block + await state.runtime.activate('dispatch_test', FastAPI()) + loaded = state.runtime.loaded['dispatch_test'] + + async def stop_child() -> None: + assert closed.is_set() + assert state.sessions == ['open', 'close'] + + loaded.lifespan = SimpleNamespace(ready=True, shutdown=stop_child) + task = asyncio.create_task(dispatch_plugin_job('dispatch_test', 'tick', '1.0.0')) + try: + await asyncio.wait_for(started.wait(), 5) + finally: + await state.runtime.shutdown() + with pytest.raises(asyncio.CancelledError): + await task + metric = next(item for item in state.runtime.metrics.snapshot().series if item.operation == 'job:tick') + assert (metric.active, metric.cancelled) == (0, 1) + + +@pytest.mark.asyncio +async def test_job_timeout_and_host_exception_propagate(setup_plugin: SimpleNamespace) -> None: + state = setup_plugin + state.plugin.manifest.backend.jobs[0].timeout_seconds = 0.01 + closed = asyncio.Event() + + async def wait(*args: object, **kwargs: object) -> None: + try: + await asyncio.Event().wait() + finally: + closed.set() + + await state.runtime.activate('dispatch_test', FastAPI()) + try: + state.service.side_effect = wait + with pytest.raises(asyncio.TimeoutError): + await dispatch_plugin_job('dispatch_test', 'tick', '1.0.0') + assert closed.is_set() + state.service.side_effect = ValueError('business-error') + with pytest.raises(ValueError, match='business-error'): + await dispatch_plugin_job('dispatch_test', 'tick', '1.0.0') + metric = next(item for item in state.runtime.metrics.snapshot().series if item.operation == 'job:tick') + assert (metric.started, metric.active, metric.failed, metric.timed_out) == (2, 0, 2, 1) + assert metric.last_error_type == 'ValueError' + finally: + await state.runtime.shutdown() + + +@pytest.mark.asyncio +async def test_failed_activation_releases_binding_for_retry(setup_plugin: SimpleNamespace) -> None: + runtime = setup_plugin.runtime + with ( + patch.object(runtime, '_check_health', new=AsyncMock(side_effect=ValueError('unhealthy'))), + pytest.raises(ValueError, match='unhealthy'), + ): + await runtime.activate('dispatch_test', FastAPI()) + with pytest.raises(RuntimeError, match='未就绪'): + await dispatch_plugin_job('dispatch_test', 'tick', '1.0.0') + await runtime.activate('dispatch_test', FastAPI()) + await runtime.shutdown() + + +def test_job_preflight_does_not_import_and_rejects_invalid_settings(setup_plugin: SimpleNamespace) -> None: + state = setup_plugin + with patch('importlib.import_module', side_effect=AssertionError('预检不能导入插件')): + checked = PluginStructureChecker(state.plugin.backend_path.parents[1]).check( + state.plugin, include_frontend=False + ) + assert checked.ok, checked.failed_items + for update in ({'executor': 'processpool'}, {'timeoutSeconds': 0}, {'callable': 'os.system'}): + data = { + **state.data, + 'backend': {**state.data['backend'], 'jobs': [{**state.data['backend']['jobs'][0], **update}]}, + } + with pytest.raises(ValueError): + PluginManifestFactory.create(data) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('during_gate', [False, True]) +async def test_unbind_is_bounded_and_blocks_new_binding_until_old_jobs_exit( + setup_plugin: SimpleNamespace, during_gate: bool +) -> None: + """残留任务保持在途,不允许复用绑定;延迟结束最终记录取消且不启动迟到回调。""" + state = setup_plugin + opened, release = asyncio.Event(), asyncio.Event() + + async def resistant(*args: object, **kwargs: object) -> bool: + opened.set() + while not release.is_set(): + with suppress(asyncio.CancelledError): + await release.wait() + return True + + (state.gateway.is_plugin_enabled if during_gate else state.service).side_effect = resistant + await state.runtime.activate('dispatch_test', FastAPI()) + binding = state.runtime.loaded['dispatch_test'].jobs + task = asyncio.create_task(dispatch_plugin_job('dispatch_test', 'tick', '1.0.0')) + await opened.wait() + stopping = asyncio.create_task(unbind_plugin_jobs(binding, timeout=0.01)) + try: + await asyncio.wait({stopping}, timeout=1) + assert stopping.done() and not task.done() + await stopping + with pytest.raises(RuntimeError, match='未就绪'): + await dispatch_plugin_job('dispatch_test', 'tick', '1.0.0') + with pytest.raises(RuntimeError, match='旧任务尚未退出'): + bind_plugin_jobs(state.plugin, binding.host, state.gateway, lambda: True) + sample = next(item for item in state.runtime.metrics.snapshot().series if item.operation == 'job:tick') + assert (sample.active, sample.succeeded, sample.cancelled) == (1, 0, 0) + release.set() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(task, timeout=1) + if during_gate: + state.service.assert_not_called() + assert state.sessions == ['open', 'close'] + sample = next(item for item in state.runtime.metrics.snapshot().series if item.operation == 'job:tick') + assert (sample.active, sample.succeeded, sample.cancelled) == (0, 0, 1) + replacement = bind_plugin_jobs(state.plugin, binding.host, state.gateway, lambda: True) + await unbind_plugin_jobs(replacement, timeout=0.01) + finally: + release.set() + await asyncio.gather(stopping, task, return_exceptions=True) + await state.runtime.shutdown() + + +@pytest.mark.asyncio +async def test_shutdown_keeps_pending_job_diagnostics_until_callback_exits( + setup_plugin: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + """运行时关闭后仍保留取消超时任务的插件归属,迟到退出后计数归零。""" + state = setup_plugin + opened, release = asyncio.Event(), asyncio.Event() + + async def resistant(*args: object, **kwargs: object) -> bool: + opened.set() + while not release.is_set(): + with suppress(asyncio.CancelledError): + await release.wait() + return True + + async def bounded_unbind(binding: object) -> None: + await unbind_plugin_jobs(binding, timeout=0.01) + + state.service.side_effect = resistant + monkeypatch.setattr('plugins.core.runtime.explicit.unbind_plugin_jobs', bounded_unbind) + await state.runtime.activate('dispatch_test', FastAPI()) + binding = state.runtime.loaded['dispatch_test'].jobs + task = asyncio.create_task(dispatch_plugin_job('dispatch_test', 'tick', '1.0.0')) + await opened.wait() + try: + running = state.runtime.diagnostic_snapshot().plugins[0] + assert running.active_jobs == 1 and running.pending_job_tasks == 0 + await state.runtime.shutdown() + pending = state.runtime.diagnostic_snapshot() + assert state.runtime.loaded['dispatch_test'].jobs is binding + assert not pending.plugins[0].ready + assert pending.plugins[0].closing + assert pending.plugins[0].active_jobs == 1 + assert pending.plugins[0].pending_job_tasks == 1 + assert pending.worker_pending_cleanup_tasks == 1 + release.set() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(task, timeout=1) + finished = state.runtime.diagnostic_snapshot() + assert finished.plugins[0].active_jobs == 0 + assert finished.plugins[0].pending_job_tasks == 0 + assert finished.worker_pending_cleanup_tasks == 0 + finally: + release.set() + await asyncio.gather(task, return_exceptions=True) + await state.runtime.shutdown() diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_metrics.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_metrics.py new file mode 100644 index 0000000..775193f --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_metrics.py @@ -0,0 +1,192 @@ +import asyncio +import json +from unittest.mock import AsyncMock + +import httpx +import pytest +from fastapi import FastAPI, HTTPException +from starlette import status +from starlette.types import Message, Receive, Scope, Send + +from middlewares.trace_middleware import TraceCtx +from middlewares.trace_middleware.ctx import CTX_REQUEST_ID +from plugins.core.runtime.metrics import ( + PluginMetricSnapshot, + PluginObservedASGI, + PluginRuntimeMetrics, + aggregate_metric_snapshots, +) + + +def collector(worker_id: str = 'a' * 32, version: str = '1.0.0', generation: str | None = None) -> PluginRuntimeMetrics: + """构建包含固定插件身份的指标收集器。""" + metrics = PluginRuntimeMetrics(worker_id=worker_id) + metrics.register('metric_demo', version, 'c' * 64, generation) + return metrics + + +def test_invocation_counts_duration_histogram_and_is_idempotent() -> None: + """在途、结果计数及直方图始终保持一致,重复结束不会重复记账。""" + now = [0.0] + metrics = collector() + metrics.clock = lambda: now[0] + invocation = metrics.begin('metric_demo', 'http', 'trace-1') + assert metrics.snapshot().series[0].active == 1 + now[0] = 0.2 + invocation.finish('succeeded') + invocation.finish('failed', error_type='Ignored') + sample = metrics.snapshot() + series = sample.series[0] + assert (series.started, series.active, series.succeeded, series.failed) == (1, 0, 1, 0) + expected_duration = 200.0 + assert series.duration_sum_ms == expected_duration + assert sum(series.latency_buckets) == 1 + assert series.to_payload()['averageDurationMs'] == expected_duration + assert series.to_payload()['failureRatePercent'] == 0 + expected_p95 = 250.0 + assert series.to_payload()['p95UpperBoundMs'] == expected_p95 + PluginMetricSnapshot.model_validate_json(sample.model_dump_json()) + metrics.begin('metric_demo', 'http', 'trace-2') + assert sample.series[0].started == 1 + + +@pytest.mark.parametrize('job_id', ['t', 'sync-users', 'sync_users']) +def test_task_metric_names_accept_all_manifest_job_id_forms(job_id: str) -> None: + """指标标签兼容清单允许的短任务 ID 和连接符。""" + metrics = collector() + invocation = metrics.begin('metric_demo', f'job:{job_id}', 'job-request') + invocation.finish('succeeded') + assert any(series.operation == f'job:{job_id}' for series in metrics.snapshot().series) + + +def test_aggregate_preserves_version_and_generation_boundaries() -> None: + """同版本多进程合并,新版本或新代际单独展示。""" + first, second = collector(), collector('b' * 32) + new_version = collector('d' * 32, '2.0.0') + new_generation = collector('e' * 32, generation='f' * 32) + first.begin('metric_demo', 'http', 'first').finish('succeeded') + second.begin('metric_demo', 'http', 'second').finish('failed', error_type='TimeoutError', timed_out=True) + rows = aggregate_metric_snapshots([item.snapshot() for item in (first, second, new_version, new_generation)], None) + expected_groups = 3 + assert len(rows) == expected_groups + shared = next(row for row in rows if row['version'] == '1.0.0' and row['generation'] is None) + assert (shared['started'], shared['succeeded'], shared['failed'], shared['timedOut']) == (2, 1, 1, 1) + assert shared['lastErrorRequestId'] == 'second' + expected_failure_rate = 50.0 + assert shared['failureRatePercent'] == expected_failure_rate + assert aggregate_metric_snapshots([first.snapshot()], 'other_demo') == [] + + +@pytest.mark.asyncio +async def test_http_observer_tracks_responses_without_recording_paths_or_content() -> None: + """收集器包含鉴权拒绝和失败,不按动态 URL 建标签或记录业务数据。""" + metrics = collector() + child = FastAPI() + seen = [] + + @child.get('/items/{item_id}') + async def item(item_id: str) -> dict[str, str]: + seen.append(TraceCtx.get_request_id()) + if item_id == 'denied': + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + if item_id == 'timeout': + raise HTTPException(status_code=status.HTTP_504_GATEWAY_TIMEOUT) + if item_id == 'failed': + raise RuntimeError('never-store-secret') + return {'value': item_id} + + app = PluginObservedASGI(child, metrics, 'metric_demo') + previous = CTX_REQUEST_ID.set('host-id') + try: + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app, raise_app_exceptions=False), base_url='http://test' + ) as client: + for item_id in ('private-item-a', 'private-item-b', 'denied', 'timeout', 'failed'): + await client.get(f'/items/{item_id}?token=private-query') + assert TraceCtx.get_request_id() == 'host-id' + finally: + CTX_REQUEST_ID.reset(previous) + assert seen == ['host-id'] * len(seen) + snapshot = metrics.snapshot() + assert len(snapshot.series) == 1 + series = snapshot.series[0] + assert (series.started, series.active, series.succeeded, series.rejected, series.failed, series.timed_out) == ( + 5, + 0, + 2, + 1, + 2, + 1, + ) + serialized = snapshot.model_dump_json() + for forbidden in ('private-item', 'private-query', 'never-store-secret', '/items/'): + assert forbidden not in serialized + assert series.last_error_type == 'RuntimeError' + assert series.last_error_request_id == 'host-id' + + +@pytest.mark.asyncio +async def test_cancelled_asgi_request_removes_active_count() -> None: + """取消未完成请求仍减少在途数量并恢复请求上下文。""" + entered = asyncio.Event() + metrics = collector() + + async def child(scope: Scope, receive: Receive, send: Send) -> None: + entered.set() + await asyncio.Event().wait() + + app = PluginObservedASGI(child, metrics, 'metric_demo') + task = asyncio.create_task(app({'type': 'http'}, AsyncMock(), AsyncMock())) + await asyncio.wait_for(entered.wait(), 2) + assert metrics.snapshot().series[0].active == 1 + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + series = metrics.snapshot().series[0] + assert (series.active, series.cancelled) == (0, 1) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'accepted,code,outcome', [(False, 1008, 'rejected'), (True, 1000, 'succeeded'), (True, 1011, 'failed')] +) +async def test_websocket_observation_covers_handshake_and_close(accepted: bool, code: int, outcome: str) -> None: + """WebSocket 统计覆盖握手拒绝和连接关闭,不将正常长连接计为失败。""" + metrics = collector() + messages: list[Message] = [] + + async def child(scope: Scope, receive: Receive, send: Send) -> None: + if accepted: + await send({'type': 'websocket.accept'}) + await send({'type': 'websocket.close', 'code': code}) + + async def send(message: Message) -> None: + messages.append(message) + + await PluginObservedASGI(child, metrics, 'metric_demo')({'type': 'websocket'}, AsyncMock(), send) + series = next(item for item in metrics.snapshot().series if item.operation == 'websocket') + assert getattr(series, outcome) == 1 + assert series.active == 0 + assert messages[-1]['code'] == code + + +def test_invalid_remote_counter_snapshot_is_rejected() -> None: + """不一致的计数或额外敏感字段不能进入跨 worker 聚合。""" + value = collector().snapshot().model_dump(by_alias=True) + value['series'][0]['active'] = 1 + with pytest.raises(ValueError, match='计数不一致'): + PluginMetricSnapshot.model_validate_json(json.dumps(value)) + value['series'][0]['active'] = 0 + value['series'][0]['body'] = 'forbidden' + with pytest.raises(ValueError): + PluginMetricSnapshot.model_validate_json(json.dumps(value)) + + +def test_metric_capacity_does_not_interrupt_business(monkeypatch: pytest.MonkeyPatch) -> None: + """指标达到上限时停止扩张,不使业务调用失败。""" + from plugins.core.runtime import metrics as module # noqa: PLC0415 + + monkeypatch.setattr(module, 'MAX_METRIC_SERIES', 1) + metrics = collector() + assert metrics.begin('metric_demo', 'job:next', 'trace') is None + assert metrics.dropped_series == 1 diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_metrics_store.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_metrics_store.py new file mode 100644 index 0000000..eb6663c --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_metrics_store.py @@ -0,0 +1,136 @@ +import json +import time +from collections.abc import AsyncIterator +from fnmatch import fnmatchcase + +import pytest + +from plugins.core.runtime.metrics import PluginRuntimeMetrics +from plugins.core.runtime.metrics_store import METRICS_TTL_SECONDS, PluginMetricsReporter + +NAMESPACE = 'isolated-test:ready:metrics' + + +class MetricsRedis: + """提供测试所需的键空间隔离和有限指标读写,不连接外部服务。""" + + def __init__(self) -> None: + self.values: dict[str, bytes] = {} + self.expirations: dict[str, int] = {} + self.fail = False + + async def set(self, key: str, value: bytes, *, ex: int) -> None: + if self.fail: + raise ConnectionError('private-redis-password') + self.values[key] = value + self.expirations[key] = ex + + async def delete(self, key: str) -> int: + return int(self.values.pop(key, None) is not None) + + async def scan_iter(self, *, match: str, count: int) -> AsyncIterator[str]: + if self.fail: + raise ConnectionError('private-redis-password') + for key in sorted(self.values): + if fnmatchcase(key, match): + yield key + + async def mget(self, keys: list[str]) -> list[bytes | None]: + return [self.values.get(key) for key in keys] + + +def reporter(worker_id: str) -> PluginMetricsReporter: + """构造加载了固定插件的进程报告器。""" + metrics = PluginRuntimeMetrics(worker_id=worker_id) + metrics.register('metric_demo', '1.0.0', None, None) + metrics.begin('metric_demo', 'http', f'trace-{worker_id}').finish('succeeded') + return PluginMetricsReporter(metrics) + + +@pytest.mark.asyncio +async def test_reporting_workers_aggregate_without_double_counting_local_snapshot() -> None: + """本地即时数据替换自己的旧快照,其他部署的命名空间不进入汇总。""" + redis = MetricsRedis() + first, second, foreign = reporter('a' * 32), reporter('b' * 32), reporter('c' * 32) + for item, namespace in ((first, NAMESPACE), (second, NAMESPACE), (foreign, 'foreign:metrics')): + item.start(redis, namespace) + await item.publish() + try: + first.metrics.begin('metric_demo', 'http', 'new').finish('succeeded') + result = await first.read('metric_demo') + assert result['scope'] == 'reporting_workers' + expected_workers = 2 + expected_requests = 3 + assert result['observedWorkers'] == expected_workers + assert result['series'][0]['started'] == expected_requests + assert {item['workerId'] for item in result['workers']} == {'a' * 32, 'b' * 32} + assert redis.expirations[first.key] == METRICS_TTL_SECONDS + assert (await first.read('other_demo'))['series'] == [] + finally: + for item in (first, second, foreign): + await item.stop() + assert not redis.values + + +@pytest.mark.asyncio +async def test_stale_corrupt_and_mismatched_snapshots_are_excluded() -> None: + """损坏、身份不符和过期的采样不会被当成活跃 worker 的数据。""" + redis = MetricsRedis() + current = reporter('a' * 32) + current.start(redis, NAMESPACE) + stale = reporter('b' * 32).metrics.snapshot().model_dump(by_alias=True) + stale['collectedAt'] = time.time() - METRICS_TTL_SECONDS - 1 + redis.values[f'{NAMESPACE}:{"b" * 32}'] = json.dumps(stale).encode() + redis.values[f'{NAMESPACE}:{"c" * 32}'] = b'{broken' + redis.values[f'{NAMESPACE}:{"d" * 32}'] = current.metrics.snapshot().model_dump_json().encode() + try: + result = await current.read() + assert result['observedWorkers'] == 1 + assert result['staleSnapshots'] == 1 + expected_invalid = 2 + assert result['invalidSnapshots'] == expected_invalid + finally: + await current.stop() + + +@pytest.mark.asyncio +async def test_redis_failure_returns_explicit_local_scope_and_recovers() -> None: + """观测 Redis 故障不影响本地统计,恢复后继续发布。""" + redis = MetricsRedis() + current = reporter('a' * 32) + current.start(redis, NAMESPACE) + redis.fail = True + try: + await current.publish() + assert current.publication_failed + result = await current.read() + assert result['scope'] == 'current_worker' + assert result['clusterAvailable'] is False + assert result['series'][0]['succeeded'] == 1 + assert 'private-redis-password' not in json.dumps(result) + redis.fail = False + await current.publish() + assert not current.publication_failed + finally: + await current.stop() + assert current.task is None + + +@pytest.mark.asyncio +async def test_worker_limit_is_reported_and_shutdown_only_deletes_own_key(monkeypatch: pytest.MonkeyPatch) -> None: + """限制跨进程查询规模,并保留其他 worker 和业务键。""" + from plugins.core.runtime import metrics_store as module # noqa: PLC0415 + + monkeypatch.setattr(module, 'MAX_METRICS_WORKERS', 1) + redis = MetricsRedis() + current = reporter('a' * 32) + current.start(redis, NAMESPACE) + await current.publish() + other_key = f'{NAMESPACE}:{"b" * 32}' + redis.values[other_key] = reporter('b' * 32).metrics.snapshot().model_dump_json().encode() + redis.values['business:key'] = b'unchanged' + try: + assert (await current.read())['workerLimitReached'] is True + finally: + await current.stop() + assert redis.values == {other_key: redis.values[other_key], 'business:key': b'unchanged'} diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_sdk_requests.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_sdk_requests.py new file mode 100644 index 0000000..32c4fa7 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_sdk_requests.py @@ -0,0 +1,194 @@ +import asyncio +from collections.abc import AsyncIterator +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock + +import httpx +import pytest +from fastapi import FastAPI, Request +from pydantic import BaseModel, Field +from starlette import status + +from middlewares.trace_middleware.ctx import CTX_REQUEST_ID +from plugins.core.sdk import PluginHostContext, PluginRequestContext, plugin_endpoint + + +class ReportBody(BaseModel): + """验证原生和 Python 回调共享的业务输入数据。""" + + name: str = Field(min_length=2) + count: int = Field(ge=1) + + +def endpoint_app(tmp_path: Path, callback: Any, **options: Any) -> FastAPI: + """创建只使用宿主签发身份的 SDK 接口测试应用。""" + app = FastAPI() + context = PluginRequestContext(PluginHostContext('sdk_demo', tmp_path), object(), frozenset({'sdk_demo:view'})) + + @app.middleware('http') + async def inject(request: Request, call_next: Any) -> Any: + request.state.plugin_context = context + return await call_next(request) + + app.add_api_route( + '/reports/{report_id}', + plugin_endpoint(callback, permission='sdk_demo:view', **options), + methods=['GET', 'POST'], + ) + return app + + +@pytest.mark.asyncio +async def test_endpoint_exposes_typed_body_multi_value_query_and_host_trace(tmp_path: Path) -> None: + """请求数据保留重复参数,模型输出转换为可供原生代码消费的 JSON。""" + callback = AsyncMock(side_effect=lambda context: context.request.to_payload()) + app = endpoint_app(tmp_path, callback, body_model=ReportBody) + token = CTX_REQUEST_ID.set('host-request-123') + try: + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client: + response = await client.post( + '/reports/7?tag=alpha&tag=beta&empty=', + json={'name': 'report', 'count': '3'}, + headers={'authorization': 'Bearer secret-token', 'cookie': 'private-cookie'}, + ) + finally: + CTX_REQUEST_ID.reset(token) + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + 'method': 'POST', + 'path': '/reports/7', + 'pathParams': {'report_id': '7'}, + 'query': {'tag': ['alpha', 'beta'], 'empty': ['']}, + 'body': {'name': 'report', 'count': 3}, + } + assert callback.await_args.args[0].request_id == 'host-request-123' + assert 'secret-token' not in response.text + assert 'private-cookie' not in response.text + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('content', 'content_type', 'expected'), + [ + (b'{broken', 'application/json', status.HTTP_400_BAD_REQUEST), + (b'{"value": NaN}', 'application/json', status.HTTP_400_BAD_REQUEST), + (b'{"value": Infinity}', 'application/json', status.HTTP_400_BAD_REQUEST), + (b'{}', 'text/plain', status.HTTP_415_UNSUPPORTED_MEDIA_TYPE), + ], +) +async def test_endpoint_rejects_invalid_json_before_callback( + tmp_path: Path, content: bytes, content_type: str, expected: int +) -> None: + """非法 JSON 或错误媒体类型不会进入业务回调。""" + callback = AsyncMock() + app = endpoint_app(tmp_path, callback, json_body=True) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client: + response = await client.post('/reports/7', content=content, headers={'content-type': content_type}) + assert response.status_code == expected + callback.assert_not_called() + + +@pytest.mark.asyncio +async def test_endpoint_limits_streamed_body_without_trusting_content_length(tmp_path: Path) -> None: + """分块请求同样按接收字节数受限,不依赖 Content-Length。""" + callback = AsyncMock() + app = endpoint_app(tmp_path, callback, json_body=True, max_body_bytes=8) + + async def chunks() -> AsyncIterator[bytes]: + yield b'{"a":' + yield b'"too large"}' + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client: + response = await client.post('/reports/7', content=chunks(), headers={'content-type': 'application/json'}) + assert response.status_code == status.HTTP_413_CONTENT_TOO_LARGE + callback.assert_not_called() + + +@pytest.mark.asyncio +async def test_model_validation_does_not_echo_sensitive_input(tmp_path: Path) -> None: + """模型错误只返回字段位置和规则,不反射请求值。""" + callback = AsyncMock() + app = endpoint_app(tmp_path, callback, body_model=ReportBody) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client: + response = await client.post('/reports/7', json={'name': 'x', 'count': 'never-echo-this'}) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + assert 'never-echo-this' not in response.text + callback.assert_not_called() + + +@pytest.mark.asyncio +async def test_permission_check_precedes_reading_body_and_legacy_adapter_leaves_body_unread(tmp_path: Path) -> None: + """未授权请求不读取请求体;默认适配器保持原有不读取请求体的行为。""" + receive = AsyncMock(side_effect=AssertionError('body must not be read')) + context = PluginRequestContext(PluginHostContext('sdk_demo', tmp_path), object()) + scope = { + 'type': 'http', + 'method': 'POST', + 'path': '/reports/7', + 'query_string': b'', + 'headers': [], + 'state': {'plugin_context': context}, + } + callback = AsyncMock(return_value={}) + from fastapi import HTTPException # noqa: PLC0415 + + with pytest.raises(HTTPException) as denied: + await plugin_endpoint(callback, permission='sdk_demo:view', json_body=True)(Request(scope, receive)) + assert denied.value.status_code == status.HTTP_403_FORBIDDEN + await plugin_endpoint(callback, permission='sdk_demo:view')( + Request( + { + **scope, + 'state': {'plugin_context': PluginRequestContext(context.host, object(), frozenset({'sdk_demo:view'}))}, + }, + receive, + ) + ) + assert callback.await_args.args[0].request.body is None + receive.assert_not_called() + + +@pytest.mark.asyncio +async def test_payload_copy_does_not_mutate_callback_request(tmp_path: Path) -> None: + """原生调用方修改导出的 JSON 副本不会修改上下文内的原始请求数据。""" + callback = AsyncMock(return_value={}) + app = endpoint_app(tmp_path, callback, json_body=True) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client: + await client.post('/reports/7', json={'nested': [1, 2]}) + context = callback.await_args.args[0] + payload = context.request.to_payload() + payload['body']['nested'].append(3) + assert context.request.body == {'nested': [1, 2]} + with pytest.raises(TypeError): + context.request.path_params['report_id'] = '8' + + +@pytest.mark.parametrize('limit', [0, -1, True, 1.5]) +def test_invalid_body_limits_fail_at_registration(limit: Any) -> None: + """不合法限制必须在注册阶段失败。""" + with pytest.raises(ValueError, match='正整数'): + plugin_endpoint(AsyncMock(), permission='sdk_demo:view', max_body_bytes=limit) + + +@pytest.mark.asyncio +async def test_request_cancellation_reaches_callback(tmp_path: Path) -> None: + """适配器继续传播 asyncio 取消,允许业务释放资源。""" + entered = asyncio.Event() + stopped = asyncio.Event() + + async def callback(context: PluginRequestContext) -> None: + entered.set() + try: + await asyncio.Event().wait() + finally: + stopped.set() + + app = endpoint_app(tmp_path, callback) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client: + task = asyncio.create_task(client.get('/reports/7')) + await asyncio.wait_for(entered.wait(), timeout=2) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert stopped.is_set() diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_sdk_transactions.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_sdk_transactions.py new file mode 100644 index 0000000..f0dc2da --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_sdk_transactions.py @@ -0,0 +1,121 @@ +import asyncio +from collections.abc import AsyncIterator +from dataclasses import replace +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock, patch + +import pytest +import pytest_asyncio +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from plugins.core.runtime.host_services import build_host_services +from plugins.core.sdk import PluginHostContext, PluginRequestContext + + +@pytest_asyncio.fixture +async def sdk_sessions(tmp_path: Path) -> AsyncIterator[async_sessionmaker[AsyncSession]]: + """使用独立 SQLite 验证提交、回滚和会话所有权。""" + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "sdk.sqlite"}') + async with engine.begin() as db: + await db.execute(text('CREATE TABLE sdk_effect (value INTEGER NOT NULL)')) + try: + yield async_sessionmaker(engine, expire_on_commit=False) + finally: + await engine.dispose() + + +def request_context(tmp_path: Path, sessions: async_sessionmaker[AsyncSession]) -> PluginRequestContext: + """构建只授予示例业务权限的宿主身份。""" + host = PluginHostContext('sdk_demo', tmp_path, session_factory=sessions) + return PluginRequestContext(host, SimpleNamespace(user=SimpleNamespace(user_id=7)), frozenset({'sdk_demo:profile'})) + + +@pytest.mark.asyncio +async def test_explicit_transaction_commits_and_profile_reuses_owned_session( + tmp_path: Path, sdk_sessions: async_sessionmaker[AsyncSession] +) -> None: + """同一事务中的宿主服务复用会话,退出后一次提交。""" + context = request_context(tmp_path, sdk_sessions) + fallback = Mock(side_effect=AssertionError('service must reuse the transaction')) + profile = SimpleNamespace( + data=SimpleNamespace(user_id=7, user_name='demo', nick_name='Demo', avatar=''), post_group='', role_group='' + ) + service = build_host_services('sdk_demo', fallback)['users.current_profile.v1'] + with patch( + 'module_admin.service.user_service.UserService.user_profile_services', new=AsyncMock(return_value=profile) + ) as read: + async with context.transaction() as transaction: + await transaction.query_db.execute(text('INSERT INTO sdk_effect VALUES (1)')) + assert (await service(transaction))['userId'] == context.user.user.user_id + read.assert_awaited_once_with(transaction.query_db, 7) + fallback.assert_not_called() + assert context.query_db is None + async with sdk_sessions() as db: + assert (await db.execute(text('SELECT value FROM sdk_effect'))).scalars().all() == [1] + with pytest.raises(RuntimeError, match='失效'): + transaction.transaction_session() + with pytest.raises(RuntimeError, match='失效'): + await service(transaction) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('cancelled', [False, True]) +async def test_failed_or_cancelled_transaction_rolls_back( + tmp_path: Path, sdk_sessions: async_sessionmaker[AsyncSession], cancelled: bool +) -> None: + """异常和取消均不能留下已提交的业务数据。""" + context = request_context(tmp_path, sdk_sessions) + entered = asyncio.Event() + captured = [] + + async def write() -> None: + async with context.transaction() as transaction: + captured.append(transaction) + await transaction.query_db.execute(text('INSERT INTO sdk_effect VALUES (2)')) + entered.set() + if cancelled: + await asyncio.Event().wait() + raise ValueError('business failure') + + task = asyncio.create_task(write()) + await asyncio.wait_for(entered.wait(), timeout=2) + if cancelled: + task.cancel() + with pytest.raises(asyncio.CancelledError if cancelled else ValueError): + await task + async with sdk_sessions() as db: + assert not (await db.execute(text('SELECT value FROM sdk_effect'))).all() + with pytest.raises(RuntimeError, match='失效'): + captured[0].transaction_session() + + +@pytest.mark.asyncio +async def test_transaction_rejects_nested_and_parallel_session_use( + tmp_path: Path, sdk_sessions: async_sessionmaker[AsyncSession] +) -> None: + """嵌套事务和另一个 asyncio 任务不能复用当前会话。""" + context = request_context(tmp_path, sdk_sessions) + + async def foreign_task(transaction: PluginRequestContext) -> None: + transaction.transaction_session() + + async with context.transaction() as transaction: + with pytest.raises(RuntimeError, match='跨 asyncio'): + await asyncio.create_task(foreign_task(transaction)) + with pytest.raises(RuntimeError, match='嵌套'): + async with transaction.transaction(): + pytest.fail('nested transaction entered') + assert transaction.transaction_session() is transaction.query_db + + +@pytest.mark.asyncio +async def test_unowned_session_and_missing_factory_are_rejected(tmp_path: Path) -> None: + """会话不能通过任意 query_db 注入绕过所有权检查。""" + context = PluginRequestContext(PluginHostContext('sdk_demo', tmp_path), object()) + with pytest.raises(RuntimeError, match='会话工厂'): + async with context.transaction(): + pytest.fail('missing factory entered') + with pytest.raises(RuntimeError, match='不是由宿主创建'): + replace(context, query_db=object()).transaction_session() diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_task_cleanup.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_task_cleanup.py new file mode 100644 index 0000000..d2f9a4b --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_task_cleanup.py @@ -0,0 +1,70 @@ +import asyncio + +import pytest + +from plugins.core.runtime.task_cleanup import _unfinished_tasks, cancel_plugin_tasks + + +@pytest.mark.asyncio +async def test_batch_cancellation_releases_cooperative_tasks_and_ignores_caller() -> None: + stopped = [] + + async def work() -> None: + try: + await asyncio.Event().wait() + finally: + stopped.append(True) + + tasks = {asyncio.create_task(work()) for _ in range(3)} + await asyncio.sleep(0) + assert await cancel_plugin_tasks(tasks | {asyncio.current_task()}, timeout=0.01) == set() + assert len(stopped) == len(tasks) + assert all(task.cancelled() for task in tasks) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('cancel_waiter', [False, True]) +async def test_uncooperative_task_is_retained_even_when_waiter_is_cancelled(cancel_waiter: bool) -> None: + opened, interrupted, release = (asyncio.Event() for _ in range(3)) + + async def work() -> None: + opened.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + interrupted.set() + await release.wait() + raise ValueError('late failure') from None + + child = asyncio.create_task(work()) + await opened.wait() + waiter = asyncio.create_task(cancel_plugin_tasks({child}, timeout=0.01 if not cancel_waiter else 1)) + try: + await interrupted.wait() + if cancel_waiter: + waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await waiter + else: + assert await waiter == {child} + assert child in _unfinished_tasks + release.set() + await asyncio.wait({child}, timeout=1) + await asyncio.sleep(0) + assert child.done() and child not in _unfinished_tasks + assert child._log_traceback is False # 迟到异常已被回收,不产生未读取异常告警。 + finally: + release.set() + await asyncio.gather(waiter, child, return_exceptions=True) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('timeout', [0, -1, float('inf'), float('nan')]) +async def test_invalid_timeout_is_rejected_before_any_cancellation(timeout: float) -> None: + task = asyncio.create_task(asyncio.sleep(10)) + try: + with pytest.raises(ValueError, match='大于零'): + await cancel_plugin_tasks({task}, timeout=timeout) + assert not task.done() + finally: + await cancel_plugin_tasks({task}, timeout=0.01) diff --git a/ruoyi-fastapi-backend/tests/plugins/core/validation/test_manifest_v2.py b/ruoyi-fastapi-backend/tests/plugins/core/validation/test_manifest_v2.py new file mode 100644 index 0000000..eb80d3a --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/validation/test_manifest_v2.py @@ -0,0 +1,133 @@ +from copy import deepcopy +from pathlib import Path + +import pytest +import yaml + +from plugins.core.capability import PluginRuntimeCapabilityResolver +from plugins.core.discovery.scanner import DiscoveredPlugin, PluginScanner +from plugins.core.manifest.schema import PluginManifest, PluginManifestFactory +from plugins.core.manifest.v2 import PluginManifestV2 +from plugins.core.validation.manifest import PluginManifestChecker +from plugins.core.validation.structure import PluginStructureChecker + + +def manifest_data(*, native: bool = False, asgi: bool = False, bundle: bool = False) -> dict: + module = 'ruoyi_plugin_demo' if native else 'plugins.demo' + backend = {'module': module, 'entrypoint': f'{module}.entry:create_plugin'} + if native: + backend.update(runtime='native', native={'distribution': 'ruoyi-plugin-demo'}) + if asgi: + backend['integration'] = 'asgi' + payload = {'manifestVersion': 2, 'id': 'demo', 'name': 'Demo', 'version': '1.0.0', 'backend': backend} + if bundle: + payload['frontend'] = { + 'delivery': {'type': 'bundle'}, + 'bundle': {}, + 'menus': [{'name': 'Demo', 'path': 'demo', 'component': 'PluginFrame'}], + } + return payload + + +@pytest.mark.parametrize('native', [False, True]) +@pytest.mark.parametrize('asgi', [False, True]) +def test_runtime_and_integration_are_independent(native: bool, asgi: bool) -> None: + manifest = PluginManifestFactory.create(manifest_data(native=native, asgi=asgi)) + assert isinstance(manifest, PluginManifestV2) + assert manifest.runtime_kind == ('native' if native else 'python') + assert manifest.integration_kind == ('asgi' if asgi else 'router') + assert manifest.backend.routers.auto_scan is False + if asgi: + assert manifest.backend.asgi.mount_path == '/apps/demo' + + +def test_v1_keeps_defaults_and_rejects_v2_fields() -> None: + data = {'id': 'demo', 'name': 'Demo', 'version': '1', 'backend': {'module': 'plugins.demo'}} + manifest = PluginManifestFactory.create(data) + assert type(manifest) is PluginManifest + assert manifest.backend.routers.auto_scan + assert not manifest.uses_entrypoint + data['backend']['runtime'] = 'python' + with pytest.raises(ValueError, match='Extra inputs'): + PluginManifestFactory.create(data) + + +@pytest.mark.parametrize( + ('field', 'value'), + [ + ('entrypoint', 'os:system'), + ('module', 'module_admin'), + ('routers', {'autoScan': True}), + ('native', {'distribution': 'demo'}), + ('hooks', {'onStartup': 'os:system'}), + ('asgi', {'mountPath': '/system'}), + ], +) +def test_rejects_unsafe_or_conflicting_backend(field: str, value: object) -> None: + payload = manifest_data(asgi=True) + payload['backend'][field] = value + with pytest.raises(ValueError): + PluginManifestFactory.create(payload) + + +def test_asgi_cannot_start_resources_twice() -> None: + payload = manifest_data(asgi=True) + payload['backend']['hooks'] = {'onStartup': 'plugins.demo.entry:startup'} + with pytest.raises(ValueError, match='lifespan'): + PluginManifestFactory.create(payload) + + +@pytest.mark.parametrize('path', ['../dist', '/dist', 'C:/dist', 'web\\dist', 'web/../dist', 'web/./dist']) +def test_bundle_rejects_nonportable_or_escaping_paths(path: str) -> None: + payload = manifest_data(asgi=True, bundle=True) + payload['frontend']['bundle']['directory'] = path + with pytest.raises(ValueError, match='安全相对路径'): + PluginManifestFactory.create(payload) + + +def test_bundle_does_not_request_host_build_or_npm() -> None: + payload = manifest_data(asgi=True, bundle=True) + manifest = PluginManifestFactory.create(payload) + assert manifest.frontend.delivery.type == 'bundle' + assert not manifest.frontend.delivery.build_required + plugin = DiscoveredPlugin(manifest, Path('plugins/demo'), Path('plugins/demo/plugin.yaml')) + capability = PluginRuntimeCapabilityResolver(frontend_mode='built', backend_runtime_mode='service').resolve(plugin) + assert not capability.frontend_build_required + assert capability.frontend_runtime_manageable # bundle 无需重建宿主前端。 + assert not capability.backend_runtime_manageable # 保持生产变更门禁。 + invalid = deepcopy(payload) + invalid['dependencies'] = {'npm': ['vue>=3.0.0']} + with pytest.raises(ValueError, match='独立管理'): + PluginManifestFactory.create(invalid) + + +def test_host_api_compatibility_is_enforced() -> None: + payload = manifest_data() + payload['compatibility'] = {'hostApiVersion': '>=2.0.0'} + result = PluginManifestChecker().check(PluginManifestFactory.create(payload)) + assert not result.ok + assert any(item.path == 'compatibility.hostApiVersion' for item in result.error_issues) + + +def test_discovery_and_structure_do_not_execute_entrypoint(tmp_path: Path) -> None: + root = tmp_path / 'plugins' / 'demo' + root.mkdir(parents=True) + (root / 'plugin.yaml').write_text(yaml.safe_dump(manifest_data()), encoding='utf-8') + (root / '__init__.py').write_text('raise RuntimeError("must not import")', encoding='utf-8') + (root / 'entry.py').write_text('raise RuntimeError("must not import")', encoding='utf-8') + plugin = PluginScanner(root.parent).discover()[0] + result = PluginStructureChecker(tmp_path).check(plugin) + assert result.ok + assert any(item.kind == 'entrypoint' for item in result.items) + + +def test_bundle_resources_are_checked_in_production(tmp_path: Path) -> None: + root = tmp_path / 'plugins' / 'demo' + root.mkdir(parents=True) + (root / 'plugin.yaml').write_text(yaml.safe_dump(manifest_data(asgi=True, bundle=True)), encoding='utf-8') + (root / '__init__.py').touch() + (root / 'entry.py').touch() + plugin = PluginScanner(root.parent).discover()[0] + result = PluginStructureChecker(tmp_path).check(plugin, include_frontend=False) + assert not result.ok + assert any(item.kind == 'frontend_bundle' and not item.ok for item in result.items) diff --git a/ruoyi-fastapi-backend/tests/plugins/examples/test_task_demo.py b/ruoyi-fastapi-backend/tests/plugins/examples/test_task_demo.py new file mode 100644 index 0000000..145b699 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/examples/test_task_demo.py @@ -0,0 +1,371 @@ +import asyncio +from collections.abc import AsyncIterator +from dataclasses import replace +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import httpx +import pytest +import pytest_asyncio +from fastapi import status +from sqlalchemy import func, select, text +from sqlalchemy.dialects import mysql, postgresql +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.sql.elements import TextClause + +from config.env import DataBaseConfig +from plugins.core.discovery.scanner import PluginScanner +from plugins.core.lifecycle.migration import ( + PluginMigrationHistoryRecord, + PluginMigrationHistoryStore, + PluginMigrationRunner, +) +from plugins.core.runtime.health import PluginHealthContext +from plugins.core.sdk import PluginHostContext, PluginRequestContext +from plugins.examples.python import task_demo +from plugins.examples.python.task_demo import create_plugin, health, service +from plugins.examples.python.task_demo.models import TaskInput, tasks + +PLUGIN_ROOT = Path(__file__).resolve().parents[3] / 'plugins' / 'examples' / 'python' / 'task_demo' + + +@pytest.fixture(autouse=True) +def isolated_runtime(monkeypatch: pytest.MonkeyPatch) -> None: + """每个用例恢复插件自身的工厂引用,不共享测试连接池。""" + monkeypatch.setattr(task_demo._runtime, 'session_factory', None) + + +@pytest_asyncio.fixture +async def task_host(tmp_path: Path) -> AsyncIterator[PluginHostContext]: + """仅适配迁移中的时间类型后在 SQLite 执行,不代表 PostgreSQL 方言实测。""" + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "tasks.db"}') + sessions = async_sessionmaker(engine, expire_on_commit=False) + try: + async with engine.begin() as connection: + for name in ('001_init.sql', '002_priority.sql'): + sql = (PLUGIN_ROOT / 'migrations' / 'postgresql' / name).read_text('utf-8') + await connection.execute(text(sql.replace('TIMESTAMP(3) WITH TIME ZONE', 'DATETIME'))) + yield PluginHostContext(plugin_id='task_demo', resource_root=PLUGIN_ROOT, session_factory=sessions) + finally: + await engine.dispose() + + +def _context(host: PluginHostContext, permissions: frozenset[str] = frozenset({'*:*:*'})) -> PluginRequestContext: + """构造测试专用可信身份;生产身份由宿主网关注入。""" + return PluginRequestContext(host=host, user=SimpleNamespace(user_id=1), permissions=permissions) + + +def _client(host: PluginHostContext, permissions: frozenset[str] = frozenset({'*:*:*'})) -> httpx.AsyncClient: + """将可信上下文注入插件子应用,使用真实 SDK HTTP 适配器检查权限与输入。""" + child = create_plugin(host).app_factory(host) + + async def authenticated_app(scope: dict[str, Any], receive: Any, send: Any) -> None: + scope.setdefault('state', {})['plugin_context'] = _context(host, permissions) + await child(scope, receive, send) + + return httpx.AsyncClient(transport=httpx.ASGITransport(app=authenticated_app), base_url='http://task.test') + + +@pytest.mark.asyncio +async def test_crud_is_persistent_across_new_app_and_connection_pool(task_host: PluginHostContext) -> None: + """任务随事务持久化,在重建子应用和连接池后仍可查询、更新与删除。""" + async with _client(task_host) as client: + info = await client.get('/api/info') + assert info.json() == {'pluginId': 'task_demo', 'version': '1.1.0', 'canWrite': True} + response = await client.post('/api/tasks', json={'title': ' 第一个任务 ', 'description': '保留说明'}) + assert response.status_code == status.HTTP_201_CREATED + created = response.json() + assert created['title'] == '第一个任务' + assert created['status'] == 'todo' + assert created['priority'] == 'normal' + assert created['createdAt'].endswith('Z') + task_id = created['id'] + + original_engine = task_host.session_factory.kw['bind'] + await original_engine.dispose() + restarted_engine = create_async_engine(original_engine.url) + restarted_host = replace(task_host, session_factory=async_sessionmaker(restarted_engine, expire_on_commit=False)) + try: + async with _client(restarted_host) as client: + assert (await client.get(f'/api/tasks/{task_id}')).json() == created + response = await client.put( + f'/api/tasks/{task_id}', + json={'title': '已完成任务', 'description': '更新说明', 'status': 'done', 'priority': 'high'}, + ) + assert response.status_code == status.HTTP_200_OK + updated = response.json() + assert updated['createdAt'] == created['createdAt'] + assert updated['updatedAt'] >= created['updatedAt'] + assert updated['priority'] == 'high' + assert updated['status'] == 'done' + assert (await client.delete(f'/api/tasks/{task_id}')).json() == {'deleted': True} + assert (await client.get(f'/api/tasks/{task_id}')).status_code == status.HTTP_404_NOT_FOUND + assert ( + await client.put(f'/api/tasks/{task_id}', json={'title': '消失的任务'}) + ).status_code == status.HTTP_404_NOT_FOUND + assert (await client.delete(f'/api/tasks/{task_id}')).status_code == status.HTTP_404_NOT_FOUND + assert (await client.get('/api/tasks')).json()['total'] == 0 + finally: + await restarted_engine.dispose() + + +@pytest.mark.asyncio +async def test_permissions_keep_reads_and_writes_independent(task_host: PluginHostContext) -> None: + """只读用户无法写入,仅写用户不能读取;权限校验先于无效请求体解析。""" + async with _client(task_host, frozenset({'task_demo:view'})) as reader: + assert (await reader.get('/api/info')).json()['canWrite'] is False + assert (await reader.get('/api/tasks')).json()['canWrite'] is False + assert (await reader.post('/api/tasks', content='broken')).status_code == status.HTTP_403_FORBIDDEN + async with _client(task_host, frozenset({'task_demo:write'})) as writer: + response = await writer.post('/api/tasks', json={'title': '仅写身份创建'}) + assert response.status_code == status.HTTP_201_CREATED + task_id = response.json()['id'] + assert (await writer.get('/api/info')).status_code == status.HTTP_403_FORBIDDEN + assert (await writer.get('/api/tasks')).status_code == status.HTTP_403_FORBIDDEN + assert (await writer.get(f'/api/tasks/{task_id}')).status_code == status.HTTP_403_FORBIDDEN + assert ( + await writer.put(f'/api/tasks/{task_id}', json={'title': '仅写身份更新'}) + ).status_code == status.HTTP_200_OK + async with _client(task_host, frozenset({'task_demo:view'})) as reader: + assert (await reader.get(f'/api/tasks/{task_id}')).status_code == status.HTTP_200_OK + assert ( + await reader.put(f'/api/tasks/{task_id}', json={'title': '禁止更新'}) + ).status_code == status.HTTP_403_FORBIDDEN + assert (await reader.delete(f'/api/tasks/{task_id}')).status_code == status.HTTP_403_FORBIDDEN + assert (await reader.get(f'/api/tasks/{task_id}')).json()['title'] == '仅写身份更新' + async with _client(task_host, frozenset()) as anonymous: + assert (await anonymous.get('/api/info')).status_code == status.HTTP_403_FORBIDDEN + app = create_plugin(task_host).app_factory(task_host) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://task.test') as client: + assert (await client.get('/api/info')).status_code == status.HTTP_401_UNAUTHORIZED + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'payload', + [ + {}, + {'title': ' \t\n'}, + {'title': 'a' * 121}, + {'title': '有效', 'description': 'b' * 501}, + {'title': '有效', 'status': 'pending'}, + {'title': '有效', 'priority': 'urgent'}, + {'title': '有效', 'owner': 'admin'}, + {'title': '有效', 'description': '\x00'}, + {'title': None}, + ], +) +async def test_create_and_update_reject_invalid_fields(task_host: PluginHostContext, payload: dict[str, Any]) -> None: + """创建与更新共用输入边界,错误请求不产生数据写入。""" + async with _client(task_host) as client: + assert (await client.post('/api/tasks', json=payload)).status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + assert ( + await client.put(f'/api/tasks/{"0" * 32}', json=payload) + ).status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + assert (await client.get('/api/tasks')).json()['total'] == 0 + + +@pytest.mark.asyncio +async def test_list_has_stable_pagination_filters_and_bridge_size_bound(task_host: PluginHostContext) -> None: + """分页无重复且按状态筛选,最大 Unicode 数据页保持在 64 KiB 桥上限内。""" + context = _context(task_host) + total_tasks = 23 + completed_tasks = 3 + page_size = 20 + for index in range(total_tasks): + await service.create_task( + context, + TaskInput( + title='😀' * 120, description='😀' * 500, status='done' if index < completed_tasks else 'todo' + ).model_dump(), + ) + async with _client(task_host) as client: + first = await client.get('/api/tasks?page=1&pageSize=20') + second = await client.get('/api/tasks?page=2&pageSize=20') + assert first.json()['total'] == total_tasks + assert len(first.json()['items']) == page_size + assert len(second.json()['items']) == total_tasks - page_size + assert not {task['id'] for task in first.json()['items']} & {task['id'] for task in second.json()['items']} + assert len(first.content) < 64 * 1024 + filtered = (await client.get('/api/tasks?status=done')).json() + assert filtered['total'] == completed_tasks + assert all(task['status'] == 'done' for task in filtered['items']) + assert (await client.get('/api/tasks?page=99')).json()['items'] == [] + for query in ('page=0', 'pageSize=21', 'pageSize=0', 'status=bad', 'page=x', 'page=1&page=2', 'extra=x'): + assert (await client.get(f'/api/tasks?{query}')).status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + assert (await client.get('/api/tasks/not-an-id')).status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + + +@pytest.mark.asyncio +async def test_exception_after_insert_rolls_back(task_host: PluginHostContext, monkeypatch: pytest.MonkeyPatch) -> None: + """即使 SQL 已写入,响应构造失败也会回滚该次事务。""" + + def broken_payload(row: Any) -> dict[str, Any]: + raise RuntimeError('response construction failed') + + monkeypatch.setattr(service, '_task_payload', broken_payload) + with pytest.raises(RuntimeError, match='response construction failed'): + await service.create_task(_context(task_host), TaskInput(title='必须回滚').model_dump()) + async with task_host.session_factory() as db: + assert await db.scalar(select(func.count()).select_from(tasks)) == 0 + + +@pytest.mark.asyncio +async def test_cancel_after_insert_rolls_back_and_closes_owned_session(task_host: PluginHostContext) -> None: + """取消发生在数据库写入之后时,显式事务回滚且下次写入仍可成功。""" + inserted = asyncio.Event() + closed = asyncio.Event() + + class PausedSession(AsyncSession): + async def execute(self, statement: Any, *args: Any, **kwargs: Any) -> Any: + result = await super().execute(statement, *args, **kwargs) + if getattr(statement, 'is_insert', False): + inserted.set() + await asyncio.Event().wait() + return result + + async def close(self) -> None: + await super().close() + closed.set() + + paused_host = replace( + task_host, + session_factory=async_sessionmaker(task_host.session_factory.kw['bind'], class_=PausedSession), + ) + operation = asyncio.create_task( + service.create_task(_context(paused_host), TaskInput(title='取消任务').model_dump()) + ) + await asyncio.wait_for(inserted.wait(), timeout=5) + operation.cancel() + with pytest.raises(asyncio.CancelledError): + await operation + await asyncio.wait_for(closed.wait(), timeout=5) + async with task_host.session_factory() as db: + assert await db.scalar(select(func.count()).select_from(tasks)) == 0 + created = await service.create_task(_context(task_host), TaskInput(title='回滚后新任务').model_dump()) + assert created['title'] == '回滚后新任务' + + +class MemoryMigrationHistory(PluginMigrationHistoryStore): + """保留各次调用结果的测试历史存储,数据库 DDL 仍在真实 SQLite 执行。""" + + def __init__(self) -> None: + self.records: dict[tuple[str, str], PluginMigrationHistoryRecord] = {} + self.successes: list[tuple[str, str, str]] = [] + + async def get_record( + self, query_db: Any, plugin_id: str, migration_path: str + ) -> PluginMigrationHistoryRecord | None: + return self.records.get((plugin_id, migration_path)) + + async def record_running( + self, query_db: Any, plugin_id: str, migration_path: str, checksum: str, version: str, statement_count: int + ) -> None: + self.records[plugin_id, migration_path] = PluginMigrationHistoryRecord(checksum=checksum, status='running') + + async def record_success( + self, query_db: Any, plugin_id: str, migration_path: str, checksum: str, version: str, statement_count: int + ) -> None: + self.records[plugin_id, migration_path] = PluginMigrationHistoryRecord(checksum=checksum) + self.successes.append((plugin_id, migration_path, version)) + + async def record_failure( + self, + query_db: Any, + plugin_id: str, + migration_path: str, + checksum: str, + version: str, + statement_count: int, + error_message: str, + ) -> None: + self.records[plugin_id, migration_path] = PluginMigrationHistoryRecord( + checksum=checksum, status='failed', error_message=error_message + ) + + +class SqliteMigrationSession(AsyncSession): + """仅在 SQL 执行边界适配 SQLite 时间类型,runner 校验的原始文件内容保持不变。""" + + async def execute(self, statement: Any, *args: Any, **kwargs: Any) -> Any: + if isinstance(statement, TextClause) and 'TIMESTAMP(3) WITH TIME ZONE' in statement.text: + statement = text(statement.text.replace('TIMESTAMP(3) WITH TIME ZONE', 'DATETIME')) + return await super().execute(statement, *args, **kwargs) + + +def test_time_columns_compile_to_repository_database_contract() -> None: + """基础类型按真实数据库方言编译为宿主要求的毫秒 UTC 存储类型。""" + for name in ('created_at', 'updated_at'): + column = tasks.c[name] + assert column.type.compile(dialect=mysql.dialect()) == 'DATETIME(3)' + assert column.type.compile(dialect=postgresql.dialect()) == 'TIMESTAMP(3) WITH TIME ZONE' + + +@pytest.mark.asyncio +async def test_migration_upgrade_preserves_old_rows_and_history_skips_replay( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """通过实际 runner 在 SQLite 适配时间类型后执行迁移,覆盖旧行保留和历史幂等。""" + monkeypatch.setattr(DataBaseConfig.default_source, 'db_type', 'postgresql') + current = PluginScanner(PLUGIN_ROOT.parent).load_manifest(PLUGIN_ROOT / 'plugin.yaml') + legacy_manifest = current.manifest.model_copy(deep=True) + legacy_manifest.version = '1.0.0' + legacy_manifest.backend.migrations = [path for path in legacy_manifest.backend.migrations if '/001_' in path] + legacy = replace(current, manifest=legacy_manifest) + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "upgrade.db"}') + sessions = async_sessionmaker(engine, class_=SqliteMigrationSession, expire_on_commit=False) + history = MemoryMigrationHistory() + health_context = PluginHealthContext(plugin_id='task_demo', discovered_plugin=current) + try: + assert (await health(health_context))['status'] == 'unavailable' + host = PluginHostContext(plugin_id='task_demo', resource_root=PLUGIN_ROOT, session_factory=sessions) + create_plugin(host) + assert (await health(health_context))['status'] == 'unhealthy' + async with sessions() as db: + assert (await health(replace(health_context, query_db=db)))['ok'] is False + await db.rollback() + original = await PluginMigrationRunner(legacy, history, manage_execution_transaction=True).run(db) + assert len(original) == 1 and not original[0].skipped + await db.execute( + text( + 'INSERT INTO ruoyi_plugin_task_demo ' + '(id, title, description, status, created_at, updated_at) ' + "VALUES (:id, '旧任务', '保留描述', 'done', '2026-01-01 00:00:00', '2026-01-02 00:00:00')" + ), + {'id': 'a' * 32}, + ) + await db.commit() + assert (await health(health_context))['ok'] is False + assert (await health(replace(health_context, query_db=db)))['ok'] is False + await db.rollback() + upgrade = await PluginMigrationRunner(current, history, manage_execution_transaction=True).run(db) + assert [result.skipped for result in upgrade] == [True, False] + assert (await health(replace(health_context, query_db=db)))['ok'] is True + row = (await db.execute(select(tasks))).mappings().one() + assert (row['title'], row['description'], row['status'], row['priority']) == ( + '旧任务', + '保留描述', + 'done', + 'normal', + ) + assert row['created_at'].isoformat() == '2026-01-01T00:00:00+00:00' + repeat = await PluginMigrationRunner(current, history, manage_execution_transaction=True).run(db) + assert all(result.skipped for result in repeat) + expected_migrations = 2 + assert len(history.successes) == expected_migrations + assert (await health(health_context))['ok'] is True + + def unavailable_sessions() -> None: + raise AssertionError('query_db 应优先于插件保存的会话工厂') + + monkeypatch.setattr(task_demo._runtime, 'session_factory', unavailable_sessions) + async with sessions() as db: + assert (await health(replace(health_context, query_db=db)))['ok'] is True + async with _client(host) as client: + legacy_task = (await client.get(f'/api/tasks/{"a" * 32}')).json() + assert legacy_task['priority'] == 'normal' + assert legacy_task['title'] == '旧任务' + assert legacy_task['createdAt'] == '2026-01-01T00:00:00.000Z' + finally: + await engine.dispose() diff --git a/ruoyi-fastapi-backend/tests/plugins/integration/network_bundle.html b/ruoyi-fastapi-backend/tests/plugins/integration/network_bundle.html new file mode 100644 index 0000000..911536a --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/integration/network_bundle.html @@ -0,0 +1,60 @@ + + + + + 插件网络验收夹具 + + +

    等待宿主

    + + + + +
      + + + diff --git a/ruoyi-fastapi-backend/tests/plugins/integration/test_harness_safety.py b/ruoyi-fastapi-backend/tests/plugins/integration/test_harness_safety.py new file mode 100644 index 0000000..2dacaca --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/integration/test_harness_safety.py @@ -0,0 +1,488 @@ +import asyncio +import json +import os +import threading +from collections.abc import AsyncGenerator, AsyncIterator +from contextlib import asynccontextmanager +from fnmatch import fnmatchcase +from importlib import import_module +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, Mock + +import pytest + +from scripts import plugin_release_integration as harness + + +@pytest.fixture +def run_spec(tmp_path: Path) -> harness.RunSpec: + return harness.RunSpec('mysql', '0123456789abcdef' * 2, str(tmp_path)) + + +@pytest.fixture +def built_task_bundle(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """ + 为资源清理单测提供独立的构建入口,避免依赖工作区中的忽略产物。 + + :param tmp_path: pytest 临时目录 + :param monkeypatch: 测试内属性替换工具 + :return: None + """ + backend = tmp_path / 'backend' + entry = backend / 'plugins' / 'examples' / 'python' / 'task_demo' / 'web' / 'dist' / 'index.html' + entry.parent.mkdir(parents=True) + entry.write_text('', encoding='utf-8') + monkeypatch.setattr(harness, 'BACKEND_ROOT', backend) + + +@pytest.mark.parametrize('engine', ['mysql', 'postgresql']) +def test_main_without_opt_in_never_opens_database_or_redis( + engine: str, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.delenv(harness.OPT_IN, raising=False) + database_engine = Mock(side_effect=AssertionError('Database access is forbidden')) + redis_client = Mock(side_effect=AssertionError('Redis access is forbidden')) + monkeypatch.setattr(harness, 'database_engine', database_engine) + monkeypatch.setattr(harness, 'redis_client', redis_client) + + with pytest.raises(ValueError, match='disabled'): + harness.require_opt_in(engine) + assert harness.main(['--engine', engine]) == 1 + database_engine.assert_not_called() + redis_client.assert_not_called() + assert 'Real-service tests are disabled' in capsys.readouterr().out + + +@pytest.mark.parametrize( + 'engine,password', [('mysql', 'RUOYI_IT_MYSQL_PASSWORD'), ('postgresql', 'RUOYI_IT_PG_PASSWORD')] +) +def test_opt_in_requires_explicit_password_environment( + engine: str, password: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv(harness.OPT_IN, '1') + monkeypatch.delenv(password, raising=False) + monkeypatch.delenv('RUOYI_IT_REDIS_PASSWORD', raising=False) + with pytest.raises(ValueError, match=password): + harness.require_opt_in(engine) + monkeypatch.setenv(password, '') + with pytest.raises(ValueError, match='RUOYI_IT_REDIS_PASSWORD'): + harness.require_opt_in(engine) + monkeypatch.setenv('RUOYI_IT_REDIS_PASSWORD', '') + harness.require_opt_in(engine) + + +@pytest.mark.parametrize('engine', ['mysql', 'postgresql']) +def test_cli_wait_environment_targets_only_the_isolated_database_without_mutating_parent( + engine: str, run_spec: harness.RunSpec, monkeypatch: pytest.MonkeyPatch +) -> None: + spec = harness.RunSpec(engine, run_spec.run_id, run_spec.root) + prefix = 'RUOYI_IT_MYSQL' if engine == 'mysql' else 'RUOYI_IT_PG' + monkeypatch.setenv(harness.OPT_IN, '1') + monkeypatch.setenv(f'{prefix}_PASSWORD', 'integration-only-test-secret') + monkeypatch.setenv('RUOYI_IT_REDIS_PASSWORD', '') + monkeypatch.setenv('DB_SOURCES', '{"foreign": {}}') + monkeypatch.setenv('APP_ENV', 'parent-config-must-remain') + parent = os.environ.copy() + + child = harness.cli_wait_environment(spec) + + assert dict(os.environ) == parent + assert child['APP_ENV'] == f'plugin_it_{spec.run_id}' + sources = json.loads(child['DB_SOURCES']) + assert set(sources) == {'primary'} + assert sources['primary']['db_database'] == spec.database + assert sources['primary']['db_type'] == engine + assert sources['primary']['db_password'] == 'integration-only-test-secret' + assert sources['primary']['db_echo'] is False + assert child['PLUGIN_ARTIFACT_STORE'] == str(Path(spec.root) / 'store') + assert child['PLUGIN_ARTIFACT_TRUST_FILE'] == str(Path(spec.root) / 'trusted.json') + assert child['LOG_FILE_ENABLED'] == 'false' + + +@pytest.mark.parametrize( + 'run_id', + ['', 'a' * 31, 'a' * 33, 'A' * 32, 'g' * 32, '../production', 'a' * 31 + ';', 'a' * 31 + "'", 'a' * 32 + '\n'], +) +def test_run_spec_rejects_unsafe_database_and_redis_identity(run_id: str, tmp_path: Path) -> None: + with pytest.raises(ValueError, match='identity'): + harness.RunSpec('mysql', run_id, str(tmp_path)).validate() + + +def test_run_spec_rejects_other_engine_or_relative_root(tmp_path: Path, run_spec: harness.RunSpec) -> None: + with pytest.raises(ValueError, match='identity'): + harness.RunSpec('sqlite', run_spec.run_id, str(tmp_path)).validate() + with pytest.raises(ValueError, match='absolute'): + harness.RunSpec('mysql', run_spec.run_id, 'relative-output').validate() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'engine,initial_filename', [('mysql', 'ruoyi-fastapi.sql'), ('postgresql', 'ruoyi-fastapi-pg.sql')] +) +async def test_prepare_schema_uses_initial_sql_without_drop_or_seed_data( + engine: str, initial_filename: str, run_spec: harness.RunSpec +) -> None: + spec = harness.RunSpec(engine, run_spec.run_id, run_spec.root) + connection = SimpleNamespace(exec_driver_sql=AsyncMock(), execute=AsyncMock(return_value=SimpleNamespace(all=list))) + + @asynccontextmanager + async def begin() -> AsyncGenerator[Any, None]: + yield connection + + await harness.prepare_schema(spec, SimpleNamespace(begin=begin)) + + statements = [call.args[0] for call in connection.exec_driver_sql.await_args_list] + base_tables = ( + 'sys_menu', + 'sys_job', + 'sys_plugin', + 'sys_plugin_menu', + 'sys_plugin_migration', + 'sys_plugin_config', + 'sys_plugin_operation_log', + ) + release_tables = ('sys_plugin_artifact', 'sys_plugin_release', 'sys_plugin_worker') + expected_indexes = ( + [ + 'create index if not exists idx_sys_plugin_artifact_plugin on sys_plugin_artifact (plugin_id, version)', + 'create index if not exists idx_sys_plugin_worker_plugin on sys_plugin_worker (plugin_id, heartbeat_time)', + ] + if engine == 'postgresql' + else [] + ) + initial_sql = (harness.BACKEND_ROOT / 'sql' / initial_filename).read_text('utf-8') + expected_count = len(base_tables) + len(release_tables) + len(expected_indexes) + assert len(statements) == expected_count + for table_name, statement in zip(base_tables, statements[: len(base_tables)], strict=True): + assert statement.lower().startswith(f'create table {table_name} (') + release_statements = statements[len(base_tables) : len(base_tables) + len(release_tables)] + for table_name, statement in zip(release_tables, release_statements, strict=True): + assert statement.lower().startswith(f'create table if not exists {table_name} (') + assert statements[len(base_tables) + len(release_tables) :] == expected_indexes + for statement in statements: + assert statement in initial_sql + assert "default ''''''" not in statements[0] + checked_tables = {call.args[0].get_final_froms()[0].name for call in connection.execute.await_args_list} + assert checked_tables == {'sys_plugin_artifact', 'sys_plugin_release', 'sys_plugin_worker'} + + +@pytest.mark.asyncio +@pytest.mark.parametrize('invalid_schema', ['missing', 'duplicate']) +@pytest.mark.parametrize('table_name', ['sys_menu', 'sys_plugin_release']) +async def test_prepare_schema_rejects_incomplete_or_duplicate_host_ddl_before_execution( + invalid_schema: str, table_name: str, run_spec: harness.RunSpec, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + initial_sql = (harness.BACKEND_ROOT / 'sql' / 'ruoyi-fastapi.sql').read_text('utf-8') + if invalid_schema == 'missing': + initial_sql = initial_sql.replace(f'{table_name} (', f'unused_{table_name} (') + else: + initial_sql += f'\ncreate table if not exists {table_name} (id bigint);' + sql_root = tmp_path / 'sql' + sql_root.mkdir() + (sql_root / 'ruoyi-fastapi.sql').write_text(initial_sql, encoding='utf-8') + monkeypatch.setattr(harness, 'BACKEND_ROOT', tmp_path) + engine = SimpleNamespace(begin=Mock(side_effect=AssertionError('DDL must not run'))) + + with pytest.raises(AssertionError, match=r'Incomplete host table DDL|Duplicate host table DDL'): + await harness.prepare_schema(run_spec, engine) + + engine.begin.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('engine,quote', [('mysql', '`'), ('postgresql', '"')]) +async def test_database_ddl_only_addresses_validated_run_database( + engine: str, quote: str, run_spec: harness.RunSpec +) -> None: + spec = harness.RunSpec(engine, run_spec.run_id, run_spec.root) + connection = SimpleNamespace(exec_driver_sql=AsyncMock()) + + @asynccontextmanager + async def connect() -> AsyncGenerator[Any, None]: + yield connection + + admin = SimpleNamespace(connect=Mock(side_effect=connect)) + await harness.create_isolated_database(spec, admin) + await harness.drop_isolated_database(spec, admin) + statements = [call.args[0] for call in connection.exec_driver_sql.await_args_list] + assert statements[0].startswith(f'CREATE DATABASE {quote}{spec.database}{quote}') + assert 'IF NOT EXISTS' not in statements[0].upper() + assert statements[1] == f'DROP DATABASE {quote}{spec.database}{quote}' + + unsafe = harness.RunSpec(engine, 'production', spec.root) + for operation in (harness.create_isolated_database, harness.drop_isolated_database): + with pytest.raises(ValueError, match='identity'): + await operation(unsafe, admin) + expected_ddl_connection_count = 2 + assert admin.connect.call_count == expected_ddl_connection_count + + +@pytest.mark.asyncio +@pytest.mark.parametrize('created', [False, True]) +@pytest.mark.usefixtures('built_task_bundle') +async def test_run_drops_database_only_after_successful_create( + created: bool, run_spec: harness.RunSpec, monkeypatch: pytest.MonkeyPatch +) -> None: + admin = SimpleNamespace(dispose=AsyncMock()) + engine = SimpleNamespace(dispose=AsyncMock()) + factory = Mock(side_effect=[admin, engine]) + create = AsyncMock(side_effect=None if created else RuntimeError('database already exists')) + schema = AsyncMock(side_effect=RuntimeError('stop after successful create')) + drop = AsyncMock() + monkeypatch.setattr(harness, 'configure_process', Mock()) + monkeypatch.setattr(harness, 'database_engine', factory) + monkeypatch.setattr(harness, 'create_isolated_database', create) + monkeypatch.setattr(harness, 'prepare_schema', schema) + monkeypatch.setattr(harness, 'clear_own_redis_keys', AsyncMock(return_value=0)) + monkeypatch.setattr(harness, 'drop_isolated_database', drop) + monkeypatch.setattr(harness, 'emit', Mock()) + + with pytest.raises(RuntimeError, match='stop after successful create' if created else 'database already exists'): + await harness.run(run_spec) + + admin.dispose.assert_awaited_once() + if created: + drop.assert_awaited_once_with(run_spec, admin) + engine.dispose.assert_awaited_once() + expected_engine_count = 2 + assert factory.call_count == expected_engine_count + else: + drop.assert_not_awaited() + schema.assert_not_awaited() + engine.dispose.assert_not_awaited() + assert factory.call_count == 1 + + +@pytest.mark.asyncio +@pytest.mark.usefixtures('built_task_bundle') +async def test_run_continues_owned_cleanup_after_engine_dispose_error( + run_spec: harness.RunSpec, monkeypatch: pytest.MonkeyPatch +) -> None: + admin = SimpleNamespace(dispose=AsyncMock()) + engine = SimpleNamespace(dispose=AsyncMock(side_effect=RuntimeError('engine disposal failed'))) + clear = AsyncMock(return_value=0) + drop = AsyncMock() + monkeypatch.setattr(harness, 'configure_process', Mock()) + monkeypatch.setattr(harness, 'database_engine', Mock(side_effect=[admin, engine])) + monkeypatch.setattr(harness, 'create_isolated_database', AsyncMock()) + monkeypatch.setattr(harness, 'prepare_schema', AsyncMock()) + monkeypatch.setattr(harness, 'exercise_release', AsyncMock()) + monkeypatch.setattr(harness, 'wire_runtime', Mock(return_value=(object(), object()))) + monkeypatch.setattr(import_module('plugins.core.deployment.service'), 'PluginDeploymentService', Mock()) + monkeypatch.setattr( + import_module('scripts.plugin_task_integration'), 'exercise_task_delivery', AsyncMock(return_value={}) + ) + monkeypatch.setattr(harness, 'clear_own_redis_keys', clear) + monkeypatch.setattr(harness, 'drop_isolated_database', drop) + monkeypatch.setattr(harness, 'emit', Mock()) + + with pytest.raises(RuntimeError, match='engine disposal failed'): + await harness.run(run_spec) + + clear.assert_awaited_once_with(run_spec) + drop.assert_awaited_once_with(run_spec, admin) + admin.dispose.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_redis_cleanup_scans_and_deletes_only_its_run_prefix( + run_spec: harness.RunSpec, monkeypatch: pytest.MonkeyPatch +) -> None: + own_keys = {f'{run_spec.redis_prefix}ready:generation', f'{run_spec.redis_prefix}lifecycle:global'} + foreign_keys = {'plugin:lifecycle:lock:global', f'ruoyi_plugin_it:{"f" * 32}:ready:generation'} + keys = own_keys | foreign_keys + scans = [] + + async def scan_iter(**kwargs: Any) -> AsyncIterator[str]: + scans.append(kwargs['match']) + for key in sorted(keys): + if fnmatchcase(key, kwargs['match']): + yield key + + async def delete(key: str) -> int: + keys.remove(key) + return 1 + + client = SimpleNamespace(scan_iter=scan_iter, delete=AsyncMock(side_effect=delete), aclose=AsyncMock()) + monkeypatch.setattr(harness, 'redis_client', Mock(return_value=client)) + + assert await harness.clear_own_redis_keys(run_spec) == len(own_keys) + assert keys == foreign_keys + assert scans == [f'{run_spec.redis_prefix}*', f'{run_spec.redis_prefix}*'] + assert {call.args[0] for call in client.delete.await_args_list} == own_keys + client.aclose.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_redis_cleanup_refuses_foreign_key_even_if_scan_returns_it( + run_spec: harness.RunSpec, monkeypatch: pytest.MonkeyPatch +) -> None: + async def scan_iter(**kwargs: Any) -> AsyncIterator[str]: + yield 'plugin:lifecycle:lock:global' + + client = SimpleNamespace(scan_iter=scan_iter, delete=AsyncMock(), aclose=AsyncMock()) + monkeypatch.setattr(harness, 'redis_client', Mock(return_value=client)) + with pytest.raises(AssertionError, match='outside this run prefix'): + await harness.clear_own_redis_keys(run_spec) + client.delete.assert_not_awaited() + client.aclose.assert_awaited_once() + + +def test_safe_error_redacts_explicit_credentials_and_bounds_output(monkeypatch: pytest.MonkeyPatch) -> None: + password = 'db-password-unique-92ab' + secret = 'redis-secret-unique-57ef' + monkeypatch.setenv('RUOYI_IT_MYSQL_PASSWORD', password) + monkeypatch.setenv('RUOYI_IT_REDIS_PASSWORD', secret) + result = harness.safe_error(RuntimeError(f'failed auth {password}; {secret}; ' + 'x' * 7000)) + assert password not in result + assert secret not in result + assert '' in result + assert result.startswith('RuntimeError:') + max_error_length = 6000 + assert len(result) <= max_error_length + + +def test_workers_pass_independent_stop_events_to_spawned_processes( + run_spec: harness.RunSpec, monkeypatch: pytest.MonkeyPatch +) -> None: + events = [SimpleNamespace(set=Mock()), SimpleNamespace(set=Mock())] + queue = SimpleNamespace(close=Mock(), join_thread=Mock()) + context = SimpleNamespace( + Queue=Mock(return_value=queue), + Event=Mock(side_effect=events), + Process=Mock(side_effect=[Mock(), Mock()]), + ) + get_context = Mock(return_value=context) + monkeypatch.setattr(harness.multiprocessing, 'get_context', get_context) + + workers = harness.Workers(run_spec, one_fails=True) + + get_context.assert_called_once_with('spawn') + assert context.Event.call_count == len(events) + first_args, second_args = [call.kwargs['args'] for call in context.Process.call_args_list] + assert first_args[-1] is events[0] + assert second_args[-1] is events[1] + assert first_args[-1] is not second_args[-1] + assert workers.stops == events + assert first_args[-2] == {'launch': workers.launch, 'fail': False} + assert second_args[-2] == {'launch': workers.launch, 'fail': True} + + +@pytest.mark.asyncio +@pytest.mark.parametrize('second_state', ['killed', 'dead']) +async def test_workers_close_skips_killed_or_dead_stop_event_but_stops_healthy_worker(second_state: str) -> None: + healthy_stop = SimpleNamespace(set=Mock()) + unusable_stop = SimpleNamespace(set=Mock(side_effect=AssertionError('Terminated waiter poisoned this event'))) + healthy = SimpleNamespace(pid=101, exitcode=0, is_alive=Mock(return_value=True), terminate=Mock()) + second = SimpleNamespace( + pid=102, + exitcode=-15 if second_state == 'killed' else 0, + is_alive=Mock(return_value=second_state == 'killed'), + terminate=Mock(), + ) + + def join_healthy(timeout: float) -> None: + healthy_stop.set.assert_called_once() + healthy.is_alive.return_value = False + + def join_second(timeout: float) -> None: + second.is_alive.return_value = False + + healthy.join = Mock(side_effect=join_healthy) + second.join = Mock(side_effect=join_second) + workers = harness.Workers.__new__(harness.Workers) + workers.stops = [healthy_stop, unusable_stop] + workers.processes = [healthy, second] + workers.killed = {1} if second_state == 'killed' else set() + workers.queue = SimpleNamespace(close=Mock(), join_thread=Mock()) + + await workers.close() + + healthy_stop.set.assert_called_once() + unusable_stop.set.assert_not_called() + healthy.join.assert_called_once() + second.join.assert_called_once() + healthy.terminate.assert_not_called() + second.terminate.assert_not_called() + workers.queue.close.assert_called_once() + workers.queue.join_thread.assert_called_once() + + +def _wait_for_isolated_stop(stop: Any, ready: Any, index: int) -> None: + """子进程入口不导入应用模块,也不访问外部服务。""" + ready.put(index) + if not stop.wait(30): + raise RuntimeError('Offline event waiter exceeded its safety timeout') + + +def test_real_spawn_terminated_waiter_does_not_block_other_worker_shutdown() -> None: + context = harness.multiprocessing.get_context('spawn') + workers = harness.Workers.__new__(harness.Workers) + workers.stops = [context.Event(), context.Event()] + workers.queue = context.Queue() + workers.killed = set() + workers.processes = [ + context.Process(target=_wait_for_isolated_stop, args=(workers.stops[index], workers.queue, index)) + for index in range(2) + ] + failures: list[BaseException] = [] + + async def stop_group() -> None: + await workers.kill_failed_worker() + await workers.close() + + def close_in_guarded_thread() -> None: + try: + asyncio.run(stop_group()) + except BaseException as exc: + failures.append(exc) + + # Event.set() 回归时会同步阻塞,无法依靠 asyncio 超时中断。 + # 保持测试线程可用,确保能终止两个子进程。 + closer = threading.Thread(target=close_in_guarded_thread, daemon=True) + try: + for process in workers.processes: + process.start() + assert {workers.queue.get(timeout=15) for _ in workers.processes} == {0, 1} + closer.start() + closer.join(timeout=15) + assert not closer.is_alive(), 'Worker shutdown blocked on a terminated process event' + assert not failures, [repr(error) for error in failures] + assert workers.killed == {1} + assert workers.processes[0].exitcode == 0 + assert workers.processes[1].exitcode not in (None, 0) + assert not any(process.is_alive() for process in workers.processes) + finally: + for process in workers.processes: + if process.pid is None: + continue + if process.is_alive(): + process.terminate() + process.join(timeout=5) + if process.is_alive(): + process.kill() + process.join(timeout=5) + workers.queue.close() + workers.queue.join_thread() + + +@pytest.mark.asyncio +async def test_run_requires_built_bundle_before_opening_any_resource( + tmp_path: Path, run_spec: harness.RunSpec, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(harness, 'BACKEND_ROOT', tmp_path / 'unbuilt-backend') + configure = Mock(side_effect=AssertionError('Configuration must remain untouched')) + factory = Mock(side_effect=AssertionError('Database access is forbidden')) + redis = Mock(side_effect=AssertionError('Redis access is forbidden')) + monkeypatch.setattr(harness, 'configure_process', configure) + monkeypatch.setattr(harness, 'database_engine', factory) + monkeypatch.setattr(harness, 'redis_client', redis) + + with pytest.raises(ValueError, match='web/dist'): + await harness.run(run_spec) + + configure.assert_not_called() + factory.assert_not_called() + redis.assert_not_called() diff --git a/ruoyi-fastapi-backend/tests/plugins/integration/test_network_smoke.py b/ruoyi-fastapi-backend/tests/plugins/integration/test_network_smoke.py new file mode 100644 index 0000000..5c15f98 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/integration/test_network_smoke.py @@ -0,0 +1,416 @@ +import asyncio +import ipaddress +import json +import os +import shutil +import socket +import subprocess +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager, suppress +from datetime import datetime, timedelta, timezone +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import httpx +import pytest +import uvicorn +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID +from fastapi import WebSocket +from starlette import status + +from plugins.examples.python.bundle_demo.events import register_event_routes +from tests.plugins.core.runtime.test_browser_session import browser # noqa: F401 + +if os.environ.get('RUOYI_PLUGIN_NETWORK_SMOKE') == '1': + from playwright.async_api import async_playwright, expect + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.skipif(os.environ.get('RUOYI_PLUGIN_NETWORK_SMOKE') != '1', reason='显式启用插件网络验收'), +] +FRONTEND = Path(__file__).resolve().parents[4] / 'ruoyi-fastapi-frontend' +PREFIX = '/gateway/apps/browser_test/' + + +async def wait_until(predicate: Callable[[], bool], timeout: float = 15) -> None: + """ + 有界等待服务器启动或连接回收,不使用固定长睡眠。 + + :param predicate: 待满足的状态条件 + :param timeout: 最长等待秒数 + :return: None + """ + deadline = asyncio.get_running_loop().time() + timeout + while not predicate(): + if asyncio.get_running_loop().time() >= deadline: + raise TimeoutError('网络验收等待状态超时') + await asyncio.sleep(0.05) + + +def certificate_files(directory: Path) -> tuple[Path, Path]: + """ + 为本次 loopback HTTPS 代理生成临时证书,不写入系统信任库。 + + :param directory: 隔离测试目录 + :return: PEM 证书和私钥路径 + """ + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'localhost')]) + now = datetime.now(timezone.utc) + certificate = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(subject) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(minutes=1)) + .not_valid_after(now + timedelta(days=1)) + .add_extension(x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address('127.0.0.1'))]), critical=False) + .sign(key, hashes.SHA256()) + ) + cert_path, key_path = directory / 'certificate.pem', directory / 'key.pem' + cert_path.write_bytes(certificate.public_bytes(serialization.Encoding.PEM)) + key_path.write_bytes( + key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()) + ) + return cert_path, key_path + + +def prepare_network_host(fixture: SimpleNamespace) -> None: + """ + 为现有隔离鉴权夹具添加宿主导航与真实插件流端点。 + + :param fixture: 使用生产门禁及会话协议的隔离运行时 + :return: None + """ + app = fixture.app + loaded = fixture.runtime.loaded['browser_test'] + loaded.gateway.connections.recheck_interval = 0.1 + register_event_routes(loaded.lifespan.app, permission='browser_test:view', interval=1.0) + bundle = fixture.plugin.backend_path / 'web' / 'dist' + shutil.copyfile(Path(__file__).with_name('network_bundle.html'), bundle / 'index.html') + shutil.copyfile(FRONTEND / 'src/utils/pluginBridge.js', bundle / 'assets/bridge.js') + + @loaded.lifespan.app.websocket('/ws/events') + async def events(websocket: WebSocket) -> None: + websocket.state.plugin_context.require_permission('browser_test:view') + await websocket.accept() + await websocket.send_text('connected') + await websocket.receive_text() + + @app.get('/getInfo') + async def info() -> dict[str, Any]: + return { + 'code': 200, + 'user': {'userId': 7, 'userName': 'network-smoke', 'nickName': '验收账号', 'timeZone': 'Asia/Shanghai'}, + 'roles': ['tester'], + 'permissions': ['browser_test:view'], + 'appTimezone': 'Asia/Shanghai', + } + + @app.get('/getRouters') + async def routers() -> dict[str, Any]: + return { + 'code': 200, + 'data': [ + { + 'path': '/network', + 'component': 'Layout', + 'children': [ + { + 'path': 'plugin', + 'component': 'PluginFrame', + 'name': 'NetworkPlugin', + 'meta': {'title': '插件网络验收', 'pluginId': 'browser_test'}, + } + ], + } + ], + } + + @app.get('/transport/crypto/frontend-config') + async def crypto_policy() -> dict[str, Any]: + return { + 'code': 200, + 'data': { + 'transportCryptoEnabled': False, + 'transportCryptoMode': 'off', + 'transportCryptoActive': False, + 'configExpireAt': 2100000000, + }, + } + + @app.get('/system/config/configKey/{key}') + async def config(key: str) -> dict[str, Any]: + return {'code': 200, 'msg': '30'} + + @app.get('/system/notice/listTop') + async def notices() -> dict[str, Any]: + return {'code': 200, 'data': []} + + +@asynccontextmanager +async def network_servers(fixture: SimpleNamespace, directory: Path, output: Path) -> AsyncIterator[str]: + """ + 启动真实 Uvicorn socket 与 TLS Vite 子进程,并在失败时一并回收。 + + :param fixture: 隔离宿主应用 + :param directory: 证书及就绪文件目录 + :param output: 诊断日志目录 + :return: 代理 HTTPS 来源地址 + """ + cert_path, key_path = certificate_files(directory) + server = uvicorn.Server( + uvicorn.Config( + fixture.app, + host='127.0.0.1', + root_path='/gateway', + lifespan='off', + log_level='warning', + access_log=False, + proxy_headers=True, + forwarded_allow_ips='127.0.0.1', + timeout_graceful_shutdown=3, + ) + ) + # 本场景验证 runtime drain 后 Uvicorn 正常退出,不发送进程信号。 + with socket.socket() as listener, (output / 'vite.log').open('w', encoding='utf-8') as log: + listener.bind(('127.0.0.1', 0)) + listener.listen() + serving = asyncio.create_task(server.serve(sockets=[listener])) + process = None + try: + await wait_until(lambda: server.started or serving.done()) + assert server.started, 'Uvicorn 未能启动' + ready = directory / 'vite-ready.json' + process = await asyncio.create_subprocess_exec( + shutil.which('node') or 'node', + str(FRONTEND / 'tests/plugins/network-smoke/server.mjs'), + f'http://127.0.0.1:{listener.getsockname()[1]}', + str(cert_path), + str(key_path), + str(ready), + cwd=FRONTEND, + stdin=subprocess.PIPE, + stdout=log, + stderr=subprocess.STDOUT, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0, + ) + await wait_until(lambda: ready.exists() or process.returncode is not None, timeout=60) + assert ready.exists(), f'Vite 启动失败,见 {output / "vite.log"}' + yield f'https://127.0.0.1:{json.loads(ready.read_text())["port"]}' + finally: + try: + await fixture.runtime.shutdown() + finally: + server.should_exit = True + try: + await asyncio.wait_for(serving, timeout=10) + finally: + if process is not None: + if process.returncode is None: + try: + await asyncio.wait_for(process.communicate(input=b'stop\n'), timeout=10) + except TimeoutError: + process.kill() + await asyncio.wait_for(process.wait(), timeout=5) + assert process.returncode == 0, 'Vite 代理未正常退出' + + +async def open_socket(page: Any, origin: str) -> None: + """ + 由浏览器通过真实代理携带限定路径 Cookie 握手 WebSocket。 + + :param page: 宿主浏览器页面 + :param origin: HTTPS 代理来源 + :return: None + """ + await page.evaluate( + """url => new Promise((resolve, reject) => { + window.socketCode = null; + window.smokeSocket = new WebSocket(url); + const timer = setTimeout(() => { + window.smokeSocket.close(); + reject(new Error('WebSocket greeting timed out')); + }, 10000); + window.smokeSocket.onmessage = () => { clearTimeout(timer); resolve(); }; + window.smokeSocket.onerror = () => { + clearTimeout(timer); + reject(new Error('WebSocket handshake failed')); + }; + window.smokeSocket.onclose = event => { + clearTimeout(timer); + window.socketCode = event.code; + reject(new Error('WebSocket closed before greeting')); + }; + })""", + origin.replace('https:', 'wss:') + PREFIX + 'ws/events', + ) + + +async def verify_browser_session(context: Any, page: Any, origin: str) -> Any: + """ + 通过真实签发响应验证 iframe 会话、Cookie 边界和写请求来源检查。 + + :param context: 共享浏览器 Cookie 的隔离上下文 + :param page: 宿主浏览器页面 + :param origin: HTTPS 代理地址 + :return: 已完成宿主握手的插件 iframe 定位器 + """ + async with page.expect_response(origin + '/gateway/plugin/runtime/browser_test/session') as issuing: + await page.goto(origin + '/network/plugin') + issued = await issuing.value + assert issued.status == status.HTTP_200_OK + csrf = (await issued.json())['data']['csrfToken'] + frame = page.frame_locator('.plugin-frame iframe') + await expect(frame.locator('#ready')).to_have_text('宿主已连接', timeout=60000) + cookies = await context.cookies(origin + PREFIX) + cookie = next(item for item in cookies if item['name'] == 'Plugin-Session-browser_test') + assert cookie['httpOnly'] and cookie['secure'] and cookie['sameSite'] == 'Strict' + assert cookie['path'] == PREFIX + assert 'Plugin-Session-' not in await frame.locator('body').evaluate('element => element.ownerDocument.cookie') + await frame.locator('#echo').click() + await expect(frame.locator('#echo-result')).to_have_text('network') + for headers in ( + {'Origin': origin}, + {'Origin': origin, 'X-Plugin-CSRF': 'wrong'}, + {'Origin': 'https://other.example', 'X-Plugin-CSRF': csrf}, + ): + rejected = await context.request.post( + origin + PREFIX + 'api/echo', data={'message': 'blocked'}, headers=headers + ) + assert rejected.status == status.HTTP_403_FORBIDDEN + rejected = await context.request.get( + origin + '/dev-api/system/private', headers={'Cookie': f'{cookie["name"]}={cookie["value"]}'} + ) + assert rejected.status == status.HTTP_401_UNAUTHORIZED + return frame + + +async def start_browser_streams(page: Any, frame: Any, origin: str) -> None: + """ + 从新游标建立 SSE 和 WebSocket,为独立的关闭场景保留事件余量。 + + :param page: 宿主浏览器页面 + :param frame: 已连接的插件 iframe 定位器 + :param origin: HTTPS 代理地址 + :return: None + """ + await frame.locator('#reset').click() + await frame.locator('#start').click() + await expect(frame.locator('#stream-state')).to_have_text('接收中') + await open_socket(page, origin) + + +async def verify_browser_streams(fixture: SimpleNamespace, page: Any, frame: Any, origin: str) -> None: + """ + 验证真实流增量、取消、游标恢复,以及撤权、主会话失效和停机回收。 + + :param fixture: 隔离宿主运行时和可控账号状态 + :param page: 宿主浏览器页面 + :param frame: 已连接的插件 iframe 定位器 + :param origin: HTTPS 代理地址 + :return: None + """ + loaded = fixture.runtime.loaded['browser_test'] + connections = loaded.gateway.connections.connections + await frame.locator('#start').click() + await expect(frame.locator('#stream-state')).to_have_text('接收中') + assert connections, 'SSE 必须在网络响应仍未结束时增量到达' + await frame.locator('#stop').click() + await expect(frame.locator('#stream-state')).to_contain_text('取消') + await wait_until(lambda: not connections) + last_id = int(await frame.locator('#events li').last.inner_text()) + await frame.locator('#start').click() + await expect(frame.locator('#events li').first).to_have_text(str(last_id + 1)) + await frame.locator('#stop').click() + await expect(frame.locator('#stream-state')).to_contain_text('取消') + await wait_until(lambda: not connections) + await start_browser_streams(page, frame, origin) + live = fixture.runtime.diagnostic_snapshot().plugins[0] + assert live.ready and live.connections.sse == 1 and live.connections.websocket == 1 + fixture.user.permissions = [] + await expect(frame.locator('#stream-state')).to_contain_text('宿主关闭') + await page.wait_for_function('window.socketCode === 1008') + await wait_until(lambda: not connections) + revoked = fixture.runtime.diagnostic_snapshot().plugins[0] + assert revoked.connections.sse == 0 and revoked.connections.websocket == 0 + fixture.user.permissions = ['browser_test:view'] + await start_browser_streams(page, frame, origin) + fixture.redis.values.pop(fixture.main_key) + await expect(frame.locator('#stream-state')).to_contain_text('宿主关闭') + await page.wait_for_function('window.socketCode === 1008') + await wait_until(lambda: not connections) + await fixture.redis.set(fixture.main_key, fixture.token, ex=1800) + await start_browser_streams(page, frame, origin) + await fixture.runtime.shutdown() + await expect(frame.locator('#stream-state')).to_contain_text('宿主关闭') + await page.wait_for_function('window.socketCode === 1012') + assert not connections and not loaded.lifespan.has_pending_task + stopped = fixture.runtime.diagnostic_snapshot().plugins[0] + assert stopped.closing and not stopped.ready and not stopped.active + assert stopped.connections.sse == 0 and stopped.connections.websocket == 0 + assert stopped.pending_connection_tasks == 0 and not stopped.lifespan.task_active + + +async def verify_browser(fixture: SimpleNamespace, origin: str, output: Path) -> None: + """ + 验证真实 Vue iframe、Cookie/CSRF、流增量、恢复、撤权及主动关闭。 + + :param fixture: 隔离宿主运行时和可控账号状态 + :param origin: HTTPS 代理地址 + :param output: 失败截图与 Playwright trace 目录 + :return: None + """ + async with async_playwright() as playwright: + executable = os.environ.get('RUOYI_SMOKE_BROWSER_EXECUTABLE') + launched = await playwright.chromium.launch(executable_path=executable or None, headless=True) + try: + context = await launched.new_context(ignore_https_errors=True) + await context.tracing.start(screenshots=True, snapshots=True, sources=True) + await context.add_cookies([{'name': 'Admin-Token', 'value': fixture.token, 'url': origin}]) + page = await context.new_page() + page.set_default_timeout(30000) + errors: list[str] = [] + page.on('pageerror', lambda error: errors.append(str(error))) + failed = False + try: + frame = await verify_browser_session(context, page, origin) + await verify_browser_streams(fixture, page, frame, origin) + assert not errors, errors + await page.screenshot(path=str(output / 'passed.png'), full_page=True) + except BaseException: + failed = True + with suppress(Exception): + await page.screenshot(path=str(output / 'failed.png'), full_page=True) + raise + finally: + if failed: + with suppress(Exception): + await context.tracing.stop(path=str(output / 'trace.zip')) + with suppress(Exception): + await context.close() + else: + try: + await context.tracing.stop(path=str(output / 'trace.zip')) + finally: + await context.close() + finally: + with suppress(Exception): + await launched.close() + + +async def test_plugin_network_smoke(browser: SimpleNamespace, tmp_path: Path) -> None: # noqa: F811 + """通过真实 TLS 代理与 Vue 页面验收插件网络协议,测试数据全程隔离。""" + output = await asyncio.to_thread(Path(os.environ.get('RUOYI_SMOKE_OUTPUT', 'target/plugin-network-smoke')).resolve) + output.mkdir(parents=True, exist_ok=True) + prepare_network_host(browser) + async with network_servers(browser, tmp_path, output) as origin: + async with httpx.AsyncClient(verify=False, trust_env=False) as client: + response = await client.get(origin + PREFIX + 'ui/', headers={'Accept': 'text/html'}) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + await verify_browser(browser, origin, output) diff --git a/ruoyi-fastapi-backend/tests/plugins/native/test_native_bundle_build.py b/ruoyi-fastapi-backend/tests/plugins/native/test_native_bundle_build.py new file mode 100644 index 0000000..e39299d --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/native/test_native_bundle_build.py @@ -0,0 +1,118 @@ +import os +import sys +from pathlib import Path +from unittest.mock import Mock + +import pytest +import yaml + +from plugins.core.artifacts import package +from plugins.core.manifest.schema import PluginManifest, PluginManifestFactory +from scripts import build_native_plugin + + +@pytest.fixture +def source(tmp_path: Path) -> Path: + root = tmp_path / 'native_bundle' + (root / 'web' / 'dist' / 'assets').mkdir(parents=True) + (root / 'web' / 'dist' / 'index.html').write_text('', encoding='utf-8') + (root / 'web' / 'dist' / 'assets' / 'app.js').write_text('export const value = 1;', encoding='utf-8') + (root / 'web' / 'src').mkdir() + (root / 'web' / 'src' / 'app.ts').write_text('development', encoding='utf-8') + (root / 'web' / 'package.json').write_text('{}', encoding='utf-8') + (root / 'Cargo.lock').write_text('version = 4\n', encoding='utf-8') + return root + + +def manifest(**overrides: object) -> PluginManifest: + data = { + 'manifestVersion': 2, + 'id': 'native_bundle', + 'name': 'Native bundle', + 'version': '1.0.0', + 'backend': { + 'runtime': 'native', + 'integration': 'asgi', + 'module': 'ruoyi_plugin_native_bundle', + 'entrypoint': 'ruoyi_plugin_native_bundle._native:create_plugin', + 'native': {'distribution': 'ruoyi-plugin-native-bundle'}, + }, + 'frontend': {'delivery': {'type': 'bundle'}, 'bundle': {'directory': 'web/dist'}}, + } + data.update(overrides) + return PluginManifestFactory.create(data) + + +def test_collects_only_built_bundle_and_declared_resource_directories(source: Path, tmp_path: Path) -> None: + migrations = source / 'migrations' / 'mysql' + migrations.mkdir(parents=True) + (migrations / '001_init.sql').write_text('SELECT 1;', encoding='utf-8') + (source / 'plugin.lock.yaml').write_text('schemaVersion: 1\n', encoding='utf-8') + plugin = manifest() + plugin.backend.migrations = ['migrations/mysql'] + resources = build_native_plugin.collect_deployment_resources(source, plugin, tmp_path / 'output') + assert set(resources) == { + 'web/dist/index.html', + 'web/dist/assets/app.js', + 'migrations/mysql/001_init.sql', + 'plugin.lock.yaml', + } + + +def test_missing_bundle_entry_fails_before_compilation( + source: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + (source / 'web' / 'dist' / 'index.html').unlink() + (source / 'plugin.yaml').write_text(yaml.safe_dump(manifest().model_dump(by_alias=True)), encoding='utf-8') + output = tmp_path / 'output' + compile_process = Mock() + monkeypatch.setattr(build_native_plugin.subprocess, 'run', compile_process) + monkeypatch.setattr(sys, 'argv', ['build_native_plugin.py', '--source', str(source), '--output', str(output)]) + with pytest.raises(ValueError, match='HTML'): + build_native_plugin.main() + compile_process.assert_not_called() + assert not output.exists() + + +@pytest.mark.parametrize('name', ['.env.production', 'package.json', 'secret.rs', 'src/private.ts']) +def test_rejects_development_or_secret_files_in_dist(source: Path, tmp_path: Path, name: str) -> None: + path = source / 'web' / 'dist' / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('not a deployment asset', encoding='utf-8') + with pytest.raises(ValueError, match='开发工程或敏感配置'): + build_native_plugin.collect_deployment_resources(source, manifest(), tmp_path / 'output') + + +def test_rejects_output_inside_bundle(source: Path) -> None: + with pytest.raises(ValueError, match='输出目录'): + build_native_plugin.collect_deployment_resources(source, manifest(), source / 'web' / 'dist' / 'package') + + +def test_rejects_hardlinked_assets(source: Path, tmp_path: Path) -> None: + external = tmp_path / 'private.txt' + external.write_text('private', encoding='utf-8') + os.link(external, source / 'web' / 'dist' / 'private.txt') + with pytest.raises(ValueError, match='硬链接'): + build_native_plugin.collect_deployment_resources(source, manifest(), tmp_path / 'output') + + +def test_rejects_reparse_points_before_descending( + source: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + original = package.is_link_or_reparse + resource = source / 'web' / 'dist' / 'assets' + inode = resource.lstat().st_ino + monkeypatch.setattr(package, 'is_link_or_reparse', lambda info: info.st_ino == inode or original(info)) + with pytest.raises(ValueError, match='链接'): + build_native_plugin.collect_deployment_resources(source, manifest(), tmp_path / 'output') + + +@pytest.mark.parametrize('relative', ['plugin.yaml', 'native/data.json']) +def test_resources_cannot_overwrite_manifest_or_native_package(source: Path, tmp_path: Path, relative: str) -> None: + path = source / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('reserved', encoding='utf-8') + plugin = manifest() + plugin.backend.seeds = [relative] + with pytest.raises(ValueError, match='覆盖'): + build_native_plugin.collect_deployment_resources(source, plugin, tmp_path / 'output') diff --git a/ruoyi-fastapi-backend/tests/plugins/native/test_rust_demo.py b/ruoyi-fastapi-backend/tests/plugins/native/test_rust_demo.py new file mode 100644 index 0000000..211dcd2 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/native/test_rust_demo.py @@ -0,0 +1,265 @@ +import asyncio +import inspect +import os +from collections.abc import AsyncGenerator, AsyncIterator +from contextlib import asynccontextmanager +from contextvars import ContextVar +from dataclasses import replace +from importlib.machinery import EXTENSION_SUFFIXES +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch +from zipfile import ZipFile + +import httpx +import pytest +from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_EXECUTED +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from fastapi import Depends, FastAPI +from starlette import status +from starlette.responses import JSONResponse + +from common.aspect.db_session import get_db_session_provider +from config.scheduler.job_adapter import JobAdapter +from exceptions.exception import PermissionException +from module_admin.service.login_service import LoginService, oauth2_scheme +from plugins.core.discovery.registry import RegisteredPlugin +from plugins.core.discovery.scanner import DiscoveredPlugin, PluginScanner +from plugins.core.lifecycle.jobs import PluginJobModelBuilder +from plugins.core.runtime.entrypoint import PluginEntrypointLoader +from plugins.core.runtime.explicit import ExplicitPluginRuntime +from plugins.core.runtime.health import PluginHealthChecker +from plugins.core.runtime.job_dispatcher import dispatch_plugin_job +from plugins.core.sdk import PluginHostContext, PluginRequestContext, await_plugin_callback +from plugins.core.sdk.version import HOST_API_VERSION +from plugins.core.validation.structure import PluginStructureChecker + +PLUGIN_DIRECTORY = os.environ.get('RUOYI_NATIVE_PLUGIN_DIR') +pytestmark = pytest.mark.skipif(not PLUGIN_DIRECTORY, reason='需先构建 Rust wheel 并设置 RUOYI_NATIVE_PLUGIN_DIR') + + +@pytest.fixture(scope='module') +def plugin() -> DiscoveredPlugin: + root = Path(PLUGIN_DIRECTORY).resolve() + assert (root / 'plugin.yaml').is_file(), '指定的 Rust 交付目录不存在' + return PluginScanner(root.parent).load_manifest(root / 'plugin.yaml') + + +def test_distribution_contains_native_business_code_and_passes_static_discovery(plugin: DiscoveredPlugin) -> None: + check = PluginStructureChecker(plugin.backend_path.parents[1]).check(plugin, include_frontend=False) + assert check.ok, check.failed_items + files = [path for path in plugin.backend_path.rglob('*') if path.is_file()] + assert any(any(path.name.endswith(suffix) for suffix in EXTENSION_SUFFIXES) for path in files) + assert not any(path.suffix == '.rs' or path.name in {'Cargo.toml', 'Cargo.lock'} for path in files) + assert all(path.name == '__init__.py' for path in files if path.suffix == '.py') + archive_path = plugin.backend_path.parent / 'rust_demo-1.0.0.zip' + with ZipFile(archive_path) as archive: + names = archive.namelist() + assert 'rust_demo/plugin.yaml' in names + assert not any(name.endswith(('.rs', 'Cargo.toml')) for name in names) + + +@pytest.mark.asyncio +async def test_compiled_native_health_returns_rust_future(plugin: DiscoveredPlugin) -> None: + loader = PluginEntrypointLoader(plugin) + callback = loader.load_callable(plugin.manifest.backend.health.checker) + assert not inspect.iscoroutinefunction(callback) + result = await PluginHealthChecker(plugin).check() + assert result.ok, result.error + + +@asynccontextmanager +async def fake_session() -> AsyncGenerator[object, None]: + yield object() + + +@pytest.mark.asyncio +async def test_real_native_subapp_uses_host_auth_lifespan_and_python_service(plugin: DiscoveredPlugin) -> None: + app = FastAPI() + gateway = SimpleNamespace(is_plugin_enabled=AsyncMock(return_value=True)) + runtime = ExplicitPluginRuntime(gateway) + user = SimpleNamespace(user=SimpleNamespace(user_id=7), permissions=['rust_demo:view', 'rust_demo:profile']) + profile = SimpleNamespace( + data=SimpleNamespace(user_id=7, user_name='rust-user', nick_name='Rust', avatar='', password='secret'), + post_group='Engineering', + role_group='Developer', + ) + with ( + patch('plugins.core.runtime.explicit.DataSourceRegistry.session', fake_session), + patch('plugins.core.runtime.explicit.LoginService.get_current_user', new=AsyncMock(return_value=user)), + patch( + 'module_admin.service.user_service.UserService.user_profile_services', new=AsyncMock(return_value=profile) + ) as service, + ): + runtime.prepare(RegisteredPlugin(plugin, None, True, 'installed'), app, startup_write_enabled=False) + await runtime.activate('rust_demo', app) + try: + assert runtime.loaded['rust_demo'].lifespan.state == {'nativeReady': True} + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client: + assert (await client.get('/apps/rust_demo/api/info')).status_code == status.HTTP_401_UNAUTHORIZED + headers = {'Authorization': 'Bearer test-token'} + response = await client.get('/apps/rust_demo/api/info', headers=headers) + assert response.status_code == status.HTTP_200_OK, response.text + assert response.json()['engine'] == 'rust' + assert response.json()['hostApiVersion'] == HOST_API_VERSION + assert response.json()['serverTime'].endswith('+00:00') + response = await client.get('/apps/rust_demo/api/profile', headers=headers) + assert response.json()['userName'] == 'rust-user' + assert 'password' not in response.json() + assert service.await_args.args[1] == user.user.user_id + response = await client.post( + '/apps/rust_demo/api/echo/item-7?tag=first&tag=second', + headers=headers, + json={'message': '来自 Python 的请求体'}, + ) + assert response.status_code == status.HTTP_200_OK, response.text + payload = response.json() + assert payload['pathParams'] == {'item_id': 'item-7'} + assert payload['query'] == {'tag': ['first', 'second']} + assert payload['body'] == {'message': '来自 Python 的请求体'} + assert payload['requestId'] + assert 'test-token' not in response.text + user.permissions = ['rust_demo:view'] + response = await client.get('/apps/rust_demo/api/profile', headers=headers) + assert response.status_code == status.HTTP_403_FORBIDDEN + assert service.await_count == 1 + gateway.is_plugin_enabled.return_value = False + response = await client.get('/apps/rust_demo/api/info', headers=headers) + assert response.status_code == status.HTTP_403_FORBIDDEN + finally: + await runtime.shutdown() + assert not runtime.loaded['rust_demo'].lifespan.ready + + +@pytest.mark.asyncio +async def test_native_python_coroutine_bridge_keeps_context_and_cancellation(plugin: DiscoveredPlugin) -> None: + trace = ContextVar('native_test_trace', default='missing') + started = asyncio.Event() + closed = asyncio.Event() + loop = asyncio.get_running_loop() + + async def service(context: PluginRequestContext) -> None: + assert asyncio.get_running_loop() is loop + assert trace.get() == 'current-request' + started.set() + try: + await asyncio.Event().wait() + finally: + closed.set() + + host = PluginHostContext('rust_demo', plugin.backend_path, services={'users.current_profile.v1': service}) + context = PluginRequestContext(host, object(), frozenset({'rust_demo:profile'})) + native_profile = PluginEntrypointLoader(plugin).load_callable('ruoyi_plugin_rust_demo._native:profile') + token = trace.set('current-request') + try: + task = asyncio.create_task(await_plugin_callback(native_profile, context)) + await asyncio.wait_for(started.wait(), timeout=5) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert closed.is_set() + finally: + trace.reset(token) + + +@pytest.mark.asyncio +async def test_native_python_service_exception_propagates(plugin: DiscoveredPlugin) -> None: + async def failed_service(context: PluginRequestContext) -> None: + raise ValueError('host-service-failed') + + host = PluginHostContext('rust_demo', plugin.backend_path, services={'users.current_profile.v1': failed_service}) + context = PluginRequestContext(host, object()) + native_profile = PluginEntrypointLoader(plugin).load_callable('ruoyi_plugin_rust_demo._native:profile') + with pytest.raises(ValueError, match='host-service-failed'): + await await_plugin_callback(native_profile, context) + + +@pytest.mark.asyncio +async def test_real_native_router_gets_host_context_and_permissions(plugin: DiscoveredPlugin) -> None: + manifest = plugin.manifest.model_copy(deep=True) + manifest.backend.integration = 'router' + manifest.backend.asgi = None + manifest.backend.entrypoint = 'ruoyi_plugin_rust_demo._native:create_router_plugin' + router_plugin = replace(plugin, manifest=manifest) + app = FastAPI() + gateway = SimpleNamespace(is_plugin_enabled=AsyncMock(return_value=True)) + runtime = ExplicitPluginRuntime(gateway) + user = SimpleNamespace(user=SimpleNamespace(user_id=7), permissions=['rust_demo:view', 'rust_demo:profile']) + + async def login(token: str = Depends(oauth2_scheme)) -> object: + assert token == 'test-token' + return user + + async def session() -> AsyncIterator[object]: + yield object() + + app.dependency_overrides[LoginService.get_current_user] = login + app.dependency_overrides[get_db_session_provider(None)] = session + + @app.exception_handler(PermissionException) + async def permission_error(request: object, exc: PermissionException) -> JSONResponse: + return JSONResponse({'detail': exc.message}, status_code=status.HTTP_403_FORBIDDEN) + + with patch('plugins.core.runtime.explicit.DataSourceRegistry.session', fake_session): + runtime.prepare(RegisteredPlugin(router_plugin, None, True, 'installed'), app, startup_write_enabled=False) + await runtime.activate('rust_demo', app) + try: + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client: + path = '/rust_demo/api/info' + assert (await client.get(path)).status_code == status.HTTP_401_UNAUTHORIZED + headers = {'Authorization': 'Bearer test-token'} + response = await client.get(path, headers=headers) + assert response.status_code == status.HTTP_200_OK, response.text + assert response.json()['engine'] == 'rust' + user.permissions = ['rust_demo:view'] + response = await client.get('/rust_demo/api/profile', headers=headers) + assert response.status_code == status.HTTP_403_FORBIDDEN + user.permissions = ['unrelated:view'] + assert (await client.get(path, headers=headers)).status_code == status.HTTP_403_FORBIDDEN + gateway.is_plugin_enabled.return_value = False + assert (await client.get(path, headers=headers)).status_code == status.HTTP_403_FORBIDDEN + gateway.is_plugin_enabled.return_value = True + user.permissions = ['rust_demo:view'] + await runtime.shutdown() + assert (await client.get(path, headers=headers)).status_code == status.HTTP_503_SERVICE_UNAVAILABLE + finally: + await runtime.shutdown() + + +@pytest.mark.asyncio +async def test_real_scheduler_invokes_rust_awaitable_through_persistent_dispatcher(plugin: DiscoveredPlugin) -> None: + app = FastAPI() + gateway = SimpleNamespace(is_plugin_enabled=AsyncMock(return_value=True)) + runtime = ExplicitPluginRuntime(gateway) + scheduler = AsyncIOScheduler() + completed = asyncio.get_running_loop().create_future() + + def on_execution(event: object) -> None: + if not completed.done(): + completed.set_result(event) + + scheduler.add_listener(on_execution, EVENT_JOB_EXECUTED | EVENT_JOB_ERROR) + with patch('plugins.core.runtime.explicit.DataSourceRegistry.session', fake_session): + runtime.prepare(RegisteredPlugin(plugin, None, True, 'installed'), app, startup_write_enabled=False) + await runtime.activate('rust_demo', app) + try: + job = plugin.manifest.backend.jobs[0] + assert not job.enabled + options = JobAdapter.prepare(PluginJobModelBuilder.build('rust_demo', job, manifest=plugin.manifest)) + # 显式单次触发测试;不安装或启用真实数据库中的默认停用示例任务。 + options.pop('trigger') + scheduler.add_job(trigger='date', **options) + scheduler.start() + event = await asyncio.wait_for(completed, 5) + assert event.exception is None, event.exception + assert event.retval == 'heartbeat' + gateway.is_plugin_enabled.return_value = False + with pytest.raises(PermissionError, match='未启用'): + await dispatch_plugin_job('rust_demo', 'heartbeat', '1.0.0') + finally: + if scheduler.running: + scheduler.shutdown(wait=False) + await asyncio.sleep(0) + await runtime.shutdown() + with pytest.raises(RuntimeError, match='未就绪'): + await dispatch_plugin_job('rust_demo', 'heartbeat', '1.0.0') diff --git a/ruoyi-fastapi-backend/tests/server/test_oidc_runtime.py b/ruoyi-fastapi-backend/tests/server/test_oidc_runtime.py index 0d9b044..c64b7fd 100644 --- a/ruoyi-fastapi-backend/tests/server/test_oidc_runtime.py +++ b/ruoyi-fastapi-backend/tests/server/test_oidc_runtime.py @@ -1,5 +1,5 @@ import asyncio -from collections.abc import AsyncIterator +from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from types import SimpleNamespace from unittest.mock import AsyncMock, patch @@ -43,7 +43,7 @@ async def test_oidc_runtime_validation_loads_database_active_key() -> None: db = object() @asynccontextmanager - async def session() -> AsyncIterator[object]: + async def session() -> AsyncGenerator[object, None]: yield db with ( @@ -65,7 +65,7 @@ async def test_oidc_runtime_validation_fails_closed_without_usable_key() -> None """active 元数据或私钥不可用时后台继续启动,但 OIDC 标记为未就绪。""" @asynccontextmanager - async def session() -> AsyncIterator[object]: + async def session() -> AsyncGenerator[object, None]: yield object() with ( @@ -123,7 +123,7 @@ async def test_background_loops_resume_after_lease_reacquisition(loop: str) -> N db = SimpleNamespace(commit=AsyncMock()) @asynccontextmanager - async def session() -> AsyncIterator[SimpleNamespace]: + async def session() -> AsyncGenerator[SimpleNamespace, None]: yield db with ( diff --git a/ruoyi-fastapi-backend/tests/sql/test_plugin_release_schema.py b/ruoyi-fastapi-backend/tests/sql/test_plugin_release_schema.py new file mode 100644 index 0000000..aa5a3d5 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/sql/test_plugin_release_schema.py @@ -0,0 +1,55 @@ +import re +from pathlib import Path + +import pytest +from sqlalchemy import CheckConstraint +from sqlalchemy.dialects import mysql, postgresql +from sqlalchemy.schema import CreateTable + +from plugins.core.lifecycle.script import PluginLifecycleScriptHelper +from plugins.core.management.entity.do.release_models import SysPluginArtifact, SysPluginRelease, SysPluginWorker + +SQL_ROOT = Path(__file__).resolve().parents[2] / 'sql' + + +@pytest.mark.parametrize( + ('initial', 'time_type'), + [ + ('ruoyi-fastapi.sql', 'datetime(3)'), + ('ruoyi-fastapi-pg.sql', 'timestamp(3) with time zone'), + ], +) +def test_initial_release_schema_matches_models(initial: str, time_type: str) -> None: + initialization = (SQL_ROOT / initial).read_text(encoding='utf-8') + statements = PluginLifecycleScriptHelper.split_sql_statements(initialization) + for table in (SysPluginArtifact.__table__, SysPluginRelease.__table__, SysPluginWorker.__table__): + table_statements = [ + statement + for statement in statements + if re.match(rf'CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+{table.name}\s*\(', statement, re.IGNORECASE) + ] + assert len(table_statements) == 1 + ddl = table_statements[0].lower() + normalized_ddl = ' '.join(ddl.split()) + assert time_type in ddl + for column in table.columns: + assert re.search(rf'^\s*{column.name}\s+\w+', ddl, re.MULTILINE) + primary_keys = ', '.join(column.name for column in table.primary_key.columns) + assert f'primary key ({primary_keys})' in normalized_ddl + for constraint in table.constraints: + if isinstance(constraint, CheckConstraint): + assert f'constraint {constraint.name} check ({constraint.sqltext})' in normalized_ddl + for index in table.indexes: + columns = ', '.join(column.name for column in index.columns) + if initial == 'ruoyi-fastapi-pg.sql': + assert f'create index if not exists {index.name} on {table.name} ({columns})' in statements + else: + assert f'key {index.name} ({columns})' in normalized_ddl + + +@pytest.mark.parametrize('dialect', [mysql.dialect(), postgresql.dialect()]) +def test_release_orm_schema_compiles_for_supported_database_dialects(dialect: object) -> None: + for model in (SysPluginArtifact, SysPluginRelease, SysPluginWorker): + ddl = str(CreateTable(model.__table__).compile(dialect=dialect)) + assert model.__tablename__ in ddl + assert 'PRIMARY KEY' in ddl diff --git a/ruoyi-fastapi-backend/tests/test_entity_time_columns.py b/ruoyi-fastapi-backend/tests/test_entity_time_columns.py index 9b79942..925f5c8 100644 --- a/ruoyi-fastapi-backend/tests/test_entity_time_columns.py +++ b/ruoyi-fastapi-backend/tests/test_entity_time_columns.py @@ -37,6 +37,8 @@ from plugins.core.management.entity.do.models import ( SysPluginMigration, SysPluginOperationLog, ) +from plugins.core.management.entity.do.release_models import SysPluginArtifact, SysPluginRelease, SysPluginWorker +from scripts.verify_timezone_database import MAIN_INSTANT_COLUMNS, PLUGIN_INSTANT_COLUMNS # 显式维护 DO 类及 update_time 列清单,新增或移除时需同步核对模型。 ENTITY_MODELS = ( @@ -77,6 +79,9 @@ ENTITY_MODELS = ( SysPluginMigration, SysPluginConfig, SysPluginOperationLog, + SysPluginArtifact, + SysPluginRelease, + SysPluginWorker, ) UPDATE_TIME_MODELS = ( @@ -101,6 +106,8 @@ UPDATE_TIME_MODELS = ( SysPlugin, SysPluginMigration, SysPluginConfig, + SysPluginRelease, + SysPluginWorker, ) CREATE_TIME_ONLY_MODELS = ( @@ -110,6 +117,7 @@ CREATE_TIME_ONLY_MODELS = ( SysJobLog, SysPluginMenu, SysPluginOperationLog, + SysPluginArtifact, ) REQUIRED_CREATE_TIME_MODELS = { @@ -173,6 +181,19 @@ def test_all_time_columns_use_utc_type_and_callable_defaults() -> None: assert callable(default), f'{model.__name__}.{column.name}' +def test_timezone_database_verifier_counts_match_entity_columns() -> None: + """真实数据库验收的字段总数随模型契约更新,避免新增表后仅在集成 CI 中失败。""" + main_count = 0 + plugin_count = 0 + for model in ENTITY_MODELS: + count = sum(isinstance(column.type, DbUtcDateTime) for column in model.__table__.columns) + if model.__tablename__.startswith('ai_'): + plugin_count += count + else: + main_count += count + assert (main_count, plugin_count) == (MAIN_INSTANT_COLUMNS, PLUGIN_INSTANT_COLUMNS) + + @pytest.mark.asyncio async def test_core_model_time_defaults_are_evaluated_per_insert_and_core_update() -> None: """SQLite 行为契约:INSERT 默认值和 Core UPDATE onupdate 都重新求值。""" diff --git a/ruoyi-fastapi-frontend/.env.development b/ruoyi-fastapi-frontend/.env.development index a086d04..0e38c8a 100644 --- a/ruoyi-fastapi-frontend/.env.development +++ b/ruoyi-fastapi-frontend/.env.development @@ -6,3 +6,6 @@ VITE_APP_ENV = 'development' # vfadmin管理系统/开发环境 VITE_APP_BASE_API = '/dev-api' + +# 插件浏览器路径需与后端 APP_ROOT_PATH 一致 +VITE_APP_PLUGIN_BASE = '/dev-api' diff --git a/ruoyi-fastapi-frontend/.env.docker b/ruoyi-fastapi-frontend/.env.docker index 8487fb4..170069e 100644 --- a/ruoyi-fastapi-frontend/.env.docker +++ b/ruoyi-fastapi-frontend/.env.docker @@ -7,5 +7,8 @@ VITE_APP_ENV = 'docker' # vfadmin管理系统/生产环境 VITE_APP_BASE_API = '/docker-api' +# 插件浏览器路径需与后端 APP_ROOT_PATH 一致 +VITE_APP_PLUGIN_BASE = '/docker-api' + # 是否在打包时开启压缩,支持 gzip 和 brotli -VITE_BUILD_COMPRESS = gzip \ No newline at end of file +VITE_BUILD_COMPRESS = gzip diff --git a/ruoyi-fastapi-frontend/.env.production b/ruoyi-fastapi-frontend/.env.production index ac80031..7370c23 100644 --- a/ruoyi-fastapi-frontend/.env.production +++ b/ruoyi-fastapi-frontend/.env.production @@ -7,5 +7,8 @@ VITE_APP_ENV = 'production' # vfadmin管理系统/生产环境 VITE_APP_BASE_API = '/prod-api' +# 插件浏览器路径需与后端 APP_ROOT_PATH 一致 +VITE_APP_PLUGIN_BASE = '/prod-api' + # 是否在打包时开启压缩,支持 gzip 和 brotli -VITE_BUILD_COMPRESS = gzip \ No newline at end of file +VITE_BUILD_COMPRESS = gzip diff --git a/ruoyi-fastapi-frontend/.env.staging b/ruoyi-fastapi-frontend/.env.staging index c3fcf1a..c77b86d 100644 --- a/ruoyi-fastapi-frontend/.env.staging +++ b/ruoyi-fastapi-frontend/.env.staging @@ -7,5 +7,8 @@ VITE_APP_ENV = 'staging' # vfadmin管理系统/生产环境 VITE_APP_BASE_API = '/stage-api' +# 插件浏览器路径需与后端 APP_ROOT_PATH 一致 +VITE_APP_PLUGIN_BASE = '/stage-api' + # 是否在打包时开启压缩,支持 gzip 和 brotli -VITE_BUILD_COMPRESS = gzip \ No newline at end of file +VITE_BUILD_COMPRESS = gzip diff --git a/ruoyi-fastapi-frontend/bin/nginx.dockermy.conf b/ruoyi-fastapi-frontend/bin/nginx.dockermy.conf index 63a1b20..43d367c 100644 --- a/ruoyi-fastapi-frontend/bin/nginx.dockermy.conf +++ b/ruoyi-fastapi-frontend/bin/nginx.dockermy.conf @@ -1,3 +1,8 @@ +map $http_upgrade $plugin_connection_upgrade { + default upgrade; + '' close; +} + server { listen 80; server_name localhost; @@ -27,6 +32,30 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + # /docker-api 与前端 VITE_APP_PLUGIN_BASE、后端 APP_ROOT_PATH 一致。 + # 转发时剥离部署前缀,由 Uvicorn root_path 补回;保留外部 Host 以维持同源。 + location ^~ /docker-api/apps/ { + proxy_pass http://ruoyi-backend-my:9099/apps/; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $plugin_connection_upgrade; + proxy_buffering off; + proxy_read_timeout 300s; + } + + location ^~ /docker-api/plugin/runtime/ { + proxy_pass http://ruoyi-backend-my:9099/plugin/runtime/; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + add_header Cache-Control "no-store" always; + } + # 认证中心标准端点和交互 API 必须与 issuer、认证页面保持同源根路径。 location ~ ^/(\.well-known/|oauth2/|auth/interaction/) { proxy_pass http://ruoyi-backend-my:9099; diff --git a/ruoyi-fastapi-frontend/bin/nginx.dockerpg.conf b/ruoyi-fastapi-frontend/bin/nginx.dockerpg.conf index 2a95911..b696257 100644 --- a/ruoyi-fastapi-frontend/bin/nginx.dockerpg.conf +++ b/ruoyi-fastapi-frontend/bin/nginx.dockerpg.conf @@ -1,3 +1,8 @@ +map $http_upgrade $plugin_connection_upgrade { + default upgrade; + '' close; +} + server { listen 80; server_name localhost; @@ -27,6 +32,30 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + # /docker-api 与前端 VITE_APP_PLUGIN_BASE、后端 APP_ROOT_PATH 一致。 + # 转发时剥离部署前缀,由 Uvicorn root_path 补回;保留外部 Host 以维持同源。 + location ^~ /docker-api/apps/ { + proxy_pass http://ruoyi-backend-pg:9099/apps/; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $plugin_connection_upgrade; + proxy_buffering off; + proxy_read_timeout 300s; + } + + location ^~ /docker-api/plugin/runtime/ { + proxy_pass http://ruoyi-backend-pg:9099/plugin/runtime/; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + add_header Cache-Control "no-store" always; + } + # 认证中心标准端点和交互 API 必须与 issuer、认证页面保持同源根路径。 location ~ ^/(\.well-known/|oauth2/|auth/interaction/) { proxy_pass http://ruoyi-backend-pg:9099; diff --git a/ruoyi-fastapi-frontend/src/api/system/plugin.js b/ruoyi-fastapi-frontend/src/api/system/plugin.js index a28bb1e..7b12f41 100644 --- a/ruoyi-fastapi-frontend/src/api/system/plugin.js +++ b/ruoyi-fastapi-frontend/src/api/system/plugin.js @@ -1,5 +1,16 @@ import request from '@/utils/request' +// 查询实际加载插件的累计运行指标 +export function getPluginRuntimeMetrics(pluginId, signal) { + return request({ + url: '/system/plugin/runtime/metrics', + method: 'get', + params: { pluginId }, + signal, + skipErrorMessage: true, + }) +} + // 查询插件列表 export function listPlugin(query) { return request({ @@ -137,6 +148,16 @@ export function updatePluginConfig(pluginId, data) { }) } +// 查询配置保存版本与已观测进程的启动快照。 +export function getPluginConfigStatus(pluginId, signal) { + return request({ + url: '/system/plugin/' + pluginId + '/config/status', + method: 'get', + signal, + skipErrorMessage: true, + }) +} + // 检查插件依赖 export function checkPluginDependencies(pluginId) { return request({ diff --git a/ruoyi-fastapi-frontend/src/components/PluginFrame/index.vue b/ruoyi-fastapi-frontend/src/components/PluginFrame/index.vue new file mode 100644 index 0000000..6148e7f --- /dev/null +++ b/ruoyi-fastapi-frontend/src/components/PluginFrame/index.vue @@ -0,0 +1,289 @@ +