koishi-plugin-docker-control 0.0.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.
@@ -0,0 +1,267 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DockerConnector = void 0;
4
+ /**
5
+ * SSH 连接器 - 通过 SSH 执行 docker 命令
6
+ */
7
+ const ssh2_1 = require("ssh2");
8
+ const constants_1 = require("../constants");
9
+ const config_1 = require("../config");
10
+ const logger_1 = require("../utils/logger");
11
+ class DockerConnector {
12
+ constructor(config, fullConfig) {
13
+ this.config = config;
14
+ this.fullConfig = fullConfig;
15
+ this.sshClient = null;
16
+ this.connected = true;
17
+ }
18
+ /**
19
+ * 执行 SSH 命令
20
+ */
21
+ async exec(command) {
22
+ let lastError;
23
+ // 自动重试一次
24
+ for (let attempt = 0; attempt < 2; attempt++) {
25
+ try {
26
+ return await this.execInternal(command);
27
+ }
28
+ catch (err) {
29
+ lastError = err;
30
+ const msg = err.message || '';
31
+ // 如果是 SSH 通道打开失败,或者是连接已结束,则强制重连
32
+ if (msg.includes('Channel open failure') || msg.includes('Client ended') || msg.includes('Socket ended')) {
33
+ logger_1.connectorLogger.warn(`[${this.config.name}] SSH 连接异常 (${msg}),尝试重连...`);
34
+ this.dispose(); // 强制销毁当前连接
35
+ continue; // 重试
36
+ }
37
+ // 其他错误直接抛出
38
+ throw err;
39
+ }
40
+ }
41
+ throw lastError;
42
+ }
43
+ async execInternal(command) {
44
+ const client = await this.getConnection();
45
+ logger_1.connectorLogger.debug(`[${this.config.name}] 执行命令: ${command}`);
46
+ return new Promise((resolve, reject) => {
47
+ client.exec(command, (err, stream) => {
48
+ if (err) {
49
+ logger_1.connectorLogger.debug(`[${this.config.name}] 命令执行错误: ${err.message}`);
50
+ reject(err);
51
+ return;
52
+ }
53
+ let stdout = '';
54
+ let stderr = '';
55
+ stream.on('close', (code, signal) => {
56
+ logger_1.connectorLogger.debug(`[${this.config.name}] 命令完成: code=${code}, signal=${signal}`);
57
+ // 显式结束 stream 防止 channel 泄露
58
+ try {
59
+ stream.end();
60
+ }
61
+ catch (e) {
62
+ // 可能已经关闭,忽略错误
63
+ }
64
+ if (stderr.includes('Error') || stderr.includes('error')) {
65
+ reject(new Error(stderr.trim()));
66
+ }
67
+ else {
68
+ resolve(stdout.trim());
69
+ }
70
+ });
71
+ stream.on('data', (data) => {
72
+ stdout += data.toString();
73
+ });
74
+ stream.on('err', (data) => {
75
+ stderr += data.toString();
76
+ });
77
+ });
78
+ });
79
+ }
80
+ /**
81
+ * 执行 docker ps 获取容器列表
82
+ */
83
+ async listContainers(all = true) {
84
+ const flag = all ? '-a' : '';
85
+ // 使用双引号包裹 format,以兼容 Windows CMD
86
+ return this.exec(`docker ps -a --format "{{.ID}}|{{.Names}}|{{.Image}}|{{.State}}|{{.Status}}" ${flag}`);
87
+ }
88
+ /**
89
+ * 执行 docker start
90
+ */
91
+ async startContainer(containerId) {
92
+ await this.exec(`docker start ${containerId}`);
93
+ }
94
+ /**
95
+ * 执行 docker stop
96
+ */
97
+ async stopContainer(containerId, timeout = 10) {
98
+ await this.exec(`docker stop -t ${timeout} ${containerId}`);
99
+ }
100
+ /**
101
+ * 执行 docker restart
102
+ */
103
+ async restartContainer(containerId, timeout = 10) {
104
+ await this.exec(`docker restart -t ${timeout} ${containerId}`);
105
+ }
106
+ /**
107
+ * 获取容器日志
108
+ */
109
+ async getLogs(containerId, tail = 100) {
110
+ return this.exec(`docker logs --tail ${tail} ${containerId} 2>&1`);
111
+ }
112
+ /**
113
+ * 执行容器内命令
114
+ */
115
+ async execContainer(containerId, cmd) {
116
+ // 使用 docker exec 需要处理引号
117
+ const escapedCmd = cmd.replace(/'/g, "'\\''");
118
+ return this.exec(`docker exec ${containerId} sh -c '${escapedCmd}'`);
119
+ }
120
+ /**
121
+ * 监听 Docker 事件流
122
+ * @param callback 每行事件数据的回调
123
+ * @returns 停止监听的方法
124
+ */
125
+ async startEventStream(callback) {
126
+ const client = await this.getConnection();
127
+ logger_1.connectorLogger.debug(`[${this.config.name}] 正在启动事件流监听...`);
128
+ return new Promise((resolve, reject) => {
129
+ client.exec(`docker events --format "{{json .}}" --filter "type=container"`, (err, stream) => {
130
+ if (err) {
131
+ logger_1.connectorLogger.error(`[${this.config.name}] 启动事件流失败: ${err.message}`);
132
+ reject(err);
133
+ return;
134
+ }
135
+ logger_1.connectorLogger.info(`[${this.config.name}] Docker 事件流已连接`);
136
+ let buffer = '';
137
+ let closed = false;
138
+ const stop = () => {
139
+ if (!closed) {
140
+ closed = true;
141
+ // [新增] 强制销毁流,防止僵尸连接
142
+ try {
143
+ stream.unpipe();
144
+ stream.destroy();
145
+ this._eventStream = null;
146
+ }
147
+ catch (e) {
148
+ // 可能已经关闭,忽略错误
149
+ }
150
+ logger_1.connectorLogger.debug(`[${this.config.name}] 主动停止事件流`);
151
+ }
152
+ };
153
+ stream.on('close', (code, signal) => {
154
+ if (!closed) {
155
+ closed = true;
156
+ logger_1.connectorLogger.warn(`[${this.config.name}] 事件流意外断开 (Code: ${code}, Signal: ${signal})`);
157
+ }
158
+ });
159
+ stream.on('data', (data) => {
160
+ buffer += data.toString();
161
+ // 按行处理,解决 TCP 粘包问题
162
+ let newlineIndex;
163
+ while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
164
+ const line = buffer.slice(0, newlineIndex).trim();
165
+ buffer = buffer.slice(newlineIndex + 1);
166
+ if (line) {
167
+ callback(line);
168
+ }
169
+ }
170
+ });
171
+ stream.stderr.on('data', (data) => {
172
+ logger_1.connectorLogger.debug(`[${this.config.name}] 事件流 stderr: ${data.toString().trim()}`);
173
+ });
174
+ this._eventStream = stream;
175
+ resolve(stop);
176
+ });
177
+ });
178
+ }
179
+ /**
180
+ * 标记连接状态
181
+ */
182
+ setConnected(status) {
183
+ this.connected = status;
184
+ }
185
+ /**
186
+ * 销毁连接
187
+ */
188
+ dispose() {
189
+ if (this.sshClient) {
190
+ this.sshClient.end();
191
+ this.sshClient = null;
192
+ }
193
+ }
194
+ /**
195
+ * 获取 SSH 连接
196
+ */
197
+ async getConnection() {
198
+ if (!this.sshClient) {
199
+ this.sshClient = await this.createConnection();
200
+ }
201
+ return this.sshClient;
202
+ }
203
+ /**
204
+ * 创建 SSH 连接
205
+ */
206
+ async createConnection() {
207
+ const credential = this.getCredential();
208
+ if (!credential) {
209
+ throw new Error(`凭证不存在: ${this.config.credentialId}`);
210
+ }
211
+ logger_1.connectorLogger.info(`[${this.config.name}] 正在连接到 ${this.config.host}:${this.config.port}`);
212
+ logger_1.connectorLogger.debug(`[${this.config.name}] 用户名: ${credential.username}, 认证方式: ${credential.authType}`);
213
+ return new Promise((resolve, reject) => {
214
+ const conn = new ssh2_1.Client();
215
+ conn.on('ready', () => {
216
+ logger_1.connectorLogger.info(`[${this.config.name}] SSH 连接成功`);
217
+ resolve(conn);
218
+ });
219
+ conn.on('error', (err) => {
220
+ logger_1.connectorLogger.error(`[${this.config.name}] SSH 连接失败: ${err.message}`);
221
+ conn.end();
222
+ reject(err);
223
+ });
224
+ conn.on('close', () => {
225
+ logger_1.connectorLogger.debug(`[${this.config.name}] SSH 连接关闭`);
226
+ });
227
+ conn.on('banner', (msg) => {
228
+ logger_1.connectorLogger.debug(`[${this.config.name}] SSH Banner: ${msg.trim()}`);
229
+ });
230
+ const connectConfig = {
231
+ host: this.config.host,
232
+ port: this.config.port,
233
+ username: credential.username,
234
+ readyTimeout: constants_1.SSH_TIMEOUT,
235
+ timeout: constants_1.SSH_TIMEOUT,
236
+ tryKeyboard: true,
237
+ ...this.buildAuthOptions(credential),
238
+ };
239
+ conn.connect(connectConfig);
240
+ });
241
+ }
242
+ /**
243
+ * 构建认证选项
244
+ */
245
+ buildAuthOptions(credential) {
246
+ const options = {};
247
+ if (credential.authType === 'password') {
248
+ options.password = credential.password;
249
+ }
250
+ else {
251
+ if (credential.privateKey) {
252
+ options.privateKey = Buffer.from(credential.privateKey, 'utf8');
253
+ }
254
+ if (credential.passphrase) {
255
+ options.passphrase = credential.passphrase;
256
+ }
257
+ }
258
+ return options;
259
+ }
260
+ /**
261
+ * 获取凭证配置
262
+ */
263
+ getCredential() {
264
+ return (0, config_1.getCredentialById)(this.fullConfig, this.config.credentialId);
265
+ }
266
+ }
267
+ exports.DockerConnector = DockerConnector;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Docker 服务主类
3
+ * 管理所有 Docker 节点
4
+ */
5
+ import { Context } from 'koishi';
6
+ import type { DockerEvent, DockerControlConfig, ContainerInfo } from '../types';
7
+ import { DockerNode } from './node';
8
+ export declare class DockerService {
9
+ /** Koishi Context */
10
+ private readonly ctx;
11
+ /** 节点映射 */
12
+ private nodes;
13
+ /** 配置 */
14
+ private readonly config;
15
+ /** 全局事件回调集合 - 事件中转站 */
16
+ private eventCallbacks;
17
+ constructor(ctx: Context, config: DockerControlConfig);
18
+ /**
19
+ * 初始化所有节点
20
+ */
21
+ initialize(): Promise<void>;
22
+ /**
23
+ * 【新增】内部方法:分发全局事件
24
+ * 将节点事件转发给所有注册的全局回调
25
+ */
26
+ private dispatchGlobalEvent;
27
+ getNode(id: string): DockerNode | undefined;
28
+ getNodeByName(name: string): DockerNode | undefined;
29
+ getNodesByTag(tag: string): DockerNode[];
30
+ getNodesBySelector(selector: string): DockerNode[];
31
+ getAllNodes(): DockerNode[];
32
+ getOnlineNodes(): DockerNode[];
33
+ getOfflineNodes(): DockerNode[];
34
+ /**
35
+ * 搜索容器
36
+ */
37
+ findContainer(nodeId: string, containerIdOrName: string): Promise<{
38
+ node: DockerNode;
39
+ container: ContainerInfo;
40
+ }>;
41
+ /**
42
+ * 在所有节点上搜索容器
43
+ */
44
+ findContainerGlobal(containerIdOrName: string): Promise<Array<{
45
+ node: DockerNode;
46
+ container: ContainerInfo;
47
+ }>>;
48
+ /**
49
+ * 批量操作容器
50
+ */
51
+ operateContainers(nodeSelector: string, containerSelector: string, operation: 'start' | 'stop' | 'restart'): Promise<Array<{
52
+ node: DockerNode;
53
+ container: ContainerInfo;
54
+ success: boolean;
55
+ error?: string;
56
+ }>>;
57
+ private logNodeList;
58
+ /**
59
+ * 【关键修复】注册全局事件监听
60
+ * 不再遍历节点,而是添加到全局回调列表
61
+ * 无论节点何时创建,事件都能通过 dispatchGlobalEvent 转发
62
+ */
63
+ onNodeEvent(callback: (event: DockerEvent, nodeId: string) => void): () => void;
64
+ stopAll(): Promise<void>;
65
+ }
@@ -0,0 +1,202 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DockerService = void 0;
4
+ const node_1 = require("./node");
5
+ const logger_1 = require("../utils/logger");
6
+ class DockerService {
7
+ constructor(ctx, config) {
8
+ /** 节点映射 */
9
+ this.nodes = new Map();
10
+ /** 全局事件回调集合 - 事件中转站 */
11
+ this.eventCallbacks = new Set();
12
+ this.ctx = ctx;
13
+ this.config = config;
14
+ }
15
+ /**
16
+ * 初始化所有节点
17
+ */
18
+ async initialize() {
19
+ logger_1.logger.info('初始化 Docker 服务...');
20
+ const nodeConfigs = this.config.nodes || [];
21
+ const credentials = this.config.credentials || [];
22
+ for (const nodeConfig of nodeConfigs) {
23
+ // 查找对应的凭证
24
+ const credential = credentials.find(c => c.id === nodeConfig.credentialId);
25
+ if (!credential) {
26
+ logger_1.logger.warn(`节点 ${nodeConfig.name} 找不到凭证 ${nodeConfig.credentialId},跳过`);
27
+ continue;
28
+ }
29
+ const node = new node_1.DockerNode(nodeConfig, credential, this.config.debug);
30
+ // 【关键修复】创建节点时,立即绑定事件转发
31
+ // 无论 index.ts 何时调用 onNodeEvent,这里都会把事件转发给 eventCallbacks
32
+ node.onEvent((event) => {
33
+ this.dispatchGlobalEvent(event, node.id);
34
+ });
35
+ this.nodes.set(nodeConfig.id, node);
36
+ logger_1.logger.info(`节点已创建: ${nodeConfig.name} (${nodeConfig.id})`);
37
+ }
38
+ this.logNodeList();
39
+ // 连接所有节点
40
+ const promises = [];
41
+ for (const node of this.nodes.values()) {
42
+ promises.push(node.connect().catch((e) => {
43
+ logger_1.logger.warn(`节点 ${node.name} 连接失败: ${e}`);
44
+ }));
45
+ }
46
+ await Promise.allSettled(promises);
47
+ const online = [...this.nodes.values()].filter((n) => n.status === 'connected').length;
48
+ const offline = [...this.nodes.values()].filter((n) => n.status === 'error' || n.status === 'disconnected').length;
49
+ logger_1.logger.info(`连接完成: ${online} 在线, ${offline} 离线`);
50
+ }
51
+ /**
52
+ * 【新增】内部方法:分发全局事件
53
+ * 将节点事件转发给所有注册的全局回调
54
+ */
55
+ dispatchGlobalEvent(event, nodeId) {
56
+ for (const callback of this.eventCallbacks) {
57
+ try {
58
+ callback(event, nodeId);
59
+ }
60
+ catch (e) {
61
+ logger_1.logger.error(`事件回调执行错误: ${e}`);
62
+ }
63
+ }
64
+ }
65
+ getNode(id) {
66
+ return this.nodes.get(id);
67
+ }
68
+ getNodeByName(name) {
69
+ return [...this.nodes.values()].find((n) => n.name === name);
70
+ }
71
+ getNodesByTag(tag) {
72
+ return [...this.nodes.values()].filter((n) => n.tags.includes(tag));
73
+ }
74
+ getNodesBySelector(selector) {
75
+ if (selector === 'all' || !selector) {
76
+ return [...this.nodes.values()];
77
+ }
78
+ if (selector.startsWith('@')) {
79
+ const tag = selector.slice(1);
80
+ return this.getNodesByTag(tag);
81
+ }
82
+ const node = this.getNode(selector) || this.getNodeByName(selector);
83
+ return node ? [node] : [];
84
+ }
85
+ getAllNodes() {
86
+ return [...this.nodes.values()];
87
+ }
88
+ getOnlineNodes() {
89
+ return [...this.nodes.values()].filter((n) => n.status === 'connected');
90
+ }
91
+ getOfflineNodes() {
92
+ return [...this.nodes.values()].filter((n) => n.status === 'error' || n.status === 'disconnected');
93
+ }
94
+ /**
95
+ * 搜索容器
96
+ */
97
+ async findContainer(nodeId, containerIdOrName) {
98
+ const node = this.getNode(nodeId);
99
+ if (!node) {
100
+ throw new Error(`节点不存在: ${nodeId}`);
101
+ }
102
+ const containers = await node.listContainers(true);
103
+ let container = containers.find((c) => c.Id.startsWith(containerIdOrName));
104
+ if (!container) {
105
+ container = containers.find((c) => c.Names.some((n) => n.replace('/', '') === containerIdOrName));
106
+ }
107
+ if (!container) {
108
+ container = containers.find((c) => c.Names.some((n) => n.includes(containerIdOrName)));
109
+ }
110
+ if (!container) {
111
+ throw new Error(`找不到容器: ${containerIdOrName}`);
112
+ }
113
+ return { node, container };
114
+ }
115
+ /**
116
+ * 在所有节点上搜索容器
117
+ */
118
+ async findContainerGlobal(containerIdOrName) {
119
+ const results = [];
120
+ for (const node of this.getOnlineNodes()) {
121
+ try {
122
+ const containers = await node.listContainers(true);
123
+ let container = containers.find((c) => c.Id.startsWith(containerIdOrName));
124
+ if (!container) {
125
+ container = containers.find((c) => c.Names.some((n) => n.replace('/', '') === containerIdOrName));
126
+ }
127
+ if (container) {
128
+ results.push({ node, container });
129
+ }
130
+ }
131
+ catch (e) {
132
+ logger_1.logger.warn(`[${node.name}] 搜索容器失败: ${e}`);
133
+ }
134
+ }
135
+ return results;
136
+ }
137
+ /**
138
+ * 批量操作容器
139
+ */
140
+ async operateContainers(nodeSelector, containerSelector, operation) {
141
+ const nodes = this.getNodesBySelector(nodeSelector);
142
+ const results = [];
143
+ for (const node of nodes) {
144
+ if (node.status !== 'connected') {
145
+ continue;
146
+ }
147
+ try {
148
+ const { container } = await this.findContainer(node.id, containerSelector);
149
+ switch (operation) {
150
+ case 'start':
151
+ await node.startContainer(container.Id);
152
+ break;
153
+ case 'stop':
154
+ await node.stopContainer(container.Id);
155
+ break;
156
+ case 'restart':
157
+ await node.restartContainer(container.Id);
158
+ break;
159
+ }
160
+ results.push({ node, container, success: true });
161
+ }
162
+ catch (e) {
163
+ results.push({
164
+ node,
165
+ container: { Id: '', Names: [], State: 'stopped' },
166
+ success: false,
167
+ error: e.message,
168
+ });
169
+ }
170
+ }
171
+ return results;
172
+ }
173
+ logNodeList() {
174
+ logger_1.logger.info('=== Docker 节点列表 ===');
175
+ for (const node of this.nodes.values()) {
176
+ const tags = node.tags.length > 0 ? ` [@${node.tags.join(' @')}]` : '';
177
+ logger_1.logger.info(` - ${node.name} (${node.id})${tags}`);
178
+ }
179
+ logger_1.logger.info('======================');
180
+ }
181
+ /**
182
+ * 【关键修复】注册全局事件监听
183
+ * 不再遍历节点,而是添加到全局回调列表
184
+ * 无论节点何时创建,事件都能通过 dispatchGlobalEvent 转发
185
+ */
186
+ onNodeEvent(callback) {
187
+ this.eventCallbacks.add(callback);
188
+ // 返回取消订阅函数
189
+ return () => {
190
+ this.eventCallbacks.delete(callback);
191
+ };
192
+ }
193
+ async stopAll() {
194
+ for (const node of this.nodes.values()) {
195
+ await node.dispose();
196
+ }
197
+ this.nodes.clear();
198
+ this.eventCallbacks.clear();
199
+ logger_1.logger.info('Docker 服务已停止');
200
+ }
201
+ }
202
+ exports.DockerService = DockerService;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * 监控器 - 智能事件处理
3
+ * 实现防抖、抖动检测
4
+ */
5
+ import type { DockerEvent } from '../types';
6
+ import { DockerNode } from './node';
7
+ export interface ProcessedEvent {
8
+ eventType: string;
9
+ action: string;
10
+ nodeId: string;
11
+ nodeName: string;
12
+ containerId: string;
13
+ containerName: string;
14
+ timestamp: number;
15
+ }
16
+ export declare class MonitorManager {
17
+ private config;
18
+ /** 容器状态映射: nodeId -> containerId -> State */
19
+ private states;
20
+ /** 全局回调 */
21
+ private callback?;
22
+ constructor(config?: {
23
+ debounceWait?: number;
24
+ flappingWindow?: number;
25
+ flappingThreshold?: number;
26
+ });
27
+ /**
28
+ * 处理原始 Docker 事件
29
+ */
30
+ processEvent(node: DockerNode, event: DockerEvent): void;
31
+ /**
32
+ * 注册处理后事件的回调
33
+ */
34
+ onProcessedEvent(callback: (event: ProcessedEvent) => void): () => void;
35
+ private emit;
36
+ private getContainerState;
37
+ private cleanHistory;
38
+ }