@camstack/server 1.1.76 → 1.2.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/dist/agent/agent-cap-dispatch-service.js +93 -0
- package/dist/agent/agent-config.js +127 -0
- package/dist/agent/agent-deploy-swap.js +137 -0
- package/dist/agent/agent-group-runner.js +175 -0
- package/dist/agent/agent-http-auth.js +76 -0
- package/dist/agent/agent-http.js +444 -0
- package/dist/agent/agent-service.js +595 -0
- package/dist/agent/agent-update-service.js +184 -0
- package/dist/agent/apply-model-distribution.js +14 -0
- package/dist/agent/derive-hub-url.js +139 -0
- package/dist/agent/fetch-bundle-from-hub.js +46 -0
- package/dist/agent/main.js +1137 -0
- package/dist/agent/register-agent-cap-dispatch.js +37 -0
- package/dist/core/agent/agent-registry.service.js +4 -3
- package/dist/core/server-update/server-update.service.js +14 -5
- package/dist/core/server-update/system-exec-npm.js +33 -0
- package/dist/launcher.js +28 -5
- package/dist/node-role.js +11 -0
- package/dist/server-root/index.js +0 -8
- package/package.json +27 -14
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.resolveDeployAction = resolveDeployAction;
|
|
37
|
+
exports.createAgentService = createAgentService;
|
|
38
|
+
const fs = __importStar(require("node:fs"));
|
|
39
|
+
const os = __importStar(require("node:os"));
|
|
40
|
+
const path = __importStar(require("node:path"));
|
|
41
|
+
const system_1 = require("@camstack/system");
|
|
42
|
+
const moleculer_1 = require("moleculer");
|
|
43
|
+
const agent_deploy_swap_js_1 = require("./agent-deploy-swap.js");
|
|
44
|
+
const apply_model_distribution_js_1 = require("./apply-model-distribution.js");
|
|
45
|
+
const fetch_bundle_from_hub_js_1 = require("./fetch-bundle-from-hub.js");
|
|
46
|
+
/**
|
|
47
|
+
* Console-backed scoped logger for the deploy swap (the only consumer in this
|
|
48
|
+
* file). `applyDeployedBundle` logs only on the rare restore-after-failed-swap
|
|
49
|
+
* path, so a thin console adapter is sufficient.
|
|
50
|
+
*/
|
|
51
|
+
const deploySwapLogger = {
|
|
52
|
+
info: (msg) => console.log(`[Agent] ${msg}`),
|
|
53
|
+
warn: (msg) => console.warn(`[Agent] ${msg}`),
|
|
54
|
+
error: (msg) => console.error(`[Agent] ${msg}`),
|
|
55
|
+
debug: (msg) => console.debug(`[Agent] ${msg}`),
|
|
56
|
+
child: () => deploySwapLogger,
|
|
57
|
+
withTags: () => deploySwapLogger,
|
|
58
|
+
};
|
|
59
|
+
/** Timeout for the agent-local `$process.restart` delegation. */
|
|
60
|
+
const AGENT_PROCESS_RESTART_TIMEOUT_MS = 30_000;
|
|
61
|
+
function getLocalIps() {
|
|
62
|
+
const interfaces = os.networkInterfaces();
|
|
63
|
+
const ips = [];
|
|
64
|
+
for (const ifaces of Object.values(interfaces)) {
|
|
65
|
+
if (!ifaces)
|
|
66
|
+
continue;
|
|
67
|
+
for (const iface of ifaces) {
|
|
68
|
+
if (iface.internal)
|
|
69
|
+
continue;
|
|
70
|
+
ips.push(iface.address);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return ips;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Resolve a promise but never block longer than `ms` — on timeout, resolve to
|
|
77
|
+
* `fallback`. `$agent.status`/`$agent.health` MUST always answer promptly: the
|
|
78
|
+
* hub drops a node from `nodes.topology` whenever `$agent.status` times out, so
|
|
79
|
+
* a status RPC that hangs on a transiently-unavailable metrics provider (e.g.
|
|
80
|
+
* mid-reload) makes the agent silently vanish from the cluster. Bounding the
|
|
81
|
+
* metrics fetch keeps the node visible with a degraded (zeroed) metrics block
|
|
82
|
+
* instead of disappearing.
|
|
83
|
+
*/
|
|
84
|
+
async function withTimeout(p, ms, fallback) {
|
|
85
|
+
let timer;
|
|
86
|
+
const timeout = new Promise((resolve) => {
|
|
87
|
+
timer = setTimeout(() => resolve(fallback), ms);
|
|
88
|
+
});
|
|
89
|
+
try {
|
|
90
|
+
return await Promise.race([p, timeout]);
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
if (timer)
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Bound for the metrics snapshot inside status/health — see `withTimeout`. */
|
|
98
|
+
const METRICS_SNAPSHOT_TIMEOUT_MS = 2_000;
|
|
99
|
+
/**
|
|
100
|
+
* Pure factory that builds the deploy routing logic from a seam. Exported so
|
|
101
|
+
* the routing can be unit-tested without Moleculer or a real filesystem.
|
|
102
|
+
*/
|
|
103
|
+
function resolveDeployAction(seam) {
|
|
104
|
+
return async (params) => {
|
|
105
|
+
const { addonId, source, bundle } = params;
|
|
106
|
+
if (source && (0, system_1.isAddonDeploySource)(source)) {
|
|
107
|
+
if (source.kind === 'npm') {
|
|
108
|
+
await seam.installFromNpm(addonId, source.version);
|
|
109
|
+
// npm path installs to disk via AddonInstaller and returns no addonDir; the
|
|
110
|
+
// loadedAddons eviction (onApplied) only applies to bundle-extracted installs.
|
|
111
|
+
return { success: true, addonId };
|
|
112
|
+
}
|
|
113
|
+
const buf = await seam.fetchBundle(source);
|
|
114
|
+
const { addonDir } = await seam.applyBundle(buf);
|
|
115
|
+
seam.onApplied(addonDir);
|
|
116
|
+
return { success: true, addonId, path: addonDir };
|
|
117
|
+
}
|
|
118
|
+
// Legacy inline-bundle path (back-compat, removed next release).
|
|
119
|
+
if (bundle === undefined) {
|
|
120
|
+
throw new Error('$agent.deploy: no source descriptor and no legacy bundle provided');
|
|
121
|
+
}
|
|
122
|
+
const buf = typeof bundle === 'string' ? Buffer.from(bundle, 'base64') : bundle;
|
|
123
|
+
const { addonDir } = await seam.applyBundle(buf);
|
|
124
|
+
seam.onApplied(addonDir);
|
|
125
|
+
return { success: true, addonId, path: addonDir };
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function readHubAddressFromConfig(configPath) {
|
|
129
|
+
if (!configPath)
|
|
130
|
+
return null;
|
|
131
|
+
try {
|
|
132
|
+
if (!fs.existsSync(configPath))
|
|
133
|
+
return null;
|
|
134
|
+
const raw = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
135
|
+
return typeof raw.hubAddress === 'string' && raw.hubAddress.length > 0 ? raw.hubAddress : null;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Read the addon declaration ids contributed by a deployed package.
|
|
143
|
+
*
|
|
144
|
+
* `loadedAddons` is keyed by `camstack.addons[].id` (the declaration
|
|
145
|
+
* id), not the package name. A redeploy's `$agent.deploy` param is the
|
|
146
|
+
* package name, so the deploy handler reads the freshly-extracted
|
|
147
|
+
* `package.json` to recover the real ids it must evict before
|
|
148
|
+
* `$agent.reload`. Best-effort — returns `[]` on a missing/corrupt
|
|
149
|
+
* manifest (the reload then just falls back to its on-disk scan).
|
|
150
|
+
*/
|
|
151
|
+
function readDeployedAddonIds(addonDir) {
|
|
152
|
+
try {
|
|
153
|
+
const manifestPath = path.join(addonDir, 'package.json');
|
|
154
|
+
if (!fs.existsSync(manifestPath))
|
|
155
|
+
return [];
|
|
156
|
+
const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
157
|
+
const entries = raw.camstack?.addons ?? [];
|
|
158
|
+
const ids = [];
|
|
159
|
+
for (const entry of entries) {
|
|
160
|
+
if (typeof entry.id === 'string' && entry.id.length > 0)
|
|
161
|
+
ids.push(entry.id);
|
|
162
|
+
}
|
|
163
|
+
return ids;
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function isHubConnected(broker) {
|
|
170
|
+
try {
|
|
171
|
+
const registry = broker.registry;
|
|
172
|
+
const nodes = registry?.getNodeList?.({ onlyAvailable: true }) ?? [];
|
|
173
|
+
return nodes.some((n) => n.id === 'hub');
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function createAgentService(deps) {
|
|
180
|
+
return {
|
|
181
|
+
name: '$agent',
|
|
182
|
+
actions: {
|
|
183
|
+
status: {
|
|
184
|
+
handler: async (ctx) => {
|
|
185
|
+
const { broker } = ctx;
|
|
186
|
+
const cpus = os.cpus();
|
|
187
|
+
let cpuPercent = 0;
|
|
188
|
+
let memoryPercent = 0;
|
|
189
|
+
const metrics = deps.getMetricsProvider?.();
|
|
190
|
+
if (metrics) {
|
|
191
|
+
// Bounded so $agent.status never hangs (and drops the node from
|
|
192
|
+
// topology) when the metrics provider is mid-reload/unavailable.
|
|
193
|
+
const snapshot = await withTimeout(metrics.getCached(), METRICS_SNAPSHOT_TIMEOUT_MS, null);
|
|
194
|
+
if (snapshot) {
|
|
195
|
+
cpuPercent = snapshot.cpu.total;
|
|
196
|
+
memoryPercent = snapshot.memory.percent;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const mem = process.memoryUsage();
|
|
200
|
+
return {
|
|
201
|
+
nodeId: broker.nodeID,
|
|
202
|
+
name: deps.agentName,
|
|
203
|
+
platform: os.platform(),
|
|
204
|
+
arch: os.arch(),
|
|
205
|
+
hostname: os.hostname(),
|
|
206
|
+
cpuCores: cpus.length,
|
|
207
|
+
cpuModel: cpus[0]?.model,
|
|
208
|
+
totalMemoryMB: Math.round(os.totalmem() / 1024 / 1024),
|
|
209
|
+
freeMemoryMB: Math.round(os.freemem() / 1024 / 1024),
|
|
210
|
+
cpuPercent,
|
|
211
|
+
memoryPercent,
|
|
212
|
+
uptime: os.uptime(),
|
|
213
|
+
// The agent *process* itself (distinct from the host `uptime` /
|
|
214
|
+
// memory above) — surfaced so the UI can show how long THIS agent
|
|
215
|
+
// has been running and how much RAM its own runtime holds.
|
|
216
|
+
agentProcess: {
|
|
217
|
+
pid: process.pid,
|
|
218
|
+
uptimeSeconds: Math.round(process.uptime()),
|
|
219
|
+
rssMB: Math.round(mem.rss / 1024 / 1024),
|
|
220
|
+
heapUsedMB: Math.round(mem.heapUsed / 1024 / 1024),
|
|
221
|
+
heapTotalMB: Math.round(mem.heapTotal / 1024 / 1024),
|
|
222
|
+
},
|
|
223
|
+
localIps: getLocalIps(),
|
|
224
|
+
addons: [...deps.loadedAddons.values()].map((a) => ({
|
|
225
|
+
id: a.id,
|
|
226
|
+
status: a.status,
|
|
227
|
+
// Report the PACKAGE version (falls back to the declaration
|
|
228
|
+
// version) so the hub's per-node update check is accurate.
|
|
229
|
+
version: a.packageVersion ?? a.version,
|
|
230
|
+
packageName: a.packageName,
|
|
231
|
+
})),
|
|
232
|
+
};
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
health: {
|
|
236
|
+
handler: async (ctx) => {
|
|
237
|
+
const { broker } = ctx;
|
|
238
|
+
let cpuPercent = 0;
|
|
239
|
+
let memoryPercent = 0;
|
|
240
|
+
const metrics = deps.getMetricsProvider?.();
|
|
241
|
+
if (metrics) {
|
|
242
|
+
try {
|
|
243
|
+
// Bounded — see $agent.status. Health must answer promptly too.
|
|
244
|
+
const snapshot = await withTimeout(metrics.getCached(), METRICS_SNAPSHOT_TIMEOUT_MS, null);
|
|
245
|
+
if (snapshot) {
|
|
246
|
+
cpuPercent = snapshot.cpu.total;
|
|
247
|
+
memoryPercent = snapshot.memory.percent;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
/* metrics may be transiently unavailable */
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
let total = 0;
|
|
255
|
+
let running = 0;
|
|
256
|
+
let errored = 0;
|
|
257
|
+
for (const a of deps.loadedAddons.values()) {
|
|
258
|
+
total++;
|
|
259
|
+
if (a.status === 'running')
|
|
260
|
+
running++;
|
|
261
|
+
else if (a.status === 'error')
|
|
262
|
+
errored++;
|
|
263
|
+
}
|
|
264
|
+
const hubAddress = readHubAddressFromConfig(deps.configPath);
|
|
265
|
+
return {
|
|
266
|
+
ok: errored === 0,
|
|
267
|
+
nodeId: broker.nodeID,
|
|
268
|
+
name: deps.agentName,
|
|
269
|
+
version: deps.agentVersion ?? 'unknown',
|
|
270
|
+
uptimeSeconds: Math.round(process.uptime()),
|
|
271
|
+
pid: process.pid,
|
|
272
|
+
hubConnected: isHubConnected(broker),
|
|
273
|
+
hubAddress,
|
|
274
|
+
addons: { total, running, error: errored },
|
|
275
|
+
cpuPercent,
|
|
276
|
+
memoryPercent,
|
|
277
|
+
checkedAt: new Date().toISOString(),
|
|
278
|
+
};
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
shutdown: {
|
|
282
|
+
handler() {
|
|
283
|
+
// Graceful shutdown — schedule so the Moleculer response goes out first
|
|
284
|
+
setTimeout(() => process.exit(0), 500);
|
|
285
|
+
return { success: true };
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
rename: {
|
|
289
|
+
handler(ctx) {
|
|
290
|
+
const { params, broker } = ctx;
|
|
291
|
+
const newName = params.name;
|
|
292
|
+
if (!newName || typeof newName !== 'string') {
|
|
293
|
+
throw new Error('$agent.rename: name is required');
|
|
294
|
+
}
|
|
295
|
+
const oldName = deps.agentName;
|
|
296
|
+
// Update in-memory name (affects subsequent $agent.status responses)
|
|
297
|
+
deps.agentName = newName.trim();
|
|
298
|
+
broker.logger.info(`Agent renamed: "${oldName}" → "${deps.agentName}"`);
|
|
299
|
+
// Persist to config file
|
|
300
|
+
try {
|
|
301
|
+
const configFile = path.resolve(deps.configPath);
|
|
302
|
+
let raw = {};
|
|
303
|
+
if (fs.existsSync(configFile)) {
|
|
304
|
+
try {
|
|
305
|
+
raw = JSON.parse(fs.readFileSync(configFile, 'utf-8'));
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
/* corrupt */
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
raw.name = deps.agentName;
|
|
312
|
+
fs.mkdirSync(path.dirname(configFile), { recursive: true });
|
|
313
|
+
fs.writeFileSync(configFile, JSON.stringify(raw, null, 2), 'utf-8');
|
|
314
|
+
broker.logger.info(`Agent name persisted to ${configFile}`);
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
broker.logger.warn('Agent rename: config file write failed (in-memory rename still active)', { error: String(err) });
|
|
318
|
+
}
|
|
319
|
+
return { success: true, name: deps.agentName };
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
listAddons: {
|
|
323
|
+
handler() {
|
|
324
|
+
return [...deps.loadedAddons.keys()];
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
deploy: {
|
|
328
|
+
handler: async (ctx) => {
|
|
329
|
+
const { params } = ctx;
|
|
330
|
+
const { addonId, source, bundle } = params;
|
|
331
|
+
// execFile (no shell) instead of execSync — addonId comes from RPC
|
|
332
|
+
// params and was previously interpolated into a shell command, which
|
|
333
|
+
// is a command-injection vector. argv form doesn't go through a shell
|
|
334
|
+
// so any payload in addonId stays a literal path arg. Use the ASYNC
|
|
335
|
+
// form (not execFileSync): a synchronous extract blocks the agent
|
|
336
|
+
// event loop for the whole untar, which on a large package starves
|
|
337
|
+
// concurrent hub RPCs ($agent.status heartbeat) long enough for the
|
|
338
|
+
// hub to time them out and drop the node from topology mid-deploy.
|
|
339
|
+
const { execFile } = await Promise.resolve().then(() => __importStar(require('node:child_process')));
|
|
340
|
+
const { promisify } = await Promise.resolve().then(() => __importStar(require('node:util')));
|
|
341
|
+
const execFileAsync = promisify(execFile);
|
|
342
|
+
// Atomic install: extract into a temp dir then swap into place. The
|
|
343
|
+
// old body `rm`'d the live dir THEN untarred — a killed/timed-out
|
|
344
|
+
// untar (e.g. a redeploy whose RPC was cut) left the addon dir
|
|
345
|
+
// MISSING. `applyDeployedBundle` never leaves the live dir absent
|
|
346
|
+
// (the non-atomic-deploy fix).
|
|
347
|
+
const extract = async (tgz, destDir) => {
|
|
348
|
+
const tgzPath = path.join(destDir, '..', `.${path.basename(destDir)}.tgz`);
|
|
349
|
+
fs.writeFileSync(tgzPath, tgz);
|
|
350
|
+
try {
|
|
351
|
+
await execFileAsync('tar', ['-xzf', tgzPath, '-C', destDir, '--strip-components=1'], {
|
|
352
|
+
timeout: 60000,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
finally {
|
|
356
|
+
try {
|
|
357
|
+
fs.unlinkSync(tgzPath);
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
/* best-effort temp cleanup */
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
const seam = {
|
|
365
|
+
installFromNpm: (pkg, v) => {
|
|
366
|
+
if (!deps.installFromNpm) {
|
|
367
|
+
throw new Error('npm deploy source unsupported on this agent');
|
|
368
|
+
}
|
|
369
|
+
return deps.installFromNpm(pkg, v);
|
|
370
|
+
},
|
|
371
|
+
// #17: route the bundle through the agent's AddonInstaller when
|
|
372
|
+
// wired — installFromTgz runs the FULL post-install (manifest
|
|
373
|
+
// strip, runtime-deps npm install, manifest-driven NATIVE deps)
|
|
374
|
+
// with its own staged extract + atomic swap. The plain tar swap
|
|
375
|
+
// below never installed deps, so every pipeline/post-analysis
|
|
376
|
+
// deploy on the Mac Electron agent silently shipped without
|
|
377
|
+
// `sharp` et al. Fallback kept for agents without an installer
|
|
378
|
+
// (test harnesses).
|
|
379
|
+
applyBundle: async (buf) => {
|
|
380
|
+
if (deps.installBundleTgz) {
|
|
381
|
+
const tgzPath = path.join(os.tmpdir(), `camstack-deploy-${addonId.replace(/[^\w.-]/g, '_')}-${Date.now()}.tgz`);
|
|
382
|
+
fs.writeFileSync(tgzPath, buf);
|
|
383
|
+
try {
|
|
384
|
+
const { name } = await deps.installBundleTgz(tgzPath);
|
|
385
|
+
return { addonDir: path.join(deps.addonsDir, name) };
|
|
386
|
+
}
|
|
387
|
+
finally {
|
|
388
|
+
try {
|
|
389
|
+
fs.unlinkSync(tgzPath);
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
/* best-effort temp cleanup */
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return (0, agent_deploy_swap_js_1.applyDeployedBundle)({
|
|
397
|
+
addonsDir: deps.addonsDir,
|
|
398
|
+
addonId,
|
|
399
|
+
bundle: buf,
|
|
400
|
+
extract,
|
|
401
|
+
logger: deploySwapLogger,
|
|
402
|
+
});
|
|
403
|
+
},
|
|
404
|
+
fetchBundle: (s) => (0, fetch_bundle_from_hub_js_1.fetchBundleFromHub)(s),
|
|
405
|
+
// Evict every addon DECLARATION id this package contributes
|
|
406
|
+
// from `loadedAddons` so the follow-up `$agent.reload` actually
|
|
407
|
+
// re-instantiates it. `loadDeployedAddons` skips any addon
|
|
408
|
+
// still present in `loadedAddons`; without this eviction a
|
|
409
|
+
// redeploy would leave the agent pinned to the pre-update
|
|
410
|
+
// version. The `deploy` param `addonId` is the PACKAGE name
|
|
411
|
+
// (used only as the on-disk dir), whereas `loadedAddons` is
|
|
412
|
+
// keyed by the addon DECLARATION id — they differ for scoped
|
|
413
|
+
// packages, so we read the extracted manifest to bridge them.
|
|
414
|
+
onApplied: (addonDir) => {
|
|
415
|
+
for (const declId of readDeployedAddonIds(addonDir)) {
|
|
416
|
+
deps.loadedAddons.delete(declId);
|
|
417
|
+
}
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
return resolveDeployAction(seam)({ addonId, source, bundle });
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
/**
|
|
424
|
+
* Pull a staged model tarball from the hub and untar it into this node's
|
|
425
|
+
* `<dataDir>/models` (Model Studio P2). Reuses the agent-pull machinery:
|
|
426
|
+
* `fetchBundleFromHub` verifies bytes + sha256 before extraction. The
|
|
427
|
+
* existing `isModelDownloaded` then reports the model present, so the
|
|
428
|
+
* detection-pipeline can load it locally.
|
|
429
|
+
*/
|
|
430
|
+
distributeModel: {
|
|
431
|
+
handler: async (ctx) => {
|
|
432
|
+
const { params } = ctx;
|
|
433
|
+
const { execFile } = await Promise.resolve().then(() => __importStar(require('node:child_process')));
|
|
434
|
+
const { promisify } = await Promise.resolve().then(() => __importStar(require('node:util')));
|
|
435
|
+
const execFileAsync = promisify(execFile);
|
|
436
|
+
const modelsDir = path.join(deps.dataDir, 'models');
|
|
437
|
+
const seam = {
|
|
438
|
+
modelsDir,
|
|
439
|
+
fetchBundle: (s) => (0, fetch_bundle_from_hub_js_1.fetchBundleFromHub)(s),
|
|
440
|
+
extract: async (tgz, destDir) => {
|
|
441
|
+
const tmp = path.join(destDir, `.dist-${Date.now()}-${Math.random().toString(36).slice(2)}.tgz`);
|
|
442
|
+
fs.writeFileSync(tmp, tgz);
|
|
443
|
+
try {
|
|
444
|
+
await execFileAsync('tar', ['-xzf', tmp, '-C', destDir], { timeout: 60000 });
|
|
445
|
+
}
|
|
446
|
+
finally {
|
|
447
|
+
try {
|
|
448
|
+
fs.unlinkSync(tmp);
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
/* best-effort temp cleanup */
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
},
|
|
455
|
+
mkdirp: (dir) => fs.mkdirSync(dir, { recursive: true }),
|
|
456
|
+
};
|
|
457
|
+
return (0, apply_model_distribution_js_1.applyModelDistribution)(seam, params);
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
/**
|
|
461
|
+
* Genuinely drop a deployed addon from this agent.
|
|
462
|
+
*
|
|
463
|
+
* A plain `loadedAddons.delete()` only forgets the bookkeeping entry —
|
|
464
|
+
* the addon instance keeps running and, crucially, its Moleculer
|
|
465
|
+
* service keeps advertising the addon's capabilities into the cluster
|
|
466
|
+
* (the hub still sees the provider as a live `<cap>@<agent>` entry).
|
|
467
|
+
* To truly undeploy we must, in order:
|
|
468
|
+
* 1. `shutdown()` the running instance (releases timers, sockets,
|
|
469
|
+
* native handles) — same hook the agent's SIGTERM path uses.
|
|
470
|
+
* 2. `broker.destroyService(addonId)` — the deployed-addon Moleculer
|
|
471
|
+
* service is named after the addon DECLARATION id (see
|
|
472
|
+
* `createAddonService` → `name: declaration.id`). Destroying it
|
|
473
|
+
* unregisters every capability action so the cluster stops
|
|
474
|
+
* routing to / advertising this agent's provider.
|
|
475
|
+
* 3. Drop the `loadedAddons` entry and delete the on-disk folder.
|
|
476
|
+
*
|
|
477
|
+
* `addonId` here is the addon DECLARATION id — the same key
|
|
478
|
+
* `loadedAddons` uses and the same value `$agent.status` reports, so
|
|
479
|
+
* the hub reconciler can match it directly.
|
|
480
|
+
*/
|
|
481
|
+
undeploy: {
|
|
482
|
+
handler: async (ctx) => {
|
|
483
|
+
const { params, broker } = ctx;
|
|
484
|
+
const { addonId } = params;
|
|
485
|
+
const entry = deps.loadedAddons.get(addonId);
|
|
486
|
+
// 1. Shut the running instance down (best-effort — a throwing
|
|
487
|
+
// disposer must not block the rest of the teardown).
|
|
488
|
+
if (entry?.addon?.shutdown) {
|
|
489
|
+
try {
|
|
490
|
+
await entry.addon.shutdown();
|
|
491
|
+
}
|
|
492
|
+
catch (err) {
|
|
493
|
+
broker.logger.warn(`$agent.undeploy: ${addonId} shutdown() threw`, {
|
|
494
|
+
error: String(err),
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
// 2. Destroy the Moleculer service so the cluster stops seeing
|
|
499
|
+
// this agent's capability providers for the addon.
|
|
500
|
+
try {
|
|
501
|
+
await broker.destroyService(addonId);
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
// Service may not exist (group-spawned addons have no per-addon
|
|
505
|
+
// service on the agent broker, or it was never created).
|
|
506
|
+
}
|
|
507
|
+
// 3. Forget the entry and remove the deployed folder. The deploy
|
|
508
|
+
// handler keys the on-disk dir by the PACKAGE name, but $agent
|
|
509
|
+
// redeploys for a single addon use addonId === packageName for
|
|
510
|
+
// unscoped packages; remove whichever folder matches.
|
|
511
|
+
deps.loadedAddons.delete(addonId);
|
|
512
|
+
const addonDir = path.join(deps.addonsDir, addonId);
|
|
513
|
+
if (fs.existsSync(addonDir)) {
|
|
514
|
+
fs.rmSync(addonDir, { recursive: true, force: true });
|
|
515
|
+
}
|
|
516
|
+
// Defense-in-depth: one package dir can host MANY addons (e.g.
|
|
517
|
+
// `@camstack/addon-pipeline` ships decoder-ffmpeg, recorder,
|
|
518
|
+
// motion-wasm, …). Removing the whole bundle because ONE member was
|
|
519
|
+
// undeployed is destructive — every sibling runner would crash-loop
|
|
520
|
+
// on the missing package.json. Only remove the package dir when no
|
|
521
|
+
// OTHER loaded addon still lives in it (this addon's own entry was
|
|
522
|
+
// already deleted from `loadedAddons` above, so the remaining
|
|
523
|
+
// values are exactly the siblings).
|
|
524
|
+
const pkgName = entry?.packageName;
|
|
525
|
+
if (pkgName && pkgName !== addonId) {
|
|
526
|
+
const pkgShared = [...deps.loadedAddons.values()].some((e) => e.packageName === pkgName);
|
|
527
|
+
if (!pkgShared) {
|
|
528
|
+
const pkgDir = path.join(deps.addonsDir, pkgName);
|
|
529
|
+
if (fs.existsSync(pkgDir)) {
|
|
530
|
+
fs.rmSync(pkgDir, { recursive: true, force: true });
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
broker.logger.info(`$agent.undeploy: ${addonId} disposed (instance + service + folder)`);
|
|
535
|
+
return { success: true, addonId };
|
|
536
|
+
},
|
|
537
|
+
},
|
|
538
|
+
/**
|
|
539
|
+
* Re-run `loadDeployedAddons` so addons just dropped onto disk via
|
|
540
|
+
* `$agent.deploy` (or via the hub broadcasting an upload) start
|
|
541
|
+
* immediately. Without this the agent only discovers them on boot.
|
|
542
|
+
* Returns the ids that were newly loaded — the hub uses this to
|
|
543
|
+
* surface a per-agent diff in the upload response.
|
|
544
|
+
*/
|
|
545
|
+
reload: {
|
|
546
|
+
handler: async () => {
|
|
547
|
+
if (!deps.reloadDeployedAddons) {
|
|
548
|
+
return { success: false, loaded: [] };
|
|
549
|
+
}
|
|
550
|
+
const loaded = await deps.reloadDeployedAddons();
|
|
551
|
+
return { success: true, loaded };
|
|
552
|
+
},
|
|
553
|
+
},
|
|
554
|
+
restart: {
|
|
555
|
+
handler: async (ctx) => {
|
|
556
|
+
const { params, broker } = ctx;
|
|
557
|
+
const { addonId } = params;
|
|
558
|
+
// Delegate to THIS agent's own `$process` service — the SAME primitive
|
|
559
|
+
// the hub uses for a local forked addon (AddonRegistryService.restartAddon
|
|
560
|
+
// → `$process.restart`). `resolveRunnerName` maps `addonId` to its hosting
|
|
561
|
+
// runner (direct runner key OR `runnerAddons` membership), so the child is
|
|
562
|
+
// respawned, the crash streak reset (`crashSupervisor.reset`), the stale
|
|
563
|
+
// Moleculer node evicted, and the addon's caps re-registered — identical
|
|
564
|
+
// semantics to a hub-local restart. Pin to the agent's own node so the
|
|
565
|
+
// call always hits THIS agent's `$process`, never the hub's.
|
|
566
|
+
const result = await broker.call('$process.restart', { name: addonId }, { nodeID: broker.nodeID, timeout: AGENT_PROCESS_RESTART_TIMEOUT_MS });
|
|
567
|
+
if (!result.success) {
|
|
568
|
+
// Fail loudly so the hub's `nodes.restartAddon` surfaces the real
|
|
569
|
+
// error instead of the old fire-and-forget phantom success.
|
|
570
|
+
throw new moleculer_1.Errors.MoleculerError(`$agent.restart: process restart failed for "${addonId}": ${result.reason ?? 'unknown'}`, 500, 'AGENT_RESTART_FAILED');
|
|
571
|
+
}
|
|
572
|
+
return { success: true, addonId, pid: result.pid };
|
|
573
|
+
},
|
|
574
|
+
},
|
|
575
|
+
/**
|
|
576
|
+
* D3 subtree aggregation: a forked group-runner child calls this action
|
|
577
|
+
* to deliver its complete capability manifest to the agent. The agent
|
|
578
|
+
* then re-registers the UNION of its own in-process addons + all children
|
|
579
|
+
* with the hub via `$hub.registerNode`.
|
|
580
|
+
*/
|
|
581
|
+
registerNode: {
|
|
582
|
+
handler(ctx) {
|
|
583
|
+
const { params } = ctx;
|
|
584
|
+
// Cluster-secret gate: when the agent has a secret configured, a child
|
|
585
|
+
// runner must present a matching hash or the registration is rejected.
|
|
586
|
+
if (!(0, system_1.clusterSecretMatches)(deps.expectedClusterSecretHash, params.clusterSecretHash)) {
|
|
587
|
+
throw new moleculer_1.Errors.MoleculerError(`cluster secret mismatch — node "${params.nodeId}" rejected`, 403, system_1.CLUSTER_SECRET_MISMATCH_TYPE);
|
|
588
|
+
}
|
|
589
|
+
deps.onChildRegistered?.(params);
|
|
590
|
+
return { ok: true };
|
|
591
|
+
},
|
|
592
|
+
},
|
|
593
|
+
},
|
|
594
|
+
};
|
|
595
|
+
}
|