@hyzyn/dsh-docker 0.9.4 → 0.9.6
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 +120 -27
- package/README.md +106 -26
- package/client-src/docker.css +326 -160
- package/client-src/index.js +288 -21
- package/client.js +349 -183
- 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 +49 -0
- package/lib/index.js +390 -10
- 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 +3 -3
- package/scripts/client-smoke.mjs +205 -0
- package/scripts/log-perf.mjs +9 -2
- package/scripts/route-smoke.mjs +73 -4
package/lib/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { accessSync, constants } from 'node:fs';
|
|
2
|
+
import { delimiter, basename, join } from 'node:path';
|
|
2
3
|
import z from '@deepseek-ai/schemastery';
|
|
3
4
|
import { definePlugin, hasSameOriginProof, isLoopbackRequestStrict, originProofHint, plainConfig, readSettingsEntry, settingsEntryScope, suppressAutoSettingsPage } from '@hyzyn/dsh-kit';
|
|
4
5
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
@@ -89,6 +90,14 @@ const MUTATION_SUBROUTES = new Set([
|
|
|
89
90
|
'/elevate',
|
|
90
91
|
'/elevate/status',
|
|
91
92
|
'/elevate/revoke',
|
|
93
|
+
/*
|
|
94
|
+
* 一键连接本机(POST /connect-local):它**会写插件配置**(加一个本机目标),
|
|
95
|
+
* 与其它写路径同一档,必须带同源证明——跨站页面不该有权替用户改目标列表。
|
|
96
|
+
* 注意它**不**在能力开关(allowMutations / allowExec)里:见 connectLocal 的注释。
|
|
97
|
+
* 客户端只在「一个目标都没有」的空态里给入口,但路由本身与调用方无关(agent 工具
|
|
98
|
+
* 在任何时候都能调),所以这条守卫不能省。
|
|
99
|
+
*/
|
|
100
|
+
'/connect-local',
|
|
92
101
|
]);
|
|
93
102
|
/** 「这条错误来自目标侧」的标记(见 guardTargetFailures)。 */
|
|
94
103
|
const TARGET_FAILURE = Symbol('dsh-docker.target-failure');
|
|
@@ -132,7 +141,7 @@ function guardTargetFailures(api) {
|
|
|
132
141
|
function isTargetFailure(error) {
|
|
133
142
|
return error !== null && typeof error === 'object' && error[TARGET_FAILURE] === true;
|
|
134
143
|
}
|
|
135
|
-
const DOCKER_GUIDANCE = '本机已安装 dsh-docker 插件(Docker 容器面板):Web GUI 侧边栏「容器」入口可查看各目标(本机 / SSH 主机)上的容器列表(含 Compose 项目视图、事件「活动」条)、状态、端口、日志(含实时跟随)与资源占用(含实时跟随 + 迷你趋势图),以及镜像列表与镜像详情(层 / 大小 / 构建历史、拉取进度流)、网络与卷(列表 + 详情;删除 / 清理同样在开关之后);目标在 插件配置 → Docker 容器面板 里维护(SSH 目标可直接引用 tty 终端面板的连接簿条目)。**默认只读**:启动/停止/重启/删除容器、删除镜像 / 清理 dangling / 拉取镜像、docker exec,都需要用户在设置里显式打开「允许变更操作」「允许 exec」后才有对应工具与按钮。agent 侧配套只读工具 docker_targets
|
|
144
|
+
const DOCKER_GUIDANCE = '本机已安装 dsh-docker 插件(Docker 容器面板):Web GUI 侧边栏「容器」入口可查看各目标(本机 / SSH 主机)上的容器列表(含 Compose 项目视图、事件「活动」条)、状态、端口、日志(含实时跟随)与资源占用(含实时跟随 + 迷你趋势图),以及镜像列表与镜像详情(层 / 大小 / 构建历史、拉取进度流)、网络与卷(列表 + 详情;删除 / 清理同样在开关之后);目标在 插件配置 → Docker 容器面板 里维护(SSH 目标可直接引用 tty 终端面板的连接簿条目)。**默认只读**:启动/停止/重启/删除容器、删除镜像 / 清理 dangling / 拉取镜像、docker exec,都需要用户在设置里显式打开「允许变更操作」「允许 exec」后才有对应工具与按钮。agent 侧配套只读工具 docker_targets(列目标;没有任何目标时先调 docker_connect_local 一键加上本机目标)、docker_connect_local(把宿主所在机器加成本机目标并选中,复用已有的本机目标、不重复创建;顺带探测 daemon,失败会说明是 daemon 未启动 / CLI 缺失 / socket 无权访问)、docker_ps(列容器,含 compose 项目与服务;**target 传 `*` 可一次列出所有目标**;端口已按 IPv4/IPv6 双栈归并,`ports` 为空时看 `net`——host 网络容器的端口即宿主机端口)、docker_attention(**需关注汇总**:unhealthy / 反复重启 / OOM / 非零退出 / 僵死,同样支持 `*` 跨目标)、docker_inspect(容器详情)、docker_logs(日志快照)、docker_stats(CPU/内存/IO 快照)、docker_images(镜像列表)、docker_image_inspect(镜像详情 + 构建历史)、docker_events(容器事件快照,见面板容器列表的「活动」条)、docker_networks(网络列表)、docker_volumes(卷列表);排障推荐顺序:不确定从哪台/哪个容器看起时先 docker_attention(可 `*` 跨目标)→ docker_ps → docker_logs → docker_inspect → docker_stats → docker_events,镜像排查用 docker_images → docker_image_inspect。docker_action(容器生命周期)、docker_image_remove(删镜像)、docker_image_prune(清理 dangling)、docker_image_pull(拉取镜像)、docker_exec 仅在用户打开对应开关后可用,执行前须确认目标,破坏性操作(容器 remove / 镜像删除与清理)要向用户复述后果。网络 / 卷的删除与 prune 目前只提供面板按钮(HTTP 端点),没有对应的 agent 工具——不要在 agent 侧绕过面板做这些变更。docker socket 等价于目标主机的 root 权限,不要在用户未明确要求时执行变更操作。';
|
|
136
145
|
/**
|
|
137
146
|
* SSE 帧封装:data 一律 `JSON.stringify` 成**单行**——换行 / 引号被转义,
|
|
138
147
|
* 多字节字符也不会被 SSE 的 `\n` 行边界截断(客户端 JSON.parse 还原)。
|
|
@@ -279,6 +288,120 @@ export function sanitizeTargets(input) {
|
|
|
279
288
|
}
|
|
280
289
|
return out;
|
|
281
290
|
}
|
|
291
|
+
/** 一键连接本机时新建目标的基准名。刻意用稳定的小写 `local`(不是 `本机`):它是可预测的 id,重名时另起 `local-2`…。 */
|
|
292
|
+
export const LOCAL_TARGET_NAME = 'local';
|
|
293
|
+
/** 目标列表里第一个 `kind=local` 的名称(没有则 undefined)——「连接本机」靠它判断该新建还是复用。 */
|
|
294
|
+
export function findLocalTargetName(targets) {
|
|
295
|
+
return targets.find((item) => item.kind === 'local')?.name;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* 新建本机目标时挑一个**确定性无冲突**的名称:`local` 被占就顺着 `local-2`、`local-3`… 找第一个空位。
|
|
299
|
+
*
|
|
300
|
+
* 为什么不做随机 / 时间戳后缀:同一个工作区里重复点「连接本机」应当**幂等**(第二次复用第一次那条),
|
|
301
|
+
* 而重名只可能来自用户自己配的其它 `local*`;顺序探测既确定又与用户已有的名字不冲突(不覆盖、不改名)。
|
|
302
|
+
* 上界 1000 是防御性截断(真到那时说明列表已经病态,宁可失败也不能无限循环)。
|
|
303
|
+
*/
|
|
304
|
+
export function nextLocalTargetName(taken) {
|
|
305
|
+
const used = new Set(taken);
|
|
306
|
+
if (!used.has(LOCAL_TARGET_NAME))
|
|
307
|
+
return LOCAL_TARGET_NAME;
|
|
308
|
+
for (let index = 2; index <= 1000; index += 1) {
|
|
309
|
+
const candidate = `${LOCAL_TARGET_NAME}-${String(index)}`;
|
|
310
|
+
if (!used.has(candidate))
|
|
311
|
+
return candidate;
|
|
312
|
+
}
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* 「探不到 CLI」的判据(`runLocal` 的 ENOENT 抛错形态)。
|
|
317
|
+
*
|
|
318
|
+
* 抽出来当单一来源:文案分档与「要不要去找 podman」都用它,两处各写一遍正则
|
|
319
|
+
* 迟早会漂移——那时会出现「文案说 CLI 缺失、但不去找候选」或反过来。
|
|
320
|
+
*/
|
|
321
|
+
export function isLocalCliMissing(raw) {
|
|
322
|
+
return /ENOENT|not recognized as an internal|command not found/i.test(raw);
|
|
323
|
+
}
|
|
324
|
+
/** 本机容器 CLI 的候选(除 `dockerBin` 自己之外):按这个顺序找第一个存在的。 */
|
|
325
|
+
const LOCAL_CLI_ALTERNATIVES = ['docker', 'podman', 'nerdctl'];
|
|
326
|
+
/**
|
|
327
|
+
* 在 PATH 里找**别的**容器 CLI(`dockerBin` 缺失时的候选),返回名字数组(可能为空)。
|
|
328
|
+
*
|
|
329
|
+
* 只读探测,**绝不改配置**:返回的候选交给文案去说「你可以把 docker CLI 改成它」,
|
|
330
|
+
* 改不改由用户在设置卡片决定。静默改写 `dockerBin` 会违背本插件「不替用户决定」的
|
|
331
|
+
* 一贯取向(何况 podman 与本插件的输出格式兼容性并未逐项验证,见 README)。
|
|
332
|
+
*
|
|
333
|
+
* `env` 可注入(测试用);Windows 按 PATHEXT 补扩展名——本插件明确支持 Windows
|
|
334
|
+
* 盘符路径,不能只认无扩展名的 POSIX 查找。
|
|
335
|
+
*/
|
|
336
|
+
export function findAlternativeLocalCli(bin, env = process.env) {
|
|
337
|
+
const dirs = (env.PATH ?? '').split(delimiter).filter((item) => item !== '');
|
|
338
|
+
const win = process.platform === 'win32';
|
|
339
|
+
const exts = win ? (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter((item) => item !== '') : [''];
|
|
340
|
+
/*
|
|
341
|
+
* 比的是**工具名**(basename,Windows 再去掉扩展名),不是配置里的原字符串:
|
|
342
|
+
* `dockerBin` 常写成绝对路径(`/usr/local/bin/docker`),拿它直接比对候选名会
|
|
343
|
+
* 把「同一个 docker」当成可切换的候选,给出「把 docker CLI 改成 docker」这种废话。
|
|
344
|
+
*/
|
|
345
|
+
const stem = (value) => {
|
|
346
|
+
const base = basename(value).toLowerCase();
|
|
347
|
+
return win ? base.replace(/\.[^.]+$/, '') : base;
|
|
348
|
+
};
|
|
349
|
+
const self = stem(bin);
|
|
350
|
+
const found = [];
|
|
351
|
+
for (const candidate of LOCAL_CLI_ALTERNATIVES) {
|
|
352
|
+
if (candidate === self)
|
|
353
|
+
continue;
|
|
354
|
+
const dir = dirs.find((item) => exts.some((ext) => {
|
|
355
|
+
try {
|
|
356
|
+
accessSync(join(item, candidate + ext), constants.X_OK);
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
}));
|
|
363
|
+
if (dir !== undefined)
|
|
364
|
+
found.push(candidate);
|
|
365
|
+
}
|
|
366
|
+
return found;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* 只读探测失败 → 用户能照着做的一句话。按**成因**分档(而不是原样甩 `exit status 1`):
|
|
370
|
+
* CLI 不在(装 Docker / 改 `dockerBin`)、超时(daemon 卡死 / context 指错)、
|
|
371
|
+
* daemon 不可达、socket 无权、其它(原样透出)。
|
|
372
|
+
*
|
|
373
|
+
* `probe()` 的两条失败路径形状不同:`runLocal` 对 ENOENT 是**抛错**(`无法执行 docker:spawn docker ENOENT`),
|
|
374
|
+
* 而 daemon 连不上是 docker CLI 自己以非零退出 + stderr 文案返回;两条都要认得出。
|
|
375
|
+
*/
|
|
376
|
+
export function describeLocalProbeFailure(raw, bin = 'docker', hint = {}) {
|
|
377
|
+
const text = raw.trim();
|
|
378
|
+
if (isLocalCliMissing(text)) {
|
|
379
|
+
const head = `宿主上找不到 docker CLI(${bin}):请先安装 Docker,或在设置卡片把「docker CLI」改成正确的可执行文件路径`;
|
|
380
|
+
// 有候选就点出来——只说「请先安装 Docker」会让 podman 用户以为要装一整套 Docker。
|
|
381
|
+
// 同时写明**面板不会替你改**:这是提示,不是自动切换(见 findAlternativeLocalCli)。
|
|
382
|
+
return hint.alternativeCli === undefined || hint.alternativeCli === ''
|
|
383
|
+
? head
|
|
384
|
+
: `${head}。这台机器上装了 ${hint.alternativeCli}:把「docker CLI」填成 ${hint.alternativeCli} 就能用(面板不会替你改)`;
|
|
385
|
+
}
|
|
386
|
+
/*
|
|
387
|
+
* 超时单独一档(D159):它和「daemon 没起」的可修动作完全不同,也不能落进下面
|
|
388
|
+
* 那句「退出码 null」。判据来自 `ProbeResult.timedOut` 而不是解析文案——CLI 的
|
|
389
|
+
* 超时输出里根本没有可认的字样。
|
|
390
|
+
*/
|
|
391
|
+
if (hint.timedOut === true) {
|
|
392
|
+
return `${bin} version 15 秒没返回:daemon 可能卡死,或 docker context 指向了连不上的远端(用 docker context ls 看当前指向;Docker Desktop 卡死时重启它)`;
|
|
393
|
+
}
|
|
394
|
+
if (/ECONNREFUSED|ENOENT.*\.sock|\.sock.*(no such file|不是目录|No such)/i.test(text)) {
|
|
395
|
+
return `docker CLI 可用,但连不上 docker daemon:请确认 daemon 已启动(Linux: systemctl start docker;macOS/Windows: 启动 Docker Desktop)`;
|
|
396
|
+
}
|
|
397
|
+
if (/permission denied/i.test(text)) {
|
|
398
|
+
return `docker CLI 可用,但当前账号无权访问 docker socket:把运行宿主的账号加入 docker 组,或改用允许的 socket`;
|
|
399
|
+
}
|
|
400
|
+
if (/Is the docker daemon running|Cannot connect to the Docker daemon|error during connect/i.test(text)) {
|
|
401
|
+
return `docker CLI 可用,但连不上 docker daemon:请确认 daemon 已启动(Linux: systemctl start docker;macOS/Windows: 启动 Docker Desktop)`;
|
|
402
|
+
}
|
|
403
|
+
return `docker daemon 探测失败:${text === '' ? 'docker version 没有返回任何信息' : text}`;
|
|
404
|
+
}
|
|
282
405
|
/**
|
|
283
406
|
* 清洗一份 hostKeys 输入(settings 存储 / 热更新 / 种子复制共用)。
|
|
284
407
|
*
|
|
@@ -667,6 +790,7 @@ const plugin = definePlugin({
|
|
|
667
790
|
};
|
|
668
791
|
/* ---------- 主机指纹(TOFU,本插件自持;tty 记录作种子) ---------- */
|
|
669
792
|
let settingsScope;
|
|
793
|
+
let settingsForms;
|
|
670
794
|
/**
|
|
671
795
|
* 只读他人 entry(如 tty 的连接簿):旧 `settings.get(ns)` 在新宿主已不存在,
|
|
672
796
|
* 改走 `settings.describe()` 的 entry 查找(kit 的 readSettingsEntry)。settings
|
|
@@ -761,13 +885,19 @@ const plugin = definePlugin({
|
|
|
761
885
|
}
|
|
762
886
|
return resolveTarget(target, readTtyBooks(settingsApi));
|
|
763
887
|
};
|
|
764
|
-
/**
|
|
765
|
-
|
|
888
|
+
/**
|
|
889
|
+
* 取某个目标上的 DockerApi(Runner 每次新建,连接由 RemoteExec 池化)。
|
|
890
|
+
*
|
|
891
|
+
* `signal`(D161)只有**跨目标聚合**会传:它把「这一格的预算到点了」串到该目标
|
|
892
|
+
* **底层每一条命令**上(`createRunner` 绑到 Runner 上,见那边的注释)。其余调用点
|
|
893
|
+
* 不传,行为与之前完全一致。
|
|
894
|
+
*/
|
|
895
|
+
const apiFor = (name, signal) => {
|
|
766
896
|
const { resolved, error } = resolveByName(name);
|
|
767
897
|
if (resolved === undefined)
|
|
768
898
|
return { error };
|
|
769
899
|
try {
|
|
770
|
-
const runner = createRunner({ target: resolved, remote, logger });
|
|
900
|
+
const runner = createRunner({ target: resolved, remote, logger, ...(signal === undefined ? {} : { signal }) });
|
|
771
901
|
return { api: new DockerApi(runner, live.dockerBin, { timeoutMs: 30_000, maxBytes: live.maxOutputKb * 1024 }), resolved };
|
|
772
902
|
}
|
|
773
903
|
catch (error_) {
|
|
@@ -799,18 +929,185 @@ const plugin = definePlugin({
|
|
|
799
929
|
return { name: fallback };
|
|
800
930
|
const list = targetsNow();
|
|
801
931
|
if (list.length === 0)
|
|
802
|
-
return { error: '尚未配置任何 Docker
|
|
932
|
+
return { error: '尚未配置任何 Docker 目标:可调 docker_connect_local 一键加上本机目标,或到 插件配置 → Docker 容器面板 添加' };
|
|
803
933
|
return { error: 'target 必填(已配置多个目标:' + list.map((item) => item.name).join('、') + ')' };
|
|
804
934
|
};
|
|
935
|
+
/* ---------- 一键连接本机(面板按钮 / HTTP 路由 / agent 工具共用这一份实现) ---------- */
|
|
936
|
+
/**
|
|
937
|
+
* 探测失败 → 给用户的一句话(这次探测的 bin 以**配置里的 `dockerBin`** 为准)。
|
|
938
|
+
*
|
|
939
|
+
* 只有「CLI 缺失」这一档才去 PATH 里找候选:那要 accessSync 若干次,其余档位全是
|
|
940
|
+
* 纯文本判断,不该无谓地跑文件系统。
|
|
941
|
+
*/
|
|
942
|
+
const localProbeReason = (raw, timedOut = false) => {
|
|
943
|
+
const alternative = isLocalCliMissing(raw) ? findAlternativeLocalCli(live.dockerBin)[0] : undefined;
|
|
944
|
+
return describeLocalProbeFailure(raw, live.dockerBin, {
|
|
945
|
+
timedOut,
|
|
946
|
+
...(alternative === undefined ? {} : { alternativeCli: alternative }),
|
|
947
|
+
});
|
|
948
|
+
};
|
|
949
|
+
/**
|
|
950
|
+
* 显式加一个本机目标并选中它,然后把只读探测结果一并回报。
|
|
951
|
+
*
|
|
952
|
+
* 四条不变量(缺任何一条都会变成另一种东西):
|
|
953
|
+
* - **幂等**:已有 `kind=local` 就复用它(`created:false`),绝不重复创建;重复点、并发点都落到同一条。
|
|
954
|
+
* - **不改别人的**:SSH / 自定义目标原样保留,`targets` 顺序也只追加(`[...existing, 新建]`)——
|
|
955
|
+
* 不排序、不重写已存在的条目(重写会丢掉 `mergeTargetSecrets` 才补得回的密码)。
|
|
956
|
+
* - **没有隐式默认目标**:只有本函数被显式调用才动 `targets`;空数组 / `clearTargets` 的既有语义一字不改。
|
|
957
|
+
* - **落盘成功才算数**:settings 不可用或保存失败一律**明确报错且不改内存配置**(no-op),
|
|
958
|
+
* 绝不出现「界面显示已加上了、刷新就没了」。
|
|
959
|
+
*
|
|
960
|
+
* 权限:这条路径只写**本插件自己的配置**(加一个指向宿主自身的本机目标)+ 只读探测,
|
|
961
|
+
* 与服务端 root 等价的那两档能力(`allowMutations` / `exec`)无关,因此不要求任何授权;
|
|
962
|
+
* 也绝不自动装 Docker、起 daemon、切 docker context 或改 env。
|
|
963
|
+
*/
|
|
964
|
+
const connectLocalOnce = async () => {
|
|
965
|
+
if (!live.enabled)
|
|
966
|
+
return { ok: false, code: 403, error: 'Docker 插件已禁用' };
|
|
967
|
+
const existing = targetsNow();
|
|
968
|
+
const found = findLocalTargetName(existing);
|
|
969
|
+
// Reusing an existing local target only probes it; it does not need a settings write.
|
|
970
|
+
if (found === undefined && settingsScope === undefined) {
|
|
971
|
+
return { ok: false, code: 503, error: '设置存储不可用(宿主未提供 settings 服务),无法保存本机目标;请稍后重试' };
|
|
972
|
+
}
|
|
973
|
+
const name = found ?? nextLocalTargetName(existing.map((item) => item.name));
|
|
974
|
+
if (name === undefined) {
|
|
975
|
+
return { ok: false, code: 400, error: `本机目标名冲突:已有 1000 个 local* 目标,请先删掉一些再试` };
|
|
976
|
+
}
|
|
977
|
+
let created = false;
|
|
978
|
+
if (found === undefined) {
|
|
979
|
+
/* 新条目只写展示名与 kind:其余字段交给 normalizeConfig / sanitizeTargets 的默认值(单一来源)。 */
|
|
980
|
+
const scope = settingsScope;
|
|
981
|
+
let entry;
|
|
982
|
+
try {
|
|
983
|
+
entry = settingsForms?.describe().find((item) => item.ns === scope.ns);
|
|
984
|
+
}
|
|
985
|
+
catch {
|
|
986
|
+
return { ok: false, code: 503, error: '无法读取当前目标配置,请稍后重试' };
|
|
987
|
+
}
|
|
988
|
+
if (entry === undefined)
|
|
989
|
+
return { ok: false, code: 503, error: '当前设置条目不可用,请稍后重试' };
|
|
990
|
+
const rawTargets = entry.value?.targets;
|
|
991
|
+
const nextTargets = [...(Array.isArray(rawTargets) ? rawTargets : existing), { name, kind: 'local' }];
|
|
992
|
+
try {
|
|
993
|
+
await settingsForms.update(scope.ns, { targets: nextTargets }, entry.revision);
|
|
994
|
+
}
|
|
995
|
+
catch (error) {
|
|
996
|
+
return { ok: false, code: 500, error: '保存本机目标失败:' + (error instanceof Error ? error.message : String(error)) };
|
|
997
|
+
}
|
|
998
|
+
created = true;
|
|
999
|
+
/*
|
|
1000
|
+
* `scope.update` 之后必须自己应用一次:真实 loader 会发 `loader/volatile-update` 触发
|
|
1001
|
+
* applySection,但那个事件是**异步**的,单测 / 老宿主还可能完全不发。这里同步应用是幂等的
|
|
1002
|
+
* (applySection 用同一份 settings 值再归一化一次),而**不应用**的后果是「刚加上的目标
|
|
1003
|
+
* 在探测那一步还解析不到」。
|
|
1004
|
+
*/
|
|
1005
|
+
try {
|
|
1006
|
+
applySection({ targets: nextTargets });
|
|
1007
|
+
}
|
|
1008
|
+
catch (error) {
|
|
1009
|
+
return {
|
|
1010
|
+
ok: false,
|
|
1011
|
+
code: 500,
|
|
1012
|
+
error: '本机目标已保存但应用失败:' + (error instanceof Error ? error.message : String(error)),
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
// 探测失败一律降级为 message(见 ConnectLocalResult 的注释),不是这次操作的失败
|
|
1017
|
+
const built = apiFor(name);
|
|
1018
|
+
if (built.api === undefined) {
|
|
1019
|
+
return {
|
|
1020
|
+
ok: true,
|
|
1021
|
+
result: {
|
|
1022
|
+
name,
|
|
1023
|
+
kind: 'local',
|
|
1024
|
+
created,
|
|
1025
|
+
saved: true,
|
|
1026
|
+
reachable: false,
|
|
1027
|
+
message: `已${created ? '添加并选中' : '选中'}本机目标「${name}」,但无法构造执行通道:${built.error ?? '未知错误'}`,
|
|
1028
|
+
},
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
let reachable = false;
|
|
1032
|
+
let serverVersion;
|
|
1033
|
+
let reason = '';
|
|
1034
|
+
try {
|
|
1035
|
+
const probe = await built.api.probe();
|
|
1036
|
+
reachable = probe.ok;
|
|
1037
|
+
if (probe.serverVersion !== null)
|
|
1038
|
+
serverVersion = probe.serverVersion;
|
|
1039
|
+
if (!probe.ok)
|
|
1040
|
+
reason = localProbeReason(probe.error ?? '', probe.timedOut);
|
|
1041
|
+
}
|
|
1042
|
+
catch (error) {
|
|
1043
|
+
reason = localProbeReason(error instanceof Error ? error.message : String(error));
|
|
1044
|
+
}
|
|
1045
|
+
const head = `已${created ? '添加并选中' : '选中'}本机目标「${name}」`;
|
|
1046
|
+
return {
|
|
1047
|
+
ok: true,
|
|
1048
|
+
result: {
|
|
1049
|
+
name,
|
|
1050
|
+
kind: 'local',
|
|
1051
|
+
created,
|
|
1052
|
+
saved: true,
|
|
1053
|
+
reachable,
|
|
1054
|
+
...(serverVersion === undefined ? {} : { serverVersion }),
|
|
1055
|
+
message: reachable
|
|
1056
|
+
? `${head}:docker daemon 可达${serverVersion === undefined ? '' : `(${serverVersion})`}`
|
|
1057
|
+
// 连接失败必须说清**为什么**:daemon 没起 / CLI 缺失 / socket 权限,而不是只报「没有容器」
|
|
1058
|
+
: `${head},但本机 docker 不可用——${reason === '' ? 'docker version 未返回可用信息' : reason}`,
|
|
1059
|
+
},
|
|
1060
|
+
};
|
|
1061
|
+
};
|
|
1062
|
+
// Serialize this read-modify-write across HTTP and tool callers. Rejections never poison the queue.
|
|
1063
|
+
let localConnectQueue = Promise.resolve();
|
|
1064
|
+
const connectLocal = () => {
|
|
1065
|
+
const pending = localConnectQueue.then(connectLocalOnce);
|
|
1066
|
+
localConnectQueue = pending.catch(() => { });
|
|
1067
|
+
return pending;
|
|
1068
|
+
};
|
|
805
1069
|
/**
|
|
806
1070
|
* 跨目标聚合(0.15.0):所有目标的容器 / 需关注列表在面板与 agent 工具里共用同
|
|
807
1071
|
* 一套逻辑。三个必须的性质:
|
|
808
1072
|
* - **并发上限**:ssh exec 扇出太多会互相挤(远端 sshd MaxStartups / 本机 fd);
|
|
809
1073
|
* - **单目标超时**:一台网络不通不能把整个聚合页拖住;
|
|
810
1074
|
* - **错误隔离**:失败的组带上 error 照常返回,其余目标的结果照常可用。
|
|
1075
|
+
*
|
|
1076
|
+
* 第四个性质是 **D161 补上的**:单个目标超时的那一刻,**它底下正在跑的命令必须真的被
|
|
1077
|
+
* 停掉**。旧写法只 `Promise.race` 出一个 `ok:false`,底层那条 SSH channel 继续跑完——
|
|
1078
|
+
* 而它占着的正是 `MaxSessions` 的 session 槽(sshd 在子进程活着时不释放,见 D150)。
|
|
811
1079
|
*/
|
|
812
1080
|
const AGG_CONCURRENCY = 4;
|
|
813
|
-
|
|
1081
|
+
/**
|
|
1082
|
+
* 单目标上**每一条**命令的内层预算(30s,DockerApi 构造时传下去的那一个)。
|
|
1083
|
+
*
|
|
1084
|
+
* 单独取个名字是为了让「外层兜底」与「内层真相」共用一个来源:D161 之前这里是
|
|
1085
|
+
* `apiFor` 里的裸字面量 `30_000`,而外层是另一个裸字面量 `45_000`——两个数字各写各的,
|
|
1086
|
+
* 谁也不知道它们的关系,于是 45s 那个到底会不会打断一条健康命令,没人说得清。
|
|
1087
|
+
*/
|
|
1088
|
+
const AGG_CMD_TIMEOUT_MS = 30_000;
|
|
1089
|
+
/**
|
|
1090
|
+
* 单个目标那一格的**兜底**预算(D161)。
|
|
1091
|
+
*
|
|
1092
|
+
* 取舍(两种做法里选了后者):
|
|
1093
|
+
* - ①「把外层提到大于单目标最坏序列耗时」——`attention()` 是 `listContainers` +
|
|
1094
|
+
* 最多约 9 批 `inspect` 的**串行**序列(`ATTENTION_INSPECT_CAP 300 + 补捞 50`,
|
|
1095
|
+
* 每批 ≤ 40),最坏 10 × 30s ≈ 300s。真按这个上界设,总览页等一台卡住的机器要
|
|
1096
|
+
* 等五分钟,等于没有预算;
|
|
1097
|
+
* - ②**内层超时是唯一真相源,外层只做兜底** ← 选这个。30s/命令已经给每条命令兜了底,
|
|
1098
|
+
* 外层存在的意义只是「内层没兜住时(连接获取 / 闸门排队 / 某个实现没遵守
|
|
1099
|
+
* timeoutMs)别把整页拖死」。所以它取 3 倍单条预算(90s):大于 `attention` 的
|
|
1100
|
+
* 典型两段(ps + 首批 inspect ≈ 60s),不会被健康命令序列撞上;又给异常留了一档
|
|
1101
|
+
* 天花板。到点即 `abort()`——**这是这次修复的实质**,配置数字只是顺带对齐。
|
|
1102
|
+
*
|
|
1103
|
+
* `DSH_DOCKER_AGG_TIMEOUT_MS` 可覆盖,**只为测试与排障**(同 `DSH_DOCKER_SHORT_CHANNELS`
|
|
1104
|
+
* 的口径):90s 这条路径没法在单测里等,而「超时后底层有没有真的被取消」恰恰是本轮
|
|
1105
|
+
* 的验收点——没有这个口子就只能测到「返回了 ok:false」,那正是修复前就恒绿的那条。
|
|
1106
|
+
*/
|
|
1107
|
+
function aggTimeoutMs() {
|
|
1108
|
+
const raw = Number(process.env.DSH_DOCKER_AGG_TIMEOUT_MS);
|
|
1109
|
+
return Number.isFinite(raw) && raw > 0 ? raw : AGG_CMD_TIMEOUT_MS * 3;
|
|
1110
|
+
}
|
|
814
1111
|
async function mapLimit(items, limit, run) {
|
|
815
1112
|
const results = new Array(items.length);
|
|
816
1113
|
let cursor = 0;
|
|
@@ -829,18 +1126,35 @@ const plugin = definePlugin({
|
|
|
829
1126
|
/** 在所有(或指定)目标上跑同一件事,返回按目标分组的「部分成功」结果。 */
|
|
830
1127
|
const aggregateAcrossTargets = async (names, run) => {
|
|
831
1128
|
return await mapLimit(names, AGG_CONCURRENCY, async (name) => {
|
|
832
|
-
|
|
1129
|
+
/*
|
|
1130
|
+
* 控制器必须建在 `run()` **之前**(D161):Runner 是在 `apiFor` 里构造的,signal
|
|
1131
|
+
* 当场绑到 Runner 上(见 createRunner)。先跑再建的话,abort 时底层根本不知道
|
|
1132
|
+
* 自己被取消——那正是修复前的行为。
|
|
1133
|
+
*/
|
|
1134
|
+
const controller = new AbortController();
|
|
1135
|
+
const budget = aggTimeoutMs();
|
|
1136
|
+
const built = apiFor(name, controller.signal);
|
|
833
1137
|
const resolved = built.resolved;
|
|
834
1138
|
const label = resolved === undefined
|
|
835
1139
|
? name
|
|
836
1140
|
: (resolved.kind === 'local' ? '本机' : sshTarget(resolved.spec));
|
|
837
|
-
if (built.api === undefined)
|
|
1141
|
+
if (built.api === undefined) {
|
|
1142
|
+
controller.abort();
|
|
838
1143
|
return { target: name, label, ok: false, error: built.error ?? '无法构造执行通道' };
|
|
1144
|
+
}
|
|
839
1145
|
const api = built.api;
|
|
840
1146
|
let timer = null;
|
|
841
1147
|
try {
|
|
842
1148
|
const timeout = new Promise((_resolve, reject) => {
|
|
843
|
-
timer = setTimeout(() =>
|
|
1149
|
+
timer = setTimeout(() => {
|
|
1150
|
+
/*
|
|
1151
|
+
* 顺序是**先取消、后拒绝**(D161):反过来的话调用方在 reject 的那一刻就拿到
|
|
1152
|
+
* `ok:false` 返回了,而底层(本地子进程 / SSH channel)还在跑——用户看到的是
|
|
1153
|
+
* 「这一格失败了」,实际那条 inspect 还占着远端的 session 槽,后续短命令被拒。
|
|
1154
|
+
*/
|
|
1155
|
+
controller.abort();
|
|
1156
|
+
reject(new Error(`聚合超时(>${String(budget / 1000)}s)`));
|
|
1157
|
+
}, budget);
|
|
844
1158
|
timer.unref?.();
|
|
845
1159
|
});
|
|
846
1160
|
const data = await Promise.race([run(api, name), timeout]);
|
|
@@ -852,6 +1166,12 @@ const plugin = definePlugin({
|
|
|
852
1166
|
finally {
|
|
853
1167
|
if (timer !== null)
|
|
854
1168
|
clearTimeout(timer);
|
|
1169
|
+
/*
|
|
1170
|
+
* race 另一侧赢(命令正常跑完 / 抛错)时也要兜底取消:`abort()` 是幂等的,命令
|
|
1171
|
+
* 已 settle 时它是**无害的空操作**(监听器在收尾时已摘掉),但漏了这一句就会留下
|
|
1172
|
+
* 「signal 永不 abort、挂在它上面的闭包链不放」的尾巴。
|
|
1173
|
+
*/
|
|
1174
|
+
controller.abort();
|
|
855
1175
|
}
|
|
856
1176
|
});
|
|
857
1177
|
};
|
|
@@ -1548,6 +1868,41 @@ const plugin = definePlugin({
|
|
|
1548
1868
|
return { targets: rows };
|
|
1549
1869
|
},
|
|
1550
1870
|
}));
|
|
1871
|
+
add('docker_connect_local', defineTool({
|
|
1872
|
+
name: 'docker_connect_local',
|
|
1873
|
+
// 只读之外的唯一副作用是**写本插件自己的配置**,没有别的写路径;并发调用被 connectLocal 的幂等语义收敛
|
|
1874
|
+
isConcurrencySafe: () => true,
|
|
1875
|
+
description: '添加或复用本机 Docker 目标并探测 daemon,返回目标名、是否新建和连接状态及失败原因。保留已有 SSH 配置;不启动或安装 Docker,不改变 context 或能力授权。随后将返回的 name 作为 docker_ps 等工具的 target。',
|
|
1876
|
+
parameters: {},
|
|
1877
|
+
output: {
|
|
1878
|
+
schema: {
|
|
1879
|
+
type: 'object',
|
|
1880
|
+
additionalProperties: false,
|
|
1881
|
+
properties: {
|
|
1882
|
+
name: { type: 'string', required: true },
|
|
1883
|
+
kind: { type: 'string', required: true },
|
|
1884
|
+
created: { type: 'boolean', required: true },
|
|
1885
|
+
saved: { type: 'boolean', required: true },
|
|
1886
|
+
reachable: { type: 'boolean', required: true },
|
|
1887
|
+
message: { type: 'string', required: true },
|
|
1888
|
+
serverVersion: { type: 'string' },
|
|
1889
|
+
},
|
|
1890
|
+
},
|
|
1891
|
+
render: (_args, value) => {
|
|
1892
|
+
const row = value;
|
|
1893
|
+
return [{
|
|
1894
|
+
type: 'text',
|
|
1895
|
+
text: `${row.message}\n后续查询请传 target: ${JSON.stringify(row.name)}`,
|
|
1896
|
+
}];
|
|
1897
|
+
},
|
|
1898
|
+
},
|
|
1899
|
+
async execute() {
|
|
1900
|
+
const outcome = await connectLocal();
|
|
1901
|
+
if (!outcome.ok)
|
|
1902
|
+
throw new Error(outcome.error);
|
|
1903
|
+
return outcome.result;
|
|
1904
|
+
},
|
|
1905
|
+
}));
|
|
1551
1906
|
add('docker_ps', defineTool({
|
|
1552
1907
|
name: 'docker_ps',
|
|
1553
1908
|
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
@@ -3039,6 +3394,29 @@ const plugin = definePlugin({
|
|
|
3039
3394
|
writeJson(res, 200, { ok: true, config: snapshot(), ...(warning === undefined ? {} : { warning }) });
|
|
3040
3395
|
return;
|
|
3041
3396
|
}
|
|
3397
|
+
/*
|
|
3398
|
+
* 一键连接本机:把「加一个 local 目标」从「先开设置卡片、手填名字、保存、再切回来」压缩成一下。
|
|
3399
|
+
* 为什么是显式按钮而不是隐式默认目标:空 `targets` 的语义(= 用户没配、面板给引导)不能被悄悄改掉。
|
|
3400
|
+
* 只读探测的结果作为 `message` 回传(含「daemon 未启动 / CLI 缺失」的成因),
|
|
3401
|
+
* 客户端拿它直接给提示,不必再单独发一次 /probe 往返。
|
|
3402
|
+
*/
|
|
3403
|
+
if (sub === '/connect-local') {
|
|
3404
|
+
if (req.method !== 'POST') {
|
|
3405
|
+
writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
|
|
3406
|
+
return;
|
|
3407
|
+
}
|
|
3408
|
+
if (!hasSameOriginProof(req)) {
|
|
3409
|
+
writeJson(res, 403, { error: originProofHint(req) });
|
|
3410
|
+
return;
|
|
3411
|
+
}
|
|
3412
|
+
const outcome = await connectLocal();
|
|
3413
|
+
if (!outcome.ok) {
|
|
3414
|
+
writeJson(res, outcome.code, { error: outcome.error });
|
|
3415
|
+
return;
|
|
3416
|
+
}
|
|
3417
|
+
writeJson(res, 200, { ok: true, result: outcome.result, config: snapshot() });
|
|
3418
|
+
return;
|
|
3419
|
+
}
|
|
3042
3420
|
if (sub === '/targets') {
|
|
3043
3421
|
if (req.method !== 'GET' && req.method !== 'POST') {
|
|
3044
3422
|
writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
|
|
@@ -3408,6 +3786,7 @@ const plugin = definePlugin({
|
|
|
3408
3786
|
// 卡片是自定义页(plugins.row.config),别再让 DSH 为本 entry 自动生成一份。
|
|
3409
3787
|
const offAutoPage = suppressAutoSettingsPage(settingsCtx, ctx);
|
|
3410
3788
|
settingsScope = scope;
|
|
3789
|
+
settingsForms = settingsCtx.settings;
|
|
3411
3790
|
settingsApi = { get: (ns) => readSettingsEntry(settingsCtx, ns) };
|
|
3412
3791
|
// 立刻读一次 resolved 值(schema 默认值 ← composition base ← 用户层)。
|
|
3413
3792
|
// 走 applySection 而不是直接 normalizeConfig:能力开关的**配置值**要一起记下来(见
|
|
@@ -3425,6 +3804,7 @@ const plugin = definePlugin({
|
|
|
3425
3804
|
off();
|
|
3426
3805
|
offAutoPage();
|
|
3427
3806
|
settingsScope = undefined;
|
|
3807
|
+
settingsForms = undefined;
|
|
3428
3808
|
settingsApi = undefined;
|
|
3429
3809
|
};
|
|
3430
3810
|
}, 'dsh-docker: settings');
|