## 结果 四容器编排从 6 个 service 降到 5 个:`postgres` / `redis` / `business` / `gateway` 常驻 + `migrate` 一次性。**常驻容器 5 → 4**,实测直连 `127.0.0.1:4000|3000/health` 均 200。 ## 为什么现在做 `docker-compose.prod.yml` 里早就写着"生产不启动自带 nginx 容器,接入层交给 1Panel OpenResty",只是用 `profiles: ["never"]` 停用的。也就是说**生产本来就没这个容器**, 默认文件里留着它反而让两条路径分叉:本地开发跑一套、生产跑另一套,`nginx.conf` 只服务前者。现在两套文件对齐,反代只在宿主一处。 ## 三个必须处理的连带问题(都不是删几行就完事) **① `&logging` 锚点原本挂在 nginx service 里**,被另外 5 个 service 用 `*logging` 引用。直接删块会让 Compose 报 YAML 锚点未定义,而**报错点在引用的那几行、不在 被删的地方**。已把它提到顶层 `x-logging:` 扩展字段(Compose 支持),并把这个坑 写进注释。 **② prod 覆盖里那段 `nginx: profiles: ["never"]` 会变成悬空引用**(服务已不存在), 一并删除并改写注释。 **③ `watchdog.sh` 探的 `/healthz` 原本是 nginx 容器提供的虚拟端点。** 删掉后它会 恒报 `entry:404` —— 一个"服务其实好好的但监控一直叫"的假故障。已把探针语义改成 "接入层活着",并在 404 时把最常见成因写进告警文本(宿主反代没配 /healthz)。 ## 宿主反代模板 `deploy/nginx.host.conf.example`(替代原来的 `deploy/nginx.conf`),关键差异: upstream 从 `gateway:3000` / `business:4000` 改为 `127.0.0.1:3000` / `127.0.0.1:4000` —— 容器服务名**在宿主上根本不解析**,照抄旧配置会 `host not found in upstream`。 四条承载性指令原样保留,且写清了缺每一条的**症状**(都不是 502): - `proxy_http_version 1.1` + `Upgrade`/`Connection` → 缺了 WebSocket 握手失败 - `proxy_read_timeout 600s` → 缺了长连接 60 秒被无声掐断 - `proxy_buffering off` → 缺了画面不实时(数据被攒住) - `/relay/` 的 `proxy_request_buffering off` → 缺了"文件不落盘"变成假话 配置已用真 `nginx -t` 校验(`nginx:1.27-alpine`,`syntax is ok / test is successful`), 不是靠肉眼或 grep 判断。 ## 护栏:把"搬到宿主"这件事本身变成可验证的 `tests/dockerfile-coverage-test.js` 新增 7 条:模板存在、四条承载性指令各一条、 upstream 必须是回环而非 Docker 服务名、以及一条**负向**断言 —— 四容器编排里不许 再冒出 `nginx:` 服务(防"顺手加回来"造成两处真相)。 不加这段的话,宿主模板被删或被简化**不会有任何测试变红**,而症状是"页面能开但画面 不刷新",从现象很难倒推回配置。 测试:`dockerfile-coverage` **60/0**、`claim-proof` **57/0**。 ## README 现状描述(目录树 / 端口表 / 起栈步骤)已改。**历史段落不改写**: - "5 容器 healthy、经 nginx :80 入口"那句加"时代注记",说明那是当时形态; - "坑 B:nginx 强依赖证书"整段加抬头说明容器已移除,但**过程与教训原样保留** —— 那两条坑(硬引用可选文件导致 clone 必崩、Compose ports 是拼接语义)在宿主反代上 一样成立,只是换了文件。
706 lines
34 KiB
JavaScript
706 lines
34 KiB
JavaScript
/**
|
||
* 墨观 镜像构建覆盖性测试 —— 按 require 图校验 Dockerfile 是否漏拷模块
|
||
*
|
||
* 运行:node tests/dockerfile-coverage-test.js
|
||
*
|
||
* ── 为什么需要这个套件(两起真实事故)────────────────────────────
|
||
*
|
||
* 事故 A:新增 `shared/metrics-schema.js` 后忘了加进 Dockerfile 的 COPY 列表。
|
||
* `docker compose build` **成功**,但容器一启动就 `Cannot find module`
|
||
* —— 因为 `shared/protocol.js` 与 `server/shared/repository.js` 都 require 它。
|
||
* 服务端本地测试全绿,问题只在镜像里,靠本地跑是发现不了的。
|
||
*
|
||
* 事故 B:`server/shared/keepalive.js` 被删除后,Dockerfile.gateway 里还留着
|
||
* 对它的一行 COPY —— `COPY` 拷贝不存在的文件是**硬错误**,镜像直接构建失败。
|
||
*
|
||
* 共同根因:Dockerfile 用「逐个点名」的方式拷贝源码,等于又多了一份必须手工
|
||
* 与代码同步的文件清单。与指标字段三层分裂是同一类问题。
|
||
*
|
||
* ── 本套件做的事 ──────────────────────────────────────────────
|
||
* 1. 从每个镜像的入口模块出发,递归解析 require() 图,得到"运行时真正需要的本地文件集"
|
||
* 2. 断言该集合被 Dockerfile 的 COPY 规则完整覆盖(含通配符匹配)
|
||
* 3. 反向断言:每条 COPY 规则都能匹配到真实存在的文件(拦事故 B)
|
||
* 4. 断言 .dockerignore 不会把必需文件排除掉(否则 COPY 静默少文件)
|
||
*
|
||
* 全部离线执行,不需要 docker daemon。
|
||
*/
|
||
'use strict';
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const ROOT = path.resolve(__dirname, '..');
|
||
|
||
/**
|
||
* 从 `server/shared/instance-config.js` 的 ENV_MAP 里读出「字段 → 环境变量名」。
|
||
*
|
||
* 为什么需要它:2026-10 起,REQUIRE_AUTH / ENFORCE_DEVICE_QUOTA /
|
||
* ALLOW_OPEN_REGISTER / REQUIRE_DEVICE_TOKEN 这些开关不再由代码逐个
|
||
* `process.env.X` 读取,而是登记在一张声明式映射表里、由 `applyEnvOverrides`
|
||
* 用**动态下标**(`env[spec.env]`)统一取。正则扫 `process.env.` 扫不到它们,
|
||
* 于是本套件的"开关通路"覆盖从 5 条掉到 2 条 —— **护栏悄悄缩水**,
|
||
* 正是"测试是绿的但它守的东西已经没了"那一族。
|
||
*
|
||
* 所以这里把声明式登记也当作"读取点",并额外断言动态取值那句真的存在
|
||
* (否则就是在替一个不存在的读取辩护)。
|
||
*/
|
||
function readInstanceConfigEnvMap() {
|
||
const p = path.join(ROOT, 'server/shared/instance-config.js');
|
||
if (!fs.existsSync(p)) return { entries: [], dynamic: false, file: p };
|
||
const src = fs.readFileSync(p, 'utf8');
|
||
const entries = [];
|
||
for (const m of src.matchAll(/([a-z_]+):\s*\{\s*env:\s*'([A-Z0-9_]+)'\s*,\s*type:\s*'([a-z]+)'/g)) {
|
||
entries.push({ field: m[1], env: m[2], type: m[3] });
|
||
}
|
||
// 动态取值那句:`env[spec.env]`(若换成 `process.env[spec.env]` 同样算数)
|
||
const dynamic = /process\.env\[spec\.env\]|env\[spec\.env\]/.test(src);
|
||
return { entries, dynamic, file: p };
|
||
}
|
||
|
||
/** 某目录下的 .js 里是否 require 过指定模块名(按路径末段匹配) */
|
||
function requiresModule(dir, name) {
|
||
if (!fs.existsSync(dir)) return false;
|
||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||
if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
|
||
const p = path.join(dir, e.name);
|
||
if (e.isDirectory()) { if (requiresModule(p, name)) return true; continue; }
|
||
if (!e.name.endsWith('.js')) continue;
|
||
const src = fs.readFileSync(p, 'utf8');
|
||
for (const m of src.matchAll(/require\(\s*'([^']+)'\s*\)/g)) {
|
||
if (m[1].split('/').pop() === name) return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 某目录下的**服务代码**是否用到某个配置字段(如 `cfg.require_auth`)。
|
||
*
|
||
* 判据用字段名而不是环境变量名:服务读的是配置字段,env 只是它的一个来源。
|
||
* 必须排除 `assets/`:管理端的静态页把全部字段名列了一遍(渲染表单用),
|
||
* 那是"展示"不是"消费",把它算进来会让每条通路都成立 —— 护栏直接失效。
|
||
*/
|
||
function usesField(dir, field) {
|
||
if (!fs.existsSync(dir)) return false;
|
||
const re = new RegExp(`\\b${field}\\b`);
|
||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||
if (e.name === 'node_modules' || e.name.startsWith('.') || e.name === 'assets') continue;
|
||
const p = path.join(dir, e.name);
|
||
if (e.isDirectory()) { if (usesField(p, field)) return true; continue; }
|
||
if (!e.name.endsWith('.js')) continue;
|
||
if (re.test(fs.readFileSync(p, 'utf8'))) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
let pass = 0;
|
||
let fail = 0;
|
||
const failures = [];
|
||
|
||
function ok(name, cond, detail) {
|
||
if (cond) { pass++; console.log(' \u2713 ' + name); }
|
||
else {
|
||
fail++;
|
||
failures.push(name + (detail ? ' <- ' + detail : ''));
|
||
console.log(' \u2717 ' + name + (detail ? ' <- ' + detail : ''));
|
||
}
|
||
}
|
||
|
||
const norm = (p) => p.split(path.sep).join('/');
|
||
|
||
/* ────────────────────────────────────────────────────────────────
|
||
* 工具:glob → 正则,支持 * 与 **
|
||
* ──────────────────────────────────────────────────────────────── */
|
||
|
||
function globToRegex(glob) {
|
||
let re = '';
|
||
for (let i = 0; i < glob.length; i++) {
|
||
const c = glob[i];
|
||
if (c === '*') {
|
||
if (glob[i + 1] === '*') {
|
||
// ** 跨目录
|
||
re += '.*';
|
||
i++;
|
||
if (glob[i + 1] === '/') i++; // 吃掉 **/ 里的斜杠
|
||
} else {
|
||
// * 不跨目录
|
||
re += '[^/]*';
|
||
}
|
||
} else if ('.+?^${}()|[]\\'.includes(c)) {
|
||
re += '\\' + c;
|
||
} else {
|
||
re += c;
|
||
}
|
||
}
|
||
return new RegExp('^' + re + '$');
|
||
}
|
||
|
||
/* ────────────────────────────────────────────────────────────────
|
||
* 工具:Dockerfile 解析
|
||
* ──────────────────────────────────────────────────────────────── */
|
||
|
||
/** 取运行时阶段(最后一个 FROM 之后的指令) */
|
||
function runtimeStage(dockerfile) {
|
||
const lines = dockerfile.split('\n');
|
||
let lastFrom = -1;
|
||
lines.forEach((l, i) => {
|
||
if (/^FROM\s/i.test(l.trim())) lastFrom = i;
|
||
});
|
||
return lines.slice(lastFrom + 1).join('\n');
|
||
}
|
||
|
||
/**
|
||
* 解析 COPY 指令。
|
||
* @returns {{srcs: string[], fromBuilder: string[]}} srcs 为来自构建上下文的源(含通配)
|
||
*/
|
||
function parseCopies(text) {
|
||
const srcs = [];
|
||
const fromBuilder = [];
|
||
for (const raw of text.split('\n')) {
|
||
const line = raw.trim();
|
||
if (!/^COPY\s/i.test(line)) continue;
|
||
const tokens = line.replace(/^COPY\s+/i, '').split(/\s+/).filter(Boolean);
|
||
const flags = tokens.filter((t) => t.startsWith('--'));
|
||
const args = tokens.filter((t) => !t.startsWith('--'));
|
||
if (args.length < 2) continue;
|
||
const from = args.slice(0, -1); // 多源 COPY 支持
|
||
const isBuilder = flags.some((f) => f.startsWith('--from='));
|
||
(isBuilder ? fromBuilder : srcs).push(...from);
|
||
}
|
||
return { srcs, fromBuilder };
|
||
}
|
||
|
||
/** 递归列出目录下的全部文件(相对仓库根,posix 分隔) */
|
||
function walkDir(rel) {
|
||
const out = [];
|
||
const rec = (absDir, relDir) => {
|
||
for (const name of fs.readdirSync(absDir)) {
|
||
const abs = path.join(absDir, name);
|
||
const r = relDir + '/' + name;
|
||
if (fs.statSync(abs).isDirectory()) rec(abs, r);
|
||
else out.push(norm(r));
|
||
}
|
||
};
|
||
rec(path.join(ROOT, rel), norm(rel));
|
||
return out;
|
||
}
|
||
|
||
/** 展开构建上下文里的 glob,返回实际存在的文件(相对仓库根) */
|
||
function expandGlob(glob) {
|
||
// 只支持形如 dir/*.ext 与 dir/sub/*.ext 的简单模式(本仓库用法)
|
||
if (!glob.includes('*')) {
|
||
const abs = path.join(ROOT, glob);
|
||
if (!fs.existsSync(abs)) return [];
|
||
// 目录要**递归展开成文件列表**:`COPY server/business/admin ...` 会把整个
|
||
// 目录带进镜像。只把目录名本身放进 copied 集,下面「静态资源」段就永远查不到
|
||
// 任何一个文件(它比对的是文件路径),于是那段会变成永远为真的假绿。
|
||
if (fs.statSync(abs).isDirectory()) return walkDir(glob);
|
||
return [glob];
|
||
}
|
||
const dir = path.posix.dirname(glob);
|
||
const pattern = path.posix.basename(glob);
|
||
const absDir = path.join(ROOT, dir);
|
||
if (!fs.existsSync(absDir)) return [];
|
||
const re = globToRegex(pattern);
|
||
return fs.readdirSync(absDir)
|
||
.filter((f) => re.test(f))
|
||
.map((f) => norm(path.posix.join(dir, f)));
|
||
}
|
||
|
||
/* ────────────────────────────────────────────────────────────────
|
||
* 工具:require 图遍历
|
||
* ──────────────────────────────────────────────────────────────── */
|
||
|
||
/** 解析相对 require 到仓库内绝对路径;裸模块名返回 null */
|
||
function resolveLocal(spec, fromFile) {
|
||
if (!spec.startsWith('.')) return null; // 裸模块 → node_modules,不属于 COPY 覆盖范围
|
||
const base = path.resolve(path.dirname(fromFile), spec);
|
||
for (const cand of [base, base + '.js', path.join(base, 'index.js')]) {
|
||
if (fs.existsSync(cand) && fs.statSync(cand).isFile()) {
|
||
return norm(path.relative(ROOT, cand));
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** 从入口出发递归收集本地依赖(跳过 node_modules) */
|
||
function collectRequires(entry) {
|
||
const seen = new Set();
|
||
const missing = [];
|
||
const stack = [entry];
|
||
|
||
while (stack.length) {
|
||
const rel = stack.pop();
|
||
if (seen.has(rel)) continue;
|
||
seen.add(rel);
|
||
|
||
const abs = path.join(ROOT, rel);
|
||
if (!fs.existsSync(abs)) {
|
||
missing.push(rel);
|
||
continue;
|
||
}
|
||
const src = fs.readFileSync(abs, 'utf8');
|
||
const re = /require\(\s*['"]([^'"]+)['"]\s*\)/g;
|
||
let m;
|
||
while ((m = re.exec(src)) !== null) {
|
||
const resolved = resolveLocal(m[1], abs);
|
||
if (resolved && !seen.has(resolved)) stack.push(resolved);
|
||
if (!resolved && m[1].startsWith('.')) missing.push(`${rel} -> ${m[1]}`);
|
||
}
|
||
}
|
||
return { files: [...seen], missing };
|
||
}
|
||
|
||
/* ────────────────────────────────────────────────────────────────
|
||
* 1 & 2. 按 require 图校验 COPY 覆盖性
|
||
* ──────────────────────────────────────────────────────────────── */
|
||
|
||
const IMAGES = [
|
||
{ name: 'gateway', dockerfile: 'deploy/Dockerfile.gateway', entry: 'server/gateway/index.js' },
|
||
{ name: 'business', dockerfile: 'deploy/Dockerfile.business', entry: 'server/business/index.js' },
|
||
// 单容器镜像(all-in-one,2026-10-14):同一镜像承载两个入口 —— gateway 与
|
||
// business 仍是两个独立进程(supervisord 守护),应用代码零改动,
|
||
// 故 require 图覆盖性必须对两个入口分别成立。
|
||
{ name: 'all', dockerfile: 'deploy/Dockerfile.all', entries: ['server/gateway/index.js', 'server/business/index.js'] },
|
||
];
|
||
|
||
for (const img of IMAGES) {
|
||
console.log(`\n=== 镜像 ${img.name}(${img.dockerfile})===`);
|
||
const dfPath = path.join(ROOT, img.dockerfile);
|
||
ok('Dockerfile 存在', fs.existsSync(dfPath), img.dockerfile);
|
||
if (!fs.existsSync(dfPath)) continue;
|
||
|
||
const df = fs.readFileSync(dfPath, 'utf8');
|
||
const rt = runtimeStage(df);
|
||
const { srcs } = parseCopies(rt);
|
||
ok('运行时阶段存在 COPY 源码指令', srcs.length > 0);
|
||
|
||
// 展开 COPY 源 → 实际会被放进镜像的仓库文件集
|
||
const copied = new Set();
|
||
for (const g of srcs) for (const f of expandGlob(g)) copied.add(f);
|
||
|
||
// 反向:每条 COPY 规则必须匹配到真实文件,否则 docker build 直接失败(事故 B)
|
||
const emptyGlobs = srcs.filter((g) => expandGlob(g).length === 0);
|
||
ok(
|
||
'每条 COPY 规则都能匹配到真实文件',
|
||
emptyGlobs.length === 0,
|
||
emptyGlobs.length ? `指向不存在的文件(构建硬失败): ${emptyGlobs.join(', ')}` : ''
|
||
);
|
||
|
||
// 入口 + require 图闭包:all 镜像有两个入口(两个独立进程),逐入口断言
|
||
const entries = img.entries || [img.entry];
|
||
for (const entry of entries) {
|
||
// 入口必须被覆盖
|
||
ok(`入口模块被 COPY 覆盖: ${entry}`, copied.has(entry),
|
||
`已覆盖 ${copied.size} 个文件`);
|
||
|
||
// require 图闭包必须全部被覆盖
|
||
const { files, missing } = collectRequires(entry);
|
||
ok('require 图无未解析的相对路径', missing.length === 0, missing.join(', '));
|
||
|
||
const notCopied = files.filter((f) => !copied.has(f));
|
||
ok(
|
||
`require 图全部 ${files.length} 个文件均被 COPY 覆盖`,
|
||
notCopied.length === 0,
|
||
notCopied.length ? `漏拷(容器启动即 Cannot find module): ${notCopied.join(', ')}` : ''
|
||
);
|
||
}
|
||
|
||
// ── 静态资源覆盖性(2026-10-30 新增)────────────────────────────
|
||
// 为什么必须单独一段:静态页面**不被任何模块 require**,上面的 require 图
|
||
// 遍历永远看不见它们。于是"忘了 COPY"这种事故在上面整段里是**零信号**。
|
||
//
|
||
// 真实形态:`COPY server/business/*.js ./server/business/` 的 `*` 不跨目录,
|
||
// admin/ 子目录不会被拷进镜像 —— 但镜像照样构建成功、容器照样健康、
|
||
// /api/admin/* 照样全通,**只有 /admin/ 一片 404**。它属于本仓库第 6 条
|
||
// 「首次部署才走的路径必须有自己的测试」的同类。
|
||
const staticDirs = img.name === 'gateway' ? [] : ['server/business/admin'];
|
||
for (const dir of staticDirs) {
|
||
const abs = path.join(ROOT, dir);
|
||
if (!fs.existsSync(abs)) {
|
||
ok(`静态目录存在: ${dir}`, false, '目录不存在,管理台没有页面可发');
|
||
continue;
|
||
}
|
||
const staticFiles = walkDir(dir);
|
||
ok(`静态目录 ${dir} 内含文件`, staticFiles.length > 0, `${staticFiles.length} 个`);
|
||
const missed = staticFiles.filter((f) => !copied.has(f));
|
||
ok(
|
||
`静态资源全部被 COPY 覆盖(${staticFiles.length} 个)`,
|
||
missed.length === 0,
|
||
missed.length
|
||
? `漏拷(镜像里 /admin/ 会整个 404): ${missed.slice(0, 6).join(', ')}${missed.length > 6 ? ' …' : ''}`
|
||
: '含 assets/ 子目录'
|
||
);
|
||
}
|
||
}
|
||
|
||
/* ────────────────────────────────────────────────────────────────
|
||
* 3. .dockerignore 不得排除镜像必需文件
|
||
* ──────────────────────────────────────────────────────────────── */
|
||
|
||
console.log('\n=== .dockerignore 与必需文件 ===');
|
||
{
|
||
const diPath = path.join(ROOT, '.dockerignore');
|
||
ok('.dockerignore 存在(否则构建上下文含 node_modules/target/.git)', fs.existsSync(diPath));
|
||
|
||
if (fs.existsSync(diPath)) {
|
||
const patterns = fs.readFileSync(diPath, 'utf8')
|
||
.split('\n')
|
||
.map((l) => l.trim())
|
||
.filter((l) => l && !l.startsWith('#'))
|
||
.filter((l) => !l.startsWith('!'))
|
||
.map((l) => globToRegex(l.replace(/^\//, '').replace(/\/$/, '')));
|
||
|
||
// 全部镜像(含单容器镜像的两个入口)实际需要的文件
|
||
const needed = new Set();
|
||
for (const img of IMAGES) {
|
||
for (const entry of (img.entries || [img.entry])) {
|
||
for (const f of collectRequires(entry).files) needed.add(f);
|
||
}
|
||
}
|
||
|
||
const excluded = [...needed].filter((f) => patterns.some((re) => re.test(f) || re.test(f.split('/').pop())));
|
||
ok(
|
||
`.dockerignore 未排除任何必需文件(共 ${needed.size} 个)`,
|
||
excluded.length === 0,
|
||
excluded.length ? `被误排除: ${excluded.join(', ')}` : ''
|
||
);
|
||
|
||
// node_modules 必须被排除(性能)
|
||
const nmRe = globToRegex('**/node_modules');
|
||
ok('.dockerignore 排除了 node_modules', patterns.some((re) => re.test('server/gateway/node_modules')));
|
||
void nmRe;
|
||
}
|
||
}
|
||
|
||
/* ────────────────────────────────────────────────────────────────
|
||
* 4. 环境变量契约:.env.example 里声明的不能被代码无视
|
||
* ──────────────────────────────────────────────────────────────── */
|
||
|
||
console.log('\n=== 环境变量契约(.env.example ↔ 代码 ↔ compose)===');
|
||
{
|
||
const envKeys = fs.readFileSync(path.join(ROOT, '.env.example'), 'utf8')
|
||
.split('\n')
|
||
.map((l) => l.trim())
|
||
.filter((l) => /^[A-Z_]+=/.test(l))
|
||
.map((l) => l.split('=')[0]);
|
||
|
||
ok('.env.example 含环境变量声明', envKeys.length > 0, `实得 ${envKeys.length}`);
|
||
|
||
// 只扫我们写的源码,排除 node_modules(node_modules 里有大量内部变量名)
|
||
const srcFiles = [];
|
||
const walk = (dir) => {
|
||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||
if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
|
||
const p = path.join(dir, e.name);
|
||
if (e.isDirectory()) walk(p);
|
||
else if (e.name.endsWith('.js')) srcFiles.push(p);
|
||
}
|
||
};
|
||
walk(path.join(ROOT, 'server'));
|
||
walk(path.join(ROOT, 'shared'));
|
||
|
||
const readEnv = new Set();
|
||
for (const f of srcFiles) {
|
||
const src = fs.readFileSync(f, 'utf8');
|
||
for (const m of src.matchAll(/process\.env\.([A-Z_]+)/g)) readEnv.add(m[1]);
|
||
}
|
||
// 声明式登记(instance-config 的 ENV_MAP)同样算"被消费" —— 它们走动态下标,
|
||
// 上面那条正则扫不到。前提是模块里真有 `env[spec.env]` 那句,故一并断言。
|
||
{
|
||
const map = readInstanceConfigEnvMap();
|
||
ok('instance-config 的 ENV_MAP 可解析(声明式开关的唯一定义处)',
|
||
map.entries.length >= 5, `实得 ${map.entries.length} 项`);
|
||
ok('instance-config 真的动态读取 ENV_MAP 里登记的环境变量(env[spec.env])', map.dynamic);
|
||
for (const e of map.entries) readEnv.add(e.env);
|
||
}
|
||
|
||
// 这两类变量不通过 process.env 读取,属合法豁免:
|
||
// compose 专供 —— 由 docker-compose 展开进 DATABASE_URL,或作为 postgres 镜像参数
|
||
// 命令行提示 —— 注释里提醒必须写在 node 启动参数上,代码读不到也不该读
|
||
const COMPOSE_ONLY = new Set(['POSTGRES_USER', 'POSTGRES_PASSWORD', 'POSTGRES_DB']);
|
||
const COMMANDLINE_ONLY = new Set(['NODE_MAX_OLD_SPACE_MB']);
|
||
|
||
const dead = envKeys.filter(
|
||
(k) => !readEnv.has(k) && !COMPOSE_ONLY.has(k) && !COMMANDLINE_ONLY.has(k)
|
||
);
|
||
ok(
|
||
'.env.example 无死配置(每项都被代码/compose/命令行消费)',
|
||
dead.length === 0,
|
||
dead.length ? `声明了但无人读取: ${dead.join(', ')}` : ''
|
||
);
|
||
|
||
// 业务服务端口的专属键必须真的被读(历史事故:BUSINESS_PORT 声明了却只读 PORT)
|
||
const bizSrc = fs.readFileSync(path.join(ROOT, 'server/business/index.js'), 'utf8');
|
||
ok('BUSINESS_PORT 被业务服务读取', /process\.env\.BUSINESS_PORT/.test(bizSrc));
|
||
ok('BUSINESS_PORT 回退到 PORT', /process\.env\.BUSINESS_PORT\s*\|\|\s*process\.env\.PORT/.test(bizSrc));
|
||
}
|
||
|
||
/* ────────────────────────────────────────────────────────────────
|
||
* 5. 安全开关通路:代码读的开关,compose 必须真的传给「读它的服务」
|
||
* ────────────────────────────────────────────────────────────────
|
||
*
|
||
* 真实事故(2026-09-15,腾讯云部署评估时发现):
|
||
* 网关 `onHello` 里的设备令牌校验读 `REQUIRE_DEVICE_TOKEN`,但 docker-compose
|
||
* 只把它传给了 business、**漏了 gateway**。后果:`.env` 里写
|
||
* REQUIRE_DEVICE_TOKEN=1 **完全无效**,设备令牌校验静默失效(公网部署时
|
||
* 任何人都能拿可见的 device_id 灌假指标)。
|
||
*
|
||
* 而 `tests/auth-mode-test.js` 是**直接 spawn 进程传 env** 的,从来没走 compose
|
||
* 这条通路 —— 又一次"测试全绿、部署是假的",与事故 A/B 同族。
|
||
*
|
||
* 本段的清单**从代码派生**,不另立一份手工列表(否则又是"同一份清单写两处"):
|
||
* ① 扫 server/ 与 shared/ 下所有 `process.env.(REQUIRE_|ENFORCE_|ALLOW_)…`
|
||
* ② 每个读取点归属到服务:server/gateway/* → gateway、server/business/* → business、
|
||
* server/shared/* 与 shared/* 被两个镜像都 COPY → 归属全部服务
|
||
* (共享层读到的开关,两个服务都得拿到值,否则行为不一致)
|
||
* ③ 断言这些键在 .env.example 里登记(否则运维根本不知道有这个开关)
|
||
* ④ 断言 compose 把它们传给了**每一个**读它的服务
|
||
*/
|
||
|
||
console.log('\n=== 安全开关通路(代码 → .env.example → compose)===');
|
||
{
|
||
// 极简 compose 解析:只认本项目手写的缩进风格。够用即可,
|
||
// 但下面有"解析自检"断言,解析失手会立刻变红而不是静默放行。
|
||
function composeEnvByService(text) {
|
||
const out = {};
|
||
let svc = null;
|
||
let inEnv = false;
|
||
let envIndent = -1;
|
||
for (const raw of text.split('\n')) {
|
||
const line = raw.replace(/(^|\s)#.*$/, '$1');
|
||
if (!line.trim()) continue;
|
||
const indent = line.length - line.trimStart().length;
|
||
|
||
const mSvc = indent === 2 ? /^ {2}([a-z][a-z0-9_-]*):\s*$/.exec(line) : null;
|
||
if (mSvc) { svc = mSvc[1]; inEnv = false; out[svc] = out[svc] || new Set(); continue; }
|
||
|
||
if (/^\s*environment:\s*$/.test(line)) { inEnv = true; envIndent = indent; continue; }
|
||
|
||
if (inEnv) {
|
||
if (indent <= envIndent && !/^\s*-/.test(line)) { inEnv = false; continue; }
|
||
if (!svc) continue;
|
||
const mM = /^\s*([A-Z_][A-Z0-9_]*):/.exec(line);
|
||
if (mM) out[svc].add(mM[1]);
|
||
const mL = /^\s*-\s*([A-Z_][A-Z0-9_]*)=/.exec(line);
|
||
if (mL) out[svc].add(mL[1]);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ① 扫代码里的安全开关读取点(排除 node_modules)—— 与 compose 无关,只做一次
|
||
const reads = []; // {key, svc}
|
||
const SWITCH_RE = /process\.env\.((?:REQUIRE_|ENFORCE_|ALLOW_)[A-Z0-9_]*)/g;
|
||
const collect = (dir, owner) => {
|
||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||
if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
|
||
const p = path.join(dir, e.name);
|
||
if (e.isDirectory()) collect(p, owner);
|
||
else if (e.name.endsWith('.js')) {
|
||
const src = fs.readFileSync(p, 'utf8');
|
||
for (const m of src.matchAll(SWITCH_RE)) {
|
||
for (const s of owner(p)) reads.push({ key: m[1], svc: s });
|
||
}
|
||
}
|
||
}
|
||
};
|
||
const ownerOf = (p) => {
|
||
const rel = path.relative(ROOT, p);
|
||
if (rel.startsWith('server/gateway/')) return ['gateway'];
|
||
if (rel.startsWith('server/business/')) return ['business'];
|
||
// 共享层:两个镜像都 COPY,读到就得两边都有值
|
||
return ['gateway', 'business'];
|
||
};
|
||
collect(path.join(ROOT, 'server'), ownerOf);
|
||
collect(path.join(ROOT, 'shared'), ownerOf);
|
||
|
||
// ①b 声明式开关:ENV_MAP 里 REQUIRE_/ENFORCE_/ALLOW_ 前缀的键,走的是动态下标
|
||
// `env[spec.env]`,上面那条正则扫不到,覆盖会**静默缩水**。这里补上归属:
|
||
// 按**字段名**判断哪个服务真的消费了它(`cfg.require_auth`),
|
||
// 而不是"谁 require 了 instance-config 就算谁的" —— 后者会把网关根本没用到的
|
||
// ALLOW_OPEN_REGISTER 也算成网关的读取点,于是 compose 被要求传一堆没人读的值,
|
||
// 护栏的每一条通路就不再是真的了。
|
||
{
|
||
const map = readInstanceConfigEnvMap();
|
||
const consumerDirs = [['server/gateway', 'gateway'], ['server/business', 'business']];
|
||
const swEntries = map.entries.filter((e) => /^(REQUIRE_|ENFORCE_|ALLOW_)/.test(e.env));
|
||
ok('ENV_MAP 里登记了安全开关(≥2 个,防前缀改动静默失去覆盖)', swEntries.length >= 2,
|
||
`实得 ${swEntries.length}: ${swEntries.map((e) => e.env).join(', ')}`);
|
||
|
||
const unowned = [];
|
||
for (const e of swEntries) {
|
||
let owned = false;
|
||
for (const [dir, svc] of consumerDirs) {
|
||
if (!requiresModule(path.join(ROOT, dir), 'instance-config')) continue;
|
||
if (usesField(path.join(ROOT, dir), e.field)) { reads.push({ key: e.env, svc }); owned = true; }
|
||
}
|
||
if (!owned) unowned.push(e.env);
|
||
}
|
||
// 声明了却没人消费 = 死开关:运维改了没反应,比没这个开关更糟
|
||
ok('ENV_MAP 里的每个安全开关都至少被一个服务的代码消费',
|
||
unowned.length === 0, unowned.length ? `无人消费: ${unowned.join(', ')}` : '');
|
||
}
|
||
|
||
ok('扫到安全开关读取点(≥3 处,防扫描失手静默放行)', reads.length >= 3,
|
||
`实得 ${reads.length} 处`);
|
||
|
||
const envExample = fs.readFileSync(path.join(ROOT, '.env.example'), 'utf8');
|
||
const declared = new Set(
|
||
envExample.split('\n')
|
||
.map((l) => l.trim())
|
||
.filter((l) => /^(REQUIRE_|ENFORCE_|ALLOW_)[A-Z0-9_]*=/.test(l))
|
||
.map((l) => l.split('=')[0])
|
||
);
|
||
|
||
const switchKeys = [...new Set(reads.map((r) => r.key))];
|
||
const undocumented = switchKeys.filter((k) => !declared.has(k));
|
||
ok('.env.example 登记了全部安全开关(否则运维不知道有这个开关)',
|
||
undocumented.length === 0,
|
||
undocumented.length ? `未登记: ${undocumented.join(', ')}` : '');
|
||
|
||
// ② 逐 compose 文件断言:每个开关都传给了「读它的服务」
|
||
// 四容器路径:gateway/business 各是一个服务;
|
||
// 单容器路径(2026-10-14 起):两个进程同在 app 服务里,映射到同一服务名。
|
||
// 这条映射正是 2026-09-15 那起「漏传 REQUIRE_DEVICE_TOKEN 给网关」事故的
|
||
// 守门段:当时 compose 传了一个服务、漏了另一个,.env 里写 1 完全无效。
|
||
const COMPOSE_FILES = [
|
||
{
|
||
file: 'deploy/docker-compose.yml', label: '四容器编排',
|
||
map: { gateway: 'gateway', business: 'business' },
|
||
selfCheck: ['gateway', 'business', 'redis', 'postgres'],
|
||
// 四容器路径里网关端口由 compose 注入
|
||
portSelfCheck: (envBySvc) => ok('compose 解析自检:gateway 的 PORT 被认出',
|
||
!!(envBySvc.gateway && envBySvc.gateway.has('PORT'))),
|
||
},
|
||
{
|
||
file: 'deploy/docker-compose.all.yml', label: '单容器编排',
|
||
map: { gateway: 'app', business: 'app' },
|
||
selfCheck: ['app', 'migrate'],
|
||
// 单容器路径里端口烤进镜像(ENV PORT/BUSINESS_PORT),compose 侧改验 DATABASE_URL
|
||
portSelfCheck: (envBySvc) => ok('compose 解析自检:app 的 DATABASE_URL 被认出',
|
||
!!(envBySvc.app && envBySvc.app.has('DATABASE_URL'))),
|
||
},
|
||
];
|
||
|
||
// ── 接入层已移到宿主(2026-11-08 移除 nginx 容器)─────────────────────
|
||
// 为什么必须有这一段:nginx 容器删掉后,"反代该做什么"就只存在于**宿主**配置里,
|
||
// 而宿主配置不在本仓的运行路径上 —— 一旦那个模板被删或被简化,没有任何测试会红,
|
||
// 而症状也不是 502,是"页面能打开但画面不刷新""长连接 60 秒后被无声掐断"
|
||
// "文件其实落到了 nginx 的 temp 上"。这类退化只能靠对模板本身断言来防。
|
||
console.log('\n--- 接入层:宿主反向代理(容器已移除)---');
|
||
const hostConf = path.join(ROOT, 'deploy/nginx.host.conf.example');
|
||
ok('deploy/nginx.host.conf.example 存在', fs.existsSync(hostConf));
|
||
if (fs.existsSync(hostConf)) {
|
||
const nc = fs.readFileSync(hostConf, 'utf8');
|
||
ok('宿主模板含 WebSocket 升级(http_version 1.1 + Upgrade 头)',
|
||
/proxy_http_version\s+1\.1/.test(nc) && /Upgrade\s+\$http_upgrade/.test(nc));
|
||
ok('宿主模板含 proxy_read_timeout 600s(长连接不被 60 秒掐断)',
|
||
/proxy_read_timeout\s+600s/.test(nc));
|
||
ok('宿主模板含 proxy_buffering off(画面才实时)',
|
||
/proxy_buffering\s+off/.test(nc));
|
||
ok('宿主模板含 proxy_request_buffering off(文件中继不落盘)',
|
||
/proxy_request_buffering\s+off/.test(nc));
|
||
// upstream 必须是宿主能解析的地址。容器服务名(gateway:3000)只在 Docker
|
||
// 网络内可解析,宿主 nginx 用它必然 "host not found in upstream"。
|
||
ok('宿主模板 upstream 用回环地址而非 Docker 服务名',
|
||
/server\s+127\.0\.0\.1:3000/.test(nc) && /server\s+127\.0\.0\.1:4000/.test(nc)
|
||
&& !/server\s+gateway:3000/.test(nc) && !/server\s+business:4000/.test(nc));
|
||
}
|
||
// 负向断言:四容器编排里不该再出现 nginx 服务。
|
||
// 防的是"顺手加回来"——那会让接入层变成两处真相,且默认文件与 prod 覆盖
|
||
// 再次分叉(prod 原先正是用 profiles 停用它)。
|
||
const fourTxt = fs.readFileSync(path.join(ROOT, 'deploy/docker-compose.yml'), 'utf8');
|
||
ok('四容器编排不含 nginx 服务(接入层只在宿主)',
|
||
!/^\s{2}nginx:\s*$/m.test(fourTxt));
|
||
|
||
for (const cf of COMPOSE_FILES) {
|
||
console.log(`\n--- ${cf.label}(${cf.file})---`);
|
||
ok(`${cf.file} 存在`, fs.existsSync(path.join(ROOT, cf.file)));
|
||
const composeText = fs.readFileSync(path.join(ROOT, cf.file), 'utf8'); const envBySvc = composeEnvByService(composeText);
|
||
const svcNames = Object.keys(envBySvc);
|
||
|
||
// 解析自检:解析失手会静默放行,故必须先证明解析是对的
|
||
ok('compose 解析自检:认出了全部服务',
|
||
cf.selfCheck.every((s) => svcNames.includes(s)),
|
||
`实得 ${svcNames.join(', ')}`);
|
||
cf.portSelfCheck(envBySvc);
|
||
|
||
const gaps = [];
|
||
const seen = new Set();
|
||
for (const { key, svc } of reads) {
|
||
const target = cf.map[svc];
|
||
const tag = `${target}←${key}`;
|
||
if (seen.has(tag)) continue;
|
||
seen.add(tag);
|
||
if (!envBySvc[target] || !envBySvc[target].has(key)) gaps.push(tag);
|
||
}
|
||
ok(`${cf.label}:compose 把每个安全开关都传给了读它的服务`,
|
||
gaps.length === 0,
|
||
gaps.length ? `漏传: ${gaps.join(', ')}` : `${seen.size} 条通路全部打通`);
|
||
|
||
// 反向自检:断言集合不能是空的(否则本段形同虚设)
|
||
ok('反向自检:至少有一条 (服务←开关) 通路被检查', seen.size >= 3, `实得 ${seen.size}`);
|
||
}
|
||
}
|
||
|
||
/* ────────────────────────────────────────────────────────────────
|
||
* 6. 单容器编排的端口必须全部绑回环
|
||
* ────────────────────────────────────────────────────────────────
|
||
* 该机云防火墙是「全部 TCP 1-65535 放通」,绑 0.0.0.0 等于把
|
||
* gateway/business/PG 直接暴露公网(业务服务带 TRUST_PROXY_HOPS=1,
|
||
* 直连可伪造 X-Forwarded-For 绕过配对码 IP 限流)。
|
||
*
|
||
* 四容器路径用 prod 覆盖文件的 !override 绑回环(踩过 Compose 的 ports
|
||
* 拼接语义坑);单容器路径把回环绑在唯一文件里,故直接断言:
|
||
* 任何一条非 127.0.0.1 的端口映射都必须让本段变红。
|
||
*/
|
||
|
||
console.log('\n=== 单容器编排端口绑定(云防火墙全端口放通 → 必须回环)===');
|
||
{
|
||
const p = path.join(ROOT, 'deploy/docker-compose.all.yml');
|
||
ok('deploy/docker-compose.all.yml 存在', fs.existsSync(p));
|
||
|
||
if (fs.existsSync(p)) {
|
||
const text = fs.readFileSync(p, 'utf8');
|
||
// 只收 "IP:port:port" 形态的引号行(volumes 的路径挂载不会误入)
|
||
// 收两种形态:带 IP 前缀的 "127.0.0.1:3000:3000" 与**裸** "3000:3000"。
|
||
// ⚠️ 裸映射在 Docker 语义里就是绑 0.0.0.0(全部网卡)—— 正是本段要防的
|
||
// 最危险写法。第一版正则只认带 IP 前缀的,裸映射会被静默漏检
|
||
// (2026-10-16 审计补):把 IP 前缀设为可选,漏网之鱼全部进 notLoopback。
|
||
const ports = [...text.matchAll(/^\s*-\s*"([^"]*)"/gm)].map((m) => m[1])
|
||
.filter((s) => /^(\d{1,3}(\.\d{1,3}){3}:)?\d+:\d+$/.test(s));
|
||
ok('解析到端口映射(≥1 条)', ports.length >= 1, `实得 ${ports.length}`);
|
||
|
||
// 2026-10-17 架构收敛:单容器**只发布一个口**(3500 = 容器内 nginx 统一入口,
|
||
// /api/ → business、其余 → gateway)。原断言"≥3 条"守的是旧的 3000/4000/5432,
|
||
// 现改为守新契约,且**比原来更严**:
|
||
// · 必须有 3500(入口存在)
|
||
// · 3000/4000/5432/6379 一律不得发布(发布了就等于绕过统一入口,
|
||
// 把内部进程直接怼到宿主/公网上)
|
||
ok('发布了统一入口 3500', ports.some((s) => s.endsWith(':3500')), ports.join(', '));
|
||
const leakedInternal = ports.filter((s) => /:(3000|4000|5432|6379)$/.test(s));
|
||
ok('不再发布内部端口(3000/4000/5432/6379)',
|
||
leakedInternal.length === 0,
|
||
leakedInternal.length ? `不应发布: ${leakedInternal.join(', ')}` : '仅统一入口');
|
||
|
||
const notLoopback = ports.filter((s) => !s.startsWith('127.0.0.1:'));
|
||
ok('端口全部绑 127.0.0.1(不暴露公网)',
|
||
notLoopback.length === 0,
|
||
notLoopback.length ? `暴露公网的映射: ${notLoopback.join(', ')}` : `${ports.length} 条全部回环`);
|
||
}
|
||
}
|
||
|
||
/* ────────────────────────────────────────────────────────────────
|
||
* 汇总
|
||
* ──────────────────────────────────────────────────────────────── */
|
||
|
||
console.log('\n' + '='.repeat(60));
|
||
console.log(`测试通过: ${pass} 失败: ${fail}`);
|
||
console.log('='.repeat(60));
|
||
if (fail > 0) {
|
||
console.log('\n失败项:');
|
||
for (const f of failures) console.log(' - ' + f);
|
||
process.exit(1);
|
||
}
|