@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.
- package/README.en.md +157 -31
- package/README.md +146 -30
- package/client-src/docker.css +343 -160
- package/client-src/elevation.js +163 -0
- package/client-src/index.js +563 -119
- package/client.js +367 -183
- package/lib/audit.d.ts +106 -0
- package/lib/audit.js +140 -0
- package/lib/audit.js.map +1 -0
- package/lib/docker.d.ts +32 -3
- package/lib/docker.js +43 -7
- package/lib/docker.js.map +1 -1
- package/lib/index.d.ts +53 -0
- package/lib/index.js +473 -25
- package/lib/index.js.map +1 -1
- package/lib/ssh-exec.d.ts +9 -0
- package/lib/ssh-exec.js +149 -16
- package/lib/ssh-exec.js.map +1 -1
- package/package.json +2 -2
- package/scripts/client-smoke.mjs +394 -0
- package/scripts/log-perf.mjs +9 -2
- package/scripts/route-smoke.mjs +216 -5
package/lib/audit.d.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 能力使用审计(capability-use audit):把「授权了」和「用了」接成闭环。
|
|
3
|
+
*
|
|
4
|
+
* ## 它解决什么
|
|
5
|
+
*
|
|
6
|
+
* 授权链已有完整审计(kit `elevation.ts` 的 begin / grant / expire / revoke / load 五行),但
|
|
7
|
+
* **授权之后能力被用来干了什么**此前没有台账(README 已知限制自认的一条)。本模块给每一次
|
|
8
|
+
* **过了能力闸**的变更 / exec 操作落一行结构化审计行,两条入口都覆盖:
|
|
9
|
+
* - `source=http`(面板路由,`/action`、`/images/remove` 等 8 条);
|
|
10
|
+
* - `source=tool`(agent 工具 `docker_action` 等 5 个,走 DockerApi、不经 HTTP 路由,不会重复计数)。
|
|
11
|
+
*
|
|
12
|
+
* 行格式(对齐 kit elevation 审计行的 key=value 风格):
|
|
13
|
+
*
|
|
14
|
+
* [dsh-docker] capability-use: capability=allowMutations source=http action=container.remove target=web ref=shop-web-1 ok=true durationMs=123
|
|
15
|
+
*
|
|
16
|
+
* `capability` 刻意与能力开关同名(`allowMutations` / `allowExec`):审计行必须能和 elevation 的
|
|
17
|
+
* 授权行对上号,这是本模块的全部意义。
|
|
18
|
+
*
|
|
19
|
+
* ## 「使用 = 过了闸」的单一语义
|
|
20
|
+
*
|
|
21
|
+
* - **403 拒绝不记**:请求被闸挡回,能力没有被使用(也没有 docker 侧的任何后果);
|
|
22
|
+
* 宿主侧授权状态的变化已由 elevation 五行覆盖。tier-gate 的 ask / deny 同样不记:
|
|
23
|
+
* ask 只是询问、没有执行(不是使用),deny 的放行与否由宿主的 approval 日志兜着。
|
|
24
|
+
* - **docker 报错也算一次使用**:`ok=false` + `detail=` 截断后的错误文案——「试过删一个容器
|
|
25
|
+
* 失败了」与「删成功了」同样是审计要回答的问题。错误按原路径 rethrow,不改调用方的错误处理。
|
|
26
|
+
*
|
|
27
|
+
* ## 脱敏与截断(硬规矩,helper 统一做,不靠调用点自觉)
|
|
28
|
+
*
|
|
29
|
+
* 日志会落盘(宿主的启动失败报告会带上最近日志),所以:
|
|
30
|
+
* - exec 的 `command` 记进 `detail` 时**截断到 200 字符**(含 `…` 截断信号,与全仓「截断要有
|
|
31
|
+
* 信号」同一取向)、**控制字符转义成 `\n` 等字面量**——保证「一行就是一次事件」,命令里的
|
|
32
|
+
* 换行伪造不出第二行审计;错误文案同样处理。
|
|
33
|
+
* - 刻意**不转义空格**:`detail` 是自由文本区(永远在行尾,解析器按字段序取前段不受它干扰),
|
|
34
|
+
* `ref` 过了 assertRef / assertImageRef / assertName 白名单(无空格),`target` 是用户自己
|
|
35
|
+
* 配置的目标名——可读性优先于机器可解析。
|
|
36
|
+
* - 与 elevation「nonce 绝不进日志」是同一类取舍的正面:这条**代价**(exec 命令截断 200 字符
|
|
37
|
+
* 进日志)已写进插件 README 的已知限制。
|
|
38
|
+
*
|
|
39
|
+
* ## 出口为什么是 console 而不是 ctx.logger(docker D162)
|
|
40
|
+
*
|
|
41
|
+
* 真机验收(2026-10-07,DSH 0.2.1-alpha.1)实测:插件经 `ctx.logger.info` 打的行**既不出现在
|
|
42
|
+
* 宿主 stdout、也找不到落盘文件**——能看到的 `[dsh-docker]` 行全部是 `console.log` 调用点
|
|
43
|
+
* (mounted / config applied / agent tools registered / tier-gate;tier-gate 的日志出口默认就是
|
|
44
|
+
* console,见 kit `tier-gate.ts`)。审计行走 ctx.logger 等于在真宿主上无处可查,而「出问题时翻
|
|
45
|
+
* 得到」正是审计存在的意义。所以出口固定走 `console.log`(前缀里已带 `[dsh-docker]`),
|
|
46
|
+
* **不做参数**:出口是本模块的取舍,不是调用方的选择。kit elevation 的授权行(begin / grant /
|
|
47
|
+
* expire / revoke / load)走的仍是 ctx.logger,同一问题记在根 ROADMAP 待办——修好后两类行同通道。
|
|
48
|
+
*
|
|
49
|
+
* ## 为什么在 docker 包、不在 kit
|
|
50
|
+
*
|
|
51
|
+
* 按 conventions 的 L0/L1 边界判据(改一个包就能做完 → L1):目前只有 docker 一个插件需要使用
|
|
52
|
+
* 审计。**晋升条件**:当第二个插件(tty)也要接同一套「capability-use」行时,把本模块上提
|
|
53
|
+
* `@hyzyn/dsh-kit`,并把日志前缀做成参数(对齐 elevation 的 `logPrefix`)——在那之前不要提前抽象。
|
|
54
|
+
*/
|
|
55
|
+
/** 审计行挂的能力开关名:与授权行(elevation load/grant)对得上号。 */
|
|
56
|
+
export type CapabilityName = 'allowMutations' | 'allowExec';
|
|
57
|
+
/** 使用入口:面板路由(http)|agent 工具(tool)。 */
|
|
58
|
+
export type CapabilityUseSource = 'http' | 'tool';
|
|
59
|
+
/**
|
|
60
|
+
* action 的**固定词表**:`<资源>.<动作>`(exec 自成一格)。新增变更操作时先来这里加词,
|
|
61
|
+
* 再接调用点——审计行的消费方(人 + 日志检索)靠词表稳定。
|
|
62
|
+
*/
|
|
63
|
+
export type CapabilityUseAction = 'container.start' | 'container.stop' | 'container.restart' | 'container.remove' | 'image.remove' | 'image.prune' | 'image.pull' | 'network.remove' | 'network.prune' | 'volume.remove' | 'volume.prune' | 'exec';
|
|
64
|
+
/** 一次使用的基本字段(`audited` 的入参)。 */
|
|
65
|
+
export interface CapabilityUseFields {
|
|
66
|
+
capability: CapabilityName;
|
|
67
|
+
source: CapabilityUseSource;
|
|
68
|
+
action: CapabilityUseAction;
|
|
69
|
+
/** 目标名(docker 目标的配置名)。 */
|
|
70
|
+
target: string;
|
|
71
|
+
/** 容器名/ID、镜像引用、网络/卷名;prune 这类无目标的操作省略。 */
|
|
72
|
+
ref?: string;
|
|
73
|
+
}
|
|
74
|
+
/** 一条完整审计行的字段(含结果侧)。 */
|
|
75
|
+
export interface CapabilityUseLine extends CapabilityUseFields {
|
|
76
|
+
/** 操作本身成败:docker 报错也算一次使用(ok=false + detail)。 */
|
|
77
|
+
ok?: boolean;
|
|
78
|
+
/** 命令耗时(`audited` 计时;start 行没有)。 */
|
|
79
|
+
durationMs?: number;
|
|
80
|
+
/** 失败原因 / exec 退出码等补充(截断 + 转义)。 */
|
|
81
|
+
detail?: string;
|
|
82
|
+
/** 长流(pull 进度流)专用:开流 `start`、收尾 `end`——中途宿主挂掉时至少 start 行还在。 */
|
|
83
|
+
event?: 'start' | 'end';
|
|
84
|
+
/** end 行的收尾原因(与 SSE end 帧的 reason 同源)。 */
|
|
85
|
+
reason?: string;
|
|
86
|
+
/** end 行的 docker 退出码(被中止时为 null,省略)。 */
|
|
87
|
+
code?: number | null;
|
|
88
|
+
}
|
|
89
|
+
/** 字符串值统一截断上限(exec 命令、错误文案;硬规矩见文件头)。 */
|
|
90
|
+
export declare const AUDIT_DETAIL_MAX_CHARS = 200;
|
|
91
|
+
/**
|
|
92
|
+
* 统一的截断 + 转义:先按**原始字符数**截断(补 `…` 信号),再逐字符转义控制字符。
|
|
93
|
+
* 单次遍历、不存在二次转义;空格刻意保留(取舍见文件头「脱敏与截断」)。
|
|
94
|
+
*/
|
|
95
|
+
export declare function sanitizeAuditValue(text: string, maxChars?: number): string;
|
|
96
|
+
/** 把一条使用审计行打到宿主 stdout(出口取舍见文件头「出口为什么是 console」,docker D162)。 */
|
|
97
|
+
export declare function auditCapabilityUse(fields: CapabilityUseLine): void;
|
|
98
|
+
/**
|
|
99
|
+
* 包一层变更 / exec 调用:计时 → try/catch → 审计(成功与失败都记)→ 失败原样 rethrow。
|
|
100
|
+
*
|
|
101
|
+
* 只该放在**能力闸判定之后**(403 分支之后)——「使用 = 过了闸」。`detailOf` 可选:exec / pull
|
|
102
|
+
* 这类「api 成功返回但带退出码」的操作用它组 detail——exec 记 `code=… cmd=…`(**命令会进日志**,
|
|
103
|
+
* 这是写进 README 的代价),pull 只记退出码;整段 detail 的 200 截断与控制字符转义由 emit 时的
|
|
104
|
+
* `sanitizeAuditValue` 统一做(单一出口,不在调用点各写一遍)。失败路径的 detail 是错误文案。
|
|
105
|
+
*/
|
|
106
|
+
export declare function audited<T>(fields: CapabilityUseFields, run: () => Promise<T>, detailOf?: (result: T) => string | undefined): Promise<T>;
|
package/lib/audit.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 能力使用审计(capability-use audit):把「授权了」和「用了」接成闭环。
|
|
3
|
+
*
|
|
4
|
+
* ## 它解决什么
|
|
5
|
+
*
|
|
6
|
+
* 授权链已有完整审计(kit `elevation.ts` 的 begin / grant / expire / revoke / load 五行),但
|
|
7
|
+
* **授权之后能力被用来干了什么**此前没有台账(README 已知限制自认的一条)。本模块给每一次
|
|
8
|
+
* **过了能力闸**的变更 / exec 操作落一行结构化审计行,两条入口都覆盖:
|
|
9
|
+
* - `source=http`(面板路由,`/action`、`/images/remove` 等 8 条);
|
|
10
|
+
* - `source=tool`(agent 工具 `docker_action` 等 5 个,走 DockerApi、不经 HTTP 路由,不会重复计数)。
|
|
11
|
+
*
|
|
12
|
+
* 行格式(对齐 kit elevation 审计行的 key=value 风格):
|
|
13
|
+
*
|
|
14
|
+
* [dsh-docker] capability-use: capability=allowMutations source=http action=container.remove target=web ref=shop-web-1 ok=true durationMs=123
|
|
15
|
+
*
|
|
16
|
+
* `capability` 刻意与能力开关同名(`allowMutations` / `allowExec`):审计行必须能和 elevation 的
|
|
17
|
+
* 授权行对上号,这是本模块的全部意义。
|
|
18
|
+
*
|
|
19
|
+
* ## 「使用 = 过了闸」的单一语义
|
|
20
|
+
*
|
|
21
|
+
* - **403 拒绝不记**:请求被闸挡回,能力没有被使用(也没有 docker 侧的任何后果);
|
|
22
|
+
* 宿主侧授权状态的变化已由 elevation 五行覆盖。tier-gate 的 ask / deny 同样不记:
|
|
23
|
+
* ask 只是询问、没有执行(不是使用),deny 的放行与否由宿主的 approval 日志兜着。
|
|
24
|
+
* - **docker 报错也算一次使用**:`ok=false` + `detail=` 截断后的错误文案——「试过删一个容器
|
|
25
|
+
* 失败了」与「删成功了」同样是审计要回答的问题。错误按原路径 rethrow,不改调用方的错误处理。
|
|
26
|
+
*
|
|
27
|
+
* ## 脱敏与截断(硬规矩,helper 统一做,不靠调用点自觉)
|
|
28
|
+
*
|
|
29
|
+
* 日志会落盘(宿主的启动失败报告会带上最近日志),所以:
|
|
30
|
+
* - exec 的 `command` 记进 `detail` 时**截断到 200 字符**(含 `…` 截断信号,与全仓「截断要有
|
|
31
|
+
* 信号」同一取向)、**控制字符转义成 `\n` 等字面量**——保证「一行就是一次事件」,命令里的
|
|
32
|
+
* 换行伪造不出第二行审计;错误文案同样处理。
|
|
33
|
+
* - 刻意**不转义空格**:`detail` 是自由文本区(永远在行尾,解析器按字段序取前段不受它干扰),
|
|
34
|
+
* `ref` 过了 assertRef / assertImageRef / assertName 白名单(无空格),`target` 是用户自己
|
|
35
|
+
* 配置的目标名——可读性优先于机器可解析。
|
|
36
|
+
* - 与 elevation「nonce 绝不进日志」是同一类取舍的正面:这条**代价**(exec 命令截断 200 字符
|
|
37
|
+
* 进日志)已写进插件 README 的已知限制。
|
|
38
|
+
*
|
|
39
|
+
* ## 出口为什么是 console 而不是 ctx.logger(docker D162)
|
|
40
|
+
*
|
|
41
|
+
* 真机验收(2026-10-07,DSH 0.2.1-alpha.1)实测:插件经 `ctx.logger.info` 打的行**既不出现在
|
|
42
|
+
* 宿主 stdout、也找不到落盘文件**——能看到的 `[dsh-docker]` 行全部是 `console.log` 调用点
|
|
43
|
+
* (mounted / config applied / agent tools registered / tier-gate;tier-gate 的日志出口默认就是
|
|
44
|
+
* console,见 kit `tier-gate.ts`)。审计行走 ctx.logger 等于在真宿主上无处可查,而「出问题时翻
|
|
45
|
+
* 得到」正是审计存在的意义。所以出口固定走 `console.log`(前缀里已带 `[dsh-docker]`),
|
|
46
|
+
* **不做参数**:出口是本模块的取舍,不是调用方的选择。kit elevation 的授权行(begin / grant /
|
|
47
|
+
* expire / revoke / load)走的仍是 ctx.logger,同一问题记在根 ROADMAP 待办——修好后两类行同通道。
|
|
48
|
+
*
|
|
49
|
+
* ## 为什么在 docker 包、不在 kit
|
|
50
|
+
*
|
|
51
|
+
* 按 conventions 的 L0/L1 边界判据(改一个包就能做完 → L1):目前只有 docker 一个插件需要使用
|
|
52
|
+
* 审计。**晋升条件**:当第二个插件(tty)也要接同一套「capability-use」行时,把本模块上提
|
|
53
|
+
* `@hyzyn/dsh-kit`,并把日志前缀做成参数(对齐 elevation 的 `logPrefix`)——在那之前不要提前抽象。
|
|
54
|
+
*/
|
|
55
|
+
/** 字符串值统一截断上限(exec 命令、错误文案;硬规矩见文件头)。 */
|
|
56
|
+
export const AUDIT_DETAIL_MAX_CHARS = 200;
|
|
57
|
+
/**
|
|
58
|
+
* 统一的截断 + 转义:先按**原始字符数**截断(补 `…` 信号),再逐字符转义控制字符。
|
|
59
|
+
* 单次遍历、不存在二次转义;空格刻意保留(取舍见文件头「脱敏与截断」)。
|
|
60
|
+
*/
|
|
61
|
+
export function sanitizeAuditValue(text, maxChars = AUDIT_DETAIL_MAX_CHARS) {
|
|
62
|
+
let value = text;
|
|
63
|
+
let truncated = false;
|
|
64
|
+
if (value.length > maxChars) {
|
|
65
|
+
value = value.slice(0, maxChars);
|
|
66
|
+
truncated = true;
|
|
67
|
+
}
|
|
68
|
+
let out = '';
|
|
69
|
+
for (const char of value) {
|
|
70
|
+
if (char === '\\')
|
|
71
|
+
out += '\\\\';
|
|
72
|
+
else if (char === '\n')
|
|
73
|
+
out += '\\n';
|
|
74
|
+
else if (char === '\r')
|
|
75
|
+
out += '\\r';
|
|
76
|
+
else if (char === '\t')
|
|
77
|
+
out += '\\t';
|
|
78
|
+
else {
|
|
79
|
+
const code = char.codePointAt(0) ?? 0;
|
|
80
|
+
// 其余 C0/C1 控制字符(含 DEL):十六进制字面量,保住「一行一次事件」
|
|
81
|
+
out += code < 0x20 || code === 0x7f ? `\\x${code.toString(16).padStart(2, '0')}` : char;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return truncated ? out + '…' : out;
|
|
85
|
+
}
|
|
86
|
+
/** 把一条使用审计行打到宿主 stdout(出口取舍见文件头「出口为什么是 console」,docker D162)。 */
|
|
87
|
+
export function auditCapabilityUse(fields) {
|
|
88
|
+
const parts = [
|
|
89
|
+
`capability=${fields.capability}`,
|
|
90
|
+
`source=${fields.source}`,
|
|
91
|
+
`action=${fields.action}`,
|
|
92
|
+
`target=${sanitizeAuditValue(fields.target)}`,
|
|
93
|
+
];
|
|
94
|
+
if (fields.ref !== undefined && fields.ref !== '')
|
|
95
|
+
parts.push(`ref=${sanitizeAuditValue(fields.ref)}`);
|
|
96
|
+
if (fields.event !== undefined)
|
|
97
|
+
parts.push(`event=${fields.event}`);
|
|
98
|
+
if (fields.ok !== undefined)
|
|
99
|
+
parts.push(`ok=${fields.ok ? 'true' : 'false'}`);
|
|
100
|
+
if (fields.durationMs !== undefined)
|
|
101
|
+
parts.push(`durationMs=${String(Math.max(0, Math.round(fields.durationMs)))}`);
|
|
102
|
+
if (fields.reason !== undefined)
|
|
103
|
+
parts.push(`reason=${sanitizeAuditValue(fields.reason)}`);
|
|
104
|
+
if (fields.code !== undefined && fields.code !== null)
|
|
105
|
+
parts.push(`code=${String(fields.code)}`);
|
|
106
|
+
if (fields.detail !== undefined)
|
|
107
|
+
parts.push(`detail=${sanitizeAuditValue(fields.detail)}`);
|
|
108
|
+
console.log(`[dsh-docker] capability-use: ${parts.join(' ')}`);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* 包一层变更 / exec 调用:计时 → try/catch → 审计(成功与失败都记)→ 失败原样 rethrow。
|
|
112
|
+
*
|
|
113
|
+
* 只该放在**能力闸判定之后**(403 分支之后)——「使用 = 过了闸」。`detailOf` 可选:exec / pull
|
|
114
|
+
* 这类「api 成功返回但带退出码」的操作用它组 detail——exec 记 `code=… cmd=…`(**命令会进日志**,
|
|
115
|
+
* 这是写进 README 的代价),pull 只记退出码;整段 detail 的 200 截断与控制字符转义由 emit 时的
|
|
116
|
+
* `sanitizeAuditValue` 统一做(单一出口,不在调用点各写一遍)。失败路径的 detail 是错误文案。
|
|
117
|
+
*/
|
|
118
|
+
export async function audited(fields, run, detailOf) {
|
|
119
|
+
const startedAt = Date.now();
|
|
120
|
+
try {
|
|
121
|
+
const result = await run();
|
|
122
|
+
auditCapabilityUse({
|
|
123
|
+
...fields,
|
|
124
|
+
ok: true,
|
|
125
|
+
durationMs: Date.now() - startedAt,
|
|
126
|
+
detail: detailOf?.(result),
|
|
127
|
+
});
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
auditCapabilityUse({
|
|
132
|
+
...fields,
|
|
133
|
+
ok: false,
|
|
134
|
+
durationMs: Date.now() - startedAt,
|
|
135
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
136
|
+
});
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
//# sourceMappingURL=audit.js.map
|
package/lib/audit.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audit.js","sourceRoot":"","sources":["../src/audit.ts"],"names":[],"mappings":"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"}
|
package/lib/docker.d.ts
CHANGED
|
@@ -352,10 +352,17 @@ export declare function parseInspectPorts(value: unknown): PortMapping[];
|
|
|
352
352
|
export interface Runner {
|
|
353
353
|
/** 展示用标签:`本机` 或 `user@host`。 */
|
|
354
354
|
readonly label: string;
|
|
355
|
+
/**
|
|
356
|
+
* `signal` 是**可选**的(D161):已有 20+ 个调用点与测试里的假 runner 只传前两个
|
|
357
|
+
* 参数,加成必填会把它们全部波及,而它们的命令都是短命、不需要外部取消的。
|
|
358
|
+
* 目前只有跨目标聚合(`aggregateAcrossTargets`)会传——单目标路径的截止时间是
|
|
359
|
+
* `timeoutMs`,它本来就够。
|
|
360
|
+
*/
|
|
355
361
|
run(argv: readonly string[], options?: {
|
|
356
362
|
timeoutMs?: number;
|
|
357
363
|
maxBytes?: number;
|
|
358
364
|
keepTail?: boolean;
|
|
365
|
+
signal?: AbortSignal;
|
|
359
366
|
}): Promise<ExecResult>;
|
|
360
367
|
/** 长流(logs --follow):逐块回调,signal 中止;无总超时与输出上限。 */
|
|
361
368
|
stream(argv: readonly string[], handlers: StreamHandlers, signal?: AbortSignal): Promise<StreamResult>;
|
|
@@ -376,9 +383,14 @@ export interface ProbeResult {
|
|
|
376
383
|
bin: string;
|
|
377
384
|
/** 服务端版本(`docker version --format {{.Server.Version}}`)。 */
|
|
378
385
|
serverVersion: string | null;
|
|
379
|
-
/** 失败原因(daemon 未运行 / 未安装 /
|
|
386
|
+
/** 失败原因(daemon 未运行 / 未安装 / 权限不足 / 超时)。 */
|
|
380
387
|
error: string | null;
|
|
381
388
|
target: string;
|
|
389
|
+
/**
|
|
390
|
+
* 失败是不是「CLI 挂住到超时」。单独一档而不并进 `error` 文案:调用方要靠它
|
|
391
|
+
* 区分「没装」与「装了但不应答」——前者去装、后者去看 daemon / context。
|
|
392
|
+
*/
|
|
393
|
+
timedOut: boolean;
|
|
382
394
|
}
|
|
383
395
|
/** 单个目标上的 Docker 操作集合。 */
|
|
384
396
|
export declare class DockerApi {
|
|
@@ -389,7 +401,13 @@ export declare class DockerApi {
|
|
|
389
401
|
timeoutMs: number;
|
|
390
402
|
maxBytes: number;
|
|
391
403
|
});
|
|
392
|
-
/**
|
|
404
|
+
/**
|
|
405
|
+
* 探测:docker CLI 是否可用 + daemon 是否可达。
|
|
406
|
+
*
|
|
407
|
+
* 超时必须单独成一档(`timedOut`),不能落进「退出码 null」——`code` 为 null 时
|
|
408
|
+
* 那句兜底文案对用户零信息量(D159),而超时的真实成因(daemon 卡死 / `docker
|
|
409
|
+
* context` 指向连不上的远端 / CLI 是个挂住的包装脚本)恰恰是可修的那一类。
|
|
410
|
+
*/
|
|
393
411
|
probe(): Promise<ProbeResult>;
|
|
394
412
|
/**
|
|
395
413
|
* assertOk + 截断检查(D13):列表 / 详情类方法拿到的必须是**完整**输出。
|
|
@@ -614,11 +632,22 @@ export interface ResolvedTarget {
|
|
|
614
632
|
kind: 'local' | 'ssh';
|
|
615
633
|
spec?: SshSpec;
|
|
616
634
|
}
|
|
617
|
-
/**
|
|
635
|
+
/**
|
|
636
|
+
* 为一个目标构造 Runner。
|
|
637
|
+
*
|
|
638
|
+
* `signal`(D161)是**构造时**绑定的外部取消信号:跨目标聚合给每个目标各配一个
|
|
639
|
+
* `AbortController`,预算到点就 abort 这一条。绑定在构造期而不是每个调用点,是为了
|
|
640
|
+
* **不改那 20+ 个 `runner.run(...)` 调用点**——它们都不需要这个信号(单目标路径的
|
|
641
|
+
* 截止时间本来就是 `timeoutMs`)。
|
|
642
|
+
*
|
|
643
|
+
* 两个分支(local / ssh)**必须都透传**:只给一边接,就会出现「本机能取消、SSH 不能」
|
|
644
|
+
* 的半截行为——比不改更糟(见项目级 ROADMAP 里跳板机那条的同一判据)。
|
|
645
|
+
*/
|
|
618
646
|
export declare function createRunner(options: {
|
|
619
647
|
target: ResolvedTarget;
|
|
620
648
|
remote: RemoteExec;
|
|
621
649
|
logger: ExecLogger;
|
|
650
|
+
signal?: AbortSignal;
|
|
622
651
|
}): Runner;
|
|
623
652
|
/**
|
|
624
653
|
* 供宿主半体复用:把 HostKeyStore 与 logger 绑到 RemoteExec。
|
package/lib/docker.js
CHANGED
|
@@ -893,14 +893,29 @@ export class DockerApi {
|
|
|
893
893
|
this.bin = bin;
|
|
894
894
|
this.limits = limits;
|
|
895
895
|
}
|
|
896
|
-
/**
|
|
896
|
+
/**
|
|
897
|
+
* 探测:docker CLI 是否可用 + daemon 是否可达。
|
|
898
|
+
*
|
|
899
|
+
* 超时必须单独成一档(`timedOut`),不能落进「退出码 null」——`code` 为 null 时
|
|
900
|
+
* 那句兜底文案对用户零信息量(D159),而超时的真实成因(daemon 卡死 / `docker
|
|
901
|
+
* context` 指向连不上的远端 / CLI 是个挂住的包装脚本)恰恰是可修的那一类。
|
|
902
|
+
*/
|
|
897
903
|
async probe() {
|
|
898
|
-
const base = { bin: this.bin, target: this.runner.label };
|
|
904
|
+
const base = { bin: this.bin, target: this.runner.label, timedOut: false };
|
|
899
905
|
try {
|
|
900
906
|
const result = await this.runner.run([this.bin, 'version', '--format', '{{.Server.Version}}'], { timeoutMs: 15_000 });
|
|
901
907
|
if (result.code === 0) {
|
|
902
908
|
return { ok: true, serverVersion: result.stdout.trim() || null, error: null, ...base };
|
|
903
909
|
}
|
|
910
|
+
if (result.timedOut) {
|
|
911
|
+
return {
|
|
912
|
+
ok: false,
|
|
913
|
+
serverVersion: null,
|
|
914
|
+
error: `${this.bin} version 超时(15 秒未返回)`,
|
|
915
|
+
...base,
|
|
916
|
+
timedOut: true,
|
|
917
|
+
};
|
|
918
|
+
}
|
|
904
919
|
const message = (result.stderr.trim() || result.stdout.trim() || `退出码 ${String(result.code)}`).split('\n')[0];
|
|
905
920
|
return { ok: false, serverVersion: null, error: message ?? '未知错误', ...base };
|
|
906
921
|
}
|
|
@@ -1476,14 +1491,35 @@ export class DockerApi {
|
|
|
1476
1491
|
throw new Error(`${what}失败(${this.runner.label}):${firstLine(result.stderr, result.stdout, result.code)}`);
|
|
1477
1492
|
}
|
|
1478
1493
|
}
|
|
1479
|
-
/**
|
|
1494
|
+
/**
|
|
1495
|
+
* 为一个目标构造 Runner。
|
|
1496
|
+
*
|
|
1497
|
+
* `signal`(D161)是**构造时**绑定的外部取消信号:跨目标聚合给每个目标各配一个
|
|
1498
|
+
* `AbortController`,预算到点就 abort 这一条。绑定在构造期而不是每个调用点,是为了
|
|
1499
|
+
* **不改那 20+ 个 `runner.run(...)` 调用点**——它们都不需要这个信号(单目标路径的
|
|
1500
|
+
* 截止时间本来就是 `timeoutMs`)。
|
|
1501
|
+
*
|
|
1502
|
+
* 两个分支(local / ssh)**必须都透传**:只给一边接,就会出现「本机能取消、SSH 不能」
|
|
1503
|
+
* 的半截行为——比不改更糟(见项目级 ROADMAP 里跳板机那条的同一判据)。
|
|
1504
|
+
*/
|
|
1480
1505
|
export function createRunner(options) {
|
|
1481
1506
|
const { target, remote, logger } = options;
|
|
1507
|
+
const signal = options.signal;
|
|
1508
|
+
/**
|
|
1509
|
+
* 把构造期绑定的 `signal` 并进单次调用的选项。调用方**自带** signal 时以它为准
|
|
1510
|
+
* (当前无调用点传,留给将来单命令级的取消);否则用聚合绑定的那个。
|
|
1511
|
+
* 都不传时返回原样,避免多造一个对象。
|
|
1512
|
+
*/
|
|
1513
|
+
const withSignal = (runOptions) => {
|
|
1514
|
+
const merged = runOptions?.signal ?? signal;
|
|
1515
|
+
return merged === undefined ? (runOptions ?? {}) : { ...runOptions, signal: merged };
|
|
1516
|
+
};
|
|
1482
1517
|
if (target.kind === 'local') {
|
|
1483
1518
|
return {
|
|
1484
1519
|
label: `本机(${target.name})`,
|
|
1485
|
-
run: (argv, runOptions) => runLocal(argv, runOptions),
|
|
1486
|
-
|
|
1520
|
+
run: (argv, runOptions) => runLocal(argv, withSignal(runOptions)),
|
|
1521
|
+
// 长流**不**接聚合信号:它的取消由面板的 SSE 关闭单独驱动(见 runLocalStream 的文档)。
|
|
1522
|
+
stream: (argv, handlers, signal_) => runLocalStream(argv, handlers, signal_),
|
|
1487
1523
|
};
|
|
1488
1524
|
}
|
|
1489
1525
|
const spec = target.spec;
|
|
@@ -1491,8 +1527,8 @@ export function createRunner(options) {
|
|
|
1491
1527
|
throw new Error(`目标 ${target.name} 缺少 SSH 规格`);
|
|
1492
1528
|
return {
|
|
1493
1529
|
label: `${sshTarget(spec)}(${target.name})`,
|
|
1494
|
-
run: (argv, runOptions) => remote.run(spec, argv, runOptions),
|
|
1495
|
-
stream: (argv, handlers,
|
|
1530
|
+
run: (argv, runOptions) => remote.run(spec, argv, withSignal(runOptions)),
|
|
1531
|
+
stream: (argv, handlers, signal_) => remote.stream(spec, argv, handlers, signal_),
|
|
1496
1532
|
};
|
|
1497
1533
|
}
|
|
1498
1534
|
/**
|