@hyzyn/dsh-docker 0.3.3 → 0.4.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.
package/lib/docker.js CHANGED
@@ -1,4 +1,4 @@
1
- import { RemoteExec, runLocal, sshTarget } from './ssh-exec.js';
1
+ import { RemoteExec, runLocal, runLocalStream, sshTarget } from './ssh-exec.js';
2
2
  /* ------------------------------------------------------------------ *
3
3
  * 通用:校验与解析工具
4
4
  * ------------------------------------------------------------------ */
@@ -24,6 +24,44 @@ export function assertBin(value) {
24
24
  throw new Error(`dockerBin 含非法字符:${trimmed}`);
25
25
  return trimmed;
26
26
  }
27
+ /**
28
+ * 镜像引用白名单:比容器名宽松——允许 registry / 仓库路径 / tag / digest
29
+ * (`ghcr.io/foo/bar:1.2`、`sha256:...`、`repo@sha256:...`),但仍拒绝空格、
30
+ * 引号、`;`、`$()`、反引号等 shell 元字符,且**首字符必须是字母数字**——
31
+ * 这样 `-f` / `--force` 这类看起来像 flag 的输入会被直接拒绝,不会被 docker
32
+ * 当成选项解析。命令一律以 argv 数组构造(本机不经 shell、远程经 shJoin 转义),
33
+ * 这层白名单是纵深防御。
34
+ */
35
+ const IMAGE_REF_RE = /^[A-Za-z0-9][A-Za-z0-9_.:/@-]*$/;
36
+ /** 校验一个镜像引用(tag / digest / ID)。不合法直接抛错,绝不拼接进命令。 */
37
+ export function assertImageRef(value, field) {
38
+ if (typeof value !== 'string' || value.trim() === '')
39
+ throw new Error(`${field} 不能为空`);
40
+ const trimmed = value.trim();
41
+ if (trimmed.length > 255)
42
+ throw new Error(`${field} 过长(≤255 字符)`);
43
+ if (!IMAGE_REF_RE.test(trimmed))
44
+ throw new Error(`${field} 含非法字符(仅允许字母、数字与 _ . : / @ -):${trimmed}`);
45
+ return trimmed;
46
+ }
47
+ /**
48
+ * 网络 / 卷名白名单:比镜像引用更窄——docker 的网络名与卷名都不允许 `/` 和 `:`。
49
+ * 刻意**不复用** assertImageRef:那一个为了 registry / digest 放行了 `/:@`,
50
+ * 拿来做这里的校验等于把口径放宽了(比如 `a/b` 会被放行)。
51
+ * 首字符必须是字母数字,`--force` 这类看起来像 flag 的输入在进 argv 前就被拒。
52
+ */
53
+ const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
54
+ /** 校验一个 docker 网络 / 卷名(也是 inspect / rm 的引用)。 */
55
+ export function assertName(value, field) {
56
+ if (typeof value !== 'string' || value.trim() === '')
57
+ throw new Error(`${field} 不能为空`);
58
+ const trimmed = value.trim();
59
+ if (trimmed.length > 128)
60
+ throw new Error(`${field} 过长(≤128 字符)`);
61
+ if (!NAME_RE.test(trimmed))
62
+ throw new Error(`${field} 含非法字符(仅允许字母、数字与 _ . -):${trimmed}`);
63
+ return trimmed;
64
+ }
27
65
  /** 逐行 JSON 解析:兼容 `{{json .}}`(每行一个对象)与整体 JSON 数组。 */
28
66
  export function parseJsonLines(text) {
29
67
  const trimmed = text.trim();
@@ -108,6 +146,10 @@ export function parseIOPair(text) {
108
146
  const [left = '', right = ''] = text.split('/');
109
147
  return { rx: parseDockerSize(left), tx: parseDockerSize(right) };
110
148
  }
149
+ /** docker 失败时的单行摘要:优先 stderr,其次 stdout,最后退出码。 */
150
+ function firstLine(stderr, stdout, code) {
151
+ return (stderr.trim() || stdout.trim() || `退出码 ${String(code)}`).split('\n')[0] ?? `退出码 ${String(code)}`;
152
+ }
111
153
  /** 从 ps 的 `.Status`(`Up 2 hours (healthy)`)推导状态。健康态单独由 deriveHealth 提供。 */
112
154
  export function deriveState(status) {
113
155
  const lower = status.toLowerCase();
@@ -248,6 +290,81 @@ export function parseStatsJson(text) {
248
290
  };
249
291
  });
250
292
  }
293
+ /* ------------------------------------------------------------------ *
294
+ * 事件(docker events)
295
+ * ------------------------------------------------------------------ */
296
+ /**
297
+ * 事件白名单:只放行「容器生命周期」里真正值得刷 UI 的动作。
298
+ *
299
+ * 为什么必须在服务端过滤:docker events 会输出大量噪音(exec_create /
300
+ * exec_start / exec_die 每次 docker exec 三条、attach / detach / resize;
301
+ * network / volume / image 事件已被 --filter type=container 挡掉)。一个跑批的
302
+ * 容器几条 exec 就能把 SSE 帧率顶上去,而客户端收到每一帧都要走一次列表防抖
303
+ * 重取。白名单放服务端,浏览器与 agent 工具(docker_events)拿到的就是同一份。
304
+ */
305
+ const EVENT_ACTIONS = new Set(['start', 'die', 'stop', 'kill', 'oom', 'health_status', 'destroy', 'rename', 'update']);
306
+ /** 取 Actor.Attributes 里的字符串字段(缺失回空串)。 */
307
+ function attr(attributes, key) {
308
+ const value = attributes[key];
309
+ return typeof value === 'string' ? value : '';
310
+ }
311
+ /**
312
+ * 单行 docker events --format '{{json .}}' → ContainerEvent;坏行 / 非白名单动作
313
+ * 返回 null,由调用方丢弃:事件流里混进一条解析不了的行(daemon 版本差异、
314
+ * 被截断的 chunk)不该把整条流掐掉,也不该变成 error 帧。
315
+ *
316
+ * Action 在老版本里叫 status;health_status 的两种写法都要吃:
317
+ * Action: 'health_status: healthy'(新)与 Action: 'health_status'(老)。
318
+ */
319
+ export function parseContainerEvent(line) {
320
+ const text = line.trim();
321
+ if (text === '' || !text.startsWith('{'))
322
+ return null;
323
+ let row;
324
+ try {
325
+ const parsed = JSON.parse(text);
326
+ if (typeof parsed !== 'object' || parsed === null)
327
+ return null;
328
+ row = parsed;
329
+ }
330
+ catch {
331
+ return null;
332
+ }
333
+ const rawAction = (str(row, 'Action') || str(row, 'status')).trim();
334
+ if (rawAction === '')
335
+ return null;
336
+ // 'health_status: healthy' → 用冒号前的基础动作过白名单,完整串留给 UI
337
+ const base = (rawAction.split(':')[0] ?? '').trim().toLowerCase();
338
+ if (!EVENT_ACTIONS.has(base))
339
+ return null;
340
+ const actor = asRecord(row.Actor);
341
+ const attributes = asRecord(actor.Attributes);
342
+ const name = attr(attributes, 'name');
343
+ const exitText = attr(attributes, 'exitCode');
344
+ const exitCode = exitText === '' ? Number.NaN : Number(exitText);
345
+ const timeText = row.time;
346
+ const time = typeof timeText === 'number' && Number.isFinite(timeText) ? timeText : null;
347
+ const project = attributes['com.docker.compose.project'];
348
+ return {
349
+ action: rawAction,
350
+ // 少数事件没有 name(比如容器已删):回落到 Actor.ID 前 12 位,别让活动条出现空名字
351
+ name: name !== '' ? name : str(actor, 'ID').slice(0, 12),
352
+ image: attr(attributes, 'image'),
353
+ composeProject: project === undefined ? null : String(project),
354
+ time,
355
+ exitCode: Number.isFinite(exitCode) ? exitCode : null,
356
+ };
357
+ }
358
+ /** 多行事件输出 → ContainerEvent[](逐行解析,坏行直接丢)。 */
359
+ export function parseEventsJson(text) {
360
+ const out = [];
361
+ for (const line of text.split(/\r?\n/)) {
362
+ const event = parseContainerEvent(line);
363
+ if (event !== null)
364
+ out.push(event);
365
+ }
366
+ return out;
367
+ }
251
368
  /** `docker images --format '{{json .}}'` → ImageSummary[]。 */
252
369
  export function parseImagesJson(text) {
253
370
  return parseJsonLines(text).map((row) => {
@@ -269,6 +386,205 @@ export function parseImagesJson(text) {
269
386
  };
270
387
  });
271
388
  }
389
+ /** `docker image inspect <ref>` 的 JSON 数组 → ImageDetail[]。 */
390
+ export function parseImageInspectJson(text) {
391
+ return parseJsonLines(text).map((row) => {
392
+ const config = asRecord(row.Config);
393
+ const rootfs = asRecord(row.RootFS);
394
+ const labels = asRecord(config.Labels);
395
+ const layers = Array.isArray(rootfs.Layers) ? rootfs.Layers.map(String) : [];
396
+ const id = str(row, 'Id', 'ID');
397
+ const size = typeof row.Size === 'number' ? row.Size : null;
398
+ return {
399
+ id,
400
+ shortId: id.replace(/^sha256:/, '').slice(0, 12),
401
+ repoTags: Array.isArray(row.RepoTags) ? row.RepoTags.map(String) : [],
402
+ repoDigests: Array.isArray(row.RepoDigests) ? row.RepoDigests.map(String) : [],
403
+ size,
404
+ virtualSize: typeof row.VirtualSize === 'number' ? row.VirtualSize : null,
405
+ created: typeof row.Created === 'string' ? row.Created : '',
406
+ architecture: str(row, 'Architecture'),
407
+ os: str(row, 'Os', 'OS'),
408
+ entrypoint: Array.isArray(config.Entrypoint) ? config.Entrypoint.map(String).join(' ') : str(config, 'Entrypoint'),
409
+ command: Array.isArray(config.Cmd) ? config.Cmd.map(String).join(' ') : str(config, 'Cmd'),
410
+ workingDir: typeof config.WorkingDir === 'string' ? config.WorkingDir : '',
411
+ user: typeof config.User === 'string' ? config.User : '',
412
+ exposedPorts: Object.keys(asRecord(config.ExposedPorts)).sort(),
413
+ volumes: Object.keys(asRecord(config.Volumes)).sort(),
414
+ layers,
415
+ layerCount: layers.length,
416
+ // env 刻意不回传:inspect 的 Env 里常含密钥,浏览器与 agent 都不需要
417
+ labels: Object.fromEntries(Object.entries(labels).slice(0, 50).map(([k, v]) => [k, String(v)])),
418
+ };
419
+ });
420
+ }
421
+ /**
422
+ * `docker history --no-trunc --format '{{json .}}'` → ImageHistoryEntry[]。
423
+ * `--format` 只有 Docker ≥ 26 才支持;老版本输出的是纯文本表格,
424
+ * 由 parseImageHistoryText 兜底(调用方先试 JSON)。
425
+ */
426
+ export function parseImageHistoryJson(text) {
427
+ return parseJsonLines(text).map((row) => {
428
+ const id = str(row, 'ID', 'Id');
429
+ const sizeText = str(row, 'Size');
430
+ const tags = str(row, 'Tags');
431
+ return {
432
+ id,
433
+ shortId: id.replace(/^sha256:/, '').slice(0, 12),
434
+ created: str(row, 'CreatedAt'),
435
+ createdSince: str(row, 'CreatedSince'),
436
+ createdBy: str(row, 'CreatedBy'),
437
+ size: parseDockerSize(sizeText),
438
+ sizeText,
439
+ comment: str(row, 'Comment'),
440
+ tags: tags === '' ? [] : tags.split(',').map((tag) => tag.trim()).filter((tag) => tag !== ''),
441
+ };
442
+ });
443
+ }
444
+ /**
445
+ * `docker history --no-trunc` 的纯文本表格兜底解析(老版本 docker 没有 --format)。
446
+ *
447
+ * 表格列以 2 个以上空格对齐,但 **CREATED BY 内部也常出现连续双空格**
448
+ * (`#(nop) CMD`、`#(nop) ADD`),所以不按 `\s{2,}` 盲切:先从右往左定位 SIZE
449
+ * (行内最后一个「数字 + 单位」),再用第一个连续双空格把 CREATED 与 CREATED BY 分开。
450
+ * 这是 best-effort:列宽截断(尾部 `…`)与极端构建命令可能让个别字段不完整,
451
+ * 但不会抛错,也不会把整行吞掉。
452
+ */
453
+ export function parseImageHistoryText(text) {
454
+ const out = [];
455
+ for (const raw of text.split(/\r?\n/)) {
456
+ const line = raw.trimEnd();
457
+ const trimmed = line.trim();
458
+ if (trimmed === '')
459
+ continue;
460
+ if (/^IMAGE\s+(CREATED|CREATED BY)/i.test(trimmed))
461
+ continue;
462
+ const head = /^(\S+)\s+(.+)$/.exec(trimmed);
463
+ if (head === null)
464
+ continue;
465
+ const id = head[1];
466
+ const rest = head[2];
467
+ // SIZE 列:取行内最后一个「数字 + 单位」——构建命令里出现尺寸样式文本的概率远低于 SIZE 列本身
468
+ const sizeRe = /(\d+(?:\.\d+)?)\s*([kKmMgGtTpP]?[bB])/g;
469
+ let last = null;
470
+ let hit;
471
+ while ((hit = sizeRe.exec(rest)) !== null)
472
+ last = hit;
473
+ if (last === null)
474
+ continue;
475
+ const beforeSize = rest.slice(0, last.index).trimEnd();
476
+ const comment = rest.slice(last.index + last[0].length).trim();
477
+ // CREATED 与 CREATED BY 之间是第一个连续双空格(CREATED BY 内部的双空格留给它自己)
478
+ const createdSplit = /\s{2,}/.exec(beforeSize);
479
+ const created = createdSplit === null ? beforeSize : beforeSize.slice(0, createdSplit.index).trim();
480
+ const createdBy = createdSplit === null ? '' : beforeSize.slice(createdSplit.index).trim();
481
+ const sizeText = last[0].trim();
482
+ out.push({
483
+ id,
484
+ shortId: id.replace(/^sha256:/, '').slice(0, 12),
485
+ created: '',
486
+ createdSince: created,
487
+ createdBy,
488
+ size: parseDockerSize(sizeText),
489
+ sizeText,
490
+ comment,
491
+ tags: [],
492
+ });
493
+ }
494
+ return out;
495
+ }
496
+ /* ------------------------------------------------------------------ *
497
+ * 网络 / 卷(docker network ls|inspect、docker volume ls|inspect)
498
+ * ------------------------------------------------------------------ */
499
+ /**
500
+ * docker 的 `ls --format '{{json .}}'` 把布尔值也输出成字符串(`"false"`),
501
+ * 而 `inspect` 里是真 boolean。同一个字段两条路径都要能吃。
502
+ */
503
+ function boolish(value) {
504
+ if (typeof value === 'boolean')
505
+ return value;
506
+ if (typeof value === 'string')
507
+ return value.trim().toLowerCase() === 'true';
508
+ return false;
509
+ }
510
+ /** 对象型字段(inspect 的 Options / Labels)→ 字符串字典。 */
511
+ function stringMap(value) {
512
+ return Object.fromEntries(Object.entries(asRecord(value)).map(([key, item]) => [key, String(item)]));
513
+ }
514
+ /** `docker network ls --format '{{json .}}'` → NetworkSummary[]。 */
515
+ export function parseNetworksJson(text) {
516
+ return parseJsonLines(text).map((row) => {
517
+ const id = str(row, 'ID', 'Id');
518
+ return {
519
+ id,
520
+ shortId: id.slice(0, 12),
521
+ name: str(row, 'Name'),
522
+ driver: str(row, 'Driver'),
523
+ scope: str(row, 'Scope'),
524
+ internal: boolish(row.Internal),
525
+ ipv6: boolish(row.IPv6),
526
+ };
527
+ });
528
+ }
529
+ /** `docker network inspect <name>` 的 JSON 数组 → NetworkDetail[]。 */
530
+ export function parseNetworkInspectJson(text) {
531
+ return parseJsonLines(text).map((row) => {
532
+ const id = str(row, 'Id', 'ID');
533
+ const ipam = asRecord(row.IPAM);
534
+ const config = Array.isArray(ipam.Config) ? ipam.Config : [];
535
+ const containers = asRecord(row.Containers);
536
+ return {
537
+ id,
538
+ shortId: id.slice(0, 12),
539
+ name: str(row, 'Name'),
540
+ driver: str(row, 'Driver'),
541
+ scope: str(row, 'Scope'),
542
+ created: typeof row.Created === 'string' ? row.Created : '',
543
+ internal: boolish(row.Internal),
544
+ attachable: boolish(row.Attachable),
545
+ ingress: boolish(row.Ingress),
546
+ enableIpv6: boolish(row.EnableIPv6),
547
+ subnets: config.map((item) => {
548
+ const entry = asRecord(item);
549
+ return { subnet: typeof entry.Subnet === 'string' ? entry.Subnet : '', gateway: typeof entry.Gateway === 'string' ? entry.Gateway : '' };
550
+ }).filter((entry) => entry.subnet !== '' || entry.gateway !== ''),
551
+ options: stringMap(row.Options),
552
+ labels: stringMap(row.Labels),
553
+ containers: Object.entries(containers).map(([key, value]) => {
554
+ const item = asRecord(value);
555
+ return {
556
+ id: key,
557
+ shortId: key.slice(0, 12),
558
+ name: typeof item.Name === 'string' ? item.Name : '',
559
+ ipv4: typeof item.IPv4Address === 'string' ? item.IPv4Address : '',
560
+ ipv6: typeof item.IPv6Address === 'string' ? item.IPv6Address : '',
561
+ mac: typeof item.MacAddress === 'string' ? item.MacAddress : '',
562
+ };
563
+ }),
564
+ };
565
+ });
566
+ }
567
+ /** `docker volume ls --format '{{json .}}'` → VolumeSummary[]。 */
568
+ export function parseVolumesJson(text) {
569
+ return parseJsonLines(text).map((row) => ({
570
+ name: str(row, 'Name'),
571
+ driver: str(row, 'Driver'),
572
+ scope: str(row, 'Scope'),
573
+ mountpoint: str(row, 'Mountpoint'),
574
+ }));
575
+ }
576
+ /** `docker volume inspect <name>` 的 JSON 数组 → VolumeDetail[]。 */
577
+ export function parseVolumeInspectJson(text) {
578
+ return parseJsonLines(text).map((row) => ({
579
+ name: str(row, 'Name'),
580
+ driver: str(row, 'Driver'),
581
+ scope: str(row, 'Scope'),
582
+ mountpoint: str(row, 'Mountpoint'),
583
+ created: typeof row.CreatedAt === 'string' ? row.CreatedAt : '',
584
+ options: stringMap(row.Options),
585
+ labels: stringMap(row.Labels),
586
+ }));
587
+ }
272
588
  /** `docker inspect <id…>` 的 JSON 数组 → ContainerDetail[]。 */
273
589
  export function parseInspectJson(text) {
274
590
  const parsed = parseJsonLines(text);
@@ -409,10 +725,65 @@ export class DockerApi {
409
725
  }
410
726
  async stats(ids) {
411
727
  const safe = ids.map((id) => assertRef(id, 'container'));
412
- const result = await this.runner.run([this.bin, 'stats', '--no-stream', '--format', '{{json .}}', ...safe], { timeoutMs: Math.max(this.limits.timeoutMs, 20_000), maxBytes: this.limits.maxBytes });
728
+ const result = await this.runner.run(this.statsArgv(safe, false), {
729
+ timeoutMs: Math.max(this.limits.timeoutMs, 20_000),
730
+ maxBytes: this.limits.maxBytes,
731
+ });
413
732
  this.assertOk(result, '读取容器统计');
414
733
  return parseStatsJson(result.stdout);
415
734
  }
735
+ /**
736
+ * 实时统计流:`docker stats`(**不带 --no-stream**)每秒为每个容器输出一行
737
+ * `{{json .}}`。与快照共用 statsArgv 的构造,只差 --no-stream。
738
+ *
739
+ * 与日志流的语义差异:这条流**不会自然结束**——容器一直跑,docker stats 就
740
+ * 一直输出;只有全部被统计的容器退出(或 id 无效)时 docker 才自己退出。
741
+ * 因此「关闭」由浏览器主动断(EventSource.close → res close → abort),
742
+ * 服务端在这条路径上静默中止,不写任何帧。
743
+ */
744
+ async statsStream(ids, handlers, signal) {
745
+ const safe = ids.map((id) => assertRef(id, 'container'));
746
+ return await this.runner.stream(this.statsArgv(safe, true), handlers, signal);
747
+ }
748
+ /** 统计 argv 的唯一构造点:快照与流式只在 --no-stream 上有差异。 */
749
+ statsArgv(ids, stream) {
750
+ return [this.bin, 'stats', ...(stream ? [] : ['--no-stream']), '--format', '{{json .}}', ...ids];
751
+ }
752
+ /**
753
+ * 事件流:docker events 持续输出 JSON 行,**不会自然结束**,关闭由浏览器主动断。
754
+ * 不带 --since:默认只从「现在」开始推,活动条要的是新动静而不是历史回放。
755
+ * 带 --filter type=container 挡掉 network / volume / image 事件。
756
+ */
757
+ async eventsStream(handlers, signal) {
758
+ return await this.runner.stream(this.eventsArgv({}), handlers, signal);
759
+ }
760
+ /**
761
+ * 事件快照(agent 工具用):必须先有 --until 才能让它退出——docker events
762
+ * 只给 --since 时会一直 follow 下去,run() 会挂到超时。这里把 until 取成**请求
763
+ * 时刻的 RFC3339**(不是字符串 'now':docker 的 --until 只认时间戳或时长)。
764
+ */
765
+ async events(since) {
766
+ const result = await this.runner.run(this.eventsArgv({ since, until: new Date().toISOString() }), {
767
+ // 跨 10m 窗口的事件量取决于容器活动量,给足超时但别学流那样无上限
768
+ timeoutMs: Math.max(this.limits.timeoutMs, 30_000),
769
+ maxBytes: this.limits.maxBytes,
770
+ });
771
+ this.assertOk(result, '读取容器事件');
772
+ return parseEventsJson(result.stdout);
773
+ }
774
+ /** 事件 argv 的唯一构造点:流式与快照只差 --since / --until。 */
775
+ eventsArgv(options) {
776
+ const since = options.since === undefined ? '' : options.since.trim();
777
+ const until = options.until === undefined ? '' : options.until.trim();
778
+ return [
779
+ this.bin,
780
+ 'events',
781
+ ...(since === '' ? [] : ['--since', since]),
782
+ ...(until === '' ? [] : ['--until', until]),
783
+ '--format', '{{json .}}',
784
+ '--filter', 'type=container',
785
+ ];
786
+ }
416
787
  async images() {
417
788
  const result = await this.runner.run([this.bin, 'images', '--format', '{{json .}}'], {
418
789
  timeoutMs: this.limits.timeoutMs,
@@ -421,23 +792,247 @@ export class DockerApi {
421
792
  this.assertOk(result, '列出镜像');
422
793
  return parseImagesJson(result.stdout);
423
794
  }
795
+ /**
796
+ * 镜像详情:`docker image inspect`(权威元数据 + 层列表)加上
797
+ * `docker history`(构建历史)。
798
+ *
799
+ * history 走 **两段降级**:先试 `--format '{{json .}}'`(Docker ≥ 26),
800
+ * 老版本会因 unknown flag 失败,再退回纯文本表格;两段都失败只是
801
+ * `historyError` 非空、detail 照常返回——详情页不该因为构建历史取不到就整页报错。
802
+ */
803
+ async imageInspect(ref) {
804
+ const safe = assertImageRef(ref, 'image');
805
+ const result = await this.runner.run([this.bin, 'image', 'inspect', safe], {
806
+ timeoutMs: this.limits.timeoutMs,
807
+ maxBytes: this.limits.maxBytes,
808
+ });
809
+ this.assertOk(result, '读取镜像详情');
810
+ const detail = parseImageInspectJson(result.stdout)[0];
811
+ if (detail === undefined)
812
+ throw new Error(`镜像不存在或输出无法解析:${safe}`);
813
+ let history = [];
814
+ let historyError = null;
815
+ try {
816
+ const jsonAttempt = await this.runner.run([this.bin, 'history', '--no-trunc', '--format', '{{json .}}', safe], {
817
+ timeoutMs: this.limits.timeoutMs,
818
+ maxBytes: this.limits.maxBytes,
819
+ });
820
+ if (jsonAttempt.code === 0) {
821
+ history = parseImageHistoryJson(jsonAttempt.stdout);
822
+ if (history.length === 0)
823
+ history = parseImageHistoryText(jsonAttempt.stdout);
824
+ }
825
+ else {
826
+ // 老 docker 没有 history --format:退回纯文本表格(仍带 --no-trunc 拿完整命令)
827
+ const plainAttempt = await this.runner.run([this.bin, 'history', '--no-trunc', safe], {
828
+ timeoutMs: this.limits.timeoutMs,
829
+ maxBytes: this.limits.maxBytes,
830
+ });
831
+ if (plainAttempt.code === 0)
832
+ history = parseImageHistoryText(plainAttempt.stdout);
833
+ else
834
+ historyError = firstLine(plainAttempt.stderr, plainAttempt.stdout, plainAttempt.code);
835
+ }
836
+ }
837
+ catch (error) {
838
+ historyError = error instanceof Error ? error.message : String(error);
839
+ }
840
+ return { ref: safe, detail, history, historyError };
841
+ }
842
+ /** 删除镜像(`docker image rm`,不带 -f)。调用方负责 allowMutations 门禁。 */
843
+ async imageRemove(ref) {
844
+ const safe = assertImageRef(ref, 'image');
845
+ const result = await this.runner.run([this.bin, 'image', 'rm', safe], {
846
+ timeoutMs: Math.max(this.limits.timeoutMs, 60_000),
847
+ maxBytes: 64 * 1024,
848
+ });
849
+ if (result.code !== 0) {
850
+ const message = firstLine(result.stderr, result.stdout, result.code);
851
+ const inUse = /image is being used|conflict|must be forced|being used by/i.test(message);
852
+ const hint = inUse ? '(镜像仍被容器或子镜像引用:先删除相关容器;确实要强删请到终端面板手动执行 docker image rm -f)' : '';
853
+ throw new Error(`删除镜像 ${safe} 失败:${message}${hint}`);
854
+ }
855
+ return { ref: safe, message: result.stdout.trim() || 'ok' };
856
+ }
857
+ /**
858
+ * 清理 dangling(无标签)镜像:`docker image prune -f`。
859
+ *
860
+ * 刻意**不加 --all**:`--all` 会删掉所有未被容器使用的镜像(含普通 tag 的
861
+ * 基础镜像),破坏性远超「清 dangling」的直觉。要删有标签的镜像请走单个删除
862
+ * (imageRemove)并二次确认。
863
+ */
864
+ async imagePrune() {
865
+ const result = await this.runner.run([this.bin, 'image', 'prune', '-f'], {
866
+ timeoutMs: Math.max(this.limits.timeoutMs, 120_000),
867
+ maxBytes: 256 * 1024,
868
+ });
869
+ this.assertOk(result, '清理 dangling 镜像');
870
+ return { message: result.stdout.trim() || 'ok' };
871
+ }
872
+ /* ---------------- 网络 ---------------- */
873
+ async networks() {
874
+ const result = await this.runner.run([this.bin, 'network', 'ls', '--format', '{{json .}}'], {
875
+ timeoutMs: this.limits.timeoutMs,
876
+ maxBytes: this.limits.maxBytes,
877
+ });
878
+ this.assertOk(result, '列出网络');
879
+ return parseNetworksJson(result.stdout);
880
+ }
881
+ /**
882
+ * 网络详情。inspect 顺带返回接入的容器,所以列表行**不**逐行 inspect 算容器数
883
+ * (N 条网络就是 N 次 docker 调用),改成点进详情才取一次。
884
+ */
885
+ async networkInspect(name) {
886
+ const safe = assertName(name, 'network');
887
+ const result = await this.runner.run([this.bin, 'network', 'inspect', safe], {
888
+ timeoutMs: this.limits.timeoutMs,
889
+ maxBytes: this.limits.maxBytes,
890
+ });
891
+ this.assertOk(result, '读取网络详情');
892
+ const detail = parseNetworkInspectJson(result.stdout)[0];
893
+ if (detail === undefined)
894
+ throw new Error(`网络不存在或输出无法解析:${safe}`);
895
+ return { name: safe, detail };
896
+ }
897
+ /** 删除网络(`docker network rm`)。调用方负责 allowMutations 门禁。 */
898
+ async networkRemove(name) {
899
+ const safe = assertName(name, 'network');
900
+ const result = await this.runner.run([this.bin, 'network', 'rm', safe], {
901
+ timeoutMs: Math.max(this.limits.timeoutMs, 60_000),
902
+ maxBytes: 64 * 1024,
903
+ });
904
+ if (result.code !== 0) {
905
+ const message = firstLine(result.stderr, result.stdout, result.code);
906
+ // 还有容器接着的时候 docker 会拒绝:给出可执行的下一步,而不是只回一句英文
907
+ const inUse = /active endpoints|has active endpoints|in use/i.test(message);
908
+ const hint = inUse ? '(还有容器接着这个网络:先把它们断开或删除;本插件不做 disconnect)' : '';
909
+ throw new Error(`删除网络 ${safe} 失败:${message}${hint}`);
910
+ }
911
+ return { name: safe, message: result.stdout.trim() || 'ok' };
912
+ }
913
+ /**
914
+ * 清理未被使用的网络:`docker network prune -f`。
915
+ * `-f` 是必须的(否则 docker 会等交互确认,我们是非交互调用),
916
+ * 且 prune 只动「没有容器接入」的网络——但 compose 的自定义网络也会被清掉
917
+ * (下次 up 会重建),所以调用方仍然要二次确认。
918
+ */
919
+ async networkPrune() {
920
+ const result = await this.runner.run([this.bin, 'network', 'prune', '-f'], {
921
+ timeoutMs: Math.max(this.limits.timeoutMs, 120_000),
922
+ maxBytes: 256 * 1024,
923
+ });
924
+ this.assertOk(result, '清理未使用的网络');
925
+ return { message: result.stdout.trim() || 'ok' };
926
+ }
927
+ /* ---------------- 卷 ---------------- */
928
+ async volumes() {
929
+ const result = await this.runner.run([this.bin, 'volume', 'ls', '--format', '{{json .}}'], {
930
+ timeoutMs: this.limits.timeoutMs,
931
+ maxBytes: this.limits.maxBytes,
932
+ });
933
+ this.assertOk(result, '列出卷');
934
+ return parseVolumesJson(result.stdout);
935
+ }
936
+ /** 卷详情(`docker volume inspect <name>`)。 */
937
+ async volumeInspect(name) {
938
+ const safe = assertName(name, 'volume');
939
+ const result = await this.runner.run([this.bin, 'volume', 'inspect', safe], {
940
+ timeoutMs: this.limits.timeoutMs,
941
+ maxBytes: this.limits.maxBytes,
942
+ });
943
+ this.assertOk(result, '读取卷详情');
944
+ const detail = parseVolumeInspectJson(result.stdout)[0];
945
+ if (detail === undefined)
946
+ throw new Error(`卷不存在或输出无法解析:${safe}`);
947
+ return { name: safe, detail };
948
+ }
949
+ /** 删除卷(`docker volume rm`)。数据随卷一起没,调用方负责 allowMutations 门禁。 */
950
+ async volumeRemove(name) {
951
+ const safe = assertName(name, 'volume');
952
+ const result = await this.runner.run([this.bin, 'volume', 'rm', safe], {
953
+ timeoutMs: Math.max(this.limits.timeoutMs, 60_000),
954
+ maxBytes: 64 * 1024,
955
+ });
956
+ if (result.code !== 0) {
957
+ const message = firstLine(result.stderr, result.stdout, result.code);
958
+ const inUse = /volume is in use|in use/i.test(message);
959
+ const hint = inUse ? '(卷还被容器占用:先停掉/删除用它的容器)' : '';
960
+ throw new Error(`删除卷 ${safe} 失败:${message}${hint}`);
961
+ }
962
+ return { name: safe, message: result.stdout.trim() || 'ok' };
963
+ }
964
+ /**
965
+ * 清理未被容器使用的卷:`docker volume prune -f`。
966
+ *
967
+ * **破坏性最高的一个 prune**:卷里装的是数据。刻意不带 `--all`——实测 docker 27
968
+ * 的 `volume prune` 有 `-a/--all` 开关、不带时只删**匿名**卷;但 docker < 23 没有这个
969
+ * 开关,plain prune 会把命名卷一起删。所以调用方必须二次确认,并且确认文案要写明
970
+ * 这个版本差异(见 README「已知限制」)。
971
+ */
972
+ async volumePrune() {
973
+ const result = await this.runner.run([this.bin, 'volume', 'prune', '-f'], {
974
+ timeoutMs: Math.max(this.limits.timeoutMs, 120_000),
975
+ maxBytes: 256 * 1024,
976
+ });
977
+ this.assertOk(result, '清理未使用的卷');
978
+ return { message: result.stdout.trim() || 'ok' };
979
+ }
980
+ /**
981
+ * 拉取镜像(`docker pull`)。逐层进度天然是流:非 TTY 下 docker 按状态行输出
982
+ * (Pulling fs layer / Downloading / Extracting / Pull complete),直接复用
983
+ * ssh-exec 的长流通道(runLocalStream / RemoteExec.stream),无总超时,
984
+ * 由连接生命周期收尾。调用方负责 allowMutations 门禁。
985
+ */
986
+ async pullStream(ref, handlers, signal) {
987
+ const safe = assertImageRef(ref, 'image');
988
+ return await this.runner.stream([this.bin, 'pull', safe], handlers, signal);
989
+ }
990
+ /** 拉取的快照形态(agent 工具用):一次性跑完,输出有上限。 */
991
+ async pull(ref, timeoutMs) {
992
+ const safe = assertImageRef(ref, 'image');
993
+ const result = await this.runner.run([this.bin, 'pull', safe], {
994
+ timeoutMs: Math.min(Math.max(timeoutMs ?? 600_000, 10_000), 1_800_000),
995
+ maxBytes: this.limits.maxBytes,
996
+ });
997
+ const text = result.stdout + (result.stderr === '' ? '' : (result.stdout === '' ? '' : '\n') + result.stderr);
998
+ return { ref: safe, code: result.code, text, truncated: result.truncated, durationMs: result.durationMs };
999
+ }
424
1000
  /** 日志:stdout / stderr 分别收,再按到达顺序合并(docker logs 两者都有内容)。 */
425
1001
  async logs(id, options) {
426
1002
  const safe = assertRef(id, 'container');
1003
+ const result = await this.runner.run(this.logsArgv(safe, options, false), {
1004
+ timeoutMs: this.limits.timeoutMs,
1005
+ maxBytes: this.limits.maxBytes,
1006
+ });
1007
+ this.assertOk(result, '读取容器日志');
1008
+ const text = result.stdout + (result.stderr === '' ? '' : (result.stdout === '' ? '' : '\n') + result.stderr);
1009
+ return { id: safe, text, truncated: result.truncated };
1010
+ }
1011
+ /**
1012
+ * 实时日志流:`docker logs --follow`,stdout/stderr 逐块回调,直到容器退出 /
1013
+ * 远端关闭 / signal 中止。argv 与快照 logs() 共用同一构造(tail 夹紧
1014
+ * 1..5000、timestamps / since 语义完全一致),只多一个 --follow。
1015
+ */
1016
+ async logsStream(id, options, handlers, signal) {
1017
+ const safe = assertRef(id, 'container');
1018
+ return await this.runner.stream(this.logsArgv(safe, options, true), handlers, signal);
1019
+ }
1020
+ /**
1021
+ * 日志 argv 的唯一构造点:快照与流式只在 `--follow` 上有差异,
1022
+ * 校验与夹紧必须逐字一致(否则同一 id 在两条路径上行为漂移)。
1023
+ */
1024
+ logsArgv(id, options, follow) {
427
1025
  const tail = Math.min(Math.max(Math.trunc(options?.tail ?? 200), 1), 5000);
428
- const argv = [
1026
+ return [
429
1027
  this.bin,
430
1028
  'logs',
1029
+ ...(follow ? ['--follow'] : []),
431
1030
  '--tail',
432
1031
  String(tail),
433
1032
  ...(options?.timestamps === true ? ['--timestamps'] : []),
434
1033
  ...(typeof options?.since === 'string' && options.since.trim() !== '' ? ['--since', options.since.trim()] : []),
435
- safe,
1034
+ id,
436
1035
  ];
437
- const result = await this.runner.run(argv, { timeoutMs: this.limits.timeoutMs, maxBytes: this.limits.maxBytes });
438
- this.assertOk(result, '读取容器日志');
439
- const text = result.stdout + (result.stderr === '' ? '' : (result.stdout === '' ? '' : '\n') + result.stderr);
440
- return { id: safe, text, truncated: result.truncated };
441
1036
  }
442
1037
  /** 生命周期操作;调用方负责 readOnly / allowMutations 门禁。 */
443
1038
  async action(request) {
@@ -455,7 +1050,7 @@ export class DockerApi {
455
1050
  maxBytes: 64 * 1024,
456
1051
  });
457
1052
  if (result.code !== 0) {
458
- const message = (result.stderr.trim() || result.stdout.trim() || `退出码 ${String(result.code)}`).split('\n')[0];
1053
+ const message = firstLine(result.stderr, result.stdout, result.code);
459
1054
  const hint = request.action === 'remove' && /running/i.test(message) ? '(容器仍在运行:先停止再删除)' : '';
460
1055
  throw new Error(`${request.action} ${safe} 失败:${message}${hint}`);
461
1056
  }
@@ -487,8 +1082,7 @@ export class DockerApi {
487
1082
  assertOk(result, what) {
488
1083
  if (result.code === 0)
489
1084
  return;
490
- const message = (result.stderr.trim() || result.stdout.trim() || `退出码 ${String(result.code)}`).split('\n')[0];
491
- throw new Error(`${what}失败(${this.runner.label}):${message}`);
1085
+ throw new Error(`${what}失败(${this.runner.label}):${firstLine(result.stderr, result.stdout, result.code)}`);
492
1086
  }
493
1087
  }
494
1088
  /** 为一个目标构造 Runner。 */
@@ -498,6 +1092,7 @@ export function createRunner(options) {
498
1092
  return {
499
1093
  label: `本机(${target.name})`,
500
1094
  run: (argv, runOptions) => runLocal(argv, runOptions),
1095
+ stream: (argv, handlers, signal) => runLocalStream(argv, handlers, signal),
501
1096
  };
502
1097
  }
503
1098
  const spec = target.spec;
@@ -506,6 +1101,7 @@ export function createRunner(options) {
506
1101
  return {
507
1102
  label: `${sshTarget(spec)}(${target.name})`,
508
1103
  run: (argv, runOptions) => remote.run(spec, argv, runOptions),
1104
+ stream: (argv, handlers, signal) => remote.stream(spec, argv, handlers, signal),
509
1105
  };
510
1106
  }
511
1107
  /** 供宿主半体复用:把 HostKeyStore 与 logger 绑到 RemoteExec。 */