commit 438844b01a4c370eaeadf2b4e96bf7ee77e147c6 Author: AdamWen95 <58934607+AdamWen95@users.noreply.github.com> Date: Wed Oct 14 09:38:58 2026 +0800 feat: initialize XAS test console with native execution diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..76c58c3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +* +!scripts/ +!scripts/*.py +!deploy/ +!deploy/Dockerfile diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d0d6371 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +*.png binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8ba2e08 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.local/ +runtime/ +results/ +__pycache__/ +*.pyc +.env +.env.* +!.env.example +*.pem +*.key +realm.properties +tokens.properties +deploy/console-worker.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..46fd773 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# XAS 测试脚本控制台 + +核对日期:2026-10-13。当前采用专用网页控制台,替代前期 Rundeck 作为日常入口。 + +访问 [公司内网测试控制台](http://10.88.118.188:14440/),无需账号密码。 + +- 测试项目:按实际测试行为展示,一套测试为一项,内部脚本放到详情的“相关文件”。 +- 上传:新增 Python/Shell 脚本或上传不可变的新版本,不在线编辑代码。 +- 运行:配置参数后排队执行,查看实时标准输出/错误及登记的 .log 内容。 +- 记录:查看状态、版本、配置快照、历史日志和归档结果,预览或下载报告。 + +当前整理为 23 项业务测试(8 项套件、15 项单项测试),关联原先登记的 307 个源文件。22 项原位置入口已接入原环境执行,保留原目录、解释器、配置和输出行为;普通测试预检通过后提交,资源占用/服务启停测试每次显示影响并确认。两个控制台验收样例默认隐藏,可在“项目用途”中选择查看。原文件、GPU/端口设置和历史记录均保留。 + +2026-10-13 23:27 只读预检:16 个原环境方案满足登记的启动条件,6 个因所需模型端口未监听而被拦截。预检通过不等于业务测试通过,也不预留 GPU。GPU 综合套件支持菜单数字、y/n/q 和回车;需要自由文本的维护功能仍从原维护入口使用。本轮仅用独立短样例验证执行链路,没有运行原 GPU/长稳测试。 + +上传版本保存在服务器 `/adamwen-work-space/xas-test-library/runtime/library/`,日志和结果在 `runtime/runs/`,SQLite 在 `runtime/console-data/`,仅记录元数据、状态与文件引用。外部结果原文件保留,同时可归档本次变更;Git 不参与每次运行时拉取。 + +## 维护入口 + +- [使用、存储、配额及恢复说明](docs/console-operations.md) +- [已确认方案与 API 契约](docs/console-plan.md) +- [本轮验收记录](docs/acceptance-dedicated-2026-10-13.md) +- [按测试行为整理后的验收](docs/acceptance-behavior-catalog-2026-10-13.md) +- [原环境接入与本轮验收](docs/acceptance-native-2026-10-13.md) +- [原环境执行架构决策](docs/adr/0004-native-execution.md) +- [专用控制台架构决策](docs/adr/0002-dedicated-console.md) +- [按测试行为组织目录的规则](docs/adr/0003-test-behavior-catalog.md) + +源码位于 `console/`,部署入口 `deploy/dedicated.py`,原入口切换工具 `deploy/cutover_console.py`。旧 Rundeck 容器与运行数据保留;早期 design.md、implementation-plan.md、legacy-smoke-plan.md 均为历史阶段,不再是当前维护入口。 + +公司仓库:[wenhuadian/xas-test-library](https://code.it.aisuan.cn/wenhuadian/xas-test-library),主分支为 `main`。Git 管理控制台源码、可信执行方案、部署脚本、测试和维护文档;数据库、结果、上传文件及凭据不纳入 Git。原服务器其他工作区的脚本通过路径引用,不会随本仓库自动上传。 + +提交与推送按用户明确授权执行,服务器部署单独进行,未配置自动发布。首次入库检查日期为 2026-10-14;本地回归 55 项中通过 48 项、因 Windows 平台限制跳过 7 项。此前 Linux 和原环境运行验收保留在各自日期的验收文档中。 diff --git a/catalog/external-sources.json b/catalog/external-sources.json new file mode 100644 index 0000000..eadaf14 --- /dev/null +++ b/catalog/external-sources.json @@ -0,0 +1,10 @@ +{ + "schema_version": 1, + "observed_at": "2026-10-13T17:19:29+08:00", + "evidence": "Dedicated console inventories source files read-only; execution readiness is managed per script in SQLite", + "sources": [ + {"id": "company-gpu-suite", "path": "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0"}, + {"id": "dsv41-trial-20261010", "path": "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/scripts"}, + {"id": "model-trials-20261011", "path": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts"} + ] +} diff --git a/catalog/native-profiles.json b/catalog/native-profiles.json new file mode 100644 index 0000000..c3c5e99 --- /dev/null +++ b/catalog/native-profiles.json @@ -0,0 +1,2808 @@ +{ + "schema_version": 1, + "targets": [ + { + "id": "deepseek-a", + "label": "DeepSeek A(原服务,18100)", + "url": "http://127.0.0.1:18100", + "model": "deepseek-v4.1-flash", + "environment": { + "CT_OFF": "{\"thinking\": false}", + "CT_ON": "{\"thinking\": true}", + "MAX_LEN": "1048576" + }, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/configs/vllm.env" + ] + }, + { + "id": "dsv41cap", + "label": "dsv41cap(配置端口 18110)", + "config_name": "dsv41cap", + "url": "http://127.0.0.1:18110", + "model": "deepseek-v4.1-flash", + "environment": { + "CT_OFF": "{\"thinking\": false}", + "CT_ON": "{\"thinking\": true}", + "MAX_LEN": "1048576" + }, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "config_file": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/dsv41cap.conf" + }, + { + "id": "glm53", + "label": "glm53(配置端口 18102)", + "config_name": "glm53", + "url": "http://127.0.0.1:18102", + "model": "glm-5.3", + "environment": { + "CT_OFF": "{\"reasoning_effort\": \"low\"}", + "CT_ON": "{}", + "MAX_LEN": "262144" + }, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "config_file": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/glm53.conf" + }, + { + "id": "mimo", + "label": "mimo(配置端口 18104)", + "config_name": "mimo", + "url": "http://127.0.0.1:18104", + "model": "mimo-v2.6-pro", + "environment": { + "CT_OFF": "{\"enable_thinking\": false}", + "CT_ON": "{\"enable_thinking\": true}", + "MAX_LEN": "262144" + }, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "config_file": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/mimo.conf" + }, + { + "id": "qwen27b", + "label": "qwen27b(配置端口 18103)", + "config_name": "qwen27b", + "url": "http://127.0.0.1:18103", + "model": "qwen3.5-27b", + "environment": { + "CT_OFF": "{\"enable_thinking\": false}", + "CT_ON": "{\"enable_thinking\": true}", + "MAX_LEN": "262144" + }, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "config_file": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/qwen27b.conf" + }, + { + "id": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)", + "config_name": "qwen27btp8", + "url": "http://127.0.0.1:18103", + "model": "qwen3.5-27b", + "environment": { + "CT_OFF": "{\"enable_thinking\": false}", + "CT_ON": "{\"enable_thinking\": true}", + "MAX_LEN": "262144" + }, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "config_file": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/qwen27btp8.conf" + }, + { + "id": "qwen38cap", + "label": "qwen38cap(配置端口 18107)", + "config_name": "qwen38cap", + "url": "http://127.0.0.1:18107", + "model": "qwen3.8-27b", + "environment": { + "CT_OFF": "{\"enable_thinking\": false}", + "CT_ON": "{\"enable_thinking\": true}", + "MAX_LEN": "262144" + }, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "config_file": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/qwen38cap.conf" + }, + { + "id": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)", + "config_name": "qwen38fp8", + "url": "http://127.0.0.1:18105", + "model": "qwen3.8-27b-fp8", + "environment": { + "CT_OFF": "{\"enable_thinking\": false}", + "CT_ON": "{\"enable_thinking\": true}", + "MAX_LEN": "262144" + }, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "config_file": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/qwen38fp8.conf" + }, + { + "id": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)", + "config_name": "qwen38fp8cap", + "url": "http://127.0.0.1:18108", + "model": "qwen3.8-27b-fp8", + "environment": { + "CT_OFF": "{\"enable_thinking\": false}", + "CT_ON": "{\"enable_thinking\": true}", + "MAX_LEN": "262144" + }, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "config_file": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/qwen38fp8cap.conf" + }, + { + "id": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)", + "config_name": "qwen38fp8mtpcap", + "url": "http://127.0.0.1:18109", + "model": "qwen3.8-27b-fp8", + "environment": { + "CT_OFF": "{\"enable_thinking\": false}", + "CT_ON": "{\"enable_thinking\": true}", + "MAX_LEN": "262144" + }, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "config_file": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/qwen38fp8mtpcap.conf" + }, + { + "id": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)", + "config_name": "qwen38fp8tp8", + "url": "http://127.0.0.1:18110", + "model": "qwen3.8-27b-fp8", + "environment": { + "CT_OFF": "{\"enable_thinking\": false}", + "CT_ON": "{\"enable_thinking\": true}", + "MAX_LEN": "262144" + }, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "config_file": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/qwen38fp8tp8.conf" + }, + { + "id": "sglang38", + "label": "sglang38(配置端口 18106)", + "config_name": "sglang38", + "url": "http://127.0.0.1:18106", + "model": "qwen3.8-27b", + "environment": { + "CT_OFF": "{\"enable_thinking\": false}", + "CT_ON": "{\"enable_thinking\": true}", + "MAX_LEN": "262144" + }, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "config_file": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/sglang38.conf" + }, + { + "id": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)", + "config_name": "sglang38fp8", + "url": "http://127.0.0.1:18106", + "model": "qwen3.8-27b-fp8", + "environment": { + "CT_OFF": "{\"enable_thinking\": false}", + "CT_ON": "{\"enable_thinking\": true}", + "MAX_LEN": "262144" + }, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "config_file": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/sglang38fp8.conf" + } + ], + "profiles": [ + { + "id": "gpu-suite", + "source_group": "company-gpu-suite", + "entry": "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/xas-gpu-test.sh", + "interpreter": "/usr/bin/bash", + "user": "root", + "cwd": "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0", + "arguments": [], + "parameters": [], + "environment": {}, + "credential_files": [], + "impact": { + "level": "service", + "title": "进入 GPU 验收原菜单,可能影响整机与模型服务", + "details": [ + "菜单包含 GPU 压力、驱动/环境安装、模型启停、固件切换和重启相关操作;按所选菜单继续确认影响。", + "原入口会初始化既有数据目录,并可能通过 tmux 创建持久会话;取消控制台不证明菜单产生的容器或 tmux 会话已结束。" + ], + "requires_confirmation": true + }, + "interactive": true, + "timeout_seconds": 259200, + "required_tools": [ + "/usr/bin/bash", + "/usr/bin/python3", + "/usr/bin/nvidia-smi", + "/usr/bin/docker", + "/usr/bin/tmux" + ], + "required_files": [ + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/xas-gpu-test.sh", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/report.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/stress_report.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/detail_export.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/stress_metrics.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/db.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/html_to_pdf.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/ai_bench.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/model_catalog.json", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/report_templates.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/report_template_menu.sh", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/gpu_compat.sh", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/ai_runtime.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/ai_environment.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/ai_environment.sh", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/model_manager.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/model_discovery.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/model_downloads.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/llm_planner.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/llm_batch.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/llm_model_environment.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/llm_report.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/llm_menu.sh", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/token_economics.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/token_prices.json", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/ai_menu.sh", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/display_menu.sh", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/bandwidth_audit.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/bandwidth_ab.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/display_switch.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/display_tool.py", + "/XAS/SH/XAS-GPU-Test/XAS-GPU-Test-v3.5.0/display_probe.py", + "/etc/xas-gpu-test/config.env" + ], + "check_ports": [], + "output_paths": [ + "/XAS/GPUTest/runs", + "/XAS/GPUTest/reports" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "原 config.env 的 DATA_ROOT=/XAS/GPUTest。只打开原菜单,不自动输入或选择任何测试项目。", + "原工具创建的 tmux 持久任务可能脱离本次控制台 cgroup;平台取消后必须核查原工具会话和模型容器,不能承诺全部停止。", + "runs/reports 使用原工具的时间戳产物;只收集本次运行新增或发生变化的文件,不预拷整个历史 runs/reports。" + ], + "available": true + }, + { + "id": "deepseek-suite", + "source_group": "dsv41-trial-20261010", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/scripts/run-b-suite.sh", + "interpreter": "/usr/bin/bash", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial", + "arguments": [ + "{run_label}", + "{mode}" + ], + "parameters": [ + { + "key": "mode", + "label": "原测试模式", + "type": "select", + "default": "full", + "options": [ + { + "value": "full", + "label": "完整(原默认)" + }, + { + "value": "quick", + "label": "快速" + } + ], + "required": true + } + ], + "environment": {}, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/configs/vllm.env" + ], + "impact": { + "level": "resource", + "title": "向原有模型服务施加并发负载", + "details": [ + "按原流程运行并发请求、性能采样与临时 benchmark 客户端;可能明显影响目标服务的延迟和吞吐。", + "使用既有服务;此入口不会自动启动目标模型。原 Docker 客户端不属于控制台 cgroup,取消后需核对残留容器。" + ], + "requires_confirmation": true + }, + "interactive": false, + "timeout_seconds": 14400, + "required_tools": [ + "/usr/bin/bash", + "/usr/bin/python3", + "/usr/bin/docker", + "/usr/bin/curl", + "/usr/bin/nvidia-smi", + "/usr/bin/timeout" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/scripts/run-b-suite.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/configs/vllm.env", + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/scripts/spec_prompts.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/scripts/spec_bench.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/scripts/bench-serve.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/scripts/multiturn_cache.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/scripts/toolcall_stress.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/scripts/curves.py" + ], + "check_ports": [ + 18101 + ], + "output_paths": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/logs/test-{run_label}-*" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "原入口硬编码 B 服务 18101,不会切到 A 的 18100;B 未运行时预检应阻止启动。" + ], + "available": true + }, + { + "id": "model-suite", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/run-suite.sh", + "interpreter": "/usr/bin/bash", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "{target_config}", + "{run_label}", + "{mode}" + ], + "parameters": [ + { + "key": "target", + "label": "原服务配置", + "type": "select", + "default": "sglang38fp8", + "options": [ + { + "value": "dsv41cap", + "label": "dsv41cap(配置端口 18110)" + }, + { + "value": "glm53", + "label": "glm53(配置端口 18102)" + }, + { + "value": "mimo", + "label": "mimo(配置端口 18104)" + }, + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + }, + { + "value": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)" + }, + { + "value": "sglang38", + "label": "sglang38(配置端口 18106)" + }, + { + "value": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + }, + { + "key": "mode", + "label": "原测试模式", + "type": "select", + "default": "full", + "options": [ + { + "value": "full", + "label": "完整(原默认)" + }, + { + "value": "quick", + "label": "快速" + }, + { + "value": "load", + "label": "仅并发负载" + } + ], + "required": true + } + ], + "environment": {}, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "impact": { + "level": "resource", + "title": "向原有模型服务施加并发负载", + "details": [ + "按原流程运行并发请求、性能采样与临时 benchmark 客户端;可能明显影响目标服务的延迟和吞吐。", + "使用既有服务;此入口不会自动启动目标模型。原 Docker 客户端不属于控制台 cgroup,取消后需核对残留容器。" + ], + "requires_confirmation": true + }, + "interactive": false, + "timeout_seconds": 14400, + "required_tools": [ + "/usr/bin/bash", + "/usr/bin/python3", + "/usr/bin/docker", + "/usr/bin/curl", + "/usr/bin/nvidia-smi", + "/usr/bin/timeout" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/run-suite.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/{target_config}.conf", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/spec_prompts.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/spec_bench.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/bench-serve.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/multiturn_cache.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/toolcall_stress.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/functional_check.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/needle.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/curves.py" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/{target_config}-{run_label}-*" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "Shell 继续读取 configs/{target_config}.conf 的 SERVED、PORT、GPUS、CT_OFF、CT_ON 等原字段;控制台不覆盖这些配置。" + ], + "available": true + }, + { + "id": "functional", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/functional_check.py", + "interpreter": "/usr/bin/python3", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "{output_file}" + ], + "parameters": [ + { + "key": "target", + "label": "目标服务", + "type": "select", + "default": "deepseek-a", + "options": [ + { + "value": "deepseek-a", + "label": "DeepSeek A(原服务,18100)" + }, + { + "value": "dsv41cap", + "label": "dsv41cap(配置端口 18110)" + }, + { + "value": "glm53", + "label": "glm53(配置端口 18102)" + }, + { + "value": "mimo", + "label": "mimo(配置端口 18104)" + }, + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + }, + { + "value": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)" + }, + { + "value": "sglang38", + "label": "sglang38(配置端口 18106)" + }, + { + "value": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + } + ], + "environment": { + "PYTHONUNBUFFERED": "1", + "SPEC_URLS": "{target_url}", + "MODEL": "{target_model}" + }, + "credential_files": [], + "impact": { + "level": "standard", + "title": "向已运行服务发送测试请求", + "details": [ + "原脚本按既定用例调用目标接口,可能影响该服务的响应时间。" + ], + "requires_confirmation": false + }, + "interactive": false, + "timeout_seconds": 7200, + "required_tools": [ + "/usr/bin/python3" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/functional_check.py" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [ + "{output_file}" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "通过所选目标的原认证文件在运行时加载 VLLM_API_KEY;不在参数或日志中显示值。" + ], + "available": true + }, + { + "id": "curves", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/curves.py", + "interpreter": "/usr/bin/python3", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "{mode}", + "{output_file}", + "{lengths}" + ], + "parameters": [ + { + "key": "target", + "label": "目标服务", + "type": "select", + "default": "deepseek-a", + "options": [ + { + "value": "deepseek-a", + "label": "DeepSeek A(原服务,18100)" + }, + { + "value": "dsv41cap", + "label": "dsv41cap(配置端口 18110)" + }, + { + "value": "glm53", + "label": "glm53(配置端口 18102)" + }, + { + "value": "mimo", + "label": "mimo(配置端口 18104)" + }, + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + }, + { + "value": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)" + }, + { + "value": "sglang38", + "label": "sglang38(配置端口 18106)" + }, + { + "value": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + }, + { + "key": "mode", + "label": "曲线类型", + "type": "select", + "default": "context", + "options": [ + { + "value": "context", + "label": "输入长度曲线" + }, + { + "value": "output", + "label": "输出长度曲线" + } + ], + "required": true + }, + { + "key": "lengths", + "label": "输入长度档位", + "type": "select", + "default": "1000,8000,32000,128000,256000", + "options": [ + { + "value": "1000,8000,32000,128000,256000", + "label": "原默认 1千 / 8千 / 3.2万 / 12.8万 / 25.6万" + }, + { + "value": "1000,8000,32000", + "label": "1千 / 8千 / 3.2万" + }, + { + "value": "32000", + "label": "3.2万" + } + ], + "required": true, + "help": "输出曲线固定生成 8192 token;该模式忽略输入长度档位。" + } + ], + "environment": { + "PYTHONUNBUFFERED": "1", + "SPEC_URLS": "{target_url}", + "MODEL": "{target_model}" + }, + "credential_files": [], + "impact": { + "level": "standard", + "title": "向已运行服务发送测试请求", + "details": [ + "原脚本按既定用例调用目标接口,可能影响该服务的响应时间。" + ], + "requires_confirmation": false + }, + "interactive": false, + "timeout_seconds": 7200, + "required_tools": [ + "/usr/bin/python3" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/curves.py" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [ + "{output_file}" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "通过所选目标的原认证文件在运行时加载 VLLM_API_KEY;不在参数或日志中显示值。", + "context 长度默认与原脚本一致;超过服务上下文上限会按原脚本返回错误。" + ], + "available": true + }, + { + "id": "language", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/spec_bench.py", + "interpreter": "/usr/bin/python3", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/qwen38fp8tp8-01-prompts.json", + "{concurrency}", + "{requests}", + "{output_file}", + "--max-tokens", + "{max_tokens}" + ], + "parameters": [ + { + "key": "target", + "label": "目标服务", + "type": "select", + "default": "deepseek-a", + "options": [ + { + "value": "deepseek-a", + "label": "DeepSeek A(原服务,18100)" + }, + { + "value": "dsv41cap", + "label": "dsv41cap(配置端口 18110)" + }, + { + "value": "glm53", + "label": "glm53(配置端口 18102)" + }, + { + "value": "mimo", + "label": "mimo(配置端口 18104)" + }, + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + }, + { + "value": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)" + }, + { + "value": "sglang38", + "label": "sglang38(配置端口 18106)" + }, + { + "value": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + }, + { + "key": "concurrency", + "label": "并发数", + "type": "integer", + "default": 1, + "min": 1, + "max": 512, + "required": true + }, + { + "key": "requests", + "label": "正式请求数", + "type": "integer", + "default": 32, + "min": 1, + "max": 100000, + "required": true, + "help": "另外有原脚本固定的 4 次预热请求。" + }, + { + "key": "max_tokens", + "label": "每次最大生成 token", + "type": "integer", + "default": 768, + "min": 1, + "max": 32768, + "required": true + } + ], + "environment": { + "PYTHONUNBUFFERED": "1", + "SPEC_URLS": "{target_url}", + "MODEL": "{target_model}" + }, + "credential_files": [], + "impact": { + "level": "standard", + "title": "向已运行服务发送测试请求", + "details": [ + "原脚本按既定用例调用目标接口,可能影响该服务的响应时间。" + ], + "requires_confirmation": false + }, + "interactive": false, + "timeout_seconds": 7200, + "required_tools": [ + "/usr/bin/python3" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/spec_bench.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/qwen38fp8tp8-01-prompts.json" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [ + "{output_file}" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "通过所选目标的原认证文件在运行时加载 VLLM_API_KEY;不在参数或日志中显示值。", + "复用原 logs/qwen38fp8tp8-01-prompts.json;本轮仅核对路径及文件大小,未读取题目内容。", + "保留原温度 0.6、关闭思考、正常采样流程;原 --greedy / --thinking / --logprobs 未在本方案开放。" + ], + "available": true + }, + { + "id": "conversation-cache", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/multiturn_cache.py", + "interpreter": "/usr/bin/python3", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "{output_file}" + ], + "parameters": [ + { + "key": "target", + "label": "目标服务", + "type": "select", + "default": "deepseek-a", + "options": [ + { + "value": "deepseek-a", + "label": "DeepSeek A(原服务,18100)" + }, + { + "value": "dsv41cap", + "label": "dsv41cap(配置端口 18110)" + }, + { + "value": "glm53", + "label": "glm53(配置端口 18102)" + }, + { + "value": "mimo", + "label": "mimo(配置端口 18104)" + }, + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + }, + { + "value": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)" + }, + { + "value": "sglang38", + "label": "sglang38(配置端口 18106)" + }, + { + "value": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + } + ], + "environment": { + "PYTHONUNBUFFERED": "1", + "SPEC_URLS": "{target_url}", + "MODEL": "{target_model}" + }, + "credential_files": [], + "impact": { + "level": "standard", + "title": "向已运行服务发送测试请求", + "details": [ + "原脚本按既定用例调用目标接口,可能影响该服务的响应时间。" + ], + "requires_confirmation": false + }, + "interactive": false, + "timeout_seconds": 7200, + "required_tools": [ + "/usr/bin/python3" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/multiturn_cache.py" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [ + "{output_file}" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "通过所选目标的原认证文件在运行时加载 VLLM_API_KEY;不在参数或日志中显示值。" + ], + "available": true + }, + { + "id": "needle", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/needle.py", + "interpreter": "/usr/bin/python3", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "{input_tokens}", + "{depth}" + ], + "parameters": [ + { + "key": "target", + "label": "目标服务", + "type": "select", + "default": "deepseek-a", + "options": [ + { + "value": "deepseek-a", + "label": "DeepSeek A(原服务,18100)" + }, + { + "value": "dsv41cap", + "label": "dsv41cap(配置端口 18110)" + }, + { + "value": "glm53", + "label": "glm53(配置端口 18102)" + }, + { + "value": "mimo", + "label": "mimo(配置端口 18104)" + }, + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + }, + { + "value": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)" + }, + { + "value": "sglang38", + "label": "sglang38(配置端口 18106)" + }, + { + "value": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + }, + { + "key": "input_tokens", + "label": "目标上下文 token", + "type": "integer", + "default": 32000, + "min": 1000, + "max": 1000000, + "required": true + }, + { + "key": "depth", + "label": "目标信息位置比例", + "type": "number", + "default": 0.5, + "min": 0, + "max": 1, + "required": true + }, + { + "key": "max_tokens", + "label": "最大生成 token", + "type": "integer", + "default": 4096, + "min": 1, + "max": 32768, + "required": true + } + ], + "environment": { + "PYTHONUNBUFFERED": "1", + "SPEC_URLS": "{target_url}", + "MODEL": "{target_model}", + "NEEDLE_MAX_TOKENS": "{max_tokens}" + }, + "credential_files": [], + "impact": { + "level": "standard", + "title": "向已运行服务发送测试请求", + "details": [ + "原脚本按既定用例调用目标接口,可能影响该服务的响应时间。" + ], + "requires_confirmation": false + }, + "interactive": false, + "timeout_seconds": 7200, + "required_tools": [ + "/usr/bin/python3" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/needle.py" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "通过所选目标的原认证文件在运行时加载 VLLM_API_KEY;不在参数或日志中显示值。", + "原入口只向标准输出打印 JSON,不接受结果路径;结果保存在本次控制台运行日志。" + ], + "available": true + }, + { + "id": "concurrent-input", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/concurrent_prefill.py", + "interpreter": "/usr/bin/python3", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "{concurrency}", + "{input_tokens}", + "{output_file}" + ], + "parameters": [ + { + "key": "target", + "label": "目标服务", + "type": "select", + "default": "deepseek-a", + "options": [ + { + "value": "deepseek-a", + "label": "DeepSeek A(原服务,18100)" + }, + { + "value": "dsv41cap", + "label": "dsv41cap(配置端口 18110)" + }, + { + "value": "glm53", + "label": "glm53(配置端口 18102)" + }, + { + "value": "mimo", + "label": "mimo(配置端口 18104)" + }, + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + }, + { + "value": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)" + }, + { + "value": "sglang38", + "label": "sglang38(配置端口 18106)" + }, + { + "value": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + }, + { + "key": "concurrency", + "label": "并发数", + "type": "integer", + "default": 4, + "min": 1, + "max": 256, + "required": true + }, + { + "key": "input_tokens", + "label": "每请求输入 token", + "type": "integer", + "default": 32000, + "min": 1000, + "max": 1000000, + "required": true + } + ], + "environment": { + "PYTHONUNBUFFERED": "1", + "SPEC_URLS": "{target_url}", + "MODEL": "{target_model}" + }, + "credential_files": [], + "impact": { + "level": "standard", + "title": "向已运行服务发送测试请求", + "details": [ + "原脚本按既定用例调用目标接口,可能影响该服务的响应时间。" + ], + "requires_confirmation": false + }, + "interactive": false, + "timeout_seconds": 7200, + "required_tools": [ + "/usr/bin/python3" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/concurrent_prefill.py" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [ + "{output_file}" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "通过所选目标的原认证文件在运行时加载 VLLM_API_KEY;不在参数或日志中显示值。", + "原 CLI 的数字参数为必填;表单初值为控制台建议值,不冒充原脚本默认值。" + ], + "available": true + }, + { + "id": "cache-revisit", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/kv_revisit.py", + "interpreter": "/usr/bin/python3", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "{sessions}", + "{input_tokens}", + "{output_file}" + ], + "parameters": [ + { + "key": "target", + "label": "目标服务", + "type": "select", + "default": "deepseek-a", + "options": [ + { + "value": "deepseek-a", + "label": "DeepSeek A(原服务,18100)" + }, + { + "value": "dsv41cap", + "label": "dsv41cap(配置端口 18110)" + }, + { + "value": "glm53", + "label": "glm53(配置端口 18102)" + }, + { + "value": "mimo", + "label": "mimo(配置端口 18104)" + }, + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + }, + { + "value": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)" + }, + { + "value": "sglang38", + "label": "sglang38(配置端口 18106)" + }, + { + "value": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + }, + { + "key": "sessions", + "label": "会话数", + "type": "integer", + "default": 8, + "min": 1, + "max": 256, + "required": true + }, + { + "key": "input_tokens", + "label": "每会话输入 token", + "type": "integer", + "default": 32000, + "min": 1000, + "max": 1000000, + "required": true + } + ], + "environment": { + "PYTHONUNBUFFERED": "1", + "SPEC_URLS": "{target_url}", + "MODEL": "{target_model}" + }, + "credential_files": [], + "impact": { + "level": "standard", + "title": "向已运行服务发送测试请求", + "details": [ + "原脚本按既定用例调用目标接口,可能影响该服务的响应时间。" + ], + "requires_confirmation": false + }, + "interactive": false, + "timeout_seconds": 7200, + "required_tools": [ + "/usr/bin/python3" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/kv_revisit.py" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [ + "{output_file}" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "通过所选目标的原认证文件在运行时加载 VLLM_API_KEY;不在参数或日志中显示值。", + "原 CLI 的数字参数为必填;表单初值为控制台建议值,不冒充原脚本默认值。" + ], + "available": true + }, + { + "id": "interference", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/prefill_interference.py", + "interpreter": "/usr/bin/python3", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "{input_tokens}", + "{output_file}" + ], + "parameters": [ + { + "key": "target", + "label": "目标服务", + "type": "select", + "default": "deepseek-a", + "options": [ + { + "value": "deepseek-a", + "label": "DeepSeek A(原服务,18100)" + }, + { + "value": "dsv41cap", + "label": "dsv41cap(配置端口 18110)" + }, + { + "value": "glm53", + "label": "glm53(配置端口 18102)" + }, + { + "value": "mimo", + "label": "mimo(配置端口 18104)" + }, + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + }, + { + "value": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)" + }, + { + "value": "sglang38", + "label": "sglang38(配置端口 18106)" + }, + { + "value": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + }, + { + "key": "input_tokens", + "label": "长输入 token", + "type": "integer", + "default": 128000, + "min": 1000, + "max": 1000000, + "required": true + } + ], + "environment": { + "PYTHONUNBUFFERED": "1", + "SPEC_URLS": "{target_url}", + "MODEL": "{target_model}" + }, + "credential_files": [], + "impact": { + "level": "standard", + "title": "向已运行服务发送测试请求", + "details": [ + "原脚本按既定用例调用目标接口,可能影响该服务的响应时间。" + ], + "requires_confirmation": false + }, + "interactive": false, + "timeout_seconds": 7200, + "required_tools": [ + "/usr/bin/python3" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/prefill_interference.py" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [ + "{output_file}" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "通过所选目标的原认证文件在运行时加载 VLLM_API_KEY;不在参数或日志中显示值。", + "原 CLI 的数字参数为必填;表单初值为控制台建议值,不冒充原脚本默认值。" + ], + "available": true + }, + { + "id": "tool-calls", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/toolcall_stress.py", + "interpreter": "/usr/bin/python3", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "{concurrency}", + "{repeats}", + "{output_file}" + ], + "parameters": [ + { + "key": "target", + "label": "目标服务", + "type": "select", + "default": "deepseek-a", + "options": [ + { + "value": "deepseek-a", + "label": "DeepSeek A(原服务,18100)" + }, + { + "value": "dsv41cap", + "label": "dsv41cap(配置端口 18110)" + }, + { + "value": "glm53", + "label": "glm53(配置端口 18102)" + }, + { + "value": "mimo", + "label": "mimo(配置端口 18104)" + }, + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + }, + { + "value": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)" + }, + { + "value": "sglang38", + "label": "sglang38(配置端口 18106)" + }, + { + "value": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + }, + { + "key": "concurrency", + "label": "并发数", + "type": "integer", + "default": 16, + "min": 1, + "max": 256, + "required": true + }, + { + "key": "repeats", + "label": "每题重复次数", + "type": "integer", + "default": 4, + "min": 1, + "max": 1000, + "required": true + } + ], + "environment": { + "PYTHONUNBUFFERED": "1", + "SPEC_URLS": "{target_url}", + "MODEL": "{target_model}" + }, + "credential_files": [], + "impact": { + "level": "standard", + "title": "向已运行服务发送测试请求", + "details": [ + "原脚本按既定用例调用目标接口,可能影响该服务的响应时间。" + ], + "requires_confirmation": false + }, + "interactive": false, + "timeout_seconds": 7200, + "required_tools": [ + "/usr/bin/python3" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/toolcall_stress.py" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [ + "{output_file}" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "通过所选目标的原认证文件在运行时加载 VLLM_API_KEY;不在参数或日志中显示值。", + "原 CLI 的数字参数为必填;表单初值为控制台建议值,不冒充原脚本默认值。" + ], + "available": true + }, + { + "id": "throughput", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/bench-serve.sh", + "interpreter": "/usr/bin/bash", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "{target_config}", + "{run_label}-throughput.json", + "--dataset-name", + "random", + "--random-input-len", + "{input_tokens}", + "--random-output-len", + "{output_tokens}", + "--random-range-ratio", + "{range_ratio}", + "--num-prompts", + "{requests}", + "--max-concurrency", + "{concurrency}", + "--seed", + "{seed}" + ], + "parameters": [ + { + "key": "target", + "label": "原服务配置", + "type": "select", + "default": "sglang38fp8", + "options": [ + { + "value": "dsv41cap", + "label": "dsv41cap(配置端口 18110)" + }, + { + "value": "glm53", + "label": "glm53(配置端口 18102)" + }, + { + "value": "mimo", + "label": "mimo(配置端口 18104)" + }, + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + }, + { + "value": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)" + }, + { + "value": "sglang38", + "label": "sglang38(配置端口 18106)" + }, + { + "value": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + }, + { + "key": "input_tokens", + "label": "平均输入 token", + "type": "integer", + "default": 1024, + "min": 1, + "max": 1000000, + "required": true + }, + { + "key": "output_tokens", + "label": "平均生成 token", + "type": "integer", + "default": 512, + "min": 1, + "max": 32768, + "required": true + }, + { + "key": "range_ratio", + "label": "随机长度浮动比例", + "type": "number", + "default": 0, + "min": 0, + "max": 1, + "required": true + }, + { + "key": "requests", + "label": "请求数", + "type": "integer", + "default": 32, + "min": 1, + "max": 1000000, + "required": true + }, + { + "key": "concurrency", + "label": "最大并发数", + "type": "integer", + "default": 4, + "min": 1, + "max": 2048, + "required": true + }, + { + "key": "seed", + "label": "随机种子", + "type": "integer", + "default": 20261013, + "min": 0, + "max": 2147483647, + "required": true + } + ], + "environment": {}, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "impact": { + "level": "resource", + "title": "向原有模型服务施加并发负载", + "details": [ + "按原流程运行并发请求、性能采样与临时 benchmark 客户端;可能明显影响目标服务的延迟和吞吐。", + "使用既有服务;此入口不会自动启动目标模型。原 Docker 客户端不属于控制台 cgroup,取消后需核对残留容器。" + ], + "requires_confirmation": true + }, + "interactive": false, + "timeout_seconds": 14400, + "required_tools": [ + "/usr/bin/bash", + "/usr/bin/python3", + "/usr/bin/docker", + "/usr/bin/curl", + "/usr/bin/nvidia-smi", + "/usr/bin/timeout" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/bench-serve.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/{target_config}.conf" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/{run_label}-throughput.json" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "Shell 继续读取 configs/{target_config}.conf 的 SERVED、PORT、GPUS、CT_OFF、CT_ON 等原字段;控制台不覆盖这些配置。", + "原 Shell 透传的 vllm bench serve 参数限定为上述登记字段;不接受任意额外参数。表单初值是控制台建议值。" + ], + "available": true + }, + { + "id": "math", + "source_group": "dsv41-trial-20261010", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/scripts/math_check.py", + "interpreter": "/usr/bin/python3", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial", + "arguments": [ + "{concurrency}", + "{samples}", + "{output_file}" + ], + "parameters": [ + { + "key": "target", + "label": "目标服务", + "type": "select", + "default": "deepseek-a", + "options": [ + { + "value": "deepseek-a", + "label": "DeepSeek A(原服务,18100)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + }, + { + "key": "concurrency", + "label": "并发数", + "type": "integer", + "default": 4, + "min": 1, + "max": 256, + "required": true + }, + { + "key": "samples", + "label": "每题采样次数", + "type": "integer", + "default": 1, + "min": 1, + "max": 1000, + "required": true, + "help": "原脚本固定生成 32 道题,总请求数为 32 × 采样次数。" + } + ], + "environment": { + "PYTHONUNBUFFERED": "1" + }, + "credential_files": [], + "impact": { + "level": "standard", + "title": "向已运行服务发送测试请求", + "details": [ + "原脚本按既定用例调用目标接口,可能影响该服务的响应时间。" + ], + "requires_confirmation": false + }, + "interactive": false, + "timeout_seconds": 7200, + "required_tools": [ + "/usr/bin/python3" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/scripts/math_check.py" + ], + "check_ports": [ + 18100 + ], + "output_paths": [ + "{output_file}" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "通过所选目标的原认证文件在运行时加载 VLLM_API_KEY;不在参数或日志中显示值。", + "原 CLI 的数字参数为必填;表单初值为控制台建议值,不冒充原脚本默认值。", + "原脚本硬编码 DeepSeek 模型和 18100 地址;本方案固定目标 A。" + ], + "available": true + }, + { + "id": "vision", + "source_group": "dsv41-trial-20261010", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/scripts/vision_check.py", + "interpreter": "/usr/bin/python3", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial", + "arguments": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/logs/test-34-vision-input.png", + "{output_file}" + ], + "parameters": [ + { + "key": "target", + "label": "目标服务", + "type": "select", + "default": "deepseek-a", + "options": [ + { + "value": "deepseek-a", + "label": "DeepSeek A(原服务,18100)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + } + ], + "environment": { + "PYTHONUNBUFFERED": "1", + "SPEC_URLS": "{target_url}", + "MODEL": "{target_model}" + }, + "credential_files": [], + "impact": { + "level": "standard", + "title": "向已运行服务发送测试请求", + "details": [ + "原脚本按既定用例调用目标接口,可能影响该服务的响应时间。" + ], + "requires_confirmation": false + }, + "interactive": false, + "timeout_seconds": 7200, + "required_tools": [ + "/usr/bin/python3" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/scripts/vision_check.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/logs/test-34-vision-input.png" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [ + "{output_file}" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "通过所选目标的原认证文件在运行时加载 VLLM_API_KEY;不在参数或日志中显示值。", + "复用原 run-d7v-d8.sh 引用的 test-34-vision-input.png;本轮仅核对文件名及大小,未读取或复制图像内容。", + "原入口分别发送关闭/开启 thinking 的两次看图请求;目标服务需已启用视觉。未自动修改或重启模型。" + ], + "available": true + }, + { + "id": "robust", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/robust_check.py", + "interpreter": "/usr/bin/python3", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "{output_file}" + ], + "parameters": [ + { + "key": "target", + "label": "目标服务", + "type": "select", + "default": "deepseek-a", + "options": [ + { + "value": "deepseek-a", + "label": "DeepSeek A(原服务,18100)" + }, + { + "value": "dsv41cap", + "label": "dsv41cap(配置端口 18110)" + }, + { + "value": "glm53", + "label": "glm53(配置端口 18102)" + }, + { + "value": "mimo", + "label": "mimo(配置端口 18104)" + }, + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + }, + { + "value": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)" + }, + { + "value": "sglang38", + "label": "sglang38(配置端口 18106)" + }, + { + "value": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + }, + { + "key": "storm_n", + "label": "断连并发数", + "type": "integer", + "default": 64, + "min": 1, + "max": 256, + "required": true + }, + { + "key": "storm_rounds", + "label": "断连轮数", + "type": "integer", + "default": 5, + "min": 1, + "max": 100, + "required": true + } + ], + "environment": { + "PYTHONUNBUFFERED": "1", + "SPEC_URLS": "{target_url}", + "MODEL": "{target_model}", + "STORM_N": "{storm_n}", + "STORM_ROUNDS": "{storm_rounds}" + }, + "credential_files": [], + "impact": { + "level": "resource", + "title": "执行断连风暴及超长异常请求", + "details": [ + "原默认是 64 路并发、5 轮断连,并发送超过目标上下文上限的输入/输出等异常请求,可能短时影响目标服务。", + "MAX_LEN 来自登记目标的原启动配置,运行前需核对服务实际配置是否仍一致。" + ], + "requires_confirmation": true + }, + "interactive": false, + "timeout_seconds": 7200, + "required_tools": [ + "/usr/bin/python3" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/robust_check.py" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [ + "{output_file}" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "通过所选目标的原认证文件在运行时加载 VLLM_API_KEY;不在参数或日志中显示值。" + ], + "available": true + }, + { + "id": "agent", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/agent_sim.py", + "interpreter": "/usr/bin/python3", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "{target_url}", + "{target_model}", + "{sessions}", + "{turns}", + "{context_tokens}", + "{new_tokens}", + "{output_tokens}", + "{seed}", + "{output_file}" + ], + "parameters": [ + { + "key": "target", + "label": "目标服务", + "type": "select", + "default": "deepseek-a", + "options": [ + { + "value": "deepseek-a", + "label": "DeepSeek A(原服务,18100)" + }, + { + "value": "dsv41cap", + "label": "dsv41cap(配置端口 18110)" + }, + { + "value": "glm53", + "label": "glm53(配置端口 18102)" + }, + { + "value": "mimo", + "label": "mimo(配置端口 18104)" + }, + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + }, + { + "value": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)" + }, + { + "value": "sglang38", + "label": "sglang38(配置端口 18106)" + }, + { + "value": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + }, + { + "key": "sessions", + "label": "每地址会话数", + "type": "integer", + "default": 4, + "min": 1, + "max": 512, + "required": true + }, + { + "key": "turns", + "label": "每会话轮数", + "type": "integer", + "default": 4, + "min": 1, + "max": 1000, + "required": true + }, + { + "key": "context_tokens", + "label": "固定上下文 token", + "type": "integer", + "default": 8192, + "min": 0, + "max": 1000000, + "required": true + }, + { + "key": "new_tokens", + "label": "每轮新增 token", + "type": "integer", + "default": 1024, + "min": 0, + "max": 1000000, + "required": true + }, + { + "key": "output_tokens", + "label": "每轮生成 token", + "type": "integer", + "default": 256, + "min": 1, + "max": 32768, + "required": true + }, + { + "key": "seed", + "label": "随机种子", + "type": "integer", + "default": 20261013, + "min": 0, + "max": 2147483647, + "required": true + }, + { + "key": "agent_mode", + "label": "上下文增长方式", + "type": "select", + "default": "replace", + "options": [ + { + "value": "replace", + "label": "替换新增段(原默认)" + }, + { + "value": "append", + "label": "逐轮追加" + } + ], + "required": true + } + ], + "environment": { + "PYTHONUNBUFFERED": "1", + "SPEC_URLS": "{target_url}", + "MODEL": "{target_model}", + "AGENT_MODE": "{agent_mode}" + }, + "credential_files": [], + "impact": { + "level": "standard", + "title": "向已运行服务发送测试请求", + "details": [ + "原脚本按既定用例调用目标接口,可能影响该服务的响应时间。" + ], + "requires_confirmation": false + }, + "interactive": false, + "timeout_seconds": 7200, + "required_tools": [ + "/usr/bin/python3" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/agent_sim.py" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [ + "{output_file}" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "通过所选目标的原认证文件在运行时加载 VLLM_API_KEY;不在参数或日志中显示值。", + "原 CLI 数字参数全部必填;表单初值是控制台建议值。", + "输入使用原脚本生成的随机 token 编号;append 模式每轮增长,超过目标上下文限制会失败。" + ], + "available": true + }, + { + "id": "replicas", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/run-replicas.sh", + "interpreter": "/usr/bin/bash", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "{target_config}", + "{run_label}", + "8" + ], + "parameters": [ + { + "key": "target", + "label": "原实例配置", + "type": "select", + "default": "qwen38fp8cap", + "options": [ + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + } + ], + "required": true, + "help": "请选择当前 18110–18117 上已有 8 份服务对应的原配置。" + } + ], + "environment": {}, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "impact": { + "level": "resource", + "title": "向原有模型服务施加并发负载", + "details": [ + "按原流程运行并发请求、性能采样与临时 benchmark 客户端;可能明显影响目标服务的延迟和吞吐。", + "使用既有服务;此入口不会自动启动目标模型。原 Docker 客户端不属于控制台 cgroup,取消后需核对残留容器。" + ], + "requires_confirmation": true + }, + "interactive": false, + "timeout_seconds": 14400, + "required_tools": [ + "/usr/bin/bash", + "/usr/bin/python3", + "/usr/bin/docker", + "/usr/bin/curl", + "/usr/bin/nvidia-smi", + "/usr/bin/timeout", + "/usr/bin/vmstat" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/run-replicas.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/{target_config}.conf", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/spec_prompts.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/spec_bench.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/bench-replicas.sh" + ], + "check_ports": [ + 18110, + 18111, + 18112, + 18113, + 18114, + 18115, + 18116, + 18117 + ], + "output_paths": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/{target_config}-{run_label}-*" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "Shell 继续读取 configs/{target_config}.conf 的 SERVED、PORT、GPUS、CT_OFF、CT_ON 等原字段;控制台不覆盖这些配置。", + "保留原默认 8 份、GPU_LIST=8 9 10 11 12 13 14 15、端口 18110–18117,以及原 NAT_LEVELS/RAND_LEVELS;控制台本方案固定此组合。", + "只检测并使用已启动的 8 个实例,不调用 serve-replicas.sh。所选配置自身 PORT 不代表此入口的实例端口。" + ], + "available": true + }, + { + "id": "engine-comparison", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/run-compare.sh", + "interpreter": "/usr/bin/bash", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "{target_config}", + "{run_label}" + ], + "parameters": [ + { + "key": "target", + "label": "原服务配置", + "type": "select", + "default": "sglang38fp8", + "options": [ + { + "value": "dsv41cap", + "label": "dsv41cap(配置端口 18110)" + }, + { + "value": "glm53", + "label": "glm53(配置端口 18102)" + }, + { + "value": "mimo", + "label": "mimo(配置端口 18104)" + }, + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + }, + { + "value": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)" + }, + { + "value": "sglang38", + "label": "sglang38(配置端口 18106)" + }, + { + "value": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + } + ], + "environment": {}, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "impact": { + "level": "resource", + "title": "向原有模型服务施加并发负载", + "details": [ + "按原流程运行并发请求、性能采样与临时 benchmark 客户端;可能明显影响目标服务的延迟和吞吐。", + "使用既有服务;此入口不会自动启动目标模型。原 Docker 客户端不属于控制台 cgroup,取消后需核对残留容器。" + ], + "requires_confirmation": true + }, + "interactive": false, + "timeout_seconds": 14400, + "required_tools": [ + "/usr/bin/bash", + "/usr/bin/python3", + "/usr/bin/docker", + "/usr/bin/curl", + "/usr/bin/nvidia-smi", + "/usr/bin/timeout" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/run-compare.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/{target_config}.conf", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/spec_prompts.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/spec_bench.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/bench-serve.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/curves.py" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/{target_config}-{run_label}-*" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "Shell 继续读取 configs/{target_config}.conf 的 SERVED、PORT、GPUS、CT_OFF、CT_ON 等原字段;控制台不覆盖这些配置。", + "保留原 NAT_LEVELS、THINK_N=16、RAND_LEVELS、LONG=32000、SEED_BASE=5000;本入口测试所选已运行配置,并不自行切换引擎。" + ], + "available": true + }, + { + "id": "soak", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/soak.sh", + "interpreter": "/usr/bin/bash", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [ + "{target_config}", + "{run_label}", + "{concurrency}", + "{requests}", + "{long_inputs}" + ], + "parameters": [ + { + "key": "target", + "label": "原服务配置", + "type": "select", + "default": "sglang38fp8", + "options": [ + { + "value": "dsv41cap", + "label": "dsv41cap(配置端口 18110)" + }, + { + "value": "glm53", + "label": "glm53(配置端口 18102)" + }, + { + "value": "mimo", + "label": "mimo(配置端口 18104)" + }, + { + "value": "qwen27b", + "label": "qwen27b(配置端口 18103)" + }, + { + "value": "qwen27btp8", + "label": "qwen27btp8(配置端口 18103)" + }, + { + "value": "qwen38cap", + "label": "qwen38cap(配置端口 18107)" + }, + { + "value": "qwen38fp8", + "label": "qwen38fp8(配置端口 18105)" + }, + { + "value": "qwen38fp8cap", + "label": "qwen38fp8cap(配置端口 18108)" + }, + { + "value": "qwen38fp8mtpcap", + "label": "qwen38fp8mtpcap(配置端口 18109)" + }, + { + "value": "qwen38fp8tp8", + "label": "qwen38fp8tp8(配置端口 18110)" + }, + { + "value": "sglang38", + "label": "sglang38(配置端口 18106)" + }, + { + "value": "sglang38fp8", + "label": "sglang38fp8(配置端口 18106)" + } + ], + "required": true, + "help": "配置存在不代表服务正在运行;预检仅检查端口,不自动启动模型。" + }, + { + "key": "concurrency", + "label": "并发数", + "type": "integer", + "default": 32, + "min": 1, + "max": 2048, + "required": true + }, + { + "key": "requests", + "label": "主负载请求数", + "type": "integer", + "default": 3200, + "min": 1, + "max": 10000000, + "required": true + }, + { + "key": "long_inputs", + "label": "长输入次数上限", + "type": "integer", + "default": 12, + "min": 0, + "max": 864, + "required": true + } + ], + "environment": {}, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "impact": { + "level": "resource", + "title": "向原有模型服务施加并发负载", + "details": [ + "按原流程运行并发请求、性能采样与临时 benchmark 客户端;可能明显影响目标服务的延迟和吞吐。", + "使用既有服务;此入口不会自动启动目标模型。原 Docker 客户端不属于控制台 cgroup,取消后需核对残留容器。" + ], + "requires_confirmation": true + }, + "interactive": false, + "timeout_seconds": 259200, + "required_tools": [ + "/usr/bin/bash", + "/usr/bin/python3", + "/usr/bin/docker", + "/usr/bin/curl", + "/usr/bin/nvidia-smi", + "/usr/bin/timeout" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/soak.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/{target_config}.conf", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/bench-serve.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/concurrent_prefill.py" + ], + "check_ports": [ + "{target_port}" + ], + "output_paths": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/{target_config}-{run_label}-*" + ], + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "Shell 继续读取 configs/{target_config}.conf 的 SERVED、PORT、GPUS、CT_OFF、CT_ON 等原字段;控制台不覆盖这些配置。", + "32 / 3200 / 12 为原 CLI 默认;按请求数决定时长。原遥测 timeout 固定 14400 秒,延长控制台超时不会延长原遥测覆盖。" + ], + "available": true + }, + { + "id": "capacity", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/cap-8x-chain.sh", + "interpreter": "/usr/bin/bash", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [], + "parameters": [], + "environment": {}, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env", + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/configs/vllm.env" + ], + "impact": { + "level": "service", + "title": "停止 DeepSeek A,并进行 16 卡整机满载测试", + "details": [ + "原链先使用 GPU 8–15,随后停止 DeepSeek A,使用 GPU 0–15,启停多套模型及 16 个 Qwen 实例。", + "原 EXIT trap 会尝试按 start-rec.sh 重启 A;失败、超时或取消不能保证恢复原状态。", + "原日志和结果名称固定,可能覆盖已有 dsv41cap、glm53、mimo、qwen38fp8tp8、qwen38fp8cap 结果;需逐次确认并保全历史文件。" + ], + "requires_confirmation": true + }, + "interactive": false, + "timeout_seconds": 259200, + "required_tools": [ + "/usr/bin/bash", + "/usr/bin/python3", + "/usr/bin/docker", + "/usr/bin/curl", + "/usr/bin/nvidia-smi", + "/usr/bin/timeout", + "/usr/bin/pgrep", + "/usr/bin/setsid", + "/usr/bin/vmstat" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/cap-8x-chain.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env", + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/configs/vllm.env", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/serve-sets.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/serve-replicas.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/stop-model.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/bench-multi.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/run-replicas.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/run-compare.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/bench-replicas.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/bench-serve.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/spec_prompts.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/spec_bench.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/curves.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/dsv41cap.conf", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/glm53.conf", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/mimo.conf", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/qwen38fp8tp8.conf", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/qwen38fp8cap.conf", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/phaseb-levels.env", + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/scripts/start-rec.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/scripts/serve.sh" + ], + "check_ports": [], + "output_paths": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/dsv41cap-01-*", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/dsv41cap-02-*", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/glm53-11-*", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/glm53-12-*", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/mimo-11-*", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/mimo-12-*", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/qwen38fp8tp8-01-*", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/qwen38fp8cap-02-*", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/serve-dsv41cap-x01-g8.log", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/serve-dsv41cap-x02-g*.log", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/serve-glm53-x11-g8.log", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/serve-glm53-x12-g*.log", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/serve-mimo-x11-g8.log", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/serve-mimo-x12-g*.log", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/serve-qwen38fp8tp8-a1-g8.log", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/serve-qwen38fp8cap-x16-g*.log", + "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/logs/serve-r7-rec-pin.log" + ], + "preserve_existing_outputs": true, + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "原入口无位置参数;不伪造可配置的编号或输出路径。原链使用固定历史文件名,再次运行可能覆盖同名结果,应在确认前检查并保全相关历史文件。" + ], + "available": true + }, + { + "id": "cost", + "source_group": "model-trials-20261011", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/value-chain.sh", + "interpreter": "/usr/bin/bash", + "user": "root", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials", + "arguments": [], + "parameters": [], + "environment": {}, + "credential_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env" + ], + "impact": { + "level": "service", + "title": "启停 GPU 8–15 上的性能与成本测试模型", + "details": [ + "原链等待 cap-8x-chain.sh 退出,再检查 GPU 8–15 显存并启动/停止 DeepSeek 与 8 份 Qwen 模型。", + "原结果使用固定 dsv41v-*、qwen38v-* 名称,可能覆盖历史结果。", + "该入口产生本机性能证据,成本折算和报告仍按原配套脚本处理;不代表本轮已验证价格口径。" + ], + "requires_confirmation": true + }, + "interactive": false, + "timeout_seconds": 259200, + "required_tools": [ + "/usr/bin/bash", + "/usr/bin/python3", + "/usr/bin/docker", + "/usr/bin/curl", + "/usr/bin/nvidia-smi", + "/usr/bin/timeout", + "/usr/bin/pgrep", + "/usr/bin/setsid", + "/usr/bin/vmstat" + ], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/value-chain.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/vllm.env", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/serve-sets.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/serve-replicas.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/stop-model.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/bench-multi.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/scripts/bench-one.sh", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/dsv41cap.conf", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/configs/qwen38fp8cap.conf" + ], + "check_ports": [], + "output_paths": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/dsv41v-*", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/qwen38v-*", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/serve-dsv41cap-xv-g8.log", + "/dengyuyun-joy-work-space/workpc-claude-code/20261011-model-trials/logs/serve-qwen38fp8cap-xv8-g*.log" + ], + "preserve_existing_outputs": true, + "notes": [ + "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", + "原入口无位置参数;不伪造可配置的编号或输出路径。原链使用固定历史文件名,再次运行可能覆盖同名结果,应在确认前检查并保全相关历史文件。" + ], + "available": true + } + ] +} diff --git a/catalog/test-definitions.json b/catalog/test-definitions.json new file mode 100644 index 0000000..606020e --- /dev/null +++ b/catalog/test-definitions.json @@ -0,0 +1,574 @@ +{ + "schema_version": 1, + "reviewed_at": "2026-10-13", + "basis": "Reviewed README entry points, script purpose comments and call references; registration does not authorize service/GPU actions.", + "example_script_ids": [ + "499b615a7dc7435eb30789b7a7dc090e", + "fe5a2b28c8b4449591ef55bd88299a6d" + ], + "tests": [ + { + "key": "gpu-suite", + "source_group": "company-gpu-suite", + "entry": "xas-gpu-test.sh", + "name": "GPU 综合验收套件", + "description": "公司 XAS GPU Test v3.5.0 的统一入口,包含硬件压力、多卡通信、模型性能及报告。内部模块和套件自身测试统一收在详情中。", + "kind": "suite", + "members": [ + { + "source_group": "company-gpu-suite", + "pattern": "**/*" + } + ] + }, + { + "key": "deepseek-suite", + "source_group": "dsv41-trial-20261010", + "entry": "run-b-suite.sh", + "name": "DeepSeek 综合性能测试", + "description": "在第二套 DeepSeek 服务上完成单路曲线、并发吞吐、自然语言生成、多轮缓存和工具调用的一整轮测试。", + "kind": "suite", + "members": [ + { + "source_group": "dsv41-trial-20261010", + "pattern": "bench-serve.sh" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "curves.py" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "multiturn_cache.py" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "spec_bench.py" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "spec_prompts.py" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "toolcall_stress.py" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "run-report-phase*.sh", + "role": "variant" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "run-d2.sh", + "role": "variant" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "run-d3.sh", + "role": "variant" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "run-d456.sh", + "role": "variant" + } + ] + }, + { + "key": "model-suite", + "source_group": "model-trials-20261011", + "entry": "run-suite.sh", + "name": "模型综合测试套件", + "description": "面向被测模型的一整轮速度、长上下文、功能与稳定性测试。不同模型的历史调度脚本收在相关文件中。", + "kind": "suite", + "members": [ + { + "source_group": "model-trials-20261011", + "pattern": "bench-serve.sh" + }, + { + "source_group": "model-trials-20261011", + "pattern": "curves.py" + }, + { + "source_group": "model-trials-20261011", + "pattern": "functional_check.py" + }, + { + "source_group": "model-trials-20261011", + "pattern": "multiturn_cache.py" + }, + { + "source_group": "model-trials-20261011", + "pattern": "needle.py" + }, + { + "source_group": "model-trials-20261011", + "pattern": "spec_bench.py" + }, + { + "source_group": "model-trials-20261011", + "pattern": "spec_prompts.py" + }, + { + "source_group": "model-trials-20261011", + "pattern": "toolcall_stress.py" + }, + { + "source_group": "model-trials-20261011", + "pattern": "run-glm-chain.sh", + "role": "variant" + }, + { + "source_group": "model-trials-20261011", + "pattern": "run-mimo-chain.sh", + "role": "variant" + }, + { + "source_group": "model-trials-20261011", + "pattern": "run-qwen-chain.sh", + "role": "variant" + }, + { + "source_group": "model-trials-20261011", + "pattern": "serve-model.sh", + "role": "variant" + } + ] + }, + { + "key": "functional", + "source_group": "model-trials-20261011", + "entry": "functional_check.py", + "name": "模型功能检查", + "description": "检查接口功能、结构化输出、多轮交互等可用性,不等同于答案准确率评测。", + "kind": "test", + "members": [ + { + "source_group": "dsv41-trial-20261010", + "pattern": "functional_check.py", + "role": "variant" + } + ] + }, + { + "key": "curves", + "source_group": "model-trials-20261011", + "entry": "curves.py", + "name": "单路性能曲线", + "description": "单请求逐项测量生成长度和上下文长度变化时的性能曲线。", + "kind": "test", + "members": [ + { + "source_group": "dsv41-trial-20261010", + "pattern": "curves.py", + "role": "variant" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "run-curves.sh", + "role": "variant" + }, + { + "source_group": "model-trials-20261011", + "pattern": "context_probe.py", + "role": "variant" + }, + { + "source_group": "model-trials-20261011", + "pattern": "run-mimo-recheck.sh", + "role": "variant" + } + ] + }, + { + "key": "language", + "source_group": "model-trials-20261011", + "entry": "spec_bench.py", + "name": "自然语言生成速度", + "description": "用固定自然语言题库测量生成速度、并发和不同解码设置的表现。", + "kind": "test", + "members": [ + { + "source_group": "model-trials-20261011", + "pattern": "spec_prompts.py" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "spec_bench.py", + "role": "variant" + } + ] + }, + { + "key": "conversation-cache", + "source_group": "model-trials-20261011", + "entry": "multiturn_cache.py", + "name": "多轮对话缓存效果", + "description": "模拟多轮对话,观察前缀缓存对每轮首字时间的影响。", + "kind": "test", + "members": [ + { + "source_group": "dsv41-trial-20261010", + "pattern": "multiturn_cache.py", + "role": "variant" + } + ] + }, + { + "key": "needle", + "source_group": "model-trials-20261011", + "entry": "needle.py", + "name": "长上下文检索", + "description": "在指定上下文长度和位置中检索目标信息,检查长上下文能力。", + "kind": "test", + "members": [ + { + "source_group": "dsv41-trial-20261010", + "pattern": "needle.py", + "role": "variant" + } + ] + }, + { + "key": "concurrent-input", + "source_group": "model-trials-20261011", + "entry": "concurrent_prefill.py", + "name": "并发长输入读入", + "description": "检查多个长输入同时到达时的读入表现。", + "kind": "test", + "members": [ + { + "source_group": "dsv41-trial-20261010", + "pattern": "concurrent_prefill.py", + "role": "variant" + } + ] + }, + { + "key": "cache-revisit", + "source_group": "model-trials-20261011", + "entry": "kv_revisit.py", + "name": "长会话缓存回访", + "description": "检查长会话缓存被挤出后再次访问时的等待时间。", + "kind": "test", + "members": [ + { + "source_group": "dsv41-trial-20261010", + "pattern": "kv_revisit.py", + "role": "variant" + } + ] + }, + { + "key": "interference", + "source_group": "model-trials-20261011", + "entry": "prefill_interference.py", + "name": "长输入对聊天延迟的影响", + "description": "测量超长输入进入时,对其他交互请求延迟造成的影响。", + "kind": "test", + "members": [ + { + "source_group": "dsv41-trial-20261010", + "pattern": "prefill_interference.py", + "role": "variant" + } + ] + }, + { + "key": "tool-calls", + "source_group": "model-trials-20261011", + "entry": "toolcall_stress.py", + "name": "工具调用并发稳定性", + "description": "检查并发工具调用、参数 JSON 和必填字段的稳定性。", + "kind": "test", + "members": [ + { + "source_group": "dsv41-trial-20261010", + "pattern": "toolcall_stress.py", + "role": "variant" + } + ] + }, + { + "key": "throughput", + "source_group": "model-trials-20261011", + "entry": "bench-serve.sh", + "name": "单服务并发吞吐", + "description": "对一个已部署的模型服务测量不同并发下的吞吐和延迟。", + "kind": "test", + "members": [ + { + "source_group": "model-trials-20261011", + "pattern": "bench-one.sh" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "bench-serve.sh", + "role": "variant" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "bench.py", + "role": "variant" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "run-fresh-seed-dsv41.sh", + "role": "variant" + } + ] + }, + { + "key": "math", + "source_group": "dsv41-trial-20261010", + "entry": "math_check.py", + "name": "数学应用题回归", + "description": "通过可自动判对错的算术应用题检查解码配置变更后是否出现明显退化。", + "kind": "test", + "members": [] + }, + { + "key": "vision", + "source_group": "dsv41-trial-20261010", + "entry": "vision_check.py", + "name": "图片理解功能检查", + "description": "检查模型图片输入功能是否可用,需要准备图片素材。", + "kind": "test", + "members": [ + { + "source_group": "dsv41-trial-20261010", + "pattern": "run-d7v-d8.sh", + "role": "variant" + } + ] + }, + { + "key": "robust", + "source_group": "model-trials-20261011", + "entry": "robust_check.py", + "name": "异常输入与断连检查", + "description": "检查异常输入、请求中断等情况下的模型服务稳定性。", + "kind": "test", + "members": [] + }, + { + "key": "agent", + "source_group": "model-trials-20261011", + "entry": "agent_sim.py", + "name": "Agent 多轮场景测试", + "description": "用多轮 Agent 场景请求测量运行表现和 token 消耗,汇总脚本和历史调度链作为配套文件。", + "kind": "test", + "members": [ + { + "source_group": "model-trials-20261011", + "pattern": "agent_summary.py" + }, + { + "source_group": "model-trials-20261011", + "pattern": "agent-chain.sh" + } + ] + }, + { + "key": "replicas", + "source_group": "model-trials-20261011", + "entry": "run-replicas.sh", + "name": "多实例并行吞吐", + "description": "测量多个模型实例同时运行时的合计能力;实例数、GPU 和端口仍由原配置确定。", + "kind": "suite", + "members": [ + { + "source_group": "model-trials-20261011", + "pattern": "bench-replicas.sh" + }, + { + "source_group": "model-trials-20261011", + "pattern": "bench-multi.sh" + }, + { + "source_group": "model-trials-20261011", + "pattern": "spec_bench.py" + }, + { + "source_group": "model-trials-20261011", + "pattern": "spec_prompts.py" + }, + { + "source_group": "model-trials-20261011", + "pattern": "serve-replicas.sh" + }, + { + "source_group": "model-trials-20261011", + "pattern": "serve-sets.sh" + } + ] + }, + { + "key": "engine-comparison", + "source_group": "model-trials-20261011", + "entry": "run-compare.sh", + "name": "推理引擎与解码配置对比", + "description": "比较推理引擎、权重精度和解码配置的速度表现;同类历史对照脚本不再单独平铺。", + "kind": "suite", + "members": [ + { + "source_group": "model-trials-20261011", + "pattern": "bench-serve.sh" + }, + { + "source_group": "model-trials-20261011", + "pattern": "curves.py" + }, + { + "source_group": "model-trials-20261011", + "pattern": "spec_bench.py" + }, + { + "source_group": "model-trials-20261011", + "pattern": "spec_prompts.py" + }, + { + "source_group": "model-trials-20261011", + "pattern": "qwen38-compare-chain.sh", + "role": "variant" + }, + { + "source_group": "model-trials-20261011", + "pattern": "qwen38-fast-chain.sh", + "role": "variant" + }, + { + "source_group": "model-trials-20261011", + "pattern": "qwen38-sglang-track.sh", + "role": "variant" + }, + { + "source_group": "model-trials-20261011", + "pattern": "run-glm-mtp-retry.sh", + "role": "variant" + }, + { + "source_group": "model-trials-20261011", + "pattern": "run-qwen-pin-check.sh", + "role": "variant" + }, + { + "source_group": "model-trials-20261011", + "pattern": "run-qwen-shm-check.sh", + "role": "variant" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "run-spec-compare.sh", + "role": "variant" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "run-dspark-tests.sh", + "role": "variant" + } + ] + }, + { + "key": "soak", + "source_group": "model-trials-20261011", + "entry": "soak.sh", + "name": "长时间稳定性测试", + "description": "持续施加并发和长输入负载,观察失败、吞吐和资源变化。", + "kind": "suite", + "members": [ + { + "source_group": "model-trials-20261011", + "pattern": "bench-serve.sh" + }, + { + "source_group": "model-trials-20261011", + "pattern": "concurrent_prefill.py" + }, + { + "source_group": "model-trials-20261011", + "pattern": "soak24-chain.sh", + "role": "variant" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "run-d8.sh", + "role": "variant" + } + ] + }, + { + "key": "capacity", + "source_group": "model-trials-20261011", + "entry": "cap-8x-chain.sh", + "name": "单卡与整机并发上限", + "description": "测量单卡或单套服务的并发上限,以及多实例整机满载表现。", + "kind": "suite", + "members": [ + { + "source_group": "model-trials-20261011", + "pattern": "bench-multi.sh" + }, + { + "source_group": "model-trials-20261011", + "pattern": "serve-replicas.sh" + }, + { + "source_group": "model-trials-20261011", + "pattern": "serve-sets.sh" + }, + { + "source_group": "model-trials-20261011", + "pattern": "qwen38-cap-chain.sh", + "role": "variant" + }, + { + "source_group": "model-trials-20261011", + "pattern": "x16-chain.sh", + "role": "variant" + } + ] + }, + { + "key": "cost", + "source_group": "model-trials-20261011", + "entry": "value-chain.sh", + "name": "性能与成本评估", + "description": "按聊天、检索和代码生成场景测量性能,结合既定价格口径汇总成本。", + "kind": "suite", + "members": [ + { + "source_group": "model-trials-20261011", + "pattern": "bench-multi.sh" + }, + { + "source_group": "model-trials-20261011", + "pattern": "bench-one.sh" + }, + { + "source_group": "model-trials-20261011", + "pattern": "value_ladder.py" + }, + { + "source_group": "model-trials-20261011", + "pattern": "value2-chain.sh", + "role": "variant" + } + ] + }, + { + "key": "light-smoke", + "managed_profile": "legacy_smoke", + "kind": "test", + "members": [ + { + "source_group": "dsv41-trial-20261010", + "pattern": "smoke.sh", + "role": "variant" + } + ] + } + ] +} diff --git a/console/__init__.py b/console/__init__.py new file mode 100644 index 0000000..5ec2b6a --- /dev/null +++ b/console/__init__.py @@ -0,0 +1 @@ +"""XAS dedicated script console.""" diff --git a/console/app.py b/console/app.py new file mode 100644 index 0000000..2a3ed69 --- /dev/null +++ b/console/app.py @@ -0,0 +1,297 @@ +"""LAN-only HTTP interface; does not spawn processes or access Docker.""" +import hashlib +import ipaddress +from pathlib import Path +from typing import Annotated +from urllib.parse import urlsplit +import uuid +from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, ConfigDict, Field +from . import catalog +from . import test_catalog +from . import native_plans, native_io +from .settings import Settings +from .store import Store, TERMINAL, now + + +class ConfigPatch(BaseModel): + model_config = ConfigDict(extra="forbid") + args: list[str] | None = None + cwd: str | None = None + output_paths: list[str] | None = None + timeout_seconds: Annotated[int, Field(strict=True)] | None = None + native_options: dict[str, str | int | float | bool] | None = None + + +class ScriptPatch(BaseModel): + model_config = ConfigDict(extra="forbid") + name: str | None = Field(default=None, min_length=1, max_length=120) + description: str | None = Field(default=None, max_length=4000) + enabled: bool | None = None + config: ConfigPatch | None = None + + +class RunRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + request_id: uuid.UUID + plan_token: str | None = Field(default=None, max_length=64) + impact_acknowledged: bool = False + + +class NativeInput(BaseModel): + model_config = ConfigDict(extra="forbid") + text: str = Field(max_length=1024) + + +def create_app(settings=None): + settings = settings or Settings.from_env() + store = Store(settings) + app = FastAPI(title="XAS 测试控制台", docs_url=None, redoc_url=None, openapi_url=None) + app.state.store = store + static = Path(__file__).parent / "static" + + @app.middleware("http") + async def boundaries(request, call_next): + host = request.headers.get("host", "") + try: + address = ipaddress.ip_address(request.client.host) + allowed = any(address in ipaddress.ip_network(n) for n in settings.allowed_networks) + except ValueError: + allowed = False + if host not in settings.allowed_hosts or not allowed: + return JSONResponse({"detail": "仅允许公司内网访问"}, status_code=403) + if request.method not in {"GET", "HEAD", "OPTIONS"}: + origin = request.headers.get("origin") + valid_origin = not origin or origin == f"{request.url.scheme}://{host}" + if (not valid_origin or request.headers.get("x-xas-console") != "1" + or request.headers.get("sec-fetch-site") == "cross-site"): + return JSONResponse({"detail": "请求来源校验失败,请从控制台页面操作"}, status_code=403) + try: + size = int(request.headers.get("content-length", "0")) + except ValueError: + size = -1 + if size < 0 or size > settings.max_upload + 65536 or "transfer-encoding" in request.headers: + return JSONResponse({"detail": "请求超过大小限制"}, status_code=413) + response = await call_next(request) + response.headers["Cache-Control"] = "no-store" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Referrer-Policy"] = "same-origin" + response.headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'" + return response + + @app.exception_handler(ValueError) + async def value_error(request, exc): + return JSONResponse({"detail": str(exc)}, status_code=400) + + @app.exception_handler(KeyError) + async def missing(request, exc): + return JSONResponse({"detail": str(exc.args[0])}, status_code=404) + + @app.get("/api/summary") + def summary(): + inventory, runs = store.scripts(), store.runs(limit=10000) + scripts = test_catalog.tests(settings, inventory) + # No directory recursion on every page refresh; size derives from recorded artifacts. + size = sum(a["size"] for r in runs for a in r.get("artifacts", [])) + return {"scripts": len(scripts), "source_files": len(inventory), + "examples": len(test_catalog.tests(settings, inventory, True)) - len(scripts), + "ready": sum(s["enabled"] for s in scripts), "runs": len(runs), + "active": sum(r["state"] in {"queued", "running"} for r in runs), "worker": store.health(), + "storage": {"run_bytes": size}, "limits": {"upload_bytes": settings.max_upload, + "log_bytes": settings.max_log, "concurrency": 1, "timeout_seconds": 7200}} + + @app.get("/api/scripts") + def scripts(include_examples: bool = False): + return {"items": test_catalog.tests(settings, store.scripts(), include_examples)} + + @app.post("/api/scripts/upload") + async def new_script(file: UploadFile, name: Annotated[str, Form()] = "", description: Annotated[str, Form()] = ""): + try: + item = catalog.upload(store, await file.read(settings.max_upload + 1), file.filename, name, description) + return store.script(item["id"], versions=True) + finally: + await file.close() + + @app.get("/api/scripts/{identity}") + def script(identity: str): + return test_catalog.describe(settings, store.script(identity, versions=True), store.scripts()) + + @app.get("/api/scripts/{identity}/files") + def script_files(identity: str): + return {"items": test_catalog.members(settings, store.script(identity), store.scripts())} + + @app.post("/api/scripts/{identity}/versions") + async def new_version(identity: str, file: UploadFile): + try: + catalog.upload(store, await file.read(settings.max_upload + 1), file.filename, identity=identity) + return store.script(identity, versions=True) + finally: + await file.close() + + def source_version(identity, version): + item = store.script(identity) + if item["kind"] == "managed" and version: + v = store.version(identity, version) + item.update(source_path=v["path"], sha256=v["sha256"]) + elif version and version != item["version"]: + raise ValueError("原位置脚本仅展示当前文件,历史运行请查看运行归档") + return item + + @app.get("/api/scripts/{identity}/code") + def code(identity: str, version: int | None = None): + return catalog.read_code(settings, source_version(identity, version)) + + @app.get("/api/scripts/{identity}/download") + def download_source(identity: str, version: int | None = None): + item = source_version(identity, version) + source = catalog.read_code(settings, item) + if source["redacted"]: + raise ValueError("包含疑似凭据的原文件不能下载") + return FileResponse(catalog.script_path(settings, item), filename=source["filename"], media_type="text/plain") + + @app.patch("/api/scripts/{identity}") + def update_script(identity: str, patch: ScriptPatch): + # Serialize with version uploads to avoid restoring an obsolete current-version pointer. + import json + with store.connect(write=True) as db: + row = db.execute("SELECT data FROM scripts WHERE id=?", (identity,)).fetchone() + if not row: + raise KeyError("脚本不存在") + item = json.loads(row[0]) + if not test_catalog.is_entry(settings, item): + raise ValueError("此文件属于内部源码,请从测试项目入口配置和运行") + values = patch.model_dump(exclude_none=True) + if "config" in values: + config_patch = values.pop("config") + if native_plans.profile_for(settings, item) and set(config_patch) - {"native_options", "timeout_seconds"}: + raise ValueError("原环境入口、目录和输出规则由已登记方案管理") + item["config"] = catalog.validate_config(settings, item, {**item["config"], **config_patch}) + if values.get("enabled"): + native = native_plans.profile_for(settings, item) + blocked = not native.get("available", True) if native else item.get("blocked") + if blocked or item["status"] == "missing": + raise ValueError(item["reason"] or "此脚本暂不能启用") + catalog.script_path(settings, item) + item["config"] = catalog.validate_config(settings, item, item["config"]) + item.update(values, updated_at=now()) + item["status"] = "ready" if item["enabled"] else "needs_configuration" + item["reason"] = "" if item["enabled"] else (item.get("reason") or "已停用") + db.execute("UPDATE scripts SET data=? WHERE id=?", (json.dumps(item), identity)) + return store.script(identity, versions=True) + + @app.post("/api/discover") + def discover(): + result = catalog.discover(store) + result["tests"] = len(test_catalog.tests(settings, store.scripts())) + return result + + @app.post("/api/scripts/{identity}/runs") + def run_script(identity: str, body: RunRequest, request: Request): + script = test_catalog.describe(settings, store.script(identity), store.scripts()) + if not script["is_test"]: + raise ValueError("此文件属于内部源码,不能作为独立测试运行") + if not script["enabled"] or script["status"] != "ready": + raise ValueError(script["reason"] or "请先配置并启用脚本") + if native_plans.profile_for(settings, script): + return native_io.request(settings, {"op": "queue", "script_id": identity, + "request_id": str(body.request_id), "plan_token": body.plan_token, + "impact_acknowledged": body.impact_acknowledged, "source_ip": request.client.host}) + source = catalog.script_path(settings, script).read_bytes() + if hashlib.sha256(source).hexdigest() != script["sha256"]: + raise ValueError("原脚本已变动,请刷新目录并重新确认配置") + if catalog.SECRET.search(catalog.clean_source(source)): + raise ValueError("源文件包含疑似凭据,暂不执行") + return store.queue(script, str(body.request_id), request.client.host) + + @app.post("/api/scripts/{identity}/preflight") + def preflight(identity: str): + item = store.script(identity) + if not native_plans.profile_for(settings, item): + raise ValueError("此项目使用隔离容器,无原环境启动方案") + return native_io.request(settings, {"op": "preflight", "script_id": identity}) + + @app.post("/api/runs/{identity}/input") + def native_input(identity: str, body: NativeInput): + return native_io.request(settings, {"op": "input", "run_id": identity, "text": body.text}) + + @app.get("/api/runs") + def runs(script_id: str | None = None, limit: int = Query(default=100, ge=1, le=500)): + return {"items": store.runs(script_id, limit)} + + @app.get("/api/runs/{identity}") + def run(identity: str): + return store.run(identity) + + @app.post("/api/runs/{identity}/cancel") + def cancel(identity: str): + return store.cancel(identity) + + def run_path(identity, filename): + store.run(identity) + root = catalog.safe_path(settings.runs / identity, settings.runs, must_exist=False) + return catalog.safe_path(root / filename, root, must_exist=False) + + @app.get("/api/runs/{identity}/log") + def log(identity: str, offset: int = Query(default=0, ge=0)): + item = store.run(identity) + path = run_path(identity, "console.log") + data = b"" + at_eof = True + if path.exists(): + with path.open("rb") as handle: + handle.seek(min(offset, path.stat().st_size)) + offset = handle.tell() + data = handle.read(65536) + at_eof = offset + len(data) >= path.stat().st_size + # Keep any incomplete UTF-8 tail for the next poll. + import codecs + decoder = codecs.getincrementaldecoder("utf-8")("replace") + text = decoder.decode(data, final=item["state"] in TERMINAL and at_eof) + pending = len(decoder.getstate()[0]) + else: + text, pending = "", 0 + return {"text": text, "next_offset": offset + len(data) - pending, + "done": item["state"] in TERMINAL and len(data) < 65536, + "truncated": path.exists() and path.stat().st_size >= settings.max_log} + + @app.get("/api/runs/{identity}/log/download") + def log_download(identity: str): + path = run_path(identity, "console.log") + if not path.is_file(): + raise KeyError("日志尚未生成") + return FileResponse(path, filename=f"{identity}.log", media_type="text/plain") + + def artifact_path(identity, artifact_id): + item = store.run(identity) + artifact = next((a for a in item.get("artifacts", []) if a["id"] == artifact_id), None) + if not artifact: + raise KeyError("结果文件不存在") + return run_path(identity, "artifacts/" + artifact["id"]), artifact + + @app.get("/api/runs/{identity}/artifacts/{artifact_id}/preview") + def artifact_preview(identity: str, artifact_id: str): + path, artifact = artifact_path(identity, artifact_id) + if artifact["kind"] not in {"text", "json"}: + raise ValueError("此文件只支持下载") + with path.open("rb") as handle: + data = handle.read(262145) + return {"text": data[:262144].decode("utf-8", errors="replace"), "truncated": len(data) > 262144} + + @app.get("/api/runs/{identity}/artifacts/{artifact_id}") + def artifact_download(identity: str, artifact_id: str): + path, artifact = artifact_path(identity, artifact_id) + return FileResponse(path, filename=artifact["name"], media_type="application/octet-stream") + + @app.get("/") + def index(): + return FileResponse(static / "index.html") + + @app.get("/project/xas-test-library/{legacy_path:path}") + def old_bookmark(legacy_path: str): + return RedirectResponse("/#/scripts", status_code=307) + + if static.exists(): + app.mount("/static", StaticFiles(directory=static), name="static") + return app diff --git a/console/artifacts.py b/console/artifacts.py new file mode 100644 index 0000000..74c8091 --- /dev/null +++ b/console/artifacts.py @@ -0,0 +1,144 @@ +"""Bounded log following and result snapshots; never modify original outputs.""" +import hashlib +import codecs +import os +from pathlib import Path +import time +from .catalog import safe_path, SECRET + + +def files_under(paths, limit=256): + items = [] + deadline = time.monotonic() + 2 + for root in map(Path, paths): + if root.is_symlink(): + continue + if root.is_file(): + items.append(root) + elif root.is_dir(): + for directory, dirs, files in os.walk(root, followlinks=False): + dirs[:] = sorted(d for d in dirs if not (Path(directory) / d).is_symlink()) + for name in sorted(files): + path = Path(directory) / name + if not path.is_symlink() and path.is_file(): + items.append(path) + if len(items) >= limit or time.monotonic() > deadline: + return items[:limit] + return items[:limit] + + +def signature(path): + try: + stat = path.stat() + return (stat.st_ino, stat.st_size, stat.st_mtime_ns) + except OSError: + return None + + +class Collector: + def __init__(self, settings, directory, output_paths, redactor=None, stream_filter_factory=None, file_limit=256): + self.settings, self.directory = settings, directory + self.file_limit = file_limit + self.output_provider = output_paths if callable(output_paths) else lambda: [Path(p) for p in output_paths] + self.redactor = redactor + self.stream_filter_factory = stream_filter_factory + self.log_filters = {} + self.before = {str(p): signature(p) for p in files_under(self.outputs, self.file_limit)} + self.positions = {str(p): p.stat().st_size for p in files_under(self.outputs, self.file_limit) if p.suffix == ".log"} + self.notices = [] + self.decoders = {} + self.last_file = None + + @property + def outputs(self): + return self.output_provider() + + def follow(self, emit): + total = 0 + for path in files_under([*self.outputs, self.directory / "work"], self.file_limit): + if path.suffix != ".log": + continue + try: + start = self.positions.get(str(path), 0) + if path.stat().st_size < start: + start = 0 + self.decoders.pop(str(path), None) + with path.open("rb") as handle: + handle.seek(start) + data = handle.read(32768) + self.positions[str(path)] = handle.tell() + if data: + total += len(data) + if self.stream_filter_factory: + log_filter = self.log_filters.setdefault(str(path), self.stream_filter_factory()) + data = log_filter.feed(data) + decoder = self.decoders.setdefault(str(path), codecs.getincrementaldecoder("utf-8")("replace")) + text = decoder.decode(data) + prefix = f"\n[文件日志 {path.name}]\n" if self.last_file != str(path) else "" + emit((prefix + text).encode("utf-8")) + self.last_file = str(path) + except OSError: + self.notices.append(f"日志读取失败:{path.name}") + return total + + def finish_follow(self, emit): + total = 0 + while total < self.settings.max_log: + count = self.follow(emit) + if count == 0: + break + total += count + for name, decoder in self.decoders.items(): + extra = self.log_filters[name].finish() if name in self.log_filters else b"" + tail = decoder.decode(extra, final=True) + if tail: + emit(tail.encode("utf-8")) + self.decoders.clear() + self.log_filters.clear() + + def external_growth(self): + growth = 0 + for path in files_under(self.outputs, limit=1025): + stat = signature(path) + before = self.before.get(str(path)) + if stat: + growth += max(0, stat[1] - (before[1] if before and before[0] == stat[0] else 0)) + return growth + + def collect(self): + destination = self.directory / "artifacts" + destination.mkdir(exist_ok=True) + local = files_under([self.directory / "work"]) + changed = [p for p in files_under(self.outputs, self.file_limit) if signature(p) != self.before.get(str(p))] + sources = [self.directory / "source" / "script.txt", *local, *changed] + items, total = [], 0 + for path in sources[:self.settings.max_run_files - 1]: + try: + if not path.is_file() or path.is_symlink(): + continue + if path.stat().st_size > self.settings.max_artifact: + retention = "临时文件将在收尾时释放" if path in local else "原位置文件保留" + self.notices.append(f"未归档 {path.name}:超过单文件 32 MiB 上限,{retention}") + continue + data = path.read_bytes() + if len(data) > self.settings.max_artifact or total + len(data) > 64 * 1024 * 1024: + self.notices.append("结果超过归档容量限制:原目录文件保留,未归档的临时结果将在收尾时释放") + continue + suffix = path.suffix.lower() + kind = "json" if suffix == ".json" else ("text" if suffix in {".log", ".txt", ".py", ".sh", ".csv", ".md", ".jsonl"} else "file") + if kind != "file": + text = data.decode("utf-8", errors="replace") + data = SECRET.sub("[已隐藏疑似凭据]", text).encode("utf-8") + if self.redactor: + data = self.redactor(data) + identity = hashlib.sha256(str(path).encode()).hexdigest()[:24] + target = destination / identity + with target.open("xb") as handle: + handle.write(data) + items.append({"id": identity, "name": path.name, "size": len(data), "kind": kind}) + total += len(data) + except OSError: + self.notices.append(f"未能归档:{path.name}") + if len(sources) > self.settings.max_run_files - 1: + self.notices.append("结果超过文件数上限:保留 63 个文件及运行摘要,未归档临时结果将释放") + return items diff --git a/console/catalog.py b/console/catalog.py new file mode 100644 index 0000000..09dcb11 --- /dev/null +++ b/console/catalog.py @@ -0,0 +1,193 @@ +"""Source registration and filesystem boundaries shared by API and worker.""" +import hashlib +import os +from pathlib import Path +import re +import uuid +from .store import now + +SECRET = re.compile(r"(?im)(?:[\w-]*(?:api[_-]?key|password|token|secret)[\w-]*)\s*[:=]\s*['\"][^'\"\r\n]{8,}['\"]|-----BEGIN [A-Z ]*PRIVATE KEY-----|Bearer\s+[A-Za-z0-9._-]{16,}") +SERVICE = re.compile(r"(?im)\b(?:docker|podman|systemctl|sudo|pkill|killall|shutdown|reboot|nvidia-smi)\b|\brm\s+-[\w]*r|\bkill\s+-") +SMOKE_HASH = "be858e914d87454240791da773578cb3f80f380e740f919546db347b7715a13d" + + +def clean_source(content): + try: + text = content.decode("utf-8-sig") + except UnicodeDecodeError as exc: + raise ValueError("脚本必须使用 UTF-8 编码") from exc + if "\x00" in text: + raise ValueError("不支持二进制脚本") + return text + + +def safe_path(path, root, must_exist=True): + candidate, base = Path(path), Path(root) + if not candidate.is_absolute(): + raise ValueError("路径必须为绝对路径") + # Never follow a symlink, including symlink ancestors under the allowed root. + resolved_base = base.resolve() + resolved = candidate.resolve() + if not resolved.is_relative_to(resolved_base): + raise ValueError("路径超出允许目录") + for component in [candidate, *candidate.parents]: + if component.is_symlink(): + raise ValueError("不允许符号链接") + if component == base: + break + if must_exist and not candidate.exists(): + raise ValueError("文件或目录不存在") + return candidate + + +def source_for(settings, group): + for source in settings.sources: + if source["id"] == group: + return Path(source["path"]) + raise ValueError("未知来源目录") + + +def validate_config(settings, item, config): + from . import native_plans + if native_plans.profile_for(settings, item): + return native_plans.validate_config(settings, item, config) + if config.get("native_options"): + raise ValueError("此项目不支持原环境参数") + defaults = {"args": [], "cwd": "", "output_paths": [], "timeout_seconds": 300, "profile": "standard"} + config = {**defaults, **config} + if config["profile"] not in {"standard", "legacy_smoke"}: + raise ValueError("未知执行环境") + if not isinstance(config["args"], list) or len(config["args"]) > 40 or any( + not isinstance(a, str) or len(a) > 2048 or "\x00" in a for a in config["args"]): + raise ValueError("参数需为最多 40 项的字符串数组") + if SECRET.search("\n".join(config["args"])): + raise ValueError("参数不能包含明文凭据") + if config["profile"] == "legacy_smoke" and config["args"]: + raise ValueError("轻量模型兼容环境使用已核对的固定参数,请新建脚本以配置其他测试") + if type(config["timeout_seconds"]) is not int or not 5 <= config["timeout_seconds"] <= 7200: + raise ValueError("运行时限须为 5 至 7200 秒") + if not isinstance(config["output_paths"], list) or len(config["output_paths"]) > 8: + raise ValueError("最多登记 8 个日志或结果路径") + if not isinstance(config["cwd"], str): + raise ValueError("工作目录格式错误") + if item["kind"] == "managed": + if config["cwd"] or config["output_paths"]: + raise ValueError("上传脚本使用本次独立运行目录;通过 XAS_RUN_DIR 写入结果") + else: + source = source_for(settings, item["source_group"]) + base = source.parent if source.name == "scripts" else source + cwd = safe_path(config["cwd"] or str(base), base) + if not cwd.is_dir(): + raise ValueError("工作目录不存在") + config["cwd"] = str(cwd) + for raw in config["output_paths"]: + if not isinstance(raw, str): + raise ValueError("输出路径格式错误") + # Only logs/results directories in that script family may become writable. + if not any(Path(raw).resolve().is_relative_to((base / name).resolve()) for name in ("logs", "results")): + raise ValueError("原目录输出只允许该来源的 logs/ 或 results/ 下路径") + safe_path(raw, base) + return config + + +def script_path(settings, item): + root = settings.library if item["kind"] == "managed" else source_for(settings, item["source_group"]) + path = safe_path(item["source_path"], root) + if not path.is_file() or path.suffix not in {".py", ".sh"} or path.stat().st_size > settings.max_upload: + raise ValueError("脚本缺失、类型不支持或大于 1 MiB") + return path + + +def read_code(settings, item): + data = script_path(settings, item).read_bytes() + text = clean_source(data) + redacted = bool(SECRET.search(text)) + if redacted: + text = SECRET.sub("[已隐藏疑似凭据]", text) + return {"code": text, "filename": Path(item["source_path"]).name, + "sha256": hashlib.sha256(data).hexdigest(), "redacted": redacted, "truncated": False} + + +def upload(store, data, filename, name="", description="", identity=None): + if not filename or Path(filename).name != filename or "/" in filename or "\\" in filename or not re.fullmatch(r"[\w.\- ]{1,120}\.(py|sh)", filename): + raise ValueError("只支持文件名安全的 .py 或 .sh 脚本") + if len(data) > store.settings.max_upload: + raise ValueError("脚本不能超过 1 MiB") + if not data: + raise ValueError("脚本不能为空") + text = clean_source(data) + if SECRET.search(text): + raise ValueError("脚本包含疑似明文凭据,请移除后上传") + if identity: + item = store.script(identity) + if item["kind"] != "managed": + raise ValueError("原位置脚本不支持上传覆盖,请新建脚本") + if item["config"]["profile"] == "legacy_smoke" and hashlib.sha256(data).hexdigest() != SMOKE_HASH: + raise ValueError("此兼容环境绑定已核对的原脚本;修改代码请新建脚本并另行配置认证") + if (Path(filename).suffix == ".py") != (item["language"] == "python"): + raise ValueError("新版本的语言必须与原脚本一致") + else: + item = {"id": uuid.uuid4().hex, "name": (name or filename)[:120], "description": description[:4000], + "kind": "managed", "language": "python" if filename.endswith(".py") else "shell", + "source_group": "uploaded", "source_path": "", "status": "ready", "enabled": True, + "reason": "", "version": 0, "sha256": "", "updated_at": now(), "synthetic": False, + "config": {"args": [], "cwd": "", "output_paths": [], "timeout_seconds": 300, "profile": "standard"}} + return store.upload_version(item, data, filename) + + +def discover(store): + existing = {s["id"]: s for s in store.scripts() if s["kind"] == "external"} + found, registered = set(), 0 + for source in store.settings.sources: + root = Path(source["path"]) + if not root.is_dir(): + continue + count = 0 + for directory, dirs, files in os.walk(root, followlinks=False): + dirs[:] = [d for d in dirs if d not in {".git", "__pycache__", ".venv", "node_modules", "logs", "results"} + and not (Path(directory) / d).is_symlink()] + for filename in sorted(files): + path = Path(directory) / filename + if path.suffix not in {".py", ".sh"} or path.is_symlink(): + continue + count += 1 + if count > 2000: + raise ValueError("来源脚本超过扫描上限 2000,请收窄来源目录") + identity = hashlib.sha256(str(path).encode()).hexdigest()[:32] + found.add(identity) + before = existing.get(identity) + try: + if path.stat().st_size > store.settings.max_upload: + raise ValueError("大于 1 MiB,仅登记") + raw = path.read_bytes() + code = clean_source(raw) + digest = hashlib.sha256(raw).hexdigest() + blocked = bool(SERVICE.search(code) or SECRET.search(code)) + reason = "涉及服务、设备管理或疑似凭据,需管理员审查后接入" if blocked else "请确认参数、依赖和输出路径后启用" + except (OSError, ValueError): + digest, blocked, reason = "", True, "文件不可读取或格式不支持" + item = before or {"id": identity, "name": filename, "description": "", + "kind": "external", "language": "python" if path.suffix == ".py" else "shell", + "source_group": source["id"], "source_path": str(path), "version": 1, "enabled": False, + "config": {"args": [], "cwd": str(root.parent if root.name == "scripts" else root), + "output_paths": [], "timeout_seconds": 300, "profile": "standard"}} + from .native_plans import profile_for + native = profile_for(store.settings, item) + if native: + blocked = not native.get("available", True) + reason = native.get("unavailable_reason", "原环境方案已登记,请确认参数后启用") + if before and before["sha256"] != digest: + item["version"] += 1 + item["enabled"] = False + reason = "源文件已变动,请重新确认配置并启用" + if blocked: + item["enabled"] = False + item.update(sha256=digest, blocked=blocked, reason="" if item["enabled"] else reason, + status="ready" if item["enabled"] else "needs_configuration", updated_at=now()) + store.save_script(item) + registered += int(before is None) + for identity, item in existing.items(): + if identity not in found: + item.update(status="missing", enabled=False, reason="原脚本当前不存在或来源不可访问", updated_at=now()) + store.save_script(item) + return {"registered": registered, "missing": len(set(existing) - found), "total": len(found)} diff --git a/console/job_main.py b/console/job_main.py new file mode 100644 index 0000000..73056bf --- /dev/null +++ b/console/job_main.py @@ -0,0 +1,115 @@ +"""In-container bootstrap. Input contains optional credentials; never persist it.""" +from http.server import BaseHTTPRequestHandler, HTTPServer +import json +import os +from pathlib import Path +import subprocess +import sys +import threading +import time +import urllib.error +import urllib.request + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, fp, code, msg, headers, newurl): + return None + + +class BudgetRelay(HTTPServer): + def __init__(self, address, upstream, key): + super().__init__(address, RelayHandler) + self.upstream, self.key = upstream, key + self.gets, self.posts = 0, 0 + self.records = [] + + +class RelayHandler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_GET(self): + if self.path != "/v1/models" or self.server.gets >= 1: + self.send_error(403, "Request outside run budget") + return + self.server.gets += 1 + self.forward(None, 10) + + def do_POST(self): + if self.path != "/v1/chat/completions" or self.server.posts >= 3: + self.send_error(403, "Request outside run budget") + return + try: + length = int(self.headers.get("Content-Length", "0")) + if not 0 < length <= 8192: + raise ValueError() + data = self.rfile.read(length) + body = json.loads(data) + if body.get("model") != "deepseek-v4.1-flash" or body.get("max_tokens") != 512 or body.get("stream"): + raise ValueError() + except (ValueError, json.JSONDecodeError): + self.send_error(400, "Unexpected smoke request") + return + self.server.posts += 1 # Count before I/O: timeouts never replenish the budget. + self.forward(data, 600) + + def forward(self, data, timeout): + started = time.monotonic() + record = {"path": self.path, "status": None} + try: + req = urllib.request.Request(self.server.upstream + self.path, data=data, headers={ + "Content-Type": "application/json", "Authorization": "Bearer " + self.server.key}) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) + with opener.open(req, timeout=timeout) as response: + status, payload = response.status, response.read(2 * 1024 * 1024 + 1) + if len(payload) > 2 * 1024 * 1024: + raise ValueError("Response exceeded limit") + record["status"] = status + decoded = json.loads(payload) + if self.path.endswith("completions"): + record["response"] = decoded + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + except urllib.error.HTTPError as exc: + record["status"] = exc.code + self.send_error(exc.code, "Upstream request failed") + except (OSError, ValueError) as exc: + record["error"] = type(exc).__name__ + try: + self.send_error(502, "Upstream outcome uncertain; no retry") + except OSError: + pass + finally: + record["seconds"] = round(time.monotonic() - started, 3) + self.server.records.append(record) + print(json.dumps({"event": "model_request", **{k: v for k, v in record.items() if k != "response"}}), flush=True) + + +def main(): + payload = json.loads(sys.stdin.buffer.readline(16384)) + relay = None + if payload.get("profile") == "legacy_smoke": + relay = BudgetRelay(("127.0.0.1", 18100), payload.pop("upstream"), payload.pop("key")) + threading.Thread(target=relay.serve_forever, daemon=True).start() + command = (["python3", "-u"] if payload["language"] == "python" else ["/bin/bash"]) + [payload["script"]] + payload["args"] + print("[控制台] 测试进程已启动,输出将持续显示。", flush=True) + status = subprocess.call(command, stdin=subprocess.DEVNULL) + if relay: + report = {"synthetic": False, "scope": "接口冒烟检查,非模型质量评测", "get_attempts": relay.gets, + "completion_attempts": relay.posts, "records": relay.records, "script_exit_code": status} + valid = len(relay.records) == 4 and all(r.get("status") == 200 for r in relay.records) + report["requests_completed"] = valid + report["message"] = "请求均返回成功;请结合实际回答与 finish_reason 判断内容" if valid else "存在失败或未完成请求;未自动重试" + Path(os.environ["XAS_RUN_DIR"], "smoke-response.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + if not valid: + status = status or 1 + relay.shutdown() + print(f"[控制台] 测试进程结束,退出码 {status}。", flush=True) + return status + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/console/native_executor.py b/console/native_executor.py new file mode 100644 index 0000000..068001f --- /dev/null +++ b/console/native_executor.py @@ -0,0 +1,160 @@ +"""Manage only this console's transient units; reattach after worker restarts.""" +import hashlib +import json +from pathlib import Path +import re +import subprocess +import time +from .native_plans import build_plan, public_plan +from .store import now + + +def unit_name(identity): + if not re.fullmatch(r"[a-f0-9]{32}", identity):raise ValueError("运行编号不合法") + return "adamwen-native-" + identity + ".service" + + +def inspect(identity): + unit = unit_name(identity) + result = subprocess.run(["/usr/bin/systemctl", "show", unit, "--property=Id,LoadState,ActiveState,SubState,Description,Result,ExecMainStatus,MainPID,TasksCurrent"], + capture_output=True, timeout=8, text=True) + values = dict(line.split("=", 1) for line in result.stdout.splitlines() if "=" in line) + if values.get("LoadState") == "not-found":return None + if result.returncode or not values.get("LoadState"): + raise OSError("systemd 状态查询失败,任务状态未知") + if values.get("Id") != unit or values.get("Description") != "XAS native " + identity: + raise ValueError("原环境服务归属核验失败") + return values + + +def stop_unit(identity): + if inspect(identity): + subprocess.run(["/usr/bin/systemctl", "--no-block", "stop", unit_name(identity)], check=True, capture_output=True, timeout=8) + + +class NativeExecutor: + def __init__(self, worker): + self.worker, self.store, self.settings = worker, worker.store, worker.settings + + def execute(self, run): + if self.store.run(run["id"])["cancel_requested"]: + self.store.finish(run["id"], "cancelled", message="启动前已取消,原脚本未执行") + return + directory = self.settings.runs / run["id"] + directory.mkdir(mode=0o750, exist_ok=False) + try: + script = {**run, "id": run["script_id"]} + plan = build_plan(self.settings, script, run_id=run["id"]) + if plan["plan_token"] != run["execution"]["plan_token"] or not plan["ready"]: + raise ValueError("执行前原入口或环境已变化,请重新预检;未启动原测试") + if plan["impact"]["requires_confirmation"] and not run["execution"].get("impact_acknowledged"): + raise ValueError("缺少本次运行影响确认") + if inspect(run["id"]):raise ValueError("本次原环境服务已存在,拒绝重复启动") + run = self.store.update_execution(run["id"], {**public_plan(plan), "unit": unit_name(run["id"])}) + project = Path(self.worker.config["project"]) + log = directory / "console.log" + log.touch(mode=0o600) + with (directory / "native-permit").open("x") as permit: + permit.write(run["execution"]["plan_token"]) + command = ["/usr/bin/systemd-run", "--unit=" + unit_name(run["id"]), "--description=XAS native " + run["id"], + "--service-type=exec", "--property=RemainAfterExit=yes", "--property=KillMode=control-group", + "--property=TimeoutStopSec=120", "--property=RuntimeMaxSec=" + str(plan["timeout_seconds"]), + "--property=WorkingDirectory=" + str(project), "--property=User=root", "--property=UMask=0022", + "--property=StandardOutput=append:" + str(log), "--property=StandardError=append:" + str(log), + "--setenv=XAS_CONSOLE_ROOT=" + str(self.settings.root), "--setenv=PYTHONDONTWRITEBYTECODE=1", + "--", "/usr/bin/python3", "-m", "console.native_job", run["id"]] + if self.store.run(run["id"])["cancel_requested"]: + (directory / "native-permit").unlink(missing_ok=True) + self.store.finish(run["id"], "cancelled", message="启动前已取消,原脚本未执行") + return + result = subprocess.run(command, capture_output=True, timeout=15, text=True) + if result.returncode: + raise ValueError("systemd 未接受原环境任务,原脚本未启动") + except Exception as exc: + message = str(exc) if isinstance(exc, ValueError) else "原环境启动失败,未自动重试" + # A timeout can leave a submitted unit; inspect it instead of submitting again. + if inspect(run["id"]): + return self.monitor(run) + (directory / "native-permit").unlink(missing_ok=True) + if (directory / "native-started").exists(): + self.worker.stopping = True + self.store.finish(run["id"], "interrupted", message="原入口可能已启动但服务状态未知,执行器停止接单;请核对后再操作") + return + (directory / "console.log").write_text(message + "\n", encoding="utf-8") + self.store.finish(run["id"], "failed", message=message) + return + self.monitor(run) + + def monitor(self, run): + requested = False + failures = 0 + while not self.worker.stopping: + self.store.heartbeat() + try: + state = inspect(run["id"]) + failures = 0 + except (OSError, ValueError, subprocess.TimeoutExpired): + failures += 1 + self.store.running_message(run["id"], "无法确认原环境服务状态,保留运行占位,不启动下一项") + if failures >= 10:self.worker.stopping = True + time.sleep(1) + continue + if state is None or state.get("SubState") in {"exited", "dead", "failed"}: + return self.finish(run, state) + if self.store.run(run["id"])["cancel_requested"] and not requested: + stop_unit(run["id"]) + requested = True + time.sleep(.5) + # The independent unit keeps running. The next worker attaches without replaying it. + + def finish(self, run, unit): + directory = self.settings.runs / run["id"] + directory.mkdir(mode=0o750, exist_ok=True) + outcome_file = directory / "unit-outcome.json" + outcome = unit + if outcome_file.exists():outcome = json.loads(outcome_file.read_text()) + elif unit:outcome_file.write_text(json.dumps(unit)) + result_file = directory / "native-result.json" + result = json.loads(result_file.read_text()) if result_file.is_file() else {} + current = self.store.run(run["id"]) + code = result.get("exit_code") + if current["cancel_requested"]:state, message = "cancelled", "已取消本次宿主入口;请核对原脚本启动的 Docker/tmux 任务和模型恢复情况" + elif outcome and outcome.get("Result") == "timeout":state, message = "timed_out", "原环境运行超过时限;原服务是否恢复需结合收尾记录核对" + elif not result:state, message = "interrupted", "原环境任务结束但完整结果未取得;未自动重跑,请查看日志和原服务状态" + elif result.get("stopped"):state, message = "interrupted", "原环境入口收到外部停止信号,服务恢复情况未确认" + elif code == 0:state, message = "succeeded", "原入口正常退出;业务结论和共享资源状态请查看报告" + else:state, message = "failed", f"原入口退出码 {code},请查看日志" + sessions = result.get("runtime_changes", {}).get("new_tmux_sessions", []) + if sessions and state == "succeeded": + state, message = "interrupted", "原入口已退出,但检测到新增 tmux 会话;后台任务完成状态未确认,请核对原工具" + artifacts = result.get("artifacts", []) + summary = {"run_id": run["id"], "state": state, "exit_code": code, "message": message, + "finished_at": now(), "source_sha256": run["sha256"], "execution": run["execution"], + "runtime_changes": result.get("runtime_changes"), "archive_notices": result.get("archive_notices", []), + "log_truncated": result.get("log_truncated", False)} + directory.mkdir(mode=0o750, exist_ok=True) + (directory / "artifacts").mkdir(exist_ok=True) + data = json.dumps(summary, ensure_ascii=False, indent=2).encode() + identity = hashlib.sha256((str(directory) + "/summary.json").encode()).hexdigest()[:24] + (directory / "artifacts" / identity).write_bytes(data) + artifacts.append({"id": identity, "name": "summary.json", "kind": "json", "size": len(data)}) + if unit: + if not self.wait_stopped(run):return + subprocess.run(["/usr/bin/systemctl", "reset-failed", unit_name(run["id"])], capture_output=True, timeout=8) + self.store.finish(run["id"], state, message=message, exit_code=code, artifacts=artifacts) + + def wait_stopped(self, run): + try: + stop_unit(run["id"]) + deadline = time.monotonic() + 130 + while time.monotonic() < deadline and not self.worker.stopping: + self.store.heartbeat() + current = inspect(run["id"]) + if current is None:return True + if current.get("ActiveState") in {"inactive", "failed"} and current.get("MainPID", "0") == "0" and current.get("TasksCurrent", "0") in {"0", "[not set]"}: + return True + time.sleep(.5) + except (OSError, ValueError, subprocess.TimeoutExpired, subprocess.CalledProcessError):pass + self.store.running_message(run["id"], "本次进程收尾未确认,保留运行占位,执行器停止接单") + self.worker.stopping = True + return False diff --git a/console/native_files.py b/console/native_files.py new file mode 100644 index 0000000..6d9600c --- /dev/null +++ b/console/native_files.py @@ -0,0 +1,75 @@ +"""Read-only output discovery, history preservation and small runtime snapshots.""" +import glob +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +from .artifacts import files_under + + +def outputs(patterns): + paths = [] + for pattern in patterns: + expanded = glob.iglob(pattern) if glob.has_magic(pattern) else [pattern] + for name in expanded: + path = Path(name) + if path.is_symlink(): + continue + if path.is_file():paths.append(path) + elif path.is_dir():paths.extend(files_under([path], limit=4097)) + if len(paths) > 4096: + raise ValueError("原输出文件超过 4096 项,请收窄登记输出范围") + return sorted(set(paths)) + + +def preserve(paths, directory, maximum=64 * 1024**3): + sizes = [path.stat().st_size for path in paths] + total = sum(sizes) + if total > maximum or shutil.disk_usage(directory).free < total + 10 * 1024**3: + raise ValueError("保护原有同名结果所需空间不足或超过 64 GiB,本次未启动") + destination = directory / "previous-outputs" + destination.mkdir(mode=0o700) + manifest = [] + for path in paths: + identity = hashlib.sha256(str(path).encode()).hexdigest() + target = destination / identity + before = path.stat() + with path.open("rb") as source, target.open("xb") as output: + shutil.copyfileobj(source, output, length=1024 * 1024) + after = path.stat() + if (before.st_size, before.st_mtime_ns) != (after.st_size, after.st_mtime_ns): + raise ValueError("原输出仍被其他任务写入,请等待后再试") + manifest.append({"path": str(path), "backup": identity, "size": before.st_size}) + (destination / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + return len(manifest) + + +def runtime_snapshot(): + result = {"containers": [], "tmux_sessions": [], "errors": []} + try: + raw = subprocess.run(["/usr/bin/docker", "ps", "-a", "--format", "{{json .}}"], capture_output=True, timeout=10, text=True) + if raw.returncode == 0: + for line in raw.stdout.splitlines(): + value = json.loads(line) + result["containers"].append({key: value.get(key) for key in ("ID", "Names", "Image", "State", "Status")}) + else:result["errors"].append("容器状态读取失败") + except (OSError, ValueError, subprocess.TimeoutExpired):result["errors"].append("容器状态不可用") + try: + raw = subprocess.run(["/usr/bin/tmux", "list-sessions", "-F", "#{session_id}\t#{session_name}"], capture_output=True, timeout=5, text=True) + if raw.returncode == 0: + result["tmux_sessions"] = [dict(zip(("id", "name"), line.split("\t", 1))) for line in raw.stdout.splitlines() if "\t" in line] + except (OSError, subprocess.TimeoutExpired):result["errors"].append("tmux 状态不可用") + return result + + +def differences(before, after): + old = {v["ID"]: v for v in before["containers"]} + new = {v["ID"]: v for v in after["containers"]} + sessions = {v["id"] for v in before["tmux_sessions"]} + return {"created_containers": [v for k, v in new.items() if k not in old], + "removed_containers": [v for k, v in old.items() if k not in new], + "changed_containers": [v for k, v in new.items() if k in old and v["State"] != old[k]["State"]], + "new_tmux_sessions": [v for v in after["tmux_sessions"] if v["id"] not in sessions], + "note": "共享宿主前后快照差异不等于资源归属证明;不会自动删除这些容器或会话。"} diff --git a/console/native_io.py b/console/native_io.py new file mode 100644 index 0000000..46675b2 --- /dev/null +++ b/console/native_io.py @@ -0,0 +1,122 @@ +"""Bounded IPC and streaming redaction for original-environment runs.""" +import json +import os +from pathlib import Path +import re +import shlex +import socket +import subprocess + +ANSI = re.compile(rb"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[()][0-2A-Z]|[@-_])") +SENSITIVE_NAME = re.compile(r"(?i)(?:api_?key|password|passwd|secret|access_token|auth_token|credential)") + + +def strip_terminal(data, state="text"): + output = bytearray() + for value in data: + if state == "text": + if value == 27:state = "escape" + else:output.append(value) + elif state == "escape": + state = "csi" if value == 91 else "osc" if value == 93 else "charset" if value in (40, 41) else "text" + elif state == "csi": + if 64 <= value <= 126:state = "text" + elif value == 27:state = "escape" + elif state == "osc": + if value == 7:state = "text" + elif value == 27:state = "osc_escape" + elif state == "osc_escape": + state = "text" if value in (92, 7) else "osc" + else:state = "text" + return bytes(output), state + + +class SecretFilter: + def __init__(self, secrets): + values = set() + for value in secrets: + values.update(v.encode() for v in [value, *value.splitlines()] if len(v) >= 4) + self.secrets = sorted(values, key=len, reverse=True) + self.keep = max([256, *[len(v) for v in self.secrets]]) + self.pending = b"" + self.terminal_state = "text" + + def clean(self, data): + data, _ = strip_terminal(data) + for secret in self.secrets: + data = data.replace(secret, b"[REDACTED]") + return data + + def feed(self, data): + plain, self.terminal_state = strip_terminal(data, self.terminal_state) + self.pending += plain + for secret in self.secrets:self.pending = self.pending.replace(secret, b"[REDACTED]") + # Emit prompts without requiring a newline, retaining only a possible secret prefix. + hold = 0 + for secret in self.secrets: + for size in range(min(len(secret) - 1, len(self.pending)), hold, -1): + if self.pending.endswith(secret[:size]): + hold = size + break + cut = len(self.pending) - hold + result, self.pending = self.pending[:cut], self.pending[cut:] + return result + + def finish(self): + partial = len(self.pending) >= 4 and any(s.startswith(self.pending) for s in self.secrets) + result = b"[REDACTED]" if partial else self.clean(self.pending) + self.pending = b"" + self.terminal_state = "text" + return result + + +def load_environment(plan): + import pwd + account = pwd.getpwnam(plan["user"]) + env = {"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "HOME": account.pw_dir, + "USER": account.pw_name, "LOGNAME": account.pw_name, "LANG": "C.UTF-8", "TERM": "xterm", + "PYTHONUNBUFFERED": "1", "PYTHONDONTWRITEBYTECODE": "1"} + secrets = [] + for path in plan.get("env_setup_files", []): + # Only server-owned recipe paths reach this command; output stays in private process memory. + command = ["/bin/bash", "-c", 'set -a; source "$1" >/dev/null; /usr/bin/env -0', "xas-env", path] + result = subprocess.run(command, env=env, cwd=plan["cwd"], capture_output=True, timeout=10) + if result.returncode: + raise ValueError("原环境配置加载失败") + env.update(dict(v.decode().split("=", 1) for v in result.stdout.split(b"\0") if b"=" in v)) + secrets.extend(v for k, v in env.items() if SENSITIVE_NAME.search(k) and v) + for path in plan.get("credential_files", []): + for line in Path(path).read_text().splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + match = re.match(r"^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$", line.strip()) + if not match: + raise ValueError("原认证配置格式不支持") + parts = shlex.split(match[2], comments=True) + if len(parts) > 1: + raise ValueError("原认证配置格式不支持") + env[match[1]] = parts[0] if parts else "" + if SENSITIVE_NAME.search(match[1]) and env[match[1]]:secrets.append(env[match[1]]) + env.update(plan.get("environment", {})) + secrets.extend(v for k, v in env.items() if SENSITIVE_NAME.search(k) and v) + return env, secrets + + +def request(settings, payload): + if not hasattr(socket, "AF_UNIX"): + raise ValueError("原环境执行器仅在 Linux 服务器可用") + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: + connection.settimeout(15) + connection.connect(str(settings.native_socket)) + connection.sendall(json.dumps(payload).encode() + b"\n") + stream = connection.makefile("rb") + data = stream.readline(131073) + if len(data) > 131072: + raise ValueError("原环境执行器响应过大") + result = json.loads(data) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError("原环境执行器暂不可用,请稍后重试") from exc + if "error" in result: + raise ValueError(result["error"]) + return result diff --git a/console/native_job.py b/console/native_job.py new file mode 100644 index 0000000..137ceba --- /dev/null +++ b/console/native_job.py @@ -0,0 +1,176 @@ +"""Original-environment launcher, supervised by a per-run systemd service.""" +import errno +import fcntl +import hashlib +import json +import os +from pathlib import Path +import pty +import pwd +import selectors +import signal +import stat +import struct +import subprocess +import sys +import termios +import time +from .artifacts import Collector +from .native_files import differences, outputs, preserve, runtime_snapshot +from .native_io import SecretFilter, load_environment +from .native_plans import build_plan, profile_for +from .settings import Settings +from .store import Store, now + + +def acquire_terminal(): + # Popen has already created the session and duplicated the slave onto stdin. + # The original tmux/menu tools need a controlling terminal, not just isatty(). + fcntl.ioctl(0, termios.TIOCSCTTY, 0) + + +def execute(settings, run): + script = {**run, "id": run["script_id"]} + plan = build_plan(settings, script, run_id=run["id"]) + if plan["plan_token"] != run["execution"]["plan_token"] or not plan["ready"]: + raise ValueError("执行前环境或参数发生变化,未启动原测试") + if plan["impact"]["requires_confirmation"] and not run["execution"].get("impact_acknowledged"): + raise ValueError("缺少本次影响确认") + directory = settings.runs / run["id"] + (directory / "work").mkdir(mode=0o700, exist_ok=True) + env, secrets = load_environment(plan) + redactor = SecretFilter(secrets) + file_redactor = SecretFilter(secrets) + limit, written, truncated = settings.max_log, 0, False + def emit(data): + nonlocal written, truncated + remaining = limit - written + if len(data) > remaining:truncated = True + if remaining > 0: + sys.stdout.buffer.write(data[:remaining]);sys.stdout.buffer.flush() + written += min(len(data), remaining) + def message(text):emit(("[原环境] " + text + "\n").encode()) + collector = Collector(settings, directory, lambda: outputs(plan["output_paths"]), redactor=file_redactor.clean, + stream_filter_factory=lambda: SecretFilter(secrets), file_limit=4096) + profile = profile_for(settings, script) + if profile.get("preserve_existing_outputs"): + message("正在备份原有同名结果,原测试尚未开始。") + count = preserve(collector.outputs, directory) + message(f"已保护 {count} 个原有结果文件。") + before = runtime_snapshot() + (directory / "runtime-before.json").write_text(json.dumps(before, ensure_ascii=False), encoding="utf-8") + stopped = False + def stop(signum, frame): + nonlocal stopped + stopped = True # systemd sends the same signal to the full cgroup and enforces the grace period. + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + fifo = settings.native_socket.parent / ("input-" + run["id"]) + master = slave = input_fd = None + selector = selectors.DefaultSelector() + process = None + code = 1 + try: + account = pwd.getpwnam(plan["user"]) + options = {"cwd": plan["cwd"], "env": env, "start_new_session": True, + "user": account.pw_uid, "group": account.pw_gid, "extra_groups": []} + if stopped or Store(settings).run(run["id"])["cancel_requested"]: + message("已在创建原入口前取消,原测试未启动。") + return 1 + if plan["interactive"]: + master, slave = pty.openpty() + attributes = termios.tcgetattr(slave) + attributes[3] &= ~(termios.ECHO | termios.ECHONL) + termios.tcsetattr(slave, termios.TCSANOW, attributes) + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 36, 120, 0, 0)) + process = subprocess.Popen(plan["argv"], stdin=slave, stdout=slave, stderr=slave, + preexec_fn=acquire_terminal, **options) + os.close(slave);slave = None + os.mkfifo(fifo, 0o600) + input_fd = os.open(fifo, os.O_RDWR | os.O_NONBLOCK) + selector.register(input_fd, selectors.EVENT_READ, "input") + output_fd = master + else: + process = subprocess.Popen(plan["argv"], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, **options) + output_fd = process.stdout.fileno() + os.set_blocking(output_fd, False) + selector.register(output_fd, selectors.EVENT_READ, "output") + message("已在原目录启动登记入口。取消仅处理本次宿主进程,Docker/tmux 或远端推理需结合收尾记录核对。") + last_follow = time.monotonic() + while selector.get_map(): + for key, _ in selector.select(.3): + try:data = os.read(key.fd, 4096) + except OSError as exc: + if exc.errno in (errno.EIO, errno.EBADF):data = b"" + elif exc.errno == errno.EAGAIN:continue + else:raise + if key.data == "input": + if data and process.poll() is None: + os.write(master, data) + elif data:emit(redactor.feed(data)) + else:selector.unregister(key.fd) + if time.monotonic() - last_follow > 1: + collector.follow(lambda data: emit(file_redactor.clean(data))) + last_follow = time.monotonic() + if process.poll() is not None: + if input_fd is not None: + try:selector.unregister(input_fd) + except KeyError:pass + if not any(k.data == "output" for k in selector.get_map().values()):break + code = process.wait() + emit(redactor.finish()) + finally: + selector.close() + if process and process.poll() is None: + os.killpg(process.pid, signal.SIGTERM) + try:process.wait(timeout=5) + except subprocess.TimeoutExpired:os.killpg(process.pid, signal.SIGKILL);process.wait(timeout=5) + for descriptor in (input_fd, master, slave): + if descriptor is not None: + try:os.close(descriptor) + except OSError:pass + if process and process.stdout:process.stdout.close() + if fifo.exists() and stat.S_ISFIFO(fifo.lstat().st_mode):fifo.unlink() + after = runtime_snapshot() + changes = differences(before, after) + if plan["interactive"] and code == 0 and changes["new_tmux_sessions"] and not stopped: + sessions = {item["id"] for item in changes["new_tmux_sessions"]} + message("原菜单已退出,观察到新增 tmux 会话;继续跟踪其结束,不把入口退出当作后台测试完成。") + while sessions and not stopped: + collector.follow(lambda data: emit(file_redactor.clean(data))) + time.sleep(2) + current = runtime_snapshot() + sessions &= {item["id"] for item in current["tmux_sessions"]} + after = runtime_snapshot() + changes = differences(before, after) + (directory / "work/runtime-changes.json").write_text(json.dumps(changes, ensure_ascii=False, indent=2), encoding="utf-8") + collector.finish_follow(lambda data: emit(file_redactor.clean(data))) + artifacts = collector.collect() + result = {"exit_code": code, "stopped": stopped, "finished_at": now(), "artifacts": artifacts, + "log_truncated": truncated, "archive_notices": list(dict.fromkeys(collector.notices))[:32], + "runtime_changes": changes, "command_preview": plan["command_preview"]} + tmp = directory / "native-result.tmp" + tmp.write_text(json.dumps(result, ensure_ascii=False), encoding="utf-8") + os.replace(tmp, directory / "native-result.json") + message(f"原入口进程退出,退出码 {code}。共享资源前后变化已记录;不代表原模型已恢复。") + return code + + +def main(): + identity = sys.argv[1] + import re + if not re.fullmatch(r"[a-f0-9]{32}", identity):return 2 + settings = Settings.from_env() + run = Store(settings).run(identity) + if run["state"] != "running" or run.get("execution", {}).get("mode") != "native":return 2 + directory = settings.runs / identity + try:os.rename(directory / "native-permit", directory / "native-started") + except OSError:return 2 + try:return execute(settings, run) + except Exception as exc: + print(f"[原环境] 启动或收尾失败:{type(exc).__name__}。未自动重试。", flush=True) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/console/native_plans.py b/console/native_plans.py new file mode 100644 index 0000000..22fa0fe --- /dev/null +++ b/console/native_plans.py @@ -0,0 +1,193 @@ +"""Trusted original-environment recipes; user input never supplies executable code.""" +import hashlib +import json +import math +import os +from pathlib import Path +import re +import shlex +import socket +from urllib.parse import urlsplit +from .catalog import script_path, safe_path + +MAX_TIMEOUT = 72 * 3600 +PLACEHOLDER = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}") + + +def profile_for(settings, script): + if script["kind"] != "external": + return None + source = next((s for s in settings.sources if s["id"] == script["source_group"]), None) + if not source: + return None + expected = Path(script["source_path"]) + for profile in settings.native_catalog.get("profiles", []): + if profile["source_group"] == source["id"] and Path(source["path"]) / profile["entry"] == expected: + return profile + return None + + +def validate_options(profile, options): + if not isinstance(options, dict): + raise ValueError("原环境参数必须是对象") + fields = {f["key"]: f for f in profile.get("parameters", [])} + if set(options) - set(fields): + raise ValueError("包含启动方案未登记的参数") + result = {} + for key, field in fields.items(): + value = options.get(key, field.get("default")) + label = field.get("label", key) + if value is None or value == "": + if field.get("required", True): + raise ValueError(f"请填写{label}") + result[key] = "" + continue + kind = field["type"] + if kind in {"integer", "number"}: + valid = type(value) is int if kind == "integer" else type(value) in (int, float) + if not valid or not math.isfinite(value) or value < field.get("min", 0) or value > field.get("max", 10000000): + raise ValueError(f"{label}超出允许范围或类型不正确") + elif kind == "select": + if value not in [o["value"] for o in field["options"]]: + raise ValueError(f"{label}必须从登记选项中选择") + elif kind == "text": + if not isinstance(value, str) or len(value) > 1024 or re.search(r"[\x00-\x1f;$`|&<>\\]", value): + raise ValueError(f"{label}格式不正确") + if "pattern" in field and not re.fullmatch(field["pattern"], value): + raise ValueError(f"{label}格式不正确") + else: + raise ValueError("启动方案包含未知参数类型") + result[key] = value + return result + + +def resolve_target(target): + target = {**target, "environment": dict(target.get("environment", {}))} + path = target.get("config_file") + if not path: + return target + values = {} + for line in Path(path).read_text().splitlines(): + match = re.match(r"^(PORT|SERVED|CT_OFF|CT_ON)=(.*)$", line.strip()) + if not match:continue + parts = shlex.split(match[2], comments=True) + if len(parts) != 1 or "$" in parts[0] or "`" in parts[0]: + raise ValueError("原配置的模型/端口包含动态表达式,需管理员核对") + values[match[1]] = parts[0] + if "PORT" in values: + port = int(values["PORT"]) + if not 1 <= port <= 65535:raise ValueError("原模型端口不合法") + target["url"] = "http://127.0.0.1:" + str(port) + if "SERVED" in values:target["model"] = values["SERVED"] + for key in ("CT_OFF", "CT_ON"): + if key in values:target["environment"][key] = values[key] + return target + + +def public_execution(settings, script): + profile = profile_for(settings, script) + if profile is None: + return {"mode": "container", "interactive": False, "max_timeout_seconds": 7200} + return {"mode": "native", "profile_id": profile["id"], "interpreter": profile["interpreter"], + "user": profile["user"], "cwd": profile["cwd"], "description": "使用服务器原有环境和原始入口,不迁移依赖或修改源文件。", + "impact": profile["impact"], "parameters": profile.get("parameters", []), + "interactive": profile.get("interactive", False), "max_timeout_seconds": MAX_TIMEOUT, + "notes": profile.get("notes", []), "verification": "启动方案已接入,未完成本轮 GPU/长稳业务验收"} + + +def validate_config(settings, script, values): + profile = profile_for(settings, script) + if not profile: + raise ValueError("未登记原环境方案") + config = dict(values) + config["native_options"] = validate_options(profile, config.get("native_options", {})) + timeout = config.get("timeout_seconds", profile.get("timeout_seconds", 3600)) + if type(timeout) is not int or not 5 <= timeout <= MAX_TIMEOUT: + raise ValueError("原环境时限须为 5 秒至 72 小时") + config.update(timeout_seconds=timeout, cwd=profile["cwd"], args=[], output_paths=[], profile="native") + return config + + +def build_plan(settings, script, run_id=None, check_host=True): + profile = profile_for(settings, script) + if not profile: + raise ValueError("该入口未登记原环境方案") + source = script_path(settings, script) + source_hash = hashlib.sha256(source.read_bytes()).hexdigest() + if source_hash != script["sha256"]: + raise ValueError("原入口已变更,请刷新来源并重新确认后运行") + options = validate_options(profile, script["config"].get("native_options", {})) + target_id = options.get("target") + target = next((t for t in settings.native_catalog.get("targets", []) if t["id"] == target_id), {}) + if target_id and not target: + raise ValueError("目标服务未登记") + if target: + target = resolve_target(target) + if run_id and not re.fullmatch(r"[a-f0-9]{32}", run_id): + raise ValueError("运行编号格式错误") + identity = run_id or "{run_id}" + directory = settings.runs / identity + variables = {**options, "run_id": identity, "run_label": "console-" + identity, + "run_dir": str(directory), "output_file": str(Path(profile["cwd"]) / "logs" / f"console-{identity}-{profile['id']}.json"), + "target_url": target.get("url", ""), "target_model": target.get("model", ""), + "target_port": urlsplit(target.get("url", "")).port or "", "target_config": target.get("config_name", "")} + def render(value): + def replace(match): + if match[1] not in variables: + raise ValueError("原环境方案包含未定义的占位参数") + return str(variables[match[1]]) + return PLACEHOLDER.sub(replace, str(value)) + argv = [profile["interpreter"], str(source), *[render(a) for a in profile.get("arguments", [])]] + environment = {**target.get("environment", {}), **{k: render(v) for k, v in profile.get("environment", {}).items()}} + credential_files = list(dict.fromkeys(profile.get("credential_files", []) + target.get("credential_files", []))) + setup_files = [render(p) for p in profile.get("env_setup_files", [])] + required_files = [render(p) for p in profile.get("required_files", [])] + if target.get("config_file"): + required_files.append(target["config_file"]) + timeout = script["config"].get("timeout_seconds", profile.get("timeout_seconds", 3600)) + if type(timeout) is not int or not 5 <= timeout <= MAX_TIMEOUT: + raise ValueError("原环境时限不合法") + checks = [] + def check(name, ok, message): + checks.append({"name": name, "ok": bool(ok), "message": message}) + check("启动方案", profile.get("available", True), profile.get("unavailable_reason", "已登记原环境入口")) + if check_host: + check("原工作目录", Path(profile["cwd"]).is_dir(), profile["cwd"]) + tools = list(dict.fromkeys([profile["interpreter"], "/usr/bin/systemd-run", *profile.get("required_tools", [])])) + for tool in tools: + check("工具", Path(tool).is_file() and os.access(tool, os.X_OK), tool) + for path in required_files + credential_files + setup_files: + check("原配置或素材", Path(path).is_file(), path) + for value in profile.get("check_ports", []): + port = int(render(value)) + if not 1 <= port <= 65535: + raise ValueError("原服务端口不合法") + try: + with socket.create_connection(("127.0.0.1", port), timeout=.3):pass + available = True + except OSError:available = False + check("目标服务端口", available, f"127.0.0.1:{port}" + (" 可连接" if available else " 未监听,请先准备原服务")) + # Binds acknowledgment to saved configuration, source and recipe, not to a mutable browser flag. + dependency_hashes = {} + for name in required_files + setup_files: + path = Path(name) + if name not in credential_files and path.is_file() and path.stat().st_size <= 16 * 1024**2: + dependency_hashes[name] = hashlib.sha256(path.read_bytes()).hexdigest() + fingerprint = {"profile": profile, "target": target, "options": options, "source": source_hash, + "dependencies": dependency_hashes, + "script_id": script["id"], "timeout_seconds": timeout} + token = hashlib.sha256(json.dumps(fingerprint, sort_keys=True, ensure_ascii=False).encode()).hexdigest() + return {"mode": "native", "profile_id": profile["id"], "plan_token": token, + "ready": all(c["ok"] for c in checks), "checks": checks, "argv": argv, "command_preview": shlex.join(argv), + "cwd": profile["cwd"], "user": profile["user"], "environment": environment, + "credential_files": credential_files, "env_setup_files": setup_files, + "impact": profile["impact"], "interactive": profile.get("interactive", False), + "timeout_seconds": timeout, "source_sha256": source_hash, "dependency_hashes": dependency_hashes, + "output_paths": [render(p) for p in profile.get("output_paths", [])], + "notes": profile.get("notes", []) + ([f"目标服务:{target['url']};模型:{target['model']}(原配置已复核)"] if target else [])} + + +def public_plan(plan): + keys = ("mode", "profile_id", "plan_token", "ready", "checks", "command_preview", "cwd", "user", + "impact", "interactive", "output_paths", "notes", "timeout_seconds", "source_sha256") + return {key: plan[key] for key in keys} diff --git a/console/native_rpc.py b/console/native_rpc.py new file mode 100644 index 0000000..7d33139 --- /dev/null +++ b/console/native_rpc.py @@ -0,0 +1,95 @@ +"""Private, bounded worker RPC. It exposes registered plans, never shell commands.""" +import json +import os +from pathlib import Path +import re +import socket +import socketserver +import stat +import threading +import uuid +from . import native_plans, test_catalog +from .catalog import safe_path +from .store import Store + + +def handle(settings, store, data): + operation = data.get("op") + if operation == "input": + identity, text = data.get("run_id", ""), data.get("text") + if not re.fullmatch(r"[a-f0-9]{32}", identity) or not isinstance(text, str) or "\x00" in text or len(text.encode()) > 1024: + raise ValueError("菜单输入格式不正确") + if not re.fullmatch(r"(?:[0-9 ,:./+\-]*|[yYnNqQ])", text): + raise ValueError("菜单通道仅接受数字选项、数字列表或 y/n/q;不接受命令、控制键或凭据") + run = store.run(identity) + execution = run.get("execution", {}) + if run["state"] != "running" or execution.get("mode") != "native" or not execution.get("interactive"): + raise ValueError("当前任务不接受菜单输入") + path = safe_path(settings.native_socket.parent / ("input-" + identity), settings.native_socket.parent) + if not stat.S_ISFIFO(path.lstat().st_mode): + raise ValueError("菜单输入通道不可用") + try: + descriptor = os.open(path, os.O_WRONLY | os.O_NONBLOCK | os.O_NOFOLLOW) + try: + payload = (text.rstrip("\r\n") + "\n").encode() + if os.write(descriptor, payload) != len(payload): + raise ValueError("菜单输入未完整送达") + finally:os.close(descriptor) + except OSError as exc: + raise ValueError("菜单暂未就绪或已结束") from exc + return {"accepted": True} + if operation not in {"preflight", "queue"}: + raise ValueError("未知原环境操作") + identity = data.get("script_id", "") + if not re.fullmatch(r"[a-f0-9]{32}", identity): + raise ValueError("测试项目编号不合法") + if operation == "queue": + request_id = str(uuid.UUID(data.get("request_id", ""))) + previous = store.run_for_request(request_id) + if previous: + if previous["script_id"] != identity: + raise ValueError("请求编号已被其他测试使用") + return previous + script = test_catalog.describe(settings, store.script(identity), store.scripts()) + plan = native_plans.build_plan(settings, script) + if operation == "preflight": + return native_plans.public_plan(plan) + if not script["enabled"] or script["status"] != "ready": + raise ValueError("请先配置并启用测试项目") + if not plan["ready"]: + raise ValueError("原环境预检未通过,请查看缺失条件") + if data.get("plan_token") != plan["plan_token"]: + raise ValueError("原入口、参数或配置已变化,请重新预检并确认") + acknowledged = data.get("impact_acknowledged") is True + if plan["impact"]["requires_confirmation"] and not acknowledged: + raise ValueError("此测试会使用共享资源或切换服务,需要本次运行的明确确认") + execution = native_plans.public_plan(plan) + execution.update(impact_acknowledged=acknowledged, verification="启动方案已核对;本次结果以真实运行记录为准") + return store.queue(script, request_id, data.get("source_ip", "unknown"), execution=execution) + + +def start(settings, store): + folder = settings.native_socket.parent + safe_path(folder, settings.root, must_exist=False).mkdir(mode=0o750, exist_ok=True) + if settings.native_socket.exists(): + if not stat.S_ISSOCK(settings.native_socket.lstat().st_mode): + raise ValueError("原环境 IPC 路径不是 socket") + settings.native_socket.unlink() + class Handler(socketserver.StreamRequestHandler): + def handle(self): + self.connection.settimeout(15) + try: + raw = self.rfile.readline(8193) + if len(raw) > 8192: + raise ValueError("原环境请求过大") + result = handle(settings, store, json.loads(raw)) + except (ValueError, KeyError) as exc: + result = {"error": str(exc.args[0])} + except Exception: + result = {"error": "原环境操作失败,请检查执行器状态"} + try:self.wfile.write(json.dumps(result).encode() + b"\n") + except OSError:pass + server = socketserver.UnixStreamServer(str(settings.native_socket), Handler) + os.chmod(settings.native_socket, 0o600) + threading.Thread(target=server.serve_forever, daemon=True).start() + return server diff --git a/console/requirements-test.txt b/console/requirements-test.txt new file mode 100644 index 0000000..a6f6b37 --- /dev/null +++ b/console/requirements-test.txt @@ -0,0 +1,4 @@ +-r requirements.txt +certifi==2026.7.22 +httpcore==1.0.9 +httpx==0.28.1 diff --git a/console/requirements.txt b/console/requirements.txt new file mode 100644 index 0000000..b1f8a53 --- /dev/null +++ b/console/requirements.txt @@ -0,0 +1,16 @@ +# Resolved and verified in the isolated project environment on 2026-10-13. +annotated-doc==0.0.5 +annotated-types==0.8.0 +anyio==4.15.1 +click==8.5.0 +fastapi==0.143.0 +h11==0.16.0 +idna==3.20 +opentelemetry-api==1.45.1 +pydantic==2.14.1 +pydantic_core==2.50.1 +python-multipart==0.0.32 +starlette==1.8.0 +typing-inspection==0.4.4 +typing_extensions==4.16.0 +uvicorn==0.54.0 diff --git a/console/settings.py b/console/settings.py new file mode 100644 index 0000000..531fd1e --- /dev/null +++ b/console/settings.py @@ -0,0 +1,62 @@ +from dataclasses import dataclass, field +import json +import os +from pathlib import Path + + +@dataclass +class Settings: + root: Path + sources: list + allowed_hosts: list = field(default_factory=lambda: ["10.88.118.188:14442", "10.88.118.188:14440"]) + allowed_networks: list = field(default_factory=lambda: ["10.88.0.0/17", "10.20.0.0/21"]) + test_mode: bool = False + max_upload: int = 1024 * 1024 + max_log: int = 16 * 1024 * 1024 + max_artifact: int = 32 * 1024 * 1024 + max_run_files: int = 64 + test_definitions: list = field(default_factory=list) + example_script_ids: list = field(default_factory=list) + native_catalog: dict = field(default_factory=lambda: {"profiles": [], "targets": []}) + + @property + def native_socket(self): + return self.root / "native-ipc" / "control.sock" + + @property + def library(self): + return self.root / "library" + + @property + def runs(self): + return self.root / "runs" + + @property + def db(self): + return self.root / "console-data" / "console.sqlite3" + + def prepare(self): + self.validate_paths() + for directory in (self.root, self.library, self.runs, self.db.parent): + directory.mkdir(parents=True, exist_ok=True) + + def validate_paths(self): + for directory in (self.root, self.library, self.runs, self.db.parent): + if not directory.is_absolute(): + raise ValueError("Runtime must be an absolute path") + for part in (directory, *directory.parents): + if part.is_symlink(): + raise ValueError("Runtime paths must not contain symlinks") + for path in (self.db, Path(str(self.db) + "-wal"), Path(str(self.db) + "-shm")): + if path.is_symlink(): + raise ValueError("Database symlinks are not supported") + + @classmethod + def from_env(cls): + project = Path(__file__).resolve().parents[1] + catalog = json.loads((project / "catalog/external-sources.json").read_text(encoding="utf-8")) + tests = json.loads((project / "catalog/test-definitions.json").read_text(encoding="utf-8")) + native_file = project / "catalog/native-profiles.json" + native = json.loads(native_file.read_text(encoding="utf-8")) if native_file.exists() else {"profiles": [], "targets": []} + return cls(Path(os.environ.get("XAS_CONSOLE_ROOT", str(project / "runtime"))), catalog["sources"], + test_definitions=tests["tests"], example_script_ids=tests.get("example_script_ids", []), native_catalog=native) diff --git a/console/static/app.js b/console/static/app.js new file mode 100644 index 0000000..4ecdf8d --- /dev/null +++ b/console/static/app.js @@ -0,0 +1,61 @@ +import {api, el, note, heading, errorBox, pageContext, bytes, date} from './ui.js'; +import {scriptList, scriptDetail} from './scripts.js'; +import {runList, runDetail} from './runs.js'; + +const main = document.querySelector('#main'); +let context; +let summaryBusy = false; + +async function summary() { + if (summaryBusy || document.visibilityState !== 'visible') return; + summaryBusy = true; + const health = document.querySelector('#worker-status'); + const notice = document.querySelector('#global-notice'); + try { + const data = await api('/api/summary'); + document.querySelector('#nav-count').textContent = data.scripts; + document.querySelector('#summary').replaceChildren(...[ + ['测试项目', data.scripts, '按测试行为统计', ''], ['可运行', data.ready, '业务测试已就绪', 'accent'], + ['进行中', data.active, '排队 / 执行', ''], ['运行记录', data.runs, `归档 ${bytes(data.storage.run_bytes)}`, ''] + ].map(([label, value, hint, style]) => el('div', {class: `stat ${style}`}, el('span', {class: 'stat-label'}, label), el('strong', {class: 'stat-value'}, value), el('span', {class: 'stat-foot'}, hint)))); + health.replaceChildren(el('span', {class: `worker-light ${data.worker.online ? '' : 'offline'}`, 'aria-hidden': 'true'}), data.worker.online ? '执行器在线 · 单任务队列' : '执行器离线'); + health.title = `最近心跳:${date(data.worker.last_seen)}`; + notice.hidden = data.worker.online; + notice.textContent = data.worker.online ? '' : `执行器当前离线,排队任务需要等待执行器恢复。最近心跳:${date(data.worker.last_seen)}。`; + } catch (error) { + health.replaceChildren(el('span', {class: 'worker-light offline', 'aria-hidden': 'true'}), '连接状态未知'); + notice.hidden = false; + notice.textContent = `工作台状态更新失败:${error.message} 显示的数据可能已过期。`; + } finally { summaryBusy = false; } +} + +async function route() { + context?.dispose(); + context = pageContext(main); + main.setAttribute('aria-busy', 'true'); + const value = location.hash.slice(1) || '/scripts'; + const [path, query = ''] = value.split('?'); + const segments = path.split('/').filter(Boolean); + const current = segments[0] || 'scripts'; + document.querySelectorAll('[data-nav]').forEach(nav => { + if (nav.dataset.nav === current) nav.setAttribute('aria-current', 'page'); + else nav.removeAttribute('aria-current'); + }); + document.title = `${current === 'runs' ? '运行记录' : '测试项目'} · XAS 测试控制台`; + const ctx = context; + summary(); + try { + if (segments.length > 2) throw new Error('页面地址无效。'); + const id = segments[1] ? decodeURIComponent(segments[1]) : null; + if (current === 'scripts') await (id ? scriptDetail(ctx, id) : scriptList(ctx)); + else if (current === 'runs') await (id ? runDetail(ctx, id) : runList(ctx, new URLSearchParams(query).get('script_id') || '')); + else ctx.show(heading('找不到此页面', '请选择左侧的测试项目或运行记录。'), note('页面地址不存在。')); + } catch (error) { if (ctx.alive) ctx.show(errorBox(error, route)); } +} + +document.querySelector('.skip-link').addEventListener('click', event => { event.preventDefault(); main.focus(); }); +window.addEventListener('hashchange', route); +window.addEventListener('pagehide', () => context?.dispose()); +document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') summary(); }); +setInterval(summary, 15000); +route(); diff --git a/console/static/core.js b/console/static/core.js new file mode 100644 index 0000000..a4c37e3 --- /dev/null +++ b/console/static/core.js @@ -0,0 +1,29 @@ +export function parseLines(value) { + return value.split(/\r?\n/).map(line => line.trim()).filter(Boolean); +} + +export function parseArgs(value) { + if (!value.trim().startsWith('[')) return parseLines(value); + let args; + try { args = JSON.parse(value); } + catch { throw new Error('参数 JSON 格式不正确,请填写字符串数组。'); } + if (!Array.isArray(args) || args.some(arg => typeof arg !== 'string')) { + throw new Error('参数必须是 JSON 字符串数组,或每行一个参数。'); + } + return args; +} + +export function appendLog(previous, incoming, limit = 240000) { + const all = previous + incoming; + return {text: all.slice(-limit), trimmed: all.length > limit}; +} + +export function requestId() { + if (typeof crypto.randomUUID === 'function') return crypto.randomUUID(); + // LAN HTTP can lack randomUUID; getRandomValues remains available there. + const bytes = crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 15) | 64; + bytes[8] = (bytes[8] & 63) | 128; + const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} diff --git a/console/static/index.html b/console/static/index.html new file mode 100644 index 0000000..287d320 --- /dev/null +++ b/console/static/index.html @@ -0,0 +1,37 @@ + + + + + + + + XAS · 测试控制台 + + + + + + +
+
ENGINEERING /测试控制台
正在连接执行器…
+
+ +
正在加载工作台…
+ +
+ + + + diff --git a/console/static/native-ui.js b/console/static/native-ui.js new file mode 100644 index 0000000..ff0d915 --- /dev/null +++ b/console/static/native-ui.js @@ -0,0 +1,187 @@ +import {api, el, button, badge, note, loading, field, input, select, kv, errorBox, dialog, scriptUrl, runUrl} from './ui.js'; + +export const isNative = record => record.execution?.mode === 'native'; +export function maxTimeout(script) { + if (!isNative(script)) return 7200; + const limit = Number(script.execution.max_timeout_seconds); + return Number.isSafeInteger(limit) && limit >= 5 ? Math.min(limit, 259200) : 259200; +} + +export function nativeOptions(parameters, values) { + const entries = []; + for (const parameter of parameters) { + const {key, label, type, required, min, max} = parameter; + const raw = values[key]; + const blank = raw === undefined || raw === null || String(raw).trim() === ''; + if (required && blank) throw new Error(`请填写${label}。`); + if (!['integer', 'number', 'select', 'text'].includes(type)) throw new Error(`${label}的参数类型不支持。`); + if (blank && type !== 'text') continue; + let value = raw ?? ''; + if (type === 'integer' || type === 'number') { + value = Number(raw); + if (!Number.isFinite(value)) throw new Error(`${label}必须是有效数字。`); + if (type === 'integer' && !Number.isSafeInteger(value)) throw new Error(`${label}必须是整数。`); + if ((min !== undefined && value < min) || (max !== undefined && value > max)) throw new Error(`${label}超出允许范围。`); + } else if (type === 'select') { + const option = (parameter.options || []).find(item => String(item.value) === String(raw)); + if (!option) throw new Error(`请选择有效的${label}。`); + value = option.value; + } else value = String(value); + entries.push([key, value]); + } + return Object.fromEntries(entries); +} + +export function nativeParameterForm(script) { + const parameters = script.execution.parameters || []; + const saved = script.config.native_options || {}; + const controls = new Map(); + const node = el('div', {class: 'native-parameters'}); + for (const parameter of parameters) { + const value = Object.hasOwn(saved, parameter.key) ? saved[parameter.key] : parameter.default; + let control; + if (parameter.type === 'select') { + const options = (parameter.options || []).map(item => [item.value, item.label]); + if (value === undefined || value === null || value === '' || !options.some(([id]) => String(id) === String(value))) options.unshift(['', '请选择…']); + control = select(parameter.label, options, value ?? ''); + } else { + const numeric = ['integer', 'number'].includes(parameter.type); + control = input({type: numeric ? 'number' : 'text', value: value ?? '', + min: numeric ? parameter.min : undefined, max: numeric ? parameter.max : undefined, + step: parameter.type === 'integer' ? '1' : numeric ? 'any' : undefined, + autocomplete: 'off', spellcheck: 'false'}); + } + if (parameter.required) control.required = true; + controls.set(parameter.key, control); + const bounds = parameter.min !== undefined || parameter.max !== undefined + ? `允许范围:${parameter.min ?? '不限'} 至 ${parameter.max ?? '不限'}。` : ''; + node.append(field(parameter.label, control, [parameter.help, bounds].filter(Boolean).join(' '))); + } + if (!parameters.length) node.append(note('此原环境方案使用固定参数。')); + return {node, read: () => nativeOptions(parameters, Object.fromEntries(Array.from(controls, ([key, control]) => [key, control.value])))}; +} + +function impactView(impact) { + if (!impact) return null; + return el('div', {class: `native-impact ${impact.requires_confirmation ? 'attention' : ''}`}, + el('h3', {}, impact.title || '运行影响'), + (impact.details || []).length ? el('ul', {}, impact.details.map(detail => el('li', {}, detail))) : null, + el('p', {class: 'muted'}, impact.requires_confirmation ? '每次启动前须确认本次影响。' : '普通测试,无额外影响确认。')); +} + +export function nativeScriptInfo(script) { + if (!isNative(script)) return null; + const execution = script.execution; + return el('section', {class: 'panel native-plan'}, el('div', {class: 'panel-header'}, el('h2', {}, '原环境执行方案'), badge('running', '原环境')), + kv([['运行账号', execution.user], ['原工作目录', execution.cwd], ['解释器', execution.interpreter], ['方案编号', execution.profile_id], + ['交互方式', execution.interactive ? '原菜单 · 可发送菜单选项' : '自动执行']]), + execution.description ? el('p', {class: 'native-description'}, execution.description) : null, + impactView(execution.impact), note('启动方案已接入,未完成本轮业务验收。', 'info')); +} + +export async function preflightNativeRun(ctx, script) { + const content = el('div', {}, loading('正在只读预检原环境…')); + const panel = dialog('原环境运行预检', content); + panel.classList.add('native-dialog'); + let plan; + try { + plan = await api(`${scriptUrl(script.id)}/preflight`, {method: 'POST', body: {}}, ctx.signal); + if (!ctx.alive || !panel.open) return null; + const command = el('pre', {class: 'native-command mono', tabindex: '0', 'aria-label': '本次启动命令'}, plan.command_preview || '未生成启动命令'); + content.replaceChildren(...[note(plan.ready ? '只读预检通过,原脚本尚未执行。' : '预检未通过,本次不会执行。', plan.ready ? 'success' : 'danger'), + kv([['执行模式', plan.mode === 'native' ? '原环境' : plan.mode], ['运行账号', plan.user], ['原工作目录', plan.cwd]]), + el('h3', {class: 'native-subtitle'}, '本次启动命令'), command, + el('ul', {class: 'preflight-checks'}, (plan.checks || []).map(check => el('li', {}, + badge(check.ok ? 'ready' : 'failed', check.ok ? '通过' : '未通过'), el('div', {}, el('strong', {}, check.name), el('p', {}, check.message || ''))))), + impactView(plan.impact), + (plan.output_paths || []).length ? el('details', {class: 'disclosure'}, el('summary', {}, '查看原输出规则'), el('pre', {}, plan.output_paths.join('\n'))) : null, + ...(plan.notes || []).map(text => note(text, 'info'))].filter(node => node !== null)); + if (!plan.ready) return null; + if (plan.mode !== 'native' || typeof plan.plan_token !== 'string' || !plan.plan_token) throw new Error('预检未返回有效的原环境计划,请关闭窗口后重新预检。'); + if (!plan.impact?.requires_confirmation) { + content.append(note('正在创建本次运行记录…', 'info')); + return {payload: {plan_token: plan.plan_token}, close: () => panel.close()}; + } + } catch (error) { + if (ctx.alive && panel.open) content.replaceChildren(errorBox(error), note('本次没有提交运行。关闭窗口后可重新预检。')); + return null; + } + return new Promise(resolve => { + let acknowledged = false; + const confirm = button('确认影响并开始本次运行', () => { + if (!ctx.alive || !panel.open || acknowledged) return; + acknowledged = true; + confirm.disabled = true; + panel.close(); + resolve({payload: {plan_token: plan.plan_token, impact_acknowledged: true}, close: () => {}}); + }, 'primary'); + panel.addEventListener('close', () => { if (!acknowledged) resolve(null); }, {once: true}); + content.append(note('确认仅适用于上面显示的源码、参数和方案;每次重新启动都会重新预检。'), + el('div', {class: 'form-footer'}, button('暂不运行', () => panel.close()), confirm)); + }); +} + +export function validateMenuInput(text) { + if (text.includes('\0')) throw new Error('菜单输入不能包含 NUL 字符。'); + if (new TextEncoder().encode(text).length > 1024) throw new Error('单次菜单输入不能超过 1024 UTF-8 字节。'); + if (!/^(?:[0-9 ,:./+\-]*|[yYnNqQ])$/.test(text)) throw new Error('仅支持数字选项、数字列表或 y/n/q;不接受命令、控制键或凭据。'); +} + +export function nativeRunPanel(ctx, initialRun) { + let current = initialRun; + let blocked = false; + let sending = false; + let executionKey = ''; + const details = el('div'); + const control = input({type: 'text', autocomplete: 'off', spellcheck: 'false', maxlength: '1024', placeholder: '输入原菜单选项后发送'}); + const feedback = el('div', {'aria-live': 'polite'}); + const submit = el('button', {type: 'submit', class: 'button primary'}, '发送菜单选项'); + const menu = el('form', {class: 'native-menu', autocomplete: 'off', hidden: true}, + el('h3', {}, '原菜单输入'), note('仅支持数字选项、数字列表或 y/n/q,不接受命令和控制键。不要输入密码或令牌;发送后清空,不保存输入历史。'), + field('菜单选项', control, '留空发送回车以接受原菜单默认值;单次最多 1024 UTF-8 字节。'), + feedback, el('div', {class: 'form-footer'}, submit)); + const node = el('section', {class: 'panel native-plan'}, details, menu); + const eligible = () => isNative(current) && current.execution.interactive && current.state === 'running'; + function update(run, options = {}) { + current = run; + blocked = Boolean(options.blocked); + const execution = run.execution || {mode: 'container'}; + const key = JSON.stringify(execution); + if (key !== executionKey) { + executionKey = key; + details.replaceChildren(el('div', {class: 'panel-header'}, el('h2', {}, '执行环境'), badge('running', isNative(run) ? '原环境' : '隔离容器'))); + if (isNative(run)) details.append(...[ + kv([['运行账号', execution.user], ['原工作目录', execution.cwd], ['方案编号', execution.profile_id], + ['影响确认', execution.impact?.requires_confirmation ? execution.impact_acknowledged ? '已确认本次影响' : '未记录确认' : '无需额外确认']]), + el('h3', {class: 'native-subtitle'}, '实际启动命令'), el('pre', {class: 'native-command mono', tabindex: '0'}, execution.command_preview || '尚未记录启动命令'), + impactView(execution.impact), + execution.verification ? el('details', {class: 'disclosure'}, el('summary', {}, '查看执行核对信息'), el('pre', {}, JSON.stringify(execution.verification, null, 2))) : null].filter(node => node !== null)); + else details.append(el('p', {class: 'muted'}, '本次运行使用隔离容器。')); + } + menu.hidden = !eligible(); + if (!eligible()) control.value = ''; + control.disabled = blocked || sending || !eligible(); + submit.disabled = control.disabled; + } + menu.addEventListener('submit', async event => { + event.preventDefault(); + if (!ctx.alive || sending || blocked || !eligible()) return; + let text = control.value; + control.value = ''; + feedback.replaceChildren(); + try { validateMenuInput(text); } + catch (error) { feedback.replaceChildren(errorBox(error)); return; } + sending = true; + update(current, {blocked}); + try { + const response = api(`${runUrl(current.id)}/input`, {method: 'POST', body: {text}}, ctx.signal); + text = ''; + const result = await response; + if (ctx.alive) feedback.replaceChildren(note(result.accepted === true ? '输入已送达本次运行。' : '输入结果未确认,请先核对当前状态和日志。', result.accepted === true ? 'success' : '')); + } catch { + if (ctx.alive) feedback.replaceChildren(note('输入未送达或结果尚未确认。请核对运行状态和最新日志,再决定是否重新输入。', 'danger')); + } finally { text = ''; sending = false; if (ctx.alive) update(current, {blocked}); } + }); + update(initialRun); + return {node, update}; +} diff --git a/console/static/related-files.js b/console/static/related-files.js new file mode 100644 index 0000000..7e29740 --- /dev/null +++ b/console/static/related-files.js @@ -0,0 +1,63 @@ +import {api, el, select, input, loading, note, errorBox, link, scriptUrl} from './ui.js'; + +const roles = {entry: '执行入口', support: '配套文件', self_test: '套件内部验证', variant: '相关实现 / 场景'}; + +export function relatedFiles(ctx, script) { + const content = el('div', {class: 'related-files'}); + const panel = el('details', {class: 'panel disclosure'}, + el('summary', {}, `相关文件 · ${script.file_count || 1} 个`), content); + let loaded = false; + let busy = false; + async function load() { + if (busy || loaded || !panel.open) return; + busy = true; + content.replaceChildren(loading('正在读取项目文件…')); + try { + const {items} = await api(`${scriptUrl(script.id)}/files`, {}, ctx.signal); + if (!ctx.alive) return; + const search = input({type: 'search', placeholder: '按文件名或路径查找', 'aria-label': '搜索相关文件'}); + const choice = select('查看相关文件', []); + const info = el('p', {class: 'source-info mono'}); + const feedback = el('div'); + const download = link('下载此文件', '', 'button small'); + const code = el('pre', {class: 'code', tabindex: '0', 'aria-label': '相关文件源代码,只读'}); + content.replaceChildren(note('这里展示测试入口、配套模块和相关实现。查看文件不会改变本次测试的执行入口。'), + search, choice, info, download, feedback, code); + let generation = 0; + async function show() { + const ticket = ++generation; + const file = items.find(item => item.id === choice.value); + download.hidden = true; + feedback.replaceChildren(); + info.textContent = file ? `${roles[file.role] || '配套文件'} · ${file.source_path}` : ''; + code.textContent = file ? '正在读取…' : '没有匹配的文件。'; + if (!file) return; + try { + const data = await api(`${scriptUrl(file.id)}/code`, {}, ctx.signal); + if (!ctx.alive || ticket !== generation) return; + code.textContent = data.code || '(空文件)'; + download.href = `${scriptUrl(file.id)}/download`; + download.hidden = Boolean(data.redacted); + if (data.redacted) feedback.append(note('疑似凭据已隐藏,该文件不可下载。')); + } catch (error) { + if (ctx.alive && ticket === generation) { code.textContent = ''; feedback.replaceChildren(errorBox(error, show)); } + } + } + function filter() { + const term = search.value.toLowerCase(); + const selected = choice.value; + const matching = items.filter(file => (file.name + file.source_path).toLowerCase().includes(term)); + choice.replaceChildren(...matching.map(file => el('option', {value: file.id}, `${roles[file.role]} · ${file.relative_path}`))); + if (matching.some(file => file.id === selected)) choice.value = selected; + show(); + } + choice.addEventListener('change', show); + search.addEventListener('input', filter); + loaded = true; + filter(); + } catch (error) { if (ctx.alive) content.replaceChildren(errorBox(error, load)); } + finally { busy = false; } + } + panel.addEventListener('toggle', load); + return panel; +} diff --git a/console/static/runs.js b/console/static/runs.js new file mode 100644 index 0000000..c77f59b --- /dev/null +++ b/console/static/runs.js @@ -0,0 +1,184 @@ +import {api, el, button, link, badge, note, empty, loading, select, heading, kv, errorBox, dialog, date, bytes, enc, runUrl, active} from './ui.js'; +import {appendLog} from './core.js'; +import {isNative, nativeRunPanel} from './native-ui.js'; + +export function runTable(items) { + return el('div', {class: 'table-wrap'}, el('table', {}, + el('thead', {}, el('tr', {}, ['脚本 / 运行编号', '状态', '版本', '创建时间', '退出码', ''].map(title => el('th', {scope: 'col'}, title)))), + el('tbody', {}, items.map(run => el('tr', {class: 'run-row'}, + el('td', {}, el('span', {class: 'run-name'}, run.script_name), el('div', {class: 'source-info mono'}, run.id)), + el('td', {}, badge(run.state), run.synthetic ? badge('synthetic', '模拟验收') : null), + el('td', {class: 'mono'}, `v${run.version}`), el('td', {}, date(run.created_at)), + el('td', {class: 'mono'}, run.exit_code ?? '—'), el('td', {}, el('a', { + href: `#/runs/${enc(run.id)}`, class: 'button detail-link run-open', + 'aria-label': `查看运行详情:${run.script_name},${run.id}`}, '查看详情 →'))))))); +} + +export async function runList(ctx, scriptId = '') { + const status = select('运行状态', [['', '全部状态'], ['running', '运行中'], ['queued', '排队中'], ['succeeded', '进程成功'], ['failed', '失败'], ['cancelled', '已取消'], ['timed_out', '已超时'], ['interrupted', '已中断']]); + const origin = select('记录类型', [['', '全部记录'], ['real', '普通脚本运行'], ['synthetic', '模拟验收']]); + const results = el('div', {}, loading('正在读取运行记录…')); + const feedback = el('div', {'aria-live': 'polite'}); + const updated = el('span', {class: 'muted'}); + let items = []; + let busy = false; + const refresh = button('↻ 刷新', () => update()); + ctx.show(heading('运行记录', scriptId ? '查看此脚本最近 100 次运行的状态、日志与结果。' : '查看最近 100 次运行。每次执行使用固定版本和独立结果目录。', + [scriptId ? link('查看全部记录', '#/runs', 'button') : null, refresh]), + el('div', {class: 'filters'}, status, origin, scriptId ? link('返回脚本', `#/scripts/${enc(scriptId)}`, 'button small') : null), + el('div', {class: 'section-title'}, el('span', {class: 'muted'}, '退出码 0 表示进程正常结束;业务结论以脚本报告为准。'), updated), feedback, results); + const render = () => { + const filtered = items.filter(run => (!status.value || run.state === status.value) + && (!origin.value || Boolean(run.synthetic) === (origin.value === 'synthetic'))); + results.replaceChildren(filtered.length ? runTable(filtered) : empty('没有符合条件的运行记录', '调整筛选条件,或从脚本详情开始一次运行。')); + }; + async function update() { + if (busy) return; + busy = true; + refresh.disabled = true; + try { + const data = await api(`/api/runs?limit=100${scriptId ? `&script_id=${enc(scriptId)}` : ''}`, {}, ctx.signal); + if (!ctx.alive) return; + items = data.items; + render(); + feedback.replaceChildren(); + updated.textContent = `更新于 ${new Date().toLocaleTimeString('zh-CN', {hour12: false})}`; + } catch (error) { + if (ctx.alive) { feedback.replaceChildren(errorBox(error, update)); if (!items.length) results.replaceChildren(); } + } finally { busy = false; refresh.disabled = false; } + } + [status, origin].forEach(control => control.addEventListener('change', render)); + ctx.repeat(update, 6000); +} + +export async function runDetail(ctx, id) { + const navigation = () => el('div', {class: 'detail-navigation'}, link('← 返回运行记录', '#/runs', 'button back-button')); + ctx.show(navigation(), loading('正在读取运行状态…')); + let run; + try { run = await api(runUrl(id), {}, ctx.signal); } + catch (error) { if (ctx.alive) ctx.show(navigation(), heading('运行详情', '运行记录读取失败。'), errorBox(error, () => runDetail(ctx, id))); return; } + if (!ctx.alive) return; + let offset = 0; + let logText = ''; + let retainedTrimmed = false; + let serverTruncated = false; + let busy = false; + let logDone = false; + let cancelling = false; + let cancelRequested = false; + let mutation = 0; + let artifactsKey = ''; + const feedback = el('div', {'aria-live': 'polite'}); + const execution = nativeRunPanel(ctx, run); + const status = el('div', {class: 'actions'}); + const metadata = el('div', {class: 'run-summary'}); + const message = el('div'); + const log = el('pre', {class: 'log', tabindex: '0', 'aria-label': '运行标准输出与错误日志'}, '正在读取日志…'); + const logStatus = el('span', {}, '等待日志'); + const logHint = el('span', {}, '标准输出 / 标准错误'); + const follow = el('input', {type: 'checkbox', checked: true}); + const files = el('div', {class: 'artifact-list'}); + const retry = button('立即刷新', () => tick(), 'small'); + const cancel = button('取消运行', async () => { + if (cancelling || !active(run)) return; + cancelling = true; + execution.update(run, {blocked: true}); + mutation++; + cancel.disabled = true; + cancel.textContent = '正在取消…'; + try { + run = await api(`${runUrl(id)}/cancel`, {method: 'POST'}, ctx.signal); + cancelRequested = true; + if (ctx.alive) { feedback.replaceChildren(); renderRun(); } + } catch (error) { if (ctx.alive) feedback.replaceChildren(errorBox(error)); } + finally { cancelling = false; if (ctx.alive) { cancel.textContent = '取消运行'; cancel.disabled = !active(run); execution.update(run, {blocked: cancelRequested}); } } + }, 'danger'); + ctx.show(navigation(), el('div', {class: 'breadcrumbs'}, link('运行记录', '#/runs'), ' / ', id), + heading(run.script_name, `运行 ${id}`, [link('查看测试项目', `#/scripts/${enc(run.script_id)}`, 'button back-button'), cancel]), + feedback, el('section', {class: 'panel'}, el('div', {class: 'panel-header'}, el('h2', {}, '运行概况'), status), metadata, message, + kv([['脚本版本', `v${run.version}`], ['源码摘要', run.sha256], ['创建时间', date(run.created_at)], ['请求来源', run.source_ip || '—']]), + el('details', {class: 'disclosure'}, el('summary', {}, '查看本次运行配置快照'), el('pre', {}, JSON.stringify(run.config, null, 2)))), + execution.node, + el('section', {class: 'panel log-panel'}, el('div', {class: 'log-toolbar'}, el('h2', {}, '实时日志'), + el('div', {class: 'actions'}, el('label', {}, follow, '跟随最新日志'), retry, link('下载完整日志', `${runUrl(id)}/log/download`, 'button small'))), + log, el('div', {class: 'log-hint'}, logStatus, logHint)), + el('section', {class: 'panel'}, el('div', {class: 'panel-header'}, el('h2', {}, '结果文件')), files), + note(isNative(run) ? '取消与超时仅处理本次原环境任务的本机进程。原脚本通过 Docker/tmux 启动的任务可能仍在运行;请核对收尾记录,不能据此认定原模型已恢复。' + : '取消与超时只终止本次执行容器,不能证明外部模型服务已停止推理。')); + + function renderRun() { + execution.update(run, {blocked: cancelling || cancelRequested}); + status.replaceChildren(badge(run.state)); + if (run.synthetic) status.append(badge('synthetic', '模拟验收')); + cancel.hidden = !active(run); + cancel.disabled = cancelling || !active(run); + metadata.replaceChildren(...[ + ['开始时间', date(run.started_at)], ['结束时间', date(run.finished_at)], + ['退出码', run.exit_code ?? '—'], ['执行类型', run.synthetic ? '模拟验收' : '脚本测试'] + ].map(([label, value]) => el('div', {}, el('small', {}, label), el('strong', {}, value)))); + message.replaceChildren(); + if (run.message) message.append(note(run.message, ['failed', 'timed_out', 'interrupted'].includes(run.state) ? 'danger' : 'info')); + if (run.state === 'succeeded') message.append(note('进程正常结束。是否达到测试目标,请查看脚本产生的报告。', 'success')); + if (run.synthetic) message.append(note('这是模拟验收记录,不代表真实模型服务或测试业务通过。', 'info')); + const key = JSON.stringify([run.state, run.artifacts]); + if (key !== artifactsKey) { + artifactsKey = key; + files.replaceChildren(...((run.artifacts || []).length ? run.artifacts.map(artifact => artifactRow(ctx, run, artifact)) : + [empty(active(run) ? '结果文件等待归档' : '本次运行没有归档文件', active(run) ? '执行结束后,登记的结果文件会出现在这里。' : '脚本可能没有生成文件,或输出路径未登记;请查看日志和运行配置。')])); + } + } + + async function tick() { + if (busy || cancelling) return; + if (!active(run) && logDone) return false; + busy = true; + retry.disabled = true; + const ticket = mutation; + try { + const [current, chunk] = await Promise.all([ + api(runUrl(id), {}, ctx.signal), api(`${runUrl(id)}/log?offset=${offset}`, {}, ctx.signal) + ]); + if (!ctx.alive) return; + if (ticket !== mutation) return true; + run = current; + if (!Number.isSafeInteger(chunk.next_offset) || chunk.next_offset < offset) throw new Error('日志游标异常,请重新打开此运行。'); + if (chunk.next_offset > offset) { + const retained = appendLog(logText, chunk.text); + logText = retained.text; + retainedTrimmed ||= retained.trimmed; + } + offset = chunk.next_offset; + logDone = chunk.done; + serverTruncated ||= chunk.truncated; + log.textContent = logText || (active(run) ? '等待脚本输出…' : '本次运行没有标准输出或标准错误。'); + if (follow.checked) log.scrollTop = log.scrollHeight; + logStatus.textContent = active(run) ? (run.state === 'queued' ? '已排队 · 等待执行器' : '持续更新中 · 每 2.5 秒刷新') : logDone ? '日志读取完成' : '正在读取剩余日志…'; + logHint.textContent = serverTruncated ? '日志达到服务端限制,后续输出可能未保留。' : retainedTrimmed ? '页面仅显示最近 240,000 字符;完整内容请下载日志。' : `已读取 ${bytes(offset)}`; + feedback.replaceChildren(); + renderRun(); + return active(run) || !logDone; + } catch (error) { + if (ctx.alive) { execution.update(run, {blocked: true}); feedback.replaceChildren(errorBox(error, tick)); logStatus.textContent = '连接暂时中断,保留已读取日志并继续重试'; } + } finally { busy = false; retry.disabled = false; } + } + follow.addEventListener('change', () => { if (follow.checked) log.scrollTop = log.scrollHeight; }); + renderRun(); + ctx.repeat(tick); +} + +function artifactRow(ctx, run, artifact) { + const url = `${runUrl(run.id)}/artifacts/${enc(artifact.id)}`; + const actions = [link('下载', url, 'button small')]; + if (['text', 'json'].includes(artifact.kind)) actions.unshift(button('预览', async () => { + const content = el('div', {}, loading('正在读取文件…')); + const panel = dialog(artifact.name, content); + try { + const preview = await api(`${url}/preview`, {}, ctx.signal); + if (!ctx.alive || !panel.open) return; + content.replaceChildren(el('pre', {class: 'code', tabindex: '0'}, preview.text || '(空文件)')); + if (preview.truncated) content.prepend(note('仅显示文件开头部分,请下载查看完整内容。')); + } catch (error) { if (ctx.alive && panel.open) content.replaceChildren(errorBox(error)); } + }, 'small')); + return el('div', {class: 'artifact'}, el('div', {class: 'artifact-name'}, artifact.name, + el('small', {}, `${bytes(artifact.size)} · ${artifact.kind === 'json' ? 'JSON' : artifact.kind === 'text' ? '文本' : '文件'}`)), el('div', {class: 'actions'}, actions)); +} diff --git a/console/static/scripts.js b/console/static/scripts.js new file mode 100644 index 0000000..b2ce3db --- /dev/null +++ b/console/static/scripts.js @@ -0,0 +1,275 @@ +import {api, el, button, link, badge, note, empty, loading, field, input, textarea, select, heading, kv, errorBox, toast, dialog, date, enc, scriptUrl} from './ui.js'; +import {parseArgs, parseLines, requestId} from './core.js'; +import {runTable} from './runs.js'; +import {relatedFiles} from './related-files.js'; +import {isNative, maxTimeout, nativeParameterForm, nativeScriptInfo, preflightNativeRun} from './native-ui.js'; + +const pendingRuns = new Map(); +function pendingRequest(id) { + if (pendingRuns.has(id)) return pendingRuns.get(id); + let value; + try { value = sessionStorage.getItem(`xas.pending-run.${id}`); } catch { /* Private browser settings can disable storage. */ } + if (!value || !/^[0-9a-f-]{36}$/.test(value)) value = requestId(); + pendingRuns.set(id, value); + try { sessionStorage.setItem(`xas.pending-run.${id}`, value); } catch { /* The in-memory request still supports safe retry. */ } + return value; +} +function clearRequest(id) { + pendingRuns.delete(id); + try { sessionStorage.removeItem(`xas.pending-run.${id}`); } catch { /* Storage may be unavailable. */ } +} +const scriptState = script => !script.enabled && script.status === 'ready' ? 'disabled' : script.status; +const scriptBadge = script => badge(scriptState(script), scriptState(script) === 'disabled' ? '未启用' : undefined); +const sourceLabel = script => script.kind === 'managed' ? '托管脚本' : '外部原文件'; +const profileLabel = script => isNative(script) ? '服务器原环境' : script.config.profile === 'legacy_smoke' ? '旧版 Smoke 兼容环境' : '标准运行环境'; +const typeLabel = script => script.category === 'example' ? '验收示例' : script.test_kind === 'suite' ? '测试套件' : '单项测试'; +const groupLabels = new Map([ + ['uploaded', '上传的脚本'], ['company-gpu-suite', '公司 GPU 测试套件'], + ['dsv41-trial-20261010', 'DeepSeek 试验脚本(10/10)'], ['model-trials-20261011', '模型试验脚本(10/11)'] +]); + +export async function scriptList(ctx) { + ctx.show(heading('测试项目', '按实际测试行为管理,一项对应一次完整测试。'), loading()); + try { + const {items} = await api('/api/scripts?include_examples=true', {}, ctx.signal); + if (!ctx.alive) return; + const search = input({type: 'search', placeholder: '搜索测试名称、用途或入口', 'aria-label': '搜索测试'}); + const group = select('来源目录', [['', '全部来源'], ...Array.from(new Set(items.map(item => item.source_group || '未分组'))).sort().map(name => [name, groupLabels.get(name) || name])]); + const status = select('脚本状态', [['', '全部状态'], ['ready', '可运行'], ['needs_configuration', '待配置'], ['missing', '源文件缺失'], ['disabled', '未启用']]); + const kind = select('测试类型', [['', '全部类型'], ['suite', '测试套件'], ['test', '单项测试']]); + const purpose = select('项目用途', [['test', '业务测试'], ['example', '验收示例'], ['', '全部项目']], 'test'); + const results = el('div'); + const count = el('span', {class: 'muted', role: 'status'}); + const discover = button('↻ 刷新来源', async () => { + discover.disabled = true; + try { + const result = await api('/api/discover', {method: 'POST'}, ctx.signal); + if (ctx.alive) { + toast(`目录已刷新:${result.tests} 项测试。内部文件更新不会自动新增测试项目。`); + await scriptList(ctx); + } + } catch (error) { if (ctx.alive) { toast(error.message); discover.disabled = false; } } + }); + ctx.show(heading('测试项目', '一项对应一次测试;套件内部的多个脚本统一在详情中查看。', [discover, button('+ 新增测试', () => uploadDialog(ctx), 'primary')]), + el('div', {class: 'filters'}, el('div', {class: 'search'}, search), purpose, kind, status, group), + el('div', {class: 'section-title'}, el('span', {class: 'muted'}, '按测试行为组织 · 验收示例默认隐藏'), count), results); + const render = () => { + const term = search.value.trim().toLowerCase(); + const filtered = items.filter(item => (!group.value || (item.source_group || '未分组') === group.value) + && (!status.value || scriptState(item) === status.value) && (!kind.value || item.test_kind === kind.value) + && (!purpose.value || item.category === purpose.value) + && (!term || [item.name, item.description, item.source_path].some(value => (value || '').toLowerCase().includes(term)))); + count.textContent = `${filtered.length} 项测试`; + results.replaceChildren(); + if (!filtered.length) { results.append(empty(items.length ? '没有匹配的测试项目' : '从第一个测试开始', items.length ? '调整关键词或筛选条件。' : '上传一个测试的入口脚本,创建测试项目。')); return; } + const groups = new Map(); + for (const item of filtered) { + const name = item.category === 'example' ? '控制台验收示例' : item.test_kind === 'suite' ? '测试套件' : '单项测试'; + if (!groups.has(name)) groups.set(name, []); + groups.get(name).push(item); + } + for (const [name, scripts] of groups) results.append(el('section', {}, + el('div', {class: 'section-title'}, el('h2', {}, name, el('span', {class: 'count'}, `${scripts.length} 项`))), + el('div', {class: 'catalog-grid'}, scripts.map(scriptCard)))); + }; + [search, group, status, kind, purpose].forEach(control => control.addEventListener('input', render)); + render(); + } catch (error) { if (ctx.alive) ctx.show(heading('测试项目', '查看、配置与运行测试。'), errorBox(error, () => scriptList(ctx))); } +} + +function scriptCard(script) { + return el('a', {class: 'script-card', href: `#/scripts/${enc(script.id)}`, 'aria-label': script.name}, + el('div', {class: 'card-top'}, el('span', {class: `language ${script.language}`}, typeLabel(script)), scriptBadge(script)), + el('h3', {}, script.name), + el('p', {class: 'script-description'}, script.description || '尚未填写说明。'), + el('p', {class: 'card-source', title: script.source_path}, script.source_path), + script.reason ? el('p', {class: 'card-reason'}, script.reason) : null, + el('div', {class: 'card-bottom'}, el('span', {}, `${script.file_count} 个相关文件 · v${script.version}`), el('span', {class: 'button detail-link'}, '查看项目详情 →'))); +} + +function uploadDialog(ctx, script) { + const file = input({type: 'file', accept: '.py,.sh', required: '', name: 'file'}); + const name = input({name: 'name', required: '', maxlength: 120, placeholder: '例如:模型接口连通性检查'}); + const description = textarea({name: 'description', placeholder: '说明测试目标、运行条件和输出结果'}); + const feedback = el('div', {'aria-live': 'polite'}); + const submit = el('button', {class: 'button primary', type: 'submit'}, script ? '上传新版本' : '创建脚本'); + const form = el('form', {}, note(script ? '新版本单独保存,不覆盖历史源码。后续运行使用最新版本,已有运行记录保持原版本。' : '支持 Python 和 Shell。脚本代码只读;后续修改请上传新版本。'), + field('脚本文件', file, '选择 .py 或 .sh 文件。'), + script ? null : field('脚本名称', name), script ? null : field('说明', description), feedback, + el('div', {class: 'form-footer'}, submit)); + const panel = dialog(script ? `上传新版本 · ${script.name}` : '上传新脚本', form); + form.addEventListener('submit', async event => { + event.preventDefault(); + if (submit.disabled || !form.reportValidity()) return; + const data = new FormData(form); + submit.disabled = true; + submit.textContent = '正在上传…'; + feedback.replaceChildren(); + try { + const saved = await api(script ? `${scriptUrl(script.id)}/versions` : '/api/scripts/upload', {method: 'POST', body: data}, ctx.signal); + if (!ctx.alive) return; + panel.close(); + toast(script ? '新版本已保存。' : '脚本已创建。'); + if (location.hash === `#/scripts/${enc(saved.id)}`) await scriptDetail(ctx, saved.id); + else location.hash = `/scripts/${enc(saved.id)}`; + } catch (error) { + if (ctx.alive) feedback.replaceChildren(errorBox(error), note('如果连接中断,请先关闭窗口并刷新脚本库,核对是否已保存,再决定重新上传。')); + } finally { submit.disabled = false; submit.textContent = script ? '上传新版本' : '创建脚本'; } + }); +} + +export async function scriptDetail(ctx, id) { + const navigation = () => el('div', {class: 'detail-navigation'}, link('← 返回测试项目', '#/scripts', 'button back-button')); + ctx.show(navigation(), loading('正在读取脚本与版本…')); + try { + const script = await api(scriptUrl(id), {}, ctx.signal); + if (!ctx.alive) return; + if (!script.is_test) { + const source = el('section', {class: 'panel'}); + ctx.show(navigation(), heading(script.name, '这是测试内部的源码文件。'), note('请从测试项目的执行入口配置和运行。'), source); + sourcePanel(ctx, source, script); + return; + } + let dirty = false; + const feedback = el('div', {'aria-live': 'polite'}); + const start = button('▶ 开始运行', async () => { + if (start.disabled) return; + start.disabled = true; + start.textContent = '正在提交…'; + feedback.replaceChildren(); + let prepared; + try { + if (isNative(script)) { + start.textContent = '正在预检…'; + prepared = await preflightNativeRun(ctx, script); + if (!prepared || !ctx.alive || dirty) return; + start.textContent = '正在提交…'; + } + const run = await api(`${scriptUrl(id)}/runs`, {method: 'POST', body: {request_id: pendingRequest(id), ...prepared?.payload}}, ctx.signal); + clearRequest(id); + if (ctx.alive) location.hash = `/runs/${enc(run.id)}`; + } catch (error) { + if (ctx.alive) feedback.replaceChildren(errorBox(error), note(isNative(script) + ? '再次点击会重新预检并重新确认影响,同时复用本次请求编号核对原记录,避免重复排队。' + : '再次点击会复用本次请求编号,核对原运行记录,避免重复排队。')); + } finally { + prepared?.close(); + if (ctx.alive) { start.disabled = dirty || !script.enabled || script.status !== 'ready'; start.textContent = '▶ 开始运行'; } + } + }, 'primary'); + start.disabled = !script.enabled || script.status !== 'ready'; + const actions = [link('运行记录', `#/runs?script_id=${enc(id)}`, 'button')]; + if (script.kind === 'managed') actions.push(button('上传新版本', () => uploadDialog(ctx, script))); + actions.push(start); + const source = el('section', {class: 'panel'}); + const history = el('section', {class: 'panel'}, el('div', {class: 'panel-header'}, el('h2', {}, '近期运行')), loading()); + ctx.show(navigation(), el('div', {class: 'breadcrumbs'}, link('测试项目', '#/scripts'), ' / ', script.name), + heading(script.name, script.description || '查看源代码、配置参数并开始一次独立运行。', actions), + feedback, + script.reason ? note(script.reason, script.status === 'missing' ? 'danger' : '') : null, + !script.enabled ? note('当前脚本未启用。确认运行参数和环境要求后,在配置中启用并保存。') : null, + nativeScriptInfo(script), + el('div', {class: 'detail-grid'}, source, configForm(ctx, script, () => { + dirty = true; + start.disabled = true; + start.title = '请先保存修改后的运行配置。'; + })), + relatedFiles(ctx, script), el('div', {class: 'section-title'}, el('h2', {}, '执行记录')), history); + sourcePanel(ctx, source, script); + const loadHistory = async () => { + try { + const {items} = await api(`/api/runs?script_id=${enc(id)}&limit=10`, {}, ctx.signal); + if (ctx.alive) history.replaceChildren(items.length ? runTable(items) : empty('还没有运行记录', '首次运行后,可在这里回看状态、日志和结果文件。')); + } catch (error) { if (ctx.alive) history.replaceChildren(errorBox(error, loadHistory)); } + }; + await loadHistory(); + } catch (error) { if (ctx.alive) ctx.show(navigation(), heading('脚本详情', '读取脚本信息失败。'), errorBox(error, () => scriptDetail(ctx, id))); } +} + +function sourcePanel(ctx, target, script) { + const versions = script.versions?.length ? script.versions : [{version: script.version}]; + const version = select('查看源码版本', versions.map(item => [item.version, `版本 ${item.version}${String(item.version) === String(script.version) ? ' · 当前' : ''}`]), script.version); + const download = link('下载源码', '', 'button small'); + const code = el('pre', {class: 'code', tabindex: '0', 'aria-label': '脚本源代码,只读'}, '正在读取源码…'); + const info = el('div', {class: 'source-info mono'}); + const feedback = el('div'); + target.append(el('div', {class: 'panel-header'}, el('h2', {}, script.is_test ? '执行入口(代码只读)' : '内部源码(只读)'), scriptBadge(script)), + kv([['来源类型', sourceLabel(script)], ['源文件', script.source_path], ['运行环境', profileLabel(script)], ['更新时间', date(script.updated_at)]]), + el('div', {class: 'source-header'}, version, download), info, feedback, code, + el('p', {class: 'source-info'}, '代码只读。查看历史版本不改变运行版本;“开始运行”使用当前已保存版本与配置。')); + let generation = 0; + const load = async () => { + const ticket = ++generation; + code.textContent = '正在读取源码…'; + download.hidden = true; + info.textContent = ''; + feedback.replaceChildren(); + try { + const data = await api(`${scriptUrl(script.id)}/code?version=${enc(version.value)}`, {}, ctx.signal); + if (!ctx.alive || ticket !== generation) return; + code.textContent = data.code || '(空文件)'; + info.textContent = `${data.filename} · v${version.value}\nSHA-256 ${data.sha256}`; + download.href = `${scriptUrl(script.id)}/download?version=${enc(version.value)}`; + download.hidden = Boolean(data.redacted); + if (data.redacted) feedback.append(note('源码中包含敏感内容,预览已脱敏,下载不可用。')); + if (data.truncated) feedback.append(note(data.redacted ? '文件较大,当前仅显示部分已脱敏源码。' : '文件较大,当前仅显示部分源码。可下载完整文件。')); + } catch (error) { + if (ctx.alive && ticket === generation) { code.textContent = ''; feedback.replaceChildren(errorBox(error, load)); } + } + }; + version.addEventListener('change', load); + load(); +} + +function configForm(ctx, script, onDirty) { + const config = script.config; + const native = isNative(script); + const limit = maxTimeout(script); + const parameters = native ? nativeParameterForm(script) : null; + const name = input({value: script.name, required: '', maxlength: 120}); + const description = textarea({value: script.description || ''}); + const args = textarea({value: JSON.stringify(config.args, null, 2), rows: 4, class: 'input mono', spellcheck: 'false'}); + const cwd = input({value: native ? script.execution.cwd || config.cwd || '' : config.cwd || '', placeholder: script.kind === 'managed' ? '留空:使用本次运行目录' : '原脚本工作目录'}); + const outputs = textarea({value: (config.output_paths || []).join('\n'), class: 'input mono', spellcheck: 'false', placeholder: '每行一个日志或结果路径'}); + if (native) [args, cwd, outputs].forEach(control => control.setAttribute('readonly', '')); + const timeout = input({type: 'number', value: config.timeout_seconds, required: '', min: '5', max: String(limit), step: '1'}); + const enabled = input({type: 'checkbox', checked: script.enabled}); + const feedback = el('div', {'aria-live': 'polite'}); + const submit = el('button', {type: 'submit', class: 'button primary'}, '保存配置'); + const form = el('form', {class: 'panel'}, el('div', {class: 'panel-header'}, el('h2', {}, '运行配置')), + field('脚本名称', name), field('说明', description), parameters?.node, + field(native ? '原入口参数(只读)' : '参数', args, native ? '参数由原环境方案计算;可调整的选项在上方填写,实际命令以本次预检为准。' : 'JSON 字符串数组,或每行一个参数;不会交给 shell 拼接。'), + field(native ? '原工作目录(只读)' : '工作目录', cwd, native ? '固定使用登记的原目录。' : script.kind === 'managed' ? '托管脚本留空。可通过 XAS_RUN_DIR 获取本次结果目录。' : '必须位于已登记的来源目录内。'), + field(native ? '原输出规则(只读)' : '归档输出路径', outputs, native ? '由固定方案管理;运行前预检显示本次输出规则,保留原脚本的输出行为。' : script.kind === 'managed' ? '托管脚本留空;本次结果目录统一归档,.log 文件的新增内容进入实时日志。' : '登记该来源 logs/ 或 results/ 内已存在的文件或目录,每行一项。.log 新增内容进入实时日志,其他结果结束后归档。'), + field('超时时间(秒)', timeout, `填写 5–${limit} 秒之间的整数。${native ? '原环境方案最长支持 72 小时。' : ''}`), el('label', {class: 'checkbox'}, enabled, '启用此脚本'), + native ? note('复用原账号、解释器、目录与已有配置,不修改原项目文件。实际运行按原脚本逻辑产生输出与影响。') + : note(`${profileLabel(script)}。提供 Python 标准库、Bash 和 curl;额外依赖不会自动安装。`), + !native && script.config.profile === 'legacy_smoke' ? note('兼容配置由服务端管理,只读。代理请求有固定上限,不能在此调整服务或模型。', 'info') : null, + feedback, el('div', {class: 'form-footer'}, submit)); + form.addEventListener('input', () => { + onDirty(); + feedback.replaceChildren(note('配置有未保存的修改。保存后才能开始运行。')); + }); + form.addEventListener('submit', async event => { + event.preventDefault(); + if (submit.disabled || !form.reportValidity()) return; + feedback.replaceChildren(); + let payload; + try { + const seconds = Number(timeout.value); + if (!Number.isSafeInteger(seconds) || seconds < 5 || seconds > limit) throw new Error(`超时时间必须是 5–${limit} 秒之间的整数。`); + if (!name.value.trim()) throw new Error('请填写脚本名称。'); + payload = {name: name.value.trim(), description: description.value, enabled: enabled.checked, + config: native ? {native_options: parameters.read(), timeout_seconds: seconds} + : {args: parseArgs(args.value), cwd: cwd.value.trim(), output_paths: parseLines(outputs.value), timeout_seconds: seconds}}; + } catch (error) { feedback.replaceChildren(errorBox(error)); return; } + submit.disabled = true; + submit.textContent = '正在保存…'; + try { + await api(scriptUrl(script.id), {method: 'PATCH', body: payload}, ctx.signal); + if (ctx.alive) { toast('配置已保存,后续运行将使用新配置。'); await scriptDetail(ctx, script.id); } + } catch (error) { if (ctx.alive) feedback.replaceChildren(errorBox(error)); } + finally { submit.disabled = false; submit.textContent = '保存配置'; } + }); + return form; +} diff --git a/console/static/styles.css b/console/static/styles.css new file mode 100644 index 0000000..56c99b6 --- /dev/null +++ b/console/static/styles.css @@ -0,0 +1,176 @@ +@charset "UTF-8"; +:root{font-family:Inter,"Segoe UI","Microsoft YaHei",sans-serif;color:#22332c;background:#f4f6f3;font-synthesis:none;--ink:#22332c;--muted:#748079;--line:#e3e8e1;--green:#237349;--light:#ecf4ec;--danger:#b43d35;--radius:12px} +*{box-sizing:border-box} +body{margin:0;font-size:14px;line-height:1.55} +button,input,textarea,select{font:inherit} +button,a,input,textarea,select{touch-action:manipulation} +a{color:var(--green);text-decoration:none} +a:hover{text-decoration:underline} +button{cursor:pointer} +button:disabled{opacity:.5;cursor:not-allowed} +:focus-visible{outline:3px solid #73b48a;outline-offset:3px} +[hidden]{display:none!important} +h1,h2,h3,p{margin:0} +h1{font-size:28px;letter-spacing:-.8px;line-height:1.25;font-weight:650;overflow-wrap:anywhere} +h2{font-size:17px;font-weight:650} +h3{font-size:15px;font-weight:600} +.sidebar{position:fixed;inset:0 auto 0 0;width:220px;background:#14231e;color:#b4c0b8;padding:31px 20px;display:flex;flex-direction:column;z-index:5} +.brand{display:flex;align-items:center;gap:12px;color:#f6faf4;padding:0 12px;text-decoration:none!important;margin-bottom:60px} +.brand-mark{display:grid;place-items:center;background:#b9db91;color:#173522;width:37px;height:39px;border-radius:10px;font-size:25px;font-weight:800;transform:rotate(-5deg)} +.brand strong{font-size:25px;letter-spacing:3px;line-height:1;display:block} +.brand small{font-size:9px;letter-spacing:1.6px;color:#a2b5a7;display:block;margin-top:5px} +.sidebar-label{font-size:11px;padding:0 15px;margin-bottom:12px;color:#7e9487;letter-spacing:2px} +nav{display:grid;gap:8px} +nav a{border:1px solid transparent;display:flex;align-items:center;gap:13px;color:#bac8bf;padding:12px 14px;border-radius:8px;font-weight:550} +nav a:hover{background:#1e342a;text-decoration:none} +nav a[aria-current=page]{background:#2b4334;color:#d4edb1;border-color:#3b5642} +nav a>span:first-child{font-size:20px;line-height:1;opacity:.85} +.nav-count{font-size:11px;border-radius:4px;background:#3e5844;min-width:24px;padding:0 5px;text-align:center;margin-left:auto} +.sidebar-note{font-size:11px;border-top:1px solid #304034;padding:21px 12px 0;margin-top:auto;color:#becdbf} +.sidebar-note p{font-size:11px;color:#7d9383;margin-top:9px;line-height:1.9} +.dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:#adc888;margin-right:7px;vertical-align:2px} +.sidebar-bottom{padding:29px 12px 0;color:#879e8d;font-size:12px} +.sidebar-bottom span{font-size:10px;margin-left:7px;color:#617a69} +.workspace{margin-left:220px;min-height:100vh;display:flex;flex-direction:column} +.topbar{height:73px;border-bottom:1px solid var(--line);background:#fbfcf9;display:flex;align-items:center;justify-content:space-between;padding:0 38px;font-size:12px} +.topbar>div:first-child{display:flex;align-items:center;gap:14px} +.eyebrow{font-size:10px;letter-spacing:1.5px;color:#98a298} +#worker-status{display:flex;align-items:center;gap:8px;color:#748477} +.worker-light{width:7px;height:7px;display:inline-block;border-radius:100%;background:#2e955d} +.worker-light.offline{background:#cd8b36} +.summary{padding:26px 38px 0;display:grid;grid-template-columns:repeat(4,1fr);gap:16px} +.stat{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:16px 20px;position:relative} +.stat-label{font-size:12px;color:var(--muted)} +.stat-value{font-size:27px;font-weight:620;letter-spacing:-1px;line-height:1.2;margin-top:6px;display:block} +.stat-foot{font-size:10px;color:#859286;position:absolute;bottom:19px;right:18px} +.stat.accent{background:#edf4e7;border-color:#dce9d3} +.stat.accent .stat-value{color:#477129} +main{padding:30px 38px 36px;flex:1;min-width:0} +.page-heading{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:26px} +.page-heading p{color:var(--muted);font-size:12px;margin-top:9px;max-width:650px} +.actions{display:flex;gap:9px;align-items:center;flex-wrap:wrap} +.button{border:1px solid #dbe3d8;background:#fff;color:var(--ink);border-radius:7px;padding:9px 15px;min-height:38px;display:inline-flex;justify-content:center;align-items:center;gap:7px;white-space:nowrap;font-size:12px;font-weight:550;text-decoration:none!important;line-height:1.4} +.button:hover:enabled{background:#f1f5ed;border-color:#b8cbb8} +.button.primary{background:#2b703d;border-color:#2b703d;color:white;box-shadow:0 2px 3px #234d2710} +.button.primary:hover:enabled{background:#235e32} +.button.danger{color:#a64236;border-color:#e8c7bf} +.button.small{padding:5px 10px;min-height:29px;font-size:11px} +.button.ghost{border-color:transparent;background:transparent;color:#618063} +.button.detail-link{min-height:46px;min-width:112px;padding:11px 18px;font-size:14px;font-weight:600;color:var(--green);border-color:#bed5c4;background:#f2f8f2} +.button.detail-link:hover,.button.back-button:hover{background:#e6f1e7;border-color:#79a586} +.button.back-button{min-height:44px;padding:10px 17px;font-size:14px;border-color:#b8cdbb;color:var(--green);box-shadow:0 2px 8px #1839230c} +.detail-navigation{position:sticky;top:12px;z-index:3;display:flex;width:fit-content;margin-bottom:20px} +.filters{display:flex;gap:11px;align-items:center;flex-wrap:wrap;background:#fff;border:1px solid var(--line);padding:15px 17px;border-radius:var(--radius);margin-bottom:22px} +.input{border:1px solid #dce3d9;border-radius:7px;padding:9px 11px;background:#fff;color:var(--ink);min-height:38px;min-width:0;width:100%;font-size:12px;transition:border-color .15s} +.input:hover{border-color:#acbfaa} +.input:focus{border-color:#55905e} +.input::placeholder{color:#9aa69a} +textarea.input{resize:vertical;line-height:1.7} +.search{flex:1;min-width:200px;position:relative} +.search .input{padding-left:34px;background:#f8faf6} +.search:before{content:'⌕';position:absolute;left:13px;top:6px;font-size:20px;color:#8b9c89} +.filters select{width:auto;max-width:220px} +.section-title{display:flex;justify-content:space-between;align-items:center;gap:16px;margin:23px 0 13px;font-size:12px} +.section-title h2{font-size:14px;overflow-wrap:anywhere} +.section-title .count{font-size:11px;font-weight:400;color:#8c9b8b;margin-left:9px} +.muted{color:var(--muted);font-size:12px} +.catalog-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(275px,1fr));gap:16px} +.script-card{min-width:0;background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:19px 20px 16px;display:flex;flex-direction:column;transition:box-shadow .15s,border-color .15s;text-decoration:none!important;color:inherit} +.script-card:hover{border-color:#b5caae;box-shadow:0 5px 18px #233c2910} +.card-top{display:flex;justify-content:space-between;gap:14px;align-items:center;margin-bottom:16px} +.language{font-family:Consolas,monospace;font-size:11px;font-weight:bold;padding:5px 8px;border-radius:6px;color:#557b34;background:#f0f5e6;letter-spacing:.4px} +.language.shell{background:#eef1f7;color:#607087} +.script-card h3{color:#263c2b;font-size:15px;overflow-wrap:anywhere} +.script-description{color:#7a877b;line-height:1.8;font-size:12px;margin:9px 0 14px;min-height:43px;overflow-wrap:anywhere} +.card-source{color:#93a090;font-family:Consolas,monospace;font-size:10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-bottom:14px} +.card-bottom{border-top:1px solid #ecf0e8;padding-top:12px;display:flex;flex-wrap:wrap;gap:12px;justify-content:space-between;align-items:center;margin-top:auto;font-size:10px;color:#879481} +.card-bottom .detail-link{width:100%}.script-card:hover .detail-link{background:#e6f1e7;border-color:#79a586} +.card-reason{color:#9c7737;font-size:11px;line-height:1.7;margin:-1px 0 14px} +.badge{font-size:10px;white-space:nowrap;display:inline-flex;align-items:center;border-radius:5px;padding:3px 7px;line-height:1.5;background:#eef0ed;color:#69786a;font-weight:500} +.badge:before{content:'';width:5px;height:5px;border-radius:50%;background:currentColor;margin-right:5px} +.badge.ready,.badge.succeeded{color:#458344;background:#edf6e9} +.badge.running{color:#3472a3;background:#eaf3fa} +.badge.queued,.badge.needs_configuration,.badge.timed_out{color:#a17a26;background:#fcf4df} +.badge.failed,.badge.missing,.badge.interrupted{color:#b55248;background:#fbeeea} +.badge.synthetic{color:#7f648e;background:#f2edf7} +.badge.disabled,.badge.cancelled{color:#7b867a;background:#f0f2ee} +.panel{border:1px solid var(--line);border-radius:var(--radius);background:white;padding:22px;margin-bottom:20px;min-width:0} +.panel-header{display:flex;align-items:center;justify-content:space-between;gap:15px;margin-bottom:20px} +.detail-grid{display:grid;grid-template-columns:minmax(0,1.55fr) minmax(310px,1fr);gap:22px;align-items:start} +.detail-grid>.panel{margin-bottom:0} +.breadcrumbs{display:flex;flex-wrap:wrap;align-items:center;gap:10px;font-size:11px;color:#8d9c89;margin:-4px 0 17px;overflow-wrap:anywhere} +.metadata{display:grid;grid-template-columns:90px minmax(0,1fr);column-gap:16px;row-gap:10px;font-size:11px;margin:0} +.metadata dt{color:#8c9a87} +.metadata dd{margin:0;overflow-wrap:anywhere;color:#475840} +.metadata dd.mono,.mono{font-family:"Cascadia Code",Consolas,monospace} +.source-header{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin:18px 0 11px} +.source-header select{width:auto;min-width:160px;flex:1} +.source-info{font-size:10px;color:#82917d;overflow-wrap:anywhere;line-height:1.9;margin:12px 0} +pre{white-space:pre;tab-size:4;overflow:auto;margin:0;font-family:"Cascadia Code",Consolas,monospace;font-size:12px;line-height:1.8} +.code{background:#f6f8f3;border:1px solid #e4eadf;border-radius:8px;min-height:240px;max-height:580px;padding:18px;color:#425637} +.code[tabindex]:focus{outline-offset:2px} +.field{display:flex;flex-direction:column;gap:7px;margin-bottom:17px} +.field-label{font-size:12px;font-weight:550} +.field small{color:#8b9885;font-size:10px;line-height:1.75} +.field-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px} +.checkbox{display:flex;gap:9px;align-items:center;margin:18px 0;font-size:12px} +.checkbox input{accent-color:#477e38;width:15px;height:15px;min-height:auto} +.form-footer{border-top:1px solid #e9eee5;padding-top:17px;display:flex;align-items:center;justify-content:flex-end;gap:10px} +.notice{padding:12px 14px;border-radius:8px;border:1px solid #e5e8d4;background:#f7f8ed;color:#7c8055;font-size:12px;margin-bottom:17px;line-height:1.8;display:flex;align-items:center;gap:13px;justify-content:space-between;overflow-wrap:anywhere} +.notice.danger{background:#fff1ee;border-color:#f0d7d0;color:#a34c3d} +.notice.success{background:#eff7e9;border-color:#dfebd4;color:#56783d} +.notice.info{background:#edf3f6;border-color:#dae5eb;color:#647f8d} +.global-notice{margin:18px 38px 0;padding:12px 15px;border:1px solid #ebdac1;background:#fcf5e7;color:#8c744b;border-radius:8px;font-size:12px} +.loading,.empty{padding:65px 25px;text-align:center;color:#819078;grid-column:1/-1} +.loading:before{content:'';display:inline-block;border:2px solid #d7e4cf;border-top-color:#618d51;border-radius:50%;width:14px;height:14px;margin-right:10px;vertical-align:-3px;animation:spin .9s linear infinite} +@keyframes spin{to{transform:rotate(360deg)}} +.empty-icon{font-size:33px;color:#a5b49a;display:block;margin-bottom:8px} +.empty p{font-size:12px;margin-top:8px;color:#9ca795} +.table-wrap{overflow:auto;border:1px solid var(--line);border-radius:var(--radius);background:white} +table{border-collapse:collapse;width:100%;text-align:left;white-space:nowrap;font-size:12px} +th{color:#93a087;font-size:10px;font-weight:500;background:#f9fbf6;padding:13px 18px} +td{padding:16px 18px;border-top:1px solid #edf0e8;color:#6c7c60} +td:first-child{color:#354b2c;font-weight:550;max-width:360px;white-space:normal;overflow-wrap:anywhere} +td .badge{margin-right:6px} +tbody tr:hover{background:#fcfdf9} +.run-row{position:relative;cursor:pointer}.run-row:hover,.run-row:focus-within{background:#f1f7ef}.run-row:focus-within{box-shadow:inset 0 0 0 2px #73b48a} +.run-name{font-size:13px;color:#354b2c}.run-open:after{content:'';position:absolute;inset:0} +.run-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:15px;margin-bottom:20px} +.run-summary>div{border-right:1px solid var(--line);padding-right:15px} +.run-summary>div:last-child{border-right:0} +.run-summary small{display:block;color:#94a18b;font-size:10px;margin-bottom:7px} +.run-summary strong{font-size:12px;font-weight:550;overflow-wrap:anywhere} +.log-panel{padding:0;overflow:hidden} +.log-toolbar{padding:15px 19px;display:flex;justify-content:space-between;align-items:center;gap:16px;border-bottom:1px solid #e0e6d9} +.log-toolbar h2{font-size:14px} +.log{background:#1b281f;color:#cedcbd;padding:22px;min-height:320px;max-height:590px;font-size:12px;line-height:1.8;white-space:pre-wrap;overflow-wrap:anywhere} +.log-hint{font-size:10px;color:#8b9b80;padding:10px 19px;border-top:1px solid #e8eddf;display:flex;justify-content:space-between;gap:12px} +.log-toolbar label{font-size:11px;display:flex;align-items:center;gap:6px;color:#879779} +.artifact-list{display:grid;gap:9px} +.artifact{display:flex;justify-content:space-between;align-items:center;padding:13px 15px;border:1px solid #e6ecde;border-radius:8px;gap:16px} +.artifact-name{font-size:12px;overflow-wrap:anywhere} +.artifact-name small{display:block;font-size:10px;color:#9aa68f;margin-top:3px} +.disclosure{margin-top:17px} +.disclosure summary{cursor:pointer;font-size:12px;color:#71845f;padding:8px 0} +.disclosure pre{max-height:300px;background:#f5f8ef;border-radius:7px;padding:16px;font-size:11px;white-space:pre-wrap;overflow-wrap:anywhere} +.dialog{border:1px solid #dce5d0;border-radius:14px;padding:25px;width:min(580px,calc(100% - 30px));max-height:90vh;color:var(--ink);box-shadow:0 20px 80px #17250d35} +.dialog::backdrop{background:#15201070;backdrop-filter:blur(2px)} +.related-files{display:grid;gap:12px;margin-top:18px}.related-files .code{max-height:420px}.related-files>.button{justify-self:start} +.native-plan .notice:last-child{margin-bottom:0}.native-description{font-size:12px;color:#748079;margin-top:17px;line-height:1.8} +.native-impact{margin:18px 0;padding:14px 16px;background:#f5f8f0;border:1px solid #e4eada;border-radius:8px}.native-impact.attention{background:#fdf6e7;border-color:#ecdcba} +.native-impact ul{padding-left:20px;margin:9px 0;font-size:12px;line-height:1.9}.native-impact h3{font-size:13px}.native-impact .muted{font-size:11px} +.native-command{margin:12px 0;padding:15px;background:#f2f6ee;border:1px solid #dfe7d7;border-radius:8px;white-space:pre-wrap;overflow-wrap:anywhere;max-height:280px;font-size:11px} +.native-subtitle{margin-top:20px;font-size:12px}.native-dialog{width:min(720px,calc(100% - 30px))} +.preflight-checks{padding:0;margin:15px 0;list-style:none;display:grid;gap:10px}.preflight-checks li{display:flex;align-items:flex-start;gap:10px;font-size:12px}.preflight-checks p{font-size:11px;color:#7f8c74;margin-top:3px;overflow-wrap:anywhere} +.native-menu{border-top:1px solid var(--line);margin-top:20px;padding-top:20px}.native-menu h3{margin-bottom:12px}.native-parameters{border-top:1px solid var(--line);padding-top:17px}.input[readonly]{background:#f6f8f3;color:#73816a} +.dialog-header{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:23px} +.dialog .code{max-height:55vh;white-space:pre-wrap;overflow-wrap:anywhere} +.page-footer{display:flex;justify-content:space-between;gap:20px;padding:17px 38px;border-top:1px solid var(--line);color:#a0ac94;font-size:10px} +.toast{position:fixed;bottom:26px;left:calc(50% + 110px);transform:translateX(-50%);z-index:20;background:#263e21;color:#e0edcf;padding:13px 22px;border-radius:9px;font-size:12px;box-shadow:0 5px 25px #19311425;max-width:calc(100% - 40px)} +.skip-link{position:fixed;top:-100px;left:15px;padding:10px;background:white;z-index:100} +.skip-link:focus{top:10px} +.noscript{position:fixed;top:80px;left:240px;background:#fff3df;padding:20px} +@media(min-width:1600px){main,.summary{padding-left:55px;padding-right:55px}.catalog-grid{grid-template-columns:repeat(3,1fr)}} +@media(max-width:1100px){.sidebar{width:184px;padding:29px 14px}.workspace{margin-left:184px}.topbar{padding:0 25px}main{padding:26px 25px}.summary{padding:22px 25px 0;gap:10px}.stat{padding:14px}.stat-foot{display:none}.detail-grid{grid-template-columns:1fr}.toast{left:calc(50% + 92px)}.global-notice{margin-left:25px;margin-right:25px}} +@media(max-width:720px){.sidebar{position:static;width:auto;padding:16px 19px;flex-direction:row;align-items:center;gap:20px}.brand{padding:0;margin:0;gap:8px}.brand strong{font-size:20px}.brand small{font-size:7px}.brand-mark{width:30px;height:33px;font-size:21px}.sidebar-label,.sidebar-note,.sidebar-bottom{display:none}nav{display:flex;gap:5px;margin-left:auto}nav a{padding:8px 10px;font-size:12px;gap:6px}nav a>span:first-child,.nav-count{display:none}.workspace{margin:0}.topbar{height:48px;padding:0 20px;font-size:10px}.eyebrow{display:none}.summary{padding:19px 18px 0;grid-template-columns:repeat(2,1fr);gap:9px}.stat{padding:12px 15px}.stat-label{font-size:10px}.stat-value{font-size:23px}.stat-foot{display:block;font-size:9px;right:12px;bottom:15px}main{padding:24px 18px}.page-heading{align-items:flex-start;flex-direction:column;gap:16px;margin-bottom:20px}.page-heading h1{font-size:24px}.page-heading p{font-size:11px}.page-heading>.actions{width:100%}.filters{gap:8px;padding:12px}.search{flex-basis:100%}.filters select{flex:1;max-width:none;width:calc(50% - 8px)}.catalog-grid{grid-template-columns:1fr}.panel{padding:17px}.log-panel{padding:0}.run-summary{grid-template-columns:1fr 1fr;row-gap:20px}.run-summary>div:nth-child(2){border:0}.log-toolbar{align-items:flex-start;flex-direction:column;gap:10px}.log-toolbar .actions{width:100%;justify-content:space-between}.log{padding:17px;font-size:11px}.log-hint{flex-direction:column;gap:3px}.artifact{align-items:flex-start;flex-direction:column}.artifact>.actions{align-self:flex-end}.field-grid{grid-template-columns:1fr;gap:0}.page-footer{padding:15px 18px;font-size:9px;flex-direction:column;gap:4px}.toast{left:50%;width:max-content}.global-notice{margin:15px 18px 0}.noscript{left:20px;right:20px}.metadata{grid-template-columns:75px minmax(0,1fr);gap:9px}.source-header{align-items:stretch}} +@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} diff --git a/console/static/ui.js b/console/static/ui.js new file mode 100644 index 0000000..578cea2 --- /dev/null +++ b/console/static/ui.js @@ -0,0 +1,125 @@ +export const stateNames = { + queued: '排队中', running: '运行中', succeeded: '进程成功', failed: '失败', + cancelled: '已取消', timed_out: '已超时', interrupted: '已中断', + ready: '可运行', needs_configuration: '待配置', missing: '源文件缺失' +}; +export const active = run => ['queued', 'running'].includes(run.state); +export const enc = value => encodeURIComponent(value); +export const scriptUrl = id => `/api/scripts/${enc(id)}`; +export const runUrl = id => `/api/runs/${enc(id)}`; +export const date = value => value ? new Date(value).toLocaleString('zh-CN', {hour12: false}) : '—'; +export function bytes(value) { + if (!Number.isFinite(Number(value))) return '—'; + const size = Number(value); + if (size < 1024) return `${size} B`; + if (size < 1048576) return `${(size / 1024).toFixed(1)} KB`; + return `${(size / 1048576).toFixed(1)} MB`; +} +export function el(tag, attributes = {}, ...children) { + const node = document.createElement(tag); + for (const [key, value] of Object.entries(attributes)) { + if (key === 'class') node.className = value; + else if (key.startsWith('on')) node.addEventListener(key.slice(2), value); + else if (key === 'text') node.textContent = value; + else if (['checked', 'disabled', 'hidden', 'selected', 'value'].includes(key)) node[key] = value; + else if (value !== undefined && value !== null) node.setAttribute(key, value); + } + for (const child of children.flat(Infinity)) { + if (child !== null && child !== undefined) node.append(child instanceof Node ? child : document.createTextNode(String(child))); + } + return node; +} +export const button = (text, onClick, style = 'secondary') => el('button', {type: 'button', class: `button ${style}`, onclick: onClick}, text); +export const link = (text, href, className = '') => el('a', {href, class: className}, text); +export const badge = (state, text = stateNames[state] || state) => el('span', {class: `badge ${state}`}, text); +export const note = (text, tone = '') => el('div', {class: `notice ${tone}`}, text); +export const empty = (title, detail) => el('div', {class: 'empty'}, el('span', {class: 'empty-icon', 'aria-hidden': 'true'}, '▤'), el('h3', {}, title), el('p', {}, detail)); +export const loading = (text = '正在加载…') => el('div', {class: 'loading', role: 'status'}, text); +let fieldCounter = 0; +export function field(text, control, hint = '') { + const hintId = `field-hint-${++fieldCounter}`; + control.setAttribute('aria-label', text); + if (hint) control.setAttribute('aria-describedby', hintId); + return el('label', {class: 'field'}, el('span', {class: 'field-label'}, text), control, hint ? el('small', {id: hintId}, hint) : null); +} +export const input = (attributes = {}) => el('input', {class: 'input', ...attributes}); +export const textarea = (attributes = {}) => el('textarea', {class: 'input', rows: 3, ...attributes}); +export const select = (label, options, value = '') => el('select', {class: 'input', 'aria-label': label}, options.map(([id, text]) => el('option', {value: id, selected: String(id) === String(value)}, text))); +export function heading(title, subtitle, actions = []) { + return el('div', {class: 'page-heading'}, el('div', {}, el('h1', {}, title), el('p', {}, subtitle)), el('div', {class: 'actions'}, actions)); +} +export function kv(items) { + return el('dl', {class: 'metadata'}, items.map(([label, value]) => [el('dt', {}, label), el('dd', {}, value ?? '—')])); +} +export function errorBox(error, retry) { + return el('div', {class: 'notice danger', role: 'alert'}, el('span', {}, error.message || '请求失败,请稍后重试。'), retry ? button('重试', retry, 'small') : null); +} +export async function api(path, options = {}, signal) { + const controller = new AbortController(); + const abort = () => controller.abort(); + if (signal?.aborted) controller.abort(); + else signal?.addEventListener('abort', abort, {once: true}); + const timer = setTimeout(abort, options.body instanceof FormData ? 120000 : 20000); + const headers = {...options.headers}; + if (options.method && options.method !== 'GET') headers['X-XAS-Console'] = '1'; + let body = options.body; + if (body && !(body instanceof FormData)) { + headers['Content-Type'] = 'application/json'; + body = JSON.stringify(body); + } + try { + const response = await fetch(path, {...options, body, headers, signal: controller.signal, credentials: 'same-origin'}); + const data = await response.json().catch(() => null); + if (!response.ok) throw new Error(typeof data?.detail === 'string' ? data.detail : `请求未完成(HTTP ${response.status})。`); + if (data === null) throw new Error('服务器返回了无法读取的数据。'); + return data; + } catch (error) { + if (signal?.aborted) throw error; + if (error.name === 'AbortError') throw new Error('请求超时,操作结果尚未确认。请先核对记录;运行请求重试会复用原编号。'); + if (error instanceof TypeError) throw new Error('无法连接控制台,请检查网络后重试。'); + throw error; + } finally { + clearTimeout(timer); + signal?.removeEventListener('abort', abort); + } +} +let toastTimer; +export function toast(text) { + const target = document.querySelector('#toast'); + target.textContent = text; + target.hidden = false; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { target.hidden = true; }, 5000); +} +export function dialog(title, content) { + const close = button('关闭', () => panel.close(), 'small'); + close.setAttribute('aria-label', '关闭对话框'); + const panel = el('dialog', {class: 'dialog', 'aria-label': title}, el('div', {class: 'dialog-header'}, el('h2', {}, title), close), content); + panel.addEventListener('close', () => panel.remove(), {once: true}); + document.body.append(panel); + panel.showModal(); + return panel; +} +export function pageContext(main) { + const controller = new AbortController(); + const timers = new Set(); + const context = { + main, signal: controller.signal, + get alive() { return !controller.signal.aborted; }, + show(...nodes) { if (context.alive) { main.replaceChildren(...nodes.filter(node => node !== null && node !== undefined)); main.setAttribute('aria-busy', 'false'); } }, + async repeat(callback, interval = 2500) { + if (!context.alive) return; + if (document.visibilityState === 'visible' && await callback() === false) return; + if (context.alive) { + const timer = setTimeout(() => { timers.delete(timer); context.repeat(callback, interval); }, interval); + timers.add(timer); + } + }, + dispose() { + controller.abort(); + timers.forEach(clearTimeout); + document.querySelectorAll('dialog').forEach(panel => panel.close()); + } + }; + return context; +} diff --git a/console/store.py b/console/store.py new file mode 100644 index 0000000..7e718d1 --- /dev/null +++ b/console/store.py @@ -0,0 +1,216 @@ +"""Small SQLite repository. Queue transitions are serialized transactions.""" +from contextlib import contextmanager +from datetime import datetime, timezone +import json +import sqlite3 +import uuid + +TERMINAL = {"succeeded", "failed", "cancelled", "timed_out", "interrupted"} + + +def now(): + return datetime.now(timezone.utc).isoformat() + + +class Store: + def __init__(self, settings): + self.settings = settings + settings.prepare() + with self.connect() as db: + version = db.execute("PRAGMA user_version").fetchone()[0] + if version not in (0, 1): + raise RuntimeError("Unsupported database version") + db.executescript(""" + PRAGMA journal_mode=WAL; + CREATE TABLE IF NOT EXISTS scripts(id TEXT PRIMARY KEY, data TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS versions( + script_id TEXT NOT NULL, version INTEGER NOT NULL, data TEXT NOT NULL, + PRIMARY KEY(script_id,version)); + CREATE TABLE IF NOT EXISTS runs( + id TEXT PRIMARY KEY, request_id TEXT NOT NULL UNIQUE, + script_id TEXT NOT NULL, state TEXT NOT NULL, + cancel_requested INTEGER NOT NULL DEFAULT 0, data TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS health(key TEXT PRIMARY KEY, value TEXT NOT NULL); + PRAGMA user_version=1; + """) + + @contextmanager + def connect(self, write=False): + db = sqlite3.connect(self.settings.db, timeout=10) + db.row_factory = sqlite3.Row + try: + if write: + db.execute("BEGIN IMMEDIATE") + yield db + db.commit() + except Exception: + db.rollback() + raise + finally: + db.close() + + def scripts(self): + with self.connect() as db: + return [json.loads(r[0]) for r in db.execute("SELECT data FROM scripts ORDER BY id")] + + def script(self, identity, versions=False): + with self.connect() as db: + row = db.execute("SELECT data FROM scripts WHERE id=?", (identity,)).fetchone() + if not row: + raise KeyError("脚本不存在") + item = json.loads(row[0]) + if versions: + item["versions"] = [json.loads(r[0]) for r in db.execute( + "SELECT data FROM versions WHERE script_id=? ORDER BY version DESC", (identity,))] + return item + + def save_script(self, item): + with self.connect(write=True) as db: + db.execute("INSERT OR REPLACE INTO scripts VALUES(?,?)", (item["id"], json.dumps(item))) + + def version(self, identity, version): + with self.connect() as db: + row = db.execute("SELECT data FROM versions WHERE script_id=? AND version=?", (identity, version)).fetchone() + if not row: + raise KeyError("版本不存在") + return json.loads(row[0]) + + def upload_version(self, item, content, filename): + # A transaction prevents two simultaneous uploads from assigning the same version. + with self.connect(write=True) as db: + current = db.execute("SELECT data FROM scripts WHERE id=?", (item["id"],)).fetchone() + if current: + item = json.loads(current[0]) + version = item["version"] + 1 + target = self.settings.library / item["id"] / "versions" / str(version) / filename + target.parent.mkdir(parents=True, exist_ok=False) + try: + with target.open("xb") as handle: + handle.write(content) + import hashlib + value = {"version": version, "sha256": hashlib.sha256(content).hexdigest(), + "created_at": now(), "filename": filename, "path": str(target)} + item.update(version=version, sha256=value["sha256"], source_path=str(target), updated_at=now()) + db.execute("INSERT INTO versions VALUES(?,?,?)", (item["id"], version, json.dumps(value))) + db.execute("INSERT OR REPLACE INTO scripts VALUES(?,?)", (item["id"], json.dumps(item))) + except Exception: + target.unlink(missing_ok=True) + target.parent.rmdir() + raise + return item + + @staticmethod + def unpack_run(row): + data = json.loads(row["data"]) + data.update(state=row["state"], cancel_requested=bool(row["cancel_requested"])) + return data + + def runs(self, script_id=None, limit=100): + with self.connect() as db: + query = "SELECT * FROM runs" + values = [] + if script_id: + query += " WHERE script_id=?" + values.append(script_id) + query += " ORDER BY rowid DESC LIMIT ?" + values.append(limit) + return [self.unpack_run(r) for r in db.execute(query, values)] + + def run(self, identity): + with self.connect() as db: + row = db.execute("SELECT * FROM runs WHERE id=?", (identity,)).fetchone() + if not row: + raise KeyError("运行记录不存在") + return self.unpack_run(row) + + def run_for_request(self, request_id): + with self.connect() as db: + row = db.execute("SELECT * FROM runs WHERE request_id=?", (request_id,)).fetchone() + return self.unpack_run(row) if row else None + + def queue(self, script, request_id, ip, execution=None): + with self.connect(write=True) as db: + old = db.execute("SELECT * FROM runs WHERE request_id=?", (request_id,)).fetchone() + if old: + if old["script_id"] != script["id"]: + raise ValueError("请求编号已用于其他脚本") + return self.unpack_run(old) + pending = db.execute("SELECT count(*) FROM runs WHERE state IN ('queued','running')").fetchone()[0] + if pending >= 20: + raise ValueError("队列已满,请等待已有测试结束") + item = {"id": uuid.uuid4().hex, "script_id": script["id"], "script_name": script["name"], + "version": script["version"], "sha256": script["sha256"], "state": "queued", + "created_at": now(), "started_at": None, "finished_at": None, "exit_code": None, + "message": "等待执行", "config": script["config"], "artifacts": [], + "synthetic": script.get("synthetic", False), "source_ip": ip, + "source_path": script["source_path"], "language": script["language"], + "kind": script["kind"], "source_group": script["source_group"]} + if execution: + item["execution"] = execution + db.execute("INSERT INTO runs(id,request_id,script_id,state,data) VALUES(?,?,?,?,?)", + (item["id"], request_id, script["id"], "queued", json.dumps(item))) + return item + + def claim(self): + with self.connect(write=True) as db: + if db.execute("SELECT 1 FROM runs WHERE state='running'").fetchone(): + return None + row = db.execute("SELECT * FROM runs WHERE state='queued' ORDER BY rowid LIMIT 1").fetchone() + if not row: + return None + item = self.unpack_run(row) + item.update(started_at=now(), state="running", message="正在启动测试") + db.execute("UPDATE runs SET state='running',data=? WHERE id=?", (json.dumps(item), item["id"])) + return item + + def finish(self, identity, state, **values): + assert state in TERMINAL + with self.connect(write=True) as db: + row = db.execute("SELECT * FROM runs WHERE id=?", (identity,)).fetchone() + item = self.unpack_run(row) + item.update(values, state=state, finished_at=now()) + db.execute("UPDATE runs SET state=?,data=? WHERE id=?", (state, json.dumps(item), identity)) + return item + + def running_message(self, identity, message): + with self.connect(write=True) as db: + row = db.execute("SELECT * FROM runs WHERE id=? AND state='running'", (identity,)).fetchone() + if row: + item = self.unpack_run(row) + item["message"] = message + db.execute("UPDATE runs SET data=? WHERE id=?", (json.dumps(item), identity)) + + def update_execution(self, identity, values): + with self.connect(write=True) as db: + row = db.execute("SELECT * FROM runs WHERE id=? AND state='running'", (identity,)).fetchone() + if not row: + raise ValueError("运行状态已变化") + item = self.unpack_run(row) + item["execution"] = {**item.get("execution", {}), **values} + db.execute("UPDATE runs SET data=? WHERE id=?", (json.dumps(item), identity)) + return item + + def cancel(self, identity): + with self.connect(write=True) as db: + row = db.execute("SELECT * FROM runs WHERE id=?", (identity,)).fetchone() + if not row: + raise KeyError("运行记录不存在") + item = self.unpack_run(row) + if row["state"] == "queued": + item.update(state="cancelled", finished_at=now(), message="已取消排队") + db.execute("UPDATE runs SET state='cancelled',cancel_requested=1,data=? WHERE id=?", + (json.dumps(item), identity)) + elif row["state"] == "running": + db.execute("UPDATE runs SET cancel_requested=1 WHERE id=?", (identity,)) + return self.run(identity) + + def heartbeat(self): + with self.connect(write=True) as db: + db.execute("INSERT OR REPLACE INTO health VALUES('worker',?)", (now(),)) + + def health(self): + with self.connect() as db: + row = db.execute("SELECT value FROM health WHERE key='worker'").fetchone() + last = row[0] if row else None + online = bool(last and (datetime.now(timezone.utc) - datetime.fromisoformat(last)).total_seconds() < 15) + return {"online": online, "last_seen": last} diff --git a/console/test_catalog.py b/console/test_catalog.py new file mode 100644 index 0000000..a63b90e --- /dev/null +++ b/console/test_catalog.py @@ -0,0 +1,72 @@ +"""Project a file inventory into reviewed, runnable test entry points.""" +import fnmatch +from pathlib import Path + + +def relative_path(settings, item): + source = next((s for s in settings.sources if s["id"] == item["source_group"]), None) + if not source: + return None + try: + return Path(item["source_path"]).relative_to(Path(source["path"])).as_posix() + except ValueError: + return None + + +def definition(settings, item): + if item["kind"] == "managed": + return next((d for d in settings.test_definitions + if d.get("managed_profile") == item["config"]["profile"]), {}) + path = relative_path(settings, item) + return next((d for d in settings.test_definitions + if d.get("source_group") == item["source_group"] and d.get("entry") == path), None) + + +def is_entry(settings, item): + return definition(settings, item) is not None + + +def members(settings, item, inventory): + spec = definition(settings, item) + if spec is None: + raise ValueError("此文件是内部源码,请从测试项目入口查看相关文件") + result = [] + for candidate in inventory: + path = relative_path(settings, candidate) + match = next((m for m in spec.get("members", []) + if candidate["source_group"] == m["source_group"] and path is not None + and (m["pattern"] == "**/*" or fnmatch.fnmatchcase(path, m["pattern"]))), None) + if candidate["id"] != item["id"] and match is None: + continue + role = "entry" if candidate["id"] == item["id"] else (match or {}).get("role", "support") + if role == "support" and path and ("tests" in Path(path).parts or Path(path).name.startswith("release-")): + role = "self_test" + result.append({"id": candidate["id"], "name": Path(candidate["source_path"]).name, + "source_path": candidate["source_path"], "relative_path": path or Path(candidate["source_path"]).name, + "source_group": candidate["source_group"], "role": role, "status": candidate["status"]}) + return sorted(result, key=lambda f: (f["role"] != "entry", f["source_group"], f["relative_path"])) + + +def describe(settings, item, inventory): + from .native_plans import public_execution + result = dict(item) + spec = definition(settings, item) + result["is_test"] = spec is not None + result["execution"] = public_execution(settings, item) + if spec is None: + result.update(test_kind="source", category="source", file_count=1, enabled=False) + return result + example = item.get("synthetic", False) or item["id"] in settings.example_script_ids + result.update(test_kind=spec.get("kind", "test"), category="example" if example else "test", + file_count=len(members(settings, item, inventory))) + if item["kind"] == "external" and item["name"] == Path(item["source_path"]).name: + result["name"] = spec.get("name", item["name"]) + if not item.get("description"): + result["description"] = spec.get("description", "") + return result + + +def tests(settings, inventory, include_examples=False): + result = [describe(settings, item, inventory) for item in inventory if is_entry(settings, item)] + result = [item for item in result if include_examples or item["category"] != "example"] + return sorted(result, key=lambda s: (not s["enabled"], s["category"] == "example", s["source_group"], s["name"])) diff --git a/console/worker.py b/console/worker.py new file mode 100644 index 0000000..e9c5a59 --- /dev/null +++ b/console/worker.py @@ -0,0 +1,270 @@ +"""Private dispatcher with a local Unix socket and one active test at a time.""" +import fcntl +import hashlib +import json +import os +from pathlib import Path +import re +import signal +import shutil +import subprocess +import threading +import time +from .artifacts import Collector +from .catalog import clean_source, safe_path, script_path, source_for, validate_config, SECRET, SERVICE, SMOKE_HASH +from .settings import Settings +from .store import Store, now +from . import workspace + +LABEL = "xas.console=dedicated" + + +def docker(*args, timeout=20): + result = subprocess.run(["docker", *args], capture_output=True, text=True, timeout=timeout) + if result.returncode: + # Arguments and secrets are never included in public error details. + raise RuntimeError("Docker operation failed: " + args[0]) + return result.stdout.strip() + + +class Worker: + def __init__(self, settings, config): + self.settings, self.config = settings, config + self.store = Store(settings) + self.stopping = False + self.container = None + + def stop(self, *args): + self.stopping = True + + def inspect_owned(self, container): + info = json.loads(docker("inspect", container))[0] + if info["Config"]["Labels"].get("xas.console") != "dedicated": + raise RuntimeError("Refusing to manage a foreign container") + return info + + def remove(self, container): + self.inspect_owned(container) + docker("rm", "-f", container) + + def recover(self): + for container in docker("ps", "-aq", "--filter", "label=" + LABEL).splitlines(): + if container: + self.remove(container) + for mount in workspace.mounts(self.settings): + workspace.remove(self.settings, mount) + for run in self.store.runs(limit=10000): + if run["state"] == "running": + if run.get("execution", {}).get("mode") == "native": + from .native_executor import NativeExecutor + NativeExecutor(self).monitor(run) + else: + self.store.finish(run["id"], "interrupted", message="执行器重启,测试容器已停止;未自动补跑,远端请求结果可能未知") + + def command(self, run, directory): + if not re.fullmatch(r"[a-f0-9]{32}", run["id"]): + raise ValueError("Invalid run identity") + source = script_path(self.settings, run) + raw = source.read_bytes() + if hashlib.sha256(raw).hexdigest() != run["sha256"]: + raise ValueError("脚本在排队后发生变化,本次未执行") + if SECRET.search(clean_source(raw)): + raise ValueError("脚本包含疑似凭据,未执行") + if run["kind"] == "external" and SERVICE.search(clean_source(raw)): + raise ValueError("涉及服务或设备管理的脚本尚未接入") + config = validate_config(self.settings, run, run["config"]) + if run["language"] not in {"shell", "python"}: + raise ValueError("Unsupported language") + snapshot = directory / "source" + snapshot.mkdir(mode=0o750) + (snapshot / "script.txt").write_bytes(raw) + (snapshot / "script.txt").chmod(0o444) + work = directory / "work" + work.mkdir(mode=0o770) + uid = 0 if run["kind"] == "external" else 1000 + os.chown(work, uid, uid) + workspace.create(self.settings, run["id"], uid) + project = Path(self.config["project"]) + cmd = ["create", "--name", "adamwen-test-run-" + run["id"], "--label", LABEL, + "--label", "xas.run=" + run["id"], "--runtime", "runc", "--network", "bridge", + "--user", f"{uid}:{uid}", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", + "--pids-limit", "64", "--memory", "512m", "--cpus", "1", "--read-only", + "--tmpfs", "/tmp:rw,nosuid,nodev,noexec,size=67108864", "--log-driver", "local", + "--log-opt", "max-size=10m", "--log-opt", "max-file=2", "--interactive", + "--env", "PYTHONUNBUFFERED=1", "--env", "PYTHONDONTWRITEBYTECODE=1", + "--env", "XAS_RUN_DIR=/work", "--mount", f"type=bind,src={work},dst=/work", + "--mount", f"type=bind,src={project / 'console'},dst=/opt/xas-console,readonly", + "--mount", f"type=bind,src={snapshot / 'script.txt'},dst=/input/script{source.suffix},readonly"] + cwd, execution_path = "/work", "/input/script" + source.suffix + if run["kind"] == "external": + root = source_for(self.settings, run["source_group"]) + cmd += ["--mount", f"type=bind,src={root},dst={root},readonly"] + cwd = config["cwd"] + # Preserve __file__ and sibling imports. Hash is rechecked immediately before launch. + execution_path = str(source) + cmd += ["--mount", f"type=bind,src={snapshot / 'script.txt'},dst={source},readonly"] + for output in config["output_paths"]: + target = Path(output) + cmd += ["--mount", f"type=bind,src={target},dst={target}"] + cmd += ["--workdir", cwd, "--entrypoint", "python3", self.config["runner_image"], "/opt/xas-console/job_main.py"] + payload = {"language": run["language"], "script": execution_path, "args": config["args"], "profile": config["profile"]} + if config["profile"] == "legacy_smoke": + if run["sha256"] != SMOKE_HASH or config["args"] or run["id"] in self.config.get("forbidden_runs", []): + raise ValueError("轻量模型测试必须使用已核对的原始 smoke.sh,不接受额外参数") + credential = Path(self.config["smoke_credential"]) + pairs = [line.split("=", 1)[1].strip().strip("\"'") for line in credential.read_text().splitlines() + if line.startswith("VLLM_API_KEY=")] + if len(pairs) != 1 or not pairs[0]: + raise ValueError("模型认证配置不可用") + payload.update(key=pairs[0], upstream=self.config["smoke_upstream"]) + return cmd, payload + + def execute(self, run): + directory = safe_path(self.settings.runs / run["id"], self.settings.runs, must_exist=False) + directory.mkdir(mode=0o750, exist_ok=False) + log = (directory / "console.log").open("wb", buffering=0) + log_lock = threading.Lock() + overflow = threading.Event() + process = None + collector = None + state, exit_code, message = "failed", None, "执行器发生错误" + + def emit(data): + with log_lock: + remaining = self.settings.max_log - log.tell() + if remaining <= len(data): + overflow.set() + if remaining > 0: + log.write(data[:remaining]) + + try: + if shutil.disk_usage(self.settings.root).free < 10 * 1024**3: + raise ValueError("服务器可用磁盘低于 10 GiB,未启动测试") + cmd, payload = self.command(run, directory) + collector = Collector(self.settings, directory, run["config"]["output_paths"]) + self.container = docker(*cmd) + process = subprocess.Popen(["docker", "start", "-ai", self.container], stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + process.stdin.write((json.dumps(payload) + "\n").encode()) + process.stdin.close() + payload.clear() + + def drain(): + while True: + chunk = os.read(process.stdout.fileno(), 4096) + if not chunk: + break + emit(chunk) + reader = threading.Thread(target=drain, daemon=True) + reader.start() + started = time.monotonic() + while process.poll() is None: + self.store.heartbeat() + collector.follow(emit) + cancel = self.store.run(run["id"])["cancel_requested"] + timed_out = time.monotonic() - started > run["config"]["timeout_seconds"] + disk_full = shutil.disk_usage(self.settings.root).free < 10 * 1024**3 or collector.external_growth() > 128 * 1024**2 + if cancel or timed_out or overflow.is_set() or self.stopping or disk_full: + self.inspect_owned(self.container) + docker("kill", self.container) + state = "cancelled" if cancel else ("timed_out" if timed_out else ("interrupted" if self.stopping else "failed")) + message = "本次容器已停止;若已发送模型请求,远端推理是否结束需另行核对" + if overflow.is_set(): + message = "日志达到 16 MiB 上限,本次测试已停止" + if disk_full: + message = "可用空间低于 10 GiB 或原目录输出新增超过 128 MiB,本次测试已停止;原文件保留" + break + time.sleep(0.5) + process.wait(timeout=15) + reader.join(timeout=5) + if reader.is_alive(): + raise RuntimeError("Log reader did not stop") + info = self.inspect_owned(self.container) + exit_code = info["State"]["ExitCode"] + if state == "failed" and message == "执行器发生错误": + state = "succeeded" if exit_code == 0 else "failed" + message = "脚本正常退出;模型质量请结合报告判断" if exit_code == 0 else f"脚本退出码 {exit_code},请查看日志" + if info["State"].get("OOMKilled"): + message = "测试超过 512 MiB 内存限制" + if info["State"].get("Error") and info["State"].get("StartedAt", "").startswith("0001-"): + exit_code, state, message = None, "failed", "容器未成功启动,脚本未执行,请查看日志" + except Exception as exc: + message = str(exc) if isinstance(exc, ValueError) else f"执行器错误:{type(exc).__name__};未自动重试" + emit(("\n[控制台] " + message + "\n").encode()) + finally: + if self.container: + try: + self.remove(self.container) + self.container = None + except Exception: + state, message = "interrupted", "容器清理未确认,执行器停止接单,请管理员检查" + self.stopping = True + if process and process.poll() is None: + process.terminate() + process.wait(timeout=10) + if collector: + collector.finish_follow(emit) + artifacts = collector.collect() if collector else [] + work = directory / "work" + if work in workspace.mounts(self.settings): + try: + workspace.remove(self.settings, work) + except Exception: + state, message = "interrupted", "临时目录卸载未确认,执行器停止接单,请管理员检查" + self.stopping = True + if overflow.is_set() and state == "succeeded": + state, message = "failed", "日志超过 16 MiB 上限,日志记录不完整,请查看已归档结果" + notices = list(dict.fromkeys(collector.notices))[:32] if collector else [] + summary = {"run_id": run["id"], "script_name": run["script_name"], "state": state, + "exit_code": exit_code, "sha256": run["sha256"], "version": run["version"], + "synthetic": run["synthetic"], "message": message, "finished_at": now(), + "archive_notices": notices, "log_truncated": overflow.is_set()} + data = json.dumps(summary, ensure_ascii=False, indent=2).encode("utf-8") + summary_path = directory / "summary.json" + summary_path.write_bytes(data) + artifact_id = hashlib.sha256(str(summary_path).encode()).hexdigest()[:24] + (directory / "artifacts").mkdir(exist_ok=True) + (directory / "artifacts" / artifact_id).write_bytes(data) + artifacts.append({"id": artifact_id, "name": "summary.json", "size": len(data), "kind": "json"}) + for notice in notices: + emit(("\n[归档] " + notice + "\n").encode()) + emit(("\n[控制台] " + message + "\n").encode()) + log.close() + if process: + process.stdout.close() + self.store.finish(run["id"], state, exit_code=exit_code, message=message, artifacts=artifacts) + + def loop(self): + self.recover() + while not self.stopping: + self.store.heartbeat() + run = self.store.claim() + if run: + if run.get("execution", {}).get("mode") == "native": + from .native_executor import NativeExecutor + NativeExecutor(self).execute(run) + else: + self.execute(run) + else: + time.sleep(1) + + +def main(): + config_path = Path(os.environ["XAS_WORKER_CONFIG"]) + config = json.loads(config_path.read_text()) + settings = Settings.from_env() + settings.prepare() + lock = (settings.db.parent / "worker.lock").open("a") + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + worker = Worker(settings, config) + from . import native_rpc + rpc = native_rpc.start(settings, worker.store) + signal.signal(signal.SIGTERM, worker.stop) + signal.signal(signal.SIGINT, worker.stop) + try:worker.loop() + finally: + rpc.shutdown();rpc.server_close() + + +if __name__ == "__main__": + main() diff --git a/console/workspace.py b/console/workspace.py new file mode 100644 index 0000000..3d7eafc --- /dev/null +++ b/console/workspace.py @@ -0,0 +1,35 @@ +"""Bounded temporary work files on a private per-run tmpfs mount.""" +from pathlib import Path +import re +import subprocess +from .catalog import safe_path + + +def mounts(settings): + result = [] + for line in Path("/proc/mounts").read_text().splitlines(): + device, target, kind, *_ = line.split() + if not device.startswith("xas-run-") or kind != "tmpfs": + continue + identity = device.removeprefix("xas-run-") + if not re.fullmatch(r"[a-f0-9]{32}", identity): + continue + expected = settings.runs / identity / "work" + if target != str(expected): + continue + result.append(safe_path(expected, settings.runs)) + return result + + +def create(settings, identity, uid): + target = safe_path(settings.runs / identity / "work", settings.runs) + if target in mounts(settings): + raise ValueError("本次临时目录已经挂载,拒绝覆盖") + subprocess.run(["mount", "-t", "tmpfs", "-o", f"size=134217728,nodev,nosuid,noexec,mode=0770,uid={uid},gid={uid}", + "xas-run-" + identity, str(target)], check=True, capture_output=True, timeout=10) + + +def remove(settings, target): + if target not in mounts(settings): + raise ValueError("临时挂载归属核验失败") + subprocess.run(["umount", "--", str(target)], check=True, capture_output=True, timeout=10) diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 0000000..341a037 --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,8 @@ +FROM rundeck/rundeck:6.2.1@sha256:63d12aefbbd129a121d2af7d97abfaa64c3cbd7756063f74dbf3ab3377d136bd +USER root +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3=3.10.6-1~22.04.1 \ + && dpkg-query -W python3 > /opt/xas-runtime-packages.txt \ + && rm -rf /var/lib/apt/lists/* +COPY --chown=1000:0 scripts /opt/xas-test-library/scripts +USER 1000 diff --git a/deploy/console-worker.service b/deploy/console-worker.service new file mode 100644 index 0000000..9921939 --- /dev/null +++ b/deploy/console-worker.service @@ -0,0 +1,21 @@ +[Unit] +Description=XAS script console private execution worker +After=docker.service +Requires=docker.service + +[Service] +Type=simple +WorkingDirectory=/adamwen-work-space/xas-test-library +Environment=XAS_CONSOLE_ROOT=/adamwen-work-space/xas-test-library/runtime +Environment=XAS_WORKER_CONFIG=/adamwen-work-space/xas-test-library/deploy/console-worker.json +Environment=PYTHONDONTWRITEBYTECODE=1 +ExecStart=/usr/bin/python3 -m console.worker +User=root +Group=root +UMask=0022 +Restart=on-failure +RestartSec=5 +TimeoutStopSec=30 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/console.Dockerfile b/deploy/console.Dockerfile new file mode 100644 index 0000000..7246bf6 --- /dev/null +++ b/deploy/console.Dockerfile @@ -0,0 +1,10 @@ +ARG PYTHON_IMAGE=python@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1 +FROM ${PYTHON_IMAGE} +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 +WORKDIR /app +COPY console/requirements.txt /app/console/requirements.txt +RUN pip install --no-cache-dir -r /app/console/requirements.txt +COPY console /app/console +COPY catalog /app/catalog +ENTRYPOINT ["python3", "-m", "uvicorn", "console.app:create_app", "--factory", "--no-proxy-headers", "--no-access-log", "--host", "10.88.118.188"] +CMD ["--port", "14442"] diff --git a/deploy/console.Dockerfile.dockerignore b/deploy/console.Dockerfile.dockerignore new file mode 100644 index 0000000..79e2de6 --- /dev/null +++ b/deploy/console.Dockerfile.dockerignore @@ -0,0 +1,10 @@ +* +!console/ +!console/** +!catalog/ +!catalog/*.json +!deploy/ +!deploy/console.Dockerfile +!deploy/console.Dockerfile.dockerignore +**/__pycache__ +**/*.pyc diff --git a/deploy/cutover_console.py b/deploy/cutover_console.py new file mode 100644 index 0000000..a2f5f00 --- /dev/null +++ b/deploy/cutover_console.py @@ -0,0 +1,90 @@ +"""Cut over the verified dedicated console; preserve both previous deployments.""" +from datetime import datetime, timezone +import json +from pathlib import Path +import shutil +import sqlite3 +import subprocess +import sys +import time +import urllib.request + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from deploy.dedicated import ROOT, NAME, IMAGE, UNIT, command, prepare, web_arguments +from console.settings import Settings +from console.store import Store + +PREVIEW = NAME + "-preview-20261013" + + +def inspect(name): + return json.loads(command("docker", "inspect", name))[0] + + +def get(port, path): + req = urllib.request.Request(f"http://10.88.118.188:{port}{path}", headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=8) as response: + return json.load(response) + + +def main(): + prepare() + preview = inspect(NAME) + if preview["Config"]["Labels"].get("xas.application") != "script-console": + raise RuntimeError("Unexpected preview owner") + if command("docker", "ps", "-aq", "--filter", "name=^/" + PREVIEW + "$"): + raise RuntimeError("Cutover has already been attempted; inspect saved state") + for name in ("adamwen-test-console", "adamwen-test-console-proxy"): + if inspect(name)["Config"]["Labels"].get("cn.aisuan.owner") != "adamwen-test-console": + raise RuntimeError("Unexpected old console owner") + old_runs = get(14440, "/api/41/project/xas-test-library/executions/running") + if not isinstance(old_runs.get("executions"), list): + raise RuntimeError("Cannot verify old console running tasks") + if get(14442, "/api/summary")["active"] or old_runs["executions"]: + raise RuntimeError("Console has running work; wait before cutover") + store = Store(Settings.from_env()) + if not any(r["state"] == "succeeded" and r["config"]["profile"] == "legacy_smoke" for r in store.runs()): + raise RuntimeError("Successful single smoke acceptance missing") + backup = ROOT / "runtime/backups/before-dedicated-20261013" + backup.mkdir(mode=0o700, exist_ok=False) + command("systemctl", "stop", UNIT) + command("docker", "stop", NAME) + with sqlite3.connect(store.settings.db) as source, sqlite3.connect(backup / "console.sqlite3") as destination: + source.backup(destination) + if destination.execute("PRAGMA integrity_check").fetchone()[0] != "ok": + raise RuntimeError("Snapshot integrity failure") + for folder in ("library", "runs"): + shutil.copytree(ROOT / "runtime" / folder, backup / folder) + (backup / "metadata.json").write_text(json.dumps({"observed_at": datetime.now(timezone.utc).isoformat(), + "preview_image": preview["Image"], "new_image": IMAGE, "old_console": "adamwen-test-console", + "old_proxy": "adamwen-test-console-proxy"}, indent=2)) + command("docker", "rename", NAME, PREVIEW) + command("docker", "stop", "adamwen-test-console") + command("docker", "stop", "adamwen-test-console-proxy") + try: + print(command(*web_arguments(14440)), flush=True) + command("systemctl", "start", UNIT) + for _ in range(20): + try: + if get(14440, "/api/summary")["worker"]["online"]: + print("CUTOVER_OK", flush=True) + break + except (OSError, ValueError): + pass + time.sleep(.5) + else: + raise RuntimeError("New entry point failed readiness check") + except Exception: + # Only revert app availability, never restore old data over newer records. + result = subprocess.run(["docker", "inspect", NAME], capture_output=True, text=True) + if result.returncode == 0 and json.loads(result.stdout)[0]["Config"]["Labels"].get("xas.application") == "script-console": + command("docker", "rm", "-f", NAME) + command("docker", "start", "adamwen-test-console-proxy") + command("docker", "start", "adamwen-test-console") + command("docker", "start", PREVIEW) + command("systemctl", "start", UNIT) + raise + + +if __name__ == "__main__": + main() diff --git a/deploy/dedicated.py b/deploy/dedicated.py new file mode 100644 index 0000000..bf5bc56 --- /dev/null +++ b/deploy/dedicated.py @@ -0,0 +1,102 @@ +"""First deployment / explicit port cutover. Only manages this application's resources.""" +import argparse +import json +import os +from pathlib import Path +import shutil +import socket +import subprocess +import sys + +ROOT = Path("/adamwen-work-space/xas-test-library") +NAME = "adamwen-script-console" +IMAGE = "adamwen/script-console:20261013-5" +UNIT = "adamwen-script-worker.service" + + +def validate_runtime(root): + for path in (root / "runtime", *(root / "runtime" / name for name in ("console-data", "library", "runs", "native-ipc"))): + if not path.resolve().is_relative_to(root.resolve()): + raise ValueError("Runtime must remain inside this project") + if any(part.is_symlink() for part in (path, *path.parents)): + raise ValueError("Runtime symlink rejected") + + +def command(*args, timeout=30): + return subprocess.check_output(args, text=True, timeout=timeout).strip() + + +def web_arguments(port): + validate_runtime(ROOT) + runtime = ROOT / "runtime" + args = ["docker", "run", "-d", "--name", NAME, "--label", "xas.application=script-console", + "--runtime", "runc", "--network", "host", "--user", "0:0", "--cap-drop", "ALL", + "--security-opt", "no-new-privileges", "--read-only", "--memory", "512m", "--cpus", "1", + "--pids-limit", "96", "--tmpfs", "/tmp:rw,nosuid,nodev,noexec,size=67108864", + "--log-driver", "local", "--log-opt", "max-size=10m", "--log-opt", "max-file=2", + "--env", "XAS_CONSOLE_ROOT=" + str(runtime)] + for name in ("console-data", "library", "runs", "native-ipc"): + path = runtime / name + args += ["--mount", f"type=bind,src={path},dst={path}" + (",readonly" if name in {"runs", "native-ipc"} else "")] + sources = json.loads((ROOT / "catalog/external-sources.json").read_text())["sources"] + for source in sources: + path = Path(source["path"]) + if path.is_dir(): + args += ["--mount", f"type=bind,src={path},dst={path},readonly"] + if path.name == "scripts": + for folder in ("logs", "results"): + logs = path.parent / folder + if logs.is_dir(): + args += ["--mount", f"type=bind,src={logs},dst={logs},readonly"] + return args + [IMAGE, "--port", str(port)] + + +def prepare(): + if os.geteuid() != 0 or ROOT.resolve() != ROOT or not (ROOT / "console/worker.py").is_file(): + raise RuntimeError("Run through the documented sudo flow in the confirmed project") + validate_runtime(ROOT) + for name in ("console-data", "library", "runs", "native-ipc"): + path = ROOT / "runtime" / name + if path.is_symlink(): + raise RuntimeError("Runtime symlink rejected") + path.mkdir(mode=0o750, exist_ok=True) + runner = command("docker", "image", "inspect", "--format", "{{.Id}}", "adamwen/test-console:20261013-2") + config = {"project": str(ROOT), "runner_image": runner, + "smoke_upstream": "http://100.64.0.55:18100", + "smoke_credential": "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/configs/vllm.env"} + path = ROOT / "deploy/console-worker.json" + if path.exists() and json.loads(path.read_text()) != config: + raise RuntimeError("Existing worker configuration differs; review before replacing") + path.write_text(json.dumps(config, indent=2)) + path.chmod(0o600) + # Existing roots are 0700/root. Container root has no capabilities and read-only mounts. + # Runtime code used by non-root jobs needs read access, without changing original scripts. + for p in (ROOT / "console").rglob("*"): + p.chmod(0o755 if p.is_dir() else 0o644) + (ROOT / "console").chmod(0o755) + + +def start(port): + existing = command("docker", "ps", "-aq", "--filter", "name=^/" + NAME + "$") + if existing: + raise RuntimeError("Console container already exists; inspect before replacement") + with socket.socket() as probe: + probe.bind(("10.88.118.188", port)) + print(command(*web_arguments(port))) + service = Path("/etc/systemd/system") / UNIT + content = (ROOT / "deploy/console-worker.service").read_bytes() + if service.exists() and service.read_bytes() != content: + raise RuntimeError("Existing systemd service differs") + service.write_bytes(content) + command("systemctl", "daemon-reload") + # Start only; no automatic boot scheduling is enabled by this deployment. + command("systemctl", "start", UNIT) + print(command("systemctl", "is-active", UNIT)) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--port", type=int, choices=[14440, 14442], default=14442) + options = parser.parse_args() + prepare() + start(options.port) diff --git a/deploy/jaas-loginmodule.conf.template b/deploy/jaas-loginmodule.conf.template new file mode 100644 index 0000000..0e3f1c3 --- /dev/null +++ b/deploy/jaas-loginmodule.conf.template @@ -0,0 +1,5 @@ +rundeck { + org.rundeck.jaas.jetty.ReloadablePropertyFileLoginModule required + debug="false" + file="/home/rundeck/secure/realm.properties"; +}; diff --git a/deploy/jobs.yaml b/deploy/jobs.yaml new file mode 100644 index 0000000..ec20181 --- /dev/null +++ b/deploy/jobs.yaml @@ -0,0 +1,31 @@ +- uuid: ef76123d-f532-42c7-b7f7-a78f9231c18d + name: 快速流程验收(模拟数据) + group: 00-platform-acceptance + description: | + 验证网页、执行、日志与结果流程。不使用 GPU,不调用模型。 + success:约一秒后成功;failure:主动失败;invalid-report:报告异常;wait:等待,用于测试超时或点击停止。 + 结果中的 synthetic=true 表示模拟验收数据,不能作为模型性能结果。 + executionEnabled: true + scheduleEnabled: false + multipleExecutions: false + loglevel: INFO + timeout: 45s + nodeFilterEditable: false + options: + - name: mode + label: 验收场景 + required: true + enforced: true + value: success + values: [success, failure, invalid-report, wait] + - name: timeout + label: 最长运行秒数 + required: true + enforced: true + value: '8' + values: ['2', '5', '8', '15', '30'] + sequence: + keepgoing: false + strategy: node-first + commands: + - exec: python3 -u /opt/xas-test-library/scripts/run_smoke.py diff --git a/deploy/lan-settings.json b/deploy/lan-settings.json new file mode 100644 index 0000000..5ea5ecd --- /dev/null +++ b/deploy/lan-settings.json @@ -0,0 +1,5 @@ +{ + "address": "10.88.118.188", + "allowed_cidrs": ["10.88.0.0/17", "10.20.0.0/21"], + "port": 14440 +} diff --git a/deploy/lan_config.py b/deploy/lan_config.py new file mode 100644 index 0000000..c37492d --- /dev/null +++ b/deploy/lan_config.py @@ -0,0 +1,89 @@ +"""Validated private-interface settings and a fixed-identity reverse proxy.""" +import ipaddress +import json + +PROXY_IMAGE = 'nginx:stable-alpine@sha256:0985e772fb9f729e6fa0980da05fca5d9c468e870eed43071545afa9d2e27d94' +NETWORK = 'adamwen-test-console-net' +PROXY = 'adamwen-test-console-proxy' + + +def validate_settings(value): + if not isinstance(value, dict) or set(value) != {'address', 'allowed_cidrs', 'port'}: + raise ValueError('Exactly address, allowed_cidrs and port must be configured') + address = ipaddress.ip_address(value['address']) + if not isinstance(value['allowed_cidrs'],list) or not 1 <= len(value['allowed_cidrs']) <= 8: + raise ValueError('Configure one to eight explicit private source networks') + networks = [ipaddress.ip_network(n, strict=True) for n in value['allowed_cidrs']] + private = [ipaddress.ip_network(n) for n in ('10.0.0.0/8','172.16.0.0/12','192.168.0.0/16')] + if address.version != 4 or any(n.version != 4 or not any(n.subnet_of(p) for p in private) for n in networks): + raise ValueError('An explicit RFC1918 IPv4 interface and network are required') + if not any(address in n and address not in (n.network_address,n.broadcast_address) for n in networks): + raise ValueError('The interface must belong to the allowed LAN subnet') + if type(value['port']) is not int or not 1024 <= value['port'] <= 65535: + raise ValueError('The port must be an integer between 1024 and 65535') + if len({str(n) for n in networks}) != len(networks): + raise ValueError('Duplicate source networks are not allowed') + return {'address': str(address), 'allowed_cidrs': [str(n) for n in networks], 'port': value['port']} + + +def load_settings(path): + return validate_settings(json.loads(path.read_text(encoding='utf-8'))) + + +def proxy_config(settings): + value = validate_settings(settings) + address, port = value['address'], value['port'] + allows = '\n '.join(f'allow {cidr};' for cidr in value['allowed_cidrs']) + origin = f'http://{address}:{port}' + return f'''worker_processes 1; +pid /tmp/nginx.pid; +error_log /dev/stderr warn; +events {{ worker_connections 256; }} +http {{ + client_body_temp_path /tmp/client_body; + proxy_temp_path /tmp/proxy; + fastcgi_temp_path /tmp/fastcgi; + uwsgi_temp_path /tmp/uwsgi; + scgi_temp_path /tmp/scgi; + log_format limited '$remote_addr [$time_local] "$request_method $uri $server_protocol" $status $body_bytes_sent'; + access_log /dev/stdout limited; + map $http_origin $origin_allowed {{ default 0; "" 1; "{origin}" 1; }} + map $http_sec_fetch_site $cross_site {{ default 0; cross-site 1; }} + server {{ + listen 8080; + server_name {address}; + {allows} + deny all; + if ($host != {address}) {{ return 403; }} + if ($origin_allowed = 0) {{ return 403; }} + if ($cross_site = 1) {{ return 403; }} + if ($arg_authtoken != "") {{ return 403; }} + client_max_body_size 1m; + keepalive_timeout 30s; + send_timeout 30s; + location / {{ + proxy_pass http://127.0.0.1:4440; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host {address}:{port}; + proxy_set_header X-Forwarded-Host {address}:{port}; + proxy_set_header X-Forwarded-Port {port}; + proxy_set_header X-Forwarded-Proto http; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Forwarded ""; + proxy_set_header X-Forwarded-Uuid xas-intranet; + proxy_set_header X-Forwarded-Roles xas-operator; + proxy_set_header X-Forwarded-User-FirstName ""; + proxy_set_header X-Forwarded-User-LastName ""; + proxy_set_header X-Forwarded-User-Email ""; + proxy_set_header Authorization ""; + proxy_set_header X-Rundeck-Auth-Token ""; + proxy_buffering off; + proxy_connect_timeout 5s; + proxy_read_timeout 60s; + proxy_send_timeout 30s; + }} + }} +}} +''' diff --git a/deploy/migrate_lan.py b/deploy/migrate_lan.py new file mode 100644 index 0000000..6272e9d --- /dev/null +++ b/deploy/migrate_lan.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""One-time, scoped cutover from the loopback preview; preserve a recovery snapshot.""" +import argparse +import hashlib +import json +from pathlib import Path +import subprocess +import tarfile + +from start import CONTAINER, IMAGE, validate_runtime + +OLD = 'adamwen-test-console-before-lan-20261013' + + +def run(args): + return subprocess.check_output(args, text=True, timeout=90).strip() + + +def inspect(name): + result = subprocess.run(['docker','inspect',name], capture_output=True, text=True) + return json.loads(result.stdout)[0] if result.returncode == 0 else None + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--project-root',type=Path,required=True) + args = parser.parse_args() + project = args.project_root.resolve(strict=True) + runtime = validate_runtime(project) + before = inspect(CONTAINER) + if before is None or before['Config'].get('Labels',{}).get('cn.aisuan.owner') != CONTAINER: + parser.error('Expected the existing project-owned preview container') + if before['HostConfig'].get('PortBindings',{}).get('4440/tcp') != [{'HostIp':'127.0.0.1','HostPort':'14440'}]: + parser.error('Expected the original loopback preview; do not repeat this migration') + if inspect(OLD) or inspect('adamwen-test-console-proxy'): + parser.error('Cutover containers already exist; inspect the saved outcome before retrying') + if inspect(IMAGE) is None: + parser.error('Build the new project image before cutover') + backup = runtime / 'backups/before-lan-20261013' + if not (backup/'source/deploy/start.py').is_file() or backup.is_symlink(): + parser.error('The source snapshot must already exist inside this project') + archive = backup/'runtime-before-lan.tar.gz' + if archive.exists(): + parser.error('A data snapshot already exists; refusing to overwrite it') + active = run(['docker','exec',CONTAINER,'python3','-c', + "from pathlib import Path;import os;print(len([p for p in Path('/proc').glob('[0-9]*/cmdline') if p.parent.name != str(os.getpid()) and any(x in p.read_bytes().split(b'\\x00') for x in (b'/opt/xas-test-library/scripts/run_smoke.py',b'/opt/xas-test-library/scripts/smoke_task.py'))]))"]) + if active != '0': + parser.error('An acceptance task is still running; wait for it to finish') + for name in ('configure-login.sh','set_login.py'): + current, saved = project/'deploy'/name, backup/'source/deploy'/name + if current.exists() and hashlib.sha256(current.read_bytes()).digest() != hashlib.sha256(saved.read_bytes()).digest(): + parser.error('A legacy source file has changed since backup; preserve the user change') + print(run(['docker','stop','--timeout','30',CONTAINER]),flush=True) + try: + with tarfile.open(archive,'x:gz') as tar: + for name in ('data','logs','projects','results','auth'): + source = runtime/name + if source.exists(): + tar.add(source,arcname=name,recursive=True) + archive.chmod(0o600) + metadata = {'container_id':before['Id'],'image_id':before['Image'], + 'started_at':before['State']['StartedAt'],'backup_container':OLD, + 'database_snapshot':archive.name} + (backup/'metadata.json').write_text(json.dumps(metadata,indent=2),encoding='utf-8') + print(run(['docker','rename',CONTAINER,OLD]),flush=True) + except Exception: + run(['docker','start',CONTAINER]) + raise + # The two removed files are this project's reviewed password provisioning implementation. + for name in ('configure-login.sh','set_login.py'): + path=project/'deploy'/name + if path.exists(): + path.unlink() + result = subprocess.run(['python3',str(project/'deploy/start.py'),'--project-root',str(project)]) + if result.returncode: + raise SystemExit('New stack was not fully started. Old container and data snapshot are preserved; inspect before rollback.') + print(json.dumps({'cutover':'started','backup':str(backup),'previous_container':OLD})) + + +if __name__ == '__main__': + main() diff --git a/deploy/native_profiles.py b/deploy/native_profiles.py new file mode 100644 index 0000000..a9193fb --- /dev/null +++ b/deploy/native_profiles.py @@ -0,0 +1,83 @@ +"""Auditable, additive configuration migration; no source/history/schema changes.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from console.native_plans import profile_for, validate_config +from console.settings import Settings +from console.store import Store, now + + +def migrate(store, backup, apply=False): + changes = [] + for item in store.scripts(): + profile = profile_for(store.settings, item) + if not profile: + continue + if hashlib.sha256(Path(item["source_path"]).read_bytes()).hexdigest() != item["sha256"]: + raise ValueError("Source changed: " + profile["id"]) + updated = {**item} + updated["config"] = validate_config(store.settings, item, + {"timeout_seconds": profile.get("timeout_seconds", 3600), "native_options": item["config"].get("native_options", {})}) + available = profile.get("available", True) + updated.update(enabled=available, blocked=not available, + status="ready" if available else "needs_configuration", + reason="" if available else profile.get("unavailable_reason", "原环境不可用")) + if all(updated[k] == item.get(k) for k in ("config", "enabled", "blocked", "status", "reason")): + continue + updated["updated_at"] = now() + changes.append((item, updated)) + if len({profile_for(store.settings, item)["id"] for item in store.scripts() if profile_for(store.settings, item)}) != len(store.settings.native_catalog["profiles"]): + raise ValueError("Registered sources do not match trusted profiles") + if not apply: + return {"pending": len(changes), "applied": False} + if not changes: + return {"pending": 0, "applied": True} + with store.connect(write=True) as db: + if db.execute("SELECT 1 FROM runs WHERE state IN ('queued','running')").fetchone(): + raise ValueError("Drain active runs before migrating") + saved = [] + for old, updated in changes: + raw = db.execute("SELECT data FROM scripts WHERE id=?", (old["id"],)).fetchone()[0] + if json.loads(raw) != old: + raise ValueError("Concurrent configuration change; migration aborted") + saved.append({"id": old["id"], "before": raw, "after": json.dumps(updated)}) + backup.parent.mkdir(parents=True, exist_ok=True) + with backup.open("x", encoding="utf-8") as handle: + json.dump({"created_at": now(), "scripts": saved}, handle, ensure_ascii=False, indent=2) + backup.chmod(0o600) + for item in saved: + db.execute("UPDATE scripts SET data=? WHERE id=?", (item["after"], item["id"])) + return {"pending": len(changes), "applied": True, "backup": str(backup)} + + +def restore(store, backup): + saved = json.loads(backup.read_text(encoding="utf-8"))["scripts"] + with store.connect(write=True) as db: + if db.execute("SELECT 1 FROM runs WHERE state IN ('queued','running')").fetchone(): + raise ValueError("Drain active runs before rollback") + for item in saved: + current = db.execute("SELECT data FROM scripts WHERE id=?", (item["id"],)).fetchone()[0] + if current not in (item["before"], item["after"]): + raise ValueError("Configuration changed after migration; review before rollback") + for item in saved: + db.execute("UPDATE scripts SET data=? WHERE id=?", (item["before"], item["id"])) + return {"restored": len(saved), "history_preserved": True} + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--apply", action="store_true") + parser.add_argument("--restore", action="store_true") + parser.add_argument("--backup", type=Path, required=True) + args = parser.parse_args() + if os.geteuid() != 0: + raise SystemExit("Run using the documented sudo flow") + if args.apply and args.restore: + parser.error("Choose apply or restore") + store = Store(Settings.from_env()) + print(json.dumps(restore(store, args.backup) if args.restore else migrate(store, args.backup, args.apply))) diff --git a/deploy/operator.aclpolicy b/deploy/operator.aclpolicy new file mode 100644 index 0000000..d517fe3 --- /dev/null +++ b/deploy/operator.aclpolicy @@ -0,0 +1,30 @@ +description: Operators may view and execute registered acceptance jobs only +context: + project: xas-test-library +for: + resource: + - equals: + kind: job + allow: [read] + - equals: + kind: event + allow: [read] + job: + - equals: + group: 00-platform-acceptance + allow: [read, run, kill] + node: + - allow: [read, run] +by: + group: xas-operator +--- +description: Operators may see this project, but not create projects or commands +context: + application: rundeck +for: + project: + - equals: + name: xas-test-library + allow: [read] +by: + group: xas-operator diff --git a/deploy/realm.disabled.properties b/deploy/realm.disabled.properties new file mode 100644 index 0000000..e9e0620 --- /dev/null +++ b/deploy/realm.disabled.properties @@ -0,0 +1 @@ +# Password login is disabled. The private backend accepts the proxy's fixed operator identity. diff --git a/deploy/start.py b/deploy/start.py new file mode 100644 index 0000000..62f2b5d --- /dev/null +++ b/deploy/start.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Start only this project's CPU-only Rundeck container (run via normal sudo).""" +import argparse +import json +import os +from pathlib import Path +import socket +import subprocess +import sys + +CONTAINER = "adamwen-test-console" +IMAGE = "adamwen/test-console:20261013-2" + + +def validate_runtime(project): + project = project.resolve(strict=True) + runtime = project / "runtime" + candidates = [runtime] + [runtime / name for name in ("auth", "data", "logs", "projects", "results", "proxy")] + for path in candidates: + if path.is_symlink() or not path.resolve().is_relative_to(project): + raise ValueError("Runtime directories must remain inside this project without symlinks") + if path.exists() and not path.is_dir(): + raise ValueError("Runtime paths must be directories") + return runtime + + +def main(): + from lan_config import load_settings, proxy_config, PROXY_IMAGE, NETWORK, PROXY + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--project-root", type=Path, required=True) + args = parser.parse_args() + project = args.project_root.resolve(strict=True) + try: + runtime = validate_runtime(project) + except ValueError as exc: + parser.error(str(exc)) + settings = load_settings(project / "deploy/lan-settings.json") + for name in (CONTAINER, PROXY): + existing = subprocess.run(["docker", "container", "inspect", name], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if existing.returncode == 0: + parser.error(f"{name} already exists; inspect it instead of replacing it") + with socket.socket() as probe: + probe.bind((settings['address'], settings['port'])) + network = subprocess.run(["docker", "network", "inspect", NETWORK], capture_output=True, text=True) + if network.returncode == 0: + if json.loads(network.stdout)[0].get('Labels', {}).get('cn.aisuan.owner') != CONTAINER: + parser.error('Existing network does not belong to this project') + else: + subprocess.run(['docker','network','create','--label',f'cn.aisuan.owner={CONTAINER}',NETWORK],check=True) + for name in ("data", "logs", "projects", "results", "proxy"): + path = runtime / name + if path.is_symlink(): + parser.error("Symlink runtime directories are forbidden") + path.mkdir(parents=True, exist_ok=True, mode=0o700) + os.chown(path, 1000, 0) + os.chmod(path, 0o700) + config = runtime / 'proxy/nginx.conf' + if config.is_symlink(): + parser.error('Proxy config must not be a symlink') + config.write_text(proxy_config(settings), encoding='utf-8') + os.chmod(config, 0o644) + url = f"http://{settings['address']}:{settings['port']}" + command = ["docker", "run", "-d", "--name", CONTAINER, + "--label", "cn.aisuan.owner=adamwen-test-console", + "--runtime", "runc", "--user", "1000:0", "--init", + "--cpus", "2", "--memory", "2g", "--memory-swap", "2g", + "--pids-limit", "256", "--cap-drop", "ALL", + "--security-opt", "no-new-privileges:true", + "--restart", "no", "--network", f"container:{PROXY}", + "--log-opt", "max-size=10m", "--log-opt", "max-file=3", + "-e", f"RUNDECK_GRAILS_URL={url}", + "-e", "RUNDECK_SERVER_ADDRESS=127.0.0.1", + "-e", "RUNDECK_SERVER_FORWARDED=true", + "-e", "RUNDECK_PREAUTH_ENABLED=true", + "-e", "RUNDECK_PREAUTH_ATTRIBUTE_NAME=REMOTE_USER_GROUPS", + "-e", "RUNDECK_PREAUTH_USERNAME_HEADER=X-Forwarded-Uuid", + "-e", "RUNDECK_PREAUTH_ROLES_HEADER=X-Forwarded-Roles", + "-e", "RUNDECK_SERVER_SESSION_TIMEOUT=1800", + "-e", "RUNDECK_QUARTZ_THREADPOOL_THREADCOUNT=2", + "-e", "JVM_MAX_RAM_PERCENTAGE=60", + "-e", "NVIDIA_VISIBLE_DEVICES=void", + "-e", "CUDA_VISIBLE_DEVICES="] + mounts = [(project / 'deploy/realm.disabled.properties', "/home/rundeck/secure/realm.properties", "ro"), + (runtime / "data", "/home/rundeck/server/data", "rw"), + (runtime / "logs", "/home/rundeck/var/logs", "rw"), + (runtime / "projects", "/home/rundeck/projects", "rw"), + (runtime / "results", "/home/rundeck/test-results", "rw"), + (project / "deploy" / "operator.aclpolicy", "/home/rundeck/etc/xas-operator.aclpolicy", "ro"), + (project / "deploy" / "jaas-loginmodule.conf.template", "/etc/remco/templates/jaas-loginmodule.conf", "ro")] + for source, destination, access in mounts: + command += ["--mount", f"type=bind,src={source},dst={destination}" + (",readonly" if access == "ro" else "")] + command.append(IMAGE) + proxy_base = ['docker','run','--runtime','runc','--network',NETWORK,'--user','101:101', + '--cpus','0.25','--memory','64m','--pids-limit','32','--cap-drop','ALL', + '--security-opt','no-new-privileges:true','--read-only', + '--tmpfs','/tmp:rw,noexec,nosuid,size=16m', + '--mount',f'type=bind,src={config},dst=/etc/nginx/nginx.conf,readonly', + '--entrypoint','nginx'] + subprocess.run(proxy_base + ['--rm',PROXY_IMAGE,'-t'],check=True) + subprocess.run(proxy_base + ['-d','--name',PROXY,'--label',f'cn.aisuan.owner={CONTAINER}', + '--publish',f"{settings['address']}:{settings['port']}:8080", + '--restart','no','--log-opt','max-size=5m','--log-opt','max-file=2', + PROXY_IMAGE,'-g','daemon off;'],check=True) + # Namespace loopback cannot be reached via the container IP from the shared host. + subprocess.run(command, check=True) + print(json.dumps({"container":CONTAINER,"proxy":PROXY,"url":url,"allowed_cidrs":settings['allowed_cidrs'],"gpu":False})) + + +if __name__ == "__main__": + main() diff --git a/docs/acceptance-2026-10-13.md b/docs/acceptance-2026-10-13.md new file mode 100644 index 0000000..13fd2b8 --- /dev/null +++ b/docs/acceptance-2026-10-13.md @@ -0,0 +1,81 @@ +# 第一阶段验收记录 + +此文为最初回环预览的历史快照;用户后续免登录要求的当前状态见 [内网验收](acceptance-lan-2026-10-13.md)。 + +核对日期:2026-10-13(北京时间)。结论:受限网页预览与轻量流程验证通过;正式 GPU 测试尚未接入。 + +## 本轮变更 + +- 新建本地私有脚本库工作目录及空 Git 仓库,所有文件未暂存;提交数为 0,无远程仓库。 +- 新建服务器目录 `/adamwen-work-space/xas-test-library` 和容器 `adamwen-test-console`。 +- 网页使用 Rundeck 6.2.1,基础镜像摘要 `sha256:63d12aefbbd129a121d2af7d97abfaa64c3cbd7756063f74dbf3ab3377d136bd`。 +- 运行镜像 `adamwen/test-console:20261013-1`,镜像 ID `sha256:429dfa1322c8e72b25fb82d5a1a6452af4968f30f5dbb6ee6937825aadd38ab7`。 +- 镜像增加 Python 3.10.6-1~22.04.1、python3-bcrypt 3.2.0-1build1,宿主机运行时未升级。 +- 只导入一个轻量任务,UUID 为 `ef76123d-f532-42c7-b7f7-a78f9231c18d`。旧公司套件与两个试验目录只登记来源。 + +## 行为测试 + +| 验证环境 | 本轮最终结果 | +|---|---| +| 本地 Windows | 10 项中 6 通过、4 跳过;跳过原因是 Linux 进程组及当前账号无法创建目录链接 | +| 00088 Linux | 10/10 通过,耗时 4.087 秒,无跳过 | + +覆盖:成功日志和模拟结果、脚本非零退出、退出 0 但报告无效、重复编号不覆盖、路径穿越/无效参数拒绝、超时清理、取消父子进程、运行根目录和子目录链接拒绝。 + +新增代码先观察失败测试,再完成实现;独立审查指出的运行目录父链接问题已补回归测试并修复。修复后的全套 Linux 测试通过。没有未处理的审查发现。 + +## Rundeck 真实任务链路 + +| 执行编号 | 场景 | Rundeck 状态 | 业务状态 | 清理确认 | +|---|---|---|---|---| +| 1 | 成功 | succeeded | succeeded | true | +| 2 | 主动失败 | failed | failed | true | +| 3 | 无效报告 | failed | invalid_result | true | +| 4 | 2 秒超时 | failed | timed_out | true | +| 5 | 运行后取消 | aborted | cancelled | true | +| 6 | 浏览器点击运行 | succeeded | succeeded | true | + +前五项在 11:14:36 至 11:14:43 完成。第六项在 11:19:28 至 11:19:29 完成,运行器记录约 0.64 秒。以上均为 synthetic 流程验收数据,没有使用 GPU 或模型接口。 + +未登录 API 返回 403;普通操作角色执行任意命令返回 403;同一任务正在执行时再次提交返回 409。 + +## 真实浏览器 + +使用独立 Edge 浏览器上下文,通过 SSH 转发登录普通操作账号,核对任务列表和参数页;点击 Run Job Now 产生执行 6;核对完成页、三条进度日志和结构化结果。 + +页面的 Succeeded 文案由 CSS `::after` 生成,最初按 DOM 文字精确定位的自动等待超时。服务端记录与截图均成功,后续只读取已有执行 6,确认伪元素内容为 `"Succeeded"` 并打开日志页,没有因文字定位问题重复提交任务。 + +截图:[任务参数页](images/job.png)、[执行日志页](images/logs.png)。视口为 1440 × 1000。 + +## 共享服务与隔离证据 + +- 原有 `dengyuyun-joy-dsv41-trial` 容器 ID 为 `114ceebc787845e45e1abcb0a46ac962529c28a4b865894cd3581234cd729e1a`,仍为 running,启动时间为 2026-10-12 13:29:36(北京时间),RestartCount=0。它的启动时间早于本轮操作,未被本轮重启。 +- GPU 查询仍看到 VLLM::Worker_TP0~TP7;没有创建新的 GPU 任务。本轮没有调用模型接口,不能把进程存活当作模型业务质量验证。 +- 新容器 DeviceRequests=null、Devices=[]、Privileged=false,容器内 `/dev/nvidia*` 为空。 +- 所有绑定挂载都在本项目目录下;没有 `/dengyuyun-joy-work-space`、共享模型或 Docker socket 挂载。 +- 只发布 `127.0.0.1:14440 → 4440`,未改防火墙和旧端口。 +- 配置上限为 2 CPU、2 GiB 内存、256 进程。检查时新容器 CPU 约 0.06%、内存约 1.275 GiB;这是观察值,不是持续性能承诺。 + +## 过程中的问题与修复 + +- 首次启动登录页早于 API 就绪;将准备检查改为 API 的未认证响应,避免把加载页 HTTP 200 当作可用。 +- 权限验收请求最初缺少 project 参数,返回 400;补齐官方 API 参数后得到明确 403。 +- YAML 的固定标识改用 uuid,并与实际已创建任务对齐,保留同一任务;原 404 请求没有创建运行记录。 +- 浏览器输出使用 UTF-8;状态文字采用实际 CSS 和截图证据核验。 +- 独立审查发现运行目录父链接可跨出项目;已在任何 mkdir/chown/chmod 前核验完整运行路径。 + +## 交付边界 + +用户仍需通过 README 中的交互式工具设置自己的网页登录密码,明文不进入聊天、源码或参数。临时验收账号在交付收尾时撤销。 + +当前为单实例 H2 预览,未配置组织 SSO、正式数据库、自动备份、系统自启或公司网访问入口。运行日志与结果尚无自动清理;没有新增定时任务。 + +长时 GPU 压测、模型性能/质量、正式脚本停止语义与多人资源调度均未验证。公司仓库尚未建立,因此远程 Git 集成未验证。 + +恢复时只操作本项目容器及目录;保留数据库和结果。版本回退不自动恢复数据库,后续升级需另行备份并核验数据兼容性。 + +## 核对过的官方资料 + +- [Rundeck Docker 配置](https://docs.rundeck.com/docs/administration/configuration/docker.html) +- [认证和可重载密码文件](https://docs.rundeck.com/docs/administration/security/authentication.html) +- [API:导入、执行与取消](https://docs.rundeck.com/docs/api/) diff --git a/docs/acceptance-behavior-catalog-2026-10-13.md b/docs/acceptance-behavior-catalog-2026-10-13.md new file mode 100644 index 0000000..5efc084 --- /dev/null +++ b/docs/acceptance-behavior-catalog-2026-10-13.md @@ -0,0 +1,35 @@ +# 按测试行为整理目录:验收记录 + +核对日期:2026-10-13 19:06(北京时间)。用户要求“一项测试为一个项目”,不把套件内部文件平铺。当前规则见 [ADR 0003](adr/0003-test-behavior-catalog.md)。 + +## 实际结果 + +- 正式地址仍为 `http://10.88.118.188:14440/#/scripts`。主列表 23 项业务测试,包含 8 项套件和 15 项单项测试。 +- GPU 综合验收套件只显示一项,详情关联 224 个文件,其内部 148 个验证文件不再作为独立项目。 +- 套件和同类测试的辅助模块、素材生成脚本及相关实现归入详情“相关文件”。文件可搜索和只读查看;选择文件不会变更执行入口。 +- 两个控制台验收样例默认隐藏,可在“项目用途 → 验收示例”查看。已接入的轻量检查保持可运行,其他业务测试仍待配置。 +- 扫描文件只更新文件清单,不自动生成测试卡片。测试入口由 `catalog/test-definitions.json` 明确登记;新上传的单入口脚本作为新测试。 + +## 本轮验证 + +隔离 Linux 环境 **37/37** 回归检查通过,其中新增 6 项覆盖:套件合并、刷新不提升辅助文件、内部文件服务端执行限制、上传与示例筛选、历史保留、测试名称与唯一入口排队。原有配置、日志、结果、版本和执行器回归一并通过。 + +真实浏览器在数据库副本预览与正式地址均完成只读验收:主列表 23 项,套件详情 224 个文件,源码只读,内部文件详情没有运行按钮,直接运行 API 返回 400,示例筛选 2 项,10 条历史记录保留。桌面和 390px 视口通过,浏览器无 JavaScript 错误。验收发现长路径卡片在小屏撑宽,已用网格子项 min-width 修复并复查。 + +本轮没有执行任何服务器测试脚本或调用实际模型。测试中的本机假上游与临时子进程只用于隔离回归,不是业务测试运行。 + +## 数据及共享资源 + +更新前后 scripts、versions、runs 三张表的完整数据摘要一致;SQLite 结构仍为 schema v1,没有结构迁移、回填、删除或历史重写。307 个原位置文件的 SHA-256 全部一致。 + +私有执行器进程 MainPID 仍为 2086920,没有重启;模型容器 ID 和 StartedAt 与更新前一致,没有重启或更改配置。只替换了本项目网页容器。正式地址执行器在线、活动任务 0、历史记录 10;目录预览容器已移除。 + +## 发布、回退和改动文件 + +正式镜像为 `adamwen/script-console:20261013-2`,摘要 `sha256:e163e0dcf1bc7bff05d0f42cb495732de6e7d97a8950f6ad64a7e6ccc40641f1`。切换前的网页容器停止保留为 `adamwen-script-console-before-catalog-20261013`。 + +备份位于 `runtime/backups/before-behavior-catalog-20261013/`,包括本轮改动前源码、SQLite 快照、原文件摘要、原网页/模型状态与执行器 PID。应用回退只需在无活动任务时停止并保留新网页,恢复旧网页容器原名后启动;旧镜像兼容同一 schema v1,无须覆盖数据库。恢复源码时逐文件核对后续改动,不能用整目录覆盖;数据快照恢复须另行确认,不自动丢弃新记录。故障回退路径未实际触发。 + +主要修改:`console/test_catalog.py`、`console/app.py`、`console/settings.py`、`console/static/` 相关页面、`catalog/test-definitions.json`、`tests/test_test_catalog.py`、既有测试入口夹具、部署镜像引用和维护文档。没有 Git 暂存、提交或推送。 + +脱敏证据:服务器 `runtime/behavior-catalog-acceptance.json`;本地 `.local/evidence/behavior-catalog-browser.json` 与 `.local/screenshots/behavior-*.png`。旧部署验收记录保留其原始时点,不作为本轮状态。 diff --git a/docs/acceptance-dedicated-2026-10-13.md b/docs/acceptance-dedicated-2026-10-13.md new file mode 100644 index 0000000..6725afe --- /dev/null +++ b/docs/acceptance-dedicated-2026-10-13.md @@ -0,0 +1,64 @@ +# 专用控制台验收记录 + +核对日期:2026-10-13;正式入口切换时间 17:32(北京时间),中断后再次核对时间 17:59。以下是本轮实际执行证据,不代表所有原有脚本或 GPU 测试已验收。 + +## 已交付 + +正式入口为 `http://10.88.118.188:14440/`,公司内网免登录。旧 `/project/xas-test-library/nodes` 等项目书签自动转到新脚本库。14442 预览已停止,旧 Rundeck 网页和代理容器停止保留,原数据没有删除或迁移覆盖。 + +支持脚本搜索/筛选、只读源码、上传新脚本及新版本、元数据与运行配置、单并发队列、实时标准输出/错误和 `.log`、取消、历史记录、结果预览/下载。排队记录固定版本和配置,历史上传版本不可覆盖。 + +当前共 310 项:公司套件 224 项、DeepSeek 试验目录 35 项、模型试验目录 48 项、托管脚本 3 项。307 个原位置文件包括执行入口和辅助模块,只登记,不批量复制。可运行的 3 项为复制的 DeepSeek 轻量检查、快速流程样例和网页上传演示;原位置条目仍待逐项配置。 + +## 自动检查与浏览器 + +- 最终候选版本在隔离 Linux 容器执行 **31/31** 项检查通过,包括历史运行器回归、上传版本、路径/链接限制、请求幂等、日志 UTF-8 分块、文件日志尾段、模型代理预算/禁止重定向、清理失败时记录与摘要一致性。 +- 本地 Windows 对相关检查通过;Linux 专属符号链接与 worker/进程检查在 Linux 补齐,未把平台跳过当成通过。 +- 真实浏览器完成上传、新版本、版本切换、只读源码、运行、日志、JSON 预览;390px 视口没有横向溢出。最终入口验证无浏览器 JavaScript 错误。 +- 错误 Origin 和缺少修改请求专用头均返回 403;生产不信任转发 IP 或身份头。 +- SQLite `PRAGMA integrity_check` 返回 `ok`。17:59 执行器在线,活动任务 0,本项目临时容器 0、tmpfs 挂载残留 0,共保留 10 条运行记录。 + +## 现场执行记录 + +| 场景 | 运行编号 | 实际结果 | +|---|---|---| +| 最初容器启动验证 | 45def157885d4228bb9c0fc8480dda00 | Docker 日志轮转 max-file=1 与压缩不兼容,脚本未启动;改为 2 后验证通过。保留失败记录。 | +| 快速模拟成功 | 13caa662bf10489698b3d1bf4b567021 | succeeded,日志及 result.json 可见 | +| 脚本主动失败 | 794e00e7dc35437ba29191c229db595b | failed,退出码 2,符合预期 | +| 5 秒超时 | 3716cec9637143048220c6fe7b16abab | timed_out,测试容器清理 | +| 运行中取消 | 92b38203e0cd42e3a585c8eb4f09c614 | cancelled | +| 排队取消 | c2eafe168c09444e96f800fd1094e3e1 | cancelled,未启动脚本 | +| 网页上传版本 2 | 8607ca8f5511498ca041b65ebfd4223d | succeeded,标准输出、application.log、result.json 可见;仅模拟数据 | +| worker 意外退出 | bffd569a881d43d5a2e87fdef04c40b6 | interrupted;恢复后无任务容器/挂载残留,未补跑 | +| 原始轻量模型检查 | fddc3dd84d36403cb8083c3303220ce7 | succeeded,真实请求见下 | +| 正式入口最终快速验证 | 76478f6925d34c4eb3d3b613c558946a | succeeded,运行状态与下载摘要一致 | + +失败、超时、取消和中断记录是有意验证的不同路径,除了最初已修复的容器配置错误,不应当作未解决的产品故障,也不作为模型质量统计。 + +## 一轮真实模型检查 + +使用复制的原始 smoke.sh,SHA-256 为 `be858e914d87454240791da773578cb3f80f380e740f919546db347b7715a13d`。原位置文件、本地副本与运行摘要相同。 + +一次模型列表 GET 和三个顺序问答 POST 全部 HTTP 200,没有重试或重定向。三个回答 `finish_reason=stop`,completion_tokens 分别为 99、100、300(合计 499);每次仍保留原脚本 max_tokens=512。算术问题返回 97,406,784,日志和完整响应报告 `smoke-response.json` 已在控制台查看。 + +本次只证明接口与控制台执行/记录链路可用,不是全面的回答质量、代码正确性、多卡性能或并发压力验收。没有执行模型返回的 Fibonacci 代码。 + +## 共享资源与恢复 + +`dengyuyun-joy-dsv41-trial` 的容器 ID 始终为 `114ceebc787845e45e1abcb0a46ac962529c28a4b865894cd3581234cd729e1a`,StartedAt 仍为 `2026-10-12T05:29:36.607636623Z`、RestartCount=0。未改原脚本、原目录权限、模型服务配置或 GPU 分配;仅向已运行的模型发出上述少量推理请求。 + +17:59 GPU 0–7 仍由既有模型占用,GPU 8–15 显存为 0;GPU 0–7 的显存快照相较 17:35 有变化,不能据此推断原因或声称整个共享服务器状态没有变化。本项目没有残留测试进程容器或额外模型服务。 + +正式网页镜像 `adamwen/script-console:20261013-1`,当前摘要 `sha256:7ba1cd10360271fad99975b599264a0c8a1d1458ae23a7b0f44387079aef9c3d`。基础 Python 镜像固定摘要,依赖版本固定。网页没有 Docker socket/GPU,根文件系统只读、capabilities 全部移除。 + +切换前已保存 `runtime/backups/before-dedicated-20261013/`,包含 SQLite 完整性检查通过的快照、library、runs 和切换元数据。旧容器和预览容器停止保留。实际故障回退/数据恢复未演练;操作步骤及必须保留快照后数据的限制见 [console-operations.md](console-operations.md)。 + +尚未接入多 GPU 分配、模型服务启停、通用依赖环境、更多原输出目录、个人账号、开机自启或自动清理。普通新上传脚本支持当前标准环境;原位置脚本需要确认配置和依赖后逐项启用。 + +## 改动与证据位置 + +主要改动为 `console/`、`tests/test_console*.py`、`deploy/console*`、`deploy/dedicated.py`、`deploy/cutover_console.py`、`scripts/console_example.py`、目录配置、README 和当前设计/操作文档。所有手写新增源码文件不超过 500 行。 + +本地浏览器证据保存在 `.local/evidence/console-browser.json`、`.local/evidence/final-console-browser.json` 和 `.local/screenshots/`;模型运行与幂等请求编号保存在 `.local/evidence/model-smoke-request.json`。服务器脱敏收尾证据为 `runtime/dedicated-acceptance.json`。这些运行材料均不进入 Git。 + +本项目仍没有提交历史,所有源代码未暂存、未提交、未推送。Git 仅配置了公司 origin,不自动拉取或部署。 diff --git a/docs/acceptance-lan-2026-10-13.md b/docs/acceptance-lan-2026-10-13.md new file mode 100644 index 0000000..f391f58 --- /dev/null +++ b/docs/acceptance-lan-2026-10-13.md @@ -0,0 +1,51 @@ +# 内网免登录验收 + +核对日期:2026-10-13(北京时间)。本记录对应用户最新的公司内网免登录要求,替代最初的回环网页登录方式。 + +## 结果 + +- 入口:http://10.88.118.188:14440/project/xas-test-library/jobs ,全新浏览器无需登录。 +- 允许网段为 10.88.0.0/17 和 10.20.0.0/21,来自服务器网卡与当前电脑路由现场核对。 +- 访客可读取本项目、运行/停止登记任务及查看历史。 +- 伪造管理员身份头、跨站 Origin/Fetch-Site、错误 Host 和令牌参数均返回 403。 +- 修改同一任务定义得到 Unauthorized,没有成功更新。 +- 宿主机直连容器 IP:4440 失败;后端没有宿主机端口映射。 +- 来自 Docker 网桥的请求即使伪造办公网 X-Forwarded-For,仍被代理拒绝 403。 + +## 行为与浏览器 + +Linux 完整回归 14/14 通过,4.101 秒;Windows 10 通过、4 项平台相关跳过。 + +真实内网浏览器点击运行创建执行 7,身份为 xas-intranet,于北京时间 12:11:21~12:11:23 成功完成。浏览器核对 Succeeded 状态、三条进度日志及结构化结果。历史 API 返回 7 条记录,原 1~6 记录保留。未调用模型或使用 GPU。 + +截图:[免登录任务页](images/lan-jobs.png)、[执行日志](images/lan-logs.png)。 + +浏览器辅助 API 客户端携带会话 Cookie 时得到 404,而无 Cookie、明确 JSON 响应的直接查询返回真实记录。生命周期通过独立 HTTP 查询核验,浏览器核验真实页面、CSS 状态和日志,没有因验收工具问题重复提交任务。 + +## 隔离与原服务 + +12:11 只读核对:原 dengyuyun-joy-dsv41-trial 容器 ID、2026-10-12 的启动时间和 RestartCount=0 均不变;VLLM::Worker_TP0~TP7 的 PID 仍为 1578853~1578860。未修改其工作区、模型、容器或 GPU 任务。 + +后端镜像 ID:sha256:6b694273a33c449829f7dc5a777cb2b09c2df630c70035c45956673fa35953d9。 +代理摘要:sha256:0985e772fb9f729e6fa0980da05fca5d9c468e870eed43071545afa9d2e27d94,实际 nginx/1.30.5。 + +后端上限 2 CPU/2 GiB,无 GPU;代理上限 0.25 CPU/64 MiB。只对指定内网 IP 发布 14440,没有修改系统防火墙。 + +## 审查与修复 + +独立审查指出后端不发布端口仍能被宿主机直连伪造角色。新增 Docker 边界回归先失败,再改成共享命名空间和后端回环;现场 TCP 检查确认直连被阻断。 + +首次代理检查因默认临时目录与只读根文件系统冲突失败,指定 tmpfs 临时路径后 nginx -t 通过。当前电脑来源 10.20.1.3 最初被服务器网段名单拒绝,核对本地 /21 路由后加入对应办公网。历史所需 event/read 权限经 403→200 验证补齐,未增加管理权限。 + +## 备份与恢复 + +备份目录:/adamwen-work-space/xas-test-library/runtime/backups/before-lan-20261013/。 + +- source/:切换前源码与配置。 +- runtime-before-lan.tar.gz:旧预览停止后生成的数据库、日志、结果与配置快照。 +- metadata.json:旧容器、镜像与快照关联信息。 +- 旧容器 adamwen-test-console-before-lan-20261013 保持停止状态。 + +应用回退只停止本项目新后端与代理,保留当前数据,再恢复旧配置和旧容器名称。两版均为 Rundeck 6.2.1,本轮未改变数据库格式。恢复旧数据库快照需要单独确认,会移除快照之后的记录,不能默认执行。 + +未配置自启、定时备份或自动清理。正式 GPU 长测尚未接入。公司远程 Git 仓库待后续接入,未提交或推送。 diff --git a/docs/acceptance-native-2026-10-13.md b/docs/acceptance-native-2026-10-13.md new file mode 100644 index 0000000..4fb453e --- /dev/null +++ b/docs/acceptance-native-2026-10-13.md @@ -0,0 +1,68 @@ +# 原环境执行接入验收 + +核对日期:2026-10-13 23:37(北京时间)。用户已授权开放原测试,包括显示影响并逐次确认的资源/服务操作;本轮验收没有实际启动原 GPU、模型切换或长稳测试。 + +## 已交付行为 + +- 正式入口仍为 `http://10.88.118.188:14440/#/scripts`。23 项业务测试中,22 项原位置入口使用登记的原目录、解释器、配置、依赖和参数,复制版轻量检查保持既有容器模式。 +- 详情显示只读执行方案、账号、目录、解释器和影响;只开放方案声明的参数及最长 72 小时时限。原入口和相关文件只读,上传脚本继续容器隔离。 +- 普通原环境测试预检通过后排队;资源/服务测试必须每次确认。源码、依赖配置、方案或参数变化时旧确认失效。预检只检查路径、工具、摘要和本机 TCP 端口,不发推理、不启动模型、不预留 GPU。 +- 独立 systemd 服务运行原入口,页面接收标准输出、错误、登记的文件日志和归档结果。菜单支持数字选项/列表、y/n/q、回车,关闭回显,不保存输入历史。 +- worker 重启后继续跟踪同一次原环境任务;取消和超时等待本次本机进程收尾。Docker/tmux 分离任务和模型恢复需根据日志及资源变化核对,控制台不自动删除他人资源。 + +## 验证证据 + +| 验证层级 | 本轮结果 | +|---|---| +| Linux 自动回归 | 55/55 通过,包含参数/来源限制、确认失效、取消竞态、状态未知保留占位、凭据跨块/ANSI 脱敏、控制终端、300 个旧日志不遮蔽新日志 | +| 真实 systemd 短进程 | 6/6 通过:成功、失败、超时、取消、交互、worker 重关联;仅独立样例,不调用模型/GPU | +| 配置迁移 | 生产库隔离副本迁移 22 项、再次迁移幂等、逐条恢复后内容完全一致;runs/versions 不变 | +| 已部署 API | 22 项原环境预检可调用;23 项业务目录、10 条旧运行、0 活动任务,worker 在线 | +| 真实浏览器 | 1440px/390px 方案显示、72 小时上限、只读目录、逐次确认/取消、缺失端口拦截、返回及历史记录通过;无 JS 错误、无横向溢出、运行提交 0 次 | +| 原项目保护 | 307 个原脚本 SHA-256 与升级前一致,10 条 runs 完整摘要一致;原模型容器 ID/StartedAt 不变、RestartCount=0 | + +测试依赖产生既有 Starlette/httpx 弃用提示,不影响通过结果;本轮未升级依赖。 + +最终短进程证据在服务器 `runtime/native-final-validation-20261013/acceptance.json`,隔离于生产运行记录: + +| 场景 | 运行编号 | 结果 | +|---|---|---| +| 正常退出 | eae879ee835c4b99948b48fdbeaa28da | succeeded / 0 | +| 非零退出 | 6abb4c53215f4dcfa221aee43b4c4e0d | failed / 7 | +| 时限到期 | e6e5142ccb7c411d9d6cfb34d21fb298 | timed_out / -15 | +| 主动取消 | f5413e5e37144eb0bbfaf2651768273c | cancelled / -15 | +| 菜单与控制终端 | f3dd72ef551e4f6c933a0e4af2f0d4fd | succeeded / 0 | +| 同一服务重新跟踪 | bdb9fe7cd24a457aa79c2e7732778964 | succeeded / 0,未重放 | + +已核验超时/取消子进程消失、假凭据不出现在平台日志及归档、菜单输入不回显、实际命令无运行编号占位、原样例文件摘要不变。完整模型业务的指标、质量及长稳效果仍未验收,页面保留提示。 + +## 原模型服务条件 + +23:27 的只读预检,22 项中 16 项满足登记条件;以下 6 项的原服务未监听,自动拦截。此快照不是永久可用性保证,真正点击运行时重新预检。 + +| 方案编号 | 缺失原端口 | +|---|---| +| deepseek-suite | 18101 | +| throughput | 18106 | +| replicas | 18110–18117 | +| engine-comparison | 18106 | +| model-suite | 18106 | +| soak | 18106 | + +这些入口已完成方案接入;准备好对应原服务后才能执行。未改为调用其他端口,也未擅自补启动命令。 + +## 发布与恢复 + +最终网页镜像 `adamwen/script-console:20261013-5`,摘要 `sha256:05ad7a7a332a1e26eaeebff790e53c16318e547d3ad936e67aee6ff11acd1710`。仅更新本项目网页和空闲 worker,访问网段、无账号安排、模型服务与 GPU 分配未改变。原环境宿主代码与网页方案来自当前同一项目目录;每次运行记录源码和计划摘要,Git 不参与运行时拉取。 + +升级前备份为 `runtime/backups/before-native-20261013/`,含旧源码、SQLite、原文件摘要与服务状态。配置迁移清单 `native-config-migration.json` 记录 22 项 before/after;数据库仍 schema v1。旧网页停止保留为 `adamwen-script-console-before-native-20261013`,首次接入镜像也保留为 `adamwen-script-console-native-initial-20261013`。 + +回退须停止接单、确认无活动 native unit 和排队/运行任务,再停 worker;使用迁移工具逐条恢复旧配置,核对并恢复匹配的旧 worker 源码,恢复旧网页后启动 worker。配置已发生后续变化时工具拒绝覆盖。保留新运行记录及所有结果,不能直接用旧 SQLite 覆盖。配置恢复已在隔离副本验证,生产故障回退未触发。 + +服务器证据:`runtime/native-deployment-acceptance.json`、`runtime/native-release-precheck.json`。本地浏览器证据:`.local/evidence/native-browser.json`、`.local/screenshots/native-*.png`。早期部署记录保留各自时点,不代表当前状态。 + +## 变更范围 + +核心新增 `console/native_{plans,io,rpc,files,job,executor}.py`、`console/static/native-ui.js`、`catalog/native-profiles.json`、`deploy/native_profiles.py`、3 个原环境测试文件及 ADR 0004。配套修改 API、目录描述、存储、worker、日志归档、运行/详情页面、部署镜像和维护文档。新手写文件均小于 500 行。 + +未暂存、提交、打标签或推送 Git。当前仓库尚无提交,所有实现继续留待用户审阅;运行数据、凭据和本机辅助证据不进入 Git。 diff --git a/docs/adr/0001-intranet-access.md b/docs/adr/0001-intranet-access.md new file mode 100644 index 0000000..f72824a --- /dev/null +++ b/docs/adr/0001-intranet-access.md @@ -0,0 +1,13 @@ +# ADR 0001:内网免登录入口与固定操作权限 + +日期:2026-10-13。状态:已实施。 + +用户选择公司内网少数人使用、暂不设置账号。采用 Nginx 来源网段限制和固定操作身份,保留 Rundeck 服务端授权。任务维护继续通过受控源码和服务器维护流程完成。 + +仅不发布后端端口,宿主机仍可连接容器 IP 并伪造预认证角色。实际将后端与代理放入同一本项目网络命名空间,后端只监听其回环;代理覆盖所有受信身份头。宿主机 root / Docker 管理者处于信任边界内。 + +共享 xas-intranet 身份的代价是无法确定具体操作人。日志只保留来源 IP、方法、路径、状态与大小,不记录查询参数或会话信息。允许网段来自现场网络配置,不开放整个私有地址空间。 + +以后需要逐人权限或审计时,再将固定身份替换为真实认证;当前不提前建设账号系统。 + +依据:[Rundeck 预认证](https://docs.rundeck.com/docs/administration/security/authentication.html#preauthenticated-mode-using-headers)、[Docker 端口边界](https://docs.docker.com/engine/network/port-publishing/)、[Nginx 访问控制](https://nginx.org/en/docs/http/ngx_http_access_module.html)。 diff --git a/docs/adr/0002-dedicated-console.md b/docs/adr/0002-dedicated-console.md new file mode 100644 index 0000000..ba86af9 --- /dev/null +++ b/docs/adr/0002-dedicated-console.md @@ -0,0 +1,21 @@ +# ADR 0002:使用专用脚本管理控制台 + +日期:2026-10-13;状态:按用户确认方案实施,替代 Rundeck 作为日常入口。 + +## 原因 + +用户需要直接上传脚本、查看只读代码、上传新版本、配置执行参数,并在同一页面看到日志、结果和历史。Rundeck 的任务编辑/导入模型与这里的脚本文件管理存在额外映射,继续叠加集成会增加维护成本。 + +## 决策 + +网页/API 使用 FastAPI,SQLite 保存元数据与状态,文件系统保存不可变上传版本、日志和结果。数据库 `user_version=1` 为新控制台独立结构,不改写旧 H2。Git 保留应用源代码,不作为每次运行的自动拉取来源。 + +共享队列单并发,独立宿主 worker 拥有 Docker 管理能力,但没有网络监听入口。网页和任务容器没有 Docker socket、GPU、宿主 PID 或设备。网页需读取原目录的 root/0700 文件,使用容器 UID 0、移除全部 capabilities、只读根文件系统和来源挂载;不改变原目录属主或权限。网页仅绑定内网 IP,通过真实 peer IP、Host、Origin 和自定义请求头限制访问,不信任转发身份/IP头。 + +上传代码仍是不可信输入。任务放入受限容器;临时结果目录是宿主项目路径下的 128 MiB tmpfs,归档后卸载。允许的原输出目录可写,原文件不清理;其增长采用运行时软限制,不能当成文件系统硬配额。特定旧脚本认证仅存在专用代理进程内存中,stdin 注入,不修改原脚本或复制认证文件。 + +## 兼容和退出条件 + +旧容器、H2、日志和备份保留,但旧记录不转换或混入 SQLite。14440 上 `/project/xas-test-library/*` 的浏览器书签跳转到新脚本库;仅为已经发给用户的公开页面链接保留,在团队更新书签后可以移除。没有保留旧 API 或双写路径。 + +后续接入 GPU、服务管理脚本、额外依赖、更多输出目录或鉴权,需要单独明确资源和权限范围。当前服务不自动安装依赖,也不从任务容器停止其他维护者服务。 diff --git a/docs/adr/0003-test-behavior-catalog.md b/docs/adr/0003-test-behavior-catalog.md new file mode 100644 index 0000000..636b02c --- /dev/null +++ b/docs/adr/0003-test-behavior-catalog.md @@ -0,0 +1,29 @@ +# ADR 0003:按实际测试行为组织目录 + +日期:2026-10-13。依据用户明确要求:一个测试为一项,一套测试的内部文件不再平铺。 + +## 调整原因与领域边界 + +原目录将递归发现的 .py/.sh 文件当成测试项目,307 个原位置文件中含辅助模块、套件自身验证、服务管理和历史场景脚本。扩展名只能识别源码文件,不能证明它是一项独立测试。 + +现在分为“文件清单”和“测试入口清单”:文件清单保留源码定位、摘要及版本;`catalog/test-definitions.json` 根据实际 README、脚本用途和调用关系,登记测试名称、执行入口、套件/单项类型及相关文件。公开目录和首页数量均来自测试入口清单;扫描新文件不会自动新增测试项目。用户主动上传的单入口脚本仍作为新测试登记。 + +本次整理 22 个现有测试入口,加上已接入的轻量模型检查,共 23 项业务测试;其中 8 项套件、15 项单项测试。两个本控制台验收样例默认隐藏,可通过“项目用途 → 验收示例”查看。公司 GPU 套件的 224 个文件归到统一入口,其 148 个内部验证文件不再进入主列表。同类测试的不同来源实现归入相关文件,不因为源文件数量增加重复卡片。 + +同一个文件既可以是套件的组成部分,也可以在确有独立测试用途时作为单项入口;这体现不同的测试行为,不为每个辅助文件建立入口。总入口与相应完整测试流程维持原样,不在本轮拼接、改写或实际运行测试链。 + +## 数据与执行不变量 + +- 原 SQLite schema v1、脚本/版本/运行 ID、历史名称和结果文件均保留。本轮不做数据库结构迁移、回填或历史重写。 +- 目录是对现有记录的只读整理。第一次显示使用清单中的业务名称;用户保存后的名称/说明优先。后续运行记录保存对应测试名称,执行仍只有原始入口文件。 +- 详情增加相关文件只读查看;内部文件不能通过旧详情链接或直接 API 被配置/启用/运行。现有记录查询、日志、结果下载与取消仍按原运行 ID 工作。 +- 未开启 GPU 或服务管理能力;未就绪项目保持待配置。清单不是执行授权,不能据此运行历史脚本中的启停或整机测试步骤。 +- 所有上传版本和原文件仍按原路径保存,没有批量复制、合并或删除。 + +## API 与维护方式 + +`GET /api/scripts` 现返回业务测试入口,`include_examples=true` 包含验收示例。原路径保留,条目增加 is_test/test_kind/category/file_count;`GET /api/scripts/{id}/files` 提供只读相关文件。`GET /api/scripts/{id}` 仍可读取内部文件,但 is_test=false;这类文件的修改/运行 API 明确拒绝。summary.scripts/ready 统计业务测试,新增 source_files/examples 说明其他数量。 + +维护者新增已有套件时,应先核对真实执行入口与测试目的,再编辑测试入口清单和相关文件映射。不能按目录名、所有 .py 文件或自动 import 扫描结果直接推断测试项目。新来源和新执行环境仍沿用原有权限与部署流程。 + +升级采用新镜像替换网页容器,私有 worker 与模型服务不重启。更新前备份原源码、镜像信息、SQLite,并记录历史/源文件摘要;验证失败恢复原网页容器,不回写备份数据库。旧版本可继续读取原 schema v1,应用回退无需数据恢复;数据快照恢复仍须单独确认并保留快照后记录。 diff --git a/docs/adr/0004-native-execution.md b/docs/adr/0004-native-execution.md new file mode 100644 index 0000000..67a10d1 --- /dev/null +++ b/docs/adr/0004-native-execution.md @@ -0,0 +1,25 @@ +# ADR 0004:可信原入口复用原环境,上传脚本保持隔离 + +日期:2026-10-13;状态:按用户明确授权实施。补充 ADR 0002 的执行边界。 + +## 目标与决策 + +现有测试依赖宿主 Python、原模型配置、Docker 和交互菜单,统一放进轻量容器无法保持原行为。网页登记固定原入口,参数只允许方案声明的类型和范围;不改原脚本、原配置或素材。`catalog/native-profiles.json` 是管理员维护的可信方案,上传代码不能使用宿主模式。 + +网页只负责展示、参数配置、逐次影响确认和记录。宿主 worker 通过私有 0600 Unix socket 校验入口、来源摘要、参数、计划摘要与确认,创建 `adamwen-native-.service`。网页没有 Docker socket 或任意命令入口,既有 LAN 访问范围与无账号安排不变。 + +服务独立于 HTTP 连接和 worker 生命周期。worker 重启后关联同一服务;未确认结束时保留队列占位,不自动补跑。超时/取消等待本次 cgroup 收尾后释放占位。Docker daemon 或 tmux 分离任务需要另行核对;前后资源快照只是变化证据,不能据此自动删容器或声明恢复模型。当前控制台单并发不能阻止维护者在控制台之外同时运行任务。 + +菜单以有控制终端的 PTY 运行,关闭回显,只接收数字选项/列表、y/n/q、空行。禁止控制键和任意命令,避免菜单通道变成宿主终端。需要其他输入的高级维护操作不在本控制台范围内。 + +秘密仅在宿主内存读取;标准输出、文件日志及文本归档按已知秘密流式脱敏。输出保留在原处,平台归档新增/变更文件;固定文件名会覆盖历史的容量/成本方案先做本项目私有备份。保留采集/归档上限并显示未归档原因,不把归档当成整个原工作区备份。 + +## 数据迁移与恢复 + +SQLite 保持 schema v1,仅增加 JSON 配置和运行快照字段。`deploy/native_profiles.py` 对已有 22 个入口做单事务配置迁移,保留 ID、versions 和 runs;拒绝活动任务或并发配置变化。先备份 SQLite 和旧源码,再执行;逐条 before/after 保存在私有迁移清单。隔离生产库副本已验证迁移、幂等及精确配置恢复,历史记录不改变。 + +回退必须先停止接单并确认无排队/运行中的 native 服务,再停止 worker,按迁移清单恢复配置以及匹配的旧 worker 源码和网页镜像。若配置有后续修改,恢复工具拒绝覆盖,须人工核对。新运行历史和结果继续保留;应用回退不恢复旧数据库。不可让旧 worker 处理仍活动的新模式任务。 + +## 验证范围 + +方案接入、只读预检、参数/权限边界、模拟及真实短进程生命周期已验证。原项目的 GPU、长稳和模型切换业务不在本轮实际运行,页面如实提示。后续只有在用户实际发起并确认相应影响时才执行原项目测试。 diff --git a/docs/console-operations.md b/docs/console-operations.md new file mode 100644 index 0000000..93dd51d --- /dev/null +++ b/docs/console-operations.md @@ -0,0 +1,67 @@ +# 当前控制台操作与维护 + +核对日期:2026-10-13。当前源代码入口为本项目 `console/`;部署根为 `/adamwen-work-space/xas-test-library`。历史 Rundeck 文档不再是日常维护入口。 + +## 日常使用 + +内网入口 `http://10.88.118.188:14440/`,不设置账号。允许 `10.88.0.0/17` 和 `10.20.0.0/21`。所有访问者共享管理与执行权限,运行记录保存来源 IP,不区分个人身份。 + +1. 在测试项目中按用途、套件/单项、来源和状态筛选。一项对应一个测试行为,详情的“执行入口”和“相关文件”均只读。 +2. 上传 `.py` 或 `.sh` 创建托管脚本;修改时上传新版本,旧版本保留。只接受 UTF-8,单文件最大 1 MiB。素材、模型、第三方包不会随脚本自动取得。 +3. 在详情修改名称、说明、已开放的参数和超时,再保存并运行。22 项已登记原入口使用原环境方案,工作目录、解释器、命令模板和输出规则只读。普通测试预检通过后提交;涉及资源占用或服务启停的测试须逐次确认影响。 +4. 运行页查看持续刷新的标准输出/错误与文件日志,结束后预览/下载 JSON、文本或其他报告。历史保留本次配置、版本和摘要。 + +托管脚本工作目录为本次独立 `/work`,通过 `XAS_RUN_DIR` 环境变量取得它;将输出写到此目录即可归档,其中 `.log` 的新增内容会进入实时日志。原环境测试直接使用原文件、目录、依赖和已登记配置,不修改原代码。原输出照常产生,平台仅归档本次观察到的新增/变更内容;多人同时写同一原文件时无法证明内容只来自本次测试。容量和成本链路会覆盖固定文件名,启动前把已存在的相关输出备份到本次 `previous-outputs/`;其他方案优先采用原入口支持的运行编号参数。 + +现有 307 个 `.py`/`.sh` 文件包括入口和辅助模块,按已核对的用途整理为 23 个测试项目,不再全部平铺。22 个原环境入口和已有复制版轻量检查已配置;两个控制台样例在“项目用途 → 验收示例”查看。相关文件不能单独启用或运行。原环境复用宿主环境,上传脚本继续使用 Python 标准库、Bash、curl 的受限容器,无 GPU;不自动安装依赖或启动缺失模型服务。 + +“已配置”表示有启动方案,不表示服务已在线或测试已通过。运行前只读预检核对入口摘要、原配置、工具、素材及所需 TCP 端口,不发模型推理、不预留 GPU。2026-10-13 23:27 有 6 项因原模型端口未监听被拦截。整机/服务测试的影响确认只对本次源码、参数和方案有效。长稳测试最高 72 小时;原菜单支持数字选项/列表、y/n/q、空行回车,输入不回显、不保存。不能通过菜单发送 shell 命令、控制键、密码或令牌。 + +“DeepSeek V4.1 轻量功能检查(已复制)”使用原始 smoke.sh,源码摘要固定,兼容环境不可在网页修改。一次最多 1 个 models GET 和 3 个 completion POST,固定模型、最多 512 token/问答;禁止重定向和重试。新代码不能直接沿用这个认证环境,应新建脚本后接入。 + +## 数据与配额 + +| 内容 | 服务器位置 | +|---|---| +| 数据库 | runtime/console-data/console.sqlite3 | +| 上传脚本版本 | runtime/library/<脚本ID>/versions/<版本>/<文件名> | +| 运行日志 | runtime/runs/<运行ID>/console.log | +| 运行结果/源码快照 | runtime/runs/<运行ID>/artifacts/,下载名称由数据库映射 | +| 容器临时文件 | runtime/runs/<运行ID>/work,128 MiB tmpfs,归档后卸载 | +| 原环境运行与输出保护 | runtime/runs/<运行ID>/,原输出仍在原目录,固定名称旧结果另存 previous-outputs/ | +| 原环境私有控制通道 | runtime/native-ipc/control.sock,0600,仅本机网页/worker 使用 | +| 切换前快照 | runtime/backups/before-dedicated-20261013/ | + +数据库没有保存完整脚本、完整日志或完整报告正文。容器任务有单文件源码快照;原环境任务保留源文件摘要、实际命令与方案快照,源文件留在原处。外部结果原文件保留,同时归档本次变化内容用于历史查询。历史归档不会因原文件下次覆盖而变化。 + +共享队列单并发、最多排队 20 项。容器任务限制 1 CPU、512 MiB、64 进程,时限 5–7200 秒;原环境任务不套用这些 CPU/内存限额,时限 5 秒至 72 小时,真实资源占用遵循原项目。平台日志最大 16 MiB;结果单文件最大 32 MiB、总归档约 64 MiB、最多 63 个文件另加摘要。原环境登记输出最多 4096 文件,超过时明确失败,不静默遍历整个磁盘。 + +超过归档限制的原位置文件保留;容器未归档临时结果随 tmpfs 释放,原环境工作目录保留。容器运行检查磁盘 10 GiB 下限及原输出增长 128 MiB 软限制;原环境不施加该增长限制,以免改变长稳行为。固定旧输出备份上限 64 GiB,且要求备份后仍有 10 GiB 余量。原环境对原目录没有硬配额,操作者需按原测试规模安排磁盘与 GPU。 + +容器任务取消/超时只管理本项目容器,worker 重启时旧容器任务标为中断、不补跑。原环境任务由独立 systemd 服务承载,worker 重启后继续跟踪同一任务;状态查询异常时保留占位,不误判结束。取消/超时终止本次本机进程,最多留 120 秒收尾,然后确认 cgroup 已结束再释放队列。 + +原脚本通过 Docker daemon 或 tmux 启动的分离任务可能继续运行,取消不等于恢复原模型,也不保证远端推理已停止。日志和结果保留前后资源变化供核对;控制台不据此自动删除容器或他人会话。正常菜单退出后如观察到新增 tmux 会话,继续跟踪其结束。退出码零仅表示入口进程正常退出,业务结论以报告为准。 + +## 部署与恢复 + +网页容器 `adamwen-script-console`;私有执行器 `adamwen-script-worker.service`;容器任务前缀 `adamwen-test-run-`;原环境服务前缀 `adamwen-native-`。初次部署使用 `deploy/dedicated.py`。`deploy/cutover_console.py` 是历史 Rundeck 切换工具,不适用于本次原环境升级。原环境方案由 `catalog/native-profiles.json` 管理,配置迁移使用 `deploy/native_profiles.py`;禁止在网页上传内容中定义宿主方案。 + +当前没有设置开机自启、定时任务或自动清理。整机重启后,按 00088 操作手册连接,核对共享资源,再启动本项目网页容器与 worker。日常可读检查 `docker logs --tail 100 adamwen-script-console`、`systemctl status adamwen-script-worker.service` 和运行摘要;不要输出模型认证文件。worker 配置只记录认证文件位置,秘密仅在内存中读取。 + +本次应用回退:先停止网页接单,确认无排队/运行任务及活动 native unit,再停止 worker。用 `deploy/native_profiles.py --restore --backup runtime/backups/before-native-20261013/native-config-migration.json` 恢复 22 项旧配置;有后续修改则工具拒绝覆盖,先核对。恢复匹配的旧 worker 源码以及保留的 `adamwen-script-console-before-native-20261013` 网页容器,再启动 worker。保留新运行记录与结果,不覆盖 SQLite;旧代码不能处理活动 native 任务。具体版本、备份与验证见原环境验收记录。回到更早 Rundeck/H2 是另一种迁移,不作为本轮普通回退路径。 + +数据恢复须另行明确:停止新网页/worker,保留故障数据副本,检查 SQLite 快照 `PRAGMA integrity_check`,将匹配时点的数据库、library、runs 作为一组恢复,验证文件摘要与引用。不能只覆盖数据库,不能自动丢弃快照后的运行。当前仅初始 schema v1,未来结构变更需新增可审查迁移,不改已执行历史迁移。 + +## 本地验证与源码管理 + +Python 3.12 隔离环境安装 `console/requirements-test.txt`,执行 `python -m unittest discover -s tests -v`。Linux 检查涵盖进程组、符号链接和 worker 收尾,Windows 会明确跳过不适用项。依赖已固定版本,网页无外部 CDN。 + +Git 远程为公司仓库 `https://code.it.aisuan.cn/wenhuadian/xas-test-library`,主分支 `main`。用户于 2026-10-14 明确授权首次提交和推送,后续 Git 历史操作仍需当前对话中的明确指令。`.local/`、`runtime/`、认证材料和服务器私有 worker 配置不进 Git;Git 不会自动拉取上传脚本或发布应用。原位置脚本按服务器路径引用,本仓库不是其他工作区的完整备份。 + +## 历史阶段:2026-10-13 20:25 导航与点击区域优化 + +项目详情顶部提供“返回测试项目”,运行详情提供“返回运行记录”;加载和错误页面同样可返回,长页面滚动时返回按钮保持可见。项目整张卡片和运行记录整行均为可点击入口;详情按钮至少 46px 高,返回按钮至少 44px 高,保留原生链接和键盘 Enter 操作。 + +修改限定于 `console/static/scripts.js`、`runs.js`、`styles.css` 及部署镜像引用。正式镜像为 `adamwen/script-console:20261013-3`,摘要 `sha256:62424bf4b9c34c986cc6831b8fc083e821fa78bbd5da8fbf89a1d9e897acd7fe`。1440px 和 390px 真实浏览器验证整卡/整行点击、返回、滚动、键盘及错误状态通过,没有页面横向溢出或 JavaScript 错误。 + +本轮未执行测试脚本或调用模型。历史记录仍为 10 条;scripts/versions/runs 数据摘要与更新前一致,worker PID 与原模型容器启动状态未改变。备份在 `runtime/backups/before-ui-navigation-20261013/`,旧网页容器 `adamwen-script-console-before-navigation-20261013` 停止保留。回退只需替换网页,不覆盖数据库或重启 worker;未实际触发回退。证据位于本地 `.local/evidence/navigation-browser.json` 和服务器 `runtime/navigation-acceptance.json`。 diff --git a/docs/console-plan.md b/docs/console-plan.md new file mode 100644 index 0000000..7751423 --- /dev/null +++ b/docs/console-plan.md @@ -0,0 +1,52 @@ +# 专用测试控制台实施方案 + +> 2026-10-13 目录展示已按用户后续确认改为“一项测试行为一个项目”,内部文件不平铺;清单与接口补充见 [ADR 0003](adr/0003-test-behavior-catalog.md)。本文其余存储和执行边界继续适用。 + +日期:2026-10-13。本文是当前维护入口,覆盖此前 Rundeck 方案的产品范围;历史验收记录继续保留。 + +## 已确认的行为 + +- 公司内网免登录;只读查看脚本代码,上传创建新脚本或不可变的新版本。 +- 登记三个已知测试目录内的 Python/Shell 脚本,不批量复制源文件。原脚本保持原位置、参数与工作目录。危险的服务管理脚本仅登记待配置。 +- 允许已登记测试按原逻辑写入原日志/结果目录;不修改源文件,不授权停止他人的模型服务。 +- 每次运行有独立记录、实时标准输出/错误、退出状态、源文件摘要和结果文件。文件型日志需登记路径才会采集。历史结果有独立归档,不覆盖原文件。 +- 数据库存元数据与状态,源文件/完整日志/报告存在文件系统,不把文件正文重复塞进数据库。 +- 上传目录 runtime/library/<脚本ID>/versions/<版本>/;运行目录 runtime/runs/<运行ID>/。 +- Git 管理控制台源码和部署配置,不在每次运行时拉取。没有提交、标签或推送授权。 + +## 架构与信任边界 + +FastAPI 提供网页/API,SQLite WAL 保存目录与队列;网页容器没有 Docker socket 或 GPU。独立宿主执行器处理单并发队列,启动有固定标签、限定资源、无 Docker socket 的临时容器。可配置的工作目录和输出路径必须位于已登记的来源目录,上传脚本只能写本次结果目录。源文件挂载只读,只有明确登记的输出目录可写。 + +所有内网用户共享权限,可上传和运行自己的代码,因此信任边界是允许访问的两个公司网段。执行容器限制宿主文件与设备,不能作为抵御恶意内网用户的完整安全平台。API 校验 Host、Origin、来源网段和专用请求头,拒绝跨站修改;不接收任意 shell 命令、环境变量、镜像或宿主挂载。 + +运行记录固定版本、配置快照与源码摘要;排队后原文件变动则拒绝执行。超时/取消只终止本次容器,不宣称远端模型推理已取消。执行器重启时只恢复/终止本项目标签容器,不自动补跑。日志、上传、结果大小与执行时长有上限。业务判定只在脚本提供明确报告时呈现;退出零只表示进程正常结束。 + +旧 smoke.sh 缺少当前服务要求的认证。为保留原代码,在其运行容器中放置仅服务本次进程的回环代理:凭据经 stdin 传递、只保留内存,固定上游地址、模型、1 次 models 与 3 次 completion,不重试。真实验收最多一轮,保留失败和截断输出。 + +## 实施和验收 + +1. 固定 API,先测试版本不可覆盖、目录边界、排队幂等与单并发、日志游标、归档和取消。 +2. 实现目录/上传/代码只读、历史、实时日志、文件下载;快速样例验证成功、失败、取消和结果。 +3. 只读扫描已知来源,配置一个轻量既有脚本。新端口 14442 并行部署,旧入口 14440 暂保留。 +4. 浏览器贯通上传、新版本、运行、日志、结果和历史;在模型服务仍正常的条件下执行一次有界 smoke。 +5. 验收后切换 14440 的项目入口,保留旧容器/数据和明确回退路径。未通过则保留新预览并如实报告。 + +## 前后端接口契约 v1 + +JSON 错误 `{detail: string}`。修改请求必须含 `X-XAS-Console: 1`。所有时间为 UTC ISO。 + +- GET `/api/summary`: `{scripts, ready, runs, active, worker:{online,last_seen}, storage:{run_bytes}, limits:{...}}` +- GET `/api/scripts`: `{items:[Script]}`;GET `/api/scripts/{id}`: Script(含 versions) +- Script: `{id,name,description,kind:'managed'|'external',language:'python'|'shell',source_path,source_group,status:'ready'|'needs_configuration'|'missing',enabled,reason,version,sha256,updated_at,config:{args:[],cwd:'',output_paths:[],timeout_seconds:300,profile:'standard'|'legacy_smoke'},versions:[{version,sha256,created_at,filename}]}` +- GET `/api/scripts/{id}/code?version=`: `{code,filename,sha256,truncated,redacted}`;GET `/api/scripts/{id}/download?version=` 返回只读源文件(含秘密的文件拒绝下载) +- POST `/api/scripts/upload`: multipart `file`, `name`, `description`(新建);POST `/api/scripts/{id}/versions`: multipart `file`(仅 managed);返回 Script。 +- PATCH `/api/scripts/{id}`: `{name?,description?,enabled?,config?:{args?,cwd?,output_paths?,timeout_seconds?}}`;不允许更新代码、profile、路径来源或版本。 +- POST `/api/discover`: 返回 `{registered,missing,total}`,仅扫描预设来源。 +- POST `/api/scripts/{id}/runs`: `{request_id:UUID}`,返回 Run;重复请求返回同一记录。运行采用当前登记配置和版本。 +- GET `/api/runs?script_id=&limit=100`: `{items:[Run]}`;GET `/api/runs/{id}`: Run。 +- Run: `{id,script_id,script_name,version,sha256,state:'queued'|'running'|'succeeded'|'failed'|'cancelled'|'timed_out'|'interrupted',created_at,started_at,finished_at,exit_code,message,config,artifacts:[{id,name,size,kind:'text'|'json'|'file'}],synthetic,source_ip}`。 +- POST `/api/runs/{id}/cancel`: 返回 Run;GET `/api/runs/{id}/log?offset=0`: `{text,next_offset,done,truncated}`。 +- GET `/api/runs/{id}/log/download`;GET `/api/runs/{id}/artifacts/{artifact_id}`(下载);GET 同路径加 `/preview`: `{text,truncated}`。 + +网页无需外部 CDN。中文、清晰加载/空/错误状态,脚本列表筛选,详情页源码只读、配置编辑、版本上传、运行记录。运行页轮询日志与状态,取消只对进行中的任务显示。结果区展示文件列表、文本预览/下载。默认突出真实状态,不把模拟验收算作模型测评。 diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..aceac57 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,31 @@ +# XAS 测试控制台设计 + +> 历史方案:以下为 Rundeck 阶段记录,已被 [专用控制台方案](console-plan.md) 和 [当前操作说明](console-operations.md) 覆盖。不要按本文重新部署旧控制台。 + +更新日期:2026-10-13(北京时间)。用户先授权网页脚本库,随后明确要求公司内网少数人使用、暂不设置账号。最新要求覆盖初始回环网页登录方案。 + +## 目标与范围 + +Rundeck 提供任务列表、参数表单、日志和历史,源码准备为公司私有 Git 脚本库。当前只开放无 GPU 的轻量验收。现有公司套件与其他维护者脚本只登记来源,不移动、修改或开放执行。 + +## 访问与信任边界 + +入口为 10.88.118.188:14440,允许已核对的 10.88.0.0/17 服务器网和 10.20.0.0/21 办公网。Nginx 固定注入 xas-intranet 身份和 xas-operator 角色,覆盖外来身份头,拒绝跨站来源、错误 Host 和 API 令牌查询参数。 + +所有人共享执行身份,无法逐人审计;代理保留来源 IP。访客仅有本项目历史读取和已登记验收任务的 read/run/kill 权限,没有任务编辑、任意命令或系统管理权限。 + +仅隐藏后端端口不能阻止宿主机直连容器 IP。后端实际加入代理的专用网络命名空间,并只监听内部 127.0.0.1:4440;代理是唯一网络入口。宿主机 root / Docker 管理权限仍是信任边界。 + +## 执行与数据 + +运行器接受固定模式和参数,使用独占结果目录、源码 SHA-256、UTC 时间和结构化结果。模拟数据标识 synthetic=true。退出 0 但结果无效仍失败,重复编号不能覆盖历史。每次创建独立进程组,超时和取消只清理本次组并核验后代退出。 + +Rundeck 状态与业务状态分别保留:超时为 failed / timed_out,取消为 aborted / cancelled。HTTP 成功或进程启动不代表任务完成。 + +## 部署与恢复 + +只使用 /adamwen-work-space/xas-test-library,容器使用 adamwen- 前缀。后端上限 2 CPU/2 GiB,代理上限 0.25 CPU/64 MiB。无 GPU、Docker socket 或其他维护者目录挂载。不修改 Linux 账号、SSH、旧服务、防火墙、模型或计划任务。 + +当前为 H2 单实例预览,没有自启、自动备份或清理。正式 GPU 测试和多人资源调度未验证。公司 Git 远程稍后接入,不创建提交、标签或推送。 + +切换前源码、停机数据库和历史已备份,旧容器保持停止状态。应用回退不自动覆盖数据;数据恢复需核对快照后的新增记录。正式脚本接入前逐项确认维护来源、权限、GPU/服务影响、停止语义、版本及结果判定。 diff --git a/docs/images/job.png b/docs/images/job.png new file mode 100644 index 0000000..619da78 Binary files /dev/null and b/docs/images/job.png differ diff --git a/docs/images/lan-jobs.png b/docs/images/lan-jobs.png new file mode 100644 index 0000000..7676c13 Binary files /dev/null and b/docs/images/lan-jobs.png differ diff --git a/docs/images/lan-logs.png b/docs/images/lan-logs.png new file mode 100644 index 0000000..adf209d Binary files /dev/null and b/docs/images/lan-logs.png differ diff --git a/docs/images/logs.png b/docs/images/logs.png new file mode 100644 index 0000000..f8471d3 Binary files /dev/null and b/docs/images/logs.png differ diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md new file mode 100644 index 0000000..d4231f2 --- /dev/null +++ b/docs/implementation-plan.md @@ -0,0 +1,45 @@ +# XAS 测试控制台实施计划 + +> 历史计划:本文件只描述已完成的 Rundeck 阶段。当前实施入口是 [console-plan.md](console-plan.md)。 + +日期:2026-10-13。设计见 design.md。当前会话内执行,遵循用户不创建 Git 历史的规则。 + +## 验收要求 + +保留现有服务和其他工作区;不使用 GPU、不调用模型;代码和服务器运行数据分开;先实现轻量验收全流程。 + +## 任务 + +- [x] 1. 运行器与验收脚本:先写失败测试,覆盖成功、失败、报告异常、重复编号、输入拒绝、超时和 Linux 子进程取消,再实现最小功能。 + - 文件:scripts/run_smoke.py、scripts/smoke_task.py、tests/test_runner.py。 + - 接口:run_smoke.py --mode MODE --run-id ID --timeout SECONDS --results-root PATH。 + - 验证:python -m unittest discover -s tests -v;Linux 上进程测试不得跳过。 +- [x] 2. Rundeck 配置:固定版本、限制资源、回环绑定、专用数据目录;生成单一白名单任务定义与外部来源目录。 + - 文件:deploy/、catalog/、README.md。 + - 验证:服务运行身份、挂载和设备可见性;任务参数校验和非授权请求拒绝。 +- [x] 3. 服务器验证:复核资源后只部署本项目,验证 API 正常、失败、报告异常、超时、取消和运行记录。 + - 证据:脱敏汇总保存于 docs/acceptance-2026-10-13.md。 +- [x] 4. 浏览器与交付:核对登录、任务、参数、日志与历史页面;交代未验证范围、访问方式和恢复方式。 + +## 重点故障 + +- 运行编号复用、路径穿越和无效模式:在创建进程前拒绝。 +- 进程正常退出但报告无效:结果失败。 +- 取消、超时与正常结束:只清理本次进程组,并核验后代退出。 +- 管理页面与脚本输入:只有登记任务可执行,普通操作角色不能执行任意命令。 +- 新容器不能访问 GPU、其他维护者目录或 Docker socket。 + +## 执行记录 + +- 10:53 只读预检查:现有 DeepSeek 容器运行,GPU 0~7 占用显存;磁盘约 1.4 TiB 可用。现有 Docker 可通过正常 sudo 使用,未安装 Compose;直接使用项目部署脚本,不修改系统工具链。 +- Final: 独立审查的 runtime 父链接越界问题已修复;test_runtime_parent_link_cannot_escape_project 与相关测试 RED→GREEN,Linux 全套 10/10 通过。 +- UI/API: 实际执行 1~6 已核验;浏览器状态伪元素造成的自动定位问题仅重读已有执行,没有重跑业务任务。 +- 交付保留项:用户私密设置网页登录密码;公司远程 Git 仓库稍后接入;正式 GPU 脚本尚未开放。 + +## 后续内网免登录调整(用户最新要求) + +- [x] 固定访客角色与核对过的网段限制,移除密码设置工具。 +- [x] 通过共享网络命名空间和后端回环阻止宿主机直连伪造角色;回归先失败再通过。 +- [x] 保存旧容器、源码与停机数据快照;验证权限拒绝、浏览器执行 7、日志及全部历史。 +- [x] 最终 Linux 14/14,更新 README、ADR 和当前验收。 +- 原“用户设置密码”保留项被最新要求取消;Git 远程与正式 GPU 脚本仍待后续接入。 diff --git a/docs/legacy-smoke-plan.md b/docs/legacy-smoke-plan.md new file mode 100644 index 0000000..6de7eb3 --- /dev/null +++ b/docs/legacy-smoke-plan.md @@ -0,0 +1,35 @@ +# 单个旧脚本接入计划 + +> 已被新方案覆盖:没有实施本文的 Rundeck/UNIX socket 集成。当前采用专用控制台与私有执行器,见 [console-plan.md](console-plan.md)。原始副本仍保持不变。 + +日期:2026-10-13。用户已选择轻量功能检查,并明确只复制一份脚本,保留写死的 GPU、端口、路径,不修改其他维护者工作区;结果和日志需在控制台可见。 + +## 范围 + +只复制 20261010-dsv41-trial/scripts/smoke.sh,字节和 SHA-256 保持一致。它包含一次模型列表 GET 和三个顺序推理请求,每个请求原有 max_tokens=512。完整 functional_check.py 含长上下文,不纳入本次。 + +新增独立 CPU 执行容器,复用现有镜像的 Bash/curl/Python,不安装模型或开启 GPU 设备。容器内回环 18100 适配原脚本固定地址,转发到已有模型服务;代理只允许固定模型、固定路径及最多三个推理请求,不自动重试。原认证文件仅单文件只读挂载,在启动时读入内存后降为现有 UID 1000;不复制密钥、不写入环境或日志。 + +Rundeck 通过项目结果目录中的专用 UNIX socket 调用固定任务。任务定义由 Git 工作区中的客户端源文件生成后导入数据库,不从 Git 自动拉取。执行容器只运行原样副本,结果保存在本项目 runtime/results/legacy/<执行编号>,日志流和中文逐项汇总回传控制台。 + +## 行为约束 + +- 原脚本只复制一个;源工作区只读。 +- 不启动、停止、重启模型服务,不更改 GPU 分配。 +- 每次最多 1 个模型列表请求、3 个推理请求,顺序执行,无自动重试;总时限 180 秒。 +- 请求转发前持久化次数;同一执行编号不重复运行,服务重启不重置记录。 +- 同时只允许一个旧脚本任务,重复或并发请求拒绝。 +- 取消关闭本次客户端和进程组,阻止新请求;已提交的上游请求取消不能保证,记录结果未知。 +- 模型结果完整性和接口成功分别判断,不能把退出 0 或截断回答当作通过;不宣称完成标准模型质量评测。 +- 密钥和上游认证头不进入命令参数、文件、Git 或网页日志。 + +## 实施步骤 + +- [ ] 原样复制并核对哈希;备份本项目受影响配置。 +- [ ] 编写并验证请求预算、结果判定、日志脱敏和旧脚本进程生命周期。 +- [ ] 配置隔离执行容器和固定 UNIX socket 客户端,登记一个网页任务。 +- [ ] 在无网络隔离容器内用模拟上游验证原脚本、超时、取消、重复编号与恢复路径。 +- [ ] 真实调用只运行一轮:1 GET + 3 推理请求,不因模型失败自动重跑。 +- [ ] 浏览器核对日志与结果;记录请求次数、原脚本哈希、原模型容器状态;更新维护说明。 + +所有 Git 改动保持未暂存、未提交、未推送。测试结果真实失败时保留失败证据,不修改原脚本参数来制造通过。 diff --git a/docs/native-execution-plan.md b/docs/native-execution-plan.md new file mode 100644 index 0000000..e5bf003 --- /dev/null +++ b/docs/native-execution-plan.md @@ -0,0 +1,45 @@ +# 原环境执行设计与实施计划 + +日期:2026-10-13。用户已明确同意开放测试入口,并采用“普通接口直接触发;启停服务/占整机的测试显示影响并逐次确认”的方式。不修改原项目的脚本、配置和素材;运行本身仍按原逻辑产生输出及副作用。本轮验证不自动执行整机压测或启停现有模型。 + +## 业务与执行边界 + +已有项目新增 native 原环境方案:在服务器原工作目录,以已登记的解释器和固定原入口运行,复用原配置文件及现有 Docker 工具。上传脚本继续在隔离容器中运行,网页不能把任意上传代码切换为宿主执行,也不能填写任意宿主命令、账号、解释器或环境变量。 + +每个方案明确:原入口、目录、可选参数类型/范围、环境和凭据来源、输出位置、预计影响及是否需要确认。登记可读元数据在 catalog/native-profiles.json;秘密只在宿主执行时读取并保留内存,不能进 API 响应、命令行、数据库或日志。 + +Native 运行使用独立的 systemd transient service,按运行编号关联,使用原账号和目录。不套用通用容器的 1 CPU/512 MiB 限制;仅限制同一控制台单任务和总时限。默认允许最长 72 小时,超过原先两小时限制的长稳测试可按需设置。工作进程/模型的实际 CPU/GPU 仍按原项目配置。 + +运行前预检是只读的:检查入口摘要、所需文件/工具、参数和目标服务端口(TCP 连接,不发推理请求)。预检返回计划摘要,影响确认绑定源码、参数与方案版本,变化后须重新确认。开始运行保存计划与确认记录。 + +菜单型入口使用 PTY,控制台仅接受数字选项/数字列表或 y/n/q,留空发送回车。不接受 shell 命令、控制键、密码或令牌;输入不写数据库,PTY 关闭回显。需要自由文本的原工具高级维护操作继续使用原维护入口,不通过此测试控制台发送。执行器只能向已启动的本项目交互式任务传入输入。 + +取消只针对本次 transient service 的进程组/cgroup,先给原脚本收尾时间,再强制结束残留本机进程。原脚本通过 Docker daemon 创建的容器不属于本机 cgroup,控制台不擅自清理或宣称已恢复原模型。保存运行前后容器状态差异并提示核对。worker 重启后重新关联仍存活的 native service,不重跑命令、不重置尝试次数。 + +数据库沿用 schema v1,在 JSON 记录增加可选 native_options 与 execution 字段。历史记录、版本、源码文件和 ID 不改写。新原环境登记是配置更新,保留旧记录和备份;恢复旧应用前须无活动 native 任务,不能让旧执行器误处理新任务。 + +## 模块职责与实施顺序 + +- [x] 清点并登记各入口的原参数、依赖、配置、影响及缺失项;不猜测凭据/启动命令。 +- [x] 先写参数、命令注入防护、来源限制、确认失效与原文件不变等测试,再实现原环境计划生成。 +- [x] 实现独立 transient service、日志脱敏、取消/超时、输入通道及 worker 重关联;用本项目短样例验证,不运行整机任务。 +- [x] 实现只读方案展示、原参数表单、预检与逐次影响确认、交互输入和原环境运行信息。 +- [x] 集成审查、回归及真实浏览器验证,备份后更新网页/worker,核验原文件摘要和当前模型服务。 + +所有新手写源码文件不超过 500 行,无 Git 暂存、提交或推送。现有仓库没有提交,继续在当前已确认工作目录增量修改,不创建包含用户未提交内容的额外 Git 历史。 + +## 前后端接口契约 + +Script 增加 `execution`:`{mode:'native'|'container', profile_id?, interpreter?, user?, cwd?, description?, impact:{level:'standard'|'resource'|'service',title,details:[],requires_confirmation}, parameters:[{key,label,type:'integer'|'number'|'select'|'text',default,min?,max?,options?:[{value,label}],required?,help?}], interactive:boolean, max_timeout_seconds:number}`。 + +native 项目的 config 增加 `native_options:{key:value}`。旧 args/cwd/output_paths 为方案计算值,不允许网页自由改写;网页显示只读启动目录、环境说明与输出规则。可编辑参数和 timeout_seconds,以及现有名称/说明/启用状态。PATCH 仍是 `/api/scripts/{id}`,新字段位于 `config.native_options`。 + +POST `/api/scripts/{id}/preflight`,JSON `{}`,含 X-XAS-Console:1。返回 `{ready:boolean,plan_token:string,mode,command_preview:string,cwd:string,user:string,checks:[{name,ok,message}],impact:{...},interactive:boolean,output_paths:[],notes:[]}`。没有副作用,不执行原脚本。原输出中的 `{run_id}` 保持占位,队列分配编号后再解析。 + +POST `/api/scripts/{id}/runs` 扩展 `{request_id:UUID,plan_token?:string,impact_acknowledged?:boolean}`。native 必须先预检;有影响的方案必须显式确认,计划变更返回 409/400,重新预检。普通容器脚本原调用仍可用。 + +Run 增加 `execution`:`{mode,profile_id?,command_preview?,cwd?,user?,impact?,impact_acknowledged?,interactive?,unit?,verification?:...}`。默认字段缺失的历史记录按 container 展示。运行仍单并发。 + +POST `/api/runs/{id}/input`,JSON `{text:string}`;仅 native、interactive、running 状态接受,单次 <=1024 UTF-8 字节,禁止 NUL;末尾自动换行。返回 `{accepted:true}`。无需把输入保存到历史记录。拒绝任意路径、其他任务或已结束运行。用户应只输入原菜单需要的选项,不输入密码或令牌。 + +日志和结果 API 沿用现有接口。native 结果包含原命令/环境引用、源码摘要、退出状态、取消/清理说明和容器状态差异。对未完成真实 GPU/长稳业务验证的项目,页面说明“启动方案已接入,未完成本轮业务验收”。 diff --git a/legacy/dsv41-smoke/SOURCE.md b/legacy/dsv41-smoke/SOURCE.md new file mode 100644 index 0000000..981252c --- /dev/null +++ b/legacy/dsv41-smoke/SOURCE.md @@ -0,0 +1,14 @@ +# 原样脚本副本 + +复制日期:2026-10-13(北京时间)。用户明确授权复制一份脚本。 + +- 来源:/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/scripts/smoke.sh +- 原作者标注:dengyuyun-joy,保留原注释。 +- SHA-256:be858e914d87454240791da773578cb3f80f380e740f919546db347b7715a13d +- 修改:无,按字节复制;固定端口、模型名、提示词和输出限制全部保留。 +- 依赖:Bash、curl、Python 标准库。 +- 外部调用:一次 GET /v1/models,三个顺序 POST /v1/chat/completions。 +- 脚本只输出标准输出;控制台包装层保存日志和结构化汇总,不向原工作区写入文件。 +- 原脚本不含认证参数;隔离执行环境的限定代理按需使用现有服务凭据,凭据不在此仓库。 + +此副本不自动跟随原文件变化。后续更新需重新审阅差异、记录新哈希并验证。 diff --git a/legacy/dsv41-smoke/smoke.sh b/legacy/dsv41-smoke/smoke.sh new file mode 100644 index 0000000..3f11b67 --- /dev/null +++ b/legacy/dsv41-smoke/smoke.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# dengyuyun-joy: quick functional check against the trial API. +U=http://127.0.0.1:18100 +curl -s -m 10 $U/v1/models | python3 -c "import json,sys;d=json.load(sys.stdin);print('models:',[(m['id'],m.get('max_model_len')) for m in d['data']])" +for q in "用一句话介绍你自己。" "计算 123456 × 789,只给出结果。" "Write a Python function that returns the n-th Fibonacci number, then explain it in one sentence."; do +python3 - "$q" <<'PY' +import json,sys,time,urllib.request +q=sys.argv[1] +body={"model":"deepseek-v4.1-flash","messages":[{"role":"user","content":q}],"temperature":0,"max_tokens":512} +t=time.time() +r=urllib.request.urlopen(urllib.request.Request("http://127.0.0.1:18100/v1/chat/completions",json.dumps(body).encode(),{"Content-Type":"application/json"}),timeout=600) +d=json.load(r); dt=time.time()-t +m=d["choices"][0]["message"]; u=d.get("usage",{}) +print("Q:",q); print("A:",(m.get("content") or "").strip()[:600]); print("reasoning chars:",len(m.get("reasoning_content") or m.get("reasoning") or "")) +print(f"usage={u} wall={dt:.1f}s ~{u.get('completion_tokens',0)/dt:.1f} tok/s"); print("-"*60) +PY +done diff --git a/scripts/console_example.py b/scripts/console_example.py new file mode 100644 index 0000000..be8eb6e --- /dev/null +++ b/scripts/console_example.py @@ -0,0 +1,23 @@ +"""Fast deterministic example; no model service or GPU access.""" +import argparse +import json +import os +from pathlib import Path +import sys +import time + +parser = argparse.ArgumentParser() +parser.add_argument("--scenario", choices=["success", "failure", "wait"], default="success") +args = parser.parse_args() +root = Path(os.environ["XAS_RUN_DIR"]) +print("开始快速流程验收(模拟数据),不调用模型。", flush=True) +for index in range(3): + print(f"步骤 {index + 1}/3:日志输出正常", flush=True) + time.sleep(0.3) +if args.scenario == "wait": + print("等待取消或超时。", flush=True) + time.sleep(120) +report = {"synthetic": True, "scenario": args.scenario, "success": args.scenario == "success", "checks": 3} +(root / "result.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") +print("结果已写入 result.json", flush=True) +sys.exit(0 if args.scenario == "success" else 2) diff --git a/scripts/run_smoke.py b/scripts/run_smoke.py new file mode 100644 index 0000000..f10bd9d --- /dev/null +++ b/scripts/run_smoke.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Run only the bundled synthetic acceptance task, with bounded lifetime.""" +import argparse +import ctypes +from datetime import datetime, timezone +import hashlib +import json +import math +import os +from pathlib import Path +import re +import signal +import subprocess +import sys +import time + +MODES = ("success", "failure", "invalid-report", "wait") + + +def now(): + return datetime.now(timezone.utc).isoformat() + + +def write_json(path, value): + temporary = path.with_suffix(".tmp") + with temporary.open("x", encoding="utf-8") as stream: + json.dump(value, stream, ensure_ascii=False, indent=2) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + temporary.replace(path) + + +def subreaper(): + # Adopt the fixture's child so cancellation can reap it even in containers. + if sys.platform == "linux": + libc = ctypes.CDLL(None, use_errno=True) + if libc.prctl(36, 1, 0, 0, 0) != 0: + raise OSError(ctypes.get_errno(), "cannot enable child subreaper") + + +def cleanup(proc): + if os.name == "posix": + for sig in (signal.SIGTERM, signal.SIGKILL): + try: + os.killpg(proc.pid, sig) + except ProcessLookupError: + break + if sig == signal.SIGTERM: + time.sleep(.15) + elif proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=2) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=2) + if sys.platform == "linux": + deadline = time.monotonic() + 2 + while True: + try: + pid, _ = os.waitpid(-1, os.WNOHANG) + except ChildProcessError: + break + if pid == 0: + if time.monotonic() >= deadline: + raise RuntimeError("child cleanup could not be confirmed") + time.sleep(.02) + + +def read_result(folder, run_id): + path = folder / "result.json" + if path.is_symlink() or path.stat().st_size > 65536: + raise ValueError("invalid result file") + result = json.loads(path.read_text(encoding="utf-8")) + if (not isinstance(result, dict) or result.get("run_id") != run_id + or result.get("synthetic") is not True + or type(result.get("checks_passed")) is not int + or result["checks_passed"] != 3): + raise ValueError("result does not satisfy the acceptance contract") + return result + + +def arguments(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--mode", choices=MODES, default=os.getenv("RD_OPTION_MODE", "success")) + parser.add_argument("--run-id", default=os.getenv("RD_JOB_EXECID")) + parser.add_argument("--timeout", type=float, default=os.getenv("RD_OPTION_TIMEOUT", "8")) + parser.add_argument("--results-root", type=Path, + default=Path(os.getenv("XAS_RESULTS_ROOT", "/home/rundeck/test-results"))) + args = parser.parse_args() + if args.mode not in MODES: + parser.error("unregistered mode") + if not args.run_id or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}", args.run_id): + parser.error("run-id must be 1-64 letters, digits, underscores or hyphens") + if not math.isfinite(args.timeout) or not 2 <= args.timeout <= 30: + parser.error("timeout must be between 2 and 30 seconds") + if os.name != "posix" and args.mode == "wait": + parser.error("wait/cancellation acceptance requires Linux") + return args + + +def main(): + args = arguments() + os.umask(0o077) + args.results_root.mkdir(parents=True, exist_ok=True) + folder = args.results_root.resolve() / args.run_id + try: + folder.mkdir() + except FileExistsError: + print("Run ID already exists; history was preserved", file=sys.stderr) + return 2 + source = Path(__file__).resolve().parent + summary = {"schema_version": 1, "run_id": args.run_id, "synthetic": True, + "mode": args.mode, "timeout_seconds": args.timeout, + "started_at": now(), "status": "running", "exit_code": None, + "source_sha256": {name: hashlib.sha256((source / name).read_bytes()).hexdigest() + for name in ("run_smoke.py", "smoke_task.py")}} + write_json(folder / "request.json", summary) + cancelled = False + + def on_signal(signum, frame): + nonlocal cancelled + cancelled = True + + signal.signal(signal.SIGTERM, on_signal) + signal.signal(signal.SIGINT, on_signal) + proc = None + rc = 1 + try: + subreaper() + proc = subprocess.Popen([sys.executable, "-u", str(source / "smoke_task.py"), + args.mode, args.run_id, str(folder)], + start_new_session=(os.name == "posix")) + deadline = time.monotonic() + args.timeout + while proc.poll() is None and not cancelled and time.monotonic() < deadline: + time.sleep(.04) + summary["exit_code"] = proc.poll() + if cancelled: + summary["status"], rc = "cancelled", 130 + elif proc.poll() is None: + summary["status"], rc = "timed_out", 124 + elif proc.returncode != 0: + summary["status"] = "failed" + else: + try: + summary["result"] = read_result(folder, args.run_id) + summary["status"], rc = "succeeded", 0 + except (OSError, ValueError, TypeError): + summary["status"] = "invalid_result" + except (OSError, RuntimeError) as exc: + summary["status"] = "runner_error" + summary["error_type"] = type(exc).__name__ + finally: + if proc is not None: + try: + cleanup(proc) + summary["cleanup_confirmed"] = True + except (OSError, RuntimeError, subprocess.TimeoutExpired) as exc: + summary["status"], rc = "cleanup_failed", 1 + summary["cleanup_confirmed"] = False + summary["error_type"] = type(exc).__name__ + summary["worker_pid"] = proc.pid + summary["final_exit_code"] = proc.returncode + child_file = folder / "child.pid" + if child_file.exists(): + summary["child_pid"] = int(child_file.read_text(encoding="utf-8")) + summary["finished_at"] = now() + write_json(folder / "summary.json", summary) + print("XAS_ACCEPTANCE_RESULT " + json.dumps(summary, ensure_ascii=False), flush=True) + return rc + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/smoke_task.py b/scripts/smoke_task.py new file mode 100644 index 0000000..6bad935 --- /dev/null +++ b/scripts/smoke_task.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Synthetic fixture. No GPU, network, model calls or existing data access.""" +import json +from pathlib import Path +import subprocess +import sys +import time + + +def main(): + mode, run_id, destination = sys.argv[1:] + folder = Path(destination) + print("SYNTHETIC FLOW ACCEPTANCE: not a model benchmark", flush=True) + if mode == "wait": + child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"]) + (folder / "child.pid").write_text(str(child.pid), encoding="utf-8") + print(f"waiting; controlled_child_pid={child.pid}", flush=True) + child.wait(timeout=65) + return 0 + if mode == "failure": + print("Expected fixture failure", flush=True) + return 23 + if mode == "invalid-report": + (folder / "result.json").write_text('{"incomplete":true}', encoding="utf-8") + return 0 + if mode != "success": + return 2 + for step in range(1, 4): + print(f"progress={step}/3", flush=True) + time.sleep(.2) + (folder / "result.json").write_text(json.dumps({ + "run_id": run_id, "synthetic": True, "checks_passed": 3, + "description": "Synthetic flow acceptance only"}), encoding="utf-8") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_console.py b/tests/test_console.py new file mode 100644 index 0000000..44615e1 --- /dev/null +++ b/tests/test_console.py @@ -0,0 +1,157 @@ +"""Console contracts: immutable uploads, isolation and durable queue semantics.""" +import json +from pathlib import Path +import tempfile +import unittest +from fastapi.testclient import TestClient +from console.app import create_app +from console.settings import Settings +from console.store import Store + + +class ConsoleTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.source = self.root / "original" / "scripts" + self.source.mkdir(parents=True) + self.settings = Settings(self.root / "runtime", [ + {"id": "trial", "path": str(self.source)}], allowed_hosts=["testserver"], + allowed_networks=["127.0.0.0/8"], test_mode=True) + self.settings.test_definitions = [{"key": name, "source_group": "trial", "entry": name, + "name": name, "kind": "test"} for name in ("sample.py", "test.py", "stop.sh")] + self.app = create_app(self.settings) + self.client = TestClient(self.app, client=("127.0.0.1", 5555)) + self.headers = {"X-XAS-Console": "1"} + + def tearDown(self): + self.client.close() + self.temp.cleanup() + + def upload(self, content=b"print('hello')\n", name="test.py"): + r = self.client.post("/api/scripts/upload", files={"file": (name, content)}, + data={"name": "Sample"}, headers=self.headers) + self.assertEqual(r.status_code, 200, r.text) + return r.json() + + def queue(self, script, key="9ad0745c-0d5e-4d1e-88e8-000000000001"): + return self.client.post(f"/api/scripts/{script['id']}/runs", + json={"request_id": key}, headers=self.headers) + + def test_upload_versions_are_immutable_and_runs_pin_version(self): + script = self.upload() + run = self.queue(script).json() + new = self.client.post(f"/api/scripts/{script['id']}/versions", + files={"file": ("test.py", b"print('v2')\n")}, headers=self.headers) + self.assertEqual(new.status_code, 200, new.text) + old = self.client.get(f"/api/scripts/{script['id']}/code?version=1").json() + self.assertEqual(old["code"], "print('hello')\n") + self.assertEqual(self.client.get(f"/api/runs/{run['id']}").json()["version"], 1) + self.assertEqual(new.json()["version"], 2) + + def test_code_cannot_be_patched_and_path_cannot_be_replaced(self): + s = self.upload() + for patch in ({"code": "bad"}, {"source_path": "/etc/passwd"}, + {"config": {"profile": "legacy_smoke"}}): + r = self.client.patch(f"/api/scripts/{s['id']}", json=patch, headers=self.headers) + self.assertEqual(r.status_code, 422, r.text) + + def test_upload_rejects_traversal_secrets_and_oversize(self): + for name, content in [("../x.py", b"pass"), ("x.env", b"pass"), + ("x.py", b"API_KEY = 'abc-secret-key-value-01234'"), + ("x.py", b"x" * (1024 * 1024 + 1))]: + r = self.client.post("/api/scripts/upload", files={"file": (name, content)}, + headers=self.headers) + self.assertIn(r.status_code, (400, 413, 422), r.text) + + def test_cross_site_mutations_and_host_spoofing_rejected(self): + r = self.client.post("/api/discover", headers={**self.headers, "Origin": "https://evil.test"}) + self.assertEqual(r.status_code, 403) + self.assertEqual(self.client.post("/api/discover").status_code, 403) + self.assertEqual(self.client.get("/api/summary", headers={"Host": "evil.test"}).status_code, 403) + + def test_duplicate_submission_and_single_claim(self): + s = self.upload() + a, b = self.queue(s), self.queue(s) + self.assertEqual(a.json()["id"], b.json()["id"]) + store = Store(self.settings) + self.assertEqual(store.claim()["id"], a.json()["id"]) + self.assertIsNone(store.claim()) + self.queue(s, "9ad0745c-0d5e-4d1e-88e8-000000000002") + self.assertIsNone(store.claim()) + + def test_pending_cancellation_does_not_execute(self): + run = self.queue(self.upload()).json() + r = self.client.post(f"/api/runs/{run['id']}/cancel", headers=self.headers) + self.assertEqual(r.json()["state"], "cancelled") + self.assertIsNone(Store(self.settings).claim()) + + def test_external_inventory_readonly_and_missing_detection(self): + p = self.source / "sample.py" + p.write_text("print('original')", encoding="utf-8") + self.client.post("/api/discover", headers=self.headers) + s = self.client.get("/api/scripts").json()["items"][0] + self.assertEqual(s["kind"], "external") + self.assertFalse(s["enabled"]) + self.assertEqual(p.read_text(), "print('original')") + p.unlink() + self.client.post("/api/discover", headers=self.headers) + self.assertEqual(self.client.get(f"/api/scripts/{s['id']}").json()["status"], "missing") + + def test_service_management_script_cannot_be_enabled(self): + (self.source / "stop.sh").write_text("docker stop other-service\n") + self.client.post("/api/discover", headers=self.headers) + s = self.client.get("/api/scripts").json()["items"][0] + r = self.client.patch(f"/api/scripts/{s['id']}", json={"enabled": True}, headers=self.headers) + self.assertEqual(r.status_code, 400, r.text) + + def test_external_output_path_cannot_escape_source_family(self): + (self.source / "test.py").write_text("print(1)\n") + self.client.post("/api/discover", headers=self.headers) + s = self.client.get("/api/scripts").json()["items"][0] + for path in ["/etc", str(self.source), str(self.source.parent / "configs")]: + r = self.client.patch(f"/api/scripts/{s['id']}", + json={"config": {"output_paths": [path]}}, headers=self.headers) + self.assertEqual(r.status_code, 400, r.text) + + def test_log_offset_unicode_and_artifact_path_boundary(self): + run = self.queue(self.upload()).json() + d = self.settings.runs / run["id"] + d.mkdir(exist_ok=True) + (d / "console.log").write_bytes("你好\n".encode("utf-8")) + a = self.client.get(f"/api/runs/{run['id']}/log?offset=0").json() + self.assertEqual(a["text"], "你好\n") + b = self.client.get(f"/api/runs/{run['id']}/log?offset={a['next_offset']}").json() + self.assertEqual(b["text"], "") + self.assertEqual(self.client.get(f"/api/runs/{run['id']}/artifacts/nope").status_code, 404) + + def test_finished_log_utf8_character_across_chunk_boundary(self): + run = self.queue(self.upload()).json() + directory = self.settings.runs / run["id"] + directory.mkdir() + content = "x" * 65535 + "你" + "tail" + (directory / "console.log").write_bytes(content.encode("utf-8")) + Store(self.settings).finish(run["id"], "succeeded") + first = self.client.get(f"/api/runs/{run['id']}/log").json() + second = self.client.get(f"/api/runs/{run['id']}/log?offset={first['next_offset']}").json() + self.assertEqual(first["text"] + second["text"], content) + + def test_runtime_parent_link_rejected_before_writes(self): + from deploy.dedicated import validate_runtime + outside = self.root / "outside" + outside.mkdir() + project = self.root / "project" + project.mkdir() + try: + (project / "runtime").symlink_to(outside, target_is_directory=True) + except OSError: + self.skipTest("OS account cannot create directory symlinks") + with self.assertRaises(ValueError): + validate_runtime(project) + with self.assertRaises(ValueError): + Settings(project / "runtime", []).prepare() + self.assertEqual(list(outside.iterdir()), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_console_outputs.py b/tests/test_console_outputs.py new file mode 100644 index 0000000..aa8925a --- /dev/null +++ b/tests/test_console_outputs.py @@ -0,0 +1,126 @@ +from http.server import BaseHTTPRequestHandler, HTTPServer +import json +from pathlib import Path +import tempfile +import threading +import unittest +import urllib.error +import urllib.request +from console.artifacts import Collector +from console.job_main import BudgetRelay +from console.settings import Settings + + +class OutputTests(unittest.TestCase): + def test_final_file_log_drain_includes_fast_script_tail_larger_than_one_chunk(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + logs, run = root / "logs", root / "run" + logs.mkdir() + run.mkdir() + collector = Collector(Settings(root, []), run, [str(logs)]) + payload = b"file log\n" * 10000 + (logs / "new.log").write_bytes(payload) + parts = [] + collector.finish_follow(parts.append) + self.assertEqual(b"".join(parts), "\n[文件日志 new.log]\n".encode() + payload) + self.assertEqual(collector.follow(parts.append), 0) + + def test_proxy_does_not_redirect_credentials_or_add_requests(self): + seen = [] + class Redirect(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + def do_GET(self): + seen.append(self.path) + self.send_response(302) + self.send_header("Location", "/unexpected") + self.end_headers() + upstream = HTTPServer(("127.0.0.1", 0), Redirect) + relay = BudgetRelay(("127.0.0.1", 0), f"http://127.0.0.1:{upstream.server_port}", "test-only-credential") + threads = [threading.Thread(target=s.serve_forever, daemon=True) for s in (upstream, relay)] + for thread in threads: + thread.start() + try: + with self.assertRaises(urllib.error.HTTPError) as rejected: + urllib.request.urlopen(f"http://127.0.0.1:{relay.server_port}/v1/models", timeout=5) + self.assertEqual(rejected.exception.code, 302) + self.assertEqual(seen, ["/v1/models"]) + self.assertEqual(relay.gets, 1) + finally: + for server in (relay, upstream): + server.shutdown() + server.server_close() + for thread in threads: + thread.join() + + def test_only_changed_external_outputs_are_archived_and_originals_preserved(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + output, run = root / "original_logs", root / "run" + output.mkdir() + run.mkdir() + (run / "work").mkdir() + old = output / "old.json" + old.write_text('{"old":true}') + log = output / "current.log" + log.write_bytes(b"previous\n") + collector = Collector(Settings(root, []), run, [str(output)]) + with log.open("ab") as handle: + handle.write("新日志\n".encode()) + messages = [] + collector.follow(messages.append) + collector.follow(messages.append) + self.assertEqual(len(messages), 1) + self.assertIn("新日志", messages[0].decode()) + self.assertNotIn("previous", messages[0].decode()) + (run / "work/result.json").write_text('{"ok":true}') + items = collector.collect() + self.assertEqual({i["name"] for i in items}, {"current.log", "result.json"}) + self.assertEqual(old.read_text(), '{"old":true}') + self.assertTrue(log.exists()) + log.write_text("overwritten later") + archived = next(i for i in items if i["name"] == "current.log") + self.assertIn("新日志", (run / "artifacts" / archived["id"]).read_text(encoding="utf-8")) + + def test_model_proxy_budget_and_auth_are_per_run_without_retry(self): + requests = [] + class Upstream(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + def do_GET(self): + requests.append((self.path, self.headers.get("Authorization"))) + self.send_response(200) + self.end_headers() + self.wfile.write(b'{"data":[]}') + def do_POST(self): + self.rfile.read(int(self.headers["Content-Length"])) + requests.append((self.path, self.headers.get("Authorization"))) + self.send_response(503) + self.end_headers() + upstream = HTTPServer(("127.0.0.1", 0), Upstream) + relay = BudgetRelay(("127.0.0.1", 0), f"http://127.0.0.1:{upstream.server_port}", "test-only-credential") + threads = [threading.Thread(target=s.serve_forever, daemon=True) for s in (upstream, relay)] + for t in threads: + t.start() + url = f"http://127.0.0.1:{relay.server_port}" + try: + with urllib.request.urlopen(url + "/v1/models", timeout=5) as response: + self.assertEqual(response.status, 200) + with self.assertRaises(urllib.error.HTTPError) as rejected: + urllib.request.urlopen(url + "/v1/models", timeout=5) + self.assertEqual(rejected.exception.code, 403) + data = json.dumps({"model": "deepseek-v4.1-flash", "max_tokens": 512}).encode() + for i in range(4): + with self.assertRaises(urllib.error.HTTPError) as rejected: + urllib.request.urlopen(url + "/v1/chat/completions", data, timeout=5) + self.assertEqual(rejected.exception.code, 503 if i < 3 else 403) + self.assertEqual(len(requests), 4) + self.assertTrue(all(auth == "Bearer test-only-credential" for _, auth in requests)) + self.assertNotIn("test-only-credential", json.dumps(relay.records)) + finally: + for server in (relay, upstream): + server.shutdown() + server.server_close() + for thread in threads: + thread.join() diff --git a/tests/test_console_worker.py b/tests/test_console_worker.py new file mode 100644 index 0000000..bdf52dd --- /dev/null +++ b/tests/test_console_worker.py @@ -0,0 +1,48 @@ +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch +from console.catalog import upload +from console.settings import Settings +from console.store import Store + + +@unittest.skipUnless(sys.platform == "linux", "worker dispatcher runs on Linux") +class WorkerFinalizationTests(unittest.TestCase): + def test_unmount_failure_changes_both_record_and_archived_summary(self): + from console.worker import Worker + with tempfile.TemporaryDirectory() as temp: + settings = Settings(Path(temp), []) + store = Store(settings) + script = upload(store, b"print('test')\n", "test.py") + run = store.queue(script, "test-request", "127.0.0.1") + store.claim() + worker = Worker(settings, {}) + directory = settings.runs / run["id"] + real_popen = subprocess.Popen + def command(value, target): + (target / "work").mkdir() + (target / "work/result.txt").write_text("test output") + return ["create"], {} + def fake_docker(*args, **kwargs): + if args[0] == "inspect": + return json.dumps([{"Config": {"Labels": {"xas.console": "dedicated"}}, + "State": {"ExitCode": 0, "Running": False}}]) + return "owned-test-container" + def process(*args, **kwargs): + return real_popen([sys.executable, "-c", "import sys;sys.stdin.readline();print('output')"], **kwargs) + with patch.object(worker, "command", command), patch("console.worker.docker", fake_docker), \ + patch("console.worker.subprocess.Popen", process), \ + patch("console.worker.workspace.mounts", return_value=[directory / "work"]), \ + patch("console.worker.workspace.remove", side_effect=OSError("simulated unmount failure")): + worker.execute(run) + result = store.run(run["id"]) + self.assertEqual(result["state"], "interrupted") + summary = next(a for a in result["artifacts"] if a["name"] == "summary.json") + archived = json.loads((directory / "artifacts" / summary["id"]).read_text()) + self.assertEqual(archived["state"], result["state"]) + self.assertEqual(archived["message"], result["message"]) + self.assertTrue(worker.stopping) diff --git a/tests/test_deployment.py b/tests/test_deployment.py new file mode 100644 index 0000000..29a7f60 --- /dev/null +++ b/tests/test_deployment.py @@ -0,0 +1,82 @@ +import importlib.util +from pathlib import Path +import tempfile +import unittest +from unittest import mock +import contextlib +import io +import json +import sys +from types import SimpleNamespace + +MODULE = Path(__file__).resolve().parents[1] / "deploy/start.py" +spec = importlib.util.spec_from_file_location("start_console", MODULE) +start = importlib.util.module_from_spec(spec) +spec.loader.exec_module(start) + + +class DeploymentPathsTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.base = Path(self.temp.name) + self.project = self.base / "project" + self.project.mkdir() + self.outside = self.base / "other-owner" + self.outside.mkdir() + + def validator(self): + self.assertTrue(hasattr(start, "validate_runtime"), "runtime boundary validation is missing") + return start.validate_runtime + + def link(self, target, destination): + try: + destination.symlink_to(target, target_is_directory=True) + except OSError: + self.skipTest("OS account cannot create a directory symlink") + + def test_runtime_parent_link_cannot_escape_project(self): + validate = self.validator() + self.link(self.outside, self.project / "runtime") + before = self.outside.stat().st_mode + with self.assertRaises(ValueError): + validate(self.project) + self.assertEqual(self.outside.stat().st_mode, before) + self.assertEqual(list(self.outside.iterdir()), []) + + def test_runtime_child_link_cannot_escape_project(self): + validate = self.validator() + (self.project / "runtime").mkdir() + self.link(self.outside, self.project / "runtime/data") + with self.assertRaises(ValueError): + validate(self.project) + self.assertEqual(list(self.outside.iterdir()), []) + + def test_normal_runtime_is_resolved_without_writing(self): + validate = self.validator() + self.assertEqual(validate(self.project), self.project.resolve() / "runtime") + self.assertEqual(list(self.project.iterdir()), []) + + def test_backend_is_only_on_proxy_loopback_namespace(self): + # Regress the reviewed direct-container-IP role-header bypass at the Docker boundary. + deploy = self.project / 'deploy' + deploy.mkdir() + (deploy/'lan-settings.json').write_text(json.dumps({ + 'address':'10.88.118.188','allowed_cidrs':['10.88.0.0/17','10.20.0.0/21'],'port':14440})) + calls = [] + def docker(args, **kwargs): + calls.append(args) + absent = args[:3] in (['docker','container','inspect'], ['docker','network','inspect']) + return SimpleNamespace(returncode=1 if absent else 0, stdout='') + with mock.patch.object(sys, 'argv', ['start.py','--project-root',str(self.project)]), \ + mock.patch.object(sys, 'path', [str(MODULE.parent)] + sys.path), \ + mock.patch.object(start.subprocess, 'run', side_effect=docker), \ + mock.patch.object(start.os, 'chown', create=True), \ + mock.patch.object(start.socket, 'socket'), contextlib.redirect_stdout(io.StringIO()): + start.main() + backend = next(c for c in calls if '--name' in c and c[c.index('--name')+1]=='adamwen-test-console') + proxy = next(c for c in calls if '--name' in c and c[c.index('--name')+1]=='adamwen-test-console-proxy') + self.assertIn('RUNDECK_SERVER_ADDRESS=127.0.0.1', backend) + self.assertEqual(backend[backend.index('--network')+1], 'container:adamwen-test-console-proxy') + self.assertNotIn('--publish', backend) + self.assertLess(calls.index(proxy), calls.index(backend)) diff --git a/tests/test_lan_config.py b/tests/test_lan_config.py new file mode 100644 index 0000000..f5eedad --- /dev/null +++ b/tests/test_lan_config.py @@ -0,0 +1,36 @@ +import importlib.util +from pathlib import Path +import unittest + +MODULE = Path(__file__).resolve().parents[1] / 'deploy/lan_config.py' + + +class LanConfigurationTests(unittest.TestCase): + def validator(self): + self.assertTrue(MODULE.is_file(), 'LAN boundary configuration is not implemented') + spec = importlib.util.spec_from_file_location('lan_config', MODULE) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.validate_settings + + def test_explicit_private_interface_and_matching_subnet_are_accepted(self): + result = self.validator()({'address':'10.88.118.188','allowed_cidrs':['10.88.0.0/17','10.20.0.0/21'],'port':14440}) + self.assertEqual(result['address'], '10.88.118.188') + self.assertEqual(result['allowed_cidrs'], ['10.88.0.0/17','10.20.0.0/21']) + + def test_wildcard_public_loopback_and_nonmatching_network_are_rejected(self): + validate = self.validator() + for address, cidr in [('0.0.0.0','0.0.0.0/0'), ('8.8.8.8','8.8.8.0/24'), + ('127.0.0.1','127.0.0.0/8'), ('10.88.118.188','10.89.0.0/17'), + ('10.88.118.188','0.0.0.0/0'), ('10.88.118.188; admin','10.88.0.0/17')]: + with self.subTest(address=address,cidr=cidr), self.assertRaises(ValueError): + validate({'address':address,'allowed_cidrs':[cidr],'port':14440}) + + def test_invalid_port_and_extra_fields_are_rejected(self): + validate = self.validator() + base = {'address':'10.88.118.188','allowed_cidrs':['10.88.0.0/17','10.20.0.0/21'],'port':14440} + for port in (0, 80, 65536, True, '14440'): + with self.subTest(port=port), self.assertRaises(ValueError): + validate(dict(base, port=port)) + with self.assertRaises(ValueError): + validate(dict(base, public=True)) diff --git a/tests/test_native_lifecycle.py b/tests/test_native_lifecycle.py new file mode 100644 index 0000000..0236b57 --- /dev/null +++ b/tests/test_native_lifecycle.py @@ -0,0 +1,89 @@ +import json +from pathlib import Path +from types import SimpleNamespace +import tempfile +import os +import unittest +from unittest.mock import patch +from console.native_executor import inspect, NativeExecutor +from console.native_io import SecretFilter +from console.artifacts import Collector +from console.settings import Settings +from console.store import Store + + +class LifecycleTests(unittest.TestCase): + def test_many_old_outputs_do_not_hide_new_native_log(self): + with tempfile.TemporaryDirectory() as temp: + root=Path(temp);logs=root/'logs';run=root/'run' + logs.mkdir();run.mkdir();(run/'work').mkdir() + for number in range(300):(logs/f'{number:04}.log').write_text('old history') + collector=Collector(Settings(root,[]),run,lambda:sorted(logs.iterdir()),file_limit=4096) + (logs/'new.log').write_text('current progress') + chunks=[];collector.finish_follow(chunks.append) + self.assertIn(b'current progress',b''.join(chunks)) + self.assertNotIn(b'old history',b''.join(chunks)) + self.assertEqual([a['name'] for a in collector.collect()],['new.log']) + + @unittest.skipIf(os.name == "nt", "Linux controlling terminal") + def test_menu_has_controlling_terminal(self): + import pty + import subprocess + import sys + from console.native_job import acquire_terminal + master, slave = pty.openpty() + try: + result = subprocess.run([sys.executable, "-c", "import os; t=os.open('/dev/tty',os.O_RDWR); os.close(t)"], + stdin=slave, stdout=slave, stderr=slave, start_new_session=True, preexec_fn=acquire_terminal, timeout=5) + self.assertEqual(result.returncode, 0) + finally: + os.close(master);os.close(slave) + + def test_systemd_probe_error_is_not_missing_unit(self): + result = SimpleNamespace(returncode=1, stdout="", stderr="D-Bus unavailable") + with patch("console.native_executor.subprocess.run", return_value=result): + with self.assertRaises(OSError):inspect("a" * 32) + result.stdout = "LoadState=not-found\n" + with patch("console.native_executor.subprocess.run", return_value=result): + self.assertIsNone(inspect("a" * 32)) + + def test_file_log_secret_split_at_chunk_boundary_is_filtered(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + logs, run = root / "logs", root / "run" + logs.mkdir();run.mkdir();(run / "work").mkdir() + secret = "dummy-fixture-credential-123456" + collector = Collector(Settings(root, []), run, [logs], stream_filter_factory=lambda: SecretFilter([secret])) + (logs / "x.log").write_bytes(b"x" * 32760 + secret.encode() + b"\n") + chunks=[];collector.finish_follow(chunks.append) + result=b"".join(chunks) + self.assertNotIn(secret.encode(),result) + self.assertIn(b"[REDACTED]",result) + + def test_terminal_state_waits_for_confirmed_cleanup(self): + with tempfile.TemporaryDirectory() as temp: + settings=Settings(Path(temp),[]);store=Store(settings) + script={"id":"b"*32,"name":"fixture","version":1,"sha256":"x","config":{},"source_path":"fixture.py", + "kind":"external","source_group":"fixture","language":"python"} + run=store.queue(script,"fixture-request","127.0.0.1",execution={"mode":"native"}) + store.claim() + directory=settings.runs/run['id'];directory.mkdir() + (directory/'native-result.json').write_text(json.dumps({'exit_code':0,'artifacts':[]})) + executor=NativeExecutor(SimpleNamespace(settings=settings,store=store,stopping=False)) + with patch.object(executor,'wait_stopped',return_value=False): + executor.finish(run,{'Result':'success'}) + self.assertEqual(store.run(run['id'])['state'],'running') + self.assertIsNone(store.claim()) + + def test_cancelled_before_dispatch_does_not_create_unit(self): + with tempfile.TemporaryDirectory() as temp: + settings=Settings(Path(temp),[]);store=Store(settings) + script={"id":"b"*32,"name":"fixture","version":1,"sha256":"x","config":{},"source_path":"fixture.py", + "kind":"external","source_group":"fixture","language":"python"} + run=store.queue(script,"fixture-request","127.0.0.1",execution={"mode":"native"}) + store.claim();store.cancel(run['id']) + executor=NativeExecutor(SimpleNamespace(settings=settings,store=store,stopping=False)) + with patch('console.native_executor.subprocess.run') as launch: + executor.execute(run) + launch.assert_not_called() + self.assertEqual(store.run(run['id'])['state'],'cancelled') diff --git a/tests/test_native_plans.py b/tests/test_native_plans.py new file mode 100644 index 0000000..df7e74b --- /dev/null +++ b/tests/test_native_plans.py @@ -0,0 +1,89 @@ +import hashlib +import json +from pathlib import Path +import sys +import tempfile +import unittest +from console.settings import Settings +from console.native_plans import build_plan, profile_for, validate_options, public_execution +from console.native_io import SecretFilter + + +class NativePlanTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + source = self.root / "original" + source.mkdir() + (source / "logs").mkdir() + self.entry = source / "test.py" + self.entry.write_text("print('original')\n") + self.settings = Settings(self.root / "runtime", [{"id": "original", "path": str(source)}]) + self.profile = {"id": "test", "source_group": "original", "entry": "test.py", "cwd": str(source), + "interpreter": sys.executable, "user": "root", "arguments": ["{concurrency}", "{output_file}"], + "parameters": [{"key": "concurrency", "label": "并发", "type": "integer", "default": 2, "min": 1, "max": 8}, + {"key": "configuration", "type": "select", "default": "known", "options": [{"value": "known", "label": "已登记配置"}]}], + "impact": {"level": "service", "title": "切换服务", "details": ["可能停止旧服务"], "requires_confirmation": True}, + "environment": {}, "credential_files": [], "interactive": False, "output_paths": [str(source / "logs" / "console-{run_id}*")]} + self.settings.native_catalog = {"profiles": [self.profile], "targets": []} + self.script = {"id": "a" * 32, "kind": "external", "source_group": "original", "source_path": str(self.entry), + "sha256": hashlib.sha256(self.entry.read_bytes()).hexdigest(), "config": {"native_options": {}, "timeout_seconds": 300}, "language": "python"} + + def tearDown(self): + self.temp.cleanup() + + def test_plan_uses_original_file_and_typed_argv_without_shell_join(self): + before = self.entry.read_bytes() + plan = build_plan(self.settings, self.script, run_id="b" * 32, check_host=False) + self.assertEqual(plan["argv"][:2], [sys.executable, str(self.entry)]) + self.assertEqual(plan["argv"][2], "2") + self.assertIn("console-" + "b" * 32, plan["argv"][3]) + self.assertEqual(plan["cwd"], str(self.entry.parent)) + self.assertEqual(self.entry.read_bytes(), before) + + def test_unregistered_uploaded_and_replaced_entries_cannot_run_natively(self): + for change in ({"kind": "managed"}, {"source_path": str(self.root / "unknown.py")}, {"source_group": "other"}): + self.assertIsNone(profile_for(self.settings, {**self.script, **change})) + + def test_parameters_reject_injection_unknown_keys_bool_and_out_of_range(self): + for value in ({"configuration": "../../etc/profile"}, {"configuration": "known;id"}, + {"concurrency": True}, {"concurrency": 9}, {"command": "id"}): + with self.assertRaises(ValueError): + validate_options(self.profile, value) + + def test_confirmation_changes_with_options_source_or_recipe(self): + first = build_plan(self.settings, self.script, check_host=False)["plan_token"] + self.script["config"]["native_options"] = {"concurrency": 3} + second = build_plan(self.settings, self.script, check_host=False)["plan_token"] + self.assertNotEqual(first, second) + self.profile["impact"]["details"].append("使用全部 GPU") + self.assertNotEqual(second, build_plan(self.settings, self.script, check_host=False)["plan_token"]) + self.entry.write_text("print('changed')\n") + with self.assertRaises(ValueError): + build_plan(self.settings, self.script, check_host=False) + + def test_credentials_not_in_public_execution(self): + self.profile["credential_files"] = [str(self.root / "private.env")] + visible = public_execution(self.settings, self.script) + self.assertEqual(visible["mode"], "native") + self.assertNotIn("credential_files", visible) + self.assertNotIn("private.env", json.dumps(visible)) + + def test_stream_filter_redacts_secret_split_across_reads(self): + secret = "dummy-credential-abcdefghijk" + filt = SecretFilter([secret]) + output = filt.feed(b"hello\nkey=dummy-cred") + output += filt.feed(b"ential-abcdefghijk\nworld\n") + output += filt.finish() + self.assertNotIn(secret, output.decode()) + self.assertIn("[REDACTED]", output.decode()) + self.assertIn("world", output.decode()) + + def test_ansi_split_inside_secret_cannot_reconstruct_plaintext(self): + secret = "dummy-credential-abcdefghijk" + filt = SecretFilter([secret]) + result = filt.feed(b"dummy-cred\x1b[") + result += filt.feed(b"31mential-abcdefghijk\x1b[0m\n") + result += filt.finish() + self.assertNotIn(secret.encode(), result) + self.assertIn(b"[REDACTED]", result) diff --git a/tests/test_native_rpc.py b/tests/test_native_rpc.py new file mode 100644 index 0000000..5f507de --- /dev/null +++ b/tests/test_native_rpc.py @@ -0,0 +1,68 @@ +import hashlib +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +from console.settings import Settings +from console.store import Store +from console.native_plans import build_plan +from console.native_rpc import handle + + +class NativeRPCTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + root = Path(self.temp.name) + source = root / "source" + source.mkdir() + entry = source / "test.py" + entry.write_text("print(1)\n") + self.settings = Settings(root / "runtime", [{"id": "original", "path": str(source)}]) + self.settings.test_definitions = [{"key": "native", "source_group": "original", "entry": "test.py", "name": "原环境样例"}] + self.settings.native_catalog = {"targets": [], "profiles": [{"id": "native", "source_group": "original", "entry": "test.py", + "cwd": str(source), "user": "root", "interpreter": "/usr/bin/python3", "parameters": [], "arguments": [], + "impact": {"level": "service", "title": "服务切换", "details": ["会停止指定服务"], "requires_confirmation": True}}]} + self.script = {"id": "a" * 32, "name": "test.py", "description": "", "kind": "external", "source_group": "original", + "source_path": str(entry), "sha256": hashlib.sha256(entry.read_bytes()).hexdigest(), "language": "python", "version": 1, + "enabled": True, "status": "ready", "config": {"native_options": {}, "timeout_seconds": 300}} + self.store = Store(self.settings) + self.store.save_script(self.script) + self.plan = build_plan(self.settings, self.script, check_host=False) + self.request = {"op": "queue", "script_id": self.script["id"], "request_id": "5b0202b7-851d-48ad-a7b9-dce22f000001", + "plan_token": self.plan["plan_token"], "source_ip": "127.0.0.1"} + + def tearDown(self): + self.temp.cleanup() + + def test_server_requires_explicit_current_confirmation(self): + with patch("console.native_rpc.native_plans.build_plan", return_value=self.plan): + with self.assertRaises(ValueError):handle(self.settings, self.store, self.request) + with self.assertRaises(ValueError):handle(self.settings, self.store, {**self.request, "impact_acknowledged": True, "plan_token": "old"}) + self.assertEqual(self.store.runs(), []) + run = handle(self.settings, self.store, {**self.request, "impact_acknowledged": True}) + self.assertTrue(run["execution"]["impact_acknowledged"]) + self.assertEqual(run["execution"]["mode"], "native") + + def test_retry_returns_original_even_if_plan_later_changed(self): + with patch("console.native_rpc.native_plans.build_plan", return_value=self.plan): + first = handle(self.settings, self.store, {**self.request, "impact_acknowledged": True}) + with patch("console.native_rpc.native_plans.build_plan", side_effect=ValueError("changed")): + again = handle(self.settings, self.store, self.request) + self.assertEqual(first["id"], again["id"]) + self.assertEqual(len(self.store.runs()), 1) + + def test_failed_preflight_and_unknown_operation_never_queue(self): + with patch("console.native_rpc.native_plans.build_plan", return_value={**self.plan, "ready": False}): + with self.assertRaises(ValueError):handle(self.settings, self.store, {**self.request, "impact_acknowledged": True}) + with self.assertRaises(ValueError):handle(self.settings, self.store, {"op": "exec", "command": "id"}) + self.assertEqual(self.store.runs(), []) + + def test_input_rejected_for_queued_or_noninteractive_run(self): + run = self.store.queue(self.script, "sample", "127.0.0.1", execution={"mode": "native", "interactive": True}) + with self.assertRaises(ValueError):handle(self.settings, self.store, {"op": "input", "run_id": run["id"], "text": "1"}) + with self.assertRaises(ValueError):handle(self.settings, self.store, {"op": "input", "run_id": "../../bad", "text": "1"}) + + def test_input_channel_rejects_tmux_keys_and_shell_commands(self): + for text in ("\x02c", "\x1b", "bash", "1\nid", "echo test", ":new-window"): + with self.assertRaisesRegex(ValueError, "控制键"): + handle(self.settings, self.store, {"op": "input", "run_id": "a"*32, "text": text}) diff --git a/tests/test_runner.py b/tests/test_runner.py new file mode 100644 index 0000000..a896556 --- /dev/null +++ b/tests/test_runner.py @@ -0,0 +1,102 @@ +"""Behavioral acceptance; Linux process tests also run on the target host.""" +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import tempfile +import time +import unittest + +ROOT = Path(__file__).resolve().parents[1] +RUNNER = ROOT / "scripts" / "run_smoke.py" + + +class RunnerTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.results = Path(self.temp.name) + + def command(self, mode="success", run_id="test-1", timeout="3"): + self.assertTrue(RUNNER.is_file(), "run_smoke.py is not implemented") + return [sys.executable, "-u", str(RUNNER), "--mode", mode, + "--run-id", run_id, "--timeout", timeout, + "--results-root", str(self.results)] + + def run_task(self, **kwargs): + return subprocess.run(self.command(**kwargs), capture_output=True, + text=True, timeout=12) + + def summary(self, run_id="test-1"): + return json.loads((self.results / run_id / "summary.json").read_text()) + + def test_success_streams_progress_and_records_synthetic_result(self): + result = self.run_task() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + summary = self.summary() + self.assertEqual(summary["status"], "succeeded") + self.assertEqual(summary["run_id"], "test-1") + self.assertTrue(summary["synthetic"]) + self.assertEqual(summary["result"]["checks_passed"], 3) + self.assertIn("progress=1/3", result.stdout) + self.assertIn("progress=3/3", result.stdout) + self.assertEqual(len(summary["source_sha256"]["smoke_task.py"]), 64) + + def test_nonzero_exit_is_recorded_as_failure(self): + result = self.run_task(mode="failure") + self.assertNotEqual(result.returncode, 0) + self.assertEqual(self.summary()["status"], "failed") + self.assertEqual(self.summary()["exit_code"], 23) + + def test_zero_exit_with_invalid_report_is_failure(self): + result = self.run_task(mode="invalid-report") + self.assertNotEqual(result.returncode, 0) + self.assertEqual(self.summary()["status"], "invalid_result") + + def test_reused_run_id_preserves_previous_result(self): + self.assertEqual(self.run_task().returncode, 0) + before = (self.results / "test-1" / "summary.json").read_bytes() + self.assertNotEqual(self.run_task(mode="failure").returncode, 0) + self.assertEqual((self.results / "test-1" / "summary.json").read_bytes(), before) + + def test_invalid_inputs_do_not_create_run_directory(self): + for kwargs in ({"run_id": "../escape"}, {"mode": "arbitrary"}, + {"timeout": "0"}, {"timeout": "31"}, {"timeout": "nan"}): + with self.subTest(kwargs=kwargs): + self.assertNotEqual(self.run_task(**kwargs).returncode, 0) + self.assertEqual(list(self.results.iterdir()), []) + + @unittest.skipUnless(sys.platform == "linux", "requires Linux process groups") + def test_timeout_terminates_child_and_records_timeout(self): + result = self.run_task(mode="wait", timeout="2") + self.assertEqual(result.returncode, 124, result.stdout + result.stderr) + summary = self.summary() + self.assertEqual(summary["status"], "timed_out") + self.assertFalse(Path(f'/proc/{summary["child_pid"]}').exists()) + + @unittest.skipUnless(sys.platform == "linux", "requires Linux process groups") + def test_cancel_terminates_parent_and_child(self): + proc = subprocess.Popen(self.command(mode="wait", timeout="15"), + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + try: + deadline = time.monotonic() + 4 + child_file = self.results / "test-1" / "child.pid" + while not child_file.exists() and time.monotonic() < deadline: + time.sleep(.05) + self.assertTrue(child_file.exists(), "worker did not start") + child_pid = int(child_file.read_text()) + os.kill(proc.pid, signal.SIGTERM) + out, err = proc.communicate(timeout=4) + self.assertEqual(proc.returncode, 130, out + err) + self.assertEqual(self.summary()["status"], "cancelled") + self.assertFalse(Path(f"/proc/{child_pid}").exists()) + finally: + if proc.poll() is None: + proc.kill() + proc.communicate() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_test_catalog.py b/tests/test_test_catalog.py new file mode 100644 index 0000000..4dcf940 --- /dev/null +++ b/tests/test_test_catalog.py @@ -0,0 +1,103 @@ +"""A test entry represents behavior, not every source file under a directory.""" +from pathlib import Path +import tempfile +import unittest +from fastapi.testclient import TestClient +from console.app import create_app +from console.settings import Settings +from console.store import Store + + +class TestCatalogTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + root = Path(self.temp.name) + self.source = root / "scripts" + self.source.mkdir() + (self.source / "run-suite.sh").write_text("python3 helper.py\n") + (self.source / "helper.py").write_text("print('helper')\n") + (self.source / "tests").mkdir() + (self.source / "tests/test_internal.py").write_text("assert True\n") + self.settings = Settings(root / "runtime", [{"id": "suite", "path": str(self.source)}], + allowed_hosts=["testserver"], allowed_networks=["127.0.0.0/8"]) + self.settings.test_definitions = [{"key": "suite", "source_group": "suite", "entry": "run-suite.sh", + "name": "模型综合测试", "description": "一轮完整验证", "kind": "suite", + "members": [{"source_group": "suite", "pattern": "**/*"}]}] + self.client = TestClient(create_app(self.settings), client=("127.0.0.1", 5555)) + self.headers = {"X-XAS-Console": "1"} + self.client.post("/api/discover", headers=self.headers) + self.store = Store(self.settings) + + def tearDown(self): + self.client.close() + self.temp.cleanup() + + def test_suite_is_one_item_with_readonly_members_not_three_cards(self): + items = self.client.get("/api/scripts").json()["items"] + self.assertEqual(len(items), 1) + self.assertEqual(items[0]["name"], "模型综合测试") + self.assertEqual(items[0]["test_kind"], "suite") + self.assertEqual(items[0]["file_count"], 3) + files = self.client.get(f"/api/scripts/{items[0]['id']}/files").json()["items"] + self.assertEqual(len(files), 3) + self.assertEqual(sum(f["role"] == "entry" for f in files), 1) + summary = self.client.get("/api/summary").json() + self.assertEqual(summary["scripts"], 1) + self.assertEqual(summary["source_files"], 3) + + def test_refresh_does_not_promote_new_support_file_or_clear_configuration(self): + entry = next(s for s in self.store.scripts() if s["source_path"].endswith("run-suite.sh")) + self.client.patch(f"/api/scripts/{entry['id']}", json={"name": "我的综合测试"}, headers=self.headers) + (self.source / "new_module.py").write_text("pass\n") + self.client.post("/api/discover", headers=self.headers) + items = self.client.get("/api/scripts").json()["items"] + self.assertEqual(len(items), 1) + self.assertEqual(items[0]["name"], "我的综合测试") + self.assertEqual(items[0]["file_count"], 4) + + def test_member_cannot_be_enabled_or_run_by_direct_api(self): + helper = next(s for s in self.store.scripts() if s["source_path"].endswith("helper.py")) + self.assertFalse(self.client.get(f"/api/scripts/{helper['id']}").json()["is_test"]) + enabled = self.client.patch(f"/api/scripts/{helper['id']}", json={"enabled": True}, headers=self.headers) + self.assertEqual(enabled.status_code, 400) + # Even a pre-existing enabled inventory record cannot bypass the entry boundary. + helper.update(enabled=True, status="ready") + self.store.save_script(helper) + run = self.client.post(f"/api/scripts/{helper['id']}/runs", + json={"request_id": "caa3943d-6872-472c-86c2-500000000001"}, headers=self.headers) + self.assertEqual(run.status_code, 400) + self.assertEqual(self.store.runs(), []) + + def test_upload_remains_one_test_and_examples_are_opt_in(self): + uploaded = self.client.post("/api/scripts/upload", headers=self.headers, + files={"file": ("test.py", b"print('custom')\n")}, data={"name": "用户测试"}).json() + example = self.client.post("/api/scripts/upload", headers=self.headers, + files={"file": ("demo.py", b"print('demo')\n")}, data={"name": "演示"}).json() + item = self.store.script(example["id"]) + item["synthetic"] = True + self.store.save_script(item) + regular = self.client.get("/api/scripts").json()["items"] + self.assertEqual({s["name"] for s in regular}, {"模型综合测试", "用户测试"}) + all_items = self.client.get("/api/scripts?include_examples=true").json()["items"] + self.assertEqual(len(all_items), 3) + self.assertTrue(next(s for s in all_items if s["id"] == uploaded["id"])["is_test"]) + + def test_historical_runs_keep_ids_and_source_contents_unchanged(self): + entry = next(s for s in self.store.scripts() if s["source_path"].endswith("run-suite.sh")) + historical = self.store.queue(entry, "past-request", "127.0.0.1") + before = (self.source / "run-suite.sh").read_bytes() + self.client.get("/api/scripts") + self.client.post("/api/discover", headers=self.headers) + self.assertEqual(self.store.run(historical["id"])["script_name"], "run-suite.sh") + self.assertEqual(self.store.run(historical["id"])["script_id"], entry["id"]) + self.assertEqual(before, (self.source / "run-suite.sh").read_bytes()) + + def test_new_run_uses_one_entry_and_the_test_name(self): + entry = self.client.get("/api/scripts").json()["items"][0] + response = self.client.patch(f"/api/scripts/{entry['id']}", json={"enabled": True}, headers=self.headers) + self.assertEqual(response.status_code, 200) + run = self.client.post(f"/api/scripts/{entry['id']}/runs", headers=self.headers, + json={"request_id": "caa3943d-6872-472c-86c2-500000000002"}).json() + self.assertEqual(run["script_name"], "模型综合测试") + self.assertEqual(run["source_path"], str(self.source / "run-suite.sh")) + self.assertEqual(len(self.store.runs()), 1)