Files
inkwatch/tests/dockerfile-coverage-test.js
jkooe 088c9b34a0 refactor(deploy): 移除 nginx 容器,接入层改由宿主承担(常驻 5 → 4)
## 结果

四容器编排从 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 是拼接语义)在宿主反代上
  一样成立,只是换了文件。
2026-11-08 20:29:43 +08:00

706 lines
34 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 墨观 镜像构建覆盖性测试 —— 按 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);
}