# Ops 服务接入规范 · AI 开发单页(opsstd v1) - 标准:`opsstd` - 标准版本:`1.2.6`(唯一事实源:`opsstd://v1/manifest.json` 的 `version`;`version.json` 为镜像元数据,两者不一致时启动即报错) - 最低服务端版本:`2.1.0`(1.1.0 的 `DATA_DIR` 完整校验与 integrity 失败即备份失败依赖 2.1.0;当前**不强制校验**:没有接口暴露服务端版本,仅作规划与排障参考)。1.2.2 的备份目标语义(provider 覆盖、本地目标、远端 fail-closed)只在后端包含该改动的版本生效,发布前旧后端仍按「默认上传 OSS」行为运行 - 生成日期:`2026-11-14` - 适用对象:部署在 47.110.36.244 上、由 PM2 托管并由 Caddy 反向代理的 Node.js / Python / Go / 静态服务 - 使用方式:将本文件整份作为起点,生成代码、目录、脚本、runbook 与项目登记;文中规则全部可离线自检,只有标注「平台校验」的条目需要 Ops 平台在线判定 > **鉴权先读(v1.0.0 起)**:公网入口 `https://mcp.soyooo.top/mcp` 需要两层凭据——客户端 Token(识别调用方与限流)+ 项目 Key(`X-Ops-Project-Key`,决定项目与能力)。不带项目 Key 时只能读取标准文档资源,**所有项目数据工具返回 `PROJECT_KEY_REQUIRED`**;带 Key 时只能访问 Key 绑定的项目。本地 stdio 模式行为不变。详见 1.1、1.2;项目端客户端配置与排查见第 17 章。 > 服务端返回的 `standardVersion` / `standardDigest` 若与本文件不一致,以服务端为准,并拒绝任何写操作(`STANDARD_VERSION_MISMATCH`,不做静默降级)。 --- ## 0. 速览:12 条不可协商的硬规则 1. 代码目录固定 `deployPath`(登记 `backup.appDir`,未登记时按 `/` 兜底,安装默认 `/opt/apps/{id}`),数据目录独立(默认 `/data`),**数据不得位于 `releases/` 或 `current` 之内**。 2. PM2 进程名 = `pm2Name`,`cwd` = `deployPath`,两者与后台登记逐字一致。 3. 端口只从环境变量读取,服务只监听 `127.0.0.1`,**禁止 `0.0.0.0`**。 4. `autorestart: true`,并实现 `SIGTERM` 优雅退出。 5. 健康检查默认路径 `/healthz`(登记 `probePath`),**只有 2xx/3xx 通过,`>=400` 判失败**。 6. 日志只写 stdout/stderr,由 PM2 收集;应用**不自行写轮转文件**。 7. 启动参数按性质拆两处:非密钥运行参数(`PORT`/`HOST`/`NODE_ENV`/`WEB_DIST` 与数据路径键)写入 ecosystem `env`,作为启动唯一真源;密钥(`JWT_SECRET`、第三方 API Key 等)只放 `.env`(权限 `600`,不入 git、不进日志、不进备份包)。**数据目录不强制叫 `DATA_DIR`**:也可写应用真正消费的 `DATABASE_FILE`、`UPLOAD_ROOT` 等键,解析后必须落在登记 `dataDir` 内;无持久化状态在 projects.json 写 `stateless: true`。禁止为了凑字段写一个应用从不读取的假键。 8. 备份范围为**整个 `data/` 目录**;SQLite 必须用 `sqlite3 ... ".backup"`,禁止直接复制 `.db` 文件。 9. 运维脚本固定放 `/opt/ops-scripts/{id}/`,**不放项目 git 目录**(会被 `git pull` 覆盖)。 10. runbook 固定放 `/opt/runbooks/{id}.md`,8 章齐全,≤200KB;**不得写死具体 release 或版本号**(会天天过期),一律用 `readlink -f /current` 或读 `current/package.json` 动态取。 11. 发布产物 = `{id}-{version}.tar.gz` + 同名 `.sha256`,由项目内固定 build 脚本一条命令产出(含禁项扫描、软链检查、解包后随机端口 + 临时数据目录的实际启动自检),落到 `releases/{ts}` 并用 `current` 软链切换,不覆盖旧版本。 12. 不改动服务器上其他站点、不重启 Caddy、不使用 `pm2 delete/kill/save`、不对 `ops` 项目做任何操作。 --- ## 1. 平台关系与边界 | 事项 | 约定 | |------|------| | 公网入口 | Caddy 终止 TLS 后 `reverse_proxy 127.0.0.1:{port}`;应用不直接暴露公网端口 | | 证书 | 静态证书 `/etc/caddy/certs/fullchain.pem` 与 `privkey.pem`;新域名必须已被证书 SAN 覆盖,否则拒绝接入(**执行方:平台 / 运维**;项目方只需确认域名可正常 HTTPS 访问) | | 平台角色 | 登记 projects.json、Caddy 站点块、备份目标(本地/OSS/COS)、健康探测、审计 | | 项目角色 | 应用代码、`.env`、`data/`、本地备份触发、runbook 内容 | | 预留资源 | 端口 80/443/2019(Caddy)、3100(ops-backend)、3200(ops-mcp)、8080(jingyu-server)、25884(scmusic);项目端口建议 3000–3999 | | 保留标识 | `projectId` 不得为 `ops`;`pm2Name` 不得为 `ops-backend`、`caddy`、`pm2-logrotate` | ### 1.1 两种使用方式(不要混为一谈) | 方式 | 入口 | 凭据 | 可见范围 | |------|------|------|----------| | 公网入口 | `https://mcp.soyooo.top/mcp` | 客户端 Token + `X-Ops-Project-Key` | 仅 Key 绑定项目;无 Key 只能读标准文档资源 | | 本地 stdio | 本机 `ops-mcp` 进程(管理员) | 本地配置;`OPS_PROJECT_KEY` 可选 | 本地管理员可读全部项目,行为与旧版一致 | - 公网入口面向开发者与 CI,必须按 1.2 携带两层凭据;**不要把本地 stdio 的「可读全部项目」当成公网行为**。项目端如何配置客户端、自检与排错见第 17 章。 - stdio 模式适合管理员在本机排查;它不经过公网入口的 Key 校验,但同样不能绕过 ops-backend 的鉴权与硬约束。 ### 1.2 公网入口凭据与错误语义 **第一层:客户端 Token**。放在 `Authorization: Bearer `(兼容 `X-Ops-Token`),用于识别调用方与限流(默认 120 次/分钟,超出返回 `429`)。它**不授予任何项目数据权限**。 **第二层:项目 Key**。放在 `X-Ops-Project-Key`,格式 `ops_pt__`,绑定唯一项目,决定可见范围与能力。Key 由 superadmin 通过 `POST /api/keys` 签发,服务端只存哈希,明文只在签发时展示一次。 两层凭据**发放入口不同、不可互相替代**: - 项目 Key:在运维后台「项目 Key」签发,随「复制信息」一并下发(含 `X-Ops-Project-Key` 明文或掩码)。 - 客户端 Token:在运维后台「MCP 客户端」按**调用方**单独签发(`ops_ct__`),不属于项目接入材料;后台「复制信息」里只有 `Authorization: Bearer <客户端 Token>` 占位,必须由管理员另行签发后填入。 - 只有项目 Key、没有客户端 Token 时,**第一个 `initialize` 就会返回 `401 CLIENT_TOKEN_REQUIRED`(`reason=missing`)**,不会进入 MCP 层,也拿不到任何 `ops_*` 工具;反之只有客户端 Token 时工具可见,但项目数据类工具一律 `PROJECT_KEY_REQUIRED`。 - 不带项目 Key:只可读取标准文档类资源(`opsstd://*`、`manifest.json`、规范正文、版本与 digest);`opsproj://current` 只返回 `bound:false`。**所有项目数据类工具一律返回 `PROJECT_KEY_REQUIRED`,不会触达 ops-backend**。 - 带项目 Key:只能看到绑定项目——列表类接口只返回该项目,其余工具传入的项目必须与 Key 绑定项目一致;跨项目请求返回 `403`,错误信息不区分项目是否存在。 - 项目 Key 可读**自己项目**的只读信息:项目列表、部署状态、inventory(仅本项目域名/端口/进程)、备份列表、runbook、日志、调度状态、覆盖本项目域名的证书。跨项目访问与全部写操作仍返回 `403`;全局管理接口(`/api/keys`、`/api/caddy/*` 写接口等)一律拒绝。 - **冲突检测与新项目登记只能在管理员侧完成**:`ops_check_conflict` 依赖全量项目视图。项目 Key 下调用**不会给出“无冲突”**:返回 `ok:false`、`limitedView:true`、`requiresAdminReview:true`,并提示由管理员用全量视图复核。正确顺序是「先找管理员建项目并签 Key,再用 Key 部署」。 | 现象 | 返回 | |------|------| | 未携带 `Authorization` | `401`,`CLIENT_TOKEN_REQUIRED`,`reason=missing` | | 客户端 Token 错误、被吊销、已过期 | `401`,`CLIENT_TOKEN_REQUIRED`,`reason=invalid` / `revoked` / `expired`(`code` 相同,**按 `reason` 区分**,不要只看 `code`) | | 未带项目 Key 调用项目工具 | 工具错误 `PROJECT_KEY_REQUIRED`(不触达后端) | | Key 头格式非法、重复数组、超过 128 字节 | `400`,`INVALID_PROJECT_KEY` | | Key 伪造、过期、已吊销、一次性已使用、绑定项目不存在 | `401` | | 访问非绑定项目 | `403`,提示不区分项目是否存在 | | 使用 `ops` 项目的 Key | `403` | **Key 类型**(均由 superadmin 签发): | 类型 | scope | 用途 | 默认有效期 | |------|-------|------|------------| | `deploy` | `project:read`、`project:deploy`、`project:rollback`、`project:backup:run`、`project:caddy:write`、`project:env:write` | 本项目发布、回滚、备份与改环境变量等写操作 | 24 小时(可设 1 小时–7 天) | | `readonly` | `project:read`、`project:logs`、`project:backup:read` | 本项目只读、日志与备份查询 | 90 天(可设 1 小时–90 天) | - `deploy` 类 Key 可设为一次性,成功使用一次后立即失效,再次使用返回 `401`。 - Key 等于生产写权限:**不要发到群聊、提交进仓库、写进日志或截图**;只放本机环境变量或密钥管理,泄露立即吊销。 安全前提:本模型只约束 MCP / 平台通道。如果开发者同时持有服务器 SSH/sudo,可直接绕过全部约束——「只发 Key、不发 shell」是合规成立的前提。 --- ## 2. 当前能力边界(务必先读,避免误判) ### 2.1 已实现 - MCP 工具 14 个:11 个只读工具 `ops_list_projects`、`ops_get_health`、`ops_get_logs`、`ops_get_runbook`、`ops_list_backups`、`ops_get_scheduler_status`、`ops_get_inventory`、`ops_check_conflict`、`ops_plan_deployment`、`ops_check_conformance`、`ops_get_project_context`;3 个写工具 `ops_upload_artifact`、`ops_deploy_project`、`ops_rollback_project`(部署/回滚默认 `dryRun=true`,真正写入需 `dryRun=false` + `confirm=true`)。 - 公网入口两层凭据:客户端 Token 识别调用方与限流;项目 Key 绑定项目并决定能力。无 Key 仅标准文档资源可用,有 Key 仅绑定项目可见。 - 项目 Key 的签发/列表/吊销(`/api/keys`,仅 superadmin)、`/api/project/context`、`/api/project/deploy`、`/api/project/rollback`、`/api/project/env`(GET/PUT)。 - 受限执行器:部署/回滚/staging/`current` 切换/健康检查/失败回滚、`.env` 掩码读写,均在 ops-backend 内完成。 - 回滚语义:`ops_rollback_project` 支持可选 `targetRelease`——必须是该项目 `releases/` 中已存在的目录名,允许向前或向后;不传时保持“退一格”。目标等于当前会被拒绝;返回 `direction`(`forward`/`backward`)与 `targetSource`(`explicit`/`default`)。目标切换后重启、环境或健康校验失败时,统一切回原 release 并恢复 env 快照;切回失败会保留现场并写 error 级事件。oplog 成功事件的 `reason` 明确为 `null`,失败事件必须带 `reason`(`health`/`env`/`pm2`/`error`)。 - 线上 conformance(`ops_check_conformance`):实时拉登记、查进程、探端口、读 Caddy 后现场比对,不读取发布包内任何文件;覆盖登记字段、runbook 路径与章节、备份配置与生效的备份目标、PM2 进程存在与 online、端口探测、Caddy 站点块与反代目标、OSS 资源前缀。 - 部署工具内置 conformance 门槛:存在 `fail` 时拒绝部署(`CONFORMANCE_FAILED`),项目 Key 无 override 权限。**首次部署**(项目从未有 `releases/current`)自动把 `process.ecosystem_loadable`、`network.port_probe`、`caddy.site_block`、`caddy.proxy_target` 标为 `deferred`(不计 fail),部署成功后执行器补建站点块并立即复检、写入审计,MCP 在响应里返回 `postDeployConformance`;已部署项目仍按 `fail` 拦截。 - 备份调度、按生效目标(本地 / OSS / COS)落盘与按来源保留 7 份、审计日志、Dashboard、Caddy 配置备份与 reload 管理(人工在后台操作)。 - 规范站静态分发:`develop.soyooo.top` 提供 `/`、`/llms.txt`、`/index.md`,内容与 `standards/llms.txt` 同哈希(`/v1/` 版本化路径与 digest 在线校验尚未实现)。 ### 2.2 部分实现(不得当作完整能力) - 项目 Key 的只读范围已放宽到本项目:`GET /api/projects`、`/api/projects/deploy-status`、`/api/caddy/inventory`(按项目过滤)、`/api/scheduler/status`(按项目过滤)、`/api/certs`(按域名过滤)、`/api/projects/:id/{runbook,logs,backups,ecosystem-check}`;跨项目与写接口仍 `403`。`project:caddy:write` / `project:backup:run` 对应的项目级写接口尚未开放。 - conformance 门槛目前由 MCP 工具在部署前校验,后端未二次强制。 - `pathPrefixes` 对所有角色实际为 `['**']`,**前缀隔离未生效**;新账号需显式收窄,但不能把它当作唯一安全边界。 - `automation` 角色不存在(规划中),不得在代码或脚本中依赖。 - MCP 层 `plan:read` / `conformance:read` 只读工具已可用,但权限分层未在 MCP 侧强制。 ### 2.3 尚未实现(不要调用) - `ops init` 脚手架、`ops check --offline` 离线自检、CI 门槛。 - 项目级备份执行接口 `/api/project/backup/run`、项目级 Caddy 写入接口 `/api/project/caddy`。 - `ops_register_service`、`ops_preflight_onboard`、`ops_update_project`、`ops_publish_runbook`、`ops_run_backup`、`ops_adopt_service`、`ops_query_audit`。 - `backup.sh` / `monitor.sh` 的受控投递、runbook 自动发布、多版本兼容窗口。 > 现状补充:项目登记仍由管理员通过后台/API 写入 `projects.json`,MCP 侧没有 `ops_register_service`。启动配置(`ecosystem.config.cjs`)从 2.11.2 起已由部署执行器自动解析/渲染,**不再要求人工预先生成**;产物自带配置也可被采用。 ### 2.4 平台自动判定 / 只能人工 | 判定方式 | 条目 | |----------|------| | 平台可自动判定 | `registry.*`(字段格式与必填)、`runbook.path_standard` / `runbook.readable` / `runbook.sections`、`backup.app_dir` / `backup.schedule` / `backup.oss_prefix`(按生效目标可列出)、`process.exists` / `process.online`、`network.port_registered` / `network.port_probe`、`caddy.site_block` / `caddy.proxy_target` / `caddy.domain_unique`、`oss.asset_prefix` | | 当前一律返回 `unknown`(需人工补证) | `backup.script_referenced`、`monitor.script_referenced`(crontab)、`network.listen_loopback`(`ss`)、`process.start_params`、`caddy.hardening`、`security.env_permissions`、`security.public_bind` | `unknown` **不等于** `fail`,也不代表通过;出现 `unknown` 必须人工核对并在报告中列出。标注「平台自动判定」的条目在只读数据源不可用时(OSS 列取失败、runbook 读取异常、pm2 不可达)同样会返回 `unknown`。 结论:当前接入必须人工执行。conformance 只校验「登记与可观测项」,不校验代码质量;本文件的运行时契约(R1–R7,定义见 2.5)目前依赖开发者自检与后续离线校验,平台尚未逐条校验。 ### 2.5 运行时契约 R1–R7 本节是 R1–R7 的唯一定义;其他章节引用编号时一律指回本节,不再重复展开。 | # | 契约 | 规范要求 | 判定方式 | 当前强度 | |---|------|----------|----------|----------| | R1 | 进程标识与工作目录 | PM2 `name` 必须等于登记 `pm2Name`;`cwd` 必须等于 `deployPath`(取登记 `backup.appDir`;未登记时 `/`,安装默认 `/opt/apps/{id}`) | 预检解析 ecosystem 的 `name`/`cwd`;`pm2 describe {pm2Name}` 的 `pm_cwd` 复核 | **fail**(已实现) | | R2 | 监听地址 | 端口只来自 `PORT`;只监听 `127.0.0.1`,**禁止 `0.0.0.0`** | 预检 `PORT` 键;健康探测命中 `127.0.0.1:{localPort}`;项目方确认代码绑定 `127.0.0.1` 且与登记 `port` 一致(`ss -tlnp` 需服务器权限,属管理员核对手段) | 预检部分 **fail**;`0.0.0.0` 检测恒 `unknown`(无 `ss` 只读数据源) | | R3 | 健康检查 | 路径以登记 `probePath` 为准,默认 `/healthz`;仅 `2xx/3xx` 通过,`>=400` 判失败 | 部署后轮询 `probePath`;conformance `network.port_probe` | **fail**(健康失败触发回滚) | | R4 | 日志 | 只写 stdout/stderr;不得自建文件轮转 | 人工检查 `createWriteStream`/日志框架 file transport;`pm2 logs` 可读 | warn(无自动判定) | | R5 | 优雅退出 | 处理 `SIGTERM`:停止接收新请求 → 等在途请求 → 释放 DB/定时器/队列 → `exit 0` | 人工 `kill -TERM` 验证;`kill_timeout` 留足时间 | warn(无自动判定) | | R6 | 环境分层 | 非密钥运行参数(`PORT`/`HOST`/`NODE_ENV`/`WEB_DIST`/数据路径键)必须写入 ecosystem `env`;密钥只放 `deployPath/.env`(600),由应用自行加载 | 预检 `ecosystem.env` 缺键即 fail;`/proc//environ` 比对 `PORT`、`WEB_DIST`、ecosystem 声明的数据键;密钥是否误入 `env` 仅人工检查 | 缺键 **fail**;密钥误入 `env` warn(无自动扫描) | | R7 | 数据目录独立 | `dataDir` 及其解析后的数据键不得位于 `releases/` 或 `current` 内;切换版本后数据必须仍在 | 数据键路径校验(fail);`dataDir` 本身的 `realpath` 比对依赖后端 `2.1.0` | 数据键 **fail**;`DATA_DIR`/`dataDir` 自身校验在 `2.1.0` 前不生效 | 判定约定: - 表中「当前强度」描述的是 `1.1.0` 已实现的行为;与设计文档早期版本不一致的,以本节为准。 - R6 已按现行环境模型修正:旧表述「`.env` 由应用自身加载、不依赖 PM2 注入」只适用于密钥;非密钥参数必须由 ecosystem `env` 注入,否则 `/proc` 不可校验。 - R2/R4/R5 属于「规范硬性、平台只能人工或 `unknown`」的条目,不得因为 conformance 返回 `unknown` 就视为通过。 --- ## 3. 目录约定 ```text /{id}/ deployPath,PM2 的 cwd(安装参数 --deploy-root,默认 /opt/apps/{id}) ├── releases/{YYYYMMDD-HHMMSS}/ 不可变发布目录,每次部署新建 ├── current -> releases/{ts} 当前版本软链,回滚即重指 ├── data/ dataDir,持久数据,禁止放进 release ├── .env 运行时配置,权限 600,不入库 ├── .env.example 键名 + 说明 + 安全默认值,可入库 ├── src/ 应用代码(随 release 发布) └── package.json 依赖与启动脚本 /opt/ops-scripts/{id}/backup.sh 备份脚本(运维归属,不进项目 git) /opt/ops-scripts/{id}/monitor.sh 监控脚本(同上) /opt/runbooks/{id}.md 运维手册,≤200KB ``` > 图中 `/{id}` 是 `deployPath` 的标准写法:实际值优先取项目登记的 `backup.appDir`;未登记时取部署根(`--deploy-root`,默认 `/opt/apps`,即 `/opt/apps/{id}`)。`deployPath` **不是 `projects.json` 的独立字段**,不要自行改写或写入登记条目;任何 `/opt/{id}` 都是旧口径残留。 硬要求: - `dataDir` 的 `realpath` 不得以 `releases/` 或 `current` 为前缀——切换版本后数据必须仍在。 - `current` 是软链,不要把它当真实目录写入;应用不应依赖自身位于哪个 release。 - 运维脚本与 runbook 都不要放项目仓库:历史上 `git pull` 覆盖脚本导致备份静默失败。 - 目录与文件建议属主为非 root 运行用户;`.env` 权限恒为 `600`。 - 无端口服务:`localPort=0`,不参与端口探测、不生成 Caddy 站点块,其余契约不变。 --- ## 4. PM2 运行契约 | 项 | 规范 | |----|------| | 进程名 | `name` 必须等于登记 `pm2Name`,全局唯一 | | 工作目录 | `cwd` 必须等于 `deployPath`(取登记 `backup.appDir`;未登记时 `/`,安装默认 `/opt/apps/{id}`) | | 入口 | `script` 指向 `current/` 下的入口文件,切换版本后重启即生效 | | 实例 | 默认 `instances: 1`、`exec_mode: 'fork'`;需要 cluster 时必须先与平台确认共享状态 | | 重启 | `autorestart: true`、`max_restarts` 有限值、`max_memory_restart` 明确上限 | | 退出 | `kill_timeout` 建议 10000ms,配合应用侧 `SIGTERM` 处理 | | 环境 | `env` 必须显式写全非密钥运行参数:`PORT`(= `localPort`;无端口服务除外)、数据路径键(`DATA_DIR` 或应用真正消费的 `DATABASE_FILE`/`UPLOAD_ROOT` 等,见第 7 节)、`HOST`、`NODE_ENV`,有前端产物时加 `WEB_DIST`;**密钥只走 `.env`,禁止写进 `env`** | | 超时 | 应用层 `requestTimeout` 不得早于反代 `read_timeout`/`write_timeout`(否则反代还在等、应用先断);它只约束**接收请求体**,不约束响应流,不能当流式响应超时用 | | 禁止 | `pm2 delete`、`pm2 kill`、`pm2 save`、改监听端口、启动 `ops-backend` 替身进程 | `ecosystem.config.js` 参考骨架(平台从 2.11.2 起会在首次部署时按登记自动渲染;本地自检可参照同一结构): ```js module.exports = { apps: [{ name: 'demo-api', // 必须等于 projects.json 的 pm2Name cwd: '/opt/apps/demo-api', // 必须等于 deployPath(/{id} 或登记 backup.appDir) script: 'current/server.js', // 通过 current 软链指向当前 release instances: 1, exec_mode: 'fork', autorestart: true, max_restarts: 10, max_memory_restart: '300M', kill_timeout: 10000, env: { // 非密钥运行参数:启动唯一真源,与登记值逐字一致 NODE_ENV: 'production', PORT: '3000', // 必须等于登记 localPort HOST: '127.0.0.1', DATA_DIR: '/opt/apps/demo-api/data', // 数据路径键之一;应用实际读 DATABASE_FILE/UPLOAD_ROOT 时改写对应键 WEB_DIST: 'current/dist' // 仅当产物含前端静态资源时必填 } // 密钥(JWT_SECRET、第三方 API Key)只放 .env,不进 env }] }; ``` `SIGTERM` 语义(R5):收到信号后停止接受新连接 → 等待在途请求结束 → 关闭 DB / 定时器 / 队列 → 以 `0` 退出;超过 `kill_timeout` 允许被强杀,但不得留下锁文件或半写数据。`SIGINT` 同样处理。 **超时口径**(实测教训): - 应用层 `requestTimeout` 必须**不早于**反代侧 `read_timeout` / `write_timeout`,否则会出现「反代还在等、应用先断开」的假 502/504。 - `requestTimeout` 只约束**接收请求体**的时长,**不约束响应流**;把它当成流式响应超时会导致长任务被误判。 - 真正的超时要按**业务最长耗时**单独设计(下游 API 调用、批处理、导出等),并在 runbook 写明依据。first 的经历是:瓶颈来自上游 API 的 120s 中止,而不是本服务或反代,调小本服务超时只会更快失败。 --- ## 5. 健康检查契约 - 默认路径 `/healthz`;实际探测路径以登记 `probePath` 为准(默认值 `/healthz`,必填)。 - 成功:HTTP `200`,`Content-Type: application/json`,响应体 `{"status":"ok"}`。 - 失败:依赖不可用时返回 `503`(不要用 200 掩盖 DB/依赖故障)。 - 判定:只有 `2xx/3xx` 视为通过;**`4xx/5xx` 一律判失败**,404 不再视为存活。 - 要求:健康检查不得触发写操作或长耗时查询;接口必须在进程启动后尽快可用。 - 无端口服务:`localPort=0`,不做端口探测。 --- ## 6. 日志约定 - 应用把结构化日志写到 stdout / stderr,由 PM2 收集与 `pm2-logrotate` 轮转。 - 应用**不得自行实现文件轮转**(`createWriteStream` + 自建切割、日志框架 file transport 均属不合规,记为 warn 级)。 - 单行 JSON 优先:`{"t":"ISO8601","level":"info","msg":"..."}`,便于检索。 - 禁止打印:`.env` 值、AccessKey/Secret、Token、完整请求头、用户隐私字段。 - 日志量需可控:健康检查等高频路径不逐次打 info;错误日志必须包含可定位的上下文。 --- ## 7. 环境变量约定 启动环境按**性质**拆成两个来源,职责不重叠: | 类别 | 内容 | 位置 | 作用 | |------|------|------|------| | 非密钥运行参数 | `PORT`、`HOST`、`NODE_ENV`、`WEB_DIST`,以及**数据路径键**(`DATA_DIR` 或应用真正消费的键) | `ecosystem.config.js` 的 `env` | 启动唯一真源;平台预检与启动后校验都以它为准 | | 密钥与业务凭据 | `JWT_SECRET`、第三方 API Key、数据库口令等 | `deployPath/.env`(权限 `600`) | 只在进程内使用;不入库、不入日志、不进发布产物 | ### 7.1 端口 - `PORT` 必须显式写入 ecosystem `env`,值等于登记 `localPort`。 - 无端口服务(`localPort=0`)不声明 `PORT`,其余键照常。 ### 7.2 数据目录(三种表达,任选其一) | 方式 | 写法 | 校验 | |------|------|------| | A. 通用键 | `DATA_DIR=/data` | 解析后必须落在登记 `dataDir` 内(含相等与子目录);相对路径以部署根解析 | | B. 应用真实消费的键 | 如 `DATABASE_FILE=/data/app.db`、`UPLOAD_ROOT=/data/uploads` | 解析后**必须落在登记 `dataDir` 内**(含相等与子目录) | | C. 无持久化状态 | projects.json 写 `stateless: true` | 不要求任何数据键,也不校验 `dataDir`;备份可关闭 | **判定顺序(与实现一致,命中即停)**: 1. `projects.json` 的 `stateless === true` → 视为无持久化状态,通过; 2. ecosystem `env` 中 `DATA_DIR` 非空 → 采用该键,并按与具体数据键相同的规则解析、校验落在 `dataDir` 内; 3. ecosystem `env` 中符合 `_FILE` / `_DIR` / `_PATH` / `_ROOT` / `_DB` 后缀的数据类键(排除 `NODE_PATH`、`WEB_DIST`、`HEALTH_PATH`、`PATH`)→ 逐个解析并校验落在 `dataDir` 内; 4. 以上都没有时,读取项目根 `.env`(`deployPath/.env`)**仅补充发现**数据类键,再做同样的路径校验; 5. 仍没有 → 判 fail:`ecosystem.env 缺少 DATA_DIR,也未声明具体数据键(*_FILE/_DIR/_PATH/_ROOT/_DB)或 stateless=true:无法确认持久化数据目录`。 - **`.env` 只是补充发现来源**:从 `.env` 读到的键只参与「发现 + 路径校验」,**不参与** `/proc//environ` 运行时比对——`.env` 由应用在进程内注入,不进入 `execve` 初始环境,看不到这些键。只有 ecosystem `env` 声明的键才纳入运行时校验。 - **`DATA_DIR` 校验的生效版本**:`DATA_DIR` 的完整路径校验自后端 **`2.1.0`** 起生效;`2.0.0` 及更早版本的实际行为是「`DATA_DIR` 存在即通过,不做路径比对」。在低于 `2.1.0` 的后端上,接入方仍应主动把 `DATA_DIR` 指向登记 `dataDir`,不要依赖宽松行为;平台在该版本前不会因此判 fail。 - **相对路径以部署根为基准**:相对路径按 PM2 `cwd`(即 `deployPath`)解析,绝对路径原样使用。校验失败的错误信息会写明基准目录与登记目录,例如 `DATABASE_FILE=data/app.db(相对 cwd=/opt/apps/demo-api 解析 → /opt/apps/demo-api/data/app.db)不在登记 dataDir=/opt/apps/demo-api/data 内`。 - **为什么允许 B**:jingyu 这类应用读的是 `DATABASE_FILE` 与 `UPLOAD_ROOT`,从不读 `DATA_DIR`。硬填 `DATA_DIR` 等于在登记表里造一条永远无人读取的假数据——这比缺字段更糟,因为它会让预检「通过」却什么都没验证。用真实键才能既表达清楚,又能被实际校验。 - 一个项目可以同时有多个数据键,但解析后都必须落在同一个 `dataDir` 内;需要多个独立目录时,先与管理员确认再调整登记。 - `stateless` 与数据键/`dataDir` 互斥使用:声明 `stateless: true` 后,不应再指望平台校验数据目录;若之后产生持久化数据,必须去掉该字段并补数据键。 ### 7.3 其余运行参数 - 非密钥参数必须**显式写入 ecosystem `env`**:`PORT` 等于登记 `localPort`,数据路径键按 7.2,产物含前端静态资源时还必须写 `WEB_DIST`。缺失即预检失败(`ecosystem.env` 判 fail),不依赖应用代码或 `.env` 兜底。 - 进程启动后,平台读取 `/proc//environ`,与 ecosystem `env` 逐键比对 `PORT`、`WEB_DIST`(如有)与 **ecosystem `env` 中声明的**数据路径键(仅在 `.env` 中发现、未写入 ecosystem `env` 的键不参与运行时比对);不一致即 `ENV_MISMATCH` 并回滚。这是「配置的值」与「进程实际值」之间的可校验通道。 - **为什么不能把运行参数只放 `.env`**:`node --env-file=.env`(以及 `dotenv`)是在进程内部注入的,不进入进程的 `execve` 初始环境,`/proc//environ` 里看不到这些键;而 PM2 会把调用方父进程的环境合并进应用,且 `--env-file` 不覆盖已存在的变量。结果是「配置里写的值」与「进程实际拿到的值」可能不一致,却没有可观测通道能发现。写进 `env` 后,参数在 PM2 spawn 时即确定、`/proc` 可验证,预检与运行期校验才能闭环。 - 运行参数变更流程:改 ecosystem `env` → `pm2 restart --only --update-env`(若 `/proc` 仍未生效则 `pm2 delete` + `pm2 start`)→ 用 `/proc//environ` 确认 `PORT` 与数据路径键。 - 密钥变更流程:改 `.env` → 重启进程 → 验证;**不要**通过修改 PM2 `env` 下发密钥(会被进程快照固化,改 `.env` 不生效)。 - `.env` 放 `deployPath/.env`,权限 `600`,不提交 git,不打进发布产物,不写入 runbook 明文。 - `.env.example` 入库,只写键名、说明与安全默认值。 - 端口键固定为 `PORT`;数据路径键优先用应用真实消费的键(`DATA_DIR`、`DATABASE_FILE`、`UPLOAD_ROOT` 等),并在 runbook 第 7 章登记键名与解析后的绝对路径(值脱敏)。 - 密钥不入库、不入日志、不作为命令行参数、不返回给调用方。 --- ## 8. 数据与备份契约 ### 8.1 数据 - `dataDir` 顶层定义,默认 `/data`;`backup.dataDir` 仅作历史兼容,缺省取顶层值。 - 默认 SQLite,库文件位于 `dataDir`,可开 WAL。 ### 8.2 备份范围与一致性 备份**整个 `data/` 目录**,`app.db` 只是其中一部分: ```bash # 1) 先复制 data/ 全量,跳过 SQLite 主库与 WAL/SHM tar -C "$DATA_DIR" --exclude='*.db' --exclude='*.db-wal' --exclude='*.db-shm' \ -cf - . | tar -C "$stage" -xf - # 2) 每个 SQLite 库用在线备份生成一致性副本,并自检 sqlite3 "$DATA_DIR/app.db" ".backup '$stage/app.db'" sqlite3 "$stage/app.db" "PRAGMA integrity_check;" # 必须返回 ok # 3) 打包并原子落盘 tar -C "$stage" -czf "$BACKUP_DIR/{id}-$ts.tar.gz.tmp" . mv -f "$BACKUP_DIR/{id}-$ts.tar.gz.tmp" "$BACKUP_DIR/{id}-$ts.tar.gz" ``` 禁止:直接 `cp` / `tar` 正在写入的 `.db`(WAL 模式下会得到损坏或落后的副本)。 **完整性失败的语义**:`PRAGMA integrity_check` 不为 `ok` 时,本次备份必须判为失败,不得上传或标记成功。模板 `backup.sh` 已按 `exit 1` 实现;平台 `doBackup` 自后端 **`2.1.0`** 起也按 fail 处理(`2.0.0` 及更早版本的实际行为是仅打印 `warn` 并继续上传,运维必须人工核对告警日志)。 ### 8.2.1 非 SQLite 数据库(MySQL / PostgreSQL) 8.2 的 `data/` 打包只覆盖文件型数据。使用 MySQL/Postgres 的项目,数据库不在 `dataDir` 内,**平台备份包不包含业务数据**,必须按本节补导出。 **显式前置条件**:本节依赖「平台在 tar 打包 `dataDir` 之前先执行 `backup.triggerCmd`」这一现有执行顺序(见 §10 与备份调度器实现)。如果后端改为并行执行、跳过 `triggerCmd`,或在 `triggerCmd` 失败后仍继续打包上传,本节失效,必须重新评审。`triggerCmd` 非零退出时平台当前只告警、不中断打包,因此导出成功与否必须在脚本内部自行保证并落盘可验证标记。 1. **导出责任**:项目自己的 `/opt/ops-scripts/{id}/backup.sh` 负责导出数据库;平台只打包 `dataDir`,不直连数据库。 2. **导出落点**:导出文件必须写入 `dataDir` 下的专用子目录(建议 `$DATA_DIR/db-dump/`),先写 `.tmp` 再原子 `mv`,避免平台 tar 到半个文件。 3. **一致性参数**: - MySQL:`mysqldump --single-transaction --quick --routines --triggers`(InnoDB);需要强一致快照时从只读副本导出。 - PostgreSQL:`pg_dump --format=custom --no-owner`;全实例级备份(`pg_basebackup`)单独由运维评估。 4. **凭据**:数据库口令只从 `deployPath/.env` 读取,不得出现在命令行参数、脚本明文或日志中(避免 `ps` 泄露)。 5. **校验**:导出后检查退出码、文件非空、格式可识别(如 `pg_restore --list`);任何一项失败即 `exit 1`,并在 `dataDir` 写入失败标记供监控识别。 6. **保留**:`db-dump/` 只保留最新一份工作文件,历史由平台备份包承载;本地与远端目标仍按 8.3 各自保留 7 份,避免 `data/` 无限增长。 7. **恢复**:runbook 第 4 章必须写明取哪份包、解出 dump、恢复到哪个实例、恢复后如何校验关键表行数;每季度至少演练一次。 8. **未覆盖即视为无备份**:未按本节导出的 MySQL/Postgres 项目,不具备可恢复备份;登记时不得把「备份已开启」当作数据保护已完成的依据。 ### 8.3 备份目标与保留 备份目标是三层解析:**项目级 `backup.provider` → 全局 `OPS_BACKUP_PROVIDER` → 本地**,默认 **本地**。 - **本地目标**:落 `OPS_BACKUP_LOCAL_DIR`(建议指向独立数据盘,如 `/data/ops-backups`,避免与系统盘/发布目录争空间);未配置时由平台按自身数据目录落盘。保留最近 **7** 份,按时间倒序清理。 - 打包时只排除目录级 `logs/`,不要全局排除 `*.log`——否则 `data/` 内的日志类数据文件会被漏掉。 - **远端目标**(`oss` / `cos`):必须先在后台「存储设置」完成配置并**通过连接测试**才可用。未配置、测试失败或凭据失效时,备份请求 **fail-closed**:返回 `409 BACKUP_TARGET_UNAVAILABLE`,**不会静默退回本地**。升级到新备份模型后必须点一次连接测试,否则远端目标一律视为不可用。 - **项目级覆盖**:`backup.provider` 取 `""`(跟随全局)/ `local` / `oss` / `cos`;只有显式填远端时才强制走远端,填 `local` 可让单个项目绕开全局远端。 - **按来源保留**:本地与各远端目标各自计数、各自清理(前缀 `backups/{id}/`),切换目标不会删除另一来源的历史包;每个来源保留最新 7 份。 - **按来源查看/下载/恢复**:`GET /api/projects/{id}/backups?provider=` 列出该来源的包,`GET /api/oss/download/{key}?provider=...` 下载,`POST /api/projects/{id}/backups/restore` 指定来源与对象恢复;远端下载优先使用短期预签名 URL,不经 ops 公网带宽。 - 项目侧只产出本地一致性快照,**不要自建第二条上传链路**(双跑会产生重复备份与保留错乱)。 - 静态资源前缀 `assets/{id}/`,可选。 ### 8.4 定时任务 - 备份与健康检查的 crontab 由平台/运维登记,项目侧不重复注册同类任务。 - `monitor.sh` 建议 `*/5 * * * *`;异常只写日志并 `exit 1`,**不做任何重启动作**。 --- ## 9. runbook 必备章节(8 章,缺一章即 warn) ```markdown # ${SERVICE_NAME} 运维手册 ## 1. 服务概览(PM2 名、端口、目录、运行用户、依赖) ## 2. 域名与反代(域名、Caddy 站点块、证书 SAN) ## 3. 启停与部署(命令、部署脚本、回滚步骤) ## 4. 数据与备份(DB 路径、backup.sh 位置、备份目标与保留策略) ## 5. 健康检查与监控(${PROBE_PATH}、monitor.sh、crontab) ## 6. 日志位置与排查(pm2 logs、应用日志、审计) ## 7. 关键环境变量(脱敏) ## 8. 已知风险与注意事项 ``` - 路径 `/opt/runbooks/{id}.md`,UTF-8,≤200KB。 - 第 7 章只列键名与用途,值一律脱敏。 - 第 3 章必须能按步骤复现「部署 + 回滚」,不能只写「见 CI」。 - **第 1、3、4 章禁止写死具体 release 或版本号**(如 `releases/20261109-030000`、`v1.4.2`),它们会随每次部署过期。统一用 `readlink -f /current` 或读 `current/package.json` 的 `version` 动态获取;示意写法:`readlink -f /opt/apps/{id}/current`。 - 占位符统一为 `${VAR}` 一种写法(如 `${SERVICE_NAME}`、`${PM2_NAME}`、`${PORT}`、`${PROBE_PATH}`),不再使用 `{service}` 等变体。 --- ## 10. projects.json 字段 必填:`id`、`name`、`pm2Name`、`localPort`、`runbookPath`、`probePath`。schema 已设 `additionalProperties=false`,未列出的字段(含拼写错误)一律报错。`probePath` 必填是为了强制显式确认,登记表单与迁移脚本自动填入默认值 `/healthz`。 | 字段 | 必填 | 默认 | 含义与校验 | |------|------|------|------------| | `id` | 是 | — | `^[a-z][a-z0-9-]{1,31}$`,全局唯一,不得为 `ops` | | `name` | 是 | — | 展示名,1–40 字符 | | `pm2Name` | 是 | — | 必须存在于 `pm2 jlist`,全局唯一,不得为保留名 | | `localPort` | 是 | — | `0`–65535,`0` 表示无端口服务;其余值不得与预留端口冲突。3000–3999 **只是建议段**:登记不因超出该区间失败,部署规划仅产生一条 warning。真正参与硬校验的是登记范围 `0`–`65535` 与规划接口要求的 `1024`–`65535`;与登记值不一致时才报错 | | `runbookPath` | 是 | — | 绝对路径,标准位置 `/{id}.md`(默认 `/opt/runbooks/{id}.md`),存在且 ≤200KB。项目 Key 可读自己项目的 runbook;文件不存在返回 `404 RUNBOOK_NOT_FOUND`,路径越界返回 `403 RUNBOOK_PATH_NOT_ALLOWED` | | `probePath` | 是 | `/healthz` | 探测路径,必须返回 2xx/3xx | | `dataDir` | 否 | `/data` | 登记的数据目录(顶层唯一定义)。运行时由 `DATA_DIR` 或应用真实消费的键(`DATABASE_FILE`、`UPLOAD_ROOT` 等)表达;**两者解析后都必须落在该目录内**(`DATA_DIR` 的完整校验自后端 `2.1.0` 起生效)。不得位于 `releases/`、`current` 中;`stateless: true` 的项目不要求该目录存在 | | `stateless` | 否 | `false` | `true` = 显式声明无持久化状态:不要求数据键、不校验 `dataDir`,判定优先级最高。**声明后平台会忽略数据键,不会因「互斥」报错**;若项目仍有本地持久化文件,必须去掉该字段并补真实数据键 | | `standardVersion` | 否 | `1.1.0` | 项目**登记**的标准版本,只由显式登记/adopt 变更;与平台当前版本不是同一个概念。`ops_list_projects` 额外返回 `registeredStandardVersion`(登记值)与 `platformStandardVersion`(平台值),旧字段保留为登记值以兼容 | | `healthCheck.enabled` | 否 | `true` | 是否纳入 `healthy` 判定 | | `environment` | 否 | `production` | 环境标识,避免测试服务混入健康判定 | | `owner` | 否 | `""` | 负责人,用于审计与告警 | | `managed` | 否 | `true` | `false` 时只展示,禁止任何控制动作 | | `downloadSync` | 否 | `false` | 是否把上传同步到 `/var/www/apk/`;仅安装包类项目置 `true` | | `deployPath`(派生,非 schema 字段) | — | 取 `backup.appDir` | **不写入 `projects.json`**,`additionalProperties=false` 下写入会被判非法字段。实际值取登记的 `backup.appDir`;未登记时由 `ops_get_project_context` 与规划器统一返回 `/`(默认 `/opt/apps/{id}`,`deployPathSource:"default"`),不写回登记。与 PM2 `cwd` 必须逐字一致;规划器与 conformance 使用同一口径,不再用 `/opt/{id}` 对照 | | `backup.appDir` | 开启备份时必填 | — | **历史兼容别名,实际对应 `deployPath`**,不作为备份内容根(备份范围以顶层 `dataDir` 为准)。绝对路径且存在,不得为 `/`、`/opt`、`/etc`、`/var/www`;新项目填 `/`(默认 `/opt/apps/{id}`)。计划在下一个 minor 版本改为可选并废弃 | | `backup.dataDir` | 否 | 取顶层 `dataDir` | 历史兼容覆盖字段 | | `backup.provider` | 否 | `""` | 项目级备份目标覆盖:`""` 跟随全局 `OPS_BACKUP_PROVIDER`,或 `local` / `oss` / `cos`。远端目标必须已配置且连接测试通过,否则备份 fail-closed(`409 BACKUP_TARGET_UNAVAILABLE`),不退回本地 | | `backup.triggerCmd` | 否 | — | 绝对路径可执行文件,不含 ; | & $ \ ` > < 等 shell 元字符;标准值 `/opt/ops-scripts/{id}/backup.sh` | | `backup.schedule.enabled` | 否 | `false` | 是否启用定时备份 | | `backup.schedule.cron` | 启用时必填 | `0 3 * * *` | 5 段标准 cron,需通过 `cron.validate` | | `backup.schedule.timezone` | 否 | `Asia/Shanghai` | 时区 | | `oss.assetPrefix` | 否 | `assets/{id}/` | 该服务的 OSS 资源前缀 | | `webUrl` / `adminUrl` | 否 | — | 对外访问入口;有域名站点块时 `webUrl` 必填 | | `description` / `icon` | 否 | — | 展示用 | | `onboardedAt` / `onboardedBy` | 否 | — | 接入审计元数据 | 存量迁移提醒:`probePath` 已成为必填,上线 schema 收紧前必须由 `ensureProjectDefaults()` 为老条目补齐,否则旧数据直接判 fail。 --- ## 11. 发布产物形态(v1 定稿) ```text dist/ ├── demo-api-1.0.0.tar.gz 应用与依赖(不含 data/、.env、backups/) └── demo-api-1.0.0.tar.gz.sha256 内容:<64位小写sha256> demo-api-1.0.0.tar.gz ``` | 项 | 规范 | |----|------| | 包名 | `{id}-{version}.tar.gz`;`version` 匹配 `^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$` | | 校验文件 | 同名 `{id}-{version}.tar.gz.sha256`,一行,`<64位小写hex> <文件名>`(两空格,兼容 `sha256sum -c`) | | 包内结构 | 解包后根目录即应用根:`package.json`、入口文件(`server.js` 或 `dist/`)、`node_modules/` 或依赖锁文件 + 离线缓存 | | 包内禁止 | `.env`、`data/`、`backups/`、`logs/`、`.git/`、`releases/`、`current` | | 启动配置 | 推荐不打包,由平台按登记渲染。可选自带 `ecosystem.config.{js,cjs,mjs,json}`:部署目录没有配置时平台会复制并采用;部署目录已有配置时以部署目录为准 | | 落盘 | 解包到 `{deployPath}/releases/{YYYYMMDD-HHMMSS}/`,`current` 软链指向它,保留最近 3 个 release | - **发布产物只有两个文件**:压缩包与同名 `.sha256`。除此之外不要求、也不读取任何附加文件。 - **打包必须用项目内固定 build 脚本**,一条命令产出 `{id}-{version}.tar.gz` 与 `.sha256`。脚本至少做三件事: 1. **禁项扫描**:确认包内无 `.env`、`data/`、`backups/`、`logs/`、`.git/`、`releases/`、`current`,命中即失败退出(`ecosystem.config.*` 可选,不作禁项)。 2. **软链检查**:拒绝指向包外的符号链接与绝对路径条目,避免解包逃逸。 3. **解包启动自检**:在临时目录解包,用**随机空闲端口 + 临时数据目录**真实启动,轮询 `/healthz`(或登记 `probePath`)返回 2xx/3xx 后再清理退出;启动失败即不产出产物。 - **反例:靠人记 tar 参数打包**。手工 `tar czf` 容易漏排除项、把 `.env` 或 `data/` 打进去、忘记生成 `.sha256`,且打包前从不验证能否启动——问题全部推迟到生产部署时才暴露。build 脚本应入库,并把上述检查作为脚本的一部分,而不是写在文档里靠人执行。 - **conformance 是实时计算,不是交付物**:`ops_check_conformance` 每次都重新拉项目登记、查进程、探端口、读 Caddy 现场比对;`conformance.json` 不是发布产物,也不是部署前置条件——缺失它不会阻止部署。静态快照会立即过期,反而误导判断,因此不再要求,也不再归档。需要体检结论时直接调用该工具。 - 默认**不联网**:优先内置 `node_modules`,或在服务器保留离线缓存后执行 `npm ci --omit=dev --offline`。 - 部署流程(2.11.2 起已实现):上传 → 校验 `sha256` 与大小 → staging 解包(拒绝绝对路径、`..`、符号链接) → `releases/{ts}` → **入口探测 / 启动配置解析或渲染** → 切换 `current` → 启动/重启 PM2 → 轮询 `probePath` → 失败自动回滚上一 release。 - **保留数量**:`releases/` 仅保留最近 3 个(`OPS_DEPLOY_KEEP_RELEASES`,默认 3);更早的 release 由平台清理,显式回滚只能指向仍存在的目录。清理**由平台执行**,项目方只需在回滚前确认目标 release 仍在。 - **首次部署失败(`keep-current`)**:当项目只有一个 release、没有可切回的上一版本时,部署失败**不会删除入口**:`current` 仍指向新 release,平台标记本次失败并记录 `strategy=keep-current`。此时没有「旧版本」可回退,必须人工处置:修复后重新发布,或确认可接受后保留现状。该策略只保证入口不消失,不代表新版本可用。 - **带迁移的发布**:部署前必须按第 19 章判定迁移兼容性;`current` 的自动切换不等于数据库可回退,回滚语义见第 19 章。 - 部署前必须 conformance `fail === 0`;`warn` 允许但写审计。 ### 11.2 启动配置渲染与入口探测(1.2.6 / 服务端 2.11.2) 平台按以下**优先级**决定本次部署使用哪份启动配置,结论写入审计事件 `project.ecosystem.render`,并在部署响应/`dryRun` 的 `ecosystem` 字段回显: | 优先级 | 来源 | 行为 | |--------|------|------| | 1 | 部署目录已有 `ecosystem.config.{js,cjs,mjs,json}` | 直接使用;`projects.json` 的 `ecosystemMode="render"` 才强制重渲染,覆盖前备份为 `*.bak-` | | 2 | 产物根目录自带上述配置 | 部署目录没有配置时复制并采用(部署目录已有则仍用部署目录的) | | 3 | 按登记渲染 | 写入 `{deployPath}/ecosystem.config.cjs`,`name=pm2Name`、`cwd=deployPath`、`script=current/<入口>`、`env` 含 `PORT`(= `localPort`,0 则省略)与 `DATA_DIR`(`stateless=true` 时省略),并 `autorestart:true` / `instances:1` / `exec_mode:"fork"` / `max_memory_restart:"256M"` | **入口探测优先级**(按顺序,命中即停): 1. `projects.json` 的 `entry`(相对产物根;显式覆盖) 2. `package.json` → `scripts.start`(仅接受可直接交给 node 的命令:`node [--flags] ` 或纯文件路径;`npm/pnpm/pm2/next` 等包装命令不猜) 3. `package.json` → `main` 4. 常见入口:`server.js`、`index.js`、`app.js`、`main.js`、`dist/server.js`、`dist/index.js`、`src/server.js`、`src/index.js`、`build/server.js`、`server/index.js`、`app/server.js` 5. 静态站点:`dist/index.html`、`build/index.html`、`public/index.html`、`out/index.html`、`web/dist/index.html`,以及根目录 `index.html`(平台会写入 `current/.ops/static-server.cjs` 并提供 `/healthz`) **探测不到直接失败**(`ENTRY_DETECT_FAILED`),返回 `detail` 含已尝试的候选路径与原因,`suggestion` 给出修复方式;不会猜一个「可能能跑」的入口。项目侧可补: - `projects.json` 显式 `entry`(如 `server.js`、`dist/server.js`); - 或 `package.json` 的 `scripts.start` / `main` 指向真实存在的文件; - 或提供上述静态站点目录之一。 `dryRun` 只在临时解包目录生成 `.ops/ecosystem.preview.cjs` 预览,不写部署目录;返回 `ecosystem.contentPreview` 可直接查看渲染结果。 --- ## 12. 最小 Node.js 骨架(30 行内,含健康检查、env 端口、SIGTERM) ```js 'use strict'; const http = require('node:http'); const path = require('node:path'); const PORT = Number(process.env.PORT || 3000); // 端口只来自环境变量 const HOST = '127.0.0.1'; // 禁止 0.0.0.0 const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data'); // cwd=deployPath const server = http.createServer((req, res) => { if (req.method === 'GET' && req.url === '/healthz') { // 路径需与 probePath 一致 res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ status: 'ok' })); } res.writeHead(404, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'not_found' })); }); server.listen(PORT, HOST, () => { console.log(JSON.stringify({ t: new Date().toISOString(), msg: 'listening', host: HOST, port: PORT, dataDir: DATA_DIR })); }); let closing = false; const shutdown = (signal) => { if (closing) return; closing = true; console.log(JSON.stringify({ t: new Date().toISOString(), msg: 'shutdown', signal })); server.close(() => process.exit(0)); // 停止新请求,等待在途请求结束 setTimeout(() => process.exit(0), 10000).unref(); // 兜底强制退出 }; process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); ``` 配套:`PORT`/`HOST` 与数据路径键由 ecosystem `env` 注入,应用只读 `process.env`,不得硬编码;`.env` 只放密钥,入口仍可用 `node --env-file=.env` 或首行 `require('dotenv').config()` 加载(`--env-file` 不覆盖 `env` 已注入的变量)。示例用的是 `DATA_DIR`;若应用实际读 `DATABASE_FILE` / `UPLOAD_ROOT`,改成对应键,只要解析后落在登记 `dataDir` 内(见 7.2)。数据文件一律写在 `dataDir` 内。 --- ## 13. 验收清单(逐条自检) 清单分两组:**13.1 由项目方自己勾**(项目仓库内、项目进程内能完成的事);**13.2 由平台执行,项目方只做确认**(不要试图自己配 DNS、改证书或写 Caddy)。技术口径与编号引用保持不变。 > **优先级**:每条开头标注档位。**必检** = 不满足会导致部署失败、被平台拒绝或线上故障;**建议检** = 不满足不阻断部署,但会削弱可观测性、可维护性或排障效率。13.1 共 31 条:必检 23 条,建议检 8 条;时间紧时至少把「必检」全部走一遍。 > > **权限前提**:项目方通常只持有项目 Key,不能执行 `pm2`、`ss`、`ls releases/`、读 `/proc` 等需要服务器 shell 的操作。这类核对一律改为「确认平台接口返回值」;文中出现的命令保留给管理员核对用。 ### 13.1 项目方必须完成 代码与运行 - [ ] **[必检]** ecosystem `env` 显式写全 `PORT`(无端口服务除外)、`HOST`、数据路径键(`DATA_DIR` 或应用真实消费的 `DATABASE_FILE`/`UPLOAD_ROOT` 等;无持久化状态在 projects.json 写 `stateless: true`),有前端产物含 `WEB_DIST`;与登记值一致 - [ ] **[必检]** 由 `ops_check_conformance` 判定 `env.*`:`PORT`、数据路径键与 ecosystem `env` 一致,且数据键与 `DATA_DIR` 解析后落在登记 `dataDir` 内(`DATA_DIR` 完整校验依赖后端 ≥ `2.1.0`)。项目方确认返回的 `env.*` 结果与预期一致;`/proc//environ` 比对需服务器权限,仅供管理员核对 - [ ] **[建议检]** `requestTimeout` 不早于反代 `read_timeout`/`write_timeout`,且未把 `requestTimeout` 当作响应流超时 - [ ] **[必检]** `.env` 只放密钥(权限 `600`),PM2 `env` 中无任何密钥 - [ ] **[必检]** 端口读自 `PORT`,host 固定 `127.0.0.1`,代码中无 `0.0.0.0` - [ ] **[必检]** `curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:{port}{probePath}` 返回 2xx/3xx(本地或部署后由平台探测结果确认) - [ ] **[建议检]** 健康检查在依赖故障时返回 `503`,不以 200 掩盖 - [ ] **[必检]** 实现 `SIGTERM`:收到信号后正常退出、无锁文件残留、退出码 0 - [ ] **[建议检]** 日志只写 stdout/stderr,无自建轮转文件 - [ ] **[必检]** 若自带 `ecosystem.config.*`:`name` = `pm2Name`、`cwd` = `deployPath`、`autorestart: true`;未自带则由平台渲染,部署响应 `ecosystem` 字段会回显来源与入口 - [ ] **[建议检]** 项目方确认登记字段与平台 conformance 返回一致(`pm2` 状态、`pm_cwd`)。`pm2 describe {pm2Name}` 需服务器权限,保留给管理员核对 - [ ] **[必检]** `engines` 声明与 ecosystem `interpreter` 指向的 Node 大版本一致(见 20.3) 数据与备份 - [ ] **[必检]** 数据路径不在 `releases/` 或 `current/` 下:确认 `ops_check_conformance` 的 `config.*` 无 `fail`。`realpath` 比对需服务器权限,供管理员核对 - [ ] **[必检]** 备份包覆盖整个 `data/`,SQLite 副本 `PRAGMA integrity_check` 返回 `ok`;integrity 失败即视为备份失败(平台自后端 `2.1.0` 起按 fail 处理),MySQL/Postgres 包内包含数据库导出与校验结果(见 8.2.1) - [ ] **[建议检]** runbook 第 4 章写明迁移方式、迁移失败处置与数据库恢复步骤 - [ ] **[必检]** 发布说明标注本次迁移为「向后兼容」或「破坏性」;破坏性迁移不依赖自动回滚(见第 19 章) - [ ] **[必检]** `backup.sh`、`monitor.sh` 的内容由项目方提供,并保证脚本语法正确、具备可执行权限(本地 `bash -n` 与 `chmod +x` 检查);落位到 `/opt/ops-scripts/{id}/` 由平台执行,项目方确认平台接口可见 - [ ] **[必检]** `.env` 权限 `600`,未出现在 git 与发布包中 登记与文档 - [ ] **[必检]** `id` 合规且非 `ops`,`pm2Name` 唯一且非保留名 - [ ] **[必检]** 项目 Key 未进群聊、仓库、日志或截图;只存于本机安全位置 - [ ] **[必检]** `probePath` 与实际健康检查路径一致 - [ ] **[建议检]** `runbookPath` 指向 `/opt/runbooks/{id}.md`,8 章齐全、≤200KB - [ ] **[建议检]** runbook 未写死 release 名或版本号,均用 `readlink -f /current` 或 `current/package.json` 动态获取 - [ ] **[必检]** `dataDir` 为绝对路径且存在(`stateless: true` 的项目除外);`backup.appDir` 填 `/{id}`(默认 `/opt/apps/{id}`),按 `deployPath` 别名理解 - [ ] **[必检]** 登记的 `standardVersion` 与平台当前声明一致(以 `manifest.json` 或接口返回为准,不要写死具体版本号) 交付与接入 - [ ] **[必检]** 公网入口同时携带客户端 Token 与 `X-Ops-Project-Key`;无 Key 时只读标准资源 - [ ] **[建议检]** 项目 Key 下 `ops_list_projects` 只返回绑定项目,跨项目调用返回 `403` - [ ] **[必检]** `ops_check_conformance` 无 `fail`;`warn` 已记录;`unknown` 已人工核对 - [ ] **[必检]** 发布产物为 `{id}-{version}.tar.gz`,同名 `.sha256` 可用 `sha256sum -c` 校验 - [ ] **[必检]** 产物由项目内固定 build 脚本一条命令产出;该脚本含禁项扫描、软链检查、解包后随机端口 + 临时数据目录启动自检 - [ ] **[必检]** 包内无 `.env` / `data/` / `backups/` / `logs/` / `.git/` / `releases/` / `current`(`ecosystem.config.*` 可选) ### 13.2 平台方完成,项目方仅需确认 以下条目由平台执行,项目方**不要自己动手**;确认方式写的是“你如何验证它已生效”,不是“你如何去配置”。 | 事项 | 执行方 | 技术口径(不变) | 项目方确认方式 | |------|--------|------------------|----------------| | DNS 解析 | 平台 / 运维 | 域名 DNS 指向 `47.110.36.244` | `dig +short ` 返回该 IP | | 证书 | 平台 / 运维 | 静态证书 `/etc/caddy/certs/fullchain.pem` 的 SAN 覆盖该域名 | `curl -fsS https://{probePath}` 无证书告警;`openssl s_client -connect :443` 可验证 SAN | | Caddy 站点块 | 平台 / 运维 | 仅包含本项目域名,`reverse_proxy 127.0.0.1:{port}` | 从公网访问 `https://{probePath}` 返回 2xx/3xx,且未串到其他站点 | | PM2 托管 | 平台 | 进程以 `pm2Name` 常驻,`autorestart: true` | 确认 `ops_check_conformance` 返回的进程状态为 `online`;`pm2 describe {pm2Name}` 为管理员核对手段 | | 项目登记与 Key 签发 | 平台 / superadmin | 管理员全量冲突预检后登记 `projects.json`,通过 `POST /api/keys` 签发 `deploy`/`readonly` Key | `ops_get_project_context` 返回本项目;`ops_list_projects` 仅返回本项目 | | 端口与域名冲突预检 | 平台 / 管理员 | 用全量视图检查端口、域名、`pm2Name`、项目 `id` | 部署前由管理员给出无冲突结论(项目 Key 下 `ops_check_conflict` 会漏报) | | 备份落盘与保留 | 平台 | 按生效目标(项目 `backup.provider` → 全局 `OPS_BACKUP_PROVIDER` → `local`)落盘,每个来源保留最新 7 份;远端目标需连接测试通过,否则 409 `BACKUP_TARGET_UNAVAILABLE` | `ops_list_backups` 能看到当前来源的新对象;数量为 7 | | 备份 cron 登记 | 平台 | `schedule.enabled` / `cron` / `timezone`(`Asia/Shanghai`) | 后台或 `ops_get_project_context` 确认 schedule 已启用且时区正确 | | release 清理 | 平台 | `releases/` 仅保留最近 3 个(`OPS_DEPLOY_KEEP_RELEASES`,默认 3) | 连续部署后确认平台报告的保留数量为 3,且回滚目标仍在 | | 审计日志 | 平台 | 记录登记、备份、部署(或接入)动作 | 后台审计视图能查到对应记录 | --- ## 14. 常见失败原因与反例 | 现象 | 根因 | 正确做法 | |------|------|----------| | 端口从公网可直连,绕过 TLS 与限流 | 监听 `0.0.0.0` | 固定 `127.0.0.1`;对外只经 Caddy | | 部署后数据丢失 | `data/` 放在 `releases/` 或 `current` 内 | 数据目录独立于发布目录(R7,见 2.5,fail 级) | | Caddy 502,但进程 online | 端口硬编码与登记 `localPort` 不一致 | 端口只读 `PORT`,登记值与实际一致 | | 健康检查 404 却显示健康 | 探测路径错误或旧版把 404 当存活 | `probePath` 必须返回 2xx/3xx,否则判 fail | | 改 `.env` 不生效 | 密钥通过 PM2 `env` 注入并被进程快照固化 | 密钥只放 `.env`,改后重启进程 | | 进程实际端口/数据目录与配置不一致 | 运行参数只写在 `.env`,被 PM2 合并的父进程环境(如 `PORT=3100`)覆盖,且 `/proc` 看不到、无法校验 | `PORT`/`HOST` 与**应用实际消费的数据路径键**写入 ecosystem `env` 作为唯一真源,再用 `/proc//environ` 复核 | | 预检显示数据目录已配置,实际却没人读 | 为了满足「必须有 `DATA_DIR`」硬填一个应用从不读的键 | 按 7.2 写应用真实消费的键(`DATABASE_FILE`、`UPLOAD_ROOT` 等),并校验解析后落在 `dataDir` 内 | | 无持久化服务被要求提供数据目录 | 未声明无状态,或误以为必须填 `DATA_DIR` | projects.json 写 `stateless: true`,并移除数据键与 `dataDir` | | 在 `.env` 里写数据键,却发现运行时校验看不到 | `.env` 是进程内注入,不进 `execve` 环境,`/proc` 不可见 | 数据键写进 ecosystem `env`;`.env` 仅作补充发现 | | 502/504,或长任务被提前切断 | `requestTimeout` 短于反代 `read_timeout`/`write_timeout`;或把 `requestTimeout` 当作响应流超时 | 应用超时不早于反代;流式/长任务按业务最长耗时单独设计 | | 每次部署都要手工改 runbook | runbook 写死了 `current` 指向的 release 或版本号 | 用 `readlink -f /current` 或读 `current/package.json` | | 产物夹带 `.env`/`data/`,或打包后才发现在生产起不来 | 靠人记 `tar` 参数手工打包,无禁项扫描与启动自检 | 用项目内固定 build 脚本一条命令出包,内置扫描与解包启动验证 | | 重启时请求失败、SQLite 锁残留 | 未处理 `SIGTERM` | 实现优雅退出,`kill_timeout` 留足时间 | | 备份损坏或丢数据 | 直接 `cp`/`tar` 正在写的 `.db` | 使用 `sqlite3 ".backup"` | | 备份包缺少 `data/` 内非 DB 文件 | 只备份了 `app.db` | 备份整个 `data/`,DB 单独用 `.backup` 覆盖 | | 备份包缺少 `data/` 内日志 | 打包时全局排除 `*.log` | 只排除目录级 `logs/` | | 备份突然停止且无告警 | `backup.sh` 放在项目 git 目录被覆盖 | 脚本放 `/opt/ops-scripts/{id}/` | | 磁盘被日志写满 | 应用自建轮转或无限追加文件 | 只写 stdout,交给 PM2 日志轮转 | | 部署后进程起不来 | 产物不含依赖,且默认不联网 | 内置 `node_modules` 或使用离线缓存 | | 产物被拒收 | 缺少 `.sha256` 或文件名不合规 | 按 `{id}-{version}.tar.gz` + 同名校验文件出包 | | 修改 Caddy 后全站异常 | 直接改整份 Caddyfile 且未 validate | 只增改本项目站点块,validate → 备份 → reload,失败回滚 | | 登记被拒 | `id` 使用 `ops`,或 `pm2Name` 使用保留名 | 更换合规标识 | | 字段拼写错误却未报错 | 旧版 schema 允许额外字段 | 当前 `additionalProperties=false`,按字段表登记 | | 引入密钥泄露 | 密钥进 git / 日志 / runbook / 命令行参数 | 只放 `600` 的 `.env`,日志与响应脱敏 | | 公网入口所有项目工具都报 `PROJECT_KEY_REQUIRED` | 只带了客户端 Token,没带 `X-Ops-Project-Key` | 先找管理员签发项目 Key,再放入请求头 | | 项目 Key 调用直接 `400 INVALID_PROJECT_KEY` | 头格式错误、重复数组或超过 128 字节 | 只放单个 `ops_pt__` 值 | | 项目 Key 突然 `401` | 过期、被吊销、一次性 Key 已用过,或绑定项目已删除 | 重新向管理员申请,不要复用旧 Key | | 冲突预检显示无冲突,接入后却撞端口/域名 | 用项目 Key 跑 `ops_check_conflict`,只看到本项目 | 冲突检测必须由管理员用全量视图完成 | | 启动报 `duplicate column` / 迁移重复执行 | 迁移无记录表或记录表与文件不对齐,失败后盲目重跑 | 按第 18 章建立 `schema_migrations`,迁移语句可重复执行,失败即中止启动 | | 回滚到旧代码后接口 500 / 字段不存在 | 数据库已前进,旧代码不认识新 schema,仍自动切回上一 release | 破坏性迁移禁止自动回滚,只能前进修复;见第 19 章 | | 迁移失败但服务仍在跑 | 迁移在 `listen()` 之后执行,或失败被吞掉 | 启动前执行,失败 `exit` 非零,绝不带着半套 schema 提供服务 | --- ## 15. 版本与变更 - 当前标准版本 `1.2.5`;资源索引、`minServerVersion`(`2.1.0`)与 digest 覆盖范围见 `standards/manifest.json` 与 15.1。 - **1.0.0(2026-11-09)权限模型变更**:公网入口改为「客户端 Token + 项目 Key」两层凭据;无 Key 只能读标准文档资源,所有项目工具返回 `PROJECT_KEY_REQUIRED`;冲突检测与新项目登记只能由管理员完成。旧版权限描述作废,以 1.1、1.2 为准。 - **1.0.0(2026-11-09)产物要求收敛**:`conformance.json` 不再是发布产物,也不参与门禁判定;`ops_check_conformance` 实时计算,静态快照会过期。发布产物只有压缩包与同名 `.sha256`。 - **1.0.0(2026-11-11)数据目录表达放宽**:数据路径不再强制 `DATA_DIR`;可用应用真实消费的键(`DATABASE_FILE`、`UPLOAD_ROOT` 等),解析后落在登记 `dataDir` 内即可;校验跟着实际消费的键走。禁止为满足字段硬填无人读取的假键。 - **1.0.0(2026-11-11)实战经验补充**:新增超时口径(`requestTimeout` 与反代 `read_timeout`/`write_timeout` 的关系、不约束响应流)、项目内固定 build 脚本要求(禁项扫描、软链检查、解包启动自检)、runbook 禁止写死 release/版本号。 - **1.0.0(2026-11-11)新增 `stateless` 字段**:projects.json 与 `ops_plan_deployment` 输入均支持可选 `stateless: boolean`(默认 `false`,不进 required)。数据目录判定顺序为 `stateless` → `DATA_DIR` → ecosystem 中 `_FILE`/`_DIR`/`_PATH`/`_ROOT`/`_DB` 类键 → 项目根 `.env` 补充发现 → 都没有则拒绝;`.env` 来源的键不参与 `/proc` 比对。 - **1.1.0(2026-11-12)运行时契约补全**:新增 2.5,正式定义 R1–R7;修正 2.4、14 的悬空引用;R6 按现行环境模型改为「非密钥参数走 ecosystem `env`,密钥走 `.env`」。 - **1.1.0(2026-11-12)数据目录校验收紧**:`DATA_DIR` 与具体数据键一样,解析后必须落在登记 `dataDir` 内;自后端 `2.1.0` 起生效(`2.0.0` 为「存在即通过」)。该收紧会使部分存量项目由通过变失败,故按 minor 发布。 - **1.1.0(2026-11-12)迁移规则**:新增第 18、19 章,覆盖迁移文件位置与命名、记录表、幂等、失败中止、禁止人工补记录、带迁移回滚与 forward fix。 - **1.1.0(2026-11-12)一致性修正**:`integrity_check` 统一 fail 语义(后端 ≥ `2.1.0`,`2.0.0` 为 warn 后继续上传);`backup.appDir` 明确为 `deployPath` 历史别名;`stateless` 不再表述为「强制互斥」;13 章与 7.2 C 对齐。 - **1.1.0(2026-11-12)回滚风险明示**:第 19 章明确执行器当前无条件 `switch-back`、完全不感知迁移;破坏性迁移必须人工闸门,迁移元数据自动判定仅作为未来建议,不构成现行要求。 - **1.2.0(2026-11-12)新增运行时与依赖基线**:新增第 20 章,按「平台自身依赖 / 项目必须满足 / 平台保证项 / 建议项」四类划分约束力。B 层只保留项目真正能决定的两项——Node.js 大版本、原生模块工具链;磁盘、内存、Caddy、PM2、SQLite CLI、glibc 运行时、OS/systemd 归入平台保证项,保留失败表现仅作排障线索。不写死补丁号,Node 推荐值表述为「22.x LTS,以平台现网为准」。补充项目侧 `engines` 声明、构建脚本 Node 大版本自检与原生模块重编前置检查。纯新增说明,不改变既有判定行为,`minServerVersion` 保持 `2.1.0`。 - **1.2.0(2026-11-12)规范站构建可复现**:`develop-site/requirements.txt` 固定 `markdown==3.9`;`build.py --check` 校验运行版本与钉版本一致;发布记录需同时包含构建依赖版本与四个文件的 sha256。 - **1.2.0(2026-11-12)验收清单归属与优先级修正**:第 13 章拆为「13.1 项目方必须完成」与「13.2 平台方完成,项目方仅需确认」。DNS、证书 SAN、Caddy 站点块、PM2 托管、项目登记与 Key 签发、端口冲突预检、备份上传与保留、备份 cron、release 清理、审计日志移入平台组,并给出「项目方确认方式」;`/proc` 比对、`pm2 describe`、脚本落位、`realpath`、`ss`、`ls releases/` 等需服务器权限的核对统一改为「确认平台返回值」,命令保留给管理员。13.1 共 31 条按「必检 23 / 建议检 8」标注档位(判据:不满足是否导致部署失败或线上故障)。技术口径与编号引用不变;`standardVersion` 验收项改为「与平台当前声明一致(以 `manifest.json` 或接口返回为准)」,不再写死版本号。第 1 节证书行与第 11 章 release 保留同步补执行方说明。 - **1.2.1(2026-11-12)digest 校验盲区修正**:此前 digest 只覆盖 `manifest.resources` 的 14 个文件,规范正文 `llms.txt`、`version.json`、`manifest.json` 都不在集合内,导致 1.1.0 与 1.2.0 正文已变而 digest 仍是 `sha256:6b4b4350…`。现把 `llms.txt` 与 `version.json` 纳入 resources,正文或版本元数据的任何改动都会改变 digest;`manifest.json` 因自引用仍不参与,其版本字段由 `version.json` 镜像并在启动时校验一致。`minServerVersion` 保持 `2.1.0`,判定行为不变。 - **1.2.2(2026-11-14)备份目标模型**:备份目标改为「项目级 `backup.provider` → 全局 `OPS_BACKUP_PROVIDER` → 本地」,默认从 `oss` 改为 `local`;新增本地目标实现(`OPS_BACKUP_LOCAL_DIR`);远端(`oss`/`cos`)必须已配置且连接测试通过才可用,否则 fail-closed(`409 BACKUP_TARGET_UNAVAILABLE`,不退回本地);`projects.schema.json` 的 `backup` 新增可选 `provider`(`""`/`local`/`oss`/`cos`);保留份数按来源各自计数;新增按来源列出、下载、恢复接口。`minServerVersion` 保持 `2.1.0`(不强制校验),但新语义仅在后端包含本次改动后生效,升级后需在「存储设置」点一次连接测试。 - **1.2.6(2026-11-14)首次部署自动启动配置**:部署执行器新增入口探测与 `ecosystem.config.cjs` 渲染(部署目录已有 > 产物自带 > 按登记渲染,覆盖前备份),`dryRun` 返回 `ecosystem.contentPreview`;探测优先级为登记 `entry` → `package.json scripts.start` → `main` → 常见入口 → 静态站点,探测不到即 `ENTRY_DETECT_FAILED`(附候选与建议);渲染/探测写审计 `project.ecosystem.render`。`projects.json` 新增可选 `entry` / `ecosystemMode`(`auto`|`render`);产物可自带 `ecosystem.config.*`(不再列为禁项)。规范 §11.2 与 §2.3 已同步。需服务端 `2.11.2`;`minServerVersion` 保持 `2.1.0`(不强制校验)。 - **1.2.5(2026-11-14)冷启动口径修正**:① 规范站正文头部版本/生成日期改由构建脚本从 `manifest.json` 渲染,发布前断言首页、`llms.txt`、`version.json`、`manifest.json` 四份版本一致,不一致即构建失败;② 正文点名的 `/standards/version.json`、`/standards/manifest.json`、`/v1/manifest.json` 现在真实可访问,`opsstd://v1/...` 给出到 HTTPS 路径的映射;③ `ops_check_conflict` 对已登记项目自身的命中标 `self:true` 并从 `ok` 判定中排除,更新部署可直接放行;④ `ops_get_project_context` 内嵌 conformance 复用 `ops_check_conformance` 同一实现(含首次纳管 deferred),并返回 `snapshotAt` / `rulesVersion`;⑤ `caddy.site_block` 的 `actual` 区分「无站点块 / 有块无路由 / 有路由但目标不符」,`caddy.domain_unique` 与它同源;⑥ 首次部署把 `caddy.site_block`、`caddy.proxy_target` 一并 `deferred`,平台在部署成功后写入站点块并立即复检,已部署项目仍按 `fail` 拦截;⑦ 项目 Key 可读自己项目的 runbook,runbook 目录由 `OPS_RUNBOOKS_DIR` 配置(默认 `/opt/runbooks`),文件不存在返回 404 而非 403;⑧ `ops_list_projects` 与 context 增加 `registeredStandardVersion` / `platformStandardVersion` 显式字段(旧 `standardVersion` 保留兼容);⑨ `ops_plan_deployment` 对 `stateless` 或显式空密钥的项目把 `.env` 步骤标 `applicable:false`。需服务端 `2.11.1`;`minServerVersion` 保持 `2.1.0`(不强制校验)。 - **1.2.4(2026-11-14)产物上传与部署闭环**:新增项目产物上传通道 `POST /artifacts`(客户端 Token + `X-Ops-Project-Key` 两层鉴权)转发到后端 `POST /api/project/artifacts`,按项目落盘到 `//`,文件名白名单清洗、仅接受 `.tar.gz`/`.tgz`、默认 200 MiB、每项目保留最近 10 份,返回 `artifactPath/size/sha256/uploadedAt`;新增 `ops_upload_artifact`。首次部署(从未有 `releases/current`)自动把 `process.ecosystem_loadable` 与 `network.port_probe` 标为 `deferred`,部署后立即复检;已部署项目仍按 `fail` 拦截。项目 Key 只读范围放开到本项目(项目列表、部署状态、inventory、调度、证书、runbook、日志、备份),跨项目与写接口仍 `403`。路径口径统一为 `/`,规划器/conformance/context 清理 `/opt/{id}` 残留。`ops_plan_deployment` 返回 `planType=create|update`,仅真实冲突 `blocked`。需服务端 `2.11.0`;`minServerVersion` 保持 `2.1.0`(不强制校验)。 - **1.2.3(2026-11-14)鉴权与路径口径修订**:1.2 补充两层凭据发放入口差异与 401 的 `reason` 语义(`missing`/`invalid`/`revoked`/`expired`,`code` 相同);17.1–17.3 明确客户端 Token 需在「MCP 客户端」单独签发、不属于项目接入材料,新增不依赖客户端的 `curl initialize` 自检,并说明客户端不展开 `${VAR}` 时表现为 `reason=invalid`;`ops_check_conflict` 在项目 Key 下的描述改为与实现一致(`ok:false` + `limitedView:true` + `requiresAdminReview:true`);10 补 `deployPath` 为派生字段(取 `backup.appDir`,不写入 `projects.json`),并明确 3000–3999 只是建议段、硬校验为登记 `0`–`65535` 与规划 `1024`–`65535`;默认部署目录统一为 `/`(默认 `/opt/apps/{id}`,与 `install.sh`、`executor` 实现一致);`ops_get_project_context` 未登记 `backup.appDir` 时返回空 `deployPath`,不再猜 `/opt/{id}`。无后端行为变更,`minServerVersion` 保持 `2.1.0`。 - 版本语义:`patch` 文案与提示;`minor` 新增可选字段/模板或行为收紧,向后兼容;`major` 新增必填项或路径变更,升 `v2` 且 `v1` 保留至少两个发行周期。 - 项目侧只通过显式登记/adopt 变更 `standardVersion`,不随平台升级自动追随。 - 任何写入工具在 `standardVersion` / `standardDigest` 不一致时必须拒绝(`STANDARD_VERSION_MISMATCH`),不允许静默降级,也不解析 `latest` 别名。 - `minServerVersion` 当前不强制校验;`manifest.json` 中 `minServerVersionEnforced=false` 已注明。 - 标准内容变更流程:修改 `standards/` → CI 校验 schema/模板占位符/示例 → 生成版本与 digest → 随发行包发布;禁止手工改写版本与 digest。发布记录必须包含构建依赖版本(`develop-site/requirements.txt`)与站点四个文件的 sha256。 ### 15.1 digest 的覆盖范围与用途 **算法**:对 `manifest.resources` 中每个文件取内容 sha256,按 `uri` 字典序拼成 `uri + \0 + sha256 + \n`,再整体取 sha256,形如 `sha256:`。仓库内 `manifest.json` 的 `digest` 字段恒为 `null`,运行时按同一算法重算,禁止手工填写。 **覆盖范围**: | 是否参与 | 对象 | 说明 | |----------|------|------| | 参与 | `resources` 列出的全部文件 | 含规范正文 `llms.txt`、`version.json`、两份 schema、模板、清单等;任一内容变化都会改变 digest | | 不参与 | `manifest.json` 自身 | digest 写在自身内部,自引用无法收敛;其 `version` / `major` 由 `version.json` 镜像,`StandardRegistry` 启动时校验两者一致,不一致直接抛 `VALIDATION_ERROR` | | 不参与 | `resources[].title` / `description` / `mime` | 只改元数据不会改变 digest,因此改元数据也必须升版本号 | | 不参与 | `standards/` 下未登记为资源的文件 | 新增受控文件必须同时登记进 `resources`,否则等于没有校验 | **用途**:证明「你读到的正文与平台登记的版本是同一份」,用于工具响应里的 `standardDigest`、写入前的版本比对与审计留痕。它**不是**发布包签名,也**不是**防篡改证明——同一路径下的文件被替换后再重算 digest,摘要同样会变,但摘要本身无法证明来源可信。 **使用方式**:写入类工具在不一致时返回 `STANDARD_VERSION_MISMATCH`;人工核对时以 `opsstd://v1/manifest.json` 返回的 digest 为准,不要缓存旧值。 **路径映射(`opsstd://` → HTTPS)**:`opsstd://v1/manifest.json` ↔ `https://develop.soyooo.top/v1/manifest.json`(与 `/manifest.json`、`/standards/manifest.json` 同内容);`opsstd://v1/version.json` ↔ `/v1/version.json`(与 `/version.json`、`/standards/version.json` 同内容);其余 `opsstd://v1/` ↔ `https://develop.soyooo.top/`。正文提到的元数据路径均已真实可访问,不需要按虚拟标识去猜目录。 --- ## 16. 产物投放与部署闭环(顺序不要颠倒) 标准闭环是 **投放 → 校验 → 干跑 → 收敛 conformance → 部署 → 复检**。任何一步失败都停在该步,不要靠跳过换取“部署成功”。 1. **自检**:按第 13 章逐条自检;体检以 `ops_check_conformance` 的实时结果为准,不需要生成静态文件。 2. **找管理员建项目并签 Key**:由管理员用全量视图完成 `id` / `pm2Name` / 端口 / 域名冲突预检,登记 `projects.json`,并签发 `deploy` 或 `readonly` 项目 Key。项目 Key 下跑 `ops_check_conflict` 仍返回 `limitedView:true`(不能证明全局无冲突),但用于**已登记项目**时自身命中会标 `self:true` 且不计入 `ok`,不会再把本项目报成冲突。 3. **投放产物**:两种通道,任选其一(都返回同一个 `artifactPath`): - HTTP(推荐,不依赖 SSH):`POST https://mcp.soyooo.top/artifacts` - stdio 本地:`ops_upload_artifact` 传 `sourcePath`(MCP 读取本机文件后转发) - 已上传确认:`ops_upload_artifact` 传 `fileName`(+ `sha256`),只做存在性与校验和确认,不重复传输 4. **校验**:把响应里的 `sha256` 与本地 `sha256sum app.tar.gz` 比对;服务端已强制 `.tar.gz`/`.tgz` 与体积上限,文件名会被清洗,`artifactPath` 一定落在本项目目录。 5. **干跑**:先 `ops_plan_deployment` 确认 `planType` 与路径口径(`deployPath = /{id}`,更新计划取已登记 `backup.appDir`),再 `ops_deploy_project` 传 `artifactPath` + `dryRun=true`。 6. **收敛 conformance**:`ops_check_conformance` 直到 `fail=0`。**首次部署**(项目从未有 `releases/current`)会自动把 `process.ecosystem_loadable`、`network.port_probe`、`caddy.site_block`、`caddy.proxy_target` 标为 `deferred`,不需要在部署前“修好”它们;已部署项目仍是 `fail`,必须修完。Caddy 站点块由**平台在执行器切换 current 前创建**(`caddyEnsureSite`),项目侧不需要也不应该手工改 Caddyfile。 7. **部署**:`dryRun=false` + `confirm=true`,Key 需具备 `project:deploy`。执行器自动解析启动配置:部署目录已有 → 产物自带 → 按登记探测渲染(见 11.2);`dryRun` 返回 `ecosystem` 预览,不必先手工准备 `ecosystem.config.*`。 8. **复检**:首次部署成功后立即复检上述四项;执行器把结果写入 `deploy.conformance` 审计/oplog(含 `caddyOk` / `caddyAction`),MCP 在响应里返回 `postDeployConformance`。随后再跑一次 `ops_check_conformance`,确认无 `fail`、`unknown` 已人工核对。 ### 16.1 产物上传契约(HTTP) | 项 | 约定 | |----|------| | 路由 | 公网 `POST https://mcp.soyooo.top/artifacts`;后端实现 `POST /api/project/artifacts` | | 鉴权 | 两层:`Authorization: Bearer <客户端 Token>` + `X-Ops-Project-Key: ops_pt__`;缺任一层都失败 | | 请求体 | `multipart/form-data`;文件字段名固定 `artifact`;可选字段 `sha256`(64 位小写十六进制,用于服务端提前比对) | | 文件名 | 原文件名只取 basename 并清洗为 `[A-Za-z0-9._-]`,必须以 `.tar.gz` 或 `.tgz` 结尾;落盘名 `--` | | 落盘位置 | `//`,默认 `/opt/ops/shared/artifacts//`;项目 Key 只能写自己项目目录 | | 体积上限 | 200 MiB(`OPS_ARTIFACT_MAX_BYTES` / `OPS_DEPLOY_MAX_ARTIFACT_BYTES`) | | 保留策略 | 每项目保留最近 10 份(`OPS_ARTIFACT_KEEP`),按 mtime 清理旧文件 | | 成功响应 | `{ ok, projectId, artifactPath, fileName, size, sha256, uploadedAt, retention:{keep,pruned} }`;`artifactPath` 可直接传给 `ops_deploy_project` | | 查询/确认 | `GET /api/project/artifacts?name=` 返回 `file:{artifactPath,size,sha256,uploadedAt}`;不带 `name` 时列本项目产物 | | 错误码 | `401` 客户端 Token/项目 Key 无效;`403` 跨项目或 scope 不足;`400` `ARTIFACT_REQUIRED`/`INVALID_FILENAME`/`CHECKSUM_MISMATCH`;`413` `ARTIFACT_TOO_LARGE`;`415` `UNSUPPORTED_ARTIFACT_TYPE`;`500` `ARTIFACT_WRITE_FAILED` | MCP 在转发前会做文件系统边界校验:来自暂存区的 `artifactPath` 必须属于本项目目录,跨项目产物直接 `403 ARTIFACT_PROJECT_MISMATCH`(这是路径隔离,不替代后端的 Key 绑定与 scope 校验)。 ```bash # 投放 curl -sS -X POST https://mcp.soyooo.top/artifacts \ -H "Authorization: Bearer $OPS_CLIENT_TOKEN" \ -H "X-Ops-Project-Key: $OPS_PROJECT_KEY" \ -F "artifact=@demo-api-1.0.0.tar.gz" \ -F "sha256=$(sha256sum demo-api-1.0.0.tar.gz | awk '{print $1}')" # → { "artifactPath": "/opt/ops/shared/artifacts/demo-api/20261114-101500-ab12cd34-demo-api-1.0.0.tar.gz", ... } ### 16.2 冷启动读数与字段口径(1.2.5) | 场景 | 约定 | |------|------| | 冲突检测 | 已登记项目自身的域名/端口/PM2 名/ID 命中标 `self:true`;`ok` 只由非 self 冲突决定,`selfConflicts` 给出数量,`effectiveConflicts` 只含真实冲突 | | context 与 conformance | `ops_get_project_context.conformance` 复用 `ops_check_conformance` 同一实现(含首次纳管 deferred);两者都返回 `snapshotAt` 与 `rulesVersion`(`opsstd@+`),同一次调用的读数应一致 | | Caddy 判定 | `caddy.site_block.actual` ∈ `无站点块` / `有站点块但无路由` / `存在(N 条路由)`;`caddy.proxy_target.actual` 区分无块、有块无路由、目标不符;`caddy.domain_unique` 与 `site_block` 同源(`inventory.sites`),不再出现「缺失」与「1 个站点块」并存 | | 首次部署 | 四项 `deferred`:`process.ecosystem_loadable`、`network.port_probe`、`caddy.site_block`、`caddy.proxy_target`;执行器在切换 current 前创建站点块,部署后复检并写审计(`deploy.conformance` 含 `caddyOk` / `caddyAction`) | | 版本字段 | `registeredStandardVersion`(项目登记值)与 `platformStandardVersion`(平台当前值)显式区分;旧 `standardVersion` 在 context 中保持平台值、在 `ops_list_projects` 中保持登记值 | | .env 步骤 | `ops_plan_deployment.manualSteps` 每项带 `applicable`;`stateless:true` 或显式 `envKeys: []` 时 `.env` 步骤为 `applicable:false` 并给出 `reason`,无需人工判断 | --- ## 17. 项目端如何接入 MCP(客户端配置、自检与排查) 项目侧的 AI 环境默认没有任何 `ops_*` 工具:既查不到平台,也验证不了连通性。接入是**外部平台配置**,项目目录里不需要、也不应该添加任何实现(不加 SDK、不写代理、不放凭据文件)。 ### 17.1 客户端配置 MCP 服务地址固定为 `https://mcp.soyooo.top/mcp`,传输方式为 **Streamable HTTP**(远程 MCP,非 stdio)。两个请求头缺一不可: | 请求头 | 值 | 谁发放 | 能否写进仓库 | |--------|----|--------|--------------| | `Authorization` | `Bearer <客户端 Token>` | 平台管理员在运维后台「MCP 客户端」按调用方签发,识别调用方与限流;**不随项目接入材料下发** | **不能**。只放本机环境变量或客户端密钥管理 | | `X-Ops-Project-Key` | `ops_pt__` | 管理员通过 `POST /api/keys` 为你的项目签发 | **不能**。等同生产写权限,泄露立即吊销 | 配置片段(占位符替换为本机环境变量,不要把明文写进仓库): ```json { "mcpServers": { "ops": { "type": "http", "url": "https://mcp.soyooo.top/mcp", "headers": { "Authorization": "Bearer ${OPS_CLIENT_TOKEN}", "X-Ops-Project-Key": "${OPS_PROJECT_KEY}" } } } } ``` 不同客户端字段名可能是 `transport` / `type` / `streamableHttp`,语义相同:URL + 两个 Header。`codex` / `claude` / `cursor` 等只要支持远程 HTTP MCP 均可按上表配置。 `${OPS_CLIENT_TOKEN}` 这类写法**只在客户端自身支持环境变量展开时生效**。若客户端不展开(把字面量原样发出),请求会带上无效 Token,返回 `401 CLIENT_TOKEN_REQUIRED`(`reason=invalid`)——这与“没配 Token”的 `reason=missing` 是两种不同问题。不支持展开时,改用客户端自带的密钥管理,或在仅本机可读的配置中直接填明文;任何情况下都不要把明文提交进仓库。客户端配置改动后需要**重启客户端**才会重新连接并刷新工具列表。 ### 17.2 连通性自检 不依赖客户端,先用一条 `curl` 确认两层凭据都生效(`initialize` 不需要项目 Key 也能判定第一层): ```bash curl -sS -X POST https://mcp.soyooo.top/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -H "Authorization: Bearer $OPS_CLIENT_TOKEN" \ -H "X-Ops-Project-Key: $OPS_PROJECT_KEY" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"selfcheck","version":"0.0.0"}}}' ``` 判定:`200` 且返回 `serverInfo.name = ops-mcp` 表示两层凭据都成立;`401 CLIENT_TOKEN_REQUIRED` 是第一层失败(看 `reason`);`400 INVALID_PROJECT_KEY` 是项目 Key 头格式问题。**只带项目 Key、不带客户端 Token 时,`initialize` 一定 `401`,这是预期行为,不是服务故障。** 配置生效后,工具列表应能看到 `ops_*` 前缀的工具: - 无项目 Key:只应看到标准资源类读取能力(`opsstd://*`、manifest、规范正文、版本与 digest),看不到项目数据。 - 有项目 Key:只读工具应返回**且仅返回**绑定项目,例如 `ops_get_project_context` 返回 `ok:true`,且 `data.projectId` 等于你的 `projectId`;`ops_list_projects` 只有这一条。`deployPath` 取登记 `backup.appDir`;未登记时返回 `/{id}` 并标记 `deployPathSource:"default"`,不要把默认值写回登记。 - 推荐自检顺序:`ops_get_project_context` → `ops_list_projects` → `ops_check_conformance` → `ops_get_health`。 只读工具(无需写权限):`ops_list_projects`、`ops_get_health`、`ops_get_logs`、`ops_get_runbook`、`ops_list_backups`、`ops_get_scheduler_status`、`ops_get_inventory`、`ops_check_conflict`、`ops_plan_deployment`、`ops_check_conformance`、`ops_get_project_context`。 写工具(`ops_upload_artifact`、`ops_deploy_project`、`ops_rollback_project`)需要三件事同时成立:`deploy` 类型项目 Key(`ops_upload_artifact` 与部署需要 `project:deploy` scope)、`dryRun=false` 且 `confirm=true`(上传不需要)、conformance 无 `fail`(首次部署的 `deferred` 不计)。只读 Key 调用会被拒绝。 ### 17.3 无工具或报错时的排查顺序 按顺序排除,不要跳步: | 顺序 | 检查 | 症状 | 处理 | |------|------|------|------| | 1 | 客户端是否配置了 MCP 服务、是否重启生效 | 工具列表里没有 `ops_*` | 检查 URL 与传输方式;重启客户端 | | 2 | 是否带客户端 Token | `401 CLIENT_TOKEN_REQUIRED`,`reason=missing` | 向平台管理员申请客户端 Token(后台「MCP 客户端」),放进 `Authorization`;它不属于项目接入材料,不会随项目 Key 一起下发 | | 2.1 | 客户端是否把 `${...}` 原样发出 | `401 CLIENT_TOKEN_REQUIRED`,`reason=invalid` | 客户端不支持环境变量展开;改用明文或客户端密钥管理,然后重启客户端 | | 3 | 是否带项目 Key | 所有项目工具返回 `PROJECT_KEY_REQUIRED` | 找管理员为你的项目签发 Key,放进 `X-Ops-Project-Key` | | 4 | Key 头格式是否正确 | `400 INVALID_PROJECT_KEY` | 只放单个 `ops_pt__`,不要数组、不要多余空格、不超过 128 字节 | | 5 | Key 是否绑定当前项目 | `403`,提示不区分项目是否存在 | Key 绑定了别的项目;本项目需单独签发,不要复用他人 Key | | 6 | Key 是否过期/吊销/一次性已用 | `401` | 重新申请,不要复用旧 Key | | 7 | 权限是否够 | 写工具 403 或 scope 不足 | 换 `deploy` 类型 Key;`readonly` 只能读 | - 排查只看错误层级,不需要猜项目是否存在:`401` 是凭据问题(按 `reason` 再分 missing / invalid / revoked / expired),`400` 是格式问题,`403` 是项目/权限问题,`PROJECT_KEY_REQUIRED` 是漏了第二层凭据。 - 所有项目数据都经由平台返回,**不要在项目仓库里读取 `projects.json` 或直连 ops-backend**;也不要把 `OPS_PROJECT_KEY` 写进 `.env` 后随备份包上传。 ### 17.4 边界 - 冲突检测与新项目登记必须在管理员侧完成。项目 Key 下 `ops_check_conflict` 只看到本项目,仍返回 `limitedView:true` + `requiresAdminReview:true`;但已登记项目的自身命中会标 `self:true` 并排除在 `ok` 之外(`ok` 可直接用于更新部署放行),不会再出现「自己撞自己」的 4 条冲突。 - 本地 stdio 模式是管理员在本机排查用的,与公网入口凭据模型不同,不要把两者混用。 - 项目目录只需按本规范写出应用;MCP 接入是外部配置,不产生任何代码或文件交付物。 --- ## 18. 数据迁移规则 迁移失败的典型事故是 `duplicate column`:没有记录表、失败后盲目重跑,DDL 已生效却再次执行。本章给出可执行的统一规则。 ### 18.1 迁移文件的位置 - 目录固定为 release 根下的 `migrations/`,即运行时路径 `current/migrations/`。 - 迁移文件是**代码的一部分**,随 `{id}-{version}.tar.gz` 发布,落在 `releases/{ts}/migrations/`;不得放在 `data/`,也不得在启动时从网络下载。 - `migrations/` 不在第 11 章禁项列表内,但必须出现在 build 脚本的包清单中;发布后该目录只读,不再修改。 ### 18.2 命名与排序 - 文件名格式:`NNNN_snake_case.sql`,`NNNN` 为至少 4 位、左补零的十进制序号(如 `0001_create_user.sql`、`0002_add_user_email.sql`)。 - 排序:按序号数值升序;序号相同时按文件名字典序,但**禁止序号重复**。 - 序号只增不减,**不得复用已发布过的序号**;允许留空号(如 0003 缺失),不允许插入到已有序号之前。 - 不要求提供 `down`/回滚脚本;数据库回退不作为标准能力(见第 19 章)。 ### 18.3 随 release 发布与执行时机 - 迁移在**进程启动阶段、`listen()` 之前**执行;执行完成前不得接受任何请求。 - 执行入口必须是确定性的:启动命令 → 加载配置 → 获取迁移锁 → 校验记录表 → 按序执行未应用迁移 → 释放锁 → 启动 HTTP 服务。 - 多实例或 `exec_mode: 'cluster'` 时,必须使用数据库级咨询锁(PostgreSQL `pg_advisory_lock`、MySQL `GET_LOCK`、SQLite `BEGIN IMMEDIATE`)保证同一时刻只有一个执行者;未实现锁的项目不得开多实例。 - 迁移执行必须有超时与日志:记录开始/结束、文件名、耗时、失败原因;日志不得输出数据库口令。 ### 18.4 记录表结构与对齐要求 固定表名 `schema_migrations`(如项目已有同名表,按本节字段改造,不得另起别名)。记录表由迁移运行器在启动时以 `CREATE TABLE IF NOT EXISTS` 创建,**不占用迁移序号**,也不得依赖某条业务迁移来建表: | 列 | 类型 | 约束 | 含义 | |----|------|------|------| | `version` | VARCHAR(32) | PRIMARY KEY | 迁移序号(与文件名前缀一致,如 `0002`) | | `name` | VARCHAR(255) | NOT NULL | 文件名(不含目录) | | `checksum` | CHAR(64) | NOT NULL | 迁移文件内容的 SHA-256(小写 hex) | | `applied_at` | VARCHAR(32) | NOT NULL | ISO8601 UTC 时间 | | `duration_ms` | INTEGER | NULL | 执行耗时 | | `release` | VARCHAR(64) | NULL | 产生该记录的 release 目录名,便于追溯 | 对齐规则(启动时校验,不满足即按 18.5 处理): 1. **文件 → 记录**:release 内每个迁移文件在表中至多一条记录,且 `version` 唯一。 2. **记录 → 文件**:表中存在记录、但当前 release 找不到对应文件 → **fail**(说明库比代码新,或发布包缺文件)。 3. **校验和**:已应用文件的 `checksum` 必须与当前文件内容一致;不一致 → **fail**(已发布迁移被改写,禁止继续)。 4. **连续性**:若存在序号更大的记录,则所有更小序号的迁移都必须有记录;出现「跳号未应用」→ **fail**。 5. **未应用**:文件中没有记录的,按顺序执行;执行成功写一条记录,失败不写。 ### 18.5 幂等与失败处理 - **运行器幂等**:重复启动时,已记录且校验和一致的迁移必须跳过;重复启动不得重复执行。 - **语句幂等**:迁移本身应尽量可重复执行(如 `CREATE TABLE IF NOT EXISTS`、先查 `information_schema` 再 `ALTER`)。原因:MySQL 等引擎的 DDL 无法回滚,可能出现「DDL 已生效、记录尚未写入」的窗口;若语句不可重复执行,下一次启动就会报 `duplicate column`。 - **失败必须中止启动**:任何迁移失败 → 记录已成功的前序迁移 → 打印失败文件与原因 → 以非零码退出;**禁止** `listen()`、禁止降级为「跳过失败继续跑」、禁止只写日志不退出。 - PM2 会按 `autorestart` 重试;连续失败达到 `max_restarts` 后进入 `errored`,由监控告警。修复方式只能是:修正迁移文件(未发布时)、补一个向前修复的迁移(已发布时),或从备份恢复;不允许靠重试碰运气。 - 破坏性迁移(删列、改类型、加非空约束等)在执行前必须先完成一次可验证备份,并在 runbook/发布说明中标注;平台目前没有「部署前自动备份」钩子,因此这是人工闸门。 ### 18.6 禁止人工补记录行(事故处置 ≠ 标准做法) - **严禁**在正常发布流程中手工 `INSERT`/`UPDATE`/`DELETE` `schema_migrations`,也严禁手工修改已应用迁移文件来「对齐」记录。 - jingyu 的 `duplicate column` 事故中,曾通过人工补 tracking 行让服务继续启动。这属于**事故处置**,不是标准做法:它掩盖了「记录表与文件不一致」的真实原因,下一次发布仍会复发。 - 只有以下情况允许人工介入,且必须同时满足:已停止写入、已做完整备份、由管理员执行、写入审计、事后补一条修正迁移或在演练环境复现验证: 1. 记录表损坏且无法从备份恢复; 2. DDL 已生效但记录缺失,且该迁移语句可证明可重复执行; 3. 数据库比代码新,需临时回退到匹配的 release。 - 人工介入后必须在 runbook 第 8 章记录时间、原因、执行人、SQL、影响范围与后续修复计划。 --- ## 19. 带迁移发布的回滚语义 回滚只切 `current`、不回写数据;但代码回退后数据库 schema 可能已经前进。本章规定什么情况下允许回滚、什么情况下必须前进修复。 ### 19.1 两类迁移 | 类型 | 定义 | 示例 | |------|------|------| | 向后兼容(expand) | 旧代码在新 schema 上仍可正常读写 | 新增可空列、新增表、新增索引、扩大字段长度 | | 破坏性(contract) | 旧代码在新 schema 上会报错或写坏数据 | 删列/删表、重命名、改类型、加非空且无默认值、收紧约束 | ### 19.2 允许回滚的条件 同时满足才允许把 `current` 切回上一 release: 1. 本次发布只包含**向后兼容**迁移; 2. 发布说明明确标注「可回滚」,且回滚目标 release 仍在 `releases/` 中(保留 3 个); 3. 回滚后按 19.4 验证旧代码能读写新 schema。 回滚仍只切 `current`、不回写数据;数据库保持前进状态。 ### 19.3 必须前进修复(forward fix)的情况 出现以下任一情况,**禁止回滚到旧代码**,只能前进修复: 1. 本次包含破坏性迁移(已删列/改类型/重命名/收紧约束); 2. 迁移已写入数据(如回填、拆分表),旧代码无法解释新数据; 3. 记录表显示数据库版本高于目标 release 的迁移集合; 4. 回滚后旧代码启动即报 schema 相关错误。 处置顺序:停止继续发布 → 保留现场与日志 → 评估能否用一个向前迁移修复 → 修复并发布新 release;无法修复时才从备份恢复(会丢失备份点之后的数据,必须走事故流程)。 ### 19.4 向后兼容的最低要求 - 新 schema 必须至少兼容**上一个 release(N-1)**的代码;跨两个版本的破坏性变更必须拆成「扩展 → 双写/回填 → 收缩」多次发布。 - 重命名列/表:先新增、双写、回填、切读、最后在后续 release 删除旧列;禁止在同一次发布内改名并删除。 - 新增非空列必须带默认值或先允许为空,回填后再收紧。 - 回滚验证至少覆盖:健康检查、登录/鉴权、核心写路径、迁移记录表读取。 ### 19.5 现状风险:自动回滚不感知迁移(强制人工闸门) > **风险结论**:当前执行器在部署失败(健康检查、环境校验、PM2 启动等)时会**无条件**切回上一 release(`switch-back`)。它不读取迁移文件、不读取 `schema_migrations`,也没有任何「本次发布是否包含破坏性迁移」的判断。因此,一次包含破坏性迁移的发布如果失败,自动回滚会把**旧代码指向已经前进的数据库**,可能出现启动即报错、字段不存在、写坏数据等二次故障,且回滚动作本身会掩盖首次失败的原因。 - **标准要求(现状下必须执行)**:包含破坏性迁移的发布**不得使用自动部署工具直接发布**,必须: 1. 走人工发布流程,管理员在场;发布说明显式标注「破坏性 / 禁止回滚」,并写入发布记录; 2. 发布前完成一次可验证备份,并确认恢复步骤可用; 3. 发布失败时**不执行自动回滚**,按 19.3 前进修复;若必须回到旧代码,先确认数据库已回退到与旧代码匹配的版本(只能从备份恢复,属事故流程)。 - 使用自动部署工具发布**向后兼容**迁移时,仍可能触发自动回滚;这类回滚理论上安全,但必须满足 19.4 的 N-1 兼容要求,并在回滚后验证健康检查、登录/鉴权与核心写路径。 - 首次部署(只有一个 release)失败时保留 `current`(`keep-current`),不会切回旧代码;但新代码/新 schema 可能仍不可用,同样需要人工处置。 > **未来能力(建议,不是现行标准要求,不得依赖)**:执行器在回滚前读取 release 内的迁移元数据(例如发布说明中的 `rollback: allowed|forbidden`、迁移清单哈希或迁移文件是否存在),发现破坏性迁移时拒绝自动回滚并返回明确错误。该能力目前**完全不存在**;在实现并发布之前,填写任何标记都不会生效,也不能作为通过依据或免责理由。 ### 19.6 发布前判定清单 - [ ] 列出本次新增迁移文件与序号,确认无重复、无改写已发布文件 - [ ] 逐条标注「向后兼容/破坏性」,并写入发布说明 - [ ] 破坏性迁移:已做可验证备份,已确认走人工发布且不会被自动回滚 - [ ] 发布说明已标注「破坏性 / 禁止回滚」,并知会执行发布的管理员 - [ ] 回滚目标 release 由平台保证仍存在:确认平台报告的回滚目标版本可用,且其迁移集合不高于当前数据库版本;不要用 `ls releases/` 自行判断 - [ ] runbook 第 3、4 章已更新迁移与恢复步骤 --- ## 20. 运行时与依赖基线 本章回答一个问题:**新建项目要能被 ops 部署,哪些运行时条件需要项目自己保证、哪些由平台保证**。事实来源是 2026-11-12 的只读盘点(现网实测 + 官方发布记录);标准只把项目能够选择的项写成义务,宿主条件一律归平台。 ### 20.1 四类约束力(先分清哪层管你) | 层级 | 内容 | 对项目方的约束力 | |------|------|------------------| | A. 平台自身依赖 | ops-backend、ops-mcp、规范站构建所用的包及其版本 | **无约束**。仅作说明与排障参考,项目方不需要关心,也不得据此要求平台升级 | | B. 项目必须满足 | Node.js 大版本、原生模块工具链(python3 / make / g++ / glibc) | **规范**。这些是项目自己的代码与依赖能否装上、能否跑起来的前提,不满足会在安装或启动阶段直接失败 | | 平台保证项 | PM2、Caddy、SQLite CLI、glibc 运行时、OS / systemd、磁盘、内存 | **由平台负责,项目无需处理,也未提供降级路径**。列出仅为排障定位:出现对应症状时找管理员,不要在项目内绕过 | | C. 建议项 | 满足更顺,不满足不阻断部署 | **建议**。只影响可维护性与一致性 | > 四类不得混用:不要把 A 层或平台保证项的版本写成对项目的要求,也不要把 C 层的建议写成 `fail` 条件。宿主条件(磁盘、内存、Caddy、PM2、SQLite CLI、OS)不是项目能选择的,写进项目义务只会把平台自身的运维责任转嫁出去。 ### 20.2 A 层:平台自身依赖(仅说明) 平台组件(项目方无需关心,仅供参考): | 组件 | 现网版本 | 说明 | |------|----------|------| | Express | 4.22.3 | ops-backend 的 HTTP 框架 | | @modelcontextprotocol/sdk | 1.32.2 | MCP 工具与传输 | | zod | 3.25.76 | MCP 工具入参校验,必须与 SDK 支持的 zod 主版本一致 | | better-sqlite3 | 11.10.0 | first / jingyu 的数据库驱动,按各自 Node ABI 编译 | | node-cron / ali-oss / jsonwebtoken / bcryptjs / dotenv / multer | 4.6.0 / 6.23.0 / 9.0.3 / 2.4.3 / 16.6.1 / 1.4.5-lts.2 | 调度、对象存储、鉴权、密码、配置、上传 | | PM2 | 7.0.x | 进程托管,执行器读取 `pm2 jlist` | | Caddy | 2.11.x | 反向代理,执行器调用 `validate --config` / `reload` | | markdown | 3.9 | 规范站构建,见 20.8 | 上表是平台事实,**不构成对项目的要求**,项目侧下限只在 20.3 声明。 ### 20.3 B 层:项目必须满足(仅 2 项) | 项 | 最低可用 | 推荐 | 为什么需要 | 不满足时的具体表现 | |----|----------|------|------------|--------------------| | Node.js 大版本 | **≥ 22** | **22.x LTS,以平台现网为准**(Node 24 需平台白名单) | 平台侧 `engines` 基线、`--env-file`、现代 ESM 行为;执行器用 `node --check` 校验入口 | Node 20 及以下:`npm install` 报 `EBADENGINE`(`engine-strict` 时直接失败);应用或脚本使用 `--env-file` 时 `node` 报 `bad option`,进程起不来;ESM/CJS 互操作行为差异,表现为 `ERR_REQUIRE_ESM` 或导入失败 | | 原生模块工具链(仅当含原生模块) | `python3` + `make` + `g++`(gcc ≥ 10)且 glibc ≥ 2.28 | 与平台现网一致,以管理员告知为准 | `better-sqlite3` 等原生模块必须按目标机 Node ABI 重编;跨机部署不是「装一次到处用」 | `npm install` 或 `npm rebuild` 报 `gyp ERR! stack Error: not found: make` / `python3` / `g++`,构建中断;只拷贝 `node_modules` 不自检时,进程启动报 `NODE_MODULE_VERSION` 不匹配;glibc 过低时直接执行 `node` 报 `GLIBC_2.28 not found` | **Node 大版本怎么选(22 与 24 的差异)** - 默认使用 **22**:系统 `/usr/local/bin/node`,也是 PM2 daemon 与多数量级项目的运行时。 - **24 仅限平台白名单**(如 first)。使用方法是在 ecosystem 中显式指定解释器: ```js module.exports = [{ name: '${PM2_NAME}', cwd: '${DEPLOY_PATH}', script: '${ENTRY}', interpreter: '/opt/node24/bin/node', }]; ``` 不写 `interpreter` 时使用系统 Node(22)。**不得在项目内自行安装或切换 Node 版本**。 - `engines` 必须与 `interpreter` 实际指向的大版本一致:用 22 写 `">=22 <23"`,获准用 24 写 `">=24 <25"`。两者不一致时,本地安装与线上运行会表现出不同的模块解析行为。 - **22 与 24 的 ABI 不同**:同一项目的原生模块必须与它实际运行的 Node 版本一起编译。`first` 用 Node 24、`jingyu` 用 Node 22,两者的 `better-sqlite3` 必须分别重编。 补充约束: - **跨机迁移不是升级**:新机按平台现网版本复制,只重编 native、重装平台相关包(如 `tsx`/`esbuild` 的 linux-x64 包)。 - **无端口服务**(`localPort=0`)同样要满足 Node 与工具链下限;端口不是本层的判断依据。 ### 20.4 平台保证项(由平台负责,项目无需处理) 以下条件由平台保证。项目方不需要安装、升级或校验,平台也**未提供降级路径**(例如不能在项目内自建 Caddy 或替换 SQLite CLI)。「不满足时的表现」是排障线索:若线上出现这些症状,说明平台侧条件异常,应直接找管理员,不要在项目里绕过。 | 项 | 平台保证 | 为什么需要 | 不满足时的表现(排障线索) | |----|----------|------------|---------------------------| | PM2 | 版本支持 `pm2 jlist` 的 `pm_cwd`、`pm_exec_path`、`restart_time`,以及 `--only`、`kill_timeout`、`treekill` | 执行器据此判断进程工作目录与重启行为 | `jlist` 缺字段,R1(`cwd` 必须等于 `deployPath`)判定拿不到实际值而失败;启动参数含 `--only` 时 `pm2 start` 报未知选项;`treekill` 不生效导致重载残留旧进程 | | Caddy | 支持 `caddy validate --config` 与 `caddy reload`,站点块按 v2 语法 | 接入校验与 TLS 终止 | 不支持 `validate` 的参数形式,接入校验停在 validate 一步,配置不落盘;`reload` 行为差异导致新站点块未生效但返回成功 | | SQLite CLI | 存在,且支持 `.backup` 与 `PRAGMA integrity_check` | 备份脚本的核心命令 | `command not found`,或旧版不支持 `.backup`:`backup.sh` 非零退出,首次备份失败;后端 `2.1.0` 起未通过 integrity 的备份会被判为失败并拒绝上传 | | glibc 运行时 | ≥ 2.28 | Node 22/24 官方二进制与 native 模块的运行前提 | 直接执行 `node` 报 `GLIBC_2.28 not found`,PM2 拉起后立即 `errored` 并反复重启 | | OS / 架构 | Linux x86_64 + systemd | PM2 与 Caddy 需要服务化 | `pm2 startup` 报无 systemd / 不支持的 init,开机不自启;非 x86_64 时 native 预编译包缺失,回退到源码编译 | | 磁盘 | 满足至少 3 个 release、本地备份(保留 7 份)与依赖共存 | 部署与备份的落盘空间 | `ENOSPC`:`npm install` 失败、`tar czf` 中途失败、备份写 `.tmp` 时失败,表现为部署在校验或解包阶段中断 | | 内存 | 满足 native 构建与多个 PM2 进程并行 | 构建与运行稳定性 | 构建被 OOM kill(`Killed`,无明确错误)、PM2 反复 `restart`、健康检查超时被判定部署失败 | 平台当前实装(**记录时现网值,可能已变化**,仅作排障参照): | 项 | 记录时版本 | |----|------------| | Node(系统) | 22.12.0(Node 22 LTS) | | Node(`/opt/node24`) | 24.22.2(白名单项目使用) | | npm | 10.9.0(Node 22)/ 11.19.1(Node 24) | | SQLite CLI | 3.42.0 | | gcc / make / python3 | 12.3.0 / 4.4.1 / 3.11.6 | | glibc | 2.38 | | 内核 / 架构 | 6.6.102 / x86_64(Alibaba Cloud Linux 4) | 这些数字会随平台升级变化,**标准不对项目方声明补丁号**;需要精确值时向管理员确认。 ### 20.5 C 层:建议项(不阻断) | 建议 | 原因 | 不满足的后果 | |------|------|--------------| | `package.json` 声明 `engines` 且与 `interpreter` 一致 | 本地与 CI 能提前暴露版本不符 | 只在部署时才失败,排查成本更高 | | 本地开发环境与平台 Node 大版本对齐 | 避免本地能跑、线上装不上 | native 模块与 ESM 行为在本地与线上不一致 | | npm ≥ 10 | Node 22 自带,无需额外动作 | 旧 npm 的 lockfile 行为差异,可能装出不同依赖树 | | 默认 Node 22,Node 24 仅白名单 | 与平台基线一致 | 混用会放大 ABI 问题 | | 项目内固定构建依赖版本 | 构建可复现,产物哈希稳定 | 产物哈希漂移,无法判断是否真的改了代码 | ### 20.6 两条原则 1. **标准声明的是「已验证的大版本与工具链」,不是「最新版本」,也不是补丁号。** 具体补丁(Node 22.x 的哪个小版本、Caddy 的哪个 patch)由平台维护,会随升级变化;标准不把这类数字写成项目义务。**不要为了追新而升级运行时**,也不要因为平台补丁前进了就认为标准要求也变了。 2. **升级节奏由平台方决定。** 项目方不得在发布窗口内自行升级 Node / PM2 / Caddy / glibc / OS 大版本,也不得改 `package.json` 的 `engines` 与依赖区间来「顺便升级」。需要升级时由平台统一安排,并与执行器、备份、迁移一起回归。 3. **新机器安装取当时的稳定版本。** 默认 Node 22 LTS、Caddy 稳定版,不把补丁号写死;需要复现某次发布时,用 `manifest.json` 的 `minServerVersion`、`develop-site/requirements.txt` 的项目 lockfile 锁定基线,而不是回退到旧补丁包。新机器只保证「大版本与工具链满足」,补丁由平台在安装后按稳定版推进。 4. **升级边界:运行时不做热升级,应用依赖可升但必须重启。** Node / Caddy / PM2 的升级要停机切换并跑部署、回滚、证书与健康检查回归,不能在业务进程运行中替换;项目自身的 npm 依赖可以在发布窗口内升级,但必须重新安装、重启 PM2 进程并完成回归。标准版本号变化只表示正文口径变化,不代表平台运行时已经升级。 ### 20.7 项目侧自检(可直接复制) **(1) `package.json` 声明运行时下限** ```json { "engines": { "node": ">=22 <23", "npm": ">=10" } } ``` 注意:`engines` 默认只是告警,只有开启 `engine-strict=true` 或在 CI 中显式检查才会阻断;PM2 完全不读 `engines`。因此还必须做 (2)。 **(2) 构建脚本里的 Node 大版本硬校验**(放在 build 脚本最前面,先于打包) ```bash #!/usr/bin/env bash set -euo pipefail # 与本项目 ecosystem 的 interpreter 保持一致:系统 Node 写 22,白名单 Node 24 写 24 need_node_major=22 node_major="$(node -p 'process.versions.node.split(".")[0]')" if [ "$node_major" -ne "$need_node_major" ]; then echo "FAIL runtime: Node $(node -v) 与目标大版本 ${need_node_major}.x 不一致" >&2 exit 1 fi echo "runtime ok: node=$(node -v)" ``` PM2、Caddy、SQLite CLI 属于平台保证项,项目侧不必自检;若在本地开发时临时用到 `sqlite3`,自行安装即可,不要把它写进部署前置条件。 **(3) 原生模块重编前置检查**(只有含原生模块时需要) ```bash if grep -q '"better-sqlite3"' package.json 2>/dev/null; then for bin in python3 make g++; do command -v "$bin" >/dev/null || { echo "FAIL: 缺少 $bin,原生模块无法编译(gyp ERR! not found)" >&2; exit 1; } done node -e "require('better-sqlite3')" 2>/dev/null || { echo "FAIL: better-sqlite3 与当前 Node ABI 不匹配,执行 npm rebuild better-sqlite3" >&2 exit 1; } fi ``` **(4) 自检结论写进交付说明**:Node 主版本(是否与 `interpreter` 一致)、是否含原生模块及是否已按目标 Node 重编。这两项是部署失败时最先被问到的信息。 ### 20.8 规范站(本文件)的构建可复现 规范站由 `ops-mcp/standards/llms.txt` 生成,构建依赖只有一个 Python 包,但历史上未钉版本(本机 3.9,最新 3.11.3),导致 `index.html` 的哈希对无关升级敏感。 - `develop-site/requirements.txt` 固定 `markdown==3.9`; - 构建前执行 `pip install -r requirements.txt`,不要用全局已装的任意版本; - `build.py --check` 会比对运行版本与 `requirements.txt` 的钉版本,不一致直接失败; - **标准发布清单增加一条**:发布记录必须包含构建依赖版本(`requirements.txt` 内容)与四个文件(`llms.txt`、`index.md`、`index.html` 及标准源副本)的 sha256,便于判断哈希变化是内容变化还是构建环境变化。 升级 `markdown` 属于 A 层动作:由平台决定,升级后必须重新基线化站点哈希并重新发布,不得在内容发布中夹带。