From 99c0eb8e7ea7582b6621825c277cc0e3101eaa79 Mon Sep 17 00:00:00 2001 From: insistence <3055204202@qq.com> Date: Tue, 6 Oct 2026 20:43:13 +0800 Subject: [PATCH 01/14] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20v2=20?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E3=80=81=E5=8E=9F=E7=94=9F=E6=89=A9=E5=B1=95?= =?UTF-8?q?=E4=B8=8E=E7=AD=BE=E5=90=8D=E5=8F=91=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/native-plugin.yml | 69 ++ .../workflows/plugin-release-integration.yml | 70 ++ ruoyi-fastapi-backend/.env.dev | 12 + ruoyi-fastapi-backend/.env.dockermy | 12 + ruoyi-fastapi-backend/.env.dockerpg | 12 + ruoyi-fastapi-backend/.env.prod | 12 + .../cli/groups/plugin/artifact_controller.py | 473 +++++++++ .../cli/groups/plugin/command.py | 14 + .../cli/groups/plugin/commands/artifact.py | 322 ++++++ .../cli/groups/plugin/commands/developer.py | 8 +- ruoyi-fastapi-backend/cli/guards.py | 18 + .../plugin/scaffold/assets/gitignore.tmpl | 9 + .../scaffold/assets/native/Cargo.lock.tmpl | 224 ++++ .../scaffold/assets/native/Cargo.toml.tmpl | 18 + .../plugin/scaffold/assets/native/lib.rs.tmpl | 112 ++ .../assets/native/pyproject.toml.tmpl | 14 + .../scaffold/assets/python/__init__.py.tmpl | 85 ++ .../assets/python/build_release.py.tmpl | 100 ++ .../scaffold/assets/test_plugin.py.tmpl | 74 ++ .../scaffold/assets/web/index.html.tmpl | 22 + .../plugin/scaffold/assets/web/main.js.tmpl | 86 ++ .../scaffold/assets/web/package.json.tmpl | 8 + .../plugin/scaffold/assets/web/style.css.tmpl | 41 + .../scaffold/assets/web/vite.config.js.tmpl | 6 + .../cli/runtime/plugin/scaffold/builder.py | 11 + .../cli/runtime/plugin/scaffold/options.py | 13 +- .../cli/runtime/plugin/scaffold/v2.py | 290 ++++++ ruoyi-fastapi-backend/config/env.py | 29 + ruoyi-fastapi-backend/config/lifecycle.py | 8 +- ruoyi-fastapi-backend/docs/cli_usage.md | 40 +- .../docs/plugin_development.md | 547 +++++++++- .../unified_authentication_developer_guide.md | 22 +- .../transport_crypto_middleware.py | 21 + .../module_admin/entity/vo/login_vo.py | 1 + .../module_admin/service/login_service.py | 53 +- .../module_identity/service/key_service.py | 4 +- .../controller/plugin_browser_controller.py | 74 ++ .../controller/plugin_release_controller.py | 94 ++ .../plugins/core/artifacts/__init__.py | 22 + .../plugins/core/artifacts/package.py | 515 ++++++++++ .../plugins/core/artifacts/schema.py | 394 +++++++ .../plugins/core/artifacts/store.py | 343 +++++++ .../plugins/core/capability.py | 21 +- .../plugins/core/deployment/__init__.py | 0 .../plugins/core/deployment/catalog.py | 185 ++++ .../plugins/core/deployment/config.py | 158 +++ .../plugins/core/deployment/enablement.py | 106 ++ .../plugins/core/deployment/service.py | 503 +++++++++ .../plugins/core/deployment/state.py | 234 +++++ .../plugins/core/deployment/worker.py | 276 +++++ .../plugins/core/discovery/scanner.py | 13 + .../plugins/core/environment.py | 2 +- .../plugins/core/lifecycle/jobs.py | 27 +- .../plugins/core/management/dao/dao.py | 1 + .../core/management/dao/release_dao.py | 426 ++++++++ .../management/entity/do/release_models.py | 75 ++ .../core/management/entity/vo/schemas.py | 1 + .../core/management/service/gateway.py | 32 +- .../core/management/service/release_view.py | 113 ++ .../management/service/router_metadata.py | 42 + .../core/management/service/service.py | 67 +- .../plugins/core/manifest/schema.py | 32 + .../plugins/core/manifest/v2.py | 260 +++++ .../plugins/core/native/__init__.py | 0 .../plugins/core/native/wheel.py | 265 +++++ .../plugins/core/runtime/application.py | 60 +- .../plugins/core/runtime/asgi.py | 188 ++++ .../plugins/core/runtime/bootstrap.py | 43 +- .../plugins/core/runtime/browser_session.py | 211 ++++ .../plugins/core/runtime/bundle.py | 277 +++++ .../plugins/core/runtime/callable.py | 7 + .../plugins/core/runtime/entrypoint.py | 244 +++++ .../plugins/core/runtime/explicit.py | 398 +++++++ .../plugins/core/runtime/health.py | 3 + .../plugins/core/runtime/hooks.py | 4 + .../plugins/core/runtime/host_services.py | 46 + .../plugins/core/runtime/job_dispatcher.py | 119 +++ .../plugins/core/runtime/service/config.py | 4 +- .../plugins/core/runtime/service/context.py | 115 ++- .../plugins/core/runtime/service/facade.py | 60 +- .../core/runtime/service/lifecycle/enable.py | 8 + .../core/runtime/service/lifecycle/install.py | 3 + .../core/runtime/service/lifecycle/purge.py | 3 + .../core/runtime/service/lifecycle/upgrade.py | 3 + .../core/runtime/service/lifecycle_lock.py | 10 +- .../plugins/core/runtime/startup.py | 133 ++- .../core/runtime/startup_coordination.py | 40 +- .../core/runtime/support/payload/catalog.py | 6 + .../plugins/core/sdk/__init__.py | 15 + .../plugins/core/sdk/asgi.py | 73 ++ .../plugins/core/sdk/context.py | 112 ++ .../plugins/core/sdk/definition.py | 76 ++ .../plugins/core/sdk/version.py | 1 + .../plugins/core/validation/manifest.py | 5 + .../plugins/core/validation/structure.py | 50 + .../examples/python/asgi_demo/README.md | 31 + .../examples/python/asgi_demo/__init__.py | 56 + .../examples/python/asgi_demo/plugin.yaml | 22 + .../examples/python/bundle_demo/README.md | 192 ++++ .../examples/python/bundle_demo/__init__.py | 59 ++ .../examples/python/bundle_demo/plugin.yaml | 30 + .../python/bundle_demo/web/index.html | 75 ++ .../python/bundle_demo/web/package.json | 12 + .../python/bundle_demo/web/src/main.js | 106 ++ .../python/bundle_demo/web/src/style.css | 197 ++++ .../python/bundle_demo/web/vite.config.js | 17 + .../examples/rust/rust_demo/Cargo.lock | 224 ++++ .../examples/rust/rust_demo/Cargo.toml | 18 + .../plugins/examples/rust/rust_demo/README.md | 117 +++ .../examples/rust/rust_demo/plugin.yaml | 35 + .../examples/rust/rust_demo/pyproject.toml | 14 + .../python/ruoyi_plugin_rust_demo/__init__.py | 0 .../examples/rust/rust_demo/src/lib.rs | 171 ++++ ruoyi-fastapi-backend/pyproject.toml | 3 + .../scripts/build_native_plugin.py | 194 ++++ .../scripts/plugin_release_integration.py | 968 ++++++++++++++++++ .../sql/ruoyi-fastapi-pg.sql | 123 ++- ruoyi-fastapi-backend/sql/ruoyi-fastapi.sql | 89 +- .../tests/cli/root/test_guards.py | 6 + .../cli/root/test_plugin_artifact_commands.py | 458 +++++++++ .../cli/root/test_plugin_create_templates.py | 49 + .../plugin/test_runtime_scaffold_v2.py | 239 +++++ .../tests/config/test_lifecycle.py | 18 + .../service/test_login_plugin_routes.py | 133 +++ .../test_plugin_release_controller.py | 74 ++ .../tests/plugins/core/artifacts/conftest.py | 53 + .../plugins/core/artifacts/test_package.py | 453 ++++++++ .../plugins/core/artifacts/test_store.py | 357 +++++++ .../tests/plugins/core/deployment/conftest.py | 119 +++ .../deployment/test_deployment_catalog.py | 213 ++++ .../core/deployment/test_enablement.py | 347 +++++++ .../core/deployment/test_release_state.py | 166 +++ .../plugins/core/deployment/test_service.py | 520 ++++++++++ .../plugins/core/deployment/test_worker.py | 615 +++++++++++ .../core/management/test_artifact_views.py | 151 +++ .../core/management/test_plugin_state.py | 8 +- .../core/management/test_release_dao.py | 301 ++++++ .../tests/plugins/core/native/test_wheel.py | 181 ++++ .../plugins/core/runtime/fakes/gateway.py | 10 +- .../runtime/service/test_artifact_guards.py | 175 ++++ .../runtime/service/test_lifecycle_lock.py | 6 +- .../service/test_upgrade_enable_purge.py | 8 +- .../plugins/core/runtime/test_application.py | 29 +- .../tests/plugins/core/runtime/test_asgi.py | 119 +++ .../core/runtime/test_browser_session.py | 377 +++++++ .../tests/plugins/core/runtime/test_bundle.py | 281 +++++ .../core/runtime/test_bundle_transport.py | 205 ++++ .../plugins/core/runtime/test_entrypoint.py | 135 +++ .../plugins/core/runtime/test_explicit.py | 266 +++++ .../core/runtime/test_host_services.py | 121 +++ .../core/runtime/test_job_dispatcher.py | 227 ++++ .../core/validation/test_manifest_v2.py | 133 +++ .../integration/test_harness_safety.py | 405 ++++++++ .../native/test_native_bundle_build.py | 118 +++ .../tests/plugins/native/test_rust_demo.py | 253 +++++ .../tests/server/test_oidc_runtime.py | 8 +- .../tests/sql/test_plugin_release_schema.py | 41 + .../bin/nginx.dockermy.conf | 29 + .../bin/nginx.dockerpg.conf | 29 + .../src/components/PluginFrame/index.vue | 277 +++++ .../src/store/modules/permission.js | 3 + .../src/utils/pluginBridge.js | 543 ++++++++++ .../src/utils/pluginReleaseFormatter.js | 51 + ruoyi-fastapi-frontend/src/utils/request.js | 4 + .../src/utils/transportCrypto.js | 35 +- .../src/utils/transportCryptoPolicy.js | 35 +- .../src/utils/transportPath.js | 29 + .../plugin/components/PluginDetailDialog.vue | 121 ++- .../plugin/components/PluginReleaseStatus.vue | 34 + .../src/views/system/plugin/index.vue | 33 +- .../tests/plugins/pluginBridge.test.js | 493 +++++++++ .../tests/plugins/pluginFrame.test.js | 70 ++ .../plugins/pluginReleaseFormatter.test.js | 114 +++ ruoyi-fastapi-frontend/vite.config.js | 11 + 174 files changed, 21245 insertions(+), 255 deletions(-) create mode 100644 .github/workflows/native-plugin.yml create mode 100644 .github/workflows/plugin-release-integration.yml create mode 100644 ruoyi-fastapi-backend/cli/groups/plugin/artifact_controller.py create mode 100644 ruoyi-fastapi-backend/cli/groups/plugin/commands/artifact.py create mode 100644 ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/gitignore.tmpl create mode 100644 ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/native/Cargo.lock.tmpl create mode 100644 ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/native/Cargo.toml.tmpl create mode 100644 ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/native/lib.rs.tmpl create mode 100644 ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/native/pyproject.toml.tmpl create mode 100644 ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/python/__init__.py.tmpl create mode 100644 ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/python/build_release.py.tmpl create mode 100644 ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/test_plugin.py.tmpl create mode 100644 ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/web/index.html.tmpl create mode 100644 ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/web/main.js.tmpl create mode 100644 ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/web/package.json.tmpl create mode 100644 ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/web/style.css.tmpl create mode 100644 ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/web/vite.config.js.tmpl create mode 100644 ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/v2.py create mode 100644 ruoyi-fastapi-backend/module_plugin/controller/plugin_browser_controller.py create mode 100644 ruoyi-fastapi-backend/module_plugin/controller/plugin_release_controller.py create mode 100644 ruoyi-fastapi-backend/plugins/core/artifacts/__init__.py create mode 100644 ruoyi-fastapi-backend/plugins/core/artifacts/package.py create mode 100644 ruoyi-fastapi-backend/plugins/core/artifacts/schema.py create mode 100644 ruoyi-fastapi-backend/plugins/core/artifacts/store.py create mode 100644 ruoyi-fastapi-backend/plugins/core/deployment/__init__.py create mode 100644 ruoyi-fastapi-backend/plugins/core/deployment/catalog.py create mode 100644 ruoyi-fastapi-backend/plugins/core/deployment/config.py create mode 100644 ruoyi-fastapi-backend/plugins/core/deployment/enablement.py create mode 100644 ruoyi-fastapi-backend/plugins/core/deployment/service.py create mode 100644 ruoyi-fastapi-backend/plugins/core/deployment/state.py create mode 100644 ruoyi-fastapi-backend/plugins/core/deployment/worker.py create mode 100644 ruoyi-fastapi-backend/plugins/core/management/dao/release_dao.py create mode 100644 ruoyi-fastapi-backend/plugins/core/management/entity/do/release_models.py create mode 100644 ruoyi-fastapi-backend/plugins/core/management/service/release_view.py create mode 100644 ruoyi-fastapi-backend/plugins/core/management/service/router_metadata.py create mode 100644 ruoyi-fastapi-backend/plugins/core/manifest/v2.py create mode 100644 ruoyi-fastapi-backend/plugins/core/native/__init__.py create mode 100644 ruoyi-fastapi-backend/plugins/core/native/wheel.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/asgi.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/browser_session.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/bundle.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/entrypoint.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/explicit.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/host_services.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/job_dispatcher.py create mode 100644 ruoyi-fastapi-backend/plugins/core/sdk/__init__.py create mode 100644 ruoyi-fastapi-backend/plugins/core/sdk/asgi.py create mode 100644 ruoyi-fastapi-backend/plugins/core/sdk/context.py create mode 100644 ruoyi-fastapi-backend/plugins/core/sdk/definition.py create mode 100644 ruoyi-fastapi-backend/plugins/core/sdk/version.py create mode 100644 ruoyi-fastapi-backend/plugins/examples/python/asgi_demo/README.md create mode 100644 ruoyi-fastapi-backend/plugins/examples/python/asgi_demo/__init__.py create mode 100644 ruoyi-fastapi-backend/plugins/examples/python/asgi_demo/plugin.yaml create mode 100644 ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/README.md create mode 100644 ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/__init__.py create mode 100644 ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/plugin.yaml create mode 100644 ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/index.html create mode 100644 ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/package.json create mode 100644 ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/src/main.js create mode 100644 ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/src/style.css create mode 100644 ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/vite.config.js create mode 100644 ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/Cargo.lock create mode 100644 ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/Cargo.toml create mode 100644 ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/README.md create mode 100644 ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/plugin.yaml create mode 100644 ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/pyproject.toml create mode 100644 ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/python/ruoyi_plugin_rust_demo/__init__.py create mode 100644 ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/src/lib.rs create mode 100644 ruoyi-fastapi-backend/scripts/build_native_plugin.py create mode 100644 ruoyi-fastapi-backend/scripts/plugin_release_integration.py create mode 100644 ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py create mode 100644 ruoyi-fastapi-backend/tests/cli/root/test_plugin_create_templates.py create mode 100644 ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_scaffold_v2.py create mode 100644 ruoyi-fastapi-backend/tests/module_admin/service/test_login_plugin_routes.py create mode 100644 ruoyi-fastapi-backend/tests/module_plugin/controller/test_plugin_release_controller.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/artifacts/conftest.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/artifacts/test_package.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/artifacts/test_store.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/deployment/conftest.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/deployment/test_deployment_catalog.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/deployment/test_enablement.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/deployment/test_release_state.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/deployment/test_service.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/deployment/test_worker.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/management/test_artifact_views.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/management/test_release_dao.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/native/test_wheel.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_artifact_guards.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/test_asgi.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/test_browser_session.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/test_bundle.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/test_bundle_transport.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/test_entrypoint.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/test_explicit.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/test_host_services.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/test_job_dispatcher.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/validation/test_manifest_v2.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/integration/test_harness_safety.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/native/test_native_bundle_build.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/native/test_rust_demo.py create mode 100644 ruoyi-fastapi-backend/tests/sql/test_plugin_release_schema.py create mode 100644 ruoyi-fastapi-frontend/src/components/PluginFrame/index.vue create mode 100644 ruoyi-fastapi-frontend/src/utils/pluginBridge.js create mode 100644 ruoyi-fastapi-frontend/src/utils/pluginReleaseFormatter.js create mode 100644 ruoyi-fastapi-frontend/src/utils/transportPath.js create mode 100644 ruoyi-fastapi-frontend/src/views/system/plugin/components/PluginReleaseStatus.vue create mode 100644 ruoyi-fastapi-frontend/tests/plugins/pluginBridge.test.js create mode 100644 ruoyi-fastapi-frontend/tests/plugins/pluginFrame.test.js create mode 100644 ruoyi-fastapi-frontend/tests/plugins/pluginReleaseFormatter.test.js diff --git a/.github/workflows/native-plugin.yml b/.github/workflows/native-plugin.yml new file mode 100644 index 000000000..731ed2d4a --- /dev/null +++ b/.github/workflows/native-plugin.yml @@ -0,0 +1,69 @@ +name: Native Plugin + +on: + push: + branches: [master] + paths: + - 'ruoyi-fastapi-backend/plugins/core/**' + - 'ruoyi-fastapi-backend/plugins/examples/rust/**' + - 'ruoyi-fastapi-backend/scripts/build_native_plugin.py' + - 'ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/**' + - 'ruoyi-fastapi-backend/cli/groups/plugin/commands/developer.py' + - 'ruoyi-fastapi-backend/pyproject.toml' + - 'ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_scaffold*.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/scripts/build_native_plugin.py' + - 'ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/**' + - 'ruoyi-fastapi-backend/cli/groups/plugin/commands/developer.py' + - 'ruoyi-fastapi-backend/pyproject.toml' + - 'ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_scaffold*.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: 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/sql/test_plugin_release_schema.py -q diff --git a/.github/workflows/plugin-release-integration.yml b/.github/workflows/plugin-release-integration.yml new file mode 100644 index 000000000..18194f029 --- /dev/null +++ b/.github/workflows/plugin-release-integration.yml @@ -0,0 +1,70 @@ +name: Plugin Release Integration + +on: + pull_request: + branches: [master] + paths: + - 'ruoyi-fastapi-backend/plugins/core/**' + - 'ruoyi-fastapi-backend/config/**' + - 'ruoyi-fastapi-backend/sql/upgrade_plugin_artifact_*.sql' + - 'ruoyi-fastapi-backend/sql/ruoyi-fastapi*.sql' + - 'ruoyi-fastapi-backend/scripts/plugin_release_integration.py' + - '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 + - 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 3b7a0d537..98ef2d57a 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 a3bb6bcf7..2259d6a65 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 81aad9c7a..c18634b5f 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 a9bef3d60..bcabcee4d 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 000000000..c3b05f82e --- /dev/null +++ b/ruoyi-fastapi-backend/cli/groups/plugin/artifact_controller.py @@ -0,0 +1,473 @@ +import importlib +import os +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.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, + ) -> None: + """ + 初始化制品命令控制器,并保留部署依赖的延迟加载。 + + :param context_factory: CLI 上下文工厂 + :param execution_service: CLI 执行服务 + :param config_factory: 制品部署配置工厂 + :param catalog_factory: 制品目录服务工厂 + :param service_factory: 维护发布服务工厂 + :param enablement_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 + + @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)), + ) + + 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 a535034d1..85e98174b 100644 --- a/ruoyi-fastapi-backend/cli/groups/plugin/command.py +++ b/ruoyi-fastapi-backend/cli/groups/plugin/command.py @@ -4,6 +4,7 @@ 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 @@ -11,6 +12,7 @@ from .commands.lifecycle import register_lifecycle_commands if TYPE_CHECKING: + from .artifact_controller import PluginArtifactCommandController from .controller import PluginCommandController app = typer.Typer( @@ -31,8 +33,20 @@ 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) 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 000000000..5f3d5e595 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/groups/plugin/commands/artifact.py @@ -0,0 +1,322 @@ +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 + +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_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('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/developer.py b/ruoyi-fastapi-backend/cli/groups/plugin/commands/developer.py index 8606c3884..79cd57725 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 create_command( # noqa: PLR0913 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/guards.py b/ruoyi-fastapi-backend/cli/guards.py index cdc3976ac..e28e94309 100644 --- a/ruoyi-fastapi-backend/cli/guards.py +++ b/ruoyi-fastapi-backend/cli/guards.py @@ -208,6 +208,24 @@ def guard(self, ctx: CliContext, *, rule: DangerousCommandRule) -> CommandResult '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 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 000000000..d0b32c12b --- /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 000000000..867e450a8 --- /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 000000000..e415ea432 --- /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 000000000..5065f60bd --- /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 000000000..8f6366732 --- /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 000000000..dc09a9e24 --- /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 000000000..b768d46e3 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/python/build_release.py.tmpl @@ -0,0 +1,100 @@ +import argparse +import json +import shutil +import stat +from pathlib import Path + +PLUGIN_ID = "__PLUGIN_ID__" +BUNDLE = __BUNDLE__ +SOURCE = Path(__file__).resolve().parent +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 main() -> None: + """ + 按显式文件清单组装新交付目录,不安装依赖、导入插件或生成签名。 + + :return: None + """ + parser = argparse.ArgumentParser( + description="仅组装本模板的交付文件;不安装依赖、不导入插件、不生成签名。" + ) + 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 / name) for name in ("plugin.yaml", "__init__.py")] + if BUNDLE: + dist = SOURCE / "web" / "dist" + checked_file(dist / "index.html") + 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 or any( + part.startswith(".") for part in path.relative_to(dist).parts + ): + raise ValueError( + f"bundle 存在非交付文件;不要包含源码、source map 或密钥:{path}" + ) + resources.append(checked_file(path)) + 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 000000000..5a249826b --- /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/index.html.tmpl b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/web/index.html.tmpl new file mode 100644 index 000000000..729dbe3bf --- /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 000000000..c1196051b --- /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 000000000..852a13479 --- /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": { "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 000000000..a6000ea8a --- /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 000000000..8d52c6cdd --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/assets/web/vite.config.js.tmpl @@ -0,0 +1,6 @@ +export default { + // 使用相对资源路径,使 bundle 可由宿主从插件目录交付。 + base: './', + // 交付目录只包含构建产物,不生成源码映射文件。 + 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 c651e234a..a48542dfd 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 .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 @@ def build_plan( :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 c9a8a88b5..0c0837349 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 @@ def resolve(cls, template: str) -> PluginScaffoldOptions: :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 000000000..0c5f10df1 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/v2.py @@ -0,0 +1,290 @@ +from importlib.resources import files +from pathlib import Path +from typing import Any + +import yaml + +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') + ) + if test: + entries.append((root / 'tests' / 'test_plugin.py', self._render('test_plugin.py', replacements))) + if bundle: + sdk_path = self.frontend_root / 'src' / 'utils' / 'pluginBridge.js' + if not sdk_path.is_file(): + raise ValueError(f'缺少宿主桥接 SDK:{sdk_path};请配置正确的 RUOYI_PLUGIN_FRONTEND_ROOT') + entries.append((root / 'web' / 'vendor' / 'pluginBridge.js', sdk_path.read_text(encoding='utf-8'))) + entries.extend( + (root / 'web' / name, self._render(f'web/{name.removeprefix("src/")}', replacements)) + for name in ('index.html', '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' + 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 打包脚本只复制本模板的清单、入口和已构建 bundle;' + '增加业务模块、SQL 或资源后,应明确扩展该复制清单并测试。' + ) + 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 '' + ) + 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}## 签名与维护发布 + +先在宿主启用 `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 改为实际数量,重启全部 worker 后再次查询 status;仅所有预期 worker 均就绪才算完成。 +`installedVersion` 是数据准备状态,选择目标不会立即切换运行代码。更新版本时同步清单{'、Cargo.toml、Cargo.lock 和 pyproject.toml' if native else ''}。 +旧代码回滚必须兼容当前数据库,不撤销迁移。不要覆盖已加载二进制或修改不可变制品目录。 + +`plugins/{plugin_id}` 如有同名源码目录,会与制品发布冲突;在维护窗口备份并移出后才能导入。 +原生插件与同源 iframe 均为可信扩展,不构成安全沙箱。 + +## 访问 + +权限为 `{plugin_id}:view`,接口为 `GET /apps/{plugin_id}/api/info`。请求沿用宿主认证与传输加密策略。 +{'由宿主 PluginFrame 打开页面;前端通过 createPluginClient.ready 建立会话,再 request({method: "GET", path: "info"}),不得读取或保存管理员 token。桥仅支持有大小限制的 JSON 请求,不支持上传、流式响应或任意外部 URL。' if bundle else '先给角色授予插件权限,再通过宿主认证调用 API;子应用不提供独立登录。'} +""" diff --git a/ruoyi-fastapi-backend/config/env.py b/ruoyi-fastapi-backend/config/env.py index d515a2cda..e7e53913c 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 @@ def parse_cli_args() -> str: 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 9bd8b1fac..cc18c361c 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 112fde133..95c3dbc50 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 ``` @@ -522,7 +538,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 +858,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 cf41b453a..66a463de0 100644 --- a/ruoyi-fastapi-backend/docs/plugin_development.md +++ b/ruoyi-fastapi-backend/docs/plugin_development.md @@ -2,6 +2,20 @@ 本文档面向插件开发者,说明如何在当前插件系统中创建、安装、启用、调试和发布插件。 +本文覆盖 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) | +| 签名交付与生产维护 | [制品与发布](#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 +25,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 +39,8 @@ ruoyi-fastapi-frontend/plugins// ## 2. 快速开始 +本节使用默认 v1 模板。创建 v2 工程时,直接使用[第 16 节](#16-v2-项目脚手架与完整构建)的 `python-asgi`、`python-bundle`、`rust-asgi` 或 `rust-bundle` 模板。 + 进入后端项目目录: ```bash @@ -51,6 +70,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 +136,8 @@ plugins/demo/ ## 4. plugin.yaml 示例 +以下为 v1 清单示例;v2 的完整清单及差异见[第 5.10 节](#510-v2-清单与能力组合)。 + ```yaml manifestVersion: 1 id: demo @@ -238,8 +261,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 +291,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 +303,7 @@ config: | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | -| `autoScan` | `boolean` | `true` | 是否按插件模块自动扫描并注册控制器。 | +| `autoScan` | `boolean` | v1 为 `true`,v2 为 `false` | 是否按插件模块自动扫描并注册控制器;v2 只能由显式入口返回路由。 | `backend.health`: @@ -304,7 +327,7 @@ config: | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | -| `id` | `string` | 必填 | 插件内任务唯一标识。只能包含小写字母、数字、下划线和中划线,必须以小写字母开头。 | +| `id` | `string` | 必填 | 插件内任务唯一标识。只能包含小写字母、数字和下划线,长度 2-64,必须以小写字母开头。 | | `name` | `string \| null` | `id` | 任务展示名称。 | | `callable` | `string` | 必填 | 任务函数路径,格式为 `.`。 | | `trigger` | `"cron"` | `"cron"` | 任务触发器类型。 | @@ -315,7 +338,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 +356,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 +365,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 +442,7 @@ dependencies: | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `backendVersion` | `string \| null` | `null` | 后端版本约束。 | +| `hostApiVersion` | `string` | `^1.0.0` | 仅 v2:宿主插件 SDK 版本约束,独立于应用版本;当前 Host API 为 `1.2.0`,bundle 使用 `^1.2.0`。 | | `frontendVersion` | `string \| null` | `null` | 前端版本约束。 | | `pythonVersion` | `string \| null` | `null` | Python 版本约束。 | | `nodeVersion` | `string \| null` | `null` | Node.js 版本约束。 | @@ -505,11 +530,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 +620,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 +693,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 +731,115 @@ 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 的调度选主沿用宿主调度器。 + +### 6.6 v2 显式入口与宿主 SDK + +清单中的 `create_plugin(host)` 必须同步、快速地返回 `PluginDefinition`;它只声明能力,不打开连接、启动线程或创建后台任务。当前 `PluginDefinition.api_version` 为 `1`,与清单版本 `2`、Host API 版本 `1.2.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`、`services`、`session_factory`、`redis`、`logger`、`startup_write_enabled`、`api_version` | 配置为加载时快照,修改后重启读取;不保存请求身份或请求数据库会话 | +| `PluginRequestContext` | `host`、当前 `user`、`permissions`、`require_permission()`;可选 `request_id/query_db` | 当前宿主默认不填充后两个可选字段,不能假定存在请求事务、数据权限上下文或单独取消信号 | +| `PluginTaskContext` | `host`、`job_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` 不能用来绕过该流程。 + +当前公开的版本化业务服务为 `host.service('users.current_profile.v1')`。使用 `await host.service('users.current_profile.v1')(context)` 调用,需声明并授予 `:profile` 权限。该服务再次核对插件和当前用户身份,独立创建数据库会话,返回 `userId/userName/nickName/avatar/postGroup/roleGroup`,不返回密码或内部 ORM 对象。多次服务调用不会自动组成一个事务,新增业务能力应定义明确的权限、数据权限、DTO 和事务边界。 + +`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.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 失败后同样会调用。 + +新启用或更新代码需要重启。停用检查会拒绝后续新请求,但不会自动终止已建立的 WebSocket 或正在处理的请求;正式升级应停止全部宿主 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 +860,55 @@ 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。已经建立的长连接不会因此自动排空。 + +浏览器 SDK 位于前端 `src/utils/pluginBridge.js`,无 Vue/Axios 依赖。示例通过构建别名引入;独立插件仓库可把这一模块作为版本化依赖复制到自己的源码中。SDK 提供 `ready`、`request`、`navigate`、`subscribe` 和 `destroy`,具体用法见 [bundle_demo README](../plugins/examples/python/bundle_demo/README.md)。请求只允许当前插件 API 下的相对 `path`、JSON `params/data`;主页面使用原有加密请求客户端代理,自动附加插件 CSRF,并禁用主 Bearer。主 token、插件 Cookie 和 CSRF 值不进入桥消息。 + +当前 JSON 桥每条消息最多 64 KiB、最多 8 个待处理请求、默认 15 秒超时,支持取消;不支持上传、二进制下载、streaming、SSE 或 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` 为空,插件走同源 `/apps/` 和 `/plugin/runtime/`。若后端 `APP_ROOT_PATH=/prefix`,宿主前端需设置 `VITE_APP_PLUGIN_BASE=/prefix`,代理也要保留相同前缀及外部 Host;HTTPS 的原始 scheme 由可信代理配置传递。仓库 Vite 与 Docker Nginx 已提供对应代理入口,生产环境应验证 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 配置。 + +`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` 中。支持类型: @@ -771,7 +1026,7 @@ ruoyi plugin batch enable --env=dev --yes - `uninstall`:安全卸载,保留可恢复数据。 - `purge`:清理插件平台元数据,属于高风险操作。 -生产环境执行危险操作需要显式传入 `--allow-prod --yes`。 +生产环境执行危险操作需要显式传入 `--allow-prod --yes`;这两个参数不会绕过服务模式的生命周期限制。v2 签名制品应使用第 15 节的独立维护发布命令,不要通过切换到 dev 环境操作生产数据库。 ## 11. 默认启用内置插件 @@ -852,9 +1107,269 @@ 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/upgrade_plugin_artifact_mysql.sql) 和 [PostgreSQL](../sql/upgrade_plugin_artifact_postgresql.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、反向代理及运维进程管理仍需在部署环境验收。 + +管理页提供制品与发布状态的只读查看入口,签名制品使用上述维护 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`,读取静态发布预检计划 | + +当前宿主未启用制品发布监控时,管理页显示状态不可确认。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`。若旧代码不能使用当前数据结构,应制定数据库恢复方案,不能用代码回滚命令替代。 + +当前没有自动停止进程、热卸载、存量连接排空或制品清理 CLI。修改 enabled、选择目标或撤销签名密钥均不能卸载进程中已加载的 Python/Rust 模块;处理仍需维护窗口和进程退出。保留仍被目标、上一目标或进程引用的版本目录,不要覆盖正在使用的二进制。 + +制品 digest 是规范化 `artifacts.json` 的 SHA256,覆盖每个有效载荷文件的路径、大小和摘要;它不包含签名或 keyId。因此,用新密钥给完全相同内容重签不会生成新 digest,也不会替换现有对象的 `signature.json`。旧签名仍受信时重复导入返回原对象;旧签名已撤销时原对象复验失败,新签名不会覆盖它。需要轮换时,优先提升插件版本并发布新内容;若必须保留相同内容,必须在停机维护中显式处理旧对象及数据库索引/目标引用后重新登记。当前没有自动清理命令,不应直接覆盖签名文件或将已存在目录视为可信缓存。 + +## 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,使用相对资源基址和专用插件会话,不内嵌主登录 token。它与宿主 Vue 版本独立,v2 模板不接受 `--frontend-version`;bundle 也不能配合 `--backend-only`。 + +以下以 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 打包器仅复制模板清单、入口和构建后的 bundle;增加业务模块、SQL 或其他资源时,应显式扩展复制清单并测试。Python 插件仍以 Python 源码交付。Rust 交付不包含 `.rs`、Cargo 或前端开发工程;两种方式均需按第 15 节签名、导入、维护准备、选择目标及重启验收。所有构建输出必须使用新目录,不能覆盖已加载版本。 + +## 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/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/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 脚手架、原生扩展以及签名制品与维护发布回归。 + +发布集成工作流在 Python 3.10、3.11、3.12、3.13 上使用临时 MySQL、PostgreSQL 和 Redis 服务验证多 worker 发布行为。工作流配置不代表已经执行成功;发布前应核对对应提交的实际 CI 结果,并完成目标平台的部署验收。 diff --git a/ruoyi-fastapi-backend/docs/unified_authentication_developer_guide.md b/ruoyi-fastapi-backend/docs/unified_authentication_developer_guide.md index 2cdcb3316..ff0b9ae37 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 6289ce2be..af36983c9 100644 --- a/ruoyi-fastapi-backend/middlewares/transport_crypto_middleware.py +++ b/ruoyi-fastapi-backend/middlewares/transport_crypto_middleware.py @@ -71,6 +71,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 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 @@ def _classify_failure_reason(message: str) -> str: 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 565f0c32a..579a7d8eb 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 5368d15d7..b7692c105 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 @@ 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 @@ async def get_current_user( :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 @@ async def get_current_user( 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 @@ async def get_current_user_routers(cls, user_id: int, query_db: AsyncSession) -> ], 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 @@ def __generate_menus(cls, pid: int, permission_list: list[SysMenu]) -> list[Menu 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 @@ def __generate_user_router_menu(cls, permission_list: list[MenuTreeModel]) -> li 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 @@ def __generate_user_router_menu(cls, permission_list: list[MenuTreeModel]) -> li 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 d043882f2..ece342072 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 @@ def _require_enabled(cls, *, management: bool = False) -> None: @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 000000000..070fc1d37 --- /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_release_controller.py b/ruoyi-fastapi-backend/module_plugin/controller/plugin_release_controller.py new file mode 100644 index 000000000..8e5b31b0a --- /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 000000000..af7732c68 --- /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 000000000..1e7a89562 --- /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 000000000..c1f63ffc9 --- /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 000000000..b5ab765e5 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/artifacts/store.py @@ -0,0 +1,343 @@ +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]) -> StoredArtifact: + """ + 核对制品目录内部布局、签名文档和全部已登记文件。 + + :param directory: 待验证的候选目录或已发布制品根目录 + :param trusted_keys: 由宿主提供的签名密钥标识与可信公钥映射 + :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) + 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 359c7fc3b..f46086731 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 @@ def resolve(self, discovered_plugin: DiscoveredPlugin) -> PluginRuntimeCapabilit """ 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 000000000..e69de29bb 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 000000000..12a76ed05 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/deployment/catalog.py @@ -0,0 +1,185 @@ +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.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, + ) -> None: + """ + 初始化不可变制品目录与数据库索引的访问服务。 + + :param config: 制品发布配置快照 + :param session_factory: 异步数据库会话工厂,未提供时使用宿主数据源 + :param dao: 制品、发布目标及进程报告的数据访问接口 + :return: None + """ + self.config = config + self.session_factory = session_factory or DataSourceRegistry.session + self.dao = dao + + @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), + } + stored = await asyncio.to_thread( + self.store.import_artifact, path, keys, validate_candidate=self.validate_candidate + ) + # 文件与数据库不能共享事务。数据库失败时保留已验签不可变对象,重试可幂等登记。 + 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: 制品不存在、索引不一致或当前信任及文件校验失败 + """ + 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 000000000..1d6132c70 --- /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/enablement.py b/ruoyi-fastapi-backend/plugins/core/deployment/enablement.py new file mode 100644 index 000000000..ee758deab --- /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/service.py b/ruoyi-fastapi-backend/plugins/core/deployment/service.py new file mode 100644 index 000000000..db9d0fb84 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/deployment/service.py @@ -0,0 +1,503 @@ +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.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 _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 000000000..6d2662b55 --- /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 000000000..7562b4b01 --- /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 072f22cde..7eab247b1 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 ce5f981e7..016d71090 100644 --- a/ruoyi-fastapi-backend/plugins/core/environment.py +++ b/ruoyi-fastapi-backend/plugins/core/environment.py @@ -5,7 +5,7 @@ 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 94d9b33dd..a9f1cbc63 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.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 @@ async def install_plugin_jobs(self, discovered_plugin: DiscoveredPlugin, *, enab 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 4272cff56..01215845f 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 @@ '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 000000000..53870fd78 --- /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 000000000..153d1da4d --- /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 bf0733c44..efdc1da77 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 c19f02021..346f428ec 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 @@ async def get_plugin_state(self, plugin_id: str) -> PluginStateRecord | None: 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 000000000..3f8fb4756 --- /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 000000000..97f3d651d --- /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 d01170fa8..6d617596b 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 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 @@ async def get_plugin_list_services(cls, query_db: AsyncSession) -> list[PluginMo 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 @@ async def get_plugin_page_list_services( 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 @@ async def get_plugin_page_list_services( 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 @@ async def plugin_detail_services( 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 @@ async def build_plugin_purge_plan_by_id_services( :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 @@ async def _purge_plugin_metadata_by_id(cls, query_db: AsyncSession, plugin_id: s :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 @@ def _build_plugin_model( 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 @@ def _build_plugin_model( 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 29b323c77..5c800cf60 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 @@ def permission_codes(self) -> list[str]: """ 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 @@ def create(cls, raw_manifest: dict[str, Any]) -> PluginManifest: :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 000000000..f290b7f05 --- /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 000000000..e69de29bb 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 000000000..54655e07c --- /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 695b2fa52..c94bb13e3 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/application.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/application.py @@ -7,6 +7,7 @@ from fastapi import FastAPI from common.constant import LockConstant +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 +34,7 @@ def __init__( 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 +47,7 @@ def __init__( :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 +58,7 @@ def __init__( 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 +79,8 @@ def prepare_metadata(self, app: FastAPI) -> None: """ 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 +98,47 @@ async def startup( :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() + except BaseException: + # 激活后的 ready 发布或锁释放也可能失败,此时 server 尚未标记启动完成。 + # 显式资源需在这里释放,不能依赖正常 shutdown 分支。 + runtime = getattr(app.state, 'plugin_explicit_runtime', None) + try: + if isinstance(runtime, ExplicitPluginRuntime): + await runtime.shutdown() + finally: + 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 +383,11 @@ async def shutdown(self, app: FastAPI) -> None: """ 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: + if self.release_worker is not None: + await self.release_worker.stop() async def clear_startup_ready(self, app: FastAPI, generation: str | None = None) -> None: """ @@ -468,6 +518,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 +529,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 000000000..af1faf054 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/asgi.py @@ -0,0 +1,188 @@ +import asyncio +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.sdk.context import PluginRequestContext + + +class PluginLifespanManager: + """ + 通过 ASGI lifespan 协议驱动一个 worker 内的插件实例。 + """ + + def __init__(self, app: ASGIApp, *, timeout: float = 30.0, managed: bool = True) -> None: + """ + 初始化当前 worker 的插件生命周期管理器。 + + :param app: 插件 ASGI 应用 + :param timeout: 生命周期事件确认的超时时间,单位为秒 + :param managed: 是否由宿主驱动 ASGI 生命周期协议 + :return: None + """ + self.app = app + self.timeout = timeout + self.managed = managed + self.state: dict[str, Any] = {} + self.started = False + self._task: asyncio.Task[None] | None = None + self._receive: asyncio.Queue[Message] = asyncio.Queue() + self._send: asyncio.Queue[Message] = asyncio.Queue() + + @property + def ready(self) -> bool: + """ + lifespan 意外退出后不再接受业务请求。 + + :return: 插件是否已启动且生命周期任务仍然存活 + """ + return self.started and (not self.managed or (self._task is not None and not self._task.done())) + + async def startup(self) -> None: + """ + 只有确认 startup.complete 后才允许接收业务请求。 + + :return: None + """ + if self.started: + return + 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.app(scope, self._receive.get, self._send.put)) + 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 + """ + try: + if self.started and self.managed: + await self._exchange('shutdown') + await asyncio.wait_for(asyncio.shield(self._task), timeout=self.timeout) + finally: + self.started = False + await self._cancel_task() + + 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._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._task.result() + raise RuntimeError('ASGI lifespan 在 startup 后提前退出') + finally: + response.cancel() + with suppress(asyncio.CancelledError): + await response + + async def _cancel_task(self) -> None: + """ + 取消并回收当前 worker 的生命周期任务。 + + :return: None + """ + if self._task is None: + return + self._task.cancel() + with suppress(asyncio.CancelledError, Exception): + await self._task + self._task = None + + +class PluginGatewayASGI: + """ + HTTP、静态资源和 WebSocket 共用的宿主门禁。 + + 接受宿主 Bearer 或限定 bundle 的插件会话,由宿主授权器校验。 + 静态资源与业务接口都必须经过同一身份门禁。 + """ + + def __init__( + self, + app: ASGIApp, + lifespan: PluginLifespanManager, + authorize: Callable[[Scope], Awaitable[PluginRequestContext]], + ) -> None: + """ + 初始化插件请求门禁。 + + :param app: 受保护的插件 ASGI 应用 + :param lifespan: 当前插件的生命周期管理器 + :param authorize: 根据 ASGI 请求作用域构建宿主身份上下文的异步授权器 + :return: None + """ + self.app = app + self.lifespan = lifespan + self.authorize = authorize + + 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: + 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.app(child_scope, receive, send) + + @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 f74c8b3c2..2bff74cda 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/bootstrap.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/bootstrap.py @@ -54,6 +54,30 @@ def __init__(self, backend_root: Path | str | None = None) -> None: 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 @@ def discover_plugins(self) -> list[DiscoveredPlugin]: 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 @@ def import_plugin_entities(self, plugin_registry: PluginRegistry) -> PluginEntit """ 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 000000000..e07261755 --- /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 000000000..1f8a74e8b --- /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 eefa6637f..a590e6474 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/callable.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/callable.py @@ -50,6 +50,13 @@ def load(self, callable_path: str) -> LoadedPluginCallable: :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/entrypoint.py b/ruoyi-fastapi-backend/plugins/core/runtime/entrypoint.py new file mode 100644 index 000000000..44098be12 --- /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 000000000..dbfd2942a --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/explicit.py @@ -0,0 +1,398 @@ +import inspect +from collections.abc import Mapping +from dataclasses import dataclass +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.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.route_guard import PluginEnabledDependency, PluginRouteStateGateway +from plugins.core.sdk import PluginDefinition, PluginHostContext, PluginRequestContext +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 中的插件任务绑定 + """ + + plugin: RegisteredPlugin + host: PluginHostContext + definition: PluginDefinition + lifespan: PluginLifespanManager | None = None + active: bool = False + jobs: PluginJobBinding | None = None + + +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] = {} + + @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)) + host = PluginHostContext( + plugin_id=plugin.plugin_id, + resource_root=discovered.backend_path, + config=config_values + if config_values is not None + else {item.key: item.default for item in manifest.config.items}, + session_factory=DataSourceRegistry.session, + services=build_host_services(plugin.plugin_id, DataSourceRegistry.session), + 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) + + async def activate(self, plugin_id: str, app: FastAPI) -> None: + """ + 全部校验和初始化成功后才向应用注册路由或挂载子应用。 + + :param plugin_id: 已完成准备的插件 ID + :param app: 宿主 FastAPI 应用 + :return: None + """ + loaded = self.loaded[plugin_id] + if loaded.active: + return + 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), + ) + try: + await self._activate_web(loaded, app) + except BaseException: + if loaded.jobs is not None: + await unbind_plugin_jobs(loaded.jobs) + loaded.jobs = None + raise + loaded.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) + 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') + try: + await manager.startup() + await self._check_health(loaded, app) + 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)) + app.router.routes.append(Mount(mount_path, app=gateway, name=f'plugin:{plugin_id}')) + except BaseException: + await manager.shutdown() + raise + loaded.lifespan = manager + + @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) + 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: + app.include_router( + router, + dependencies=[ + PluginEnabledDependency(plugin_id, self.route_state_gateway), + Depends(set_context), + ], + ) + + 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) + 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: + user = await LoginService.get_current_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) + + async def shutdown(self) -> None: + """ + 逆序关闭,每个实例的失败不阻止其他实例释放资源。 + + :return: None + """ + for loaded in reversed(list(self.loaded.values())): + loaded.active = False + if loaded.jobs is not None: + await unbind_plugin_jobs(loaded.jobs) + loaded.jobs = None + if loaded.lifespan is not None: + try: + await loaded.lifespan.shutdown() + except Exception: + logger.exception(f'插件 ASGI 关闭失败:{loaded.plugin.plugin_id}') diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/health.py b/ruoyi-fastapi-backend/plugins/core/runtime/health.py index c2155de00..f107b5a02 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/health.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/health.py @@ -7,6 +7,7 @@ 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 @@ async def _invoke_checker_with_timeout( :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) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/hooks.py b/ruoyi-fastapi-backend/plugins/core/runtime/hooks.py index ff9391d0f..5cd970113 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/hooks.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/hooks.py @@ -6,6 +6,7 @@ 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 @@ async def _invoke_hook_with_timeout( :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 000000000..ecd56e8b1 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/host_services.py @@ -0,0 +1,46 @@ +from collections.abc import Callable +from typing import Any + +from plugins.core.sdk.context import PluginRequestContext + + +def build_host_services(plugin_id: str, session_factory: Callable[..., Any]) -> dict[str, Callable[..., Any]]: + """ + 请求身份只能来自宿主上下文;服务自己创建和释放数据库会话。 + + :param plugin_id: 使用宿主服务的插件 ID + :param session_factory: 创建宿主数据库会话的工厂 + :return: 按版本化服务名称索引的宿主服务集合 + """ + + async def current_user_profile(context: PluginRequestContext) -> dict[str, Any]: + """ + 验证插件身份和接口权限后读取当前用户资料。 + + :param context: 宿主鉴权层建立的插件请求上下文 + :return: 仅包含允许向插件公开字段的当前用户资料 + """ + if not isinstance(context, PluginRequestContext) or context.host.plugin_id != plugin_id: + raise PermissionError('请求上下文不属于当前插件') + context.require_permission(f'{plugin_id}:profile') + 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('缺少有效的当前用户身份') + + from module_admin.service.user_service import UserService # noqa: PLC0415 + + async with 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, + } + + return {'users.current_profile.v1': current_user_profile} 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 000000000..bc26a429e --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/job_dispatcher.py @@ -0,0 +1,119 @@ +import asyncio +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any + +from plugins.core.discovery.scanner import DiscoveredPlugin +from plugins.core.runtime.entrypoint import PluginEntrypointLoader +from plugins.core.runtime.route_guard import PluginRouteStateGateway +from plugins.core.sdk import PluginHostContext, PluginTaskContext, await_plugin_callback + +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: 当前仍在执行的插件异步任务集合 + """ + + plugin: DiscoveredPlugin + host: PluginHostContext + gateway: PluginRouteStateGateway + ready: Callable[[], bool] + loop: asyncio.AbstractEventLoop + tasks: set[asyncio.Task[Any]] = field(default_factory=set) + + +_bindings: dict[str, PluginJobBinding] = {} + + +def bind_plugin_jobs( + plugin: DiscoveredPlugin, + host: PluginHostContext, + gateway: PluginRouteStateGateway, + ready: Callable[[], bool], +) -> PluginJobBinding: + """ + 独占注册;同进程的第二个应用不能覆盖已有插件资源。 + + :param plugin: 任务所属的已发现插件 + :param host: 任务使用的宿主能力上下文 + :param gateway: 插件启用状态查询网关 + :param ready: 查询插件是否就绪的回调 + :return: 绑定到当前事件循环的插件任务能力 + """ + plugin_id = plugin.manifest.id + if plugin_id in _bindings: + 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()) + _bindings[plugin_id] = binding + return binding + + +async def unbind_plugin_jobs(binding: PluginJobBinding) -> None: + """ + 先停止接收,再取消并等待任务释放会话,最后由调用方关闭子应用。 + + :param binding: 待解除的插件任务绑定 + :return: None + """ + plugin_id = binding.plugin.manifest.id + if _bindings.get(plugin_id) is binding: + del _bindings[plugin_id] + tasks = [task for task in binding.tasks if task is not asyncio.current_task()] + for task in tasks: + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + + +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 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) + try: + async with binding.host.session_factory() as db: + if not await binding.gateway.is_plugin_enabled(db, plugin_id): + raise PermissionError(f'插件未启用:{plugin_id}') + # 数据库等待期间可能开始关闭,不能在解绑后再启动原生回调。 + 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}') + return await await_plugin_callback( + callback, + PluginTaskContext(binding.host, job_id), + *job.args, + kwargs=job.kwargs, + timeout=job.timeout_seconds, + ) + finally: + binding.tasks.discard(task) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/config.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/config.py index e13cd4881..92cd6e7e9 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 @@ async def get_plugin_config(self, plugin_id: str, *, reveal_secret: bool = False :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 @@ async def set_plugin_config( :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 c6d51157a..8d06e0fed 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 @@ 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 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 @@ def __init__(self, dependencies: PluginRuntimeDependencies) -> None: 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 @@ async def load_database_plugin_state(self, plugin_id: str) -> tuple[PluginStateR 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 @@ def discover_plugins_with_errors(self, backend_root: Path) -> PluginDiscoveryRes :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 171a32420..50ce7e543 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 @@ def __init__( ) ) 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 @@ def _execute_plugin_dependency_install( :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 338dc5686..0a380539e 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 @@ async def set_plugin_enabled( :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 @@ async def uninstall_plugin( :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 3db74de1e..722d0a7a7 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 @@ async def install_plugin( :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 d9e3c7a07..64506db9d 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 @@ async def purge_plugin( :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 2bb2c5775..0d923d58d 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 @@ async def upgrade_plugin( :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 9f28dd980..33141c6ba 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 @@ def __init__(self, expire_seconds: int | None = None) -> None: 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/startup.py b/ruoyi-fastapi-backend/plugins/core/runtime/startup.py index 48966ff03..e23f53fb0 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/startup.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/startup.py @@ -11,6 +11,7 @@ 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.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 @@ async def prepare_enabled_plugins(self, app: FastAPI, *, startup_write_enabled: 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 @@ async def requires_startup_write(self) -> bool: 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 @@ async def check_enabled_plugin_python_dependencies( 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 @@ async def activate_enabled_plugins(self, app: FastAPI, *, startup_write_enabled: 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 @@ async def shutdown(self, app: FastAPI, *, startup_write_enabled: bool = True) -> :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 @@ async def load_registry_from_database(self, app: FastAPI) -> None: 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 @@ async def install_enabled_plugin_resources(self, app: FastAPI) -> None: 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 @@ async def sync_enabled_plugin_install_states(self, app: FastAPI) -> None: 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 @@ async def run_single_plugin_hook( 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 @@ async def mark_plugin_runtime_error(self, app: FastAPI, plugin_id: str, error_me :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 @@ async def sync_default_enabled_builtin_plugin_install_states(self) -> set[str]: 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 @@ def disable_runtime_plugins(app: FastAPI, plugin_ids: set[str]) -> None: 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 bcb011980..e8cdc6e6a 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 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 @@ def resolve(self) -> str: 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 b528cc553..b45713960 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 @@ def build_plugin_detail( :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 @@ def build_plugin_detail( '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/sdk/__init__.py b/ruoyi-fastapi-backend/plugins/core/sdk/__init__.py new file mode 100644 index 000000000..bdd8c4560 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/sdk/__init__.py @@ -0,0 +1,15 @@ +from plugins.core.sdk.asgi import plugin_endpoint, plugin_lifespan +from plugins.core.sdk.context import PluginHostContext, PluginRequestContext, PluginTaskContext +from plugins.core.sdk.definition import PluginDefinition, await_plugin_callback +from plugins.core.sdk.version import HOST_API_VERSION + +__all__ = [ + 'HOST_API_VERSION', + 'PluginDefinition', + 'PluginHostContext', + '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 000000000..74120478c --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/sdk/asgi.py @@ -0,0 +1,73 @@ +import asyncio +from collections.abc import AsyncGenerator, Callable, Mapping +from contextlib import asynccontextmanager +from typing import Any + +from fastapi import FastAPI, HTTPException, Request + +from plugins.core.sdk.context import PluginHostContext, PluginRequestContext +from plugins.core.sdk.definition import await_plugin_callback + + +def plugin_endpoint(callback: Callable[..., Any], *, permission: str, timeout: float = 30.0) -> Callable[..., Any]: + """ + 原生函数只接收已鉴权的上下文,返回 awaitable;不让 FastAPI 反射原生签名。 + + :param callback: 接收插件请求上下文并返回可等待对象的业务回调 + :param permission: 调用业务回调要求的接口权限标识 + :param timeout: 业务回调等待超时时间,单位为秒 + :return: 具有固定 Request 参数的 FastAPI 端点函数 + """ + + 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) + 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/context.py b/ruoyi-fastapi-backend/plugins/core/sdk/context.py new file mode 100644 index 000000000..f2a25d8f6 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/sdk/context.py @@ -0,0 +1,112 @@ +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from pathlib import Path +from types import MappingProxyType +from typing import Any + +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 版本 + """ + + 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 + + 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))) + + 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: 由宿主按请求需要注入的数据库会话 + """ + + host: PluginHostContext + user: Any + permissions: frozenset[str] = frozenset() + request_id: str | None = None + query_db: Any = None + + 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}') + + +@dataclass(frozen=True) +class PluginTaskContext: + """ + 后台任务身份;不隐式授予请求用户或管理员权限。 + + :param host: 当前插件的宿主能力上下文 + :param job_id: 插件清单声明的任务 ID + """ + + host: PluginHostContext + job_id: str 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 000000000..f72f6ed15 --- /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/version.py b/ruoyi-fastapi-backend/plugins/core/sdk/version.py new file mode 100644 index 000000000..e1eb7e5bf --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/sdk/version.py @@ -0,0 +1 @@ +HOST_API_VERSION = '1.2.0' diff --git a/ruoyi-fastapi-backend/plugins/core/validation/manifest.py b/ruoyi-fastapi-backend/plugins/core/validation/manifest.py index d1e660d57..5aede7d75 100644 --- a/ruoyi-fastapi-backend/plugins/core/validation/manifest.py +++ b/ruoyi-fastapi-backend/plugins/core/validation/manifest.py @@ -10,6 +10,7 @@ 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 @@ def _check_compatibility(self, manifest: PluginManifest) -> list[PluginValidatio ('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 84b108076..afaea4278 100644 --- a/ruoyi-fastapi-backend/plugins/core/validation/structure.py +++ b/ruoyi-fastapi-backend/plugins/core/validation/structure.py @@ -135,6 +135,8 @@ def check( 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 @@ def check( 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 000000000..4f4354e93 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/asgi_demo/README.md @@ -0,0 +1,31 @@ +# ASGI 子应用示例 + +本示例验证 v2 显式入口、子应用挂载、宿主 Bearer 认证、请求权限及 lifespan。它只提供 API;需要已接入的 PluginFrame、独立打包页面与浏览器会话桥时,参见 [bundle_demo](../bundle_demo/README.md)。 + +清单、入口与 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 000000000..d69e54271 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/asgi_demo/__init__.py @@ -0,0 +1,56 @@ +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager + +from fastapi import FastAPI, HTTPException, Request + +from plugins.core.sdk import PluginDefinition, PluginHostContext + + +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} + + 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 000000000..46531d4a6 --- /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.0.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 000000000..3317dc156 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/README.md @@ -0,0 +1,192 @@ +# 独立打包前端插件示例 + +本示例使用 **Python ASGI 后端 + 独立 Vite 前端 bundle**。页面通过宿主 `PluginFrame` 打开,取得宿主主题、语言、时区和路由状态,再经受限 JSON 桥调用本插件的查询与回显 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`。迁移到独立插件仓库时,把该无依赖模块复制为自己的版本化源码依赖,并调整 `vite.config.js` 中 `@ruoyi/plugin-bridge` 的别名。 + +如果本仓库宿主前端依赖已安装,也可复用其中的 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 或只启动插件 Vite 页面不会建立宿主身份桥;完整交互应从管理平台的插件菜单进入。 + +## 离线目录包 + +构建完成后,只打包清单、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 $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" "$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 + 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,也不覆盖已有签名;旧签名撤销后需要新内容或显式维护清理。当前没有热卸载、自动停止 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.2.0`。菜单组件为 `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 个待处理请求,默认 15 秒超时;不支持 `FormData` 上传、文件/二进制下载、streaming、SSE 或 WebSocket 代理。 + +## 会话、权限与传输加密 + +`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 请求,父页面使用已有加密 `request` 客户端,禁用自动添加主 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 以及代理前缀。 + +同源插件属于可信代码。iframe 仅隔离布局和依赖;它不是阻止恶意插件读取同源数据的安全沙箱。现有主 token 在宿主前端可读,“桥不传 token”不等于不可信同源代码无法接触宿主凭证。 + +## 代理前缀与验证 + +默认 `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/module_admin/service/test_login_plugin_routes.py -q +npm --prefix ../ruoyi-fastapi-frontend run test:plugin +``` + +测试覆盖静态边界、Cookie/CSRF/退出、真实 RSA/AES 协议和 `root_path`;数据库及登录查询使用替身。实际角色配置、TLS/Nginx、多 worker、既有 WebSocket 或长连接排空仍需部署验收,当前不提供这些连接的自动关闭协议。 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 000000000..54e70a70f --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/__init__.py @@ -0,0 +1,59 @@ +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 + + +class EchoInput(BaseModel): + """ + 用于验证 POST 链路的回显请求参数。 + """ + + message: str = Field(min_length=1, max_length=80) + + +async def summary(context: PluginRequestContext) -> dict[str, str]: + return { + 'pluginId': context.host.plugin_id, + 'userName': context.user.user.user_name, + 'serverTime': TimezoneUtil.utc_now().isoformat(), + } + + +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) + 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/plugin.yaml b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/plugin.yaml new file mode 100644 index 000000000..fc05620df --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/plugin.yaml @@ -0,0 +1,30 @@ +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.2.0 + pythonVersion: '>=3.10' 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 000000000..7fde34b50 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/index.html @@ -0,0 +1,75 @@ + + + + + + 独立前端示例 + + +
+
+
+

插件应用

+

独立前端示例

+

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

+
+ +
+

+ 正在连接管理平台… +

+
+
+

当前用户

+

等待连接

+
+
+

显示时区

+

等待连接

+
+
+

服务端时间

+

等待连接

+
+
+
+ +
+

发送一条测试消息

+

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

+
+ +
+ + +
+
+

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

+
+ +
+
+ + + 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 000000000..5765dad70 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/package.json @@ -0,0 +1,12 @@ +{ + "name": "ruoyi-plugin-bundle-demo", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "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 000000000..17ded7fd0 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/src/main.js @@ -0,0 +1,106 @@ +import { createPluginClient } from '@ruoyi/plugin-bridge' +import './style.css' + +// 统一按 ID 获取示例页面中的固定元素。 +const element = (id) => document.getElementById(id) +let client +let context = {} + +/** + * 更新连接提示,并区分错误状态。 + */ +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 +} +/** + * 应用宿主推送的主题、语言、时区和插件内部路由。 + */ +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('已连接管理平台,当前会话有效。') + } 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() + element('send').disabled = true + element('echo-result').textContent = '正在发送…' + try { + const response = await client.request({ + method: 'POST', + path: 'echo', + data: { message: element('message').value }, + }) + element('echo-result').textContent = `服务端已收到:${response.message}` + } catch (error) { + element('echo-result').textContent = error.message + } finally { + element('send').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') { + element('workspace').replaceChildren( + Object.assign(document.createElement('p'), { + textContent: '登录已结束,请重新登录管理平台。', + }), + ) + } + }) + applyContext(await client.ready) + await refresh() + } catch (error) { + showNotice(error.message || '请从管理平台打开插件页面。', true) + setBusy(false) + 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 000000000..05cfde68c --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/src/style.css @@ -0,0 +1,197 @@ +: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; +} +: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 000000000..d3fca1f5b --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/vite.config.js @@ -0,0 +1,17 @@ +import { fileURLToPath, URL } from 'node:url' + +// 桥接 SDK 没有外部依赖,独立插件仓库可复制该模块并核对宿主版本兼容性。 +export default { + base: './', + 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 000000000..ed5615f3a --- /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 000000000..1d4326b1f --- /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 000000000..a82186eb3 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/README.md @@ -0,0 +1,117 @@ +# Rust 原生插件示例 + +本示例将业务实现编译成 PyO3 扩展,通过现有插件平台加载。默认创建 ASGI 子应用,也提供 Router 入口。Rust 可以调用已有 Python 工具,并通过宿主 SDK 调用用户资料服务。交付 ZIP 包含清单、原生扩展和包元数据,不包含 `.rs`、Cargo 文件或 Python 业务源码。 + +创建自有项目可使用 `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。后台上下文没有请求用户,不能调用要求请求身份的用户资料接口。 + +进程关闭先解绑入口、取消并等待正在执行的任务,再关闭子应用资源。异步插件必须配合取消;超时无法强制终止阻塞的原生代码。不支持 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 000000000..0bac2a2df --- /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.1.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 000000000..0d10f7a18 --- /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 000000000..e69de29bb 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 000000000..370aca874 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/src/lib.rs @@ -0,0 +1,171 @@ +//! 交付 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) in [ + ( + "/api/info", + "rust_demo:view", + wrap_pyfunction!(info, py)?, + "获取原生插件信息接口", + "用于获取当前插件标识、宿主API版本、运行引擎和服务器时间", + ), + ( + "/api/profile", + "rust_demo:profile", + wrap_pyfunction!(profile, py)?, + "获取原生插件当前用户信息接口", + "用于通过宿主服务获取当前登录用户的基本信息", + ), + ] { + let options = PyDict::new(py); + options.set_item("permission", permission)?; + 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::())?; + 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 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 b4e7eeb18..19973ac70 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 000000000..c328a4338 --- /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 000000000..f33d7898e --- /dev/null +++ b/ruoyi-fastapi-backend/scripts/plugin_release_integration.py @@ -0,0 +1,968 @@ +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 验证幂等性,再按初始化 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', + } + 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 = {} + for statement in initial_statements: + match = re.match(r'CREATE\s+TABLE\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 base table DDL: {table_name}') + table_statements[table_name] = statement + check(table_statements.keys() == required_tables, f'Incomplete base table DDL in {initial_filename}') + + filename = f'upgrade_plugin_artifact_{spec.engine}.sql' + statements = PluginLifecycleScriptHelper.split_sql_statements((BACKEND_ROOT / 'sql' / filename).read_text('utf-8')) + for _ in range(EXPECTED_WORKER_COUNT): + async with engine.begin() as connection: + for statement in statements: + await connection.exec_driver_sql(statement) + async with engine.begin() as connection: + # 制品、发布和 worker 表必须由前面的增量 SQL 创建,不能由 ORM 补建。 + for model in (SysPluginArtifact, SysPluginRelease, SysPluginWorker): + check(not (await connection.execute(select(model))).all(), f'{model.__tablename__} is not empty') + # 基础表沿用初始化 SQL,保留 DO 为兼容 Alembic 检查采用的默认值写法。 + # 只执行所需表的 CREATE TABLE,不导入种子数据或执行 DROP 等其他语句。 + for statement in table_statements.values(): + await connection.exec_driver_sql(statement) + emit('incremental_ddl', engine=spec.engine, script=filename, executions=2, 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] + queue.put( + { + 'ok': True, + 'workerId': worker.worker_id, + 'version': module.LOADED_VERSION, + 'modulePath': module.__file__, + 'active': loaded.active, + 'digest': target.digest, + '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) -> None: + """ + 创建两个 worker 及各自独立的停止事件。 + + :param spec: 当前验收的隔离运行信息,不包含连接密码 + :param one_fails: 是否让第二个 worker 主动触发启动失败 + :return: None + """ + self.spec = spec + 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 管理器 + """ + 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', + ) + 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 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(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) + await run_maintenance(spec, 'select', digest1) + async with Workers(spec) as workers: + 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) + await run_maintenance(spec, 'select', digest2) + async with Workers(spec) as workers: + 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']) + await run_maintenance(spec, 'rollback') + await verify_migrations(sessions, '2.0.0', 2) + async with Workers(spec) as workers: + 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) + 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 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') + emit('partial_and_stale', healthyWorkers=1, liveWorkers=1, staleWorkers=state['staleWorkers']) + 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 + """ + 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) + 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' + '仅用于研发验收;通过 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/sql/ruoyi-fastapi-pg.sql b/ruoyi-fastapi-backend/sql/ruoyi-fastapi-pg.sql index 71d0a9166..c0ed66c46 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 186062307..2c36a4d88 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_guards.py b/ruoyi-fastapi-backend/tests/cli/root/test_guards.py index 51ad432be..5ebac1ed7 100644 --- a/ruoyi-fastapi-backend/tests/cli/root/test_guards.py +++ b/ruoyi-fastapi-backend/tests/cli/root/test_guards.py @@ -40,6 +40,12 @@ def test_dangerous_command_rules_cover_expected_commands() -> None: 'gen export', 'gen sync-db', 'plugin install', + 'plugin artifact import', + '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 000000000..6c4dfeeba --- /dev/null +++ b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py @@ -0,0 +1,458 @@ +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'}), + (['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', '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']] +) +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', + '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'], + ['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 000000000..4fd5c4ed9 --- /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/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 000000000..fc69ee211 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_scaffold_v2.py @@ -0,0 +1,239 @@ +import ast +import json +import os +import subprocess +import sys +from pathlib import Path + +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') + + +@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: + assert (source / 'web' / 'vendor' / 'pluginBridge.js').read_text(encoding='utf-8') == ( + FRONTEND_ROOT / 'src' / 'utils' / 'pluginBridge.js' + ).read_text(encoding='utf-8') + package = json.loads((source / 'web' / 'package.json').read_text(encoding='utf-8')) + assert package['scripts']['build'] == 'vite build' + assert plugin.manifest.frontend.menus[0].component == 'PluginFrame' + assert plugin.manifest.frontend.menus[0].perms == f'{PLUGIN_ID}:view' + + +@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', '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'), + 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']) +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() diff --git a/ruoyi-fastapi-backend/tests/config/test_lifecycle.py b/ruoyi-fastapi-backend/tests/config/test_lifecycle.py index 8427ace61..6a965e552 100644 --- a/ruoyi-fastapi-backend/tests/config/test_lifecycle.py +++ b/ruoyi-fastapi-backend/tests/config/test_lifecycle.py @@ -4,6 +4,7 @@ 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 connection_context() -> AsyncGenerator[object, None]: 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 000000000..4897366f8 --- /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_release_controller.py b/ruoyi-fastapi-backend/tests/module_plugin/controller/test_plugin_release_controller.py new file mode 100644 index 000000000..7a3057d29 --- /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 000000000..7002fa1f4 --- /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 000000000..ffe13065d --- /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 000000000..e94d79dee --- /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 000000000..cc857b3d7 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/deployment/conftest.py @@ -0,0 +1,119 @@ +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 + + +@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_deployment_catalog.py b/ruoyi-fastapi-backend/tests/plugins/core/deployment/test_deployment_catalog.py new file mode 100644 index 000000000..f56cabbbc --- /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 000000000..ab227cfdf --- /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_release_state.py b/ruoyi-fastapi-backend/tests/plugins/core/deployment/test_release_state.py new file mode 100644 index 000000000..f1532c351 --- /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 000000000..09c69f959 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/deployment/test_service.py @@ -0,0 +1,520 @@ +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 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'] 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 000000000..f6a495a62 --- /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 000000000..ef4a4591c --- /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 3a81d8de5..fbc3b995d 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 @@ 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.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 000000000..d512ab16e --- /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 000000000..0c540cc0e --- /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 cb001f551..fd7729404 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 @@ async def list_plugin_states(self) -> list[SimpleNamespace]: 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 000000000..2af5b8b7a --- /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_lifecycle_lock.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_lifecycle_lock.py index 127359347..ab0c33b4a 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 @@ def __init__(self) -> None: 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 4d0704eaa..987d3bb2d 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 @@ def test_plugin_runtime_upgrade_plugin_rejects_manifest_errors(tmp_path: Path) - 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 @@ def test_plugin_runtime_upgrade_plugin_rejects_uninstalled_plugin(tmp_path: Path 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 @@ def test_plugin_runtime_upgrade_plugin_skips_latest_version(tmp_path: Path) -> N 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 @@ def test_plugin_runtime_upgrade_plugin_skips_older_source_version(tmp_path: Path 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 a986de33e..aebea953e 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 @@ def __init__(self, *, acquired: bool) -> None: 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 000000000..2c1c1eafc --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_asgi.py @@ -0,0 +1,119 @@ +import asyncio +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +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() 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 000000000..a8943e5d3 --- /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 000000000..9c3e80379 --- /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_transport.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_bundle_transport.py new file mode 100644 index 000000000..8086416c4 --- /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_entrypoint.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_entrypoint.py new file mode 100644 index 000000000..9a2c9f73a --- /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 000000000..33000f6cc --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_explicit.py @@ -0,0 +1,266 @@ +import asyncio +import sys +from collections.abc import AsyncGenerator, Generator +from contextlib import asynccontextmanager +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 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.explicit import ExplicitPluginRuntime +from plugins.core.runtime.startup import PluginRuntimeStartupManager +from plugins.core.runtime.startup_coordination import PluginStartupGenerationResolver + +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'] + + +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 +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_host_services.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_host_services.py new file mode 100644 index 000000000..c2c324da6 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_host_services.py @@ -0,0 +1,121 @@ +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', 'state': {'plugin_context': context}}) + assert await endpoint(request) == {'ok': True} + callback.assert_awaited_once_with(context) + 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', '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 000000000..4aff75294 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_job_dispatcher.py @@ -0,0 +1,227 @@ +import asyncio +import inspect +import pickle +import sys +from collections.abc import AsyncGenerator, Iterator +from contextlib import asynccontextmanager +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, dispatch_plugin_job +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) + 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 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'] + 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'] + 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 + + +@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') + 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) 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 000000000..eb80d3afd --- /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/integration/test_harness_safety.py b/ruoyi-fastapi-backend/tests/plugins/integration/test_harness_safety.py new file mode 100644 index 000000000..6a98bd02c --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/integration/test_harness_safety.py @@ -0,0 +1,405 @@ +import asyncio +import threading +from collections.abc import AsyncGenerator, AsyncIterator +from contextlib import asynccontextmanager +from fnmatch import fnmatchcase +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, Mock + +import pytest + +from plugins.core.lifecycle.script import PluginLifecycleScriptHelper +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.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( + '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] + upgrade = PluginLifecycleScriptHelper.split_sql_statements( + (harness.BACKEND_ROOT / 'sql' / f'upgrade_plugin_artifact_{engine}.sql').read_text('utf-8') + ) + repeated_upgrade = upgrade + upgrade + assert statements[: len(repeated_upgrade)] == repeated_upgrade + base_statements = statements[len(repeated_upgrade) :] + expected_tables = ( + 'sys_menu', + 'sys_job', + 'sys_plugin', + 'sys_plugin_menu', + 'sys_plugin_migration', + 'sys_plugin_config', + 'sys_plugin_operation_log', + ) + initial_sql = (harness.BACKEND_ROOT / 'sql' / initial_filename).read_text('utf-8') + assert len(base_statements) == len(expected_tables) + for table_name, statement in zip(expected_tables, base_statements, strict=True): + assert statement.lower().startswith(f'create table {table_name} (') + assert statement in initial_sql + assert "default ''''''" not in base_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']) +async def test_prepare_schema_rejects_incomplete_or_duplicate_base_ddl_before_execution( + invalid_schema: 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('create table sys_menu (', 'create table unused_menu (') + else: + initial_sql += '\ncreate table sys_menu (menu_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 base table DDL|Duplicate base 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]) +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 +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, '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() 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 000000000..e39299dc1 --- /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 000000000..02b04e6f2 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/plugins/native/test_rust_demo.py @@ -0,0 +1,253 @@ +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 + 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 0d9b0447c..c64b7fdec 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 000000000..55574865f --- /dev/null +++ b/ruoyi-fastapi-backend/tests/sql/test_plugin_release_schema.py @@ -0,0 +1,41 @@ +from pathlib import Path + +import pytest +from sqlalchemy.dialects import mysql, postgresql +from sqlalchemy.schema import CreateTable + +from plugins.core.management.entity.do.release_models import SysPluginArtifact, SysPluginRelease, SysPluginWorker + +SQL_ROOT = Path(__file__).resolve().parents[2] / 'sql' + + +@pytest.mark.parametrize( + ('initial', 'upgrade', 'time_type'), + [ + ('ruoyi-fastapi.sql', 'upgrade_plugin_artifact_mysql.sql', 'datetime(3)'), + ('ruoyi-fastapi-pg.sql', 'upgrade_plugin_artifact_postgresql.sql', 'timestamp(3) with time zone'), + ], +) +def test_incremental_and_initial_release_schema_match(initial: str, upgrade: str, time_type: str) -> None: + initialization = (SQL_ROOT / initial).read_text(encoding='utf-8') + migration = (SQL_ROOT / upgrade).read_text(encoding='utf-8') + migration_body = '\n'.join(line for line in migration.splitlines() if not line.lstrip().startswith('--')) + initialization_body = '\n'.join(line for line in initialization.splitlines() if not line.lstrip().startswith('--')) + assert ' '.join(migration_body.split()) in ' '.join(initialization_body.split()) + assert 'drop table' not in migration.lower() + assert 'alter table' not in migration.lower() + assert time_type in migration + for table in (SysPluginArtifact.__table__, SysPluginRelease.__table__, SysPluginWorker.__table__): + assert f'create table if not exists {table.name} (' in migration + for column in table.columns: + assert column.name in migration + for constraint in ('ck_sys_plugin_release_workers', 'ck_sys_plugin_release_prepare', 'ck_sys_plugin_worker_state'): + assert constraint in migration + + +@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-frontend/bin/nginx.dockermy.conf b/ruoyi-fastapi-frontend/bin/nginx.dockermy.conf index 63a1b205f..6b56ba104 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; } + # 若设置了 VITE_APP_PLUGIN_BASE/root_path,在以下两个 location 前添加相同前缀。 + # 保留原路径和外部 Host,使插件 Cookie、Origin 校验、静态资源保持同源。 + location ^~ /apps/ { + proxy_pass http://ruoyi-backend-my:9099; + 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 ^~ /plugin/runtime/ { + proxy_pass http://ruoyi-backend-my:9099; + 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 2a9591112..13e95141d 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; } + # 若设置了 VITE_APP_PLUGIN_BASE/root_path,在以下两个 location 前添加相同前缀。 + # 保留原路径和外部 Host,使插件 Cookie、Origin 校验、静态资源保持同源。 + location ^~ /apps/ { + proxy_pass http://ruoyi-backend-pg:9099; + 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 ^~ /plugin/runtime/ { + proxy_pass http://ruoyi-backend-pg:9099; + 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/components/PluginFrame/index.vue b/ruoyi-fastapi-frontend/src/components/PluginFrame/index.vue new file mode 100644 index 000000000..e09c9aafb --- /dev/null +++ b/ruoyi-fastapi-frontend/src/components/PluginFrame/index.vue @@ -0,0 +1,277 @@ +