plugin-cluster-manager 1.1.16 → 1.1.17
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/dist/client/index.js +1 -1
- package/dist/client-v2/376.cd1d86e85a50088e.js +10 -0
- package/dist/client-v2/index.js +1 -1
- package/dist/externalVersion.js +6 -6
- package/dist/locale/en-US.json +16 -6
- package/dist/locale/vi-VN.json +16 -6
- package/dist/locale/zh-CN.json +16 -6
- package/dist/server/actions/cluster-nodes.js +43 -10
- package/dist/server/actions/doctor.js +69 -4
- package/dist/server/actions/event-queue-monitor.js +33 -3
- package/dist/server/actions/orchestrator.js +48 -32
- package/dist/server/actions/queue-mappings.js +1 -0
- package/dist/server/actions/tasks.js +8 -8
- package/dist/server/adapters/redis-event-queue-adapter.js +188 -0
- package/dist/server/adapters/redis-node-registry.js +42 -3
- package/dist/server/collections/orchestrator-stacks.js +6 -0
- package/dist/server/collections/worker-queue-mappings.js +1 -1
- package/dist/server/orchestrator/PackageManager.js +47 -12
- package/dist/server/plugin.js +36 -5
- package/dist/server/queue-scanner.js +54 -34
- package/dist/server/utils/node.js +3 -7
- package/dist/server/utils/redis.js +37 -9
- package/dist/shared/worker-processes.js +233 -0
- package/package.json +1 -1
- package/src/client/ClusterNodes.tsx +76 -10
- package/src/client/ContainerOrchestrator.tsx +146 -8
- package/src/client/QueueAssignment.tsx +10 -2
- package/src/locale/en-US.json +16 -6
- package/src/locale/vi-VN.json +16 -6
- package/src/locale/zh-CN.json +16 -6
- package/src/server/__tests__/worker-processes.test.ts +42 -0
- package/src/server/actions/cluster-nodes.ts +43 -8
- package/src/server/actions/doctor.ts +77 -0
- package/src/server/actions/event-queue-monitor.ts +34 -3
- package/src/server/actions/orchestrator.ts +58 -38
- package/src/server/actions/queue-mappings.ts +1 -0
- package/src/server/actions/tasks.ts +142 -142
- package/src/server/adapters/redis-event-queue-adapter.ts +189 -0
- package/src/server/adapters/redis-node-registry.ts +44 -4
- package/src/server/collections/orchestrator-stacks.ts +6 -0
- package/src/server/collections/worker-queue-mappings.ts +3 -3
- package/src/server/orchestrator/PackageManager.ts +48 -11
- package/src/server/orchestrator/types.ts +5 -4
- package/src/server/plugin.ts +40 -6
- package/src/server/queue-scanner.ts +65 -51
- package/src/server/utils/node.ts +3 -10
- package/src/server/utils/redis.ts +39 -4
- package/src/shared/worker-processes.ts +216 -0
- package/dist/client-v2/914.c0bce51908fd81d7.js +0 -10
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { Application, IEventQueueAdapter, QueueEventOptions, QueueMessageOptions } from '@nocobase/server';
|
|
2
|
+
import { createClient } from 'redis';
|
|
3
|
+
import { randomUUID } from 'crypto';
|
|
4
|
+
import { workerModeServesProcess } from '../../shared/worker-processes';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_INTERVAL_MS = 250;
|
|
7
|
+
const DEFAULT_CONCURRENCY = 1;
|
|
8
|
+
const DEFAULT_ACK_TIMEOUT_MS = 15_000;
|
|
9
|
+
const REDIS_QUEUE_PREFIX = 'nocobase:event-queue';
|
|
10
|
+
|
|
11
|
+
type RedisClient = ReturnType<typeof createClient>;
|
|
12
|
+
|
|
13
|
+
type StoredMessage = {
|
|
14
|
+
id: string;
|
|
15
|
+
content: unknown;
|
|
16
|
+
options?: QueueMessageOptions;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function sleep(ms: number) {
|
|
20
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function createTimeoutSignal(timeout: number): AbortSignal {
|
|
24
|
+
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
|
|
25
|
+
return AbortSignal.timeout(timeout);
|
|
26
|
+
}
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
setTimeout(() => controller.abort(), timeout);
|
|
29
|
+
return controller.signal;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class RedisEventQueueAdapter implements IEventQueueAdapter {
|
|
33
|
+
private client: RedisClient;
|
|
34
|
+
private connected = false;
|
|
35
|
+
private events = new Map<string, QueueEventOptions>();
|
|
36
|
+
private reading = new Map<string, Set<Promise<void>>>();
|
|
37
|
+
private consuming = new Set<string>();
|
|
38
|
+
|
|
39
|
+
constructor(
|
|
40
|
+
private readonly options: {
|
|
41
|
+
app: Application;
|
|
42
|
+
url: string;
|
|
43
|
+
},
|
|
44
|
+
) {
|
|
45
|
+
this.client = createClient({ url: options.url });
|
|
46
|
+
this.client.on('error', (error) => {
|
|
47
|
+
this.options.app.logger.error(`[RedisEventQueueAdapter] Redis error: ${error.message}`);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
isConnected(): boolean {
|
|
52
|
+
return this.connected;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async connect(): Promise<void> {
|
|
56
|
+
if (this.connected) return;
|
|
57
|
+
if (!this.client.isOpen) {
|
|
58
|
+
await this.client.connect();
|
|
59
|
+
}
|
|
60
|
+
this.connected = true;
|
|
61
|
+
for (const channel of this.events.keys()) {
|
|
62
|
+
this.startConsumer(channel);
|
|
63
|
+
}
|
|
64
|
+
this.options.app.logger.info('[RedisEventQueueAdapter] Connected');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async close(): Promise<void> {
|
|
68
|
+
this.connected = false;
|
|
69
|
+
const batches = Array.from(this.reading.values()).flatMap((items) => Array.from(items));
|
|
70
|
+
if (batches.length) {
|
|
71
|
+
await Promise.allSettled(batches);
|
|
72
|
+
}
|
|
73
|
+
if (this.client.isOpen) {
|
|
74
|
+
await this.client.quit().catch(() => this.client.disconnect());
|
|
75
|
+
}
|
|
76
|
+
this.options.app.logger.info('[RedisEventQueueAdapter] Closed');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
subscribe(channel: string, event: QueueEventOptions): void {
|
|
80
|
+
this.events.set(channel, event);
|
|
81
|
+
if (this.connected) {
|
|
82
|
+
this.startConsumer(channel);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
unsubscribe(channel: string): void {
|
|
87
|
+
this.events.delete(channel);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async publish(channel: string, content: unknown, options: QueueMessageOptions = {}): Promise<void> {
|
|
91
|
+
if (!this.connected) {
|
|
92
|
+
throw new Error('redis event queue is not connected');
|
|
93
|
+
}
|
|
94
|
+
const message: StoredMessage = {
|
|
95
|
+
id: randomUUID(),
|
|
96
|
+
content,
|
|
97
|
+
options: {
|
|
98
|
+
...options,
|
|
99
|
+
timestamp: Date.now(),
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
await this.client.rPush(this.getQueueKey(channel), JSON.stringify(message));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private getQueueKey(channel: string) {
|
|
106
|
+
return `${REDIS_QUEUE_PREFIX}:${channel}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private startConsumer(channel: string) {
|
|
110
|
+
if (this.consuming.has(channel)) return;
|
|
111
|
+
this.consuming.add(channel);
|
|
112
|
+
return this.consume(channel)
|
|
113
|
+
.catch((error) => {
|
|
114
|
+
this.options.app.logger.error(`[RedisEventQueueAdapter] Consumer failed for ${channel}: ${error.message}`);
|
|
115
|
+
})
|
|
116
|
+
.finally(() => {
|
|
117
|
+
this.consuming.delete(channel);
|
|
118
|
+
if (this.connected && this.events.has(channel)) {
|
|
119
|
+
this.startConsumer(channel);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private async consume(channel: string) {
|
|
125
|
+
while (this.connected && this.events.has(channel)) {
|
|
126
|
+
const event = this.events.get(channel);
|
|
127
|
+
if (event && this.canProcess(channel, event)) {
|
|
128
|
+
this.read(channel, event);
|
|
129
|
+
}
|
|
130
|
+
await sleep(event?.interval || DEFAULT_INTERVAL_MS);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private canProcess(channel: string, event: QueueEventOptions) {
|
|
135
|
+
if (!workerModeServesProcess(process.env.WORKER_MODE, channel)) {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
return event.idle();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private read(channel: string, event: QueueEventOptions) {
|
|
142
|
+
const active = this.reading.get(channel) || new Set<Promise<void>>();
|
|
143
|
+
this.reading.set(channel, active);
|
|
144
|
+
|
|
145
|
+
const available = (event.concurrency || DEFAULT_CONCURRENCY) - active.size;
|
|
146
|
+
for (let index = 0; index < available; index += 1) {
|
|
147
|
+
const promise = this.readOne(channel, event).finally(() => active.delete(promise));
|
|
148
|
+
active.add(promise);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private async readOne(channel: string, event: QueueEventOptions) {
|
|
153
|
+
const raw = await this.client.lPop(this.getQueueKey(channel));
|
|
154
|
+
if (!raw) return;
|
|
155
|
+
|
|
156
|
+
let message: StoredMessage;
|
|
157
|
+
try {
|
|
158
|
+
message = JSON.parse(raw);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
this.options.app.logger.warn(`[RedisEventQueueAdapter] Dropped invalid message from ${channel}`, error);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
await this.process(channel, event, message);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private async process(channel: string, event: QueueEventOptions, message: StoredMessage) {
|
|
168
|
+
const { timeout = DEFAULT_ACK_TIMEOUT_MS, maxRetries = 0, retried = 0 } = message.options || {};
|
|
169
|
+
try {
|
|
170
|
+
await event.process(message.content, {
|
|
171
|
+
id: message.id,
|
|
172
|
+
retried,
|
|
173
|
+
signal: createTimeoutSignal(timeout),
|
|
174
|
+
queueOptions: message.options,
|
|
175
|
+
});
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (maxRetries > 0 && retried < maxRetries) {
|
|
178
|
+
await this.publish(channel, message.content, {
|
|
179
|
+
...message.options,
|
|
180
|
+
timeout,
|
|
181
|
+
maxRetries,
|
|
182
|
+
retried: retried + 1,
|
|
183
|
+
});
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
this.options.app.logger.error(`[RedisEventQueueAdapter] Message failed on ${channel}`, error);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import os from 'os';
|
|
2
|
-
import { scanKeys, getRedisClient } from '../utils/redis';
|
|
2
|
+
import { scanKeys, getRedisClient, isClusterRedisConfigured } from '../utils/redis';
|
|
3
3
|
import { getLocalNodeId } from '../utils/node';
|
|
4
4
|
|
|
5
5
|
export class RedisNodeRegistry {
|
|
@@ -7,6 +7,10 @@ export class RedisNodeRegistry {
|
|
|
7
7
|
private readonly ttlSecs = 30; // 30 seconds TTL
|
|
8
8
|
private readonly intervalMs = 10000; // Heartbeat every 10 seconds
|
|
9
9
|
private readonly keyPrefix = 'cluster-manager:nodes:';
|
|
10
|
+
private warnedMissingRedis = false;
|
|
11
|
+
private lastHeartbeatAt: number | null = null;
|
|
12
|
+
private lastHeartbeatError: string | null = null;
|
|
13
|
+
private lastReadError: string | null = null;
|
|
10
14
|
|
|
11
15
|
constructor(private app: any) {}
|
|
12
16
|
|
|
@@ -33,7 +37,16 @@ export class RedisNodeRegistry {
|
|
|
33
37
|
|
|
34
38
|
private async heartbeat() {
|
|
35
39
|
const redis = getRedisClient(this.app);
|
|
36
|
-
if (!redis)
|
|
40
|
+
if (!redis) {
|
|
41
|
+
this.lastHeartbeatError = 'Redis is not configured for cluster node discovery';
|
|
42
|
+
if (!this.warnedMissingRedis) {
|
|
43
|
+
this.warnedMissingRedis = true;
|
|
44
|
+
this.app.logger.warn(
|
|
45
|
+
'[RedisNodeRegistry] Redis is not configured; Cluster Nodes can only show the local fallback node.',
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
37
50
|
|
|
38
51
|
// Unique identifier combining hostname, port, pid, mode, and appName to handle multiple workers on the same host
|
|
39
52
|
const port = process.env.APP_PORT || 'unknown';
|
|
@@ -69,6 +82,8 @@ export class RedisNodeRegistry {
|
|
|
69
82
|
arch: process.arch,
|
|
70
83
|
uptime: process.uptime(),
|
|
71
84
|
workerMode: mode,
|
|
85
|
+
appRole: process.env.APP_ROLE || '',
|
|
86
|
+
isSandbox: process.env.SKILL_HUB_SANDBOX === 'true',
|
|
72
87
|
appPort: port,
|
|
73
88
|
clusterMode: process.env.CLUSTER_MODE || '',
|
|
74
89
|
},
|
|
@@ -90,18 +105,27 @@ export class RedisNodeRegistry {
|
|
|
90
105
|
|
|
91
106
|
try {
|
|
92
107
|
await redis.sendCommand(['SET', key, JSON.stringify(metadata), 'EX', this.ttlSecs.toString()]);
|
|
108
|
+
this.lastHeartbeatAt = Date.now();
|
|
109
|
+
this.lastHeartbeatError = null;
|
|
93
110
|
} catch (err: any) {
|
|
111
|
+
this.lastHeartbeatError = err.message;
|
|
94
112
|
this.app.logger.error(`[RedisNodeRegistry] Heartbeat failed: ${err.message}`);
|
|
95
113
|
}
|
|
96
114
|
}
|
|
97
115
|
|
|
98
116
|
public async getNodes(): Promise<any[]> {
|
|
99
117
|
const redis = getRedisClient(this.app);
|
|
100
|
-
if (!redis)
|
|
118
|
+
if (!redis) {
|
|
119
|
+
this.lastReadError = 'Redis is not configured for cluster node discovery';
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
101
122
|
|
|
102
123
|
try {
|
|
103
124
|
const rawKeys = await scanKeys(redis, `${this.keyPrefix}*`);
|
|
104
|
-
if (rawKeys.length === 0)
|
|
125
|
+
if (rawKeys.length === 0) {
|
|
126
|
+
this.lastReadError = null;
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
105
129
|
|
|
106
130
|
const values = await redis.sendCommand(['MGET', ...rawKeys]);
|
|
107
131
|
|
|
@@ -117,10 +141,26 @@ export class RedisNodeRegistry {
|
|
|
117
141
|
}
|
|
118
142
|
}
|
|
119
143
|
}
|
|
144
|
+
this.lastReadError = null;
|
|
120
145
|
return nodes;
|
|
121
146
|
} catch (err: any) {
|
|
147
|
+
this.lastReadError = err.message;
|
|
122
148
|
this.app.logger.error(`[RedisNodeRegistry] Error fetching nodes: ${err.message}`);
|
|
123
149
|
return [];
|
|
124
150
|
}
|
|
125
151
|
}
|
|
152
|
+
|
|
153
|
+
public getStatus() {
|
|
154
|
+
const redis = getRedisClient(this.app);
|
|
155
|
+
return {
|
|
156
|
+
configured: isClusterRedisConfigured(this.app),
|
|
157
|
+
connected: Boolean(redis),
|
|
158
|
+
keyPrefix: this.keyPrefix,
|
|
159
|
+
ttlSecs: this.ttlSecs,
|
|
160
|
+
intervalMs: this.intervalMs,
|
|
161
|
+
lastHeartbeatAt: this.lastHeartbeatAt,
|
|
162
|
+
lastHeartbeatError: this.lastHeartbeatError,
|
|
163
|
+
lastReadError: this.lastReadError,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
126
166
|
}
|
|
@@ -44,6 +44,12 @@ export default {
|
|
|
44
44
|
interface: 'json',
|
|
45
45
|
uiSchema: { title: 'Environment Variables' },
|
|
46
46
|
},
|
|
47
|
+
{
|
|
48
|
+
name: 'workerMode',
|
|
49
|
+
type: 'string',
|
|
50
|
+
interface: 'input',
|
|
51
|
+
uiSchema: { title: 'Worker Mode', 'x-component': 'Input' },
|
|
52
|
+
},
|
|
47
53
|
{
|
|
48
54
|
name: 'volumes',
|
|
49
55
|
type: 'json',
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* When a stack has assigned queues, the orchestrator adapter sets
|
|
6
6
|
* WORKER_MODE=<comma-separated-queue-names> on new containers.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Explicit orchestratorStacks.workerMode takes precedence. If no explicit
|
|
9
|
+
* mode exists, mappings can still provide WORKER_MODE for legacy stacks.
|
|
10
10
|
*/
|
|
11
11
|
export default {
|
|
12
12
|
name: 'workerQueueMappings',
|
|
@@ -67,7 +67,7 @@ export default {
|
|
|
67
67
|
'x-component': 'Select',
|
|
68
68
|
'x-component-props': {
|
|
69
69
|
allowClear: true,
|
|
70
|
-
placeholder: 'Unassigned (
|
|
70
|
+
placeholder: 'Unassigned (fallback only)',
|
|
71
71
|
},
|
|
72
72
|
},
|
|
73
73
|
},
|
|
@@ -8,6 +8,17 @@ import Application from '@nocobase/server';
|
|
|
8
8
|
/** Allow only safe package name characters: letters, digits, dash, underscore, dot, @, /, [, ] */
|
|
9
9
|
const SAFE_PKG_RE = /^(?:[a-zA-Z0-9_.@/-]|\[|\])+$/;
|
|
10
10
|
const INSTALL_CHANNEL = 'cluster-manager.install-packages';
|
|
11
|
+
const APT_ENV: NodeJS.ProcessEnv = {
|
|
12
|
+
DEBIAN_FRONTEND: 'noninteractive',
|
|
13
|
+
DEBCONF_NONINTERACTIVE_SEEN: 'true',
|
|
14
|
+
APT_LISTCHANGES_FRONTEND: 'none',
|
|
15
|
+
TERM: 'dumb',
|
|
16
|
+
NEEDRESTART_MODE: 'a',
|
|
17
|
+
UCF_FORCE_CONFOLD: '1',
|
|
18
|
+
UCF_FORCE_CONFFOLD: '1',
|
|
19
|
+
};
|
|
20
|
+
const APT_DPKG_OPTIONS = ['-o', 'Dpkg::Options::=--force-confdef', '-o', 'Dpkg::Options::=--force-confold'];
|
|
21
|
+
const DPKG_CONFIGURE_OPTIONS = ['--force-confdef', '--force-confold', '--configure', '-a'];
|
|
11
22
|
|
|
12
23
|
type TargetRole = 'app' | 'worker' | 'sandbox' | 'all';
|
|
13
24
|
type PackageKind = 'apt' | 'npm' | 'python';
|
|
@@ -164,22 +175,27 @@ export class PackageManager {
|
|
|
164
175
|
};
|
|
165
176
|
|
|
166
177
|
// Step 1: APT packages
|
|
167
|
-
if (
|
|
178
|
+
if (safePackages.apt.length > 0) {
|
|
168
179
|
if (registryConfig.aptMirrorUrl) {
|
|
169
180
|
const aptMirrorUrl = sanitizeHttpUrl(registryConfig.aptMirrorUrl, 'APT mirror URL');
|
|
170
181
|
logs.push(`Applying APT mirror: ${redactUrl(aptMirrorUrl)}`);
|
|
171
182
|
await this.configureAptMirror(aptMirrorUrl, logs);
|
|
172
183
|
}
|
|
173
184
|
|
|
174
|
-
await this.updateInstallStatus('running', 20, '
|
|
175
|
-
await this.
|
|
176
|
-
|
|
177
|
-
'
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
185
|
+
await this.updateInstallStatus('running', 20, 'Preparing APT/dpkg...', logs);
|
|
186
|
+
await this.repairAptState(logs);
|
|
187
|
+
if (missingPackages.apt.length > 0) {
|
|
188
|
+
await this.updateInstallStatus('running', 25, 'Installing APT packages...', logs);
|
|
189
|
+
await this.runCommand('apt-get', ['update', '-qq'], 'Updating APT package index...', logs, 1200000, APT_ENV);
|
|
190
|
+
await this.runCommand(
|
|
191
|
+
'apt-get',
|
|
192
|
+
[...APT_DPKG_OPTIONS, 'install', '-y', '--no-install-recommends', ...missingPackages.apt],
|
|
193
|
+
'Installing APT packages...',
|
|
194
|
+
logs,
|
|
195
|
+
1200000,
|
|
196
|
+
APT_ENV,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
183
199
|
}
|
|
184
200
|
|
|
185
201
|
// Step 2: NPM packages (Global)
|
|
@@ -277,12 +293,13 @@ export class PackageManager {
|
|
|
277
293
|
label: string,
|
|
278
294
|
logs: string[],
|
|
279
295
|
timeoutMs = 1200000,
|
|
296
|
+
env?: NodeJS.ProcessEnv,
|
|
280
297
|
): Promise<void> {
|
|
281
298
|
logs.push(`RUNNING: ${formatCommand(command, args)}`);
|
|
282
299
|
logs.push(`${label}`);
|
|
283
300
|
|
|
284
301
|
await new Promise<void>((resolve, reject) => {
|
|
285
|
-
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
302
|
+
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, ...env } });
|
|
286
303
|
let stdout = '';
|
|
287
304
|
let stderr = '';
|
|
288
305
|
let settled = false;
|
|
@@ -324,6 +341,26 @@ export class PackageManager {
|
|
|
324
341
|
});
|
|
325
342
|
}
|
|
326
343
|
|
|
344
|
+
private async repairAptState(logs: string[]): Promise<void> {
|
|
345
|
+
logs.push('Preparing APT/dpkg in non-interactive mode...');
|
|
346
|
+
await this.runCommand(
|
|
347
|
+
'dpkg',
|
|
348
|
+
DPKG_CONFIGURE_OPTIONS,
|
|
349
|
+
'Repairing interrupted dpkg configuration...',
|
|
350
|
+
logs,
|
|
351
|
+
1200000,
|
|
352
|
+
APT_ENV,
|
|
353
|
+
);
|
|
354
|
+
await this.runCommand(
|
|
355
|
+
'apt-get',
|
|
356
|
+
[...APT_DPKG_OPTIONS, '-f', 'install', '-y', '--no-install-recommends'],
|
|
357
|
+
'Repairing incomplete APT dependencies...',
|
|
358
|
+
logs,
|
|
359
|
+
1200000,
|
|
360
|
+
APT_ENV,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
327
364
|
private async getMissingPackages(kind: PackageKind, packages: string[], logs: string[]): Promise<string[]> {
|
|
328
365
|
const missing: string[] = [];
|
|
329
366
|
for (const pkg of packages) {
|
|
@@ -24,8 +24,8 @@ export interface ScaleResult {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export interface ContainerStats {
|
|
27
|
-
cpu: number;
|
|
28
|
-
memory: number;
|
|
27
|
+
cpu: number; // percentage 0-100
|
|
28
|
+
memory: number; // bytes
|
|
29
29
|
memoryLimit: number;
|
|
30
30
|
networkRx: number; // bytes
|
|
31
31
|
networkTx: number; // bytes
|
|
@@ -38,11 +38,12 @@ export interface StackConfig {
|
|
|
38
38
|
image: string;
|
|
39
39
|
command?: string;
|
|
40
40
|
envVars?: Record<string, string>;
|
|
41
|
+
workerMode?: string;
|
|
41
42
|
volumes?: string[];
|
|
42
43
|
networks?: string[];
|
|
43
44
|
resourceLimits?: {
|
|
44
|
-
memory?: string;
|
|
45
|
-
cpu?: string;
|
|
45
|
+
memory?: string; // "1536Mi" | "2Gi"
|
|
46
|
+
cpu?: string; // "1" | "500m"
|
|
46
47
|
};
|
|
47
48
|
replicas: number;
|
|
48
49
|
desiredReplicas: number;
|
package/src/server/plugin.ts
CHANGED
|
@@ -7,11 +7,12 @@ import { redisActions } from './actions/redis-monitor';
|
|
|
7
7
|
import { aclCacheActions, createAclCacheMiddleware } from './actions/acl-cache';
|
|
8
8
|
import { clusterActions, readLocalLogs } from './actions/cluster-nodes';
|
|
9
9
|
import { getRedisClient } from './utils/redis';
|
|
10
|
-
import { getLocalNodeId, isWorkerMode } from './utils/node';
|
|
10
|
+
import { getLocalNodeId, getLocalRole, isWorkerMode } from './utils/node';
|
|
11
11
|
import { eventQueueActions } from './actions/event-queue-monitor';
|
|
12
12
|
import { lockActions } from './actions/lock-monitor';
|
|
13
13
|
import { cacheMonitorActions } from './actions/cache-monitor';
|
|
14
14
|
import { RedisPubSubAdapter } from './adapters/redis-pubsub-adapter';
|
|
15
|
+
import { RedisEventQueueAdapter } from './adapters/redis-event-queue-adapter';
|
|
15
16
|
import { RedisNodeRegistry } from './adapters/redis-node-registry';
|
|
16
17
|
import { RedisLockAdapter } from './adapters/redis-lock-adapter';
|
|
17
18
|
import { orchestratorActions } from './actions/orchestrator';
|
|
@@ -137,6 +138,7 @@ export class PluginClusterManagerServer extends Plugin {
|
|
|
137
138
|
|
|
138
139
|
// Register Redis PubSub adapter if URL is configured and no adapter already set
|
|
139
140
|
this.registerPubSubAdapter();
|
|
141
|
+
await this.registerEventQueueAdapter();
|
|
140
142
|
|
|
141
143
|
// Register missing Redis Lock adapter if running bare open-source core
|
|
142
144
|
const lockMgr = this.app.lockManager as any;
|
|
@@ -410,6 +412,38 @@ export class PluginClusterManagerServer extends Plugin {
|
|
|
410
412
|
this.app.logger.info('[cluster-manager] Redis PubSub adapter registered');
|
|
411
413
|
}
|
|
412
414
|
|
|
415
|
+
private async registerEventQueueAdapter() {
|
|
416
|
+
const enabled = process.env.QUEUE_ADAPTER === 'redis' || Boolean(process.env.QUEUE_ADAPTER_REDIS_URL);
|
|
417
|
+
if (!enabled) {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const url = process.env.QUEUE_ADAPTER_REDIS_URL || process.env.REDIS_URL;
|
|
422
|
+
if (!url) {
|
|
423
|
+
this.app.logger.warn('[cluster-manager] QUEUE_ADAPTER=redis but QUEUE_ADAPTER_REDIS_URL/REDIS_URL is not set');
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const eventQueue = this.app.eventQueue as any;
|
|
428
|
+
const existingAdapter = eventQueue?.adapter;
|
|
429
|
+
const existingName = existingAdapter?.constructor?.name;
|
|
430
|
+
if (existingAdapter && existingName !== 'MemoryEventQueueAdapter') {
|
|
431
|
+
this.app.logger.info(`[cluster-manager] EventQueue adapter already registered (${existingName}), skipping`);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const adapter = new RedisEventQueueAdapter({ app: this.app, url });
|
|
436
|
+
const wasConnected = Boolean(eventQueue?.isConnected?.());
|
|
437
|
+
if (wasConnected) {
|
|
438
|
+
await eventQueue.close();
|
|
439
|
+
}
|
|
440
|
+
eventQueue.setAdapter(adapter);
|
|
441
|
+
if (wasConnected) {
|
|
442
|
+
await eventQueue.connect();
|
|
443
|
+
}
|
|
444
|
+
this.app.logger.info('[cluster-manager] Redis EventQueue adapter registered');
|
|
445
|
+
}
|
|
446
|
+
|
|
413
447
|
/**
|
|
414
448
|
* Initialize the Container Orchestrator subsystem.
|
|
415
449
|
* Config is loaded from DB (orchestratorSettings collection) first,
|
|
@@ -484,10 +518,10 @@ export class PluginClusterManagerServer extends Plugin {
|
|
|
484
518
|
// Leader election runs on app nodes only. Worker-only pods still load the
|
|
485
519
|
// plugin for monitoring/package installation, but they must not become the
|
|
486
520
|
// Kubernetes orchestrator leader.
|
|
487
|
-
const
|
|
521
|
+
const nonAppNode = this.isNonAppNode();
|
|
488
522
|
this.leaderElection = new LeaderElection(this.app, {
|
|
489
|
-
enabled: !
|
|
490
|
-
disabledReason:
|
|
523
|
+
enabled: !nonAppNode,
|
|
524
|
+
disabledReason: nonAppNode ? 'Non-app nodes do not run orchestrator write operations.' : '',
|
|
491
525
|
});
|
|
492
526
|
await this.leaderElection.init();
|
|
493
527
|
|
|
@@ -561,8 +595,8 @@ export class PluginClusterManagerServer extends Plugin {
|
|
|
561
595
|
}
|
|
562
596
|
}
|
|
563
597
|
|
|
564
|
-
private
|
|
565
|
-
return
|
|
598
|
+
private isNonAppNode(): boolean {
|
|
599
|
+
return getLocalRole() !== 'app';
|
|
566
600
|
}
|
|
567
601
|
}
|
|
568
602
|
|