FNOS-001 批量真机验收规程
本规程规定批量验证转换应用能否正常安装、启动、打开的标准流程。它与 单应用测试用例集 的分工是:用例集定义「怎么测、什么算通过」, 本规程定义「怎么把这套用例跑遍全部应用并留下可复核证据」。
1. 为什么需要批量通道
设备端 appcenter-cli 在多数目标设备上因 /run/trim_app_cgi/rpcbroker 权限直接 panic, 无法批量安装;逐个在应用中心 UI 手工安装也不具备可重复性与可留证性。因此批量验收走 Midscene 驱动 fnOS 应用中心 Web UI:复用已登录的真实浏览器会话,用应用中心自身的 HTTP 接口完成安装、启动、卸载,并把 UI 通道与设备端 CLI 通道的结论等效对待。
| 通道 | 工具 | 适用 | 限制 |
|---|---|---|---|
| 设备端 CLI | appcenter-cli | 目标设备上直接安装与检查 | 需要设备侧 RPC 权限,权限不足时启动即 panic |
| Web UI 自动化 | Midscene(@midscene/web,Bridge 模式) | appcenter-cli 不可用时,复用用户已登录的 Chrome 走应用中心 | 只能观察接口返回与设备状态,不能直接读容器日志 |
两条通道不改变判定口径:结论必须落到「设备上发生了什么」,不能由本地构建结果代替。
2. 环境准备
| 项 | 说明 |
|---|---|
| 浏览器 | 用户本人已登录 fnOS 的 Chrome,并安装 Midscene 扩展 |
| 桥配置 | 由 DSH 本地桥提供的固定四行(curl /v1/midscene-env 取),不涉及任何第三方模型密钥 |
| 设备通道 | SSH 可读 /var/log/apps/<app>.log(应用生命周期日志)与 /vol2/@appdata、/vol2/@apphome |
| 暂存目录 | 设备上一个应用中心可见的目录(用于「以 NAS 路径安装」,免去逐个文件上传) |
设备地址、SSH 账号、口令、令牌一律由环境变量传入,不写进任何文档、脚本或结果文件。 文档中设备一律以「目标测试设备」指代,命令示例用 ${FNOS_HOST} 占位。
3. 单应用流程(每个应用都走完这七步)
| 步 | 动作 | 判据 |
|---|---|---|
| 1 | 本地 fnpack build | 产出 .fpk;失败记 BUILD_FAIL |
| 2 | 把 .fpk 放到设备暂存目录 | — |
| 3 | 应用中心解析安装包(packageSourceType=file + path) | 解析成功、拿到 appName 与 version |
| 4 | 读取安装向导 schema 并自动填参 | 必填项全部可填;否则记 UNVERIFIED |
| 5 | 提交安装任务并轮询到终态 | 安装任务成功;否则记 FAIL 或 BLOCKED |
| 6 | 等待应用进入 running,再探测入口端口 | 顺序不可颠倒:先卸会导致端口已关闭,探针必然失败 |
| 7 | 卸载(选「删除所有数据」)→ 清理残留目录 → 取失败日志 | 卸载后设备上不得留下该应用 |
3.1 向导自动填参规则
| 字段类型 | 处理 |
|---|---|
端口(initValue 合法) | 用应用自带默认值,保持「测的就是发布配置」 |
password / 密钥类 | 生成随机值;长度按 helpText 写明的位数(如「32 位随机字符串」),否则服务端会拒绝 |
文本类且带 rules[].pattern | 用 initValue 先过一遍正则,不通过则记 UNVERIFIED |
| 必填但无默认值、且不是密钥 | 记 UNVERIFIED,不猜值 |
必填项无法自动填写时必须记 UNVERIFIED 而不是猜一个值——猜出来的值会让「装上了」失去意义。
填参必须服从该字段自己的正则契约,尤其是数据库连接类的「合并字段」:有些应用的 DB_HOST 并不只填主机,而是要求「地址:端口/数据库名」(rules.pattern 形如 ^.+:\d+/.+$),compose 里 直接把它接在 user:pass@ 之后。只填裸 IP 会让 URL 少了端口与库名,容器连到默认端口/默认库后 起不来,入口 000 —— 现象与「应用缺陷」完全一样,但根因在填参。判定方法:该字段的正则同时 约束了冒号与斜杠,就按 地址:引擎端口/应用名 填。注意不能用 /\d/ 去测正则字符串—— 模式里的 \d 是两个字符,不含真实数字。
3.2 入口探测
入口地址取应用中心返回的
appServiceInfo.urls.port,拼http://${FNOS_HOST}:<port>/。应用中心报
running不等于容器已在监听:探针要重试若干次再下结论,否则会把「启动慢」误判成 FAIL。入口打不开时必须同时取容器现场:
docker ps -a上的名称/状态/端口映射,以及该容器docker logs --tail 30。只看 HTTP 码无法区分三种完全不同的原因:容器现场 含义 Restarting (N)/Exited容器崩溃循环:应用自身起不来,去读容器日志定因 Up ...且端口映射正确,但 HTTP 仍000进程在跑但没监听该端口,或仍在初始化 查不到对应容器 容器根本没被创建 实测中大量「入口
000」都属于第一类(例如某应用已重启 243 次),若只记 HTTP 码会把它们 混成一类,既无法整改也无法判断是不是转换缺陷。判定可访问的 HTTP 码:
200 / 301 / 302 / 303 / 307 / 401 / 403。404也算「服务已响应」—— 应用 UI 可能在子路径下,不代表起不来。000(连接失败)视为不可访问。
4. 判定分级
| 判定 | 含义 | 计入转换缺陷 |
|---|---|---|
PASS | 安装成功 + running + 入口可访问 | — |
FAIL | 安装或启动失败,且日志指向转换自身的问题 | ✅ |
BLOCKED | 依赖外部数据库/中间件、端口被占用等环境原因 | ❌ |
UNVERIFIED | 向导必填项无法自动填写,需人工确认 | ❌ |
BUILD_FAIL | 本地 fnpack build 失败 | ✅ |
红线(与 FNOS-001-04-AC-06 一致):不得出现「安装成功且显示运行中,但实际不可访问」。 端口被占用时,允许的两种表现是:向导表单阶段拦截,或应用中心显示未运行。
5. 失败取证
/var/log/apps/<app>.log 是每次安装都必须读的,不是「失败时才读」。它是生命周期脚本的 stdout/stderr 汇总,容器起不来时真正的报错往往只出现在这里(install_callback 的输出、 docker compose up 的失败原因)。踩过的坑:某数据库应用安装后一直停在 start,界面只显示 「docker 不可用」,实际是默认端口 5432 被系统内置服务占用,该报错只在这份日志里 (failed to bind host port ... address already in use)。若把它归成「docker 不可用」, 整改方向会完全跑偏。
| 场景 | 取证方式 |
|---|---|
| 安装/启动失败 | SSH 读 /var/log/apps/<app>.log 尾部(生命周期脚本的 stdout/stderr 汇总) |
| 入口不可访问 | 同上 + 应用中心返回的 status、appServiceInfo,以及 docker ps -a 的容器状态与端口映射 |
| 容器崩溃循环 | docker logs --tail 30 <容器名>:缺依赖、配置解析失败、权限不足等真因只出现在容器日志里 |
| 卸载后残留 | 查 /vol2/@appdata/<app>、/vol2/@apphome/<app> 是否仍在 |
| 端口被占用 | 生命周期日志里 failed to bind host port ... address already in use;再 ss -ltn 确认占用者。默认端口撞系统服务时属环境冲突,改端口即可,不计转换缺陷 |
| 地址池耗尽 | docker network ls 与 ip route 对照:Docker 数据库里已无记录但内核仍占着网段,即孤儿网桥 |
日志读取不需要提权:应用生命周期日志对普通账号可读。日志里出现 exit status 1: open /docker/docker-compose.yaml 是卸载阶段的正常噪声(应用目录已被框架移除),判断失败原因要 看它之前的输出。
6. 设备清理纪律
批量验收会反复在设备上装卸应用,必须保证:
- 逐个测试、逐个卸载,不在设备上堆积多个测试应用;
- 卸载后核对三项:桌面入口图标消失、入口端口不再服务(
curl返回000)、应用中心列表中无该应用; - 清理数据目录残留:应用卸载回调可能删不掉自己的数据目录(见 转换规约的待确认项),需由有权限的账号清掉, 否则设备上会堆积空目录;
- 暂存目录里的
.fpk用完即删; - 回收 compose 网络:卸载后确认
docker network ls里没有该应用的<app>_default, 仍在就删除。每个残留网络占掉一个/16,累计十几个后 Docker 默认地址池耗尽,之后 任何应用都装不上(all predefined address pools have been fully subnetted)。 批量跑里每应用记录一条networkLingered,既防累积,也是「平台是否回收网络」的证据。
7. 证据与可复核性
- 批量结果以 JSON Lines 逐条落盘,每个应用一条,中途崩溃不丢已完成部分;
- 每条记录含:应用名、判定、阶段、应用状态、入口地址与 HTTP 码、卸载结果、残留计数、 失败时的日志尾部;
- 结论进 真实环境验收记录,未执行的验收条件保持「未执行」。
8. 已知限制
| 限制 | 说明 |
|---|---|
| 鉴权 | 应用中心的会话 Cookie(osrt / ost)是 HttpOnly,页内 JS 读不到,document.cookie 只剩埋点与语言项。所以不能「从浏览器抄一份 Cookie 交给 curl」——会话一过期整批就失效。正确做法是经 Midscene Bridge 连上已登录标签页,用页面同源 fetch 调接口,凭证由浏览器自己带 |
| 桥接稳定性 | 扩展后台休眠会断开传输;标签页被用户关掉也会失去目标。验收器每次调用前确保连接,断开即重连,目标标签页缺失时自动重开一次,单次调用保持短往返 |
| 会话时效 | 会话本身过期后所有调用返回未授权。验收器识别到 401/403 或空响应会抛 SESSION_EXPIRED 并中止整批,重新登录后重跑同一命令即可续跑 |
| 镜像拉取 | 冷缓存下单个应用可能拉十几分钟;轮询窗口不足会把「还在拉镜像」误判成失败 |
| 镜像源被阻断 | 目标测试设备可能只能访问部分站点:github.com、docker.com 返回 200,但 registry-1.docker.io / auth.docker.io 的 TCP 直接超时(域名还被解析到保留段地址)。此时 docker pull 必然失败,不得据此判应用缺陷。做法是先确认阻断范围(分别 curl 站点与 registry),再从设备可达的镜像加速器按前缀拉取,最后 docker tag 打回原名——compose 引用的是原名,无需改 daemon.json、不污染全局配置。判断镜像源是否真的可用看 /v2/ 返回 401(需鉴权=正常)而不是 000/HTML 页面;返回 HTML 说明那不是 registry 代理 |
| 向导必填项 | 无法自动填写的应用记 UNVERIFIED。可选用占位值模式(FNOS_FILL_PLACEHOLDERS=1)让它也跑出结论,但结果必须带 placeholderFilled 标记,不得与真实填写混为一谈 |
| 设备级阻塞 | 地址池耗尽、daemon 不可达、磁盘满会让所有安装失败,必须整批中止;单个镜像源不通只影响该应用,不得据此中止 |
| 重启循环的日志 | 容器刚被 kill 时 docker logs 可能读不到内容。取证要重试并放大窗口;仍为空则补 docker inspect(ExitCode / OOMKilled),否则只剩 status=Restarting,无法归因 |
| 并发 | 桥接端口互斥,不得并行跑两个验收器;脚本用结果文件旁的 .lock 保证单实例,第二个实例应当直接退出而不是抢占端口 |
| 原生应用 | 没有对外服务端口的应用(如纯命令行工具类)安装后不常驻 running,状态多为 nostart。等待 running 对它们永远是超时,须把 nostart 当终态接受,是否可用交给安装成功与否判断 |
9. 已发现的缺陷类
批量跑的价值不只是给出通过率,更在于把同一个原因造成的一批失败归并成缺陷类、一次修掉。
| 缺陷类 | 现象 | 根因 | 处置 |
|---|---|---|---|
| 种子配置未随包 | 安装报 Seed file missing: .../seed/... 并 exit 1 | 单文件挂载需要源文件先存在,回调从 ${TRIM_APPDEST}/seed/ 播种,但种子从未入库;把种子放成顶层 seed/ 也没用——fnpack 只打包标准条目 | 种子改放 apps/<app>/app/seed/<相对路径>:app/ 会打进 app.tgz 并解包到 TRIM_APPDEST,正好是回调期望的位置 |
| 卸载谎报删除 | 打印 Application data deleted. 但 rm 报 Permission denied | 回调在删平台管理的目录,包用户无权 unlink 其父目录 | 只清空目录内容并如实上报残留,见转换规约 |
.gitignore 误伤种子 | 种子文件加不进版本库 | data/ 规则匹配任意层级,而种子路径必须镜像 data/... | 规则锚定到仓库根:/data/ |
| 数据目录属主未对齐(挂载点不可写) | 容器 Restarting (N)、入口 000;日志形如 EACCES: permission denied, copyfile ... -> /data/settings.js、PermissionError: ... '/app/webdav'、mkdir: cannot create directory '/bitnami/...' | 镜像启动时要把默认配置/运行时文件写进挂载目录,而以非 root(包用户)运行时该目录归 root 所有。install_callback 是空操作时没有任何环节把属主交给运行用户 | 在 install_callback 里以包用户预建数据目录并自检可写(touch 探针,失败写用户可见错误而非静默重启)。不要为此提权到 root |
| 非 root 运行与镜像自身用户冲突 | 同上,/opt/config、/app/...、/root/server/logs 等镜像层内路径报权限拒绝 | 有些镜像的 entrypoint 必须以 root 改写自身目录(如 Apache 改 /etc/apache2/),加 user: 覆盖后必然失败 | 按镜像实际要求区分:能对齐属主的保持非 root;确实必须 root 的(改写系统目录)去掉 user: 覆盖,并在注释里写明真机结论 |
| 相对路径配置落盘错位 | PermissionError: ... '/app/app' | 配置项写成相对路径,进程 cwd 是 /app 时被拼成 /app/app | 容器内路径一律写绝对路径 |
| 端口未声明导致入口无监听者 | 容器 Up、端口映射存在,但入口恒为 000 | compose 映射了某端口,而应用配置文件里没声明该端口(如 Manticore 的 HTTP 接口必须显式 listen = 9308:http) | 让配置文件声明的监听端口与 compose 映射逐一对齐 |
| 必需配置文件缺失 | Error: Config file 'mkdocs.yml' does not exist. 并重启循环 | 镜像启动即要求数据目录内存在某配置文件,首次安装目录为空 | 随包提供最小可用种子,install_callback 仅在缺失时铺入,不覆盖用户内容 |
| 配置保存后不生效 | 「应用设置 → 运行设置」改完保存并重启,容器 env 仍是旧值;应用照旧连旧地址 | 模板 cmd/main 的 ensure_containers_created 先判断「容器已存在」就 return,docker compose up -d 从未执行。注意不要把重建写进 config_callback:它以包用户运行、无 docker 权限,且平台检查其退出码,非 0 会让保存整体失败 | 去掉提前 return,让 up -d 每次状态检查执行(配置未变时幂等,变了由 compose 自行重建);config_callback 保持空操作。判定依据是容器 ID 变化且新配置在入口生效 |
变更记录
| 日期 | 变更 |
|---|---|
| 2026-10-20 | 建立本规程:把单应用验证流程批量化。确立「先探入口后卸载」的顺序纪律、「安装中心报 running 不等于已监听」的探针重试、按 helpText 位数生成密钥的填参规则,以及 PASS / FAIL / BLOCKED / UNVERIFIED / BUILD_FAIL 五级判定。同步确立设备地址与凭据不入文档的纪律。 |
| 2026-10-22 | 通道改为应用中心 HTTP 接口直连(不再依赖浏览器自动化);补 ENV_BLOCKED 与整批中止判据、compose 网络回收、容器现场与容器日志取证、按容器状态区分「入口 000」的三种原因;新增「已发现的缺陷类」小节与占位值模式说明。 |
| 2026-10-28 | 全量跑完 189 个应用,通过率 54.5%(103 PASS)。通道改为「Midscene Bridge 复用浏览器会话 + 页面同源 fetch」:HttpOnly Cookie 无法抄出,必须由浏览器代发。补原生应用(nostart)判定、重启循环日志重试与 docker inspect 兜底、凭据类向导字段自动生成、桥接断线与标签页丢失的自愈。缺陷类新增 6 类,全部围绕非 root 运行时的目录属主与镜像自身用户的冲突,其中 6 个应用已修复并真机复测通过。 |
| 2026-11-03 | 补「镜像源被阻断」的绕行做法(可达加速器按前缀拉取后 docker tag 打回原名,不改全局配置)与「配置保存后不生效」缺陷类。通道在桥接之外增加 curl + 会话 Cookie(Cookie 从浏览器网络请求头读取,仍是 HttpOnly 不可由页内 JS 取),验收器支持 FNOS_SSH_PORT 指定非 22 的 SSH 端口。真机复验 4 个已构建应用全部 PASS。 |
| 2026-11-03 | 抽检 10 个应用(覆盖无依赖 Web、PHP+MySQL、Node+MySQL、Go/Node+PostgreSQL)全部 PASS。补「合并式 DB_HOST 必须按应用自己的正则契约填 地址:端口/库名」的填参规则 —— 此前一律填裸 IP,让 fn-umami、fn-miniflux 连到默认 5432 后起不来,被误判成应用缺陷。另记:库名以向导 DB_NAME 的 initValue 为准(如 wiki),验证前须先在目标实例建库。设备升级到 fnOS 1.2.1000 后复验,通道与应用行为无变化。 |