diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/commands/dependency.py b/ruoyi-fastapi-backend/cli/groups/plugin/commands/dependency.py index dae372e..174a243 100644 --- a/ruoyi-fastapi-backend/cli/groups/plugin/commands/dependency.py +++ b/ruoyi-fastapi-backend/cli/groups/plugin/commands/dependency.py @@ -93,9 +93,10 @@ def install_deps_command( # noqa: PLR0913 ] = None, allow_unlisted: Annotated[ bool, - typer.Option('--allow-unlisted', help='dev 环境允许未命中 allowlist 的依赖仅告警'), + typer.Option('--allow-unlisted', help='允许未命中 allowlist 的依赖仅告警'), ] = False, lockfile: Annotated[str, typer.Option('--lockfile', help='指定插件依赖锁文件路径')] = '', + allowlist: Annotated[str, typer.Option('--allowlist', help='指定插件依赖允许列表路径')] = '', offline_dir: Annotated[str, typer.Option('--offline-dir', help='指定插件离线依赖制品目录')] = '', require_lockfile: Annotated[ bool | None, @@ -112,8 +113,9 @@ def install_deps_command( # noqa: PLR0913 :param yes: 是否跳过确认 :param dry_run: 是否仅预演 :param policy_mode: 临时覆盖策略模式 - :param allow_unlisted: dev 环境是否允许未命中 allowlist 的依赖仅告警 + :param allow_unlisted: 是否允许未命中 allowlist 的依赖仅告警 :param lockfile: 锁文件路径 + :param allowlist: 依赖允许列表路径 :param offline_dir: 离线制品目录 :param require_lockfile: 是否要求锁文件 :return: None @@ -129,6 +131,7 @@ def install_deps_command( # noqa: PLR0913 policy_mode=policy_mode, allow_unlisted=allow_unlisted, lockfile=lockfile, + allowlist=allowlist, offline_dir=offline_dir, require_lockfile=require_lockfile, ), diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/controller.py b/ruoyi-fastapi-backend/cli/groups/plugin/controller.py index e1e021b..c0935fe 100644 --- a/ruoyi-fastapi-backend/cli/groups/plugin/controller.py +++ b/ruoyi-fastapi-backend/cli/groups/plugin/controller.py @@ -408,19 +408,20 @@ def install_plugin_dependencies( options.yes, options.dry_run, ) - policy_config = DependencyInstallPolicyConfig.from_environment( + policy_config = DependencyInstallPolicyConfig.from_cli_environment( env=env, mode=options.policy_mode, allow_prod=options.allow_prod, allow_unlisted=options.allow_unlisted, lockfile_path=options.lockfile or None, + allowlist_path=options.allowlist or None, offline_dir=options.offline_dir or None, require_lockfile=options.require_lockfile, ) core_runtime = self.core_runtime output_callback = self._build_dependency_install_output_callback(ctx) if self._should_interactive_confirm_dependency_install(ctx, options): - preview_payload = core_runtime.install_plugin_dependencies( + preview_payload = core_runtime.install_plugin_dependencies_from_cli( plugin_id, dry_run=True, policy_config=policy_config, @@ -428,7 +429,7 @@ def install_plugin_dependencies( ) preview_payload['env'] = ctx.env if self._dependency_install_policy_blocked(preview_payload): - payload = core_runtime.install_plugin_dependencies( + payload = core_runtime.install_plugin_dependencies_from_cli( plugin_id, dry_run=False, policy_config=policy_config, @@ -455,7 +456,7 @@ def install_plugin_dependencies( else: confirmed = options.yes - payload = core_runtime.install_plugin_dependencies( + payload = core_runtime.install_plugin_dependencies_from_cli( plugin_id, dry_run=options.dry_run, policy_config=policy_config, diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/options.py b/ruoyi-fastapi-backend/cli/groups/plugin/options.py index a4373e4..556fa52 100644 --- a/ruoyi-fastapi-backend/cli/groups/plugin/options.py +++ b/ruoyi-fastapi-backend/cli/groups/plugin/options.py @@ -40,8 +40,9 @@ class PluginDependencyInstallCommandOptions: :param yes: 是否跳过确认 :param dry_run: 是否仅预演 :param policy_mode: 临时覆盖策略模式 - :param allow_unlisted: dev 环境是否允许未命中 allowlist 的依赖仅告警 + :param allow_unlisted: 是否允许未命中 allowlist 的依赖仅告警 :param lockfile: 锁文件路径 + :param allowlist: 依赖允许列表路径 :param offline_dir: 离线制品目录 :param require_lockfile: 是否要求锁文件 """ @@ -52,6 +53,7 @@ class PluginDependencyInstallCommandOptions: policy_mode: str | None = None allow_unlisted: bool = False lockfile: str = '' + allowlist: str = '' offline_dir: str = '' require_lockfile: bool | None = None diff --git a/ruoyi-fastapi-backend/cli/runtime/app/support.py b/ruoyi-fastapi-backend/cli/runtime/app/support.py index 882b789..c7b3a6f 100644 --- a/ruoyi-fastapi-backend/cli/runtime/app/support.py +++ b/ruoyi-fastapi-backend/cli/runtime/app/support.py @@ -76,7 +76,8 @@ def build_app_env_snapshot(self) -> dict[str, Any]: env_module = self.infrastructure_gateway.get_env_module() app_config = env_module.AppConfig backend_dir = Path(self.runtime_environment.get_backend_dir()) - resolved_env = os.environ.get('APP_ENV', '') or 'dev' + run_env = getattr(getattr(env_module, 'get_config', None), 'run_env', None) + resolved_env = run_env or os.environ.get('APP_ENV', '') or 'dev' env_file_name = f'.env.{resolved_env}' env_file_path = backend_dir / env_file_name return { diff --git a/ruoyi-fastapi-backend/config/env.py b/ruoyi-fastapi-backend/config/env.py index 79806f3..ee8a299 100644 --- a/ruoyi-fastapi-backend/config/env.py +++ b/ruoyi-fastapi-backend/config/env.py @@ -268,7 +268,7 @@ class GetConfig: """ def __init__(self) -> None: - self.parse_cli_args() + self.run_env = self.parse_cli_args() def get_app_config(self) -> AppSettings: """ @@ -331,18 +331,23 @@ def get_upload_config(self) -> UploadSettings: return UploadSettings() @staticmethod - def parse_cli_args() -> None: + def parse_cli_args() -> str: """ - 解析命令行参数 + 解析命令行参数并加载对应环境配置。 + + ``run_env`` 用于选择 ``.env.*`` 配置文件,实际应用环境以配置 + 文件中的 ``APP_ENV`` 为准。 + + :return: 当前加载的运行环境配置名称 """ + run_env = os.environ.get('APP_ENV', '') # 检查是否在alembic环境中运行,如果是则跳过参数解析 if 'alembic' in sys.argv[0] or any('alembic' in arg for arg in sys.argv): ini_config = configparser.ConfigParser() ini_config.read('alembic.ini', encoding='utf-8') if 'settings' in ini_config: # 获取env选项 - env_value = ini_config['settings'].get('env') - os.environ['APP_ENV'] = env_value if env_value else 'dev' + run_env = ini_config['settings'].get('env') or run_env elif 'uvicorn' in sys.argv[0]: # 使用uvicorn启动时,命令行参数需要按照uvicorn的文档进行配置,无法自定义参数 pass @@ -352,17 +357,13 @@ def parse_cli_args() -> None: parser.add_argument('--env', type=str, default='', help='运行环境') # 解析命令行参数 args, _ = parser.parse_known_args() - # 设置环境变量,如果未设置命令行参数,默认APP_ENV为dev - os.environ['APP_ENV'] = args.env if args.env else 'dev' - # 读取运行环境 - run_env = os.environ.get('APP_ENV', '') + run_env = args.env or run_env # 运行环境未指定时默认加载.env.dev - env_file = '.env.dev' - # 运行环境不为空时按命令行参数加载对应.env文件 - if run_env != '': - env_file = f'.env.{run_env}' - # 加载配置 + run_env = run_env.strip() or 'dev' + env_file = f'.env.{run_env}' + # 加载配置,已通过外部命令设置的环境变量保持优先 load_dotenv(env_file) + return run_env # 实例化获取配置类 diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/dependencies.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/dependencies.py index eec4327..d3210ec 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/service/dependencies.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/dependencies.py @@ -111,7 +111,7 @@ def install_plugin_dependencies( output_callback: PluginCommandOutputCallback | None = None, ) -> PluginDependencyInstallResponse: """ - 安装插件依赖。 + 从 Web/应用运行时入口安装插件依赖。 :param plugin_id: 插件ID :param dry_run: 是否仅预演 @@ -131,20 +131,88 @@ def install_plugin_dependencies( ) if blocked_payload: return blocked_payload + return self._install_discovered_plugin_dependencies( + plugin_id, + discovered_plugin, + dry_run=dry_run, + policy_config=policy_config, + confirmed=confirmed, + output_callback=output_callback, + ) + except Exception as exc: + return PluginRuntimePayloadBuilder.build_exception_payload('安装插件依赖失败', exc) + + def install_plugin_dependencies_from_cli( + self, + plugin_id: str, + *, + dry_run: bool = False, + policy_config: DependencyInstallPolicyConfig | None = None, + confirmed: bool = False, + output_callback: PluginCommandOutputCallback | None = None, + ) -> PluginDependencyInstallResponse: + """ + 从 CLI 入口安装插件依赖。 + + CLI 不受 Web 运行时能力限制,但仍与 Web 共用同一套依赖检查、 + 安装计划和策略判定。 - dependency_result = self.dependencies.dependency_checker.check_manifest(discovered_plugin.manifest) - return self.install_plugin_dependencies_from_result( + :param plugin_id: 插件ID + :param dry_run: 是否仅预演 + :param policy_config: 依赖安装策略配置 + :param confirmed: 是否已显式确认 + :param output_callback: 依赖安装实时输出回调 + :return: 插件依赖安装负载 + """ + try: + discovered_plugin = self._get_discovered_plugin(plugin_id) + if not discovered_plugin: + return PluginPayloadBuilder.build_plugin_not_found_payload(plugin_id, dry_run=dry_run) + payload = self._install_discovered_plugin_dependencies( plugin_id, - dependency_result, + discovered_plugin, dry_run=dry_run, - discovered_plugin=discovered_plugin, policy_config=policy_config, confirmed=confirmed, output_callback=output_callback, ) + cast('dict[str, object]', payload).pop('capability', None) + return payload except Exception as exc: return PluginRuntimePayloadBuilder.build_exception_payload('安装插件依赖失败', exc) + def _install_discovered_plugin_dependencies( + self, + plugin_id: str, + discovered_plugin: DiscoveredPlugin, + *, + dry_run: bool, + policy_config: DependencyInstallPolicyConfig | None, + confirmed: bool, + output_callback: PluginCommandOutputCallback | None, + ) -> PluginDependencyInstallResponse: + """ + 对已发现插件执行共用的依赖检查、计划、策略判定和安装流程。 + + :param plugin_id: 插件ID + :param discovered_plugin: 已发现插件 + :param dry_run: 是否仅预演 + :param policy_config: 依赖安装策略配置 + :param confirmed: 是否已显式确认 + :param output_callback: 依赖安装实时输出回调 + :return: 插件依赖安装负载 + """ + dependency_result = self.dependencies.dependency_checker.check_manifest(discovered_plugin.manifest) + return self.install_plugin_dependencies_from_result( + plugin_id, + dependency_result, + dry_run=dry_run, + discovered_plugin=discovered_plugin, + policy_config=policy_config, + confirmed=confirmed, + output_callback=output_callback, + ) + def install_plugin_dependencies_from_result( self, plugin_id: str, diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/facade.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/facade.py index dad5ce4..171a324 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/service/facade.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/facade.py @@ -387,6 +387,73 @@ def install_plugin_dependencies( """ 安装插件依赖。 + :param plugin_id: 插件ID + :param dry_run: 是否仅预演 + :param policy_config: 依赖安装策略配置 + :param confirmed: 是否已显式确认 + :param record_operation_log: 是否记录插件操作审计日志 + :param output_callback: 依赖安装实时输出回调 + :return: 插件依赖安装负载 + """ + return self._execute_plugin_dependency_install( + self.dependency.install_plugin_dependencies, + plugin_id, + dry_run=dry_run, + policy_config=policy_config, + confirmed=confirmed, + record_operation_log=record_operation_log, + output_callback=output_callback, + ) + + def install_plugin_dependencies_from_cli( + self, + plugin_id: str, + *, + dry_run: bool = False, + policy_config: DependencyInstallPolicyConfig | None = None, + confirmed: bool = False, + record_operation_log: bool = True, + output_callback: PluginCommandOutputCallback | None = None, + ) -> PluginDependencyInstallResponse: + """ + 从 CLI 受控入口安装插件依赖。 + + 该入口可在非开发模式越过运行时能力拦截,但仍必须通过依赖安装 + 策略、生产确认及允许列表/锁文件等校验。 + + :param plugin_id: 插件ID + :param dry_run: 是否仅预演 + :param policy_config: 依赖安装策略配置 + :param confirmed: 是否已显式确认 + :param record_operation_log: 是否记录插件操作审计日志 + :param output_callback: 依赖安装实时输出回调 + :return: 插件依赖安装负载 + """ + return self._execute_plugin_dependency_install( + self.dependency.install_plugin_dependencies_from_cli, + plugin_id, + dry_run=dry_run, + policy_config=policy_config, + confirmed=confirmed, + record_operation_log=record_operation_log, + output_callback=output_callback, + ) + + def _execute_plugin_dependency_install( + self, + installer: Callable[..., PluginDependencyInstallResponse], + plugin_id: str, + *, + dry_run: bool, + policy_config: DependencyInstallPolicyConfig | None, + confirmed: bool, + record_operation_log: bool, + output_callback: PluginCommandOutputCallback | None, + ) -> PluginDependencyInstallResponse: + """ + 调用指定依赖安装入口并统一处理审计日志。 + + :param installer: Web/运行时或 CLI 依赖安装入口 :param plugin_id: 插件ID :param dry_run: 是否仅预演 :param policy_config: 依赖安装策略配置 @@ -396,14 +463,14 @@ def install_plugin_dependencies( :return: 插件依赖安装负载 """ if output_callback is None: - dependency_payload = self.dependency.install_plugin_dependencies( + dependency_payload = installer( plugin_id, dry_run=dry_run, policy_config=policy_config, confirmed=confirmed, ) else: - dependency_payload = self.dependency.install_plugin_dependencies( + dependency_payload = installer( plugin_id, dry_run=dry_run, policy_config=policy_config, diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/startup.py b/ruoyi-fastapi-backend/plugins/core/runtime/startup.py index 818c317..d84178a 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/startup.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/startup.py @@ -9,7 +9,7 @@ from common.router import auto_register_controller_files from config.database import AsyncSessionLocal -from config.env import AppConfig +from config.env import AppConfig, get_config from config.get_db import get_db from plugins.core.discovery.registry import PluginRegistry, RegisteredPlugin from plugins.core.lifecycle.migration import ( @@ -476,7 +476,24 @@ def _build_dependency_startup_error_message(plugin_id: str, failed_messages: lis :param failed_messages: 依赖检查失败消息 :return: 启动依赖检查失败消息 """ - install_command = f'ruoyi plugin install-deps {plugin_id} --env={AppConfig.app_env} --yes' + run_env = get_config.run_env + install_command_parts = [ + 'ruoyi', + 'plugin', + 'install-deps', + plugin_id, + f'--env={run_env}', + '--yes', + ] + if AppConfig.app_env == 'prod': + install_command_parts.extend( + [ + '--allow-prod', + '--allow-unlisted', + '--no-require-lockfile', + ] + ) + install_command = ' '.join(install_command_parts) return ( f'{PLUGIN_STARTUP_DEPENDENCY_ERROR_PREFIX}{";".join(failed_messages)};安装依赖请执行:{install_command}' ) @@ -711,7 +728,8 @@ async def sync_plugin_install(self, discovered_plugin: Any, *, enabled: bool) -> 使用独立事务执行单个插件的启动期安装生命周期。 启动期首次安装与管理端安装保持相同的关键步骤:结构校验、发现状态写入、 - 资源同步、migration、seed、on_install 钩子和最终安装状态写入。 + 资源同步、migration、seed、on_install 钩子和最终安装状态写入。非开发环境 + 部署的是已构建前端,启动期仅校验后端结构,不依赖前端源码目录。 :param discovered_plugin: 已发现插件对象 :param enabled: 插件资源是否启用 @@ -769,7 +787,10 @@ def validate_plugin_structure(self, discovered_plugin: Any) -> None: result = PluginStructureChecker( self.builder.backend_root, self.builder.frontend_plugins_root, - ).check(discovered_plugin) + ).check( + discovered_plugin, + include_frontend=AppConfig.app_env == 'dev', + ) if result.ok: return messages = ';'.join(item.message for item in result.failed_items) diff --git a/ruoyi-fastapi-backend/plugins/core/validation/dependency_policy.py b/ruoyi-fastapi-backend/plugins/core/validation/dependency_policy.py index 09b3b91..b7feb85 100644 --- a/ruoyi-fastapi-backend/plugins/core/validation/dependency_policy.py +++ b/ruoyi-fastapi-backend/plugins/core/validation/dependency_policy.py @@ -75,6 +75,7 @@ def from_environment( allow_prod: bool = False, allow_unlisted: bool = False, lockfile_path: Path | str | None = None, + allowlist_path: Path | str | None = None, offline_dir: Path | str | None = None, require_lockfile: bool | None = None, ) -> 'DependencyInstallPolicyConfig': @@ -86,6 +87,7 @@ def from_environment( :param allow_prod: 是否允许生产环境危险安装 :param allow_unlisted: 是否允许 dev 环境未命中 allowlist 的依赖仅告警 :param lockfile_path: 锁文件路径 + :param allowlist_path: 依赖允许列表路径 :param offline_dir: 离线制品目录 :param require_lockfile: 是否要求锁文件 :return: 策略配置 @@ -129,7 +131,8 @@ def from_environment( offline_dir=offline_dir or cls._read_value(settings, 'plugin_dependency_offline_dir', 'PLUGIN_DEPENDENCY_OFFLINE_DIR') or None, - allowlist_path=cls._read_value(settings, 'plugin_dependency_allowlist', 'PLUGIN_DEPENDENCY_ALLOWLIST') + allowlist_path=allowlist_path + or cls._read_value(settings, 'plugin_dependency_allowlist', 'PLUGIN_DEPENDENCY_ALLOWLIST') or None, pip_index_url=cls._read_value( settings, 'plugin_dependency_pip_index_url', 'PLUGIN_DEPENDENCY_PIP_INDEX_URL' @@ -145,6 +148,53 @@ def from_environment( ), ) + @classmethod + def from_cli_environment( + cls, + *, + env: str, + mode: DependencyInstallPolicyMode | None = None, + allow_prod: bool = False, + allow_unlisted: bool = False, + lockfile_path: Path | str | None = None, + allowlist_path: Path | str | None = None, + offline_dir: Path | str | None = None, + require_lockfile: bool | None = None, + ) -> 'DependencyInstallPolicyConfig': + """ + 为 CLI 依赖安装入口构建策略配置。 + + CLI 通道授权由专用运行时入口、``--allow-prod`` 和显式确认负责, + 不继承全局环境中的 plan_only、生产安装开关和确认开关。允许列表、 + 锁文件、离线制品、registry 和超时等供应链配置仍从环境读取。 + + :param env: 当前命令运行环境 + :param mode: CLI 显式策略模式,默认使用 explicit + :param allow_prod: 是否显式允许生产环境安装 + :param allow_unlisted: 是否允许未命中 allowlist 的依赖仅告警 + :param lockfile_path: 锁文件路径 + :param allowlist_path: 依赖允许列表路径 + :param offline_dir: 离线制品目录 + :param require_lockfile: 是否要求锁文件 + :return: CLI 依赖安装策略配置 + """ + config = cls.from_environment( + env=env, + mode=mode or 'explicit', + allow_prod=allow_prod, + allow_unlisted=allow_unlisted, + lockfile_path=lockfile_path, + allowlist_path=allowlist_path, + offline_dir=offline_dir, + require_lockfile=require_lockfile, + ) + return replace( + config, + allow_prod_install=True, + require_yes=True, + require_allowlist=False if allow_unlisted else config.require_allowlist, + ) + @staticmethod def _default_mode(env: str) -> DependencyInstallPolicyMode: """ diff --git a/ruoyi-fastapi-backend/plugins/core/validation/structure.py b/ruoyi-fastapi-backend/plugins/core/validation/structure.py index 4cbbfb9..6aa235c 100644 --- a/ruoyi-fastapi-backend/plugins/core/validation/structure.py +++ b/ruoyi-fastapi-backend/plugins/core/validation/structure.py @@ -111,11 +111,17 @@ def __init__(self, backend_root: Path | str, frontend_root: Path | str | None = else Path(PluginRuntimeEnvironmentService(backend_root=self.backend_root).get_frontend_plugins_dir()) ) - def check(self, discovered_plugin: DiscoveredPlugin) -> PluginStructureCheckResult: + def check( + self, + discovered_plugin: DiscoveredPlugin, + *, + include_frontend: bool = True, + ) -> PluginStructureCheckResult: """ 检查插件结构。 :param discovered_plugin: 已发现插件对象 + :param include_frontend: 是否检查前端源码目录和菜单组件 :return: 插件结构检查结果 """ manifest = discovered_plugin.manifest @@ -136,7 +142,8 @@ def check(self, discovered_plugin: DiscoveredPlugin) -> PluginStructureCheckResu items.extend(self._check_seed_files(discovered_plugin)) items.extend(self._check_hooks(discovered_plugin)) items.extend(self._check_jobs(discovered_plugin)) - items.extend(self._check_frontend(discovered_plugin)) + if include_frontend: + items.extend(self._check_frontend(discovered_plugin)) return PluginStructureCheckResult(plugin_id=manifest.id, items=items) diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_bootstrap.py b/ruoyi-fastapi-backend/tests/cli/root/test_bootstrap.py index 96559d5..29ecca2 100644 --- a/ruoyi-fastapi-backend/tests/cli/root/test_bootstrap.py +++ b/ruoyi-fastapi-backend/tests/cli/root/test_bootstrap.py @@ -1,4 +1,5 @@ import json +import os import subprocess import sys from pathlib import Path @@ -6,6 +7,41 @@ BACKEND_DIR = Path(__file__).resolve().parents[3] +def test_config_env_loads_app_env_from_selected_run_env() -> None: + """校验 run_env 只负责选文件,APP_ENV 使用文件内声明值。""" + script = """ +import json +import os +import sys + +sys.path.insert(0, '.') +from config.env import AppConfig, get_config + +print(json.dumps({ + 'runEnv': get_config.run_env, + 'appEnv': AppConfig.app_env, + 'processAppEnv': os.environ.get('APP_ENV'), +}, ensure_ascii=False)) +""" + process_env = dict(os.environ) + process_env.pop('APP_ENV', None) + completed = subprocess.run( + [sys.executable, '-c', script, '--env', 'dockermy'], + cwd=BACKEND_DIR, + env=process_env, + capture_output=True, + text=True, + check=False, + ) + + assert completed.returncode == 0, completed.stderr + assert json.loads(completed.stdout) == { + 'runEnv': 'dockermy', + 'appEnv': 'prod', + 'processAppEnv': 'prod', + } + + def test_bootstrap_import_does_not_eagerly_load_heavy_runtime_modules() -> None: """ 校验导入 `cli.bootstrap` 时不会提前加载重依赖模块。 diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_command_controller.py b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_command_controller.py index 3265cc7..1a8ad8f 100644 --- a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_command_controller.py +++ b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_command_controller.py @@ -172,7 +172,7 @@ async def set_plugin_config(self, plugin_id: str, values: dict[str, Any]) -> dic self.config_set_calls.append({'plugin_id': plugin_id, 'values': values}) return {'ok': False, 'message': '插件配置更新失败', 'pluginId': plugin_id, 'values': values} - def install_plugin_dependencies( + def install_plugin_dependencies_from_cli( self, plugin_id: str, *, @@ -181,7 +181,7 @@ def install_plugin_dependencies( confirmed: bool = False, output_callback: object | None = None, ) -> dict[str, Any]: - """返回依赖安装测试结果。""" + """返回 CLI 依赖安装测试结果。""" self.dependency_install_calls.append( { 'plugin_id': plugin_id, @@ -408,6 +408,7 @@ def test_install_plugin_dependencies_passes_policy_config_to_runtime() -> None: policy_mode='locked', allow_unlisted=True, lockfile='plugins/demo/plugin.lock.yaml', + allowlist='config/plugin_dependency_allowlist.yaml', offline_dir='artifacts/plugin-dependencies', require_lockfile=True, ), @@ -419,8 +420,12 @@ def test_install_plugin_dependencies_passes_policy_config_to_runtime() -> None: assert policy_config.mode == 'locked' assert policy_config.env == 'stage' assert policy_config.allow_prod is True + assert policy_config.allow_prod_install is True + assert policy_config.require_yes is True assert policy_config.allow_unlisted is True + assert policy_config.require_allowlist is False assert str(policy_config.lockfile_path) == 'plugins/demo/plugin.lock.yaml' + assert str(policy_config.allowlist_path) == 'config/plugin_dependency_allowlist.yaml' assert str(policy_config.offline_dir) == 'artifacts/plugin-dependencies' assert policy_config.require_lockfile is True assert len(plugin_runtime.core_runtime.dependency_install_calls) == 1 diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_batch_dependencies.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_batch_dependencies.py index e7fe38f..e69b3fa 100644 --- a/ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_batch_dependencies.py +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_batch_dependencies.py @@ -672,8 +672,8 @@ def test_plugin_runtime_blocks_state_changes_in_service_mode(tmp_path: Path) -> assert 'install' in payload['capability']['blockedOperations'] -def test_plugin_runtime_blocks_frontend_plugin_dependency_install_in_built_mode(tmp_path: Path) -> None: - """校验已构建前端模式下阻断前端源码插件依赖安装。""" +def test_plugin_runtime_allows_only_cli_dependency_install_in_built_service_mode(tmp_path: Path) -> None: + """校验生产服务模式拦截 Web 入口,仅 CLI 可安装后端依赖。""" backend_root = tmp_path / 'backend' write_manifest( backend_root / 'plugins' / 'demo', @@ -692,17 +692,58 @@ def test_plugin_runtime_blocks_frontend_plugin_dependency_install_in_built_mode( permissions: - demo:list dependencies: + python: + - missing-python npm: - missing-npm """, ) create_frontend_view(backend_root, 'demo') - runtime = build_runtime(backend_root) + gateway = FakePluginRuntimeGateway() + runtime = build_runtime_with_gateway(backend_root, gateway) runtime.dependencies.runtime_environment.frontend_mode = 'built' + runtime.dependencies.runtime_environment.backend_runtime_mode = 'service' + runtime.refresh_dependency_checker() + + web_payload = runtime.install_plugin_dependencies( + 'demo', + policy_config=DependencyInstallPolicyConfig( + mode='explicit', + env='prod', + allow_prod=True, + allow_prod_install=True, + require_lockfile=False, + require_allowlist=False, + ), + confirmed=True, + ) + cli_policy_blocked_payload = runtime.install_plugin_dependencies_from_cli( + 'demo', + policy_config=DependencyInstallPolicyConfig(mode='plan_only', env='prod'), + confirmed=True, + ) + assert gateway.commands == [] - payload = runtime.install_plugin_dependencies('demo', dry_run=True) + cli_payload = runtime.install_plugin_dependencies_from_cli( + 'demo', + policy_config=DependencyInstallPolicyConfig.from_cli_environment( + env='prod', + allow_prod=True, + allow_unlisted=True, + require_lockfile=False, + ), + confirmed=True, + ) - assert payload['ok'] is False - assert payload['status'] == 'blocked' - assert payload['capability']['frontendRuntimeManageable'] is False - assert 'dependency_install' in payload['capability']['blockedOperations'] + assert web_payload['ok'] is False + assert web_payload['status'] == 'blocked' + assert 'dependency_install' in web_payload['capability']['blockedOperations'] + assert cli_policy_blocked_payload['ok'] is False + assert cli_policy_blocked_payload['policy']['allowed'] is False + assert 'capability' not in cli_policy_blocked_payload + assert cli_payload['ok'] is True + assert 'capability' not in cli_payload + assert [item['kind'] for item in cli_payload['dependencies']] == ['python', 'npm'] + assert cli_payload['dependencies'][1]['status'] == 'skipped' + assert len(gateway.commands) == 1 + assert gateway.commands[0][0][1:4] == ['-m', 'pip', 'install'] diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_facade_routing.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_facade_routing.py index 3891b6c..b1eaf9c 100644 --- a/ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_facade_routing.py +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_facade_routing.py @@ -122,6 +122,15 @@ async def test_facade_routes_batch_dependency_and_audit_operations(tmp_path: Pat ('demo',), {'dry_run': True, 'policy_config': None, 'confirmed': True}, ), + ( + 'dependency', + 'install_plugin_dependencies_from_cli', + False, + ('demo',), + {'dry_run': True, 'confirmed': True, 'record_operation_log': False}, + ('demo',), + {'dry_run': True, 'policy_config': None, 'confirmed': True}, + ), ( 'dependency', 'install_plugin_dependencies_from_result', diff --git a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_startup.py b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_startup.py index c6c8e6d..df27c18 100644 --- a/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_startup.py +++ b/ruoyi-fastapi-backend/tests/plugins/core/runtime/test_startup.py @@ -37,6 +37,35 @@ def test_startup_manager_reads_default_enabled_builtin_plugins_from_app_config() assert startup_manager.default_enabled_builtin_plugin_ids == {'ai', 'demo'} +def test_startup_manager_skips_frontend_structure_validation_in_prod(tmp_path: Path) -> None: + """校验生产启动首次安装不依赖前端源码目录。""" + backend_root = tmp_path / 'ruoyi-fastapi-backend' + plugin_root = backend_root / 'plugins' / 'demo' + plugin_root.mkdir(parents=True) + (plugin_root / 'plugin.yaml').write_text( + """ +id: demo +name: Demo +version: 1.0.0 +backend: + module: plugins.demo +frontend: + menus: + - name: Demo + path: demo + component: plugin/demo/index + """, + encoding='utf-8', + ) + (plugin_root / 'controller').mkdir() + builder = PluginRuntimeBuilder(backend_root) + discovered_plugin = builder.discover_plugins()[0] + startup_manager = PluginRuntimeStartupManager(builder) + + with patch('plugins.core.runtime.startup.AppConfig.app_env', 'prod'): + startup_manager.validate_plugin_structure(discovered_plugin) + + @pytest.mark.asyncio async def test_prepare_enabled_plugins_loads_registry_and_imports_entities() -> None: """校验插件启动协调器准备启用插件实体。""" @@ -789,6 +818,7 @@ def test_build_dependency_startup_error_message_uses_current_environment( ) -> None: """校验启动依赖失败提示使用当前应用环境构建可执行命令。""" monkeypatch.setattr('plugins.core.runtime.startup.AppConfig.app_env', 'stage') + monkeypatch.setattr('plugins.core.runtime.startup.get_config.run_env', 'stage') error_message = PluginRuntimeStartupManager._build_dependency_startup_error_message( 'demo', @@ -801,6 +831,27 @@ def test_build_dependency_startup_error_message_uses_current_environment( ) +@pytest.mark.parametrize('run_env', ['prod', 'dockermy', 'dockerpg']) +def test_build_dependency_startup_error_message_builds_executable_prod_command( + monkeypatch: pytest.MonkeyPatch, + run_env: str, +) -> None: + """校验生产及 Docker 启动依赖失败提示包含 CLI 安装所需的显式授权参数。""" + monkeypatch.setattr('plugins.core.runtime.startup.AppConfig.app_env', 'prod') + monkeypatch.setattr('plugins.core.runtime.startup.get_config.run_env', run_env) + + error_message = PluginRuntimeStartupManager._build_dependency_startup_error_message( + 'demo', + ['Python 依赖未安装:example-package'], + ) + + assert error_message == ( + '插件启动依赖检查失败:Python 依赖未安装:example-package;' + f'安装依赖请执行:ruoyi plugin install-deps demo --env={run_env} --yes ' + '--allow-prod --allow-unlisted --no-require-lockfile' + ) + + def test_register_enabled_plugin_routers_uses_enabled_plugin_ids(tmp_path: Path) -> None: """校验插件路由注册只向路由注册器传递启用且允许自动扫描的插件 ID。""" backend_root = tmp_path / 'backend' diff --git a/ruoyi-fastapi-backend/tests/plugins/core/validation/test_dependency_policy.py b/ruoyi-fastapi-backend/tests/plugins/core/validation/test_dependency_policy.py index a3792e3..3fcaf34 100644 --- a/ruoyi-fastapi-backend/tests/plugins/core/validation/test_dependency_policy.py +++ b/ruoyi-fastapi-backend/tests/plugins/core/validation/test_dependency_policy.py @@ -91,6 +91,62 @@ def test_dependency_policy_reads_unified_config_entry(monkeypatch: pytest.Monkey assert policy_config.install_timeout_seconds == CONFIGURED_INSTALL_TIMEOUT_SECONDS +def test_cli_dependency_policy_separates_channel_authorization_from_environment( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """校验 CLI 授权不受生产环境 Web 限制配置影响。""" + lockfile_path = tmp_path / 'plugin.lock.yaml' + allowlist_path = tmp_path / 'allowlist.yaml' + config = SimpleNamespace( + plugin_dependency_policy_mode='prod=plan_only,dev=explicit', + plugin_dependency_allow_prod_install=False, + plugin_dependency_require_yes=False, + plugin_dependency_require_lockfile=True, + plugin_dependency_require_allowlist=True, + plugin_dependency_lockfile=str(lockfile_path), + plugin_dependency_allowlist=str(allowlist_path), + plugin_dependency_offline_dir='', + plugin_dependency_pip_index_url='https://pypi.example/simple', + plugin_dependency_npm_registry='', + plugin_dependency_install_timeout=CONFIGURED_INSTALL_TIMEOUT_SECONDS, + ) + monkeypatch.setattr(env_config.get_config, 'get_plugin_dependency_policy_config', lambda: config, raising=False) + + policy_config = DependencyInstallPolicyConfig.from_cli_environment( + env='prod', + allow_prod=True, + allow_unlisted=True, + require_lockfile=False, + ) + decision = DependencyInstallPolicyEvaluator(policy_config).evaluate(build_plan(build_python_item()), confirmed=True) + + assert policy_config.mode == 'explicit' + assert policy_config.allow_prod is True + assert policy_config.allow_prod_install is True + assert policy_config.require_yes is True + assert policy_config.require_lockfile is False + assert policy_config.require_allowlist is False + assert policy_config.allowlist_path == allowlist_path + assert policy_config.pip_index_url == 'https://pypi.example/simple' + assert policy_config.install_timeout_seconds == CONFIGURED_INSTALL_TIMEOUT_SECONDS + assert decision.allowed is True + + +def test_cli_dependency_policy_still_requires_explicit_prod_authorization() -> None: + """校验 CLI 生产安装仍必须显式传入 allow-prod。""" + policy_config = DependencyInstallPolicyConfig.from_cli_environment( + env='prod', + allow_unlisted=True, + require_lockfile=False, + ) + + decision = DependencyInstallPolicyEvaluator(policy_config).evaluate(build_plan(build_python_item()), confirmed=True) + + assert decision.allowed is False + assert '需要 --allow-prod 确认生产环境安装' in decision.requirements + + def test_dependency_policy_reads_require_lockfile_environment(monkeypatch: pytest.MonkeyPatch) -> None: """校验策略配置会读取锁文件要求环境变量。""" monkeypatch.setenv('PLUGIN_DEPENDENCY_REQUIRE_LOCKFILE', 'true') diff --git a/ruoyi-fastapi-backend/tests/plugins/core/validation/test_structure.py b/ruoyi-fastapi-backend/tests/plugins/core/validation/test_structure.py index 2adffe9..66cbf7d 100644 --- a/ruoyi-fastapi-backend/tests/plugins/core/validation/test_structure.py +++ b/ruoyi-fastapi-backend/tests/plugins/core/validation/test_structure.py @@ -90,6 +90,36 @@ def test_structure_checker_reports_missing_frontend_directories_from_manifest(tm ] +def test_structure_checker_can_skip_frontend_source_checks_for_built_deployments(tmp_path: Path) -> None: + """校验已构建前端部署可以只检查插件后端结构。""" + backend_root = tmp_path / 'ruoyi-fastapi-backend' + plugin_root = backend_root / 'plugins' / 'demo' + write_manifest( + plugin_root, + """ +id: demo +name: 演示插件 +version: 1.0.0 +backend: + module: plugins.demo +frontend: + menus: + - name: 演示菜单 + path: demo + component: plugin/demo/index +""", + ) + (plugin_root / 'controller').mkdir() + + result = PluginStructureChecker(backend_root, tmp_path / 'missing-frontend' / 'plugins').check( + load_discovered_plugin(backend_root, 'demo'), + include_frontend=False, + ) + + assert result.ok is True + assert not any(item.kind.startswith('frontend_') for item in result.items) + + def test_structure_checker_accepts_plugin_controller_route_prefix(tmp_path: Path) -> None: """校验结构检查器接受当前插件命名空间内的 controller 路由前缀。""" backend_root = tmp_path / 'ruoyi-fastapi-backend'