@hyzyn/dsh-docker 0.6.4 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +31 -16
- package/README.md +31 -18
- package/client-src/current-session.js +52 -0
- package/client-src/docker.css +2180 -0
- package/client-src/index.js +6691 -0
- package/client-src/session-target.js +92 -0
- package/client.js +46 -24
- package/lib/docker.d.ts +62 -5
- package/lib/docker.js +287 -48
- package/lib/docker.js.map +1 -1
- package/lib/index.d.ts +19 -1
- package/lib/index.js +597 -82
- package/lib/index.js.map +1 -1
- package/lib/ssh-exec.d.ts +30 -11
- package/lib/ssh-exec.js +360 -143
- package/lib/ssh-exec.js.map +1 -1
- package/package.json +4 -2
- package/scripts/build-client.mjs +33 -0
- package/scripts/client-smoke.mjs +1851 -0
- package/scripts/route-smoke.mjs +1091 -0
- package/scripts/smoke.mjs +874 -0
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @hyzyn/dsh-docker — 无 daemon 回归测试(解析器 + 命令构造 + 配置清洗)。
|
|
4
|
+
*
|
|
5
|
+
* 为什么不用真 docker:CI 与开发机不一定有可用 daemon,而本插件最容易出错的
|
|
6
|
+
* 地方恰恰是「CLI 输出解析」与「argv 构造」——两者都能用固定样本离线验证。
|
|
7
|
+
* 需要真 daemon 的端到端验证见 README 的「手工验收」一节。
|
|
8
|
+
*
|
|
9
|
+
* 用法:pnpm --filter @hyzyn/dsh-docker build && node scripts/smoke.mjs
|
|
10
|
+
* (读取 ../lib 的构建产物,确保测的是真正发布出去的代码)
|
|
11
|
+
*/
|
|
12
|
+
import { strict as assert } from 'node:assert'
|
|
13
|
+
|
|
14
|
+
const docker = await import('../lib/docker.js')
|
|
15
|
+
const host = await import('../lib/index.js')
|
|
16
|
+
const exec = await import('../lib/ssh-exec.js')
|
|
17
|
+
|
|
18
|
+
const results = []
|
|
19
|
+
/** 测试注册表:同步与异步用例统一收集,末尾一起 await(异步断言不会被漏掉)。 */
|
|
20
|
+
const pending = []
|
|
21
|
+
/*
|
|
22
|
+
* 看门狗(D71):任一用例挂起不该把整个脚本永久挂住(CI 会一直转到超时被杀)。
|
|
23
|
+
* 两层:单用例 15s(挂起的用例标失败,其余继续跑)+ 全局 90s(兜底,直接退出 1)。
|
|
24
|
+
*/
|
|
25
|
+
// 25s > ssh 建连超时 20s(D122):否则「目标不回 RST」的环境里,
|
|
26
|
+
// 用例会被看门狗报成「用例超时」,把真因盖住
|
|
27
|
+
const CASE_TIMEOUT_MS = 25_000
|
|
28
|
+
const WATCHDOG_MS = 90_000
|
|
29
|
+
const watchdog = setTimeout(() => {
|
|
30
|
+
const last = [...results].reverse().find((result) => result.ok)
|
|
31
|
+
console.error(`\n[dsh-docker] 看门狗超时(${String(WATCHDOG_MS / 1000)}s),脚本挂起;最后通过的用例:${last === undefined ? '(无)' : last.name}`)
|
|
32
|
+
process.exit(1)
|
|
33
|
+
}, WATCHDOG_MS)
|
|
34
|
+
function test(name, fn) {
|
|
35
|
+
pending.push((async () => {
|
|
36
|
+
let timer = null
|
|
37
|
+
try {
|
|
38
|
+
await Promise.race([
|
|
39
|
+
Promise.resolve().then(fn),
|
|
40
|
+
new Promise((_, reject) => {
|
|
41
|
+
timer = setTimeout(() => reject(new Error(`用例超时(${String(CASE_TIMEOUT_MS / 1000)}s)`)), CASE_TIMEOUT_MS)
|
|
42
|
+
}),
|
|
43
|
+
])
|
|
44
|
+
results.push({ name, ok: true })
|
|
45
|
+
} catch (error) {
|
|
46
|
+
results.push({ name, ok: false, message: error instanceof Error ? error.message : String(error) })
|
|
47
|
+
} finally {
|
|
48
|
+
if (timer !== null) clearTimeout(timer)
|
|
49
|
+
}
|
|
50
|
+
})())
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* ------------------------------------------------------------------ *
|
|
54
|
+
* 1. ps 解析
|
|
55
|
+
* ------------------------------------------------------------------ */
|
|
56
|
+
|
|
57
|
+
const PS_SAMPLE = [
|
|
58
|
+
JSON.stringify({
|
|
59
|
+
Command: '"/docker-entrypoint.sh nginx -g \'daemon off;\'"',
|
|
60
|
+
CreatedAt: '2026-09-01 10:00:00 +0800 CST',
|
|
61
|
+
ID: '9f2c1d4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d',
|
|
62
|
+
Image: 'nginx:1.27',
|
|
63
|
+
Labels: 'com.docker.compose.project=shop,com.docker.compose.service=web,maintainer=ops',
|
|
64
|
+
LocalVolumes: '1',
|
|
65
|
+
Mounts: '',
|
|
66
|
+
Names: 'shop-web-1',
|
|
67
|
+
Networks: 'shop_default',
|
|
68
|
+
Ports: '0.0.0.0:8080->80/tcp, [::]:8080->80/tcp',
|
|
69
|
+
RunningFor: '8 days ago',
|
|
70
|
+
Size: '0B',
|
|
71
|
+
State: 'running',
|
|
72
|
+
Status: 'Up 8 days (healthy)',
|
|
73
|
+
}),
|
|
74
|
+
JSON.stringify({
|
|
75
|
+
ID: 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
|
|
76
|
+
Image: 'redis:7-alpine',
|
|
77
|
+
Labels: '',
|
|
78
|
+
Names: 'cache',
|
|
79
|
+
Ports: '6379/tcp',
|
|
80
|
+
State: 'exited',
|
|
81
|
+
Status: 'Exited (137) 2 hours ago',
|
|
82
|
+
}),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
test('parsePsJson:字段映射 / compose 标签 / 端口', () => {
|
|
86
|
+
const rows = docker.parsePsJson(PS_SAMPLE.join('\n'))
|
|
87
|
+
assert.equal(rows.length, 2)
|
|
88
|
+
const [web, cache] = rows
|
|
89
|
+
assert.equal(web.name, 'shop-web-1')
|
|
90
|
+
assert.equal(web.shortId, '9f2c1d4e5a6b')
|
|
91
|
+
assert.equal(web.state, 'running')
|
|
92
|
+
assert.equal(web.health, 'healthy')
|
|
93
|
+
assert.equal(web.composeProject, 'shop')
|
|
94
|
+
assert.equal(web.composeService, 'web')
|
|
95
|
+
assert.deepEqual(web.ports, [
|
|
96
|
+
{ hostIp: '0.0.0.0', hostPort: 8080, containerPort: 80, protocol: 'tcp' },
|
|
97
|
+
{ hostIp: '[::]', hostPort: 8080, containerPort: 80, protocol: 'tcp' },
|
|
98
|
+
])
|
|
99
|
+
assert.equal(cache.state, 'exited')
|
|
100
|
+
assert.equal(cache.composeProject, null)
|
|
101
|
+
assert.deepEqual(cache.ports, [{ containerPort: 6379, protocol: 'tcp' }])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('parsePsJson:State 缺失时从 Status 推导(老版本 docker)', () => {
|
|
105
|
+
const rows = docker.parsePsJson(JSON.stringify({ ID: 'x', Names: 'a', Image: 'i', Status: 'Up 3 minutes (unhealthy)' }))
|
|
106
|
+
assert.equal(rows[0].state, 'running')
|
|
107
|
+
assert.equal(rows[0].health, 'unhealthy')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('parsePsJson:容忍噪声行 / 空输出 / JSON 数组', () => {
|
|
111
|
+
assert.deepEqual(docker.parsePsJson(''), [])
|
|
112
|
+
assert.deepEqual(docker.parsePsJson('WARNING: something\nnot json'), [])
|
|
113
|
+
const arr = docker.parsePsJson(JSON.stringify([{ ID: 'a', Names: 'n', Image: 'i', Status: 'Up 1s' }]))
|
|
114
|
+
assert.equal(arr.length, 1)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('parsePorts:多段映射与去重', () => {
|
|
118
|
+
assert.deepEqual(docker.parsePorts('0.0.0.0:80->80/tcp, 0.0.0.0:80->80/tcp, 443/tcp, 53/udp'), [
|
|
119
|
+
{ hostIp: '0.0.0.0', hostPort: 80, containerPort: 80, protocol: 'tcp' },
|
|
120
|
+
{ containerPort: 443, protocol: 'tcp' },
|
|
121
|
+
{ containerPort: 53, protocol: 'udp' },
|
|
122
|
+
])
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
await test('凭据解析:官方凭据服务优先,缺失/未命中再退回环境变量', async () => {
|
|
126
|
+
const { resolveSecretVia } = exec
|
|
127
|
+
assert.equal(typeof resolveSecretVia, 'function', '缺少 resolveSecretVia(纯核心)')
|
|
128
|
+
const NAME = 'DSH_DOCKER_SMOKE_SECRET'
|
|
129
|
+
delete process.env[NAME]
|
|
130
|
+
let calls = 0
|
|
131
|
+
const provider = (impl) => ({ resolve: async (ref) => { calls += 1; return impl(ref) } })
|
|
132
|
+
|
|
133
|
+
// 1) 非引用原样返回,且**不打扰**凭据服务
|
|
134
|
+
assert.equal(await resolveSecretVia(provider(() => ({ value: 'x' })), '明文密码'), '明文密码')
|
|
135
|
+
assert.equal(calls, 0, '不是 env: 引用就不该去问凭据服务')
|
|
136
|
+
|
|
137
|
+
// 2) 凭据服务命中:用它(这是"直接存在官方凭据存储里就能用"的那条路)
|
|
138
|
+
assert.equal(await resolveSecretVia(provider(() => ({ value: 'from-store' })), 'env:' + NAME), 'from-store')
|
|
139
|
+
|
|
140
|
+
// 3) 服务在、但这个引用没有 → 退回环境变量(老行为不丢)
|
|
141
|
+
process.env[NAME] = 'from-env'
|
|
142
|
+
assert.equal(await resolveSecretVia(provider(() => undefined), 'env:' + NAME), 'from-env')
|
|
143
|
+
|
|
144
|
+
// 4) 服务抛错时**不吞**:环境变量还在就继续用
|
|
145
|
+
assert.equal(await resolveSecretVia(provider(() => { throw new Error('service down') }), 'env:' + NAME), 'from-env')
|
|
146
|
+
|
|
147
|
+
// 5) 两边都没有 → 报错里要能看出「查过凭据服务」与「环境变量也没有」
|
|
148
|
+
delete process.env[NAME]
|
|
149
|
+
await assert.rejects(
|
|
150
|
+
() => resolveSecretVia(provider(() => undefined), 'env:' + NAME),
|
|
151
|
+
(error) => {
|
|
152
|
+
assert.match(error.message, new RegExp(NAME), '错误应点名引用名')
|
|
153
|
+
assert.match(error.message, /环境变量里也没有/, '错误应说清环境变量也查过')
|
|
154
|
+
return true
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
// 6) 服务抛错 + 环境变量也没有 → 错误里要带上服务那边的原因,否则"服务坏了"会伪装成"你没配"
|
|
159
|
+
await assert.rejects(
|
|
160
|
+
() => resolveSecretVia(provider(() => { throw new Error('service down') }), 'env:' + NAME),
|
|
161
|
+
(error) => {
|
|
162
|
+
assert.match(error.message, /凭据服务报错:service down/, '应带上凭据服务的原因')
|
|
163
|
+
return true
|
|
164
|
+
},
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
// 7) 服务不可用(未装 / 老宿主)时退回环境变量;undefined 输入仍是 undefined
|
|
168
|
+
process.env[NAME] = 'from-env'
|
|
169
|
+
assert.equal(await resolveSecretVia(null, 'env:' + NAME), 'from-env')
|
|
170
|
+
assert.equal(await resolveSecretVia(null, undefined), undefined)
|
|
171
|
+
delete process.env[NAME]
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
/* ------------------------------------------------------------------ *
|
|
175
|
+
* 2. stats / images 解析
|
|
176
|
+
* ------------------------------------------------------------------ */
|
|
177
|
+
|
|
178
|
+
test('parseStatsJson:百分比 / 内存 / IO / PIDs', () => {
|
|
179
|
+
const rows = docker.parseStatsJson(JSON.stringify({
|
|
180
|
+
BlockIO: '0B / 12.3MB',
|
|
181
|
+
CPUPerc: '1.25%',
|
|
182
|
+
Container: '9f2c1d4e5a6b',
|
|
183
|
+
ID: '9f2c1d4e5a6b',
|
|
184
|
+
MemPerc: '12.50%',
|
|
185
|
+
MemUsage: '256MiB / 7.66GiB',
|
|
186
|
+
Name: 'shop-web-1',
|
|
187
|
+
NetIO: '1.2kB / 3.4MB',
|
|
188
|
+
PIDs: '7',
|
|
189
|
+
}))
|
|
190
|
+
assert.equal(rows.length, 1)
|
|
191
|
+
const row = rows[0]
|
|
192
|
+
assert.equal(row.cpuPercent, 1.25)
|
|
193
|
+
assert.equal(row.memPercent, 12.5)
|
|
194
|
+
assert.equal(row.memUsed, 256 * 1024 ** 2)
|
|
195
|
+
assert.equal(row.memLimit, Math.round(7.66 * 1024 ** 3))
|
|
196
|
+
assert.equal(row.netRx, 1200)
|
|
197
|
+
assert.equal(row.netTx, 3.4e6)
|
|
198
|
+
assert.equal(row.blockRead, 0)
|
|
199
|
+
assert.equal(row.blockWrite, 12.3e6)
|
|
200
|
+
assert.equal(row.pids, 7)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('parseDockerSize / parsePercent:异常输入返回 null', () => {
|
|
204
|
+
assert.equal(docker.parseDockerSize('12.3MiB'), Math.round(12.3 * 1024 ** 2))
|
|
205
|
+
assert.equal(docker.parseDockerSize('N/A'), null)
|
|
206
|
+
assert.equal(docker.parseDockerSize(''), null)
|
|
207
|
+
assert.equal(docker.parsePercent('0.00%'), 0)
|
|
208
|
+
assert.equal(docker.parsePercent('—'), null)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('parseImagesJson:reference / dangling / 大小', () => {
|
|
212
|
+
const rows = docker.parseImagesJson([
|
|
213
|
+
JSON.stringify({ ID: 'sha256:1111111111111111', Repository: 'nginx', Tag: '1.27', Size: '142MB', CreatedSince: '2 weeks ago', CreatedAt: '2026-08-20 09:00:00 +0800 CST' }),
|
|
214
|
+
JSON.stringify({ ID: 'sha256:2222222222222222', Repository: '<none>', Tag: '<none>', Size: '1.1GB', CreatedSince: '3 months ago' }),
|
|
215
|
+
].join('\n'))
|
|
216
|
+
assert.equal(rows[0].reference, 'nginx:1.27')
|
|
217
|
+
assert.equal(rows[0].shortId, '111111111111')
|
|
218
|
+
assert.equal(rows[0].size, 142e6)
|
|
219
|
+
assert.equal(rows[0].dangling, false)
|
|
220
|
+
assert.equal(rows[1].dangling, true)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
/* ------------------------------------------------------------------ *
|
|
224
|
+
* 3. inspect 解析
|
|
225
|
+
* ------------------------------------------------------------------ */
|
|
226
|
+
|
|
227
|
+
const INSPECT_SAMPLE = JSON.stringify([{
|
|
228
|
+
Id: '9f2c1d4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d',
|
|
229
|
+
Created: '2026-09-01T02:00:00.000000000Z',
|
|
230
|
+
Name: '/shop-web-1',
|
|
231
|
+
Image: 'sha256:deadbeef',
|
|
232
|
+
Platform: 'linux',
|
|
233
|
+
RestartCount: 2,
|
|
234
|
+
State: {
|
|
235
|
+
Status: 'running',
|
|
236
|
+
Running: true,
|
|
237
|
+
Pid: 4242,
|
|
238
|
+
ExitCode: 0,
|
|
239
|
+
StartedAt: '2026-09-01T02:00:01.000000000Z',
|
|
240
|
+
FinishedAt: '0001-01-01T00:00:00Z',
|
|
241
|
+
OOMKilled: false,
|
|
242
|
+
Health: { Status: 'healthy', Log: [{ ExitCode: 0, Output: 'ready\n' }] },
|
|
243
|
+
},
|
|
244
|
+
Config: {
|
|
245
|
+
Image: 'nginx:1.27',
|
|
246
|
+
Cmd: ['nginx', '-g', 'daemon off;'],
|
|
247
|
+
Entrypoint: ['/docker-entrypoint.sh'],
|
|
248
|
+
WorkingDir: '/app',
|
|
249
|
+
User: 'www-data',
|
|
250
|
+
Labels: { 'com.docker.compose.project': 'shop', 'com.docker.compose.service': 'web' },
|
|
251
|
+
},
|
|
252
|
+
HostConfig: { RestartPolicy: { Name: 'unless-stopped' } },
|
|
253
|
+
Mounts: [{ Type: 'bind', Source: '/srv/shop', Destination: '/usr/share/nginx/html', Mode: '', RW: false }],
|
|
254
|
+
NetworkSettings: {
|
|
255
|
+
Ports: { '80/tcp': [{ HostIp: '0.0.0.0', HostPort: '8080' }] },
|
|
256
|
+
Networks: { shop_default: { IPAddress: '172.20.0.3' } },
|
|
257
|
+
},
|
|
258
|
+
}])
|
|
259
|
+
|
|
260
|
+
test('parseInspectJson:状态 / 健康 / 退出码 / 挂载 / 网络 / 端口', () => {
|
|
261
|
+
const details = docker.parseInspectJson(INSPECT_SAMPLE)
|
|
262
|
+
assert.equal(details.length, 1)
|
|
263
|
+
const d = details[0]
|
|
264
|
+
assert.equal(d.name, 'shop-web-1')
|
|
265
|
+
assert.equal(d.state, 'running')
|
|
266
|
+
assert.equal(d.health, 'healthy')
|
|
267
|
+
assert.equal(d.healthLogTail, 'ready')
|
|
268
|
+
assert.equal(d.pid, 4242)
|
|
269
|
+
assert.equal(d.restartCount, 2)
|
|
270
|
+
assert.equal(d.restartPolicy, 'unless-stopped')
|
|
271
|
+
assert.equal(d.command, 'nginx -g daemon off;')
|
|
272
|
+
assert.equal(d.entrypoint, '/docker-entrypoint.sh')
|
|
273
|
+
assert.deepEqual(d.ports, [{ hostIp: '0.0.0.0', hostPort: 8080, containerPort: 80, protocol: 'tcp' }])
|
|
274
|
+
assert.deepEqual(d.mounts, [{ type: 'bind', source: '/srv/shop', destination: '/usr/share/nginx/html', mode: '', readWrite: false }])
|
|
275
|
+
assert.deepEqual(d.networks, [{ name: 'shop_default', ip: '172.20.0.3' }])
|
|
276
|
+
assert.equal(d.composeProject, 'shop')
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
test('parseInspectJson:缺字段不抛异常', () => {
|
|
280
|
+
const details = docker.parseInspectJson(JSON.stringify([{ Id: 'abc', Name: '/x' }]))
|
|
281
|
+
assert.equal(details[0].state, 'unknown')
|
|
282
|
+
assert.equal(details[0].health, null)
|
|
283
|
+
assert.deepEqual(details[0].mounts, [])
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
/* ------------------------------------------------------------------ *
|
|
287
|
+
* 4. 参数校验(防命令注入)
|
|
288
|
+
* ------------------------------------------------------------------ */
|
|
289
|
+
|
|
290
|
+
test('assertRef:拒绝注入尝试,放行合法名', () => {
|
|
291
|
+
assert.equal(docker.assertRef('shop-web-1', 'container'), 'shop-web-1')
|
|
292
|
+
assert.equal(docker.assertRef('a.b_c-1', 'container'), 'a.b_c-1')
|
|
293
|
+
for (const bad of ['web; rm -rf /', 'web$(id)', 'web`id`', '--format', '', ' ', 'web name', 'a/b']) {
|
|
294
|
+
assert.throws(() => docker.assertRef(bad, 'container'), /container/, `应拒绝:${JSON.stringify(bad)}`)
|
|
295
|
+
}
|
|
296
|
+
assert.throws(() => docker.assertRef('a'.repeat(129), 'container'), /过长/)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
test('assertBin:只允许路径安全字符,缺省 docker', () => {
|
|
300
|
+
assert.equal(docker.assertBin(undefined), 'docker')
|
|
301
|
+
assert.equal(docker.assertBin('/usr/local/bin/docker'), '/usr/local/bin/docker')
|
|
302
|
+
assert.equal(docker.assertBin('podman'), 'podman')
|
|
303
|
+
assert.throws(() => docker.assertBin('docker; rm -rf /'), /非法字符/)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
test('shJoin:单引号转义(远程命令不得被拆开)', () => {
|
|
307
|
+
assert.equal(exec.shJoin(['docker', 'ps']), "'docker' 'ps'")
|
|
308
|
+
assert.equal(exec.shJoin(['sh', '-c', "echo 'hi'"]), "'sh' '-c' 'echo '\\''hi'\\'''")
|
|
309
|
+
// 关键:引号内容不会被 shell 解释
|
|
310
|
+
assert.equal(exec.shJoin(['echo', '$(whoami)']), "'echo' '$(whoami)'")
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
/* ------------------------------------------------------------------ *
|
|
314
|
+
* 5. 命令构造(用假 Runner 记录 argv)
|
|
315
|
+
* ------------------------------------------------------------------ */
|
|
316
|
+
|
|
317
|
+
const IMAGE_INSPECT_SAMPLE = JSON.stringify([{
|
|
318
|
+
Id: 'sha256:aaaa1111bbbb2222cccc3333dddd4444eeee5555ffff6666aaaa7777bbbb8888',
|
|
319
|
+
RepoTags: ['nginx:1.27'],
|
|
320
|
+
RepoDigests: ['nginx@sha256:deadbeef'],
|
|
321
|
+
Size: 142000000,
|
|
322
|
+
VirtualSize: 142000000,
|
|
323
|
+
Created: '2026-08-20T09:00:00.123456789Z',
|
|
324
|
+
Architecture: 'amd64',
|
|
325
|
+
Os: 'linux',
|
|
326
|
+
Config: {
|
|
327
|
+
Entrypoint: ['/docker-entrypoint.sh'],
|
|
328
|
+
Cmd: ['nginx', '-g', 'daemon off;'],
|
|
329
|
+
WorkingDir: '/app',
|
|
330
|
+
User: 'www-data',
|
|
331
|
+
ExposedPorts: { '80/tcp': {}, '443/tcp': {} },
|
|
332
|
+
Volumes: { '/data': {} },
|
|
333
|
+
Labels: { maintainer: 'ops' },
|
|
334
|
+
},
|
|
335
|
+
RootFS: { Type: 'layers', Layers: ['sha256:l1', 'sha256:l2', 'sha256:l3'] },
|
|
336
|
+
}])
|
|
337
|
+
const IMAGE_HISTORY_LINE = JSON.stringify({ ID: 'sha256:l3', CreatedSince: '2 weeks ago', CreatedBy: '/bin/sh -c #(nop) CMD ["nginx"]', Size: '0B' })
|
|
338
|
+
|
|
339
|
+
function makeStreamHandlers() {
|
|
340
|
+
return { onStdout: () => {}, onStderr: () => {} }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** 只实现 stream 的假 Runner(stats / pull 流式路径用)。 */
|
|
344
|
+
function fakeStreamApi() {
|
|
345
|
+
const calls = []
|
|
346
|
+
const runner = {
|
|
347
|
+
label: 'fake',
|
|
348
|
+
async run() { throw new Error('run 不应在流式路径被调用') },
|
|
349
|
+
async stream(argv, _handlers, signal) {
|
|
350
|
+
calls.push({ argv: [...argv], ...(signal === undefined ? {} : { signal }) })
|
|
351
|
+
return { code: 0 }
|
|
352
|
+
},
|
|
353
|
+
}
|
|
354
|
+
return { api: new docker.DockerApi(runner, 'docker', { timeoutMs: 1000, maxBytes: 1024 }), calls }
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function fakeApi() {
|
|
358
|
+
const calls = []
|
|
359
|
+
const runner = {
|
|
360
|
+
label: 'fake',
|
|
361
|
+
async run(argv, options) {
|
|
362
|
+
calls.push({ argv: [...argv], options })
|
|
363
|
+
const joined = argv.join(' ')
|
|
364
|
+
if (joined.includes('image inspect')) return { code: 0, stdout: IMAGE_INSPECT_SAMPLE, stderr: '', timedOut: false, truncated: false, durationMs: 1 }
|
|
365
|
+
if (joined.includes('history')) return { code: 0, stdout: IMAGE_HISTORY_LINE, stderr: '', timedOut: false, truncated: false, durationMs: 1 }
|
|
366
|
+
if (joined.includes('image rm')) return { code: 0, stdout: 'Untagged: nginx:1.27\n', stderr: '', timedOut: false, truncated: false, durationMs: 1 }
|
|
367
|
+
if (joined.includes('image prune')) return { code: 0, stdout: 'Total reclaimed space: 1.2GB\n', stderr: '', timedOut: false, truncated: false, durationMs: 1 }
|
|
368
|
+
if (joined.includes('pull ')) return { code: 0, stdout: 'Pull complete\n', stderr: '', timedOut: false, truncated: false, durationMs: 1 }
|
|
369
|
+
if (joined.includes('ps ')) return { code: 0, stdout: PS_SAMPLE.join('\n'), stderr: '', timedOut: false, truncated: false, durationMs: 1 }
|
|
370
|
+
if (joined.includes('inspect')) return { code: 0, stdout: INSPECT_SAMPLE, stderr: '', timedOut: false, truncated: false, durationMs: 1 }
|
|
371
|
+
if (joined.includes('stats')) return { code: 0, stdout: JSON.stringify({ ID: 'a', Name: 'n', CPUPerc: '0.00%', MemUsage: '1MiB / 2GiB', MemPerc: '0.05%', NetIO: '0B / 0B', BlockIO: '0B / 0B', PIDs: '1' }), stderr: '', timedOut: false, truncated: false, durationMs: 1 }
|
|
372
|
+
if (joined.includes('images')) return { code: 0, stdout: JSON.stringify({ ID: 'sha256:1111', Repository: 'nginx', Tag: 'latest', Size: '1MB' }), stderr: '', timedOut: false, truncated: false, durationMs: 1 }
|
|
373
|
+
if (joined.includes('version')) return { code: 0, stdout: '27.3.1\n', stderr: '', timedOut: false, truncated: false, durationMs: 1 }
|
|
374
|
+
return { code: 0, stdout: 'ok\n', stderr: '', timedOut: false, truncated: false, durationMs: 1 }
|
|
375
|
+
},
|
|
376
|
+
}
|
|
377
|
+
return { api: new docker.DockerApi(runner, 'docker', { timeoutMs: 1000, maxBytes: 1024 }), calls }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
test('DockerApi:listContainers 构造 argv 并解析', async () => {
|
|
381
|
+
const { api, calls } = fakeApi()
|
|
382
|
+
const rows = await api.listContainers(true)
|
|
383
|
+
assert.deepEqual(calls[0].argv, ['docker', 'ps', '-a', '--no-trunc', '--format', '{{json .}}'])
|
|
384
|
+
assert.equal(rows.length, 2)
|
|
385
|
+
const rows2 = await (async () => {
|
|
386
|
+
const second = fakeApi()
|
|
387
|
+
const list = await second.api.listContainers(false)
|
|
388
|
+
assert.deepEqual(second.calls[0].argv, ['docker', 'ps', '--no-trunc', '--format', '{{json .}}'])
|
|
389
|
+
return list
|
|
390
|
+
})()
|
|
391
|
+
assert.equal(rows2.length, 2)
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
test('DockerApi:logs 参数映射(tail / timestamps / since)', async () => {
|
|
395
|
+
const { api, calls } = fakeApi()
|
|
396
|
+
await api.logs('web', { tail: 5000, timestamps: true, since: '10m' })
|
|
397
|
+
assert.deepEqual(calls[0].argv, ['docker', 'logs', '--tail', '5000', '--timestamps', '--since', '10m', 'web'])
|
|
398
|
+
// tail 越界被夹紧到 5000
|
|
399
|
+
const second = fakeApi()
|
|
400
|
+
await second.api.logs('web', { tail: 999999 })
|
|
401
|
+
assert.deepEqual(second.calls[0].argv, ['docker', 'logs', '--tail', '5000', 'web'])
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
test('DockerApi:action 用 rm(不带 -f),exec 经 sh -c', async () => {
|
|
405
|
+
const { api, calls } = fakeApi()
|
|
406
|
+
await api.action({ action: 'remove', id: 'web' })
|
|
407
|
+
assert.deepEqual(calls[0].argv, ['docker', 'rm', 'web'])
|
|
408
|
+
await api.action({ action: 'restart', id: 'web' })
|
|
409
|
+
assert.deepEqual(calls[1].argv, ['docker', 'restart', 'web'])
|
|
410
|
+
await api.exec('web', 'ls -la /app', 5000)
|
|
411
|
+
assert.deepEqual(calls[2].argv, ['docker', 'exec', 'web', 'sh', '-c', 'ls -la /app'])
|
|
412
|
+
assert.equal(calls[2].options.timeoutMs, 5000)
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
test('DockerApi:probe 成功/失败两种路径', async () => {
|
|
416
|
+
const ok = fakeApi()
|
|
417
|
+
const probeOk = await ok.api.probe()
|
|
418
|
+
assert.equal(probeOk.ok, true)
|
|
419
|
+
assert.equal(probeOk.serverVersion, '27.3.1')
|
|
420
|
+
|
|
421
|
+
const failing = new docker.DockerApi({
|
|
422
|
+
label: 'fake',
|
|
423
|
+
async run() {
|
|
424
|
+
return { code: 1, stdout: '', stderr: 'Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?', timedOut: false, truncated: false, durationMs: 1 }
|
|
425
|
+
},
|
|
426
|
+
}, 'docker', { timeoutMs: 1000, maxBytes: 1024 })
|
|
427
|
+
const probeFail = await failing.probe()
|
|
428
|
+
assert.equal(probeFail.ok, false)
|
|
429
|
+
assert.match(probeFail.error, /Cannot connect to the Docker daemon/)
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
test('DockerApi:listContainers 失败时抛出可读错误', async () => {
|
|
433
|
+
const failing = new docker.DockerApi({
|
|
434
|
+
label: 'prod',
|
|
435
|
+
async run() {
|
|
436
|
+
return { code: 1, stdout: '', stderr: 'permission denied while trying to connect to the Docker daemon socket', timedOut: false, truncated: false, durationMs: 1 }
|
|
437
|
+
},
|
|
438
|
+
}, 'docker', { timeoutMs: 1000, maxBytes: 1024 })
|
|
439
|
+
await assert.rejects(() => failing.listContainers(false), /permission denied/)
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
/* ------------------------------------------------------------------ *
|
|
443
|
+
* 5.5 DockerApi.attention:口径与排序(0.15.0)
|
|
444
|
+
* ------------------------------------------------------------------ */
|
|
445
|
+
|
|
446
|
+
test('attention:OOM 优先、同级按最近结束时间倒序、非零退出入列', async () => {
|
|
447
|
+
const psRows = [
|
|
448
|
+
JSON.stringify({ ID: 'old1', Names: 'old-exit', Image: 'app:1', State: 'exited', Status: 'Exited (143) 3 months ago' }),
|
|
449
|
+
JSON.stringify({ ID: 'new2', Names: 'recent-exit', Image: 'app:1', State: 'exited', Status: 'Exited (1) 2 minutes ago' }),
|
|
450
|
+
JSON.stringify({ ID: 'oom3', Names: 'oom-worker', Image: 'app:1', State: 'exited', Status: 'Exited (137) 1 hour ago' }),
|
|
451
|
+
JSON.stringify({ ID: 'ok4', Names: 'healthy', Image: 'app:1', State: 'running', Status: 'Up 3 days (healthy)' }),
|
|
452
|
+
]
|
|
453
|
+
const inspect = (ids) => JSON.stringify(ids.map((id) => ({
|
|
454
|
+
Id: id,
|
|
455
|
+
Name: '/' + id,
|
|
456
|
+
Image: 'sha256:x',
|
|
457
|
+
RestartCount: id === 'oom3' ? 5 : 0,
|
|
458
|
+
State: {
|
|
459
|
+
Status: 'exited',
|
|
460
|
+
ExitCode: id === 'oom3' ? 137 : (id === 'new2' ? 1 : 143),
|
|
461
|
+
OOMKilled: id === 'oom3',
|
|
462
|
+
StartedAt: '2026-09-01T00:00:00Z',
|
|
463
|
+
FinishedAt: id === 'old1' ? '2026-06-01T00:00:00Z' : (id === 'new2' ? '2026-09-09T10:00:00Z' : '2026-09-09T08:00:00Z'),
|
|
464
|
+
},
|
|
465
|
+
Config: { Image: 'app:1' },
|
|
466
|
+
HostConfig: {},
|
|
467
|
+
Mounts: [],
|
|
468
|
+
NetworkSettings: { Ports: {}, Networks: {} },
|
|
469
|
+
})))
|
|
470
|
+
const runner = {
|
|
471
|
+
label: 'fake',
|
|
472
|
+
async run(argv) {
|
|
473
|
+
const joined = argv.join(' ')
|
|
474
|
+
if (joined.includes('ps ')) return { code: 0, stdout: psRows.join('\n'), stderr: '', timedOut: false, truncated: false, durationMs: 1 }
|
|
475
|
+
if (joined.includes('inspect')) {
|
|
476
|
+
const ids = argv.slice(2)
|
|
477
|
+
return { code: 0, stdout: inspect(ids), stderr: '', timedOut: false, truncated: false, durationMs: 1 }
|
|
478
|
+
}
|
|
479
|
+
return { code: 0, stdout: '', stderr: '', timedOut: false, truncated: false, durationMs: 1 }
|
|
480
|
+
},
|
|
481
|
+
}
|
|
482
|
+
const api = new docker.DockerApi(runner, 'docker', { timeoutMs: 1000, maxBytes: 65536 })
|
|
483
|
+
const { items, total, truncated } = await api.attention()
|
|
484
|
+
assert.deepEqual(items.map((item) => item.name), ['oom-worker', 'recent-exit', 'old-exit'], 'OOM 最前,其余按最近结束时间倒序')
|
|
485
|
+
assert.equal(total, 3)
|
|
486
|
+
assert.equal(truncated, false)
|
|
487
|
+
assert.equal(items[0].oomKilled, true)
|
|
488
|
+
assert.equal(items[0].reasons.includes('oom'), true)
|
|
489
|
+
assert.equal(items[0].restartCount, 5)
|
|
490
|
+
assert.equal(items[1].exitCode, 1)
|
|
491
|
+
assert.deepEqual(items[1].reasons, ['exit-nonzero'])
|
|
492
|
+
// 健康的运行中容器不该入列
|
|
493
|
+
assert.equal(items.some((item) => item.name === 'healthy'), false)
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
test('attention:inspect 不可用时退回摘要口径(不抛错)', async () => {
|
|
497
|
+
const runner = {
|
|
498
|
+
label: 'fake',
|
|
499
|
+
async run(argv) {
|
|
500
|
+
const joined = argv.join(' ')
|
|
501
|
+
if (joined.includes('ps ')) {
|
|
502
|
+
return { code: 0, stdout: JSON.stringify({ ID: 'x', Names: 'unhealthy-web', Image: 'app:1', State: 'running', Status: 'Up 1m (unhealthy)' }), stderr: '', timedOut: false, truncated: false, durationMs: 1 }
|
|
503
|
+
}
|
|
504
|
+
return { code: 1, stdout: '', stderr: 'permission denied', timedOut: false, truncated: false, durationMs: 1 }
|
|
505
|
+
},
|
|
506
|
+
}
|
|
507
|
+
const api = new docker.DockerApi(runner, 'docker', { timeoutMs: 1000, maxBytes: 65536 })
|
|
508
|
+
const { items } = await api.attention()
|
|
509
|
+
assert.equal(items.length, 1)
|
|
510
|
+
assert.deepEqual(items[0].reasons, ['unhealthy'])
|
|
511
|
+
assert.equal(items[0].oomKilled, false)
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
test('attention:limit 在过滤 + 排序之后生效,带截断信号(D12)', async () => {
|
|
515
|
+
const psRows = [
|
|
516
|
+
JSON.stringify({ ID: 'oom3', Names: 'oom-worker', Image: 'app:1', State: 'exited', Status: 'Exited (137) 1 hour ago' }),
|
|
517
|
+
...Array.from({ length: 150 }, (_, i) => JSON.stringify({ ID: 'old' + String(i), Names: 'old-exit-' + String(i), Image: 'app:1', State: 'exited', Status: 'Exited (143) 3 months ago' })),
|
|
518
|
+
]
|
|
519
|
+
const runner = {
|
|
520
|
+
label: 'fake',
|
|
521
|
+
async run(argv) {
|
|
522
|
+
const joined = argv.join(' ')
|
|
523
|
+
if (joined.includes('ps ')) return { code: 0, stdout: psRows.join('\n'), stderr: '', timedOut: false, truncated: false, durationMs: 1 }
|
|
524
|
+
if (joined.includes('inspect')) {
|
|
525
|
+
const ids = argv.slice(2)
|
|
526
|
+
return { code: 0, stdout: JSON.stringify(ids.map((id) => ({
|
|
527
|
+
Id: id,
|
|
528
|
+
Name: '/' + id,
|
|
529
|
+
State: { Status: 'exited', ExitCode: id === 'oom3' ? 137 : 143, OOMKilled: id === 'oom3', StartedAt: '2026-09-01T00:00:00Z', FinishedAt: '2026-06-01T00:00:00Z' },
|
|
530
|
+
Config: { Image: 'app:1' },
|
|
531
|
+
HostConfig: {},
|
|
532
|
+
Mounts: [],
|
|
533
|
+
NetworkSettings: { Ports: {}, Networks: {} },
|
|
534
|
+
}))), stderr: '', timedOut: false, truncated: false, durationMs: 1 }
|
|
535
|
+
}
|
|
536
|
+
return { code: 0, stdout: '', stderr: '', timedOut: false, truncated: false, durationMs: 1 }
|
|
537
|
+
},
|
|
538
|
+
}
|
|
539
|
+
const api = new docker.DockerApi(runner, 'docker', { timeoutMs: 1000, maxBytes: 65536 })
|
|
540
|
+
const { items, total, truncated } = await api.attention({ limit: 5 })
|
|
541
|
+
// 旧的实现先切 5 条(ps 顺序)→ oom-worker 根本进不了名单;现在排序后再切
|
|
542
|
+
assert.equal(items[0].name, 'oom-worker')
|
|
543
|
+
assert.equal(items.length, 5)
|
|
544
|
+
assert.equal(total, 151)
|
|
545
|
+
assert.equal(truncated, true)
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
test('attention:crash-loop 补捞——运行中 + 刚启动 + 重启计数高(D11)', async () => {
|
|
549
|
+
const psRows = [
|
|
550
|
+
JSON.stringify({ ID: 'loop1', Names: 'crash-loop', Image: 'app:1', State: 'running', Status: 'Up 4 seconds' }),
|
|
551
|
+
JSON.stringify({ ID: 'ok1', Names: 'healthy', Image: 'app:1', State: 'running', Status: 'Up 3 days (healthy)' }),
|
|
552
|
+
]
|
|
553
|
+
const runner = {
|
|
554
|
+
label: 'fake',
|
|
555
|
+
async run(argv) {
|
|
556
|
+
const joined = argv.join(' ')
|
|
557
|
+
if (joined.includes('ps ')) return { code: 0, stdout: psRows.join('\n'), stderr: '', timedOut: false, truncated: false, durationMs: 1 }
|
|
558
|
+
if (joined.includes('inspect')) {
|
|
559
|
+
const ids = argv.slice(2)
|
|
560
|
+
return { code: 0, stdout: JSON.stringify(ids.map((id) => ({
|
|
561
|
+
Id: id,
|
|
562
|
+
Name: '/' + id,
|
|
563
|
+
RestartCount: id === 'loop1' ? 17 : 0,
|
|
564
|
+
State: { Status: 'running', ExitCode: 0, Pid: 1, StartedAt: new Date().toISOString() },
|
|
565
|
+
Config: { Image: 'app:1' },
|
|
566
|
+
HostConfig: { RestartPolicy: { Name: 'unless-stopped' } },
|
|
567
|
+
Mounts: [],
|
|
568
|
+
NetworkSettings: { Ports: {}, Networks: {} },
|
|
569
|
+
}))), stderr: '', timedOut: false, truncated: false, durationMs: 1 }
|
|
570
|
+
}
|
|
571
|
+
return { code: 0, stdout: '', stderr: '', timedOut: false, truncated: false, durationMs: 1 }
|
|
572
|
+
},
|
|
573
|
+
}
|
|
574
|
+
const api = new docker.DockerApi(runner, 'docker', { timeoutMs: 1000, maxBytes: 65536 })
|
|
575
|
+
const { items } = await api.attention()
|
|
576
|
+
assert.equal(items.length, 1)
|
|
577
|
+
assert.equal(items[0].name, 'crash-loop')
|
|
578
|
+
assert.deepEqual(items[0].reasons, ['restarting'])
|
|
579
|
+
assert.equal(items[0].restartCount, 17)
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
/* ------------------------------------------------------------------ *
|
|
583
|
+
* 6. 配置清洗与目标解析
|
|
584
|
+
* ------------------------------------------------------------------ */
|
|
585
|
+
|
|
586
|
+
test('normalizeConfig:默认只读,数值夹紧', () => {
|
|
587
|
+
const config = host.normalizeConfig({})
|
|
588
|
+
assert.equal(config.allowMutations, false)
|
|
589
|
+
assert.equal(config.allowExec, false)
|
|
590
|
+
assert.equal(config.dockerBin, 'docker')
|
|
591
|
+
assert.equal(config.execTimeoutSec, 30)
|
|
592
|
+
assert.equal(config.pollIntervalSec, 5)
|
|
593
|
+
assert.equal(config.logTailDefault, 200)
|
|
594
|
+
assert.equal(config.maxOutputKb, 512)
|
|
595
|
+
assert.deepEqual(config.targets, [])
|
|
596
|
+
|
|
597
|
+
const clamped = host.normalizeConfig({ execTimeoutSec: 9999, pollIntervalSec: 0, logTailDefault: -3, maxOutputKb: 1e9 })
|
|
598
|
+
assert.equal(clamped.execTimeoutSec, 120)
|
|
599
|
+
assert.equal(clamped.pollIntervalSec, 1)
|
|
600
|
+
assert.equal(clamped.logTailDefault, 1)
|
|
601
|
+
assert.equal(clamped.maxOutputKb, 8192)
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
test('sanitizeTargets:丢弃无名/重名条目,保留合法目标', () => {
|
|
605
|
+
const targets = host.sanitizeTargets([
|
|
606
|
+
{ name: '本机', kind: 'local' },
|
|
607
|
+
{ name: '本机', kind: 'local' },
|
|
608
|
+
{ kind: 'ssh', host: 'h', username: 'u' },
|
|
609
|
+
{ name: 'prod', kind: 'ssh', book: 'prod-a', port: 2222, auth: 'key', keyPath: '~/.ssh/id_ed25519' },
|
|
610
|
+
{ name: 'bad', kind: 'ssh', port: 99999, auth: 'nope' },
|
|
611
|
+
null,
|
|
612
|
+
])
|
|
613
|
+
assert.equal(targets.length, 3)
|
|
614
|
+
assert.deepEqual(targets.map((t) => t.name), ['本机', 'prod', 'bad'])
|
|
615
|
+
assert.equal(targets[1].book, 'prod-a')
|
|
616
|
+
assert.equal(targets[1].port, 2222)
|
|
617
|
+
assert.equal(targets[1].auth, 'key')
|
|
618
|
+
assert.equal(targets[2].port, 22)
|
|
619
|
+
assert.equal(targets[2].auth, 'agent')
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
test('sanitizeHostKeys:接受 fingerprints[] 与旧版单指纹,合并同 host:port,host 小写化', () => {
|
|
623
|
+
const keys = host.sanitizeHostKeys([
|
|
624
|
+
{ host: 'a.example.com', port: 22, fingerprint: 'abc' },
|
|
625
|
+
{ host: 'A.example.com', port: 22, fingerprints: ['def'] },
|
|
626
|
+
{ host: '', fingerprint: 'abc' },
|
|
627
|
+
{ host: 'b', fingerprint: '' },
|
|
628
|
+
{ host: 'c', fingerprints: ['def', 'def'] },
|
|
629
|
+
{ host: 'NAS.example', port: 2222, fingerprints: ['fp1', ''] },
|
|
630
|
+
null,
|
|
631
|
+
])
|
|
632
|
+
assert.deepEqual(keys, [
|
|
633
|
+
{ host: 'a.example.com', port: 22, fingerprints: ['abc', 'def'] },
|
|
634
|
+
{ host: 'c', port: 22, fingerprints: ['def'] },
|
|
635
|
+
{ host: 'nas.example', port: 2222, fingerprints: ['fp1'] },
|
|
636
|
+
])
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
test('mergeHostKeys:并集合并——客户端快照不得整表覆盖既有钉扎(D10)', () => {
|
|
640
|
+
const base = [
|
|
641
|
+
{ host: 'nas.example', port: 22, fingerprints: ['pinned-1', 'pinned-2'] },
|
|
642
|
+
{ host: 'old.local', port: 2222, fingerprints: ['pinned-3'] },
|
|
643
|
+
]
|
|
644
|
+
const merged = host.mergeHostKeys(base, [{ host: 'NAS.example', port: 22, fingerprints: ['pinned-1'] }])
|
|
645
|
+
assert.deepEqual(merged, [
|
|
646
|
+
{ host: 'nas.example', port: 22, fingerprints: ['pinned-1', 'pinned-2'] },
|
|
647
|
+
{ host: 'old.local', port: 2222, fingerprints: ['pinned-3'] },
|
|
648
|
+
])
|
|
649
|
+
// 空表(面板启动竞态拿到的快照)不清空既有记录
|
|
650
|
+
assert.deepEqual(host.mergeHostKeys(base, []), base)
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
test('resolveTarget:本机 / 连接簿命中 / 连接簿缺失 / 内联', () => {
|
|
654
|
+
const books = new Map([['prod-a', { host: '10.0.0.5', port: 2222, username: 'root', auth: 'agent' }]])
|
|
655
|
+
|
|
656
|
+
const local = host.resolveTarget({ name: '本机', kind: 'local' }, books)
|
|
657
|
+
assert.deepEqual(local.resolved, { name: '本机', kind: 'local' })
|
|
658
|
+
|
|
659
|
+
const viaBook = host.resolveTarget({ name: 'prod', kind: 'ssh', book: 'prod-a' }, books)
|
|
660
|
+
assert.deepEqual(viaBook.resolved, { name: 'prod', kind: 'ssh', spec: { host: '10.0.0.5', port: 2222, username: 'root', auth: 'agent' } })
|
|
661
|
+
|
|
662
|
+
const missing = host.resolveTarget({ name: 'prod', kind: 'ssh', book: 'nope' }, books)
|
|
663
|
+
assert.equal(missing.resolved, undefined)
|
|
664
|
+
assert.match(missing.error, /连接簿条目不存在:nope/)
|
|
665
|
+
|
|
666
|
+
const inline = host.resolveTarget({ name: 'inline', kind: 'ssh', host: 'h', username: 'u' }, books)
|
|
667
|
+
assert.deepEqual(inline.resolved, { name: 'inline', kind: 'ssh', spec: { host: 'h', port: 22, username: 'u', auth: 'agent', keyPath: '', password: '', passphrase: '', agentForward: false } })
|
|
668
|
+
|
|
669
|
+
const incomplete = host.resolveTarget({ name: 'x', kind: 'ssh', host: 'h' }, books)
|
|
670
|
+
assert.match(incomplete.error, /缺少 SSH 信息/)
|
|
671
|
+
})
|
|
672
|
+
|
|
673
|
+
test('mergeTargetSecrets:卡片提交不含密码时保留已存凭证', () => {
|
|
674
|
+
const prev = [
|
|
675
|
+
{ name: 'prod', kind: 'ssh', book: '', host: 'h', port: 22, username: 'u', auth: 'password', keyPath: '', password: 'env:SSH_PW', passphrase: 'env:SSH_PP', agentForward: false },
|
|
676
|
+
]
|
|
677
|
+
// 浏览器不回显密码:提交里没有 password/passphrase 字段
|
|
678
|
+
const incoming = [{ name: 'prod', kind: 'ssh', host: 'h2', username: 'u' }]
|
|
679
|
+
const merged = host.mergeTargetSecrets(prev, incoming)
|
|
680
|
+
assert.equal(merged[0].password, 'env:SSH_PW')
|
|
681
|
+
assert.equal(merged[0].passphrase, 'env:SSH_PP')
|
|
682
|
+
assert.equal(merged[0].host, 'h2')
|
|
683
|
+
// 显式空字符串 = 清空(不是「保留」)
|
|
684
|
+
const cleared = host.mergeTargetSecrets(prev, [{ name: 'prod', password: '' }])
|
|
685
|
+
assert.equal(cleared[0].password, '')
|
|
686
|
+
// 新目标没有历史值可继承
|
|
687
|
+
const fresh = host.mergeTargetSecrets(prev, [{ name: 'new', host: 'x' }])
|
|
688
|
+
assert.equal(fresh[0].password, undefined)
|
|
689
|
+
assert.equal(host.mergeTargetSecrets(prev, 'not-an-array'), 'not-an-array')
|
|
690
|
+
})
|
|
691
|
+
|
|
692
|
+
test('mergeTargetSecrets:改名目标按连接身份继承凭证(D15)', () => {
|
|
693
|
+
const prev = [
|
|
694
|
+
{ name: 'prod', kind: 'ssh', book: '', host: 'h', port: 22, username: 'u', auth: 'password', keyPath: '', password: 'env:SSH_PW', passphrase: '', agentForward: false },
|
|
695
|
+
]
|
|
696
|
+
// 改名(prod → prod-1),连接字段不变 → 凭证继承
|
|
697
|
+
const renamed = host.mergeTargetSecrets(prev, [{ name: 'prod-1', kind: 'ssh', host: 'h', port: 22, username: 'u', auth: 'password', keyPath: '' }])
|
|
698
|
+
assert.equal(renamed[0].password, 'env:SSH_PW')
|
|
699
|
+
// 删一个、新建一个无关的(host 不同)→ 不串密码
|
|
700
|
+
const unrelated = host.mergeTargetSecrets(prev, [{ name: 'other', kind: 'ssh', host: 'elsewhere', username: 'u', auth: 'password', keyPath: '' }])
|
|
701
|
+
assert.equal(unrelated[0].password, undefined)
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
/* ------------------------------------------------------------------ *
|
|
705
|
+
* 6.5 镜像详情 / 拉取进度 / 统计流
|
|
706
|
+
* ------------------------------------------------------------------ */
|
|
707
|
+
|
|
708
|
+
test('parseImageInspectJson:元数据 / 层 / 端口 / 标签', () => {
|
|
709
|
+
const rows = docker.parseImageInspectJson(IMAGE_INSPECT_SAMPLE)
|
|
710
|
+
assert.equal(rows.length, 1)
|
|
711
|
+
const d = rows[0]
|
|
712
|
+
assert.equal(d.shortId, 'aaaa1111bbbb')
|
|
713
|
+
assert.deepEqual(d.repoTags, ['nginx:1.27'])
|
|
714
|
+
assert.deepEqual(d.repoDigests, ['nginx@sha256:deadbeef'])
|
|
715
|
+
assert.equal(d.size, 142000000)
|
|
716
|
+
assert.equal(d.layerCount, 3)
|
|
717
|
+
assert.deepEqual(d.layers, ['sha256:l1', 'sha256:l2', 'sha256:l3'])
|
|
718
|
+
assert.equal(d.entrypoint, '/docker-entrypoint.sh')
|
|
719
|
+
assert.equal(d.command, 'nginx -g daemon off;')
|
|
720
|
+
assert.equal(d.workingDir, '/app')
|
|
721
|
+
assert.equal(d.user, 'www-data')
|
|
722
|
+
assert.deepEqual(d.exposedPorts, ['443/tcp', '80/tcp'])
|
|
723
|
+
assert.deepEqual(d.volumes, ['/data'])
|
|
724
|
+
assert.equal(d.labels.maintainer, 'ops')
|
|
725
|
+
// env 刻意不回传(inspect 的 Env 常含密钥)
|
|
726
|
+
assert.equal(Object.hasOwn(d, 'env'), false)
|
|
727
|
+
})
|
|
728
|
+
|
|
729
|
+
test('parseImageInspectJson:缺字段不抛异常(老版本 docker)', () => {
|
|
730
|
+
const rows = docker.parseImageInspectJson(JSON.stringify([{ Id: 'sha256:x', Config: {} }]))
|
|
731
|
+
assert.equal(rows[0].size, null)
|
|
732
|
+
assert.equal(rows[0].virtualSize, null)
|
|
733
|
+
assert.deepEqual(rows[0].layers, [])
|
|
734
|
+
assert.deepEqual(rows[0].repoTags, [])
|
|
735
|
+
assert.equal(rows[0].state, undefined)
|
|
736
|
+
})
|
|
737
|
+
|
|
738
|
+
test('parseImageHistoryJson:命令 / 大小 / shortId', () => {
|
|
739
|
+
const rows = docker.parseImageHistoryJson([
|
|
740
|
+
JSON.stringify({ ID: 'sha256:l3', CreatedSince: '2 weeks ago', CreatedBy: '/bin/sh -c #(nop) CMD ["nginx"]', Size: '0B', Comment: '' }),
|
|
741
|
+
JSON.stringify({ ID: '<missing>', CreatedSince: '2 weeks ago', CreatedBy: '/bin/sh -c #(nop) ADD file:abc in /', Size: '142MB' }),
|
|
742
|
+
].join('\n'))
|
|
743
|
+
assert.equal(rows.length, 2)
|
|
744
|
+
assert.equal(rows[0].shortId, 'l3')
|
|
745
|
+
assert.equal(rows[0].size, 0)
|
|
746
|
+
assert.match(rows[0].createdBy, /CMD/)
|
|
747
|
+
assert.equal(rows[1].id, '<missing>')
|
|
748
|
+
assert.equal(rows[1].size, 142e6)
|
|
749
|
+
})
|
|
750
|
+
|
|
751
|
+
test('parseImageHistoryText:纯文本表格兜底(老 docker 无 --format)', () => {
|
|
752
|
+
const text = [
|
|
753
|
+
'IMAGE CREATED CREATED BY SIZE COMMENT',
|
|
754
|
+
'a2abf6c4d29d 2 weeks ago /bin/sh -c #(nop) CMD ["nginx" "-g" "daemon off;"] 0B',
|
|
755
|
+
'<missing> 2 weeks ago /bin/sh -c #(nop) ADD file:abc in / 142MB',
|
|
756
|
+
].join('\n')
|
|
757
|
+
const rows = docker.parseImageHistoryText(text)
|
|
758
|
+
assert.equal(rows.length, 2)
|
|
759
|
+
assert.equal(rows[0].shortId, 'a2abf6c4d29d')
|
|
760
|
+
assert.equal(rows[0].createdSince, '2 weeks ago')
|
|
761
|
+
assert.equal(rows[0].size, 0)
|
|
762
|
+
assert.match(rows[0].createdBy, /CMD/)
|
|
763
|
+
assert.equal(rows[1].id, '<missing>')
|
|
764
|
+
assert.equal(rows[1].size, 142e6)
|
|
765
|
+
})
|
|
766
|
+
|
|
767
|
+
test('assertImageRef:放行 registry / digest,拒绝 flag 与注入', () => {
|
|
768
|
+
assert.equal(docker.assertImageRef('nginx:1.27', 'image'), 'nginx:1.27')
|
|
769
|
+
assert.equal(docker.assertImageRef('ghcr.io/org/app@sha256:abc', 'image'), 'ghcr.io/org/app@sha256:abc')
|
|
770
|
+
assert.equal(docker.assertImageRef('sha256:aaaa', 'image'), 'sha256:aaaa')
|
|
771
|
+
for (const bad of ['-f', '--force', 'web; rm -rf /', 'a b', '$(id)', '`id`', '', ' ']) {
|
|
772
|
+
assert.throws(() => docker.assertImageRef(bad, 'image'), /image/, '应拒绝:' + JSON.stringify(bad))
|
|
773
|
+
}
|
|
774
|
+
assert.throws(() => docker.assertImageRef('a'.repeat(256), 'image'), /过长/)
|
|
775
|
+
})
|
|
776
|
+
|
|
777
|
+
test('formatBytes:十进制单位(与 docker images 的 SIZE 一致)', () => {
|
|
778
|
+
assert.equal(host.formatBytes(0), '0 B')
|
|
779
|
+
assert.equal(host.formatBytes(999), '999 B')
|
|
780
|
+
assert.equal(host.formatBytes(142000000), '142 MB')
|
|
781
|
+
assert.equal(host.formatBytes(1500000000), '1.5 GB')
|
|
782
|
+
assert.equal(host.formatBytes(Number.NaN), '—')
|
|
783
|
+
})
|
|
784
|
+
|
|
785
|
+
test('DockerApi:imageInspect 用 image inspect + history --format', async () => {
|
|
786
|
+
const { api, calls } = fakeApi()
|
|
787
|
+
const image = await api.imageInspect('nginx:1.27')
|
|
788
|
+
assert.deepEqual(calls[0].argv, ['docker', 'image', 'inspect', 'nginx:1.27'])
|
|
789
|
+
assert.deepEqual(calls[1].argv, ['docker', 'history', '--no-trunc', '--format', '{{json .}}', 'nginx:1.27'])
|
|
790
|
+
assert.equal(image.ref, 'nginx:1.27')
|
|
791
|
+
assert.equal(image.detail.layerCount, 3)
|
|
792
|
+
assert.equal(image.history.length, 1)
|
|
793
|
+
assert.equal(image.historyError, null)
|
|
794
|
+
})
|
|
795
|
+
|
|
796
|
+
test('DockerApi:history --format 失败时退回纯文本表格(老 docker)', async () => {
|
|
797
|
+
const calls = []
|
|
798
|
+
const plain = [
|
|
799
|
+
'IMAGE CREATED CREATED BY SIZE COMMENT',
|
|
800
|
+
'abc123456789 2 weeks ago /bin/sh -c #(nop) CMD ["nginx"] 0B',
|
|
801
|
+
].join('\n')
|
|
802
|
+
const runner = {
|
|
803
|
+
label: 'fake',
|
|
804
|
+
async run(argv) {
|
|
805
|
+
calls.push([...argv])
|
|
806
|
+
const joined = argv.join(' ')
|
|
807
|
+
if (joined.includes('image inspect')) return { code: 0, stdout: IMAGE_INSPECT_SAMPLE, stderr: '', timedOut: false, truncated: false, durationMs: 1 }
|
|
808
|
+
if (joined.includes('--format')) return { code: 1, stdout: '', stderr: 'unknown flag: --format', timedOut: false, truncated: false, durationMs: 1 }
|
|
809
|
+
return { code: 0, stdout: plain, stderr: '', timedOut: false, truncated: false, durationMs: 1 }
|
|
810
|
+
},
|
|
811
|
+
}
|
|
812
|
+
const api = new docker.DockerApi(runner, 'docker', { timeoutMs: 1000, maxBytes: 1024 })
|
|
813
|
+
const image = await api.imageInspect('nginx:1.27')
|
|
814
|
+
assert.equal(image.history.length, 1)
|
|
815
|
+
assert.match(image.history[0].createdBy, /CMD/)
|
|
816
|
+
assert.equal(image.historyError, null)
|
|
817
|
+
// 第三次调用是不带 --format 的纯文本 history
|
|
818
|
+
assert.deepEqual(calls[2], ['docker', 'history', '--no-trunc', 'nginx:1.27'])
|
|
819
|
+
})
|
|
820
|
+
|
|
821
|
+
test('DockerApi:镜像删除 / prune / pull 的 argv(rm 不带 -f)', async () => {
|
|
822
|
+
const { api, calls } = fakeApi()
|
|
823
|
+
const removed = await api.imageRemove('nginx:1.27')
|
|
824
|
+
assert.deepEqual(calls[0].argv, ['docker', 'image', 'rm', 'nginx:1.27'])
|
|
825
|
+
assert.match(removed.message, /Untagged/)
|
|
826
|
+
const pruned = await api.imagePrune()
|
|
827
|
+
assert.deepEqual(calls[1].argv, ['docker', 'image', 'prune', '-f'])
|
|
828
|
+
assert.match(pruned.message, /Total reclaimed space/)
|
|
829
|
+
const pulled = await api.pull('nginx:1.27', 120000)
|
|
830
|
+
assert.deepEqual(calls[2].argv, ['docker', 'pull', 'nginx:1.27'])
|
|
831
|
+
assert.equal(pulled.code, 0)
|
|
832
|
+
assert.match(pulled.text, /Pull complete/)
|
|
833
|
+
})
|
|
834
|
+
|
|
835
|
+
test('DockerApi:statsStream 不带 --no-stream;pullStream 走 stream 通道', async () => {
|
|
836
|
+
const { api, calls } = fakeStreamApi()
|
|
837
|
+
await api.statsStream(['web'], makeStreamHandlers())
|
|
838
|
+
assert.deepEqual(calls[0].argv, ['docker', 'stats', '--format', '{{json .}}', 'web'])
|
|
839
|
+
await api.pullStream('nginx:1.27', makeStreamHandlers())
|
|
840
|
+
assert.deepEqual(calls[1].argv, ['docker', 'pull', 'nginx:1.27'])
|
|
841
|
+
// 快照仍是 --no-stream(两条路径只差这一个 flag)
|
|
842
|
+
const snap = fakeApi()
|
|
843
|
+
await snap.api.stats(['web'])
|
|
844
|
+
assert.deepEqual(snap.calls[0].argv, ['docker', 'stats', '--no-stream', '--format', '{{json .}}', 'web'])
|
|
845
|
+
})
|
|
846
|
+
|
|
847
|
+
test('DockerApi:imageInspect / imageRemove 拒绝非法引用(不触达执行器)', async () => {
|
|
848
|
+
const { api, calls } = fakeApi()
|
|
849
|
+
await assert.rejects(() => api.imageInspect('-f'), /image 含非法字符/)
|
|
850
|
+
await assert.rejects(() => api.imageRemove('nginx; rm -rf /'), /image 含非法字符/)
|
|
851
|
+
await assert.rejects(() => api.pullStream('', makeStreamHandlers()), /image 不能为空/)
|
|
852
|
+
assert.equal(calls.length, 0)
|
|
853
|
+
})
|
|
854
|
+
|
|
855
|
+
/* ------------------------------------------------------------------ *
|
|
856
|
+
* 7. 结果
|
|
857
|
+
* ------------------------------------------------------------------ */
|
|
858
|
+
|
|
859
|
+
await Promise.all(pending)
|
|
860
|
+
|
|
861
|
+
let failed = 0
|
|
862
|
+
for (const result of results) {
|
|
863
|
+
if (result.ok) {
|
|
864
|
+
console.log(` ✓ ${result.name}`)
|
|
865
|
+
} else {
|
|
866
|
+
failed += 1
|
|
867
|
+
console.error(` ✗ ${result.name}\n ${result.message}`)
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
console.log(`\n[dsh-docker] smoke: ${String(results.length - failed)}/${String(results.length)} 通过`)
|
|
871
|
+
clearTimeout(watchdog)
|
|
872
|
+
// 成功路径也要显式退出(D121):主体结束后仍有周期句柄漏着的话,
|
|
873
|
+
// 关掉看门狗就再也没人兜底了 —— 直接 exit 既保证退出也省掉排空等待
|
|
874
|
+
process.exit(failed > 0 ? 1 : 0)
|