@hyzyn/dsh-docker 0.15.0 → 0.16.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 +2 -2
- package/README.md +2 -2
- package/client-src/index.js +41 -10
- package/client.js +17 -17
- package/lib/docker.d.ts +66 -1
- package/lib/docker.js +154 -3
- package/lib/docker.js.map +1 -1
- package/lib/index.js +29 -9
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
package/lib/docker.d.ts
CHANGED
|
@@ -22,6 +22,32 @@ export declare function assertBin(value: unknown): string;
|
|
|
22
22
|
export declare function assertImageRef(value: unknown, field: string): string;
|
|
23
23
|
/** 校验一个 docker 网络 / 卷名(也是 inspect / rm 的引用)。 */
|
|
24
24
|
export declare function assertName(value: unknown, field: string): string;
|
|
25
|
+
/**
|
|
26
|
+
* 容器名过滤(`docker ps --filter name=<值>`)的校验。
|
|
27
|
+
*
|
|
28
|
+
* 与 `assertRef` 分开:那里是**引用**(要能在 argv 里寻址一个对象,宽松了就可能
|
|
29
|
+
* 被当成 flag),这里是**过滤词**(含空串 = 不过滤,交给调用方决定)。它同样进
|
|
30
|
+
* argv,所以仍拒绝空串以外的注入面(空白 / 换行 / 引号 / shell 元字符)。
|
|
31
|
+
*
|
|
32
|
+
* 为什么允许 `.` `_` `-`:compose 生成的容器名常是 `proj-web-1` / `proj_db_1`,
|
|
33
|
+
* 名字过滤的主要用途正是「在一台跑了几十个容器的机器上找出一个 compose 项目」。
|
|
34
|
+
*/
|
|
35
|
+
export declare function assertPsName(value: unknown): string | undefined;
|
|
36
|
+
/** `docker exec` 精确参数数组的上限(与命令串的 8000 字符上限是两套口径)。 */
|
|
37
|
+
export declare const EXEC_ARGV_MAX_ITEMS = 128;
|
|
38
|
+
export declare const EXEC_ARGV_MAX_CHARS = 32768;
|
|
39
|
+
/**
|
|
40
|
+
* 校验 `docker exec` 的**精确参数数组**(argv 模式)。
|
|
41
|
+
*
|
|
42
|
+
* 与 `command` 字符串模式的差别就是这里存在的理由:字符串交给容器内 `sh -c`,
|
|
43
|
+
* 空格、引号、空参数都要经过一层 shell 解析;argv 模式一个 token 一个参数,
|
|
44
|
+
* 宿主与容器都不做展开。代价是**不能再依赖 shell**(管道 / 变量要自己显式写
|
|
45
|
+
* `["sh", "-c", "..."]`),所以这是一条可选的第二条通道,不是替换。
|
|
46
|
+
*
|
|
47
|
+
* 逐项保留原样(不 trim):空参数 `""` 是合法且**有意义**的(`printf '%s' ""`)。
|
|
48
|
+
* 只拒绝会让 docker CLI 无法安全传参的形状:非字符串、NUL、首项为空、总量过大。
|
|
49
|
+
*/
|
|
50
|
+
export declare function assertExecArgv(value: unknown): string[];
|
|
25
51
|
/** 逐行 JSON 解析:兼容 `{{json .}}`(每行一个对象)与整体 JSON 数组。 */
|
|
26
52
|
export declare function parseJsonLines(text: string): Record<string, unknown>[];
|
|
27
53
|
/** `12.3MiB` / `1.2kB` / `0B` → 字节数(解析失败返回 null)。 */
|
|
@@ -33,6 +59,18 @@ export declare function parseIOPair(text: string): {
|
|
|
33
59
|
rx: number | null;
|
|
34
60
|
tx: number | null;
|
|
35
61
|
};
|
|
62
|
+
/**
|
|
63
|
+
* 把 docker 的原始 stderr 映射成一句**可执行的下一步**(没有命中返回 null)。
|
|
64
|
+
*
|
|
65
|
+
* 为什么不是「把所有失败都包一层」(D105 的口径):agent 能做的下一步与它执行过
|
|
66
|
+
* 哪条命令无关,只与**失败类型**有关——daemon 没起、权限不足、CLI 不在 PATH、
|
|
67
|
+
* 容器名写错、端口冲突、超时,修法各自不同。此前这些都只回一句 docker 的英文原话,
|
|
68
|
+
* 模型只能原样重试;加上这一层之后,同一段原文后面直接跟「该去做什么」。
|
|
69
|
+
*
|
|
70
|
+
* 判据刻意保守:只认**docker / 我们自己的 SSH 层真的会打出来**的片段。猜测式的
|
|
71
|
+
* 匹配(比如任何包含 error 的行)会把真正的业务报错也改写成运维建议,那比不提示更糟。
|
|
72
|
+
*/
|
|
73
|
+
export declare function dockerErrorHint(text: string): string | null;
|
|
36
74
|
/**
|
|
37
75
|
* `--since` 的统一口径(D45):时长(docker 的 Go duration 语法,允许复合如
|
|
38
76
|
* `1h30m`)、Unix 秒或时间戳。events 与 logs 两条路径此前各有一套——events
|
|
@@ -685,7 +723,13 @@ export declare class DockerApi {
|
|
|
685
723
|
* 不抛错的路径(imageInspect 的 history,D104)复用同一句话,保持口径一致。
|
|
686
724
|
*/
|
|
687
725
|
private truncationHint;
|
|
688
|
-
|
|
726
|
+
/**
|
|
727
|
+
* 容器列表。`name` 是**服务端过滤**(`docker ps --filter name=`),不是本地筛:
|
|
728
|
+
* 本插件对列表做 limit/offset 分页,先取全量再在内存里筛的话,「第 2 页」会随
|
|
729
|
+
* 过滤词变化而变化,分页语义就与总数对不上了。docker 的 name 过滤是**子串**
|
|
730
|
+
* 匹配(不是精确匹配),对「在一台跑了几十个容器的机器上找 compose 项目」够用。
|
|
731
|
+
*/
|
|
732
|
+
listContainers(all: boolean, name?: string): Promise<ContainerSummary[]>;
|
|
689
733
|
inspect(ids: readonly string[]): Promise<ContainerDetail[]>;
|
|
690
734
|
/**
|
|
691
735
|
* 「需要关注」的容器(0.15.0):先按摘要筛候选(不健康 / 重启中 / 僵死 /
|
|
@@ -920,6 +964,27 @@ export declare class DockerApi {
|
|
|
920
964
|
truncated: boolean;
|
|
921
965
|
durationMs: number;
|
|
922
966
|
}>;
|
|
967
|
+
/**
|
|
968
|
+
* 一次性 exec 的 **argv 模式**:`docker exec <id> <argv…>`,不经容器内 shell。
|
|
969
|
+
*
|
|
970
|
+
* 与上面 command 模式互补,不是替代:
|
|
971
|
+
* - 字符串模式把整条命令交给容器内 `sh -c`,管道 / 变量 / 重定向都能用,代价是
|
|
972
|
+
* 空格与引号要再过一层 shell 解析(空参数、相邻引号、反斜杠都容易被改写);
|
|
973
|
+
* - argv 模式一个 token 一个参数,宿主与容器都不做展开,`["printf","%s","a b"]`
|
|
974
|
+
* 里的 `a b` 就是**一个**参数。需要 shell 语义时显式写 `["sh","-c","…"]`。
|
|
975
|
+
* 两条通道都受同一个 `allowExec` 静态闸与档位闸约束(工具层负责),这里只保证
|
|
976
|
+
* 参数形状合法、不会以字符串拼接的方式进入 argv。
|
|
977
|
+
*/
|
|
978
|
+
execArgv(id: string, argv: unknown, timeoutMs?: number): Promise<{
|
|
979
|
+
id: string;
|
|
980
|
+
argv: string[];
|
|
981
|
+
command: string;
|
|
982
|
+
code: number | null;
|
|
983
|
+
stdout: string;
|
|
984
|
+
stderr: string;
|
|
985
|
+
truncated: boolean;
|
|
986
|
+
durationMs: number;
|
|
987
|
+
}>;
|
|
923
988
|
/**
|
|
924
989
|
* 容器内的进程快照(`docker top <id> -eo <TOP_PS_FORMAT>`)。
|
|
925
990
|
*
|
package/lib/docker.js
CHANGED
|
@@ -77,6 +77,65 @@ export function assertName(value, field) {
|
|
|
77
77
|
throw new Error(`${field} 含非法字符(仅允许字母、数字与 _ . -):${trimmed}`);
|
|
78
78
|
return trimmed;
|
|
79
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* 容器名过滤(`docker ps --filter name=<值>`)的校验。
|
|
82
|
+
*
|
|
83
|
+
* 与 `assertRef` 分开:那里是**引用**(要能在 argv 里寻址一个对象,宽松了就可能
|
|
84
|
+
* 被当成 flag),这里是**过滤词**(含空串 = 不过滤,交给调用方决定)。它同样进
|
|
85
|
+
* argv,所以仍拒绝空串以外的注入面(空白 / 换行 / 引号 / shell 元字符)。
|
|
86
|
+
*
|
|
87
|
+
* 为什么允许 `.` `_` `-`:compose 生成的容器名常是 `proj-web-1` / `proj_db_1`,
|
|
88
|
+
* 名字过滤的主要用途正是「在一台跑了几十个容器的机器上找出一个 compose 项目」。
|
|
89
|
+
*/
|
|
90
|
+
export function assertPsName(value) {
|
|
91
|
+
if (value === undefined || value === null)
|
|
92
|
+
return undefined;
|
|
93
|
+
if (typeof value !== 'string')
|
|
94
|
+
throw new Error('name 必须是字符串(按容器名子串过滤)');
|
|
95
|
+
const trimmed = value.trim();
|
|
96
|
+
if (trimmed === '')
|
|
97
|
+
return undefined;
|
|
98
|
+
if (trimmed.length > 128)
|
|
99
|
+
throw new Error('name 过长(≤128 字符)');
|
|
100
|
+
if (!NAME_RE.test(trimmed))
|
|
101
|
+
throw new Error(`name 含非法字符(仅允许字母、数字与 _ . -):${trimmed}`);
|
|
102
|
+
return trimmed;
|
|
103
|
+
}
|
|
104
|
+
/** `docker exec` 精确参数数组的上限(与命令串的 8000 字符上限是两套口径)。 */
|
|
105
|
+
export const EXEC_ARGV_MAX_ITEMS = 128;
|
|
106
|
+
export const EXEC_ARGV_MAX_CHARS = 32_768;
|
|
107
|
+
/**
|
|
108
|
+
* 校验 `docker exec` 的**精确参数数组**(argv 模式)。
|
|
109
|
+
*
|
|
110
|
+
* 与 `command` 字符串模式的差别就是这里存在的理由:字符串交给容器内 `sh -c`,
|
|
111
|
+
* 空格、引号、空参数都要经过一层 shell 解析;argv 模式一个 token 一个参数,
|
|
112
|
+
* 宿主与容器都不做展开。代价是**不能再依赖 shell**(管道 / 变量要自己显式写
|
|
113
|
+
* `["sh", "-c", "..."]`),所以这是一条可选的第二条通道,不是替换。
|
|
114
|
+
*
|
|
115
|
+
* 逐项保留原样(不 trim):空参数 `""` 是合法且**有意义**的(`printf '%s' ""`)。
|
|
116
|
+
* 只拒绝会让 docker CLI 无法安全传参的形状:非字符串、NUL、首项为空、总量过大。
|
|
117
|
+
*/
|
|
118
|
+
export function assertExecArgv(value) {
|
|
119
|
+
if (!Array.isArray(value))
|
|
120
|
+
throw new Error('argv 必须是字符串数组');
|
|
121
|
+
if (value.length === 0)
|
|
122
|
+
throw new Error('argv 不能为空(至少一个参数)');
|
|
123
|
+
if (value.length > EXEC_ARGV_MAX_ITEMS)
|
|
124
|
+
throw new Error(`argv 最多 ${String(EXEC_ARGV_MAX_ITEMS)} 项(当前 ${String(value.length)})`);
|
|
125
|
+
let total = 0;
|
|
126
|
+
for (const item of value) {
|
|
127
|
+
if (typeof item !== 'string')
|
|
128
|
+
throw new Error('argv 的每一项都必须是字符串');
|
|
129
|
+
if (item.includes('\0'))
|
|
130
|
+
throw new Error('argv 不能包含 NUL 字符');
|
|
131
|
+
total += item.length;
|
|
132
|
+
}
|
|
133
|
+
if (total > EXEC_ARGV_MAX_CHARS)
|
|
134
|
+
throw new Error(`argv 总长超过 ${String(EXEC_ARGV_MAX_CHARS)} 字符(当前 ${String(total)})`);
|
|
135
|
+
if ((value[0] ?? '').trim() === '')
|
|
136
|
+
throw new Error('argv 的第一项不能为空(它是容器内要执行的命令)');
|
|
137
|
+
return [...value];
|
|
138
|
+
}
|
|
80
139
|
/** 逐行 JSON 解析:兼容 `{{json .}}`(每行一个对象)与整体 JSON 数组。 */
|
|
81
140
|
export function parseJsonLines(text) {
|
|
82
141
|
const trimmed = text.trim();
|
|
@@ -165,6 +224,47 @@ export function parseIOPair(text) {
|
|
|
165
224
|
function firstLine(stderr, stdout, code) {
|
|
166
225
|
return (stderr.trim() || stdout.trim() || `退出码 ${String(code)}`).split('\n')[0] ?? `退出码 ${String(code)}`;
|
|
167
226
|
}
|
|
227
|
+
/**
|
|
228
|
+
* 把 docker 的原始 stderr 映射成一句**可执行的下一步**(没有命中返回 null)。
|
|
229
|
+
*
|
|
230
|
+
* 为什么不是「把所有失败都包一层」(D105 的口径):agent 能做的下一步与它执行过
|
|
231
|
+
* 哪条命令无关,只与**失败类型**有关——daemon 没起、权限不足、CLI 不在 PATH、
|
|
232
|
+
* 容器名写错、端口冲突、超时,修法各自不同。此前这些都只回一句 docker 的英文原话,
|
|
233
|
+
* 模型只能原样重试;加上这一层之后,同一段原文后面直接跟「该去做什么」。
|
|
234
|
+
*
|
|
235
|
+
* 判据刻意保守:只认**docker / 我们自己的 SSH 层真的会打出来**的片段。猜测式的
|
|
236
|
+
* 匹配(比如任何包含 error 的行)会把真正的业务报错也改写成运维建议,那比不提示更糟。
|
|
237
|
+
*/
|
|
238
|
+
export function dockerErrorHint(text) {
|
|
239
|
+
if (/Cannot connect to the Docker daemon|Is the docker daemon running|docker\.sock: connect|open .*docker\.sock/i.test(text)) {
|
|
240
|
+
return 'daemon 不可达:启动 Docker Desktop / dockerd(Windows 需 WSL2 或 Hyper-V 后端),再用 `docker_targets`(probe:true)复查';
|
|
241
|
+
}
|
|
242
|
+
if (/permission denied while trying to connect|Got permission denied|permission denied.*docker\.sock/i.test(text)) {
|
|
243
|
+
return '权限不足:把当前用户加入 docker 组并重新登录(SSH 目标要求免 sudo 的 docker);本机仍失败时确认 dockerBin 指向的 CLI 有执行权限';
|
|
244
|
+
}
|
|
245
|
+
if (/command not found|not recognized as the name|ENOENT|no such file or directory/i.test(text)) {
|
|
246
|
+
return '找不到 docker CLI:PATH 里没有它,把插件配置的 dockerBin 填成绝对路径后重试';
|
|
247
|
+
}
|
|
248
|
+
if (/[Nn]o such container/i.test(text)) {
|
|
249
|
+
return '容器不存在:用 `docker_ps`(all:true 含已停止)确认真实容器名;SSH 目标还要确认 target 选的是那台主机';
|
|
250
|
+
}
|
|
251
|
+
if (/[Nn]o such image/i.test(text)) {
|
|
252
|
+
return '镜像不存在:本地没有就先用 `docker_image_pull` 拉取,`docker_images` 可确认已有哪些';
|
|
253
|
+
}
|
|
254
|
+
if (/context deadline exceeded|i\/o timeout|timed out|timeout/i.test(text)) {
|
|
255
|
+
return '命令超时:目标可能繁忙或网络慢;确认目标与容器状态,必要时调大 timeoutSec(或插件配置里的 execTimeoutSec)后重试';
|
|
256
|
+
}
|
|
257
|
+
if (/port is already allocated|address already in use/i.test(text)) {
|
|
258
|
+
return '端口已被占用:用 `docker_ps` 找到占用该端口的容器,或改用其它端口';
|
|
259
|
+
}
|
|
260
|
+
if (/Conflict\. The container name/i.test(text)) {
|
|
261
|
+
return '容器名冲突:已有同名容器,先用 `docker_ps` 找到它并处理,或改用其它名字';
|
|
262
|
+
}
|
|
263
|
+
if (/no more sessions|Channel open failure|open failed/i.test(text)) {
|
|
264
|
+
return 'SSH 通道额度用尽:先在面板折叠长流(把通道还回去)再重试;根治办法是调大远端 sshd 的 MaxSessions';
|
|
265
|
+
}
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
168
268
|
/**
|
|
169
269
|
* `--since` 的统一口径(D45):时长(docker 的 Go duration 语法,允许复合如
|
|
170
270
|
* `1h30m`)、Unix 秒或时间戳。events 与 logs 两条路径此前各有一套——events
|
|
@@ -1217,8 +1317,20 @@ export class DockerApi {
|
|
|
1217
1317
|
: `${alternative};或调大「单次命令输出上限」(maxOutputKb)`;
|
|
1218
1318
|
return `因超过上限被截断(目标 ${this.runner.label},当前上限 ${limitKb} KB):结果不完整。请${suggest}后重试`;
|
|
1219
1319
|
}
|
|
1220
|
-
|
|
1221
|
-
|
|
1320
|
+
/**
|
|
1321
|
+
* 容器列表。`name` 是**服务端过滤**(`docker ps --filter name=`),不是本地筛:
|
|
1322
|
+
* 本插件对列表做 limit/offset 分页,先取全量再在内存里筛的话,「第 2 页」会随
|
|
1323
|
+
* 过滤词变化而变化,分页语义就与总数对不上了。docker 的 name 过滤是**子串**
|
|
1324
|
+
* 匹配(不是精确匹配),对「在一台跑了几十个容器的机器上找 compose 项目」够用。
|
|
1325
|
+
*/
|
|
1326
|
+
async listContainers(all, name) {
|
|
1327
|
+
const filter = assertPsName(name);
|
|
1328
|
+
const argv = [
|
|
1329
|
+
this.bin, 'ps',
|
|
1330
|
+
...(all ? ['-a'] : []),
|
|
1331
|
+
...(filter === undefined ? [] : ['--filter', `name=${filter}`]),
|
|
1332
|
+
'--no-trunc', '--format', '{{json .}}',
|
|
1333
|
+
];
|
|
1222
1334
|
const result = await this.runner.run(argv, { timeoutMs: this.limits.timeoutMs, maxBytes: this.limits.maxBytes });
|
|
1223
1335
|
this.assertComplete(result, '列出容器', '改传 all=false(默认只看运行中的容器)');
|
|
1224
1336
|
return parsePsJson(result.stdout);
|
|
@@ -1971,6 +2083,38 @@ export class DockerApi {
|
|
|
1971
2083
|
durationMs: result.durationMs,
|
|
1972
2084
|
};
|
|
1973
2085
|
}
|
|
2086
|
+
/**
|
|
2087
|
+
* 一次性 exec 的 **argv 模式**:`docker exec <id> <argv…>`,不经容器内 shell。
|
|
2088
|
+
*
|
|
2089
|
+
* 与上面 command 模式互补,不是替代:
|
|
2090
|
+
* - 字符串模式把整条命令交给容器内 `sh -c`,管道 / 变量 / 重定向都能用,代价是
|
|
2091
|
+
* 空格与引号要再过一层 shell 解析(空参数、相邻引号、反斜杠都容易被改写);
|
|
2092
|
+
* - argv 模式一个 token 一个参数,宿主与容器都不做展开,`["printf","%s","a b"]`
|
|
2093
|
+
* 里的 `a b` 就是**一个**参数。需要 shell 语义时显式写 `["sh","-c","…"]`。
|
|
2094
|
+
* 两条通道都受同一个 `allowExec` 静态闸与档位闸约束(工具层负责),这里只保证
|
|
2095
|
+
* 参数形状合法、不会以字符串拼接的方式进入 argv。
|
|
2096
|
+
*/
|
|
2097
|
+
async execArgv(id, argv, timeoutMs) {
|
|
2098
|
+
const safe = assertRef(id, 'container');
|
|
2099
|
+
const safeArgv = assertExecArgv(argv);
|
|
2100
|
+
const result = await this.runner.run([this.bin, 'exec', safe, ...safeArgv], {
|
|
2101
|
+
timeoutMs: Math.min(Math.max(timeoutMs ?? 30_000, 1_000), 120_000),
|
|
2102
|
+
maxBytes: this.limits.maxBytes,
|
|
2103
|
+
});
|
|
2104
|
+
if (result.timedOut)
|
|
2105
|
+
throw new Error(`exec 超时(${String(Math.round(result.durationMs / 1000))}s):${safe}`);
|
|
2106
|
+
return {
|
|
2107
|
+
id: safe,
|
|
2108
|
+
argv: safeArgv,
|
|
2109
|
+
// 仅用于审计行与错误文案:argv 的『可读形态』,**不回传成可再执行的命令串**。
|
|
2110
|
+
command: safeArgv.join(' '),
|
|
2111
|
+
code: result.code,
|
|
2112
|
+
stdout: result.stdout,
|
|
2113
|
+
stderr: result.stderr,
|
|
2114
|
+
truncated: result.truncated,
|
|
2115
|
+
durationMs: result.durationMs,
|
|
2116
|
+
};
|
|
2117
|
+
}
|
|
1974
2118
|
/* ---------------------------------------------------------------- *
|
|
1975
2119
|
* 诊断(批一):进程快照 + 「为什么不行」的结构化结论
|
|
1976
2120
|
* ---------------------------------------------------------------- */
|
|
@@ -2093,7 +2237,14 @@ export class DockerApi {
|
|
|
2093
2237
|
assertOk(result, what) {
|
|
2094
2238
|
if (result.code === 0)
|
|
2095
2239
|
return;
|
|
2096
|
-
|
|
2240
|
+
/*
|
|
2241
|
+
* 原文照旧放第一行(用户搜得到的还是 docker 那句话),下一行才是我们给的下一步。
|
|
2242
|
+
* 分开看 stderr / stdout:docker 的错误几乎都在 stderr,但个别版本(例如
|
|
2243
|
+
* `docker context` 配错时的提示)会走 stdout。
|
|
2244
|
+
*/
|
|
2245
|
+
const hint = dockerErrorHint(result.stderr) ?? dockerErrorHint(result.stdout);
|
|
2246
|
+
throw new Error(`${what}失败(${this.runner.label}):${firstLine(result.stderr, result.stdout, result.code)}`
|
|
2247
|
+
+ (hint === null ? '' : `\n下一步:${hint}`));
|
|
2097
2248
|
}
|
|
2098
2249
|
}
|
|
2099
2250
|
/**
|