From 36c228544e713ccf0c16d3cdb5fbde868b1847b9 Mon Sep 17 00:00:00 2001 From: AdamWen95 <58934607+AdamWen95@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:06:52 +0800 Subject: [PATCH] feat: refresh model and voice test catalog with favicon --- README.md | 7 +- catalog/external-sources.json | 21 +- catalog/native-profiles.json | 399 ++++++++++++++++++++++++-- catalog/test-definitions.json | 66 ++++- console/catalog.py | 3 + console/model_preflight.py | 54 ++++ console/native_container.py | 89 ++++++ console/native_executor.py | 7 + console/native_io.py | 25 +- console/native_plans.py | 37 ++- console/static/favicon-32.png | Bin 0 -> 636 bytes console/static/favicon.svg | 4 + console/static/index.html | 2 + console/static/native-ui.js | 2 +- console/static/scripts.js | 3 +- deploy/dedicated.py | 2 +- deploy/refresh_catalog.py | 93 ++++++ docs/acceptance-refresh-2026-10-20.md | 57 ++++ docs/adr/0004-native-execution.md | 10 + docs/console-operations.md | 16 ++ tests/test_catalog_refresh.py | 43 +++ tests/test_model_preflight.py | 69 +++++ tests/test_native_container.py | 59 ++++ 23 files changed, 1026 insertions(+), 42 deletions(-) create mode 100644 console/model_preflight.py create mode 100644 console/native_container.py create mode 100644 console/static/favicon-32.png create mode 100644 console/static/favicon.svg create mode 100644 deploy/refresh_catalog.py create mode 100644 docs/acceptance-refresh-2026-10-20.md create mode 100644 tests/test_catalog_refresh.py create mode 100644 tests/test_model_preflight.py create mode 100644 tests/test_native_container.py diff --git a/README.md b/README.md index 46fd773..214d4a5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # XAS 测试脚本控制台 -核对日期:2026-10-13。当前采用专用网页控制台,替代前期 Rundeck 作为日常入口。 +核对日期:2026-10-20。当前采用专用网页控制台,替代前期 Rundeck 作为日常入口。 访问 [公司内网测试控制台](http://10.88.118.188:14440/),无需账号密码。 @@ -9,9 +9,9 @@ - 运行:配置参数后排队执行,查看实时标准输出/错误及登记的 .log 内容。 - 记录:查看状态、版本、配置快照、历史日志和归档结果,预览或下载报告。 -当前整理为 23 项业务测试(8 项套件、15 项单项测试),关联原先登记的 307 个源文件。22 项原位置入口已接入原环境执行,保留原目录、解释器、配置和输出行为;普通测试预检通过后提交,资源占用/服务启停测试每次显示影响并确认。两个控制台验收样例默认隐藏,可在“项目用途”中选择查看。原文件、GPU/端口设置和历史记录均保留。 +当前整理为 28 项业务测试(8 项套件、20 项单项测试),关联 320 个原位置源文件,另有 3 个托管脚本。25 项已配置启用,3 项新增语音测试因服务预设、管理凭据或辅助服务尚未齐备而标为待配置。原环境执行保留原入口;语音识别和网关回归复用原依赖镜像,源目录只读、输出独立归档。资源占用/服务启停测试每次显示影响并确认,两个控制台验收样例默认隐藏。 -2026-10-13 23:27 只读预检:16 个原环境方案满足登记的启动条件,6 个因所需模型端口未监听而被拦截。预检通过不等于业务测试通过,也不预留 GPU。GPU 综合套件支持菜单数字、y/n/q 和回车;需要自由文本的维护功能仍从原维护入口使用。本轮仅用独立短样例验证执行链路,没有运行原 GPU/长稳测试。 +2026-10-20 核对:GPU 综合套件仍为 v3.5.0。DeepSeek 当前主名为 `deepseek-v4.1-flash-1017`(18100,仍保留旧别名),新增可选目标 Qwen3.8-27B(GPU 14,18112);这两个目标在运行前会只读核对模型目录。预检通过不等于业务测试通过,也不预留 GPU。GPU 综合套件支持菜单数字、y/n/q 和回车;自由文本维护仍从原入口使用。本轮没有执行原模型、语音或长稳业务测试。 上传版本保存在服务器 `/adamwen-work-space/xas-test-library/runtime/library/`,日志和结果在 `runtime/runs/`,SQLite 在 `runtime/console-data/`,仅记录元数据、状态与文件引用。外部结果原文件保留,同时可归档本次变更;Git 不参与每次运行时拉取。 @@ -22,6 +22,7 @@ - [本轮验收记录](docs/acceptance-dedicated-2026-10-13.md) - [按测试行为整理后的验收](docs/acceptance-behavior-catalog-2026-10-13.md) - [原环境接入与本轮验收](docs/acceptance-native-2026-10-13.md) +- [2026-10-20 模型与测试目录更新](docs/acceptance-refresh-2026-10-20.md) - [原环境执行架构决策](docs/adr/0004-native-execution.md) - [专用控制台架构决策](docs/adr/0002-dedicated-console.md) - [按测试行为组织目录的规则](docs/adr/0003-test-behavior-catalog.md) diff --git a/catalog/external-sources.json b/catalog/external-sources.json index eadaf14..7a8e687 100644 --- a/catalog/external-sources.json +++ b/catalog/external-sources.json @@ -1,10 +1,23 @@ { "schema_version": 1, - "observed_at": "2026-10-13T17:19:29+08:00", + "observed_at": "2026-10-20T09:45:59+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"} + { + "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" + }, + { + "id": "drama3-tests-20261015", + "path": "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/tests" + } ] } diff --git a/catalog/native-profiles.json b/catalog/native-profiles.json index c3c5e99..90df504 100644 --- a/catalog/native-profiles.json +++ b/catalog/native-profiles.json @@ -3,9 +3,9 @@ "targets": [ { "id": "deepseek-a", - "label": "DeepSeek A(原服务,18100)", + "label": "DeepSeek V4.1 Flash 1017(GPU 0–7 · 18100)", "url": "http://127.0.0.1:18100", - "model": "deepseek-v4.1-flash", + "model": "deepseek-v4.1-flash-1017", "environment": { "CT_OFF": "{\"thinking\": false}", "CT_ON": "{\"thinking\": true}", @@ -13,7 +13,23 @@ }, "credential_files": [ "/dengyuyun-joy-work-space/workpc-claude-code/20261010-dsv41-trial/configs/vllm.env" - ] + ], + "verify_model": true + }, + { + "id": "qwen38-g14", + "label": "Qwen3.8-27B(GPU 14 · 18112)", + "url": "http://127.0.0.1:18112", + "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" + ], + "verify_model": true }, { "id": "dsv41cap", @@ -282,7 +298,8 @@ "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", "原 config.env 的 DATA_ROOT=/XAS/GPUTest。只打开原菜单,不自动输入或选择任何测试项目。", "原工具创建的 tmux 持久任务可能脱离本次控制台 cgroup;平台取消后必须核查原工具会话和模型容器,不能承诺全部停止。", - "runs/reports 使用原工具的时间戳产物;只收集本次运行新增或发生变化的文件,不预拷整个历史 runs/reports。" + "runs/reports 使用原工具的时间戳产物;只收集本次运行新增或发生变化的文件,不预拷整个历史 runs/reports。", + "2026-10-20:服务器最高已安装版本仍为 v3.5.0,已登记的 224 个源码文件摘要未变;当前 GPU 0–14 已分配给模型/图像/语音服务。" ], "available": true }, @@ -521,7 +538,11 @@ "options": [ { "value": "deepseek-a", - "label": "DeepSeek A(原服务,18100)" + "label": "DeepSeek V4.1 Flash 1017(GPU 0–7 · 18100)" + }, + { + "value": "qwen38-g14", + "label": "Qwen3.8-27B(GPU 14 · 18112)" }, { "value": "dsv41cap", @@ -631,7 +652,11 @@ "options": [ { "value": "deepseek-a", - "label": "DeepSeek A(原服务,18100)" + "label": "DeepSeek V4.1 Flash 1017(GPU 0–7 · 18100)" + }, + { + "value": "qwen38-g14", + "label": "Qwen3.8-27B(GPU 14 · 18112)" }, { "value": "dsv41cap", @@ -784,7 +809,11 @@ "options": [ { "value": "deepseek-a", - "label": "DeepSeek A(原服务,18100)" + "label": "DeepSeek V4.1 Flash 1017(GPU 0–7 · 18100)" + }, + { + "value": "qwen38-g14", + "label": "Qwen3.8-27B(GPU 14 · 18112)" }, { "value": "dsv41cap", @@ -923,7 +952,11 @@ "options": [ { "value": "deepseek-a", - "label": "DeepSeek A(原服务,18100)" + "label": "DeepSeek V4.1 Flash 1017(GPU 0–7 · 18100)" + }, + { + "value": "qwen38-g14", + "label": "Qwen3.8-27B(GPU 14 · 18112)" }, { "value": "dsv41cap", @@ -1032,7 +1065,11 @@ "options": [ { "value": "deepseek-a", - "label": "DeepSeek A(原服务,18100)" + "label": "DeepSeek V4.1 Flash 1017(GPU 0–7 · 18100)" + }, + { + "value": "qwen38-g14", + "label": "Qwen3.8-27B(GPU 14 · 18112)" }, { "value": "dsv41cap", @@ -1169,7 +1206,11 @@ "options": [ { "value": "deepseek-a", - "label": "DeepSeek A(原服务,18100)" + "label": "DeepSeek V4.1 Flash 1017(GPU 0–7 · 18100)" + }, + { + "value": "qwen38-g14", + "label": "Qwen3.8-27B(GPU 14 · 18112)" }, { "value": "dsv41cap", @@ -1298,7 +1339,11 @@ "options": [ { "value": "deepseek-a", - "label": "DeepSeek A(原服务,18100)" + "label": "DeepSeek V4.1 Flash 1017(GPU 0–7 · 18100)" + }, + { + "value": "qwen38-g14", + "label": "Qwen3.8-27B(GPU 14 · 18112)" }, { "value": "dsv41cap", @@ -1426,7 +1471,11 @@ "options": [ { "value": "deepseek-a", - "label": "DeepSeek A(原服务,18100)" + "label": "DeepSeek V4.1 Flash 1017(GPU 0–7 · 18100)" + }, + { + "value": "qwen38-g14", + "label": "Qwen3.8-27B(GPU 14 · 18112)" }, { "value": "dsv41cap", @@ -1546,7 +1595,11 @@ "options": [ { "value": "deepseek-a", - "label": "DeepSeek A(原服务,18100)" + "label": "DeepSeek V4.1 Flash 1017(GPU 0–7 · 18100)" + }, + { + "value": "qwen38-g14", + "label": "Qwen3.8-27B(GPU 14 · 18112)" }, { "value": "dsv41cap", @@ -1855,7 +1908,7 @@ "options": [ { "value": "deepseek-a", - "label": "DeepSeek A(原服务,18100)" + "label": "DeepSeek V4.1 Flash 1017(GPU 0–7 · 18100)" } ], "required": true, @@ -1911,7 +1964,8 @@ "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", "通过所选目标的原认证文件在运行时加载 VLLM_API_KEY;不在参数或日志中显示值。", "原 CLI 的数字参数为必填;表单初值为控制台建议值,不冒充原脚本默认值。", - "原脚本硬编码 DeepSeek 模型和 18100 地址;本方案固定目标 A。" + "原脚本硬编码 DeepSeek 模型和 18100 地址;本方案固定目标 A。", + "2026-10-20:当前 DeepSeek 服务主名为 deepseek-v4.1-flash-1017;元数据接口仍提供 deepseek-v4.1-flash 兼容别名,原脚本保持不变。" ], "available": true }, @@ -1935,7 +1989,7 @@ "options": [ { "value": "deepseek-a", - "label": "DeepSeek A(原服务,18100)" + "label": "DeepSeek V4.1 Flash 1017(GPU 0–7 · 18100)" } ], "required": true, @@ -1975,7 +2029,8 @@ "2026-10-13 只读核对原源码;启动方案登记不代表本轮业务测试通过。", "通过所选目标的原认证文件在运行时加载 VLLM_API_KEY;不在参数或日志中显示值。", "复用原 run-d7v-d8.sh 引用的 test-34-vision-input.png;本轮仅核对文件名及大小,未读取或复制图像内容。", - "原入口分别发送关闭/开启 thinking 的两次看图请求;目标服务需已启用视觉。未自动修改或重启模型。" + "原入口分别发送关闭/开启 thinking 的两次看图请求;目标服务需已启用视觉。未自动修改或重启模型。", + "2026-10-20:当前 DeepSeek 服务主名为 deepseek-v4.1-flash-1017;元数据接口仍提供 deepseek-v4.1-flash 兼容别名,原脚本保持不变。" ], "available": true }, @@ -1998,7 +2053,11 @@ "options": [ { "value": "deepseek-a", - "label": "DeepSeek A(原服务,18100)" + "label": "DeepSeek V4.1 Flash 1017(GPU 0–7 · 18100)" + }, + { + "value": "qwen38-g14", + "label": "Qwen3.8-27B(GPU 14 · 18112)" }, { "value": "dsv41cap", @@ -2135,7 +2194,11 @@ "options": [ { "value": "deepseek-a", - "label": "DeepSeek A(原服务,18100)" + "label": "DeepSeek V4.1 Flash 1017(GPU 0–7 · 18100)" + }, + { + "value": "qwen38-g14", + "label": "Qwen3.8-27B(GPU 14 · 18112)" }, { "value": "dsv41cap", @@ -2675,7 +2738,8 @@ "details": [ "原链先使用 GPU 8–15,随后停止 DeepSeek A,使用 GPU 0–15,启停多套模型及 16 个 Qwen 实例。", "原 EXIT trap 会尝试按 start-rec.sh 重启 A;失败、超时或取消不能保证恢复原状态。", - "原日志和结果名称固定,可能覆盖已有 dsv41cap、glm53、mimo、qwen38fp8tp8、qwen38fp8cap 结果;需逐次确认并保全历史文件。" + "原日志和结果名称固定,可能覆盖已有 dsv41cap、glm53、mimo、qwen38fp8tp8、qwen38fp8cap 结果;需逐次确认并保全历史文件。", + "2026-10-20 核对:原链末尾 start-rec.sh 默认切回 UNCENSORED 版,不会恢复当前 1017 主模型。GPU 8–14 另有图像/语音/Qwen 服务,执行前须协调资源。" ], "requires_confirmation": true }, @@ -2762,7 +2826,8 @@ "details": [ "原链等待 cap-8x-chain.sh 退出,再检查 GPU 8–15 显存并启动/停止 DeepSeek 与 8 份 Qwen 模型。", "原结果使用固定 dsv41v-*、qwen38v-* 名称,可能覆盖历史结果。", - "该入口产生本机性能证据,成本折算和报告仍按原配套脚本处理;不代表本轮已验证价格口径。" + "该入口产生本机性能证据,成本折算和报告仍按原配套脚本处理;不代表本轮已验证价格口径。", + "2026-10-20 核对:GPU 8–14 已有图像/语音/Qwen 服务;原测试会在这些 GPU 启动模型,须先协调资源,控制台不自动停止这些新服务。" ], "requires_confirmation": true }, @@ -2803,6 +2868,296 @@ "原入口无位置参数;不伪造可配置的编号或输出路径。原链使用固定历史文件名,再次运行可能覆盖同名结果,应在确认前检查并保全相关历史文件。" ], "available": true + }, + { + "id": "drama3-asr", + "source_group": "drama3-tests-20261015", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/tests/test_asr.py", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3", + "interpreter": "/opt/omni/bin/python", + "user": "root", + "parameters": [], + "arguments": [], + "environment": {}, + "credential_files": [], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/tests/test_asr.py" + ], + "check_ports": [ + 18301, + 18302 + ], + "timeout_seconds": 3600, + "impact": { + "level": "resource", + "title": "语音识别文件与实时转写会占用共享模型服务", + "details": [ + "用现有 Drama3 合成三段录音,核验 Qwen3-ASR 文件转写、实时转写、字错率和响应延迟;会占用共享语音服务。", + "生成的音频与标准输出存入本次运行独立目录,原测试目录和历史结果保留。" + ], + "requires_confirmation": true + }, + "available": true, + "output_paths": [], + "notes": [ + "2026-10-20 已只读核对原源码、依赖镜像和素材,未执行本项业务测试。", + "原脚本只读挂载;测试客户端使用原镜像,无直接 GPU 设备权限,2 CPU / 4 GiB 内存;会通过接口占用既有模型服务。输出使用本次独立目录,不覆盖原历史音频。" + ], + "interactive": false, + "docker": { + "image": "sha256:13369a516b4b961e36b1c3c5d47e3e04ec4bfdc03920964c621ccc83232604b8", + "mounts": [ + { + "source": "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/tests", + "target": "/t" + } + ] + } + }, + { + "id": "drama3-api", + "source_group": "drama3-tests-20261015", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/tests/test_api.py", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3", + "interpreter": "/opt/omni/bin/python", + "user": "root", + "parameters": [], + "arguments": [], + "environment": {}, + "credential_files": [], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/tests/test_api.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/secrets/api/keys.txt", + "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/logs/api-requests.log", + "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/data/voices-src/female-1.wav", + "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/data/voices-src/female-2.wav", + "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/data/voices-src/male-1.wav", + "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/data/voices-src/male-2.wav" + ], + "check_ports": [ + 18300, + 18301 + ], + "timeout_seconds": 3600, + "impact": { + "level": "resource", + "title": "语音合成网关兼容性与质量回归会占用共享模型服务", + "details": [ + "核验本地语音网关的多格式、四种音色、流式、变速、SDK 兼容和八路并发;保留原 selftest 凭据读取方式。", + "生成的音频与标准输出存入本次运行独立目录,原测试目录和历史结果保留。" + ], + "requires_confirmation": true + }, + "available": true, + "output_paths": [], + "notes": [ + "2026-10-20 已只读核对原源码、依赖镜像和素材,未执行本项业务测试。", + "原脚本只读挂载;测试客户端使用原镜像,无直接 GPU 设备权限,2 CPU / 4 GiB 内存;会通过接口占用既有模型服务。输出使用本次独立目录,不覆盖原历史音频。", + "复用原 selftest 身份;只读检查所需日志是否存在,其内容会持续变化,不把日志变化当作启动配置变更。" + ], + "interactive": false, + "docker": { + "image": "sha256:13369a516b4b961e36b1c3c5d47e3e04ec4bfdc03920964c621ccc83232604b8", + "mounts": [ + { + "source": "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/tests", + "target": "/t" + }, + { + "source": "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/secrets/api", + "target": "/secrets" + }, + { + "source": "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/data/voices-src", + "target": "/refs" + }, + { + "source": "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/logs", + "target": "/logs" + } + ] + }, + "secret_tables": [ + { + "path": "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/secrets/api/keys.txt", + "key_column": 1, + "label": "selftest" + } + ], + "mutable_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/logs/api-requests.log" + ] + }, + { + "id": "drama3-voice-chat", + "source_group": "drama3-tests-20261015", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/tests/test_voice_chat.py", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3", + "interpreter": "/opt/omni/bin/python", + "user": "root", + "parameters": [], + "arguments": [], + "environment": {}, + "credential_files": [], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/tests/test_voice_chat.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/secrets/api/keys.txt", + "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/logs/api-requests.log" + ], + "check_ports": [ + 18300, + 18301, + 18302, + 18343, + 18100 + ], + "timeout_seconds": 3600, + "impact": { + "level": "resource", + "title": "实时语音对话端到端回归会占用共享模型服务", + "details": [ + "核验输入、回答、打断和四人对话的完整链路;当前语音服务预设仍指向旧 DeepSeek 模型名,待服务方确认后再开放。", + "生成的音频与标准输出存入本次运行独立目录,原测试目录和历史结果保留。" + ], + "requires_confirmation": true + }, + "available": false, + "output_paths": [], + "notes": [ + "2026-10-20 已只读核对原源码、依赖镜像和素材,未执行本项业务测试。", + "原脚本只读挂载;测试客户端使用原镜像,无直接 GPU 设备权限,2 CPU / 4 GiB 内存;会通过接口占用既有模型服务。输出使用本次独立目录,不覆盖原历史音频。", + "复用原 selftest 身份;只读检查所需日志是否存在,其内容会持续变化,不把日志变化当作启动配置变更。" + ], + "interactive": false, + "docker": { + "image": "sha256:13369a516b4b961e36b1c3c5d47e3e04ec4bfdc03920964c621ccc83232604b8", + "mounts": [ + { + "source": "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/tests", + "target": "/t" + }, + { + "source": "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/secrets/api", + "target": "/secrets" + }, + { + "source": "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/logs", + "target": "/logs" + } + ] + }, + "unavailable_reason": "原语音服务的 DeepSeek 预设仍指向 deepseek-v4.1-flash-uncensored,与当前模型目录不一致;须由服务维护者调整预设后再接入。", + "secret_tables": [ + { + "path": "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/secrets/api/keys.txt", + "key_column": 1, + "label": "selftest" + } + ], + "mutable_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/logs/api-requests.log" + ] + }, + { + "id": "drama3-engine", + "source_group": "drama3-tests-20261015", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/tests/test_drama3.py", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3", + "interpreter": "/opt/omni/bin/python", + "user": "root", + "parameters": [], + "arguments": [], + "environment": {}, + "credential_files": [], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/tests/test_drama3.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/secrets/admin.env", + "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/data/refs/example-en.wav" + ], + "check_ports": [ + 18301 + ], + "timeout_seconds": 3600, + "impact": { + "level": "resource", + "title": "Drama3 引擎合成与流式回归会占用共享模型服务", + "details": [ + "核验 Drama3 引擎的合成、克隆、流式和并发;依赖原管理凭据及外网参考音频下载,暂未接通执行。", + "生成的音频与标准输出存入本次运行独立目录,原测试目录和历史结果保留。" + ], + "requires_confirmation": true + }, + "available": false, + "output_paths": [], + "notes": [ + "2026-10-20 已只读核对原源码、依赖镜像和素材,未执行本项业务测试。", + "原脚本只读挂载;测试客户端使用原镜像,无直接 GPU 设备权限,2 CPU / 4 GiB 内存;会通过接口占用既有模型服务。输出使用本次独立目录,不覆盖原历史音频。" + ], + "interactive": false, + "docker": { + "image": "sha256:13369a516b4b961e36b1c3c5d47e3e04ec4bfdc03920964c621ccc83232604b8", + "mounts": [ + { + "source": "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/tests", + "target": "/t" + } + ] + }, + "unavailable_reason": "此原测试还需管理凭据适配及外网参考音频下载,尚未核对其调用与输出边界。" + }, + { + "id": "drama3-voice-capacity", + "source_group": "drama3-tests-20261015", + "entry": "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/tests/load_voice.py", + "cwd": "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3", + "interpreter": "/opt/omni/bin/python", + "user": "root", + "parameters": [], + "arguments": [], + "environment": {}, + "credential_files": [], + "required_files": [ + "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/tests/load_voice.py", + "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/secrets/api/keys.txt", + "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/tests/mock_brain.py" + ], + "check_ports": [ + 18300, + 18301, + 18302 + ], + "timeout_seconds": 3600, + "impact": { + "level": "resource", + "title": "语音链路并发容量测试会占用共享模型服务", + "details": [ + "用模拟大脑测语音链路的并发容量、首声延迟和播放卡顿;需补齐临时辅助容器生命周期与每次资源上限。", + "生成的音频与标准输出存入本次运行独立目录,原测试目录和历史结果保留。" + ], + "requires_confirmation": true + }, + "available": false, + "output_paths": [], + "notes": [ + "2026-10-20 已只读核对原源码、依赖镜像和素材,未执行本项业务测试。", + "原脚本只读挂载;测试客户端使用原镜像,无直接 GPU 设备权限,2 CPU / 4 GiB 内存;会通过接口占用既有模型服务。输出使用本次独立目录,不覆盖原历史音频。" + ], + "interactive": false, + "docker": { + "image": "sha256:13369a516b4b961e36b1c3c5d47e3e04ec4bfdc03920964c621ccc83232604b8", + "mounts": [ + { + "source": "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/tests", + "target": "/t" + }, + { + "source": "/dengyuyun-joy-work-space/workpc-claude-code/20261015-drama3/secrets/api", + "target": "/secrets" + } + ] + }, + "unavailable_reason": "需要专用模拟大脑容器、原内部网络及并发上限;辅助服务生命周期尚未接入。" } - ] + ], + "reviewed_at": "2026-10-20" } diff --git a/catalog/test-definitions.json b/catalog/test-definitions.json index 606020e..6b409ee 100644 --- a/catalog/test-definitions.json +++ b/catalog/test-definitions.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "reviewed_at": "2026-10-13", + "reviewed_at": "2026-10-20", "basis": "Reviewed README entry points, script purpose comments and call references; registration does not authorize service/GPU actions.", "example_script_ids": [ "499b615a7dc7435eb30789b7a7dc090e", @@ -72,6 +72,11 @@ "source_group": "dsv41-trial-20261010", "pattern": "run-d456.sh", "role": "variant" + }, + { + "source_group": "dsv41-trial-20261010", + "pattern": "run-unc*.sh", + "role": "variant" } ] }, @@ -569,6 +574,65 @@ "role": "variant" } ] + }, + { + "key": "drama3-asr", + "source_group": "drama3-tests-20261015", + "entry": "test_asr.py", + "name": "语音识别文件与实时转写", + "description": "用现有 Drama3 合成三段录音,核验 Qwen3-ASR 文件转写、实时转写、字错率和响应延迟;会占用共享语音服务。", + "kind": "suite", + "members": [] + }, + { + "key": "drama3-api", + "source_group": "drama3-tests-20261015", + "entry": "test_api.py", + "name": "语音合成网关兼容性与质量回归", + "description": "核验本地语音网关的多格式、四种音色、流式、变速、SDK 兼容和八路并发;保留原 selftest 凭据读取方式。", + "kind": "suite", + "members": [] + }, + { + "key": "drama3-voice-chat", + "source_group": "drama3-tests-20261015", + "entry": "test_voice_chat.py", + "name": "实时语音对话端到端回归", + "description": "核验输入、回答、打断和四人对话的完整链路;当前语音服务预设仍指向旧 DeepSeek 模型名,待服务方确认后再开放。", + "kind": "suite", + "members": [ + { + "source_group": "drama3-tests-20261015", + "pattern": "test_voice_logic.py" + } + ] + }, + { + "key": "drama3-engine", + "source_group": "drama3-tests-20261015", + "entry": "test_drama3.py", + "name": "Drama3 引擎合成与流式回归", + "description": "核验 Drama3 引擎的合成、克隆、流式和并发;依赖原管理凭据及外网参考音频下载,暂未接通执行。", + "kind": "suite", + "members": [] + }, + { + "key": "drama3-voice-capacity", + "source_group": "drama3-tests-20261015", + "entry": "load_voice.py", + "name": "语音链路并发容量测试", + "description": "用模拟大脑测语音链路的并发容量、首声延迟和播放卡顿;需补齐临时辅助容器生命周期与每次资源上限。", + "kind": "suite", + "members": [ + { + "source_group": "drama3-tests-20261015", + "pattern": "mock_brain.py" + }, + { + "source_group": "drama3-tests-20261015", + "pattern": "load_summary.py" + } + ] } ] } diff --git a/console/catalog.py b/console/catalog.py index 09dcb11..2622755 100644 --- a/console/catalog.py +++ b/console/catalog.py @@ -180,6 +180,9 @@ def discover(store): item["version"] += 1 item["enabled"] = False reason = "源文件已变动,请重新确认配置并启用" + elif before and digest and not before["enabled"] and before.get("reason") == "源文件已变动,请重新确认配置并启用": + # A second scan must not clear the outstanding source-change review. + reason = before["reason"] if blocked: item["enabled"] = False item.update(sha256=digest, blocked=blocked, reason="" if item["enabled"] else reason, diff --git a/console/model_preflight.py b/console/model_preflight.py new file mode 100644 index 0000000..07bfd5e --- /dev/null +++ b/console/model_preflight.py @@ -0,0 +1,54 @@ +"""One bounded, read-only metadata request to a registered loopback model service.""" +import ipaddress +import json +from pathlib import Path +import re +import shlex +import urllib.error +import urllib.request +from urllib.parse import urlsplit + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args): + return None + + +def check_model(target, credential_files): + result = {'name': '模型身份', 'ok': False, 'message': '模型目录检查未完成'} + try: + url = urlsplit(target['url']) + if (url.scheme != 'http' or not ipaddress.ip_address(url.hostname).is_loopback + or url.username or url.password or url.query or url.fragment or url.path not in ('', '/')): + raise ValueError('仅允许登记的本机模型服务') + key = '' + for filename in credential_files: + path = Path(filename) + if path.stat().st_size > 262144: + raise ValueError('认证引用文件过大') + for line in path.read_text().splitlines(): + match = re.match(r'^(?:export\s+)?VLLM_API_KEY=(.*)$', line.strip()) + if not match: + continue + values = shlex.split(match[1], comments=True) + if len(values) > 1: + raise ValueError('认证引用格式不支持') + key = values[0] if values else '' + headers = {'Authorization': 'Bearer ' + key} if key else {} + request = urllib.request.Request(target['url'].rstrip('/') + '/v1/models', headers=headers) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) + with opener.open(request, timeout=3) as response: + data = response.read(262145) + if len(data) > 262144: + raise ValueError('模型目录超过检查上限') + payload = json.loads(data) + models = payload.get('data') if isinstance(payload, dict) else None + if not isinstance(models, list) or any(not isinstance(m, dict) or not isinstance(m.get('id'), str) for m in models): + raise ValueError('模型目录格式不支持') + result['ok'] = any(m['id'] == target['model'] for m in models) + result['message'] = ('已核对模型名称:' if result['ok'] else '当前服务未提供登记的模型名称:') + target['model'] + except urllib.error.HTTPError as exc: + result['message'] = f'模型目录校验失败(HTTP {exc.code}),请核对服务与认证配置;未发送推理' + except (OSError, ValueError, TypeError, KeyError): + result['message'] = '模型目录或认证引用不可用,未发送推理;请核对原服务配置' + return result diff --git a/console/native_container.py b/console/native_container.py new file mode 100644 index 0000000..d6a92bd --- /dev/null +++ b/console/native_container.py @@ -0,0 +1,89 @@ +"""Fixed original-image clients. No user-provided Docker flags or GPU access.""" +import json +from pathlib import Path, PurePosixPath +import re +import subprocess + + +def name(identity): + if not re.fullmatch(r'[a-f0-9]{32}', identity): + raise ValueError('原环境任务编号不合法') + return 'adamwen-native-run-' + identity + + +def mounts(profile): + values = profile['docker']['mounts'] + for mount in values: + for key in ('source', 'target'): + value = mount[key] + if not value.startswith('/') or ',' in value or '\x00' in value or '..' in PurePosixPath(value).parts: + raise ValueError('原镜像挂载路径不合法') + if mount['target'] in ('/', '/out', '/tmp'): + raise ValueError('原镜像挂载不能覆盖任务输出目录') + return values + + +def command(profile, source, arguments, directory, identity): + image = profile['docker']['image'] + if not re.fullmatch(r'sha256:[a-f0-9]{64}', image): + raise ValueError('原镜像必须登记不可变摘要') + container = name(identity) if identity != '{run_id}' else 'adamwen-native-run-{run_id}' + args = ['/usr/bin/docker', 'run', '--rm', '--init', '--name', container, '--label', 'xas.native-run=' + identity, + '--runtime', 'runc', '--network', 'host', '--user', '0:0', '--cap-drop', 'ALL', + '--security-opt', 'no-new-privileges', '--read-only', '--memory', '4g', '--cpus', '2', '--pids-limit', '128', + '--tmpfs', '/tmp:rw,nosuid,nodev,size=268435456', '--env', 'HOME=/tmp', + '--env', 'PYTHONUNBUFFERED=1', '--env', 'PYTHONDONTWRITEBYTECODE=1', + '--env', 'OMP_NUM_THREADS=2', '--env', 'OPENBLAS_NUM_THREADS=2', '--env', 'MKL_NUM_THREADS=2', + '--env', 'NUMBA_NUM_THREADS=2', '--env', 'NUMBA_CACHE_DIR=/tmp/numba'] + entry = None + for mount in mounts(profile): + args.extend(['--mount', f"type=bind,src={mount['source']},dst={mount['target']},readonly"]) + try: + relative = PurePosixPath(Path(source).as_posix()).relative_to(PurePosixPath(mount['source'])) + entry = str(PurePosixPath(mount['target']) / relative) + except ValueError: + pass + if entry is None: + raise ValueError('原入口未包含在只读挂载中') + output = str(PurePosixPath(str(directory)) / 'work') + args += ['--mount', f'type=bind,src={output},dst=/out', '--workdir', '/out', + '--entrypoint', profile['interpreter'], image, '-u', entry, *arguments] + return args + + +def checks(profile): + result = [] + for mount in mounts(profile): + path = Path(mount['source']) + valid = path.exists() and not any(p.is_symlink() for p in (path, *path.parents)) + result.append({'name': '原镜像只读依赖', 'ok': valid, 'message': mount['source']}) + try: + value = subprocess.run(['/usr/bin/docker', 'image', 'inspect', '--format', '{{.Id}}', profile['docker']['image']], + capture_output=True, text=True, timeout=5) + valid = value.returncode == 0 and value.stdout.strip() == profile['docker']['image'] + except (OSError, subprocess.TimeoutExpired): + valid = False + result.append({'name': '原依赖镜像', 'ok': valid, 'message': '使用已登记镜像,不拉取或升级环境'}) + return result + + +def cleanup(identity): + container = name(identity) + value = subprocess.run(['/usr/bin/docker', 'inspect', container], capture_output=True, text=True, timeout=8) + if value.returncode: + if 'no such object:' in value.stderr.lower() or 'no such container:' in value.stderr.lower(): + return + raise OSError('本次原镜像客户端状态未知') + item = json.loads(value.stdout)[0] + if item.get('Name') != '/' + container or item.get('Config', {}).get('Labels', {}).get('xas.native-run') != identity: + raise ValueError('原镜像客户端归属核验失败') + identity_value = item.get('Id', '') + if not re.fullmatch(r'[a-f0-9]{64}', identity_value): + raise ValueError('原镜像客户端标识不合法') + value = subprocess.run(['/usr/bin/docker', 'rm', '-f', identity_value], capture_output=True, timeout=15) + if value.returncode: + # --rm may delete the verified container between inspect and removal. + remaining = subprocess.run(['/usr/bin/docker', 'inspect', identity_value], capture_output=True, text=True, timeout=8) + if remaining.returncode and ('no such object:' in remaining.stderr.lower() or 'no such container:' in remaining.stderr.lower()): + return + raise OSError('本次原镜像客户端收尾未确认') diff --git a/console/native_executor.py b/console/native_executor.py index 068001f..ebe93cb 100644 --- a/console/native_executor.py +++ b/console/native_executor.py @@ -141,6 +141,13 @@ class NativeExecutor: 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) + if run.get("execution", {}).get("runtime") == "docker": + from .native_container import cleanup + try:cleanup(run["id"]) + except (OSError, ValueError, subprocess.TimeoutExpired): + self.store.running_message(run["id"], "本次原镜像客户端收尾未确认,保留运行占位;请核对执行器") + self.worker.stopping = True + return self.store.finish(run["id"], state, message=message, exit_code=code, artifacts=artifacts) def wait_stopped(self, run): diff --git a/console/native_io.py b/console/native_io.py index 46675b2..110da61 100644 --- a/console/native_io.py +++ b/console/native_io.py @@ -70,13 +70,36 @@ class SecretFilter: return result +def read_secret_tables(tables): + values = [] + for table in tables: + path = Path(table["path"]) + column = table.get("key_column", 1) + if type(column) is not int or not 0 <= column <= 8 or path.stat().st_size > 262144: + raise ValueError("原认证表格式或大小不支持") + found = False + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + fields = line.split() + if table.get("label") and fields[0] != table["label"]: + continue + if len(fields) <= column: + raise ValueError("原认证表缺少登记列") + values.append(fields[column]) + found = True + if not found: + raise ValueError("原认证表缺少登记的测试身份") + return values + + 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 = [] + secrets = read_secret_tables(plan.get("secret_tables", [])) 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] diff --git a/console/native_plans.py b/console/native_plans.py index 22fa0fe..f2e5eea 100644 --- a/console/native_plans.py +++ b/console/native_plans.py @@ -88,11 +88,11 @@ 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": "使用服务器原有环境和原始入口,不迁移依赖或修改源文件。", + return {"mode": "native", "runtime": "docker" if profile.get("docker") else "host", "profile_id": profile["id"], "interpreter": profile["interpreter"], + "user": profile["user"], "cwd": profile["cwd"], "description": "复用原依赖镜像和原始入口,只读挂载源文件,结果写入本次独立目录。" if profile.get("docker") else "使用服务器原有环境和原始入口,不迁移依赖或修改源文件。", "impact": profile["impact"], "parameters": profile.get("parameters", []), "interactive": profile.get("interactive", False), "max_timeout_seconds": MAX_TIMEOUT, - "notes": profile.get("notes", []), "verification": "启动方案已接入,未完成本轮 GPU/长稳业务验收"} + "notes": profile.get("notes", []), "verification": "启动方案已接入,未完成本轮业务测试验收" if profile.get("available", True) else "原环境方案待配置:" + profile.get("unavailable_reason", "请核对依赖与运行条件")} def validate_config(settings, script, values): @@ -138,10 +138,14 @@ def build_plan(settings, script, run_id=None, check_host=True): return str(variables[match[1]]) return PLACEHOLDER.sub(replace, str(value)) argv = [profile["interpreter"], str(source), *[render(a) for a in profile.get("arguments", [])]] + if profile.get("docker"): + from .native_container import command + argv = command(profile, source, argv[2:], directory, identity) 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", [])] + required_files.extend(table["path"] for table in profile.get("secret_tables", [])) if target.get("config_file"): required_files.append(target["config_file"]) timeout = script["config"].get("timeout_seconds", profile.get("timeout_seconds", 3600)) @@ -153,11 +157,21 @@ def build_plan(settings, script, run_id=None, check_host=True): 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", [])])) + tools = list(dict.fromkeys(["/usr/bin/docker" if profile.get("docker") else 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) + if profile.get("docker"): + from .native_container import checks as container_checks + checks.extend(container_checks(profile)) + if profile.get("secret_tables"): + from .native_io import read_secret_tables + try: + read_secret_tables(profile["secret_tables"]) + check("测试身份引用", True, "已找到原测试身份,凭据仅在内存中读取") + except (OSError, ValueError): + check("测试身份引用", False, "原凭据表缺失、格式不支持或没有登记的测试身份") for value in profile.get("check_ports", []): port = int(render(value)) if not 1 <= port <= 65535: @@ -167,20 +181,27 @@ def build_plan(settings, script, run_id=None, check_host=True): available = True except OSError:available = False check("目标服务端口", available, f"127.0.0.1:{port}" + (" 可连接" if available else " 未监听,请先准备原服务")) + if target.get("verify_model"): + if all(c["ok"] for c in checks): + from .model_preflight import check_model + checks.append(check_model(target, credential_files)) + else: + check("模型身份", False, "先满足上方原环境条件,再核对服务提供的模型") # 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: + secret_paths = [table["path"] for table in profile.get("secret_tables", [])] + if name not in credential_files + secret_paths + profile.get("mutable_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, + return {"mode": "native", "runtime": "docker" if profile.get("docker") else "host", "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, + "credential_files": credential_files, "env_setup_files": setup_files, "secret_tables": profile.get("secret_tables", []), "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", [])], @@ -190,4 +211,4 @@ def build_plan(settings, script, run_id=None, check_host=True): 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} + return {**{key: plan[key] for key in keys}, "runtime": plan.get("runtime", "host")} diff --git a/console/static/favicon-32.png b/console/static/favicon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..3dd93c444e93b2b8ffe8294d4e568165103e3e9c GIT binary patch literal 636 zcmV-?0)zdDP)T@dn=X7Xlr~2jTLHTuTlzH7$p&S0WgwSVI`He zhH?MZG0R-oAuNku0lRbI{BzEk!;DK3H`SwQhqFz_*aGJ?LX2GM1WsR!(JSZtzQOp# z>gawU*v*13xtHTKF}6e9g!9wovAvWcAX`1jGUCe=1DsfHWpsB1&kjDY^0F~%eKb3lupSSR>yz5?u(&v3V^G(Qj1D^aK+cx1*Jc^KBa_7hgF$H%;C!5DiI2J z0?4b!SX1K3gn9sAB|3K^7j%iYwl+<+SP2j!;MO4Uf{ZS4BN5>L`20l6xSH}MG6AMg z2yC*xBLUr@Z2`K1W78Lj0A#!_@dT!F9Ekwzb+PYTQ7PB7Rk33Mx@w1vm$M#TXeSg_ z0y0-6ZMDOm7gbUgfjN-~`1!G-(k`kUwx+J~H=a${{vTH};5WqK3D1Tw4wM@~+}HDO00030|K){U^Z)<=21!IgR09Cy WMi2KtDK`TE0000 + + + diff --git a/console/static/index.html b/console/static/index.html index 287d320..9a84cdd 100644 --- a/console/static/index.html +++ b/console/static/index.html @@ -6,6 +6,8 @@ XAS · 测试控制台 + + diff --git a/console/static/native-ui.js b/console/static/native-ui.js index ff0d915..4039ebb 100644 --- a/console/static/native-ui.js +++ b/console/static/native-ui.js @@ -76,7 +76,7 @@ export function nativeScriptInfo(script) { 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')); + impactView(execution.impact), note(execution.verification || '启动方案已接入,未完成本轮业务验收。', 'info')); } export async function preflightNativeRun(ctx, script) { diff --git a/console/static/scripts.js b/console/static/scripts.js index b2ce3db..d9a727e 100644 --- a/console/static/scripts.js +++ b/console/static/scripts.js @@ -25,7 +25,8 @@ const profileLabel = script => isNative(script) ? '服务器原环境' : script. 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)'] + ['dsv41-trial-20261010', 'DeepSeek 试验脚本(10/10)'], ['model-trials-20261011', '模型试验脚本(10/11)'], + ['drama3-tests-20261015', 'Drama3 / Qwen-ASR 语音测试'] ]); export async function scriptList(ctx) { diff --git a/deploy/dedicated.py b/deploy/dedicated.py index 2ed3def..bf6e244 100644 --- a/deploy/dedicated.py +++ b/deploy/dedicated.py @@ -10,7 +10,7 @@ import sys ROOT = Path("/adamwen-work-space/xas-test-library") NAME = "adamwen-script-console" -IMAGE = "adamwen/script-console:20261014-1" +IMAGE = "adamwen/script-console:20261020-1" UNIT = "adamwen-script-worker.service" diff --git a/deploy/refresh_catalog.py b/deploy/refresh_catalog.py new file mode 100644 index 0000000..b623ff0 --- /dev/null +++ b/deploy/refresh_catalog.py @@ -0,0 +1,93 @@ +"""Refresh reviewed sources atomically without resetting saved options or history.""" +import argparse +import copy +import json +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from console import catalog, native_plans +from console.settings import Settings +from console.store import Store + + +def refresh(store, backup, apply=False): + original = {s['id']: s for s in store.scripts()} + class Draft: + settings = store.settings + items = copy.deepcopy(original) + def scripts(self): return list(self.items.values()) + def save_script(self, item): self.items[item['id']] = item + draft = Draft() + scan = catalog.discover(draft) + for item in draft.items.values(): + profile = native_plans.profile_for(store.settings, item) + if not profile: + continue + if item['id'] not in original: + item['config'] = native_plans.validate_config(store.settings, item, + {'native_options': {}, 'timeout_seconds': profile.get('timeout_seconds', 3600)}) + item['enabled'] = profile.get('available', True) + if not profile.get('available', True): + item.update(enabled=False, blocked=True, reason=profile.get('unavailable_reason', '原环境待配置')) + if item['enabled']: + item.update(status='ready', reason='') + elif item['status'] != 'missing': + item['status'] = 'needs_configuration' + changes = [] + for identity, value in draft.items.items(): + before = original.get(identity) + if before and {k:v for k,v in before.items() if k != 'updated_at'} == {k:v for k,v in value.items() if k != 'updated_at'}: + continue + changes.append((identity, before, value)) + if not apply: + return {'scan': scan, 'changed': len(changes), 'applied': False} + with store.connect(write=True) as db: + if db.execute("SELECT 1 FROM runs WHERE state IN ('queued','running')").fetchone(): + raise ValueError('请先等待排队和运行任务结束') + records = [] + for identity, before, after in changes: + row = db.execute('SELECT data FROM scripts WHERE id=?', (identity,)).fetchone() + if (json.loads(row[0]) if row else None) != before: + raise ValueError('配置有并发修改,本次刷新未写入') + records.append({'id': identity, 'before': row[0] if row else None, 'after': json.dumps(after)}) + if not records: + return {'scan': scan, 'changed': 0, 'applied': True} + backup.parent.mkdir(parents=True, exist_ok=True) + with backup.open('x', encoding='utf-8') as handle: + json.dump({'scripts': records}, handle, ensure_ascii=False, indent=2) + backup.chmod(0o600) + for record in records: + db.execute('INSERT OR REPLACE INTO scripts VALUES(?,?)', (record['id'], record['after'])) + return {'scan': scan, 'changed': len(changes), 'applied': True} + + +def restore(store, backup): + records = 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('请先等待排队和运行任务结束') + for record in records: + row = db.execute('SELECT data FROM scripts WHERE id=?', (record['id'],)).fetchone() + current = row[0] if row else None + if current not in (record['before'], record['after']): + raise ValueError('刷新后配置已修改,请人工核对,禁止覆盖') + if record['before'] is None and db.execute('SELECT 1 FROM runs WHERE script_id=?', (record['id'],)).fetchone(): + raise ValueError('新增项目已有运行历史,不能删除登记,请人工恢复') + for record in records: + if record['before'] is None: + db.execute('DELETE FROM scripts WHERE id=?', (record['id'],)) + else: + db.execute('UPDATE scripts SET data=? WHERE id=?', (record['before'], record['id'])) + return {'restored': len(records), '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 args.apply and args.restore: parser.error('选择刷新或恢复之一') + store = Store(Settings.from_env()) + print(json.dumps(restore(store, args.backup) if args.restore else refresh(store, args.backup, args.apply))) diff --git a/docs/acceptance-refresh-2026-10-20.md b/docs/acceptance-refresh-2026-10-20.md new file mode 100644 index 0000000..022ff5a --- /dev/null +++ b/docs/acceptance-refresh-2026-10-20.md @@ -0,0 +1,57 @@ +# 模型与测试目录更新验收 + +核对日期:2026-10-20。09:45 只读盘点,10:12 完成控制台切换(北京时间)。用户要求核对当前模型、测试套件变化并更新控制台;原模型服务、源脚本和业务数据不修改,本轮不发推理、不运行原业务测试。 + +## 现场变化 + +| 对象 | 当前证据与处理 | +|---|---| +| GPU 综合套件 | 最高已安装版本仍为 XAS-GPU-Test v3.5.0,224 个登记源码摘要均未变,不切换版本 | +| DeepSeek | GPU 0–7,18100,当前权重目录 DeepSeek-V4.1-Flash-1017;模型目录返回主名 deepseek-v4.1-flash-1017 及旧别名 deepseek-v4.1-flash;更新下拉主名,旧复制版 smoke 和数学脚本保持原样 | +| Qwen 文本服务 | GPU 14,18112,模型目录返回 qwen3.8-27b,上下文 262144;已核对复用原 model-trials/configs/vllm.env,新增目标选项 | +| 图像服务 | ComfyUI 公共引擎使用 GPU 8–10,专属引擎使用 GPU 11,网关 8188;仅盘点,不操作这些服务 | +| 语音服务 | Drama3 引擎 GPU 12 / 18301,Qwen3-ASR GPU 13 / 18302,网关 18300/18343;新增专项测试目录 | +| 共享资源 | 盘点时 GPU 0–14 均有显存占用,GPU 15 为 0 MiB;采样利用率均 0% 只表示当时空闲,不代表资源未分配。根盘剩余约 679 GiB、使用率 80% | + +原 DeepSeek 目录 3 个启停脚本发生变化、增加 5 个文件;模型综合试验目录的 48 个脚本未变。新增 run-unc* 是切换服务 B 的历史变体,归入 DeepSeek 套件相关文件,不单独平铺成测试卡片。容量链的原收尾脚本默认切回 UNCENSORED 版本,已在逐次影响确认中明确提示不会恢复当前 1017 主模型。 + +## 控制台改动 + +- 更新当前 DeepSeek / Qwen 目标。只有支持 MODEL/SPEC_URLS 的通用原 Python 测试增加 Qwen GPU 14 选项,未将缺少对应原配置的 Shell 套件强行改端口。 +- 当前两个模型目标增加只读身份预检:一次本机 `/v1/models` 请求,拒绝重定向、3 秒超时、响应限 256 KiB、无重试或推理;名称或认证不匹配即拦截。 +- 增加 5 项语音测试:语音识别文件/实时转写、语音合成网关回归可触发;实时语音对话、原引擎回归、语音容量测试为待配置。 +- 语音测试使用已存在的原镜像摘要和 `/opt/omni/bin/python`。原脚本、素材、日志和认证引用只读,输出写本次独立 `/out`;仅启动客户端,无直接 GPU 设备权限,不启停模型服务。每次启动须确认共享服务负载。 +- 新增客户端受本次运行 ID/标签管理,取消/超时确认本机服务和对应容器收尾;自动删除竞态及 Docker 错误大小写已覆盖,未知状态保留占位。 +- 目录刷新采用单事务迁移,保留原配置、超时、禁用选择、版本和历史;重复扫描仍保留“源码变化待确认”的原因。 + +正式目录现有 **28 项业务测试(8 套件、20 单项)**;**25 项已配置启用、3 项待配置**。已启用不代表服务当前可用,运行前仍预检。登记原位置文件 320 个,加 3 个托管脚本共 323 个文件;两个验收示例继续默认隐藏。 + +## 待配置与暂未开放项 + +| 项目 | 原因 | +|---|---| +| 实时语音对话 | 原共享语音服务预设仍请求 deepseek-v4.1-flash-uncensored,当前模型目录不提供此名;需服务维护者调整后核对,控制台不偷偷替换 | +| Drama3 原引擎回归 | 涉及管理凭据与外网参考音频下载,尚未完成适配 | +| 语音容量测试 | 需要模拟大脑辅助容器、原内部网络与并发上限管理 | +| ComfyUI 新工程测试 | 包含测试账号/业务数据写入、插件安装、模型下载删除及引擎重启等维护行为,未直接加入可运行入口 | +| DSH 比赛模拟 | 任务包、run.json、dsh-home 和所需镜像已缺失,且有外部 Provider 调用;未猜测凭据、预算或重新启动 | + +## 验证结果 + +- 最终隔离 Linux **67/67 回归通过**;独立审查未发现未解决的确定 P1/P2。 +- 生产库副本完成刷新、幂等复验及精确恢复,runs/versions 内容不变;新增 13 个文件登记、3 个变更文件更新,共 16 项元数据变动。 +- 原语音镜像 4 项真实 systemd/Docker 短样例通过:成功、非零失败、超时、取消。使用合成正弦音频验证 librosa resample/pyin 与文件写入,检查凭据脱敏、源文件只读和客户端无残留。 +- 首轮样例发现库按宿主线程数初始化导致线程限额错误,以及 Docker 小写“对象不存在”误判;已修复并用新样例复验,首轮失败记录保留,没有把失败改成通过。 +- 无网络客户端只读验证原脚本、4 个参考音频、访问日志和 selftest 身份可读,未执行原测试。 +- 正式 API 与 1440px/390px 浏览器验证新目标、28 项目录、待配置原因、原镜像预检、影响确认取消和导航;拦截全部测试提交,历史条数不变,无 JS 错误,favicon 保留。 +- 切换后 10 条历史运行及版本摘要不变,320 个原脚本摘要不变;全部 11 个其他运行中容器的 ID/StartedAt 不变。只重启本项目网页和空闲 worker,没有运行模型、语音或 GPU 业务测试。 + +## 发布、证据与恢复 + +正式镜像 `adamwen/script-console:20261020-1`,摘要 `sha256:43bd6d5e573ffc772ad9836acb14df9a1d3dd8e60e4bf129e772fe24cff14ffa`。备份:`runtime/backups/before-catalog-refresh-20261020/`;旧网页容器:`adamwen-script-console-before-catalog-refresh-20261020`。 + +新增可审查迁移 `deploy/refresh_catalog.py`;配置恢复使用备份内 `catalog-migration.json`。回退先停止接单并确认无活动任务,停止本项目 worker,恢复配置与匹配的旧源码/网页,再启动 worker。不覆盖 SQLite 或运行目录;若配置已被修改、或新增项目已有运行历史,恢复工具拒绝覆盖,须人工核对。配置恢复已在副本验证,生产故障回退未触发。 + +服务器证据:`runtime/catalog-refresh-acceptance-20261020.json`、`runtime/releases/catalog-refresh-20261020/validation.json`、`runtime/native-docker-validation-20261020-v2/acceptance.json`。本地证据:`.local/evidence/server-refresh-20261020.json`、`server-details-20261020.json`、`catalog-refresh-browser.json`。 + +主要变更:目录三个 JSON、`model_preflight.py`、`native_container.py`、原环境计划/脱敏/收尾、目录刷新、页面提示、迁移及测试和维护文档。用户随后于 2026-10-20 明确授权将本次更新与此前已部署的 favicon 一起提交并推送到公司 Git 仓库。 diff --git a/docs/adr/0004-native-execution.md b/docs/adr/0004-native-execution.md index 67a10d1..8e2b21b 100644 --- a/docs/adr/0004-native-execution.md +++ b/docs/adr/0004-native-execution.md @@ -23,3 +23,13 @@ SQLite 保持 schema v1,仅增加 JSON 配置和运行快照字段。`deploy/n ## 验证范围 方案接入、只读预检、参数/权限边界、模拟及真实短进程生命周期已验证。原项目的 GPU、长稳和模型切换业务不在本轮实际运行,页面如实提示。后续只有在用户实际发起并确认相应影响时才执行原项目测试。 + +## 2026-10-20:原语音镜像与模型身份预检 + +语音测试的 Python、SDK 和音频库位于原 Docker 镜像,宿主 Python 不具备相同依赖。可信方案新增固定镜像摘要和只读挂载描述,由 `native_container.py` 构造固定客户端命令;上传代码仍不能选择宿主命令或 Docker 参数。容器使用 runc、无 GPU 设备、只读根目录和原源码,独立 `/out` 输出;2 CPU、4 GiB、128 进程,计算库线程为 2,缓存写临时目录,避免按宿主 128 线程初始化而超限。仅通过原接口使用共享模型,并逐次确认影响。 + +取消/超时先收尾原 systemd 服务,再按本次运行标签核验客户端容器,使用不可变容器 ID 清理。自动删除竞态确认缺失即成功;Docker 状态未知仍保留运行占位。原 label-key 认证表只读取已登记 selftest 身份用于日志脱敏,值不进入命令行、数据库或网页。 + +当前 DeepSeek/Qwen 目标在预检中增加一次有界 `/v1/models` 请求:仅允许本机地址、拒绝重定向、无重试、3 秒超时、256 KiB 响应上限,不发推理。名称不匹配或认证异常阻止运行。原共享访问日志只检查存在,不把其持续变化纳入启动配置摘要。 + +后续已审查来源更新用 `deploy/refresh_catalog.py` 单事务写入;保留已有参数、禁用选择、版本和运行历史,新增业务入口与内部文件分开。新增项目已有运行记录或配置发生后续修改时,配置恢复拒绝覆盖,不能用旧数据库替代新历史。 diff --git a/docs/console-operations.md b/docs/console-operations.md index 3ac7994..1c1cc36 100644 --- a/docs/console-operations.md +++ b/docs/console-operations.md @@ -75,3 +75,19 @@ Git 远程为公司仓库 `https://code.it.aisuan.cn/wenhuadian/xas-test-library 正式镜像 `adamwen/script-console:20261014-1`,摘要 `sha256:ddbf3622b4e468ae9368864965ed2830d067a021596a6981aeca68bb5109880c`。备份为 `runtime/backups/before-search-alignment-20261014/`;旧网页容器 `adamwen-script-console-before-search-alignment-20261014` 停止保留,可仅切换网页回退,不覆盖数据库或重启 worker。故障回退未触发。 证据:服务器 `runtime/search-alignment-acceptance.json`;本地 `.local/evidence/search-deployed.json` 和 `.local/screenshots/search-*.png`。用户随后于 2026-10-14 明确授权将本次 UI 修复提交并同步到公司 Git 仓库。 + +## 2026-10-19 19:55 网站 favicon + +沿用现有浅绿色圆角底和深绿色 X 标识,新增 `console/static/favicon.svg` 与 32px PNG 备用图标,在 `index.html` 声明。真实浏览器验证两个资源均返回正确内容类型且可解码,项目页及运行记录页均保留图标引用,无 JavaScript 错误。 + +仅更新网页容器,执行器 PID 和 scripts/versions/runs 摘要不变,仍为 10 条记录、0 活动任务。镜像 `adamwen/script-console:20261019-1`,摘要 `sha256:102266ae01d42284ccdf7d4fb27c1a534b7051e3bcd2f0b266e9b45bf6462e99`。备份为 `runtime/backups/before-favicon-20261019/`,旧网页容器 `adamwen-script-console-before-favicon-20261019` 停止保留;回退仅替换网页,不恢复数据库、不重启 worker。 + +证据:服务器 `runtime/favicon-acceptance.json`,本地 `.local/evidence/favicon-browser.json`。当次未执行 GPU 测试,也未提交 Git;用户随后于 2026-10-20 授权将 favicon 与模型、测试目录更新一并提交和推送。 + +## 2026-10-20 10:12 模型与测试目录刷新 + +当前为 28 项业务测试,25 项已配置、3 项语音测试待配置。新增 DeepSeek 1017 主名与 Qwen GPU 14 / 18112 目标;通用原 Python 测试可选当前 Qwen 服务,运行前只读核对模型身份。旧 DeepSeek 别名仍由服务支持,原复制版轻量脚本不改写。 + +新增语音识别和语音网关回归复用原镜像与只读依赖,输出归档到本次运行目录。客户端限制 2 CPU、4 GiB、128 进程和 2 个计算库线程;没有直接 GPU 设备,通过已有语音接口运行,仍须逐次确认影响。取消会核对并清理本次客户端,不能据此推断已发出的远端推理停止。3 项待配置原因在详情显示;不自动修复其他维护者的服务预设。 + +正式镜像 `adamwen/script-console:20261020-1`,旧网页保留为 `adamwen-script-console-before-catalog-refresh-20261020`。目录迁移使用 `deploy/refresh_catalog.py`,不会重置原参数或历史。详细现场状态、验证结果、未接入范围与恢复条件见 [本轮验收](acceptance-refresh-2026-10-20.md)。 diff --git a/tests/test_catalog_refresh.py b/tests/test_catalog_refresh.py new file mode 100644 index 0000000..a5a26e1 --- /dev/null +++ b/tests/test_catalog_refresh.py @@ -0,0 +1,43 @@ +import hashlib +from pathlib import Path +import tempfile +import unittest +from console.settings import Settings +from console.store import Store +from console.catalog import discover +from deploy.refresh_catalog import refresh, restore + + +class CatalogRefreshTests(unittest.TestCase): + def test_refresh_preserves_options_and_history_and_restores_exactly(self): + with tempfile.TemporaryDirectory() as temporary: + base = Path(temporary); source = base/'source'; source.mkdir() + (source/'old.py').write_text('print(1)') + settings = Settings(base/'runtime', [{'id':'suite','path':str(source)}]); store = Store(settings) + discover(store) + old = store.scripts()[0]; old.update(enabled=True,status='ready',reason='') + old['config']['timeout_seconds'] = 457 + store.save_script(old) + old_runs = store.runs() + (source/'old.py').write_text('print(5)') + (source/'new.py').write_text('print(2)'); (source/'blocked.py').write_text('print(3)') + (source/'support.py').write_text('print(4)') + settings.native_catalog['profiles'] = [ + {'id':name,'source_group':'suite','entry':name+'.py','cwd':str(source),'interpreter':'/usr/bin/python3', + 'user':'root','available':name!='blocked','unavailable_reason':'原服务预设已过期','parameters':[]} + for name in ('old','new','blocked')] + snapshot = base/'migration.json' + self.assertFalse(refresh(store, snapshot)['applied']) + self.assertFalse(snapshot.exists()) + refresh(store, snapshot, True) + by_name = {s['name']:s for s in store.scripts()} + self.assertEqual(by_name['old.py']['config']['timeout_seconds'],457) + self.assertFalse(by_name['old.py']['enabled']) + self.assertEqual(by_name['old.py']['version'],2) + self.assertTrue(by_name['new.py']['enabled']) + self.assertFalse(by_name['blocked.py']['enabled']) + self.assertFalse(by_name['support.py']['enabled']) + self.assertEqual(store.runs(),old_runs) + self.assertEqual(refresh(store,base/'again.json',True)['changed'],0) + restore(store,snapshot) + self.assertEqual(store.scripts(),[old]) diff --git a/tests/test_model_preflight.py b/tests/test_model_preflight.py new file mode 100644 index 0000000..6226c0a --- /dev/null +++ b/tests/test_model_preflight.py @@ -0,0 +1,69 @@ +import json +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +import tempfile +import threading +import unittest +from unittest.mock import patch + +from console.model_preflight import check_model + + +class ModelPreflightTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.credential = self.root / 'vllm.env' + self.credential.write_text('VLLM_API_KEY="dummy-metadata-key"\n') + self.calls = [] + self.status = 200 + self.body = {'data': [{'id': 'current-model', 'max_model_len': 262144}]} + case = self + class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): pass + def do_GET(self): + case.calls.append((self.path, self.headers.get('Authorization'))) + self.send_response(case.status) + if case.status == 302: + self.send_header('Location', '/redirect-target') + self.end_headers() + self.wfile.write(json.dumps(case.body).encode()) + self.server = HTTPServer(('127.0.0.1', 0), Handler) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + self.target = {'url': 'http://127.0.0.1:' + str(self.server.server_port), 'model': 'current-model'} + + def tearDown(self): + self.server.shutdown(); self.server.server_close(); self.thread.join() + self.temp.cleanup() + + def test_metadata_only_one_request_with_runtime_credentials(self): + result = check_model(self.target, [str(self.credential)]) + self.assertTrue(result['ok']) + self.assertEqual(self.calls, [('/v1/models', 'Bearer dummy-metadata-key')]) + self.assertNotIn('dummy-metadata-key', json.dumps(result)) + + def test_wrong_model_fails_even_when_port_is_open(self): + self.target['model'] = 'stale-model' + result = check_model(self.target, [str(self.credential)]) + self.assertFalse(result['ok']) + self.assertIn('模型名称', result['message']) + + def test_auth_failure_and_redirect_do_not_retry_or_leak(self): + for status in (401, 302): + self.status = status; self.calls.clear() + result = check_model(self.target, [str(self.credential)]) + self.assertFalse(result['ok']) + self.assertEqual(len(self.calls), 1) + self.assertNotIn('dummy-metadata-key', json.dumps(result)) + + def test_external_address_rejected_before_network(self): + self.target['url'] = 'http://example.com' + with patch('console.model_preflight.urllib.request.build_opener') as build: + self.assertFalse(check_model(self.target, [])['ok']) + build.assert_not_called() + + def test_malformed_or_oversize_metadata_is_not_success(self): + for body in ({'data': {}}, {'data': ['bad']}, {'data': [], 'padding': 'x' * 262145}): + self.body = body + self.assertFalse(check_model(self.target, [str(self.credential)])['ok']) diff --git a/tests/test_native_container.py b/tests/test_native_container.py new file mode 100644 index 0000000..c53c096 --- /dev/null +++ b/tests/test_native_container.py @@ -0,0 +1,59 @@ +import json +from pathlib import Path +from types import SimpleNamespace +import unittest +import tempfile +from unittest.mock import patch +from console.native_container import command, cleanup +from console.native_io import read_secret_tables, SecretFilter + + +class NativeContainerTests(unittest.TestCase): + def test_registered_key_table_is_redacted_without_environment_injection(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / 'keys.txt' + path.write_text('# local fixture\noperator dummy-audio-key scope\n') + values = read_secret_tables([{'path': str(path), 'key_column': 1}]) + self.assertEqual(values, ['dummy-audio-key']) + self.assertNotIn(b'dummy-audio-key', SecretFilter(values).clean(b'header=dummy-audio-key')) + + def setUp(self): + self.identity = 'a' * 32 + self.profile = {'docker': {'image': 'sha256:' + 'b' * 64, 'mounts': [{'source': '/original/tests', 'target': '/t'}]}, + 'interpreter': '/opt/omni/bin/python'} + + def test_fixed_image_readonly_sources_and_owned_output_without_gpu(self): + args = command(self.profile, Path('/original/tests/test_api.py'), ['x'], '/runs/' + self.identity, self.identity) + self.assertIn('xas.native-run=' + self.identity, args) + self.assertIn('type=bind,src=/original/tests,dst=/t,readonly', args) + self.assertIn('type=bind,src=/runs/' + self.identity + '/work,dst=/out', args) + self.assertEqual(args[-4:], [self.profile['docker']['image'], '-u', '/t/test_api.py', 'x']) + self.assertNotIn('--gpus', args) + self.assertIn('runc', args) + + def test_cleanup_rejects_foreign_container(self): + inspected = SimpleNamespace(returncode=0, stdout=json.dumps([{'Name': '/adamwen-native-run-' + self.identity, + 'Config': {'Labels': {'xas.native-run': 'other'}}}]), stderr='') + with patch('console.native_container.subprocess.run', return_value=inspected) as call: + with self.assertRaises(ValueError): cleanup(self.identity) + self.assertEqual(call.call_count, 1) + + def test_daemon_error_is_not_treated_as_container_missing(self): + error = SimpleNamespace(returncode=1, stdout='', stderr='Cannot connect to Docker daemon') + with patch('console.native_container.subprocess.run', return_value=error): + with self.assertRaises(OSError): cleanup(self.identity) + + def test_missing_owned_container_is_already_clean(self): + missing = SimpleNamespace(returncode=1, stdout='', stderr='error: no such object: adamwen-native-run-' + self.identity) + with patch('console.native_container.subprocess.run', return_value=missing) as call: + cleanup(self.identity) + self.assertEqual(call.call_count, 1) + + def test_auto_removed_container_race_is_not_an_uncertain_cleanup(self): + container_id = 'c' * 64 + inspected = SimpleNamespace(returncode=0, stdout=json.dumps([{'Id':container_id, + 'Name':'/adamwen-native-run-' + self.identity,'Config':{'Labels':{'xas.native-run':self.identity}}}]),stderr='') + gone = SimpleNamespace(returncode=1,stdout='',stderr='Error: No such container: ' + container_id) + with patch('console.native_container.subprocess.run',side_effect=[inspected,gone,gone]) as call: + cleanup(self.identity) + self.assertEqual(call.call_args_list[1].args[0][-1],container_id)