@hyzyn/dsh-docker 0.9.5 → 0.10.0

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.
@@ -181,6 +181,8 @@ function makeCtx(config, options = {}) {
181
181
  settingsStored: {},
182
182
  ttyConfig: options.ttyConfig,
183
183
  disposers: [],
184
+ // 宿主日志捕获:info / warn 都进这里(其余宿主侧断言用)
185
+ logs: [],
184
186
  /**
185
187
  * 本插件 entry 的安装级配置(= apply 拿到的 config)。DSH ≥0.1.7 的
186
188
  * `settings.describe()` 返回的是「默认值 ← 组合 base ← 用户层」的 resolved 值,
@@ -202,7 +204,10 @@ function makeCtx(config, options = {}) {
202
204
  return () => {}
203
205
  }
204
206
  const child = {
205
- logger: { info: () => {}, warn: () => {} },
207
+ logger: {
208
+ info: (msg) => { state.logs.push(msg) },
209
+ warn: (msg) => { state.logs.push(msg) },
210
+ },
206
211
  effect: (callback, _name) => {
207
212
  const dispose = callback()
208
213
  state.disposers.push(typeof dispose === 'function' ? dispose : () => {})
@@ -389,9 +394,9 @@ function toolNames() {
389
394
  * 1. 挂载面
390
395
  * ------------------------------------------------------------------ */
391
396
 
392
- await test('挂载:注册 11 个只读 agent 工具', () => {
397
+ await test('挂载:注册 12 个只读 agent 工具', () => {
393
398
  const names = toolNames().sort()
394
- assert.deepEqual(names, ['docker_attention', 'docker_events', 'docker_image_inspect', 'docker_images', 'docker_inspect', 'docker_logs', 'docker_networks', 'docker_ps', 'docker_stats', 'docker_targets', 'docker_volumes'])
399
+ assert.deepEqual(names, ['docker_attention', 'docker_connect_local', 'docker_events', 'docker_image_inspect', 'docker_images', 'docker_inspect', 'docker_logs', 'docker_networks', 'docker_ps', 'docker_stats', 'docker_targets', 'docker_volumes'])
395
400
  })
396
401
 
397
402
  await test('挂载:注册能力公告 section', () => {
@@ -426,7 +431,7 @@ await test('GET /config:凭证脱敏 + 只读默认 + 复用 tty 连接簿名'
426
431
  const direct = config.targets.find((item) => item.name === '直连')
427
432
  assert.equal(direct.passwordSet, true)
428
433
  assert.equal(direct.passphraseSet, false)
429
- assert.deepEqual(config.toolsRegistered.sort(), ['docker_attention', 'docker_events', 'docker_image_inspect', 'docker_images', 'docker_inspect', 'docker_logs', 'docker_networks', 'docker_ps', 'docker_stats', 'docker_targets', 'docker_volumes'])
434
+ assert.deepEqual(config.toolsRegistered.sort(), ['docker_attention', 'docker_connect_local', 'docker_events', 'docker_image_inspect', 'docker_images', 'docker_inspect', 'docker_logs', 'docker_networks', 'docker_ps', 'docker_stats', 'docker_targets', 'docker_volumes'])
430
435
  })
431
436
 
432
437
  await test('POST /config:未知键被拒绝', async () => {
@@ -1042,7 +1047,76 @@ await test('GET /config 以 settings 解析值为准(挂载竞态不再返回
1042
1047
  })
1043
1048
 
1044
1049
  /* ------------------------------------------------------------------ *
1045
- * 7. 禁用与恢复:enabled 热生效(设置卡片路径 + 重启路径)
1050
+ * 7. 一键连接本机:显式、保守、幂等(空 targets 的既有语义不变)
1051
+ * ------------------------------------------------------------------ */
1052
+
1053
+ /*
1054
+ * 单独搭一个「一个目标都没有」的宿主:主 ctx 已经历过 clearTargets / 重写目标,
1055
+ * 借用它会分不清「本来就空」还是「被上一节清掉了」。这条路径要的正是**从来没配过**
1056
+ * 的那份初始状态。
1057
+ */
1058
+ const localOnly = makeCtx({ dockerBin: fakeBin, targets: [] }, { ttyConfig: TTY_CONFIG })
1059
+ host.apply({ ...localOnly.ctx }, { dockerBin: fakeBin, targets: [] })
1060
+ const localRoute = localOnly.state.routes.find((item) => item.path === '/api/dsh-docker')
1061
+ assert.ok(localRoute !== undefined, '未注册 /api/dsh-docker 路由(空目标宿主)')
1062
+
1063
+ async function callOn(route_, method, sub, body) {
1064
+ const res = makeRes()
1065
+ await route_.handler(makeReq(method, '/api/dsh-docker' + sub, body), res)
1066
+ return res
1067
+ }
1068
+
1069
+ await test('POST /connect-local:空目标宿主上一步加好本机目标并选中', async () => {
1070
+ const before = await callOn(localRoute, 'GET', '/targets')
1071
+ assert.deepEqual(before.body.targets, [], '前置条件:这个宿主一个目标都没有')
1072
+ const res = await callOn(localRoute, 'POST', '/connect-local', {})
1073
+ assert.equal(res.status, 200, JSON.stringify(res.body))
1074
+ assert.equal(res.body.result.name, 'local')
1075
+ assert.equal(res.body.result.created, true)
1076
+ assert.equal(res.body.result.saved, true)
1077
+ // 假 CLI 的 version 返回 27.3.1 → daemon 可达,probe 结果随响应一起回来
1078
+ assert.equal(res.body.result.reachable, true)
1079
+ assert.equal(res.body.result.serverVersion, '27.3.1')
1080
+ assert.match(res.body.result.message, /已添加并选中本机目标「local」/)
1081
+ assert.deepEqual(res.body.config.targets.map((item) => item.name), ['local'])
1082
+ assert.deepEqual(localOnly.state.settingsStored.targets, [{ name: 'local', kind: 'local' }])
1083
+ })
1084
+
1085
+ await test('POST /connect-local:再点一次复用(不重复创建、不再写盘)', async () => {
1086
+ const writes = localOnly.state.settingsStored.targets.length
1087
+ const res = await callOn(localRoute, 'POST', '/connect-local', {})
1088
+ assert.equal(res.status, 200)
1089
+ assert.equal(res.body.result.name, 'local')
1090
+ assert.equal(res.body.result.created, false)
1091
+ assert.match(res.body.result.message, /已选中本机目标/)
1092
+ assert.equal(res.body.config.targets.length, writes, '目标数不应增加')
1093
+ })
1094
+
1095
+ await test('POST /connect-local:已有 SSH 目标原样保留,本机目标追加在末尾', async () => {
1096
+ const sshHost = makeCtx({ dockerBin: fakeBin, targets: [{ name: '远程', kind: 'ssh', book: 'prod-a' }] }, { ttyConfig: TTY_CONFIG })
1097
+ host.apply({ ...sshHost.ctx }, { dockerBin: fakeBin, targets: [{ name: '远程', kind: 'ssh', book: 'prod-a' }] })
1098
+ const sshRoute = sshHost.state.routes.find((item) => item.path === '/api/dsh-docker')
1099
+ const res = await callOn(sshRoute, 'POST', '/connect-local', {})
1100
+ assert.equal(res.status, 200)
1101
+ assert.deepEqual(res.body.config.targets.map((item) => item.name), ['远程', 'local'])
1102
+ assert.equal(res.body.config.targets[0].book, 'prod-a', 'SSH 目标的连接簿引用必须一字不动')
1103
+ // 这条路径不需要任何能力授权,也不该顺手打开开关
1104
+ assert.equal(res.body.config.allowMutations, false)
1105
+ assert.equal(res.body.config.allowExec, false)
1106
+ })
1107
+
1108
+ await test('POST /connect-local:GET 405;空数组防丢保护不受影响(clearTargets 语义不变)', async () => {
1109
+ const wrongMethod = await callOn(localRoute, 'GET', '/connect-local')
1110
+ assert.equal(wrongMethod.status, 405)
1111
+ const cleared = await callOn(localRoute, 'POST', '/config', { targets: [] })
1112
+ assert.equal(cleared.status, 200)
1113
+ assert.match(cleared.body.warning, /已忽略空的目标列表/, '未带 clearTargets 的空数组仍被忽略')
1114
+ const after = await callOn(localRoute, 'GET', '/targets')
1115
+ assert.equal(after.body.targets.length, 1)
1116
+ })
1117
+
1118
+ /* ------------------------------------------------------------------ *
1119
+ * 8. 禁用与恢复:enabled 热生效(设置卡片路径 + 重启路径)
1046
1120
  * ------------------------------------------------------------------ */
1047
1121
 
1048
1122
  await test('禁用:POST /config enabled=false → 工具清空、公告撤下、数据路由 403', async () => {
@@ -1116,6 +1190,143 @@ await test('重启路径:settings 命名空间已禁用时挂载 → 挂载即
1116
1190
  assert.equal(config.body.config.enabled, false)
1117
1191
  })
1118
1192
 
1193
+ /* ------------------------------------------------------------------ *
1194
+ * 7.5 能力使用审计(capability-use):过闸的变更 / exec 每次一行
1195
+ * ------------------------------------------------------------------ */
1196
+
1197
+ const USE_PREFIX = '[dsh-docker] capability-use: '
1198
+ /*
1199
+ * 审计行的出口是宿主 stdout(console.log,docker D162)——断言面因此是 console 捕获,
1200
+ * 不是假 ctx 的 logger(那是其余宿主侧断言用的)。捕获行原样放行,不影响人工排障。
1201
+ */
1202
+ const consoleLogs = []
1203
+ const origConsoleLog = console.log.bind(console)
1204
+ console.log = (...args) => {
1205
+ consoleLogs.push(args.map((arg) => String(arg)).join(' '))
1206
+ origConsoleLog(...args)
1207
+ }
1208
+ const useLines = () => consoleLogs.filter((line) => line.startsWith(USE_PREFIX))
1209
+ /** 前段字段无空格;detail 恒在行尾且值可含空格、可含像 key=value 的文本(exec 命令)——从最后一个 ` detail=` 处整段吃下。 */
1210
+ function parseUseLine(line) {
1211
+ const fields = {}
1212
+ const body = line.slice(USE_PREFIX.length)
1213
+ const detailAt = body.lastIndexOf(' detail=')
1214
+ const head = detailAt === -1 ? body : body.slice(0, detailAt)
1215
+ for (const pair of head.split(' ')) {
1216
+ const eq = pair.indexOf('=')
1217
+ if (eq > 0) fields[pair.slice(0, eq)] = pair.slice(eq + 1)
1218
+ }
1219
+ if (detailAt !== -1) fields.detail = body.slice(detailAt + ' detail='.length)
1220
+ return fields
1221
+ }
1222
+ /** 跑一条变更路由,断言它**恰好**产生一行 use 行且指定字段逐一对上。 */
1223
+ async function auditCase(sub, body, expected) {
1224
+ const before = useLines().length
1225
+ const res = await call('POST', sub, body)
1226
+ assert.equal(res.status, 200, `${sub} 前置:路由本身要成功`)
1227
+ const lines = useLines().slice(before)
1228
+ assert.equal(lines.length, 1, `${sub} 应恰好产生一行 use 行,实际 ${String(lines.length)} 行`)
1229
+ const fields = parseUseLine(lines[0])
1230
+ for (const [key, value] of Object.entries(expected)) {
1231
+ assert.equal(fields[key], value, `${sub}: 字段 ${key}`)
1232
+ }
1233
+ assert.equal(fields.target, '本机', `${sub}: target`)
1234
+ assert.equal(fields.ok, 'true', `${sub}: ok`)
1235
+ assert.notEqual(Number(fields.durationMs), NaN, `${sub}: durationMs 应是数字`)
1236
+ return fields
1237
+ }
1238
+
1239
+ await test('审计:八条变更 / exec 路由各恰好一行,字段与操作一一对应', async () => {
1240
+ await auditCase('/images/remove', { target: '本机', ref: 'nginx:1.27' }, { capability: 'allowMutations', source: 'http', action: 'image.remove', ref: 'nginx:1.27' })
1241
+ const prune = await auditCase('/images/prune', { target: '本机' }, { capability: 'allowMutations', source: 'http', action: 'image.prune' })
1242
+ assert.equal('ref' in prune, false, 'prune 无目标:ref 字段应省略')
1243
+ await auditCase('/networks/remove', { target: '本机', name: 'shop_default' }, { capability: 'allowMutations', source: 'http', action: 'network.remove', ref: 'shop_default' })
1244
+ await auditCase('/networks/prune', { target: '本机' }, { capability: 'allowMutations', source: 'http', action: 'network.prune' })
1245
+ await auditCase('/volumes/remove', { target: '本机', name: 'pgdata' }, { capability: 'allowMutations', source: 'http', action: 'volume.remove', ref: 'pgdata' })
1246
+ await auditCase('/volumes/prune', { target: '本机' }, { capability: 'allowMutations', source: 'http', action: 'volume.prune' })
1247
+ await auditCase('/action', { target: '本机', action: 'start', id: 'shop-web-1' }, { capability: 'allowMutations', source: 'http', action: 'container.start', ref: 'shop-web-1' })
1248
+ await auditCase('/exec', { target: '本机', id: 'shop-web-1', command: 'echo hi' }, { capability: 'allowExec', source: 'http', action: 'exec', ref: 'shop-web-1', detail: 'code=0 cmd=echo hi' })
1249
+ })
1250
+
1251
+ await test('审计:pull 流开记 start、收尾记 end(reason / code / durationMs)', async () => {
1252
+ const before = useLines().length
1253
+ const res = await call('GET', '/images/pull/stream?target=本机&ref=nginx:1.27')
1254
+ assert.equal(res.status, 200)
1255
+ const lines = useLines().slice(before)
1256
+ assert.equal(lines.length, 2, 'pull 流应恰好产生 start + end 两行')
1257
+ const start = parseUseLine(lines[0])
1258
+ assert.equal(start.event, 'start')
1259
+ assert.equal(start.action, 'image.pull')
1260
+ assert.equal(start.ref, 'nginx:1.27')
1261
+ assert.equal('ok' in start, false, 'start 行还没有结果:不带 ok')
1262
+ const end = parseUseLine(lines[1])
1263
+ assert.equal(end.event, 'end')
1264
+ assert.equal(end.ok, 'true')
1265
+ assert.equal(end.reason, 'pull-exit')
1266
+ assert.equal(end.code, '0')
1267
+ assert.notEqual(Number(end.durationMs), NaN)
1268
+ })
1269
+
1270
+ await test('审计:只读路由与 /connect-local 产生零 use 行', async () => {
1271
+ const before = useLines().length
1272
+ for (const [method, sub, body] of [
1273
+ ['POST', '/probe', { target: '本机' }],
1274
+ ['POST', '/containers', { target: '本机' }],
1275
+ ['POST', '/inspect', { target: '本机', id: 'shop-web-1' }],
1276
+ ['POST', '/logs', { target: '本机', id: 'shop-web-1' }],
1277
+ ['POST', '/stats', { target: '本机', ids: ['shop-web-1'] }],
1278
+ ['POST', '/images', { target: '本机' }],
1279
+ ['POST', '/networks', { target: '本机' }],
1280
+ ['POST', '/volumes', { target: '本机' }],
1281
+ ['POST', '/connect-local', {}],
1282
+ ]) {
1283
+ const res = await call(method, sub, body)
1284
+ assert.equal(res.status, 200, `${sub} 前置`)
1285
+ }
1286
+ assert.equal(useLines().length - before, 0, '只读操作与 /connect-local 不该产生 use 行')
1287
+ })
1288
+
1289
+ await test('审计:agent 工具 source=tool(不经 HTTP 路由,不与路由重复计数)', async () => {
1290
+ const before = useLines().length
1291
+ const action = state.tools.find((item) => item.name === 'docker_action')
1292
+ assert.ok(action !== undefined)
1293
+ await action.execute({ target: '本机', action: 'stop', id: 'shop-web-1' })
1294
+ const exec = state.tools.find((item) => item.name === 'docker_exec')
1295
+ assert.ok(exec !== undefined)
1296
+ await exec.execute({ target: '本机', id: 'shop-web-1', command: 'ls -la' })
1297
+ const lines = useLines().slice(before)
1298
+ assert.equal(lines.length, 2, '两个工具各恰好一行')
1299
+ const stop = parseUseLine(lines[0])
1300
+ assert.equal(stop.source, 'tool')
1301
+ assert.equal(stop.capability, 'allowMutations')
1302
+ assert.equal(stop.action, 'container.stop')
1303
+ assert.equal(stop.ref, 'shop-web-1')
1304
+ const execLine = parseUseLine(lines[1])
1305
+ assert.equal(execLine.source, 'tool')
1306
+ assert.equal(execLine.capability, 'allowExec')
1307
+ assert.equal(execLine.action, 'exec')
1308
+ assert.equal(execLine.detail, 'code=0 cmd=ls -la')
1309
+ })
1310
+
1311
+ await test('审计:403 不记(使用 = 过了闸);开关恢复后一切照旧', async () => {
1312
+ const before = useLines().length
1313
+ const off = await call('POST', '/config', { allowMutations: false, allowExec: false })
1314
+ assert.equal(off.status, 200)
1315
+ const action = await call('POST', '/action', { target: '本机', action: 'stop', id: 'shop-web-1' })
1316
+ assert.equal(action.status, 403)
1317
+ const exec = await call('POST', '/exec', { target: '本机', id: 'shop-web-1', command: 'ls' })
1318
+ assert.equal(exec.status, 403)
1319
+ const pull = await call('GET', '/images/pull/stream?target=本机&ref=nginx:1.27')
1320
+ assert.equal(pull.status, 403)
1321
+ assert.equal(useLines().length - before, 0, '被闸挡回的请求不算使用')
1322
+
1323
+ // 恢复开关:后续用例(若有)不受影响,工具重新注册
1324
+ const on = await call('POST', '/config', { allowMutations: true, allowExec: true })
1325
+ assert.equal(on.status, 200)
1326
+ assert.equal(toolNames().includes('docker_action'), true)
1327
+ await auditCase('/action', { target: '本机', action: 'start', id: 'shop-web-1' }, { capability: 'allowMutations', source: 'http', action: 'container.start', ref: 'shop-web-1' })
1328
+ })
1329
+
1119
1330
  /* ------------------------------------------------------------------ *
1120
1331
  * 8. 结果
1121
1332
  * ------------------------------------------------------------------ */