1046 lines
50 KiB
JavaScript
1046 lines
50 KiB
JavaScript
// 跨节点内容分发 · 回归测试(本线 §5-2 / §5-8)
|
||
//
|
||
// 形态(2026-09-24 09:0x 用户拍板):「**最好用拉取,主节点核对告知差异**」
|
||
// ⇒ 节点主动拨出去问 ⇒ Manager 核对后把差异告知回来 ⇒ 节点按差异拉产物。
|
||
//
|
||
// 本文件钉住的判据(每一条都对应一处会**静默出错**的地方):
|
||
// ① 两侧的 `hashTree` **必须算出同一个 hex** —— 这是全部完整性判据的地基。
|
||
// Manager 侧在 `src/web/routes/business-plugins.ts`、节点侧在 `src/worker/agent.ts`,
|
||
// 因分层 R1 物理上不能共用一份实现 ⇒ 靠本文件的第 1 组用例钉住漂移。
|
||
// ② 指纹**不符**或传输**损坏** ⇒ ⛔ **不换掉现役目录**(最坏结果只能是"没更新")。
|
||
// ⛔ 绝不能出现"半包":那会让该机上所有引用该插件的实例起不来。
|
||
// ③ 通信失败 ⇒ ⛔ **一个本地文件都不许动**(不许退化成"那就当没有差异")。
|
||
// ④ 节点上**多出来**的目录 ⇒ **只上报、⛔ 平台绝不删**(删错不可逆)。
|
||
// ⑤ 对账判据里「节点给不出指纹」**必须**算 `stale`(⇒ 重拉自愈),⛔ 不算"一致"。
|
||
//
|
||
// 跑法:`npm test`(先 build 再跑 `lib/`)。
|
||
|
||
import { test } from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { execFileSync } from 'node:child_process'
|
||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||
import { tmpdir } from 'node:os'
|
||
import { dirname, join, relative } from 'node:path'
|
||
|
||
import { effectiveDownloadTimeout, hashTreeAt, syncSharedLayerOnce } from '../lib/worker/agent.js'
|
||
import {
|
||
archiveLiveVersion,
|
||
availableVersionsOf,
|
||
hashTree,
|
||
historyEntryOf,
|
||
isSafeVersion,
|
||
resolveSharedTarget,
|
||
sharedLayerDiff,
|
||
sharedStatusMatrix,
|
||
packageVersionAt,
|
||
packSharedTree,
|
||
reconcileSharedManifest,
|
||
rollbackAssetPath,
|
||
versionsDirOf,
|
||
} from '../lib/web/routes/business-plugins.js'
|
||
|
||
const FLAT = '_dsh-local_probe'
|
||
const SHA_A = 'a'.repeat(64)
|
||
|
||
const tmp = () => mkdtempSync(join(tmpdir(), 'dshs-shared-sync-'))
|
||
|
||
/** 造一份"已解包的产物树"(内容刻意含子目录与多个文件,覆盖 walk 的分支)。 */
|
||
function makeTree(root) {
|
||
mkdirSync(join(root, 'dist'), { recursive: true })
|
||
writeFileSync(join(root, 'package.json'), JSON.stringify({ name: '@dsh-local/probe', version: '1.0.0' }))
|
||
writeFileSync(join(root, 'dist', 'index.js'), 'export const a = 1\n')
|
||
writeFileSync(join(root, 'README.md'), '# probe\n')
|
||
return root
|
||
}
|
||
|
||
/** 用**真 tar** 打一份 `flat/…` 的产物包(与 Manager 侧 `spawn('tar', […-C root flat])` 同形)。 */
|
||
function packDir(parent, flat) {
|
||
const tgz = join(parent, 'pack.tgz')
|
||
// ⚠️ 只给 tar **相对**参数 + `cwd`:本机(Windows/MSYS)下把 `E:/…` 这类盘符路径
|
||
// 当 argv 传给 tar 有已知坑,绕开它(被 `-C` 的 cwd 语义覆盖)。
|
||
execFileSync('tar', ['-czf', 'pack.tgz', flat], { cwd: parent, stdio: 'pipe' })
|
||
return readFileSync(tgz)
|
||
}
|
||
|
||
/**
|
||
* 假 Manager:只实现节点用到的三个口,逻辑尽量贴真路由(差异判据直接复用真函数)。
|
||
*
|
||
* `versions[flat][ver]` = **历史版本归档**的字节(B-1)—— 真路由的判据是
|
||
* 「带了 `?version=` ⇒ 按它给;解析不到就 404 `not_here`(⛔ 不悄悄回落现役)」,
|
||
* 这里照抄同一条(⛔ 别让假实现比真实现"更宽容":那会把真实现里的坑藏起来)。
|
||
*/
|
||
function fakeManager({ entries, packs, versions = {}, liveVersions = {}, syncStatus = 200 }) {
|
||
const calls = []
|
||
const fetchImpl = async (url, init = {}) => {
|
||
const u = String(url)
|
||
calls.push({ url: u, init })
|
||
if (u.endsWith('/api/plugins/shared/node/sync')) {
|
||
if (syncStatus !== 200) return new Response('nope', { status: syncStatus })
|
||
const sent = JSON.parse(init.body)
|
||
const local = new Map(sent.inventory.map((i) => [i.flat, i.treeSha256]))
|
||
const diff = sharedLayerDiff(entries, local)
|
||
return Response.json({
|
||
ok: true,
|
||
entries,
|
||
diff,
|
||
pull: [...diff.missing, ...diff.stale],
|
||
})
|
||
}
|
||
if (u.includes('/api/plugins/shared/node/pack/')) {
|
||
const parsed = new URL(u)
|
||
const flat = decodeURIComponent(parsed.pathname.split('/pack/')[1])
|
||
const want = parsed.searchParams.get('version')
|
||
// 真路由的判据:`?version=` == **现役**版本 ⇒ 现打现役目录;否则只能从归档里拿,拿不到 404。
|
||
const liveVer = liveVersions[flat] ?? entries.find((e) => e.flat === flat)?.version
|
||
const body = want !== null && want !== liveVer ? versions[flat]?.[want] : packs[flat]
|
||
if (body === undefined) return new Response('x', { status: 404 })
|
||
return new Response(body, { status: 200, headers: { 'content-type': 'application/gzip' } })
|
||
}
|
||
if (u.endsWith('/api/plugins/shared/node/result')) return Response.json({ ok: true })
|
||
return new Response('x', { status: 404 })
|
||
}
|
||
return { fetchImpl, calls }
|
||
}
|
||
|
||
/**
|
||
* 一个**尊重 abort 信号**的慢响应(真 `fetch` 就是这么表现的)。
|
||
*
|
||
* ⚠️ 不能只写 `await sleep(300)`:那样 `AbortController.abort()` 对它毫无作用
|
||
* ⇒ "downloadTimeoutMs 生效了吗"这条用例会**假绿**(测出来永远成功)。
|
||
*/
|
||
async function slowWithSignal(ms, init, makeResponse) {
|
||
await new Promise((resolve, reject) => {
|
||
const t = setTimeout(resolve, ms)
|
||
const sig = init?.signal
|
||
if (sig === undefined || sig === null) return
|
||
const onAbort = () => {
|
||
clearTimeout(t)
|
||
reject(sig.reason instanceof Error ? sig.reason : new Error('aborted'))
|
||
}
|
||
if (sig.aborted) onAbort()
|
||
else sig.addEventListener('abort', onAbort, { once: true })
|
||
})
|
||
return makeResponse()
|
||
}
|
||
|
||
// ── ① 两侧同算法(漂移钉)────────────────────────────────────────────────
|
||
|
||
test('跨实现一致性: worker 的 hashTreeAt 与 Manager 的 hashTree 必须逐字相等', () => {
|
||
const d = tmp()
|
||
try {
|
||
makeTree(join(d, FLAT))
|
||
const a = hashTree(join(d, FLAT))
|
||
const b = hashTreeAt(join(d, FLAT))
|
||
assert.equal(b.hash, a.hash, '两侧算法漂移了 —— 节点会把完好产物判成损坏并无限重拉')
|
||
assert.equal(b.fileCount, a.fileCount)
|
||
} finally {
|
||
rmSync(d, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('hashTreeAt: 内容是判据 —— 改一个字节 / 改相对路径 / 增删文件都要变', () => {
|
||
const d = tmp()
|
||
try {
|
||
makeTree(join(d, FLAT))
|
||
const base = hashTreeAt(join(d, FLAT)).hash
|
||
assert.equal(hashTreeAt(join(d, FLAT)).hash, base, '同一棵树两次必须一样(顺序无关)')
|
||
|
||
writeFileSync(join(d, FLAT, 'dist', 'index.js'), 'export const a = 2\n')
|
||
const changed = hashTreeAt(join(d, FLAT)).hash
|
||
assert.notEqual(changed, base, '内容变了指纹必须变')
|
||
|
||
writeFileSync(join(d, FLAT, 'extra.txt'), 'x')
|
||
assert.notEqual(hashTreeAt(join(d, FLAT)).hash, changed, '多一个文件指纹必须变')
|
||
} finally {
|
||
rmSync(d, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
// ── ⑤ 对账判据(真函数 `sharedLayerDiff`)──────────────────────────────────
|
||
|
||
test('对账判据: 缺的 / 指纹不同的 / 给不出指纹的 / 多出来的,四类各归各位', () => {
|
||
const auth = [
|
||
{ flat: 'have-same', treeSha256: SHA_A },
|
||
{ flat: 'have-diff', treeSha256: SHA_A },
|
||
{ flat: 'have-unknown', treeSha256: SHA_A },
|
||
{ flat: 'absent-on-node', treeSha256: SHA_A },
|
||
]
|
||
const local = new Map([
|
||
['have-same', SHA_A],
|
||
['have-diff', 'b'.repeat(64)],
|
||
['have-unknown', null], // 节点有目录但算不出指纹 = "不知道"
|
||
['node-only', SHA_A],
|
||
])
|
||
const { missing, stale, extra } = sharedLayerDiff(auth, local)
|
||
assert.deepEqual(missing, ['absent-on-node'])
|
||
assert.deepEqual(stale.sort(), ['have-diff', 'have-unknown'])
|
||
assert.deepEqual(extra, ['node-only'])
|
||
// 🔴 「给不出指纹」必须落在 stale(⇒ 重拉自愈),⛔ 绝不可当"一致"
|
||
assert.ok(!stale.includes('have-same') && !missing.includes('have-unknown'))
|
||
})
|
||
|
||
test('对账判据: `extra` 的判据是"权威清单里没有",与指纹无关(⛔ 不看版本号)', () => {
|
||
const { extra } = sharedLayerDiff([{ flat: 'a', treeSha256: SHA_A }], new Map([['b', null], ['a', SHA_A]]))
|
||
assert.deepEqual(extra, ['b'], '节点上报 null 指纹不影响它"多出来"这个事实')
|
||
})
|
||
|
||
// ── ②③④ 端到端一轮拉取(真 tar / 真文件系统 / 假 Manager)──────────────────
|
||
|
||
test('正常轮: 本机记录与权威一致 ⇒ 什么都不拉(⛔ 不做无谓重传)', async () => {
|
||
const d = tmp()
|
||
try {
|
||
const root = join(d, 'bundled-plugins')
|
||
makeTree(join(root, FLAT))
|
||
const before = hashTreeAt(join(root, FLAT)).hash
|
||
// 预置"我上次拉下来的指纹" ⇒ 这才是"没有差异"的前提
|
||
writeFileSync(
|
||
join(root, '.node-pull.json'),
|
||
JSON.stringify({ entries: { [FLAT]: { treeSha256: before, pulledAt: 1 } }, lastSyncAt: 1 }),
|
||
)
|
||
const entries = [{ flat: FLAT, id: '@dsh-local/probe', version: '1.0.0', treeSha256: before, fileCount: 3 }]
|
||
const m = fakeManager({ entries, packs: {} })
|
||
const out = await syncSharedLayerOnce({
|
||
baseUrl: 'https://manager.test/', // 尾斜杠要能被吃掉
|
||
hostId: 'w-1',
|
||
token: 'tok',
|
||
sharedRoot: root,
|
||
fetchImpl: m.fetchImpl,
|
||
})
|
||
assert.equal(out.ok, true, out.reason ?? '')
|
||
assert.deepEqual(out.pulled, [])
|
||
assert.deepEqual(out.missing, [])
|
||
assert.deepEqual(out.stale, [])
|
||
// ⛔ 没差异就不该去取产物(只该有一次对账请求 + 无产物请求)
|
||
assert.equal(m.calls.filter((c) => c.url.includes('/pack/')).length, 0)
|
||
assert.equal(hashTreeAt(join(root, FLAT)).hash, before, '正常轮⛔ 不该动产物')
|
||
// 凭据头必须带上(节点身份 = 这台机)
|
||
assert.equal(m.calls[0].init.headers['x-dsh-agent-token'], 'tok')
|
||
assert.equal(m.calls[0].init.headers['x-dsh-host-id'], 'w-1')
|
||
} finally {
|
||
rmSync(d, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('自愈: 首次没有拉取记录 ⇒ 指纹报 `null`("不知道")⇒ 被判 stale 并重拉一次', async () => {
|
||
const d = tmp()
|
||
try {
|
||
const root = join(d, 'bundled-plugins')
|
||
const src = makeTree(join(d, 'src-copy', FLAT))
|
||
const tgz = packDir(join(d, 'src-copy'), FLAT)
|
||
const want = hashTreeAt(src).hash
|
||
makeTree(join(root, FLAT)) // 本机也有这个目录(内容相同),但**没有**拉取记录
|
||
// 事后要清掉:`packDir` 把 tgz 写进了自己的 parent,与共享根无关
|
||
const entries = [{ flat: FLAT, id: '@dsh-local/probe', version: '1.0.0', treeSha256: want, fileCount: 3 }]
|
||
const m = fakeManager({ entries, packs: { [FLAT]: tgz } })
|
||
const out = await syncSharedLayerOnce({
|
||
baseUrl: 'https://manager.test',
|
||
hostId: 'w-1',
|
||
token: 'tok',
|
||
sharedRoot: root,
|
||
fetchImpl: m.fetchImpl,
|
||
})
|
||
// 🔴 关键:**"不知道"必须导致重拉**,⛔ 绝不能被当成"一致"("把未知当已知"的老病)
|
||
assert.equal(JSON.parse(m.calls[0].init.body).inventory[0].treeSha256, null)
|
||
assert.deepEqual(out.stale, [FLAT])
|
||
assert.deepEqual(out.pulled, [FLAT])
|
||
assert.equal(out.ok, true, out.reason ?? '')
|
||
// 记录已落 ⇒ 下一轮就不必再拉
|
||
const state = JSON.parse(readFileSync(join(root, '.node-pull.json'), 'utf8'))
|
||
assert.equal(state.entries[FLAT].treeSha256, want)
|
||
assert.equal(hashTreeAt(join(root, FLAT)).hash, want)
|
||
} finally {
|
||
rmSync(d, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('正常轮: 缺包 ⇒ 拉下来换上,内容与权威逐字一致,并记下指纹(第二轮无差异)', async () => {
|
||
const d = tmp()
|
||
try {
|
||
const root = join(d, 'bundled-plugins')
|
||
mkdirSync(root, { recursive: true })
|
||
const src = makeTree(join(d, 'src-copy', FLAT))
|
||
const tgz = packDir(join(d, 'src-copy'), FLAT)
|
||
const want = hashTreeAt(src).hash
|
||
const entries = [{ flat: FLAT, id: '@dsh-local/probe', version: '1.0.0', treeSha256: want, fileCount: 3 }]
|
||
|
||
const m = fakeManager({ entries, packs: { [FLAT]: tgz } })
|
||
const opts = { baseUrl: 'https://manager.test', hostId: 'w-1', token: 'tok', sharedRoot: root, fetchImpl: m.fetchImpl }
|
||
const out = await syncSharedLayerOnce(opts)
|
||
assert.equal(out.ok, true, out.reason ?? '')
|
||
assert.deepEqual(out.pulled, [FLAT])
|
||
assert.equal(hashTreeAt(join(root, FLAT)).hash, want, '换上去的内容必须与权威指纹一致')
|
||
assert.equal(readFileSync(join(root, FLAT, 'README.md'), 'utf8'), '# probe\n')
|
||
// stage 必须清净(⛔ 不留 `.pull-stage-*` 残渣)
|
||
const { readdirSync } = await import('node:fs')
|
||
assert.ok(!readdirSync(root).some((n) => n.startsWith('.pull-stage-')), 'stage 目录必须清干净')
|
||
|
||
// 第二轮:本机已记录指纹 ⇒ 与权威一致 ⇒ 无差异(证明第二轮不会白拉)
|
||
const m2 = fakeManager({ entries, packs: { [FLAT]: tgz } })
|
||
const out2 = await syncSharedLayerOnce({ ...opts, fetchImpl: m2.fetchImpl })
|
||
assert.equal(out2.ok, true, out2.reason ?? '')
|
||
assert.deepEqual(out2.pulled, [])
|
||
assert.deepEqual(out2.missing, [])
|
||
assert.deepEqual(out2.stale, [])
|
||
} finally {
|
||
rmSync(d, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('🔴 反证(最要紧): 指纹不符 ⇒ 报失败,且**现役目录一个字都不动**', async () => {
|
||
const d = tmp()
|
||
try {
|
||
const root = join(d, 'bundled-plugins')
|
||
makeTree(join(root, FLAT))
|
||
writeFileSync(join(root, FLAT, 'MARKER.txt'), 'OLD\n')
|
||
const oldHash = hashTreeAt(join(root, FLAT)).hash
|
||
|
||
// 权威宣称的是"另一个内容" ⇒ 解包后必然对不上
|
||
const src = makeTree(join(d, 'src-copy', FLAT))
|
||
const tgz = packDir(join(d, 'src-copy'), FLAT)
|
||
const entries = [{ flat: FLAT, id: 'probe', version: '1.0.0', treeSha256: 'c'.repeat(64), fileCount: 3 }]
|
||
void src
|
||
|
||
const m = fakeManager({ entries, packs: { [FLAT]: tgz } })
|
||
const out = await syncSharedLayerOnce({
|
||
baseUrl: 'https://manager.test', hostId: 'w-1', token: 'tok', sharedRoot: root, fetchImpl: m.fetchImpl,
|
||
})
|
||
assert.equal(out.ok, false)
|
||
assert.deepEqual(out.pulled, [])
|
||
assert.match(out.failed[0].error, /tree_sha256_mismatch/)
|
||
assert.equal(hashTreeAt(join(root, FLAT)).hash, oldHash, '校验不过⛔ 绝不能换掉现役目录(半包会让实例起不来)')
|
||
assert.equal(readFileSync(join(root, FLAT, 'MARKER.txt'), 'utf8'), 'OLD\n')
|
||
} finally {
|
||
rmSync(d, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('反证: 传输不是 gzip(截断/错误页)⇒ 报失败且不动本地', async () => {
|
||
const d = tmp()
|
||
try {
|
||
const root = join(d, 'bundled-plugins')
|
||
makeTree(join(root, FLAT))
|
||
const before = hashTreeAt(join(root, FLAT)).hash
|
||
const entries = [{ flat: FLAT, id: 'probe', version: '1', treeSha256: SHA_A, fileCount: 3 }]
|
||
const m = fakeManager({ entries, packs: { [FLAT]: Buffer.from('<html>500</html>') } })
|
||
const out = await syncSharedLayerOnce({
|
||
baseUrl: 'https://manager.test', hostId: 'w-1', token: 'tok', sharedRoot: root, fetchImpl: m.fetchImpl,
|
||
})
|
||
assert.equal(out.ok, false)
|
||
assert.equal(out.failed[0].error, 'pack_not_gzip')
|
||
assert.equal(hashTreeAt(join(root, FLAT)).hash, before)
|
||
} finally {
|
||
rmSync(d, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('反证: 产物口 404 ⇒ 报 `not_here`(⛔ 不是"包是空的"),不动本地', async () => {
|
||
const d = tmp()
|
||
try {
|
||
const root = join(d, 'bundled-plugins')
|
||
makeTree(join(root, FLAT))
|
||
const before = hashTreeAt(join(root, FLAT)).hash
|
||
const entries = [{ flat: FLAT, id: 'probe', version: '1', treeSha256: SHA_A, fileCount: 3 }]
|
||
const m = fakeManager({ entries, packs: {} })
|
||
const out = await syncSharedLayerOnce({
|
||
baseUrl: 'https://manager.test', hostId: 'w-1', token: 'tok', sharedRoot: root, fetchImpl: m.fetchImpl,
|
||
})
|
||
assert.equal(out.failed[0].error, 'not_here')
|
||
assert.equal(hashTreeAt(join(root, FLAT)).hash, before)
|
||
} finally {
|
||
rmSync(d, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('🔴 反证: 对账口通信失败 ⇒ 一个本地文件都不许动(⛔ 不许退化成"当没有差异")', async () => {
|
||
const d = tmp()
|
||
try {
|
||
const root = join(d, 'bundled-plugins')
|
||
makeTree(join(root, FLAT))
|
||
const before = hashTreeAt(join(root, FLAT)).hash
|
||
const entries = [{ flat: FLAT, id: 'probe', version: '1', treeSha256: SHA_A, fileCount: 3 }]
|
||
|
||
const m500 = fakeManager({ entries, packs: {}, syncStatus: 500 })
|
||
const out500 = await syncSharedLayerOnce({
|
||
baseUrl: 'https://manager.test', hostId: 'w-1', token: 'tok', sharedRoot: root, fetchImpl: m500.fetchImpl,
|
||
})
|
||
assert.equal(out500.ok, false)
|
||
assert.equal(out500.reason, 'sync_http_500')
|
||
assert.deepEqual(out500.pulled, [])
|
||
|
||
const boom = async () => {
|
||
throw new Error('ECONNREFUSED')
|
||
}
|
||
const outNet = await syncSharedLayerOnce({
|
||
baseUrl: 'https://manager.test', hostId: 'w-1', token: 'tok', sharedRoot: root, fetchImpl: boom,
|
||
})
|
||
assert.equal(outNet.ok, false)
|
||
assert.match(outNet.reason, /sync_unreachable/)
|
||
assert.equal(hashTreeAt(join(root, FLAT)).hash, before)
|
||
// ⛔ 连产物口都不该被请求(没有清单就没有要拉的东西)
|
||
assert.ok(!existsSync(join(root, '.node-pull.json')), '通信失败时⛔ 连记录都别写')
|
||
} finally {
|
||
rmSync(d, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('🔴 反证: 归档里的成员越界(绝对路径 / `../`)⇒ 拒收,且**没写到 stage 之外**', async () => {
|
||
const d = tmp()
|
||
try {
|
||
const root = join(d, 'bundled-plugins')
|
||
mkdirSync(root, { recursive: true })
|
||
// 造一个"恶意"包:成员名字面是 `../escaped.txt`(tar 能装进去)
|
||
const evil = join(d, 'evil')
|
||
mkdirSync(evil, { recursive: true })
|
||
writeFileSync(join(evil, 'escaped.txt'), 'PWNED\n')
|
||
execFileSync('tar', ['-czf', 'evil.tgz', '--transform', 's|escaped.txt|../escaped.txt|', 'escaped.txt'], {
|
||
cwd: evil,
|
||
stdio: 'pipe',
|
||
})
|
||
const evilTgz = readFileSync(join(evil, 'evil.tgz'))
|
||
const entries = [{ flat: FLAT, id: 'probe', version: '1', treeSha256: SHA_A, fileCount: 1 }]
|
||
const m = fakeManager({ entries, packs: { [FLAT]: evilTgz } })
|
||
const out = await syncSharedLayerOnce({
|
||
baseUrl: 'https://manager.test', hostId: 'w-1', token: 'tok', sharedRoot: root, fetchImpl: m.fetchImpl,
|
||
})
|
||
assert.equal(out.ok, false)
|
||
assert.equal(out.failed[0].error, 'pack_member_out_of_scope')
|
||
assert.ok(!existsSync(join(root, FLAT)), '越界包⛔ 不许落盘')
|
||
assert.ok(!existsSync(join(root, 'escaped.txt')), '越界内容⛔ 不许逃逸出 stage')
|
||
} finally {
|
||
rmSync(d, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('🔴 反证: 节点上"多出来"的目录 ⇒ 只上报,⛔ 平台绝不删', async () => {
|
||
const d = tmp()
|
||
try {
|
||
const root = join(d, 'bundled-plugins')
|
||
mkdirSync(join(root, 'node-own-dir'), { recursive: true })
|
||
writeFileSync(join(root, 'node-own-dir', 'keep.txt'), 'DO NOT DELETE\n')
|
||
const entries = [] // 权威清单为空 ⇒ 节点上那个目录就是 `extra`
|
||
const m = fakeManager({ entries, packs: {} })
|
||
const out = await syncSharedLayerOnce({
|
||
baseUrl: 'https://manager.test', hostId: 'w-1', token: 'tok', sharedRoot: root, fetchImpl: m.fetchImpl,
|
||
})
|
||
assert.deepEqual(out.extra, ['node-own-dir'])
|
||
assert.ok(existsSync(join(root, 'node-own-dir', 'keep.txt')), 'extra 只告知,⛔ 删错不可逆')
|
||
assert.equal(readFileSync(join(root, 'node-own-dir', 'keep.txt'), 'utf8'), 'DO NOT DELETE\n')
|
||
} finally {
|
||
rmSync(d, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('失败项会回报给 Manager(对账面用),⛔ 但不影响"本轮不换盘"的结论', async () => {
|
||
const d = tmp()
|
||
try {
|
||
const root = join(d, 'bundled-plugins')
|
||
makeTree(join(root, FLAT))
|
||
const entries = [{ flat: FLAT, id: 'probe', version: '1', treeSha256: SHA_A, fileCount: 3 }]
|
||
const m = fakeManager({ entries, packs: {} }) // 产物口 404 ⇒ 必失败
|
||
const out = await syncSharedLayerOnce({
|
||
baseUrl: 'https://manager.test', hostId: 'w-1', token: 'tok', sharedRoot: root, fetchImpl: m.fetchImpl,
|
||
})
|
||
assert.equal(out.failed.length, 1)
|
||
const reported = m.calls.find((c) => c.url.endsWith('/node/result'))
|
||
assert.ok(reported !== undefined, '失败项必须回报一次(否则对账面上看不到"拉失败")')
|
||
assert.deepEqual(JSON.parse(reported.init.body).failed, out.failed)
|
||
} finally {
|
||
rmSync(d, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('未配置(共享根不存在)⇒ 直接返回原因,⛔ 不发任何请求', async () => {
|
||
let hit = 0
|
||
const out = await syncSharedLayerOnce({
|
||
baseUrl: 'https://manager.test',
|
||
hostId: 'w-1',
|
||
token: 'tok',
|
||
sharedRoot: join(tmpdir(), 'dshs-definitely-not-here-' + Date.now()),
|
||
fetchImpl: async () => {
|
||
hit += 1
|
||
return new Response('{}')
|
||
},
|
||
})
|
||
assert.equal(out.ok, false)
|
||
assert.equal(out.reason, 'shared_root_missing')
|
||
assert.equal(hit, 0)
|
||
})
|
||
|
||
test('旁挂文件/目录不参与对账(`.manifest.json` / `.store` / 本记录自身)', async () => {
|
||
const d = tmp()
|
||
try {
|
||
const root = join(d, 'bundled-plugins')
|
||
mkdirSync(join(root, '.store'), { recursive: true })
|
||
writeFileSync(join(root, '.manifest.json'), '{}')
|
||
makeTree(join(root, FLAT))
|
||
const entries = [{ flat: FLAT, id: 'probe', version: '1', treeSha256: hashTreeAt(join(root, FLAT)).hash, fileCount: 3 }]
|
||
const m = fakeManager({ entries, packs: {} })
|
||
const out = await syncSharedLayerOnce({
|
||
baseUrl: 'https://manager.test', hostId: 'w-1', token: 'tok', sharedRoot: root, fetchImpl: m.fetchImpl,
|
||
})
|
||
const sent = JSON.parse(m.calls[0].init.body)
|
||
assert.deepEqual(sent.inventory.map((i) => i.flat), [FLAT], '⛔ 别把 .store / .manifest.json 报成"节点有"')
|
||
assert.deepEqual(out.extra, [])
|
||
} finally {
|
||
rmSync(d, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
// ── ③ 台账两条语义缺陷(第 21 棒 · 本线 §5-10 ㈠㈡)────────────────────────────
|
||
//
|
||
// 病根同源:**该写进台账的值,此前来自"别处的副本",而不是盘上那份实体**。
|
||
// ㈠ `version` 取自候选池行 ⇒ 库与实体一漂移,清单就在描述"另一份东西";
|
||
// ㈡ "回滚素材"落的是新包自拷贝 ⇒ 名字与内容都新,**回不到上一版**。
|
||
// ⇒ 本组单测钉住一条口径:**真值一律从盘上现读**(⛔ 不做"看起来对"的近似)。
|
||
|
||
test('台账口径(㈠): `version` 取**盘上实体现读**,⛔ 不信上游传来的池行字段', () => {
|
||
const d = tmp()
|
||
try {
|
||
const pkg = join(d, 'tree')
|
||
makeTree(pkg) // 包内 package.json 写的是 1.0.0
|
||
assert.equal(packageVersionAt(pkg), '1.0.0', '真值 = 包内 package.json 的 version')
|
||
// 最浅优先(与 findPackageJson 同口径)⇒ 嵌套依赖里的版本不算数
|
||
mkdirSync(join(pkg, 'node_modules', 'dep'), { recursive: true })
|
||
writeFileSync(join(pkg, 'node_modules', 'dep', 'package.json'), JSON.stringify({ name: 'dep', version: '0.0.1' }))
|
||
assert.equal(packageVersionAt(pkg), '1.0.0')
|
||
// 读不到 ⇒ null:⛔ 不编造、也⛔ 不向上抛(调用点在装配路径上)
|
||
assert.equal(packageVersionAt(join(d, 'definitely-not-here')), null)
|
||
const bare = join(d, 'bare')
|
||
mkdirSync(bare, { recursive: true })
|
||
writeFileSync(join(bare, 'package.json'), JSON.stringify({ name: 'y' }))
|
||
assert.equal(packageVersionAt(bare), null, '没有 version 字段 ⇒ null')
|
||
writeFileSync(join(bare, 'package.json'), '{ 不是 json')
|
||
assert.equal(packageVersionAt(bare), null, '解析不了 ⇒ null(⛔ 不抛)')
|
||
} finally {
|
||
rmSync(d, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('台账自愈(㈠): 清单与磁盘脱钩 ⇒ 以盘上真值回写三件(version / treeSha256 / fileCount)', () => {
|
||
const d = tmp()
|
||
try {
|
||
const root = join(d, 'bundled-plugins')
|
||
mkdirSync(root, { recursive: true })
|
||
makeTree(join(root, FLAT))
|
||
const real = hashTree(join(root, FLAT))
|
||
const stale = {
|
||
[FLAT]: {
|
||
id: '@dsh-local/probe',
|
||
version: '0.3.9', // ← 与盘上 1.0.0 脱钩(㈠ 的真实现象)
|
||
tgzSha256: SHA_A,
|
||
treeSha256: 'b'.repeat(64),
|
||
fileCount: 999,
|
||
backupTgz: '/b/probe.tgz',
|
||
rollbackTgz: '/b/probe-prev.tgz',
|
||
installedAt: 1,
|
||
updatedAt: 1,
|
||
},
|
||
}
|
||
const { manifest, report } = reconcileSharedManifest(root, stale)
|
||
assert.equal(report.checked, 1)
|
||
assert.equal(report.fixed.length, 1, '脱钩必须被认出来')
|
||
assert.deepEqual(report.fixed[0].version, ['0.3.9', '1.0.0'], 'version 取盘上真值')
|
||
assert.deepEqual(report.fixed[0].treeSha256, ['b'.repeat(64), real.hash], 'treeSha256 取现算值')
|
||
assert.deepEqual(report.fixed[0].fileCount, [999, real.fileCount])
|
||
// ⛔ 只动"描述盘上那份东西"的三个字段 —— 其余描述的是**别的事实**,原样保留
|
||
assert.equal(manifest[FLAT].tgzSha256, SHA_A)
|
||
assert.equal(manifest[FLAT].backupTgz, '/b/probe.tgz')
|
||
assert.equal(manifest[FLAT].rollbackTgz, '/b/probe-prev.tgz')
|
||
assert.equal(manifest[FLAT].installedAt, 1)
|
||
// 幂等:拿修好的清单再跑 ⇒ 一处不动(⛔ 不制造无谓写盘 ⇒ 不抖 mtime)
|
||
const again = reconcileSharedManifest(root, manifest)
|
||
assert.deepEqual(again.report.fixed, [])
|
||
assert.equal(again.report.checked, 1)
|
||
} finally {
|
||
rmSync(d, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('台账自愈: 清单有盘上无 ⇒ 只报 missingOnDisk;盘上有清单无 ⇒ 只报 extrasOnDisk(⛔ 双向都不增删)', () => {
|
||
const d = tmp()
|
||
try {
|
||
const root = join(d, 'bundled-plugins')
|
||
mkdirSync(root, { recursive: true })
|
||
makeTree(join(root, FLAT))
|
||
makeTree(join(root, '_dsh-local_extra')) // 盘上有、清单里没有
|
||
mkdirSync(join(root, '.store'), { recursive: true }) // 旁挂 ⇒ ⛔ 不算 extra
|
||
const m = {
|
||
[FLAT]: {
|
||
id: 'probe', version: '1.0.0', tgzSha256: SHA_A,
|
||
treeSha256: hashTree(join(root, FLAT)).hash, fileCount: 3,
|
||
backupTgz: '', installedAt: 1, updatedAt: 1,
|
||
},
|
||
'_dsh-local_ghost': {
|
||
id: 'ghost', version: '9', tgzSha256: SHA_A, treeSha256: 'c'.repeat(64), fileCount: 1,
|
||
backupTgz: '', installedAt: 1, updatedAt: 1,
|
||
},
|
||
}
|
||
const { manifest, report } = reconcileSharedManifest(root, m)
|
||
assert.deepEqual(report.missingOnDisk, ['_dsh-local_ghost'])
|
||
assert.deepEqual(report.fixed, [])
|
||
assert.deepEqual([...report.extrasOnDisk].sort(), ['_dsh-local_extra'], '旁挂文件不算 extra')
|
||
assert.ok(manifest['_dsh-local_ghost'] !== undefined, '⛔ 不许因"盘上没有"就删条目')
|
||
assert.equal(manifest['_dsh-local_extra'], undefined, '⛔ 不许因"盘上有"就新增条目')
|
||
} finally {
|
||
rmSync(d, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('回滚素材(㈡): 命名 = **上一版版本号**,内容 = 旧实体,且不含 node_modules', () => {
|
||
const d = tmp()
|
||
try {
|
||
const old = join(d, 'bundled-plugins', FLAT)
|
||
makeTree(old) // 旧实体:包内 version 1.0.0
|
||
// pnpm 铺装的副产品 ⇒ ⛔ 不进素材(否则素材膨胀、回滚重放还带垃圾)
|
||
mkdirSync(join(old, 'node_modules', 'big'), { recursive: true })
|
||
writeFileSync(join(old, 'node_modules', 'big', 'package.json'), JSON.stringify({ name: 'big', version: '9.9.9' }))
|
||
writeFileSync(join(old, 'node_modules', 'big', 'blob.js'), 'x'.repeat(4096))
|
||
|
||
const ver = packageVersionAt(old)
|
||
assert.equal(ver, '1.0.0', '素材名必须来自**旧实体自己**的版本')
|
||
const asset = rollbackAssetPath(join(d, 'backups'), FLAT, ver)
|
||
assert.equal(asset, join(d, 'backups', 'plugins', FLAT, '1.0.0.tgz'))
|
||
mkdirSync(dirname(asset), { recursive: true })
|
||
packSharedTree(old, asset)
|
||
assert.ok(existsSync(asset) && statSync(asset).size > 0)
|
||
|
||
// 解包验证:① 名实相符(包内 version ≡ 文件名)② node_modules 不在归档里
|
||
const out = join(d, 'unpack')
|
||
mkdirSync(out, { recursive: true })
|
||
execFileSync('tar', ['-xzf', relative(out, asset), '-C', '.'], { cwd: out, stdio: 'pipe' })
|
||
assert.equal(packageVersionAt(out), ver, '素材内容 = 上一版(名与内容自洽)')
|
||
assert.ok(existsSync(join(out, 'dist', 'index.js')))
|
||
assert.ok(!existsSync(join(out, 'node_modules')), '⛔ node_modules 不进回滚素材')
|
||
assert.equal(hashTree(out).fileCount, 3, '归档内容 ≡ 旧实体减去 node_modules')
|
||
} finally {
|
||
rmSync(d, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('反证: 回滚素材⛔ 不许落在被归档的目录内(否则每次打包都把自己包进去)', () => {
|
||
const d = tmp()
|
||
try {
|
||
const old = join(d, 'bundled-plugins', FLAT)
|
||
makeTree(old)
|
||
assert.throws(() => packSharedTree(old, join(old, 'self.tgz')), /pack_into_self/, '树内落点必须被拒')
|
||
} finally {
|
||
rmSync(d, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
// 层 A(止血)· 2026-09-26「版本管理与包更新机制」
|
||
//
|
||
// 真机事故(D1):`syncSharedLayerOnce` 里**对账与产物下载共用一个 20 s 常量**
|
||
// ⇒ 2.0 MB 包跨云(47↔106 经 relay)拉不完 ⇒ 106 每轮上报
|
||
// `failed:[{flat:'dsh-plugin-mcn-suite', error:'The operation was aborted due to timeout'}]`
|
||
// ⇒ **节点永远停在旧版**。本组用例钉住:两个超时**必须分开**、且都能外置注入。
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
|
||
test('A-1(最要紧): 下载**慢于**对账超时也必须成功 —— 两者共用一个超时就是本事故的病根', async () => {
|
||
const d = tmp()
|
||
try {
|
||
const root = join(d, 'bundled-plugins')
|
||
mkdirSync(root, { recursive: true })
|
||
const src = makeTree(join(d, 'src-copy', FLAT))
|
||
const tgz = packDir(join(d, 'src-copy'), FLAT)
|
||
const want = hashTreeAt(src).hash
|
||
const entries = [{ flat: FLAT, id: 'probe', version: '1.0.0', treeSha256: want, fileCount: 3 }]
|
||
const m = fakeManager({ entries, packs: { [FLAT]: tgz } })
|
||
const base = m.fetchImpl
|
||
/** 产物口故意慢:比对账超时(40 ms)慢一个数量级。 */
|
||
const slow = async (url, init) => {
|
||
if (String(url).includes('/pack/')) await new Promise((r) => setTimeout(r, 150))
|
||
return base(url, init)
|
||
}
|
||
// ⚠️ 用**老名字** `timeoutMs` 传 40 ms:这正是改动前"对账与下载共用一个常量"的形状。
|
||
// ⇒ 若两个超时没有真正分家,这里必然 abort(用例会红)。
|
||
const out = await syncSharedLayerOnce({
|
||
baseUrl: 'https://manager.test',
|
||
hostId: 'w-1',
|
||
token: 'tok',
|
||
sharedRoot: root,
|
||
fetchImpl: slow,
|
||
timeoutMs: 40,
|
||
retries: 0,
|
||
})
|
||
assert.equal(out.ok, true, out.reason ?? '')
|
||
assert.deepEqual(out.pulled, [FLAT], '下载慢于对账超时 ⛔ 不该被掐断')
|
||
assert.equal(hashTreeAt(join(root, FLAT)).hash, want)
|
||
} finally {
|
||
rmSync(d, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('A-1: `downloadTimeoutMs` **可注入且真的生效** —— 掐断时记成可重试的失败', async () => {
|
||
const d = tmp()
|
||
try {
|
||
const root = join(d, 'bundled-plugins')
|
||
mkdirSync(root, { recursive: true })
|
||
const src = makeTree(join(d, 'src-copy', FLAT))
|
||
const tgz = packDir(join(d, 'src-copy'), FLAT)
|
||
const want = hashTreeAt(src).hash
|
||
const entries = [{ flat: FLAT, id: 'probe', version: '1.0.0', treeSha256: want, fileCount: 3 }]
|
||
const m = fakeManager({ entries, packs: { [FLAT]: tgz } })
|
||
const base = m.fetchImpl
|
||
/** 产物口慢 300 ms,但**尊重 abort**(真 fetch 就是这样)。 */
|
||
const slow = async (url, init) => {
|
||
if (String(url).includes('/pack/')) return slowWithSignal(300, init, () => new Response(tgz, { status: 200 }))
|
||
return base(url, init)
|
||
}
|
||
const out = await syncSharedLayerOnce({
|
||
baseUrl: 'https://manager.test',
|
||
hostId: 'w-1',
|
||
token: 'tok',
|
||
sharedRoot: root,
|
||
fetchImpl: slow,
|
||
downloadTimeoutMs: 30, // 刻意小到必超时
|
||
retries: 0,
|
||
})
|
||
assert.equal(out.ok, false)
|
||
assert.equal(out.failed.length, 1)
|
||
assert.match(out.failed[0].error, /download_timeout/)
|
||
assert.ok(!existsSync(join(root, FLAT)), '超时 ⛔ 不许留半包')
|
||
} finally {
|
||
rmSync(d, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('A-2: `effectiveDownloadTimeout` —— 无提示走基准 / 有提示按最低速率放宽 / 封顶', () => {
|
||
const o = { downloadTimeoutMs: 300_000, maxDownloadTimeoutMs: 900_000, minRateBps: 64 * 1024 }
|
||
// 无提示(NaN / 0 / 负数)⇒ **原样**基准(⛔ 不许凭空放大:那会让真卡住的连接一直占着这一轮)
|
||
assert.equal(effectiveDownloadTimeout(Number.NaN, o), 300_000)
|
||
assert.equal(effectiveDownloadTimeout(0, o), 300_000)
|
||
assert.equal(effectiveDownloadTimeout(-1, o), 300_000)
|
||
// 小包 ⇒ 基准就够(2 MB 在 64 KiB/s 下 ≈ 32 s × 1.5 = 48 s < 300 s)
|
||
assert.equal(effectiveDownloadTimeout(2 * 1024 * 1024, o), 300_000)
|
||
// 大包 ⇒ 按最低速率放宽(100 MB ≈ 1600 s × 1.5 = 2400 s ⇒ 撞上封顶 900 s)
|
||
assert.equal(effectiveDownloadTimeout(100 * 1024 * 1024, o), 900_000)
|
||
// 基准已高于上限 ⇒ 基准优先(⛔ 不许"注入的上限把基准压小")
|
||
assert.equal(
|
||
effectiveDownloadTimeout(1, { downloadTimeoutMs: 1_200_000, maxDownloadTimeoutMs: 900_000, minRateBps: 1 }),
|
||
1_200_000,
|
||
)
|
||
})
|
||
|
||
test('A-3: 瞬态失败会重试;**永久性**错误(404)一次也不重试', async () => {
|
||
const d = tmp()
|
||
try {
|
||
const root = join(d, 'bundled-plugins')
|
||
mkdirSync(root, { recursive: true })
|
||
const src = makeTree(join(d, 'src-copy', FLAT))
|
||
const tgz = packDir(join(d, 'src-copy'), FLAT)
|
||
const want = hashTreeAt(src).hash
|
||
const entries = [{ flat: FLAT, id: 'probe', version: '1.0.0', treeSha256: want, fileCount: 3 }]
|
||
// ① 瞬态:第一次抛(模拟网络抖动 / abort),第二次成功。
|
||
{
|
||
const m = fakeManager({ entries, packs: { [FLAT]: tgz } })
|
||
const base = m.fetchImpl
|
||
let packHits = 0
|
||
const flaky = async (url, init) => {
|
||
if (String(url).includes('/pack/')) {
|
||
packHits++
|
||
if (packHits === 1) throw new Error('ECONNRESET')
|
||
}
|
||
return base(url, init)
|
||
}
|
||
const out = await syncSharedLayerOnce({
|
||
baseUrl: 'https://manager.test', hostId: 'w-1', token: 'tok', sharedRoot: root,
|
||
fetchImpl: flaky, retries: 2, backoffMs: 0,
|
||
})
|
||
assert.equal(out.ok, true, out.reason ?? '')
|
||
assert.deepEqual(out.pulled, [FLAT], '抖动应当自愈(A-3)')
|
||
assert.deepEqual(out.failed, [], '🔴 重试成功后 ⛔ 不许**同时**出现在 failed 里(自相矛盾的读数)')
|
||
assert.equal(packHits, 2, '恰好重试一次')
|
||
}
|
||
/** 每个子用例都从**空节点**起(⛔ 别复用上一段的成果):上一段拉成功后节点侧已记下指纹
|
||
* ⇒ 对账就没有差异、连产物口都不会被请求 ⇒ 用例会"假绿"。 */
|
||
const resetRoot = () => {
|
||
rmSync(join(root, FLAT), { recursive: true, force: true })
|
||
rmSync(join(root, '.node-pull.json'), { force: true })
|
||
}
|
||
// ② 永久:产物口 404 ⇒ ⛔ 一次也不许多试(重试只会同样失败,白等退避)
|
||
{
|
||
resetRoot()
|
||
const m = fakeManager({ entries, packs: {} }) // packs 空 ⇒ 404 not_here
|
||
let packHits = 0
|
||
const base = m.fetchImpl
|
||
const counting = async (url, init) => {
|
||
if (String(url).includes('/pack/')) packHits++
|
||
return base(url, init)
|
||
}
|
||
const out = await syncSharedLayerOnce({
|
||
baseUrl: 'https://manager.test', hostId: 'w-1', token: 'tok', sharedRoot: root,
|
||
fetchImpl: counting, retries: 5, backoffMs: 0,
|
||
})
|
||
assert.equal(out.failed[0].error, 'not_here')
|
||
assert.equal(packHits, 1, '404 是永久性错误 ⇒ ⛔ 不重试')
|
||
}
|
||
// ③ 指纹不符同样是永久性错误(重拉同一份包只会得到同一个 hex)
|
||
{
|
||
resetRoot()
|
||
const m = fakeManager({ entries: [{ ...entries[0], treeSha256: 'c'.repeat(64) }], packs: { [FLAT]: tgz } })
|
||
let packHits = 0
|
||
const base = m.fetchImpl
|
||
const counting = async (url, init) => {
|
||
if (String(url).includes('/pack/')) packHits++
|
||
return base(url, init)
|
||
}
|
||
const out = await syncSharedLayerOnce({
|
||
baseUrl: 'https://manager.test', hostId: 'w-1', token: 'tok', sharedRoot: root,
|
||
fetchImpl: counting, retries: 5, backoffMs: 0,
|
||
})
|
||
assert.match(out.failed[0].error, /tree_sha256_mismatch/)
|
||
assert.equal(packHits, 1, '指纹不符 ⇒ ⛔ 不重试')
|
||
}
|
||
} finally {
|
||
rmSync(d, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('A-4: 失败留痕 —— 尝试次数 + 首/末失败时刻(答得出"连续失败几次")', async () => {
|
||
const d = tmp()
|
||
try {
|
||
const root = join(d, 'bundled-plugins')
|
||
mkdirSync(root, { recursive: true })
|
||
const entries = [{ flat: FLAT, id: 'probe', version: '1', treeSha256: SHA_A, fileCount: 3 }]
|
||
const m = fakeManager({ entries, packs: {} })
|
||
const base = m.fetchImpl
|
||
const alwaysTransient = async (url, init) => {
|
||
if (String(url).includes('/pack/')) throw new Error('The operation was aborted due to timeout')
|
||
return base(url, init)
|
||
}
|
||
const out = await syncSharedLayerOnce({
|
||
baseUrl: 'https://manager.test', hostId: 'w-1', token: 'tok', sharedRoot: root,
|
||
fetchImpl: alwaysTransient, retries: 2, backoffMs: 0,
|
||
})
|
||
assert.equal(out.failed.length, 1)
|
||
const f = out.failed[0]
|
||
assert.match(f.error, /aborted/)
|
||
assert.equal(f.attempts, 3, '首次 + 2 次重试')
|
||
assert.ok(typeof f.firstFailedAt === 'number' && typeof f.lastFailedAt === 'number')
|
||
assert.ok(f.lastFailedAt >= f.firstFailedAt)
|
||
// 对账面照旧拿得到(⛔ 形状没被破坏:`error` 仍是原来的字符串)
|
||
const reported = m.calls.find((c) => c.url.endsWith('/node/result'))
|
||
assert.ok(reported !== undefined)
|
||
assert.deepEqual(JSON.parse(reported.init.body).failed, out.failed)
|
||
} finally {
|
||
rmSync(d, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
// 层 B-1/B-2/B-3 · 版本目录化 / 通道 / 定向钉版
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
|
||
test('B-1(最要紧): 归档解开后的指纹必须**逐字等于**当初现役树的指纹(否则节点无限重拉)', () => {
|
||
const d = tmp()
|
||
try {
|
||
const root = join(d, 'bundled-plugins')
|
||
const live = makeTree(join(root, FLAT))
|
||
// 现役树带上 `node_modules`(`materializeShared` 的副产品)—— 归档**必须包含**它,
|
||
// 因为产物口现打 tar 时也不排除 ⇒ 只有口径一致,解开后的 hex 才等于当初记的那个。
|
||
mkdirSync(join(live, 'node_modules', 'dep'), { recursive: true })
|
||
writeFileSync(join(live, 'node_modules', 'dep', 'index.js'), 'export const d = 1\n')
|
||
const want = hashTree(live).hash
|
||
|
||
const ref = archiveLiveVersion(root, FLAT, live, '1.0.0')
|
||
assert.ok(ref !== null, '归档必须生成')
|
||
assert.equal(ref.treeSha256, want, '归档记的指纹 = 现役现算指纹')
|
||
assert.equal(ref.fileCount, 4, 'package.json + dist/index.js + README.md + node_modules/dep/index.js')
|
||
assert.equal(ref.version, '1.0.0')
|
||
assert.ok(ref.bytes > 0)
|
||
assert.equal(ref.file, join(versionsDirOf(root, FLAT), '1.0.0.tgz'))
|
||
|
||
// 真解开:内容与现役逐字一致 ⇒ 节点侧 `hashTreeAt` 会算出同一个 hex
|
||
const out = join(d, 'unpack')
|
||
mkdirSync(out, { recursive: true })
|
||
execFileSync('tar', ['-xzf', relative(out, ref.file)], { cwd: out, stdio: 'pipe' })
|
||
assert.equal(hashTreeAt(join(out, FLAT)).hash, want, '解开后指纹必须逐字相同')
|
||
assert.ok(existsSync(join(out, FLAT, 'node_modules', 'dep', 'index.js')), '归档含 node_modules(与产物口同口径)')
|
||
|
||
// 幂等:已有归档 ⇒ 不重做(不制造无谓写盘)
|
||
const ref2 = archiveLiveVersion(root, FLAT, live, '1.0.0')
|
||
assert.equal(ref2.tgzSha256, ref.tgzSha256)
|
||
} finally {
|
||
rmSync(d, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('B-1: 版本号要进文件名 ⇒ 不合法一律**拒绝归档**(⛔ 不让它变成路径逃逸)', () => {
|
||
const d = tmp()
|
||
try {
|
||
const root = join(d, 'bundled-plugins')
|
||
const live = makeTree(join(root, FLAT))
|
||
for (const bad of ['../evil', 'a/b', '', '.', '..', 'x'.repeat(80), '-lead']) {
|
||
assert.equal(isSafeVersion(bad), false, `${bad} 不该通过`)
|
||
assert.equal(archiveLiveVersion(root, FLAT, live, bad), null)
|
||
}
|
||
for (const good of ['0.5.1', '1.0.0-rc.1', 'v2', '1.2.3+build.5']) {
|
||
assert.equal(isSafeVersion(good), true, `${good} 该通过`)
|
||
}
|
||
assert.ok(!existsSync(join(root, '..', 'evil.tgz')))
|
||
assert.ok(!existsSync(join(root, 'evil.tgz')))
|
||
} finally {
|
||
rmSync(d, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('B-1 反证: 归档口径与「回滚素材」**不同** —— 素材排除 node_modules,故两者不可互相顶替', () => {
|
||
const d = tmp()
|
||
try {
|
||
const root = join(d, 'bundled-plugins')
|
||
const live = makeTree(join(root, FLAT))
|
||
mkdirSync(join(live, 'node_modules', 'big'), { recursive: true })
|
||
writeFileSync(join(live, 'node_modules', 'big', 'blob.js'), 'x'.repeat(2048))
|
||
const asset = rollbackAssetPath(join(d, 'backups'), FLAT, '1.0.0')
|
||
mkdirSync(dirname(asset), { recursive: true })
|
||
packSharedTree(live, asset)
|
||
const out = join(d, 'unpack')
|
||
mkdirSync(out, { recursive: true })
|
||
execFileSync('tar', ['-xzf', relative(out, asset)], { cwd: out, stdio: 'pipe' })
|
||
assert.ok(!existsSync(join(out, 'node_modules')), '素材含 node_modules ⇒ 就不是素材了')
|
||
assert.notEqual(hashTree(out).hash, hashTree(live).hash, '两者指纹本就不同 ⇒ ⛔ 不可拿素材当归档下发')
|
||
} finally {
|
||
rmSync(d, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('B-1/B-2 端到端: 节点被钉在**历史版本** ⇒ 按 `?version=` 取归档,校验通过并记下版本号', async () => {
|
||
const d = tmp()
|
||
try {
|
||
const root = join(d, 'bundled-plugins')
|
||
mkdirSync(root, { recursive: true })
|
||
const srcOld = makeTree(join(d, 'src-old', FLAT))
|
||
const tgzOld = packDir(join(d, 'src-old'), FLAT)
|
||
const oldHash = hashTreeAt(srcOld).hash
|
||
const srcNew = makeTree(join(d, 'src-new', FLAT))
|
||
writeFileSync(join(srcNew, 'README.md'), '# probe v2\n')
|
||
const tgzNew = packDir(join(d, 'src-new'), FLAT)
|
||
const newHash = hashTreeAt(srcNew).hash
|
||
assert.notEqual(oldHash, newHash)
|
||
// Manager 给这台 host 的**目标形态**:版本 0.5.0(旧版)+ 旧版指纹 ⇒ 与"钉版"同形。
|
||
const entries = [{ flat: FLAT, id: 'probe', version: '0.5.0', treeSha256: oldHash, fileCount: 3 }]
|
||
const m = fakeManager({
|
||
entries,
|
||
packs: { [FLAT]: tgzNew }, // 现役口给的是新版
|
||
liveVersions: { [FLAT]: '0.5.1' }, // 现役 = 0.5.1
|
||
versions: { [FLAT]: { '0.5.0': tgzOld } }, // 历史归档口给的是旧版
|
||
})
|
||
const out = await syncSharedLayerOnce({
|
||
baseUrl: 'https://manager.test', hostId: 'w-106', token: 'tok', sharedRoot: root, fetchImpl: m.fetchImpl,
|
||
})
|
||
assert.equal(out.ok, true, out.reason ?? '')
|
||
assert.deepEqual(out.pulled, [FLAT])
|
||
assert.equal(hashTreeAt(join(root, FLAT)).hash, oldHash, '拿到的必须是**旧版**内容(不是现役的新版)')
|
||
const packCall = m.calls.find((c) => c.url.includes('/pack/'))
|
||
assert.match(packCall.url, /\?version=0\.5\.0$/, '必须带 ?version= 去取,否则拿到的会是现役')
|
||
// 节点把"我拉的是哪个版本"记下来(可观测;下一轮据此判断要不要换)
|
||
const state = JSON.parse(readFileSync(join(root, '.node-pull.json'), 'utf8'))
|
||
assert.equal(state.entries[FLAT].version, '0.5.0')
|
||
assert.equal(state.entries[FLAT].treeSha256, oldHash)
|
||
} finally {
|
||
rmSync(d, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('B-2/B-3: 目标版本解析 —— 钉版 > 通道 > 现役;取不到就**回落并如实标注**', () => {
|
||
const OLD = 'b'.repeat(64)
|
||
const e = {
|
||
version: '0.5.1',
|
||
treeSha256: SHA_A,
|
||
fileCount: 3,
|
||
history: [
|
||
{ version: '0.5.0', treeSha256: OLD, fileCount: 2, tgzSha256: 'c'.repeat(64), file: '/x/0.5.0.tgz', bytes: 10, archivedAt: 1 },
|
||
],
|
||
channel: { canary: '0.5.0', stable: null },
|
||
pinnedHosts: { 'w-106': '0.5.0' },
|
||
}
|
||
// ① 钉版优先于通道
|
||
const t1 = resolveSharedTarget(e, 'w-106', 'stable')
|
||
assert.equal(t1.version, '0.5.0')
|
||
assert.equal(t1.by, 'pin')
|
||
assert.equal(t1.source, 'archive')
|
||
assert.equal(t1.treeSha256, OLD, '必须给**归档那个版本**的指纹,⛔ 不是现役的')
|
||
// ② 通道:canary 走旧版
|
||
const t2 = resolveSharedTarget(e, 'w-47', 'canary')
|
||
assert.equal(t2.version, '0.5.0')
|
||
assert.equal(t2.by, 'channel')
|
||
// ③ stable = null ⇒ 现役
|
||
const t3 = resolveSharedTarget(e, 'w-47', 'stable')
|
||
assert.equal(t3.version, '0.5.1')
|
||
assert.equal(t3.source, 'live')
|
||
// ④ 不认识的通道 ⇒ 现役(⛔ 不报错、也不假装命中)
|
||
const t4 = resolveSharedTarget(e, 'w-47', 'beta')
|
||
assert.equal(t4.version, '0.5.1')
|
||
assert.equal(t4.by, 'live')
|
||
// ⑤ 钉到一个**没有归档**的版本 ⇒ 回落现役 + **如实标注**(⛔ 不许假装解析成功)
|
||
const t5 = resolveSharedTarget({ ...e, pinnedHosts: { 'w-x': '9.9.9' } }, 'w-x', 'stable')
|
||
assert.equal(t5.source, 'live')
|
||
assert.equal(t5.fellBack, 'version_not_archived')
|
||
// ⑥ 钉到**现役版本** ⇒ 走现役(不必走归档)
|
||
const t6 = resolveSharedTarget({ ...e, pinnedHosts: { 'w-y': '0.5.1' } }, 'w-y', 'stable')
|
||
assert.equal(t6.source, 'live')
|
||
assert.equal(t6.by, 'pin')
|
||
assert.equal(t6.fellBack, undefined)
|
||
// ⑦ 可选版本清单 = 现役 ∪ 归档(去重、现役在前)
|
||
assert.deepEqual(availableVersionsOf(e), ['0.5.1', '0.5.0'])
|
||
assert.deepEqual(availableVersionsOf({ version: '1.0.0', treeSha256: SHA_A, fileCount: 0, history: [] }), ['1.0.0'])
|
||
// ⑧ 无声明(存量清单)⇒ 恒现役 —— 行为与加这两个字段之前**完全一致**
|
||
assert.deepEqual(resolveSharedTarget({ version: '1.0.0', treeSha256: SHA_A, fileCount: 0 }, 'h', 'stable'), {
|
||
version: '1.0.0', treeSha256: SHA_A, fileCount: 0, source: 'live', by: 'live',
|
||
})
|
||
assert.equal(historyEntryOf(e, '0.5.0').treeSha256, OLD)
|
||
assert.equal(historyEntryOf(e, '0.5.1'), undefined)
|
||
assert.equal(historyEntryOf({}, '0.5.0'), undefined)
|
||
})
|
||
|
||
test('B-5: 全局矩阵 —— 目标/实报/漂移三类;老快照(无 inventory)⇒ `unknown`,⛔ 不折算成 current', () => {
|
||
const OLD = 'b'.repeat(64)
|
||
const manifest = {
|
||
[FLAT]: {
|
||
id: '@dsh-local/probe',
|
||
version: '0.5.1',
|
||
tgzSha256: 'd'.repeat(64),
|
||
treeSha256: SHA_A,
|
||
fileCount: 3,
|
||
backupTgz: '',
|
||
installedAt: 1,
|
||
updatedAt: 2,
|
||
channel: { canary: '0.5.0' },
|
||
pinnedHosts: { 'w-106': '0.5.0' },
|
||
history: [
|
||
{ version: '0.5.0', treeSha256: OLD, fileCount: 2, tgzSha256: 'c'.repeat(64), file: '/x/a.tgz', bytes: 9, archivedAt: 1 },
|
||
],
|
||
},
|
||
}
|
||
const reports = [
|
||
// ① 新节点:自报清单有 ⇒ 能算漂移(它按钉版拿了旧版 ⇒ current)
|
||
{ hostId: 'w-106', at: 111, authoritative: 1, missing: [], stale: [], extra: [], failed: [], channel: 'stable', inventory: [{ flat: FLAT, treeSha256: OLD }] },
|
||
// ② 新节点但实报与目标不符 ⇒ stale
|
||
{ hostId: 'w-47', at: 222, authoritative: 1, missing: [], stale: [FLAT], extra: [], failed: [{ flat: FLAT, error: 'x' }], channel: 'canary', inventory: [{ flat: FLAT, treeSha256: SHA_A }] },
|
||
// ③ 老节点(改造前的快照):**没有 inventory** ⇒ ⛔ 一律 unknown
|
||
{ hostId: 'w-old', at: 333, authoritative: 1, missing: [], stale: [], extra: [], failed: [], channel: undefined },
|
||
]
|
||
const { hosts, flats } = sharedStatusMatrix(manifest, reports)
|
||
assert.deepEqual(hosts.map((h) => h.hostId), ['w-106', 'w-47', 'w-old'])
|
||
assert.equal(hosts.find((h) => h.hostId === 'w-old').hasInventory, false)
|
||
assert.equal(hosts.find((h) => h.hostId === 'w-47').clean, false, '有 failed ⇒ 不干净')
|
||
assert.equal(hosts.find((h) => h.hostId === 'w-106').clean, true)
|
||
|
||
assert.equal(flats.length, 1)
|
||
const f = flats[0]
|
||
assert.equal(f.flat, FLAT)
|
||
assert.deepEqual(f.versions, ['0.5.1', '0.5.0'])
|
||
assert.equal(f.live.version, '0.5.1')
|
||
const byHost = new Map(f.hosts.map((h) => [h.hostId, h]))
|
||
assert.equal(byHost.get('w-106').target.version, '0.5.0', '按钉版解析')
|
||
assert.equal(byHost.get('w-106').drift, 'current')
|
||
assert.equal(byHost.get('w-47').target.version, '0.5.0', '按 canary 通道解析')
|
||
assert.equal(byHost.get('w-47').drift, 'stale', '实报的是现役内容 ⇒ 与目标(旧版)不符')
|
||
assert.equal(byHost.get('w-old').drift, 'unknown', '⛔ 老快照不许折算成 current')
|
||
// 旁挂目录(`.` 开头)不进矩阵
|
||
const withDot = sharedStatusMatrix({ ...manifest, '.versions': manifest[FLAT] }, [])
|
||
assert.deepEqual(withDot.flats.map((x) => x.flat), [FLAT])
|
||
})
|