@camstack/server 1.2.540 → 1.2.541
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-addon-runtime.js +117 -0
- package/dist/agent/agent-addon-updates.js +10 -20
- package/dist/agent/agent-http-auth.js +5 -2
- package/dist/agent/agent-http.js +36 -9
- package/dist/agent/agent-hub-addon-update.js +131 -0
- package/dist/agent/agent-update-routes.js +34 -2
- package/dist/agent/derive-hub-url.js +1 -51
- package/dist/agent/hubs-found.js +100 -0
- package/dist/agent/main.js +14 -46
- package/dist/api/trpc/core-cap-bridge.js +11 -2
- package/dist/api/trpc/remote-addon-update-grant.js +81 -0
- package/dist/core/moleculer/moleculer.service.js +4 -8
- package/dist/main.js +36 -2
- package/package.json +10 -8
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* ONE list for the agent UI: every addon on this node with the runner that
|
|
4
|
+
* hosts it (D841). It used to be two lists — `$agent.status` addons and
|
|
5
|
+
* `$process.list` runners — that the operator had to join by eye.
|
|
6
|
+
*
|
|
7
|
+
* The addon → runner link is the runner's own `addonIds`, which the process
|
|
8
|
+
* service fills from the runner it actually spawned (`resolveRunnerId` at spawn
|
|
9
|
+
* time). Nothing here (and nothing in the UI) re-derives a grouping: an addon
|
|
10
|
+
* shares a runner exactly when the process service listed it in that runner's
|
|
11
|
+
* `addonIds`. A runner hosting no known addon is still a row, so a stray
|
|
12
|
+
* process stays visible.
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.joinAddonRuntime = joinAddonRuntime;
|
|
16
|
+
exports.addonsFromStatus = addonsFromStatus;
|
|
17
|
+
exports.processesFromList = processesFromList;
|
|
18
|
+
function toRunner(p) {
|
|
19
|
+
return {
|
|
20
|
+
name: p.name,
|
|
21
|
+
pid: p.pid,
|
|
22
|
+
state: p.state,
|
|
23
|
+
groupId: p.groupId,
|
|
24
|
+
cpuPercent: p.cpuPercent,
|
|
25
|
+
memoryRss: p.memoryRss,
|
|
26
|
+
uptimeSeconds: p.uptimeSeconds,
|
|
27
|
+
addonIds: p.addonIds,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function joinAddonRuntime(addons, processes) {
|
|
31
|
+
const runnerByAddon = new Map();
|
|
32
|
+
for (const p of processes) {
|
|
33
|
+
for (const id of p.addonIds) {
|
|
34
|
+
if (!runnerByAddon.has(id))
|
|
35
|
+
runnerByAddon.set(id, p);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const known = new Set(addons.map((a) => a.id));
|
|
39
|
+
const addonRows = addons.map((a) => {
|
|
40
|
+
const runner = runnerByAddon.get(a.id);
|
|
41
|
+
return {
|
|
42
|
+
key: a.id,
|
|
43
|
+
kind: 'addon',
|
|
44
|
+
addonId: a.id,
|
|
45
|
+
packageName: a.packageName ?? null,
|
|
46
|
+
version: a.version ?? null,
|
|
47
|
+
status: a.status,
|
|
48
|
+
runner: runner === undefined ? null : toRunner(runner),
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
const strayRunners = processes
|
|
52
|
+
.filter((p) => !p.addonIds.some((id) => known.has(id)))
|
|
53
|
+
.map((p) => ({
|
|
54
|
+
key: `runner:${p.name}`,
|
|
55
|
+
kind: 'runner',
|
|
56
|
+
addonId: null,
|
|
57
|
+
packageName: null,
|
|
58
|
+
version: null,
|
|
59
|
+
status: p.state,
|
|
60
|
+
runner: toRunner(p),
|
|
61
|
+
}));
|
|
62
|
+
return [...addonRows, ...strayRunners];
|
|
63
|
+
}
|
|
64
|
+
function str(value) {
|
|
65
|
+
return typeof value === 'string' ? value : undefined;
|
|
66
|
+
}
|
|
67
|
+
function num(value) {
|
|
68
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
69
|
+
}
|
|
70
|
+
/** The addon rows of an `$agent.status` answer; unreadable rows are skipped. */
|
|
71
|
+
function addonsFromStatus(status) {
|
|
72
|
+
const raw = status !== null && typeof status === 'object' ? Reflect.get(status, 'addons') : null;
|
|
73
|
+
if (!Array.isArray(raw))
|
|
74
|
+
return [];
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const item of raw) {
|
|
77
|
+
if (item === null || typeof item !== 'object')
|
|
78
|
+
continue;
|
|
79
|
+
const id = str(Reflect.get(item, 'id'));
|
|
80
|
+
if (id === undefined)
|
|
81
|
+
continue;
|
|
82
|
+
const version = str(Reflect.get(item, 'version'));
|
|
83
|
+
const packageName = str(Reflect.get(item, 'packageName'));
|
|
84
|
+
out.push({
|
|
85
|
+
id,
|
|
86
|
+
status: str(Reflect.get(item, 'status')) ?? 'unknown',
|
|
87
|
+
...(version !== undefined ? { version } : {}),
|
|
88
|
+
...(packageName !== undefined ? { packageName } : {}),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
/** The runners of a `$process.list` answer; unreadable rows are skipped. */
|
|
94
|
+
function processesFromList(list) {
|
|
95
|
+
if (!Array.isArray(list))
|
|
96
|
+
return [];
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const item of list) {
|
|
99
|
+
if (item === null || typeof item !== 'object')
|
|
100
|
+
continue;
|
|
101
|
+
const name = str(Reflect.get(item, 'name'));
|
|
102
|
+
if (name === undefined)
|
|
103
|
+
continue;
|
|
104
|
+
const ids = Reflect.get(item, 'addonIds');
|
|
105
|
+
out.push({
|
|
106
|
+
name,
|
|
107
|
+
pid: num(Reflect.get(item, 'pid')),
|
|
108
|
+
state: str(Reflect.get(item, 'state')) ?? 'unknown',
|
|
109
|
+
groupId: str(Reflect.get(item, 'groupId')) ?? name,
|
|
110
|
+
addonIds: Array.isArray(ids) ? ids.filter((id) => typeof id === 'string') : [],
|
|
111
|
+
cpuPercent: num(Reflect.get(item, 'cpuPercent')),
|
|
112
|
+
memoryRss: num(Reflect.get(item, 'memoryRss')),
|
|
113
|
+
uptimeSeconds: num(Reflect.get(item, 'uptimeSeconds')),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
@@ -42,27 +42,17 @@ exports.buildAgentAddonUpdateReport = buildAgentAddonUpdateReport;
|
|
|
42
42
|
* Agent-local addon ALIGNMENT report — "is this node behind?", answered
|
|
43
43
|
* WITHOUT the hub.
|
|
44
44
|
*
|
|
45
|
-
* ── Why this
|
|
46
|
-
*
|
|
47
|
-
* the hub
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* for business logic to bridge the gap.
|
|
45
|
+
* ── Why this report installs nothing ─────────────────────────────────────
|
|
46
|
+
* An addon's version on an agent is decided by the HUB: every reconnect's
|
|
47
|
+
* back-fill restores the hub's installed version (`agent-addon-backfill.ts`).
|
|
48
|
+
* The Update action is therefore a request to the hub to bring a package to
|
|
49
|
+
* THAT version (`agent-hub-addon-update.ts`, D840) — never a local install, and
|
|
50
|
+
* never npm-latest: the hub routinely ships builds via `camstack deploy` that
|
|
51
|
+
* were never published, so a node that self-pulled npm-latest would run bytes
|
|
52
|
+
* the hub never sent and be undone at the next reconcile.
|
|
54
53
|
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* `camstack deploy` that were never published, so a node that self-pulled
|
|
58
|
-
* npm-latest would run bytes the hub never sent and still report itself
|
|
59
|
-
* updated. That is precisely the divergence the hub-push path exists to
|
|
60
|
-
* prevent.
|
|
61
|
-
*
|
|
62
|
-
* So this module answers the question the isolated operator actually has —
|
|
63
|
-
* *which of my addons are behind the published release?* — and the UI names
|
|
64
|
-
* where the action lives. A read that cannot mislead beats a button that
|
|
65
|
-
* cannot work.
|
|
54
|
+
* This module answers the read half: *which of my addons are behind the
|
|
55
|
+
* published release?* The hub-version half is joined in by the routes.
|
|
66
56
|
*/
|
|
67
57
|
const fs = __importStar(require("node:fs"));
|
|
68
58
|
const path = __importStar(require("node:path"));
|
|
@@ -82,10 +82,13 @@ function isPairingRequest(method, urlPath) {
|
|
|
82
82
|
if (method === 'GET') {
|
|
83
83
|
return (pathOnly === '/api/agent/status' ||
|
|
84
84
|
pathOnly === '/api/agent/config' ||
|
|
85
|
-
pathOnly === '/api/agent/discovered-nodes'
|
|
85
|
+
pathOnly === '/api/agent/discovered-nodes' ||
|
|
86
|
+
pathOnly === '/api/agent/hubs-found');
|
|
86
87
|
}
|
|
87
88
|
if (method === 'POST') {
|
|
88
|
-
return pathOnly === '/api/agent/config' ||
|
|
89
|
+
return (pathOnly === '/api/agent/config' ||
|
|
90
|
+
pathOnly === '/api/agent/restart' ||
|
|
91
|
+
pathOnly === '/api/agent/hubs-found/refresh');
|
|
89
92
|
}
|
|
90
93
|
return false;
|
|
91
94
|
}
|
package/dist/agent/agent-http.js
CHANGED
|
@@ -47,6 +47,7 @@ exports.pickUiDist = pickUiDist;
|
|
|
47
47
|
exports.resolveUiDistDir = resolveUiDistDir;
|
|
48
48
|
exports.getRegistryNodes = getRegistryNodes;
|
|
49
49
|
exports.resolveHubEndpoint = resolveHubEndpoint;
|
|
50
|
+
exports.isOpaqueHostname = isOpaqueHostname;
|
|
50
51
|
exports.nodeDisplayName = nodeDisplayName;
|
|
51
52
|
exports.mapDiscoveredNodes = mapDiscoveredNodes;
|
|
52
53
|
exports.createAgentHttpServer = createAgentHttpServer;
|
|
@@ -61,6 +62,8 @@ const agent_mesh_enroll_js_1 = require("./agent-mesh-enroll.js");
|
|
|
61
62
|
const agent_update_routes_js_1 = require("./agent-update-routes.js");
|
|
62
63
|
const derive_hub_url_js_1 = require("./derive-hub-url.js");
|
|
63
64
|
Object.defineProperty(exports, "pickIpv4", { enumerable: true, get: function () { return derive_hub_url_js_1.pickIpv4; } });
|
|
65
|
+
const agent_addon_runtime_js_1 = require("./agent-addon-runtime.js");
|
|
66
|
+
const hubs_found_js_1 = require("./hubs-found.js");
|
|
64
67
|
/** Dotted-numeric version compare; `null` sorts lowest. */
|
|
65
68
|
function compareVersions(a, b) {
|
|
66
69
|
if (a === null && b === null)
|
|
@@ -182,10 +185,17 @@ function resolveHubEndpoint(nodes) {
|
|
|
182
185
|
return null;
|
|
183
186
|
return (0, derive_hub_url_js_1.pickIpv4)(hub.ipList) ?? hub.hostname ?? null;
|
|
184
187
|
}
|
|
185
|
-
/**
|
|
188
|
+
/** A container id (12 hex) or full id (64 hex): a name nobody chose. */
|
|
189
|
+
function isOpaqueHostname(hostname) {
|
|
190
|
+
return /^[0-9a-f]{12}$|^[0-9a-f]{64}$/.test(hostname);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Prefer a name an operator can recognise over the bare Moleculer node id —
|
|
194
|
+
* and over a container's id, which is what the hostname of a hub in Docker is.
|
|
195
|
+
*/
|
|
186
196
|
function nodeDisplayName(node) {
|
|
187
197
|
const hostname = typeof node.hostname === 'string' ? node.hostname.trim() : '';
|
|
188
|
-
if (hostname.length > 0 && hostname !== node.id)
|
|
198
|
+
if (hostname.length > 0 && hostname !== node.id && !isOpaqueHostname(hostname))
|
|
189
199
|
return hostname;
|
|
190
200
|
const ip = (0, derive_hub_url_js_1.pickIpv4)(node.ipList);
|
|
191
201
|
if (ip !== null)
|
|
@@ -214,12 +224,12 @@ function mapDiscoveredNodes(nodes, selfId) {
|
|
|
214
224
|
* external getter is wired. Does NOT detect `secret-mismatch` — that
|
|
215
225
|
* requires the bootstrap to inject `getHubConnectionState`.
|
|
216
226
|
*/
|
|
217
|
-
function deriveHubConnectionState(nodes,
|
|
227
|
+
function deriveHubConnectionState(nodes, hubAddressConfigured) {
|
|
218
228
|
const hubInRegistry = nodes.some((n) => n.id === 'hub');
|
|
219
229
|
if (hubInRegistry)
|
|
220
230
|
return 'connected';
|
|
221
|
-
if (
|
|
222
|
-
return '
|
|
231
|
+
if (!hubAddressConfigured)
|
|
232
|
+
return 'unconfigured';
|
|
223
233
|
return 'disconnected';
|
|
224
234
|
}
|
|
225
235
|
/**
|
|
@@ -325,10 +335,9 @@ async function createAgentHttpServer(getBroker, config) {
|
|
|
325
335
|
const eff = getEffectiveConfig(config.configPath, config.nodeId);
|
|
326
336
|
const nodes = getRegistryNodes(broker);
|
|
327
337
|
const hubConnected = nodes.some((n) => n.id === 'hub');
|
|
328
|
-
const discoveryMode = !eff.hubAddress;
|
|
329
338
|
const hubConnectionState = config.getHubConnectionState
|
|
330
339
|
? config.getHubConnectionState()
|
|
331
|
-
: deriveHubConnectionState(nodes,
|
|
340
|
+
: deriveHubConnectionState(nodes, Boolean(eff.hubAddress));
|
|
332
341
|
const meshTransport = config.getMeshTransport
|
|
333
342
|
? config.getMeshTransport()
|
|
334
343
|
: eff.hasMeshTls
|
|
@@ -357,7 +366,6 @@ async function createAgentHttpServer(getBroker, config) {
|
|
|
357
366
|
appVersion,
|
|
358
367
|
hubConnected,
|
|
359
368
|
hubConnectionState,
|
|
360
|
-
discoveryMode,
|
|
361
369
|
hasSecret: eff.hasSecret,
|
|
362
370
|
meshTransport,
|
|
363
371
|
discoveredNodes,
|
|
@@ -373,7 +381,6 @@ async function createAgentHttpServer(getBroker, config) {
|
|
|
373
381
|
appVersion,
|
|
374
382
|
hubConnected,
|
|
375
383
|
hubConnectionState,
|
|
376
|
-
discoveryMode,
|
|
377
384
|
hasSecret: eff.hasSecret,
|
|
378
385
|
meshTransport,
|
|
379
386
|
discoveredNodes,
|
|
@@ -431,6 +438,15 @@ async function createAgentHttpServer(getBroker, config) {
|
|
|
431
438
|
return [];
|
|
432
439
|
}
|
|
433
440
|
});
|
|
441
|
+
// -- Addons joined with their runners (D841) ---------------------------
|
|
442
|
+
app.get('/api/agent/addon-runtime', async () => {
|
|
443
|
+
const broker = getBroker();
|
|
444
|
+
const [status, list] = await Promise.all([
|
|
445
|
+
broker.call('$agent.status').catch(() => null),
|
|
446
|
+
broker.call('$process.list').catch(() => []),
|
|
447
|
+
]);
|
|
448
|
+
return (0, agent_addon_runtime_js_1.joinAddonRuntime)((0, agent_addon_runtime_js_1.addonsFromStatus)(status), (0, agent_addon_runtime_js_1.processesFromList)(list));
|
|
449
|
+
});
|
|
434
450
|
// -- Addon restart --------------------------------------------------
|
|
435
451
|
app.post('/api/agent/addon/restart', async (req, reply) => {
|
|
436
452
|
const addonId = req.body?.addonId;
|
|
@@ -553,6 +569,17 @@ async function createAgentHttpServer(getBroker, config) {
|
|
|
553
569
|
// Registered AFTER the onRequest hook above, so every path it mounts is
|
|
554
570
|
// gated by the same loopback-or-bearer rule as the rest of `/api/`.
|
|
555
571
|
(0, agent_update_routes_js_1.registerAgentUpdateRoutes)(app, config.updateControls ?? {});
|
|
572
|
+
// -- Hubs found on this network (D839) ------------------------------
|
|
573
|
+
// The cached list is free; the refresh browses mDNS for a few seconds. The
|
|
574
|
+
// UI asks on demand (opening the edit form, "Search again"), never on its
|
|
575
|
+
// status poll.
|
|
576
|
+
const hubsFinder = config.hubsFinder ??
|
|
577
|
+
(0, hubs_found_js_1.createHubsFinder)({
|
|
578
|
+
getConfiguredHubAddress: () => getEffectiveConfig(config.configPath, config.nodeId).hubAddress,
|
|
579
|
+
log: (message) => console.warn(`[Agent] ${message}`),
|
|
580
|
+
});
|
|
581
|
+
app.get('/api/agent/hubs-found', async () => hubsFinder.snapshot());
|
|
582
|
+
app.post('/api/agent/hubs-found/refresh', async () => hubsFinder.refresh());
|
|
556
583
|
// -- Discovered nodes -----------------------------------------------
|
|
557
584
|
app.get('/api/agent/discovered-nodes', async () => {
|
|
558
585
|
const b = getBroker();
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HUB_UPDATE_TIMEOUT_MS = exports.HUB_ADDON_UPDATE_ACTION = exports.HUB_ADDON_LIST_ACTION = void 0;
|
|
4
|
+
exports.hubVersionsFromList = hubVersionsFromList;
|
|
5
|
+
exports.isBehindHub = isBehindHub;
|
|
6
|
+
exports.createAgentHubAddonControls = createAgentHubAddonControls;
|
|
7
|
+
/**
|
|
8
|
+
* Updating an addon from the agent itself (D840) — by asking the HUB.
|
|
9
|
+
*
|
|
10
|
+
* ── Who decides an agent's addon version ───────────────────────────────────
|
|
11
|
+
* The hub. Every reconnect runs `reconcileAgentAddons` → `agent-addon-backfill`,
|
|
12
|
+
* whose INTENDED VERSION is "the hub's own installed version, and only that".
|
|
13
|
+
* A package installed on the agent at any other version is put back to the
|
|
14
|
+
* hub's — which is why an update run on an agent before the hub is undone, and
|
|
15
|
+
* why this module never installs anything locally.
|
|
16
|
+
*
|
|
17
|
+
* ── What the Update action does ───────────────────────────────────────────
|
|
18
|
+
* It asks the hub, over the mesh channel the agent already holds (the mTLS
|
|
19
|
+
* leaf; the bridge names the caller `node:<id>`), to deploy THE HUB'S version
|
|
20
|
+
* of one package to THIS node: `addons.updatePackage({ name, version:
|
|
21
|
+
* <hub version>, nodeId: <self> })`. The hub packs and pushes the tarball
|
|
22
|
+
* (`$agent.deploy` + `$agent.reload`) and records nothing new — the version
|
|
23
|
+
* it recorded is the version this node now runs, so the next reconcile finds
|
|
24
|
+
* nothing to repair. A newer PUBLISHED release is a hub-level decision (it
|
|
25
|
+
* moves every node), not something one agent can start.
|
|
26
|
+
*
|
|
27
|
+
* The hub accepts the call from an agent only under the scoped grant of D842
|
|
28
|
+
* (`remote-addon-update-grant.ts`): own node, the hub's recorded version, audited.
|
|
29
|
+
* Any refusal reaches the UI verbatim as the update's failure, never as silence.
|
|
30
|
+
*/
|
|
31
|
+
const index_js_1 = require("../server-root/index.js");
|
|
32
|
+
/** Hub procedures this module calls, as `$core-caps` actions. */
|
|
33
|
+
exports.HUB_ADDON_LIST_ACTION = '$core-caps.addons.list';
|
|
34
|
+
exports.HUB_ADDON_UPDATE_ACTION = '$core-caps.addons.updatePackage';
|
|
35
|
+
/** An agent's deploy + reload of a large package can take minutes. */
|
|
36
|
+
exports.HUB_UPDATE_TIMEOUT_MS = 330_000;
|
|
37
|
+
const HUB_LIST_TIMEOUT_MS = 15_000;
|
|
38
|
+
function errorMessage(err) {
|
|
39
|
+
return err instanceof Error ? err.message : String(err);
|
|
40
|
+
}
|
|
41
|
+
/** `packageName → packageVersion` from an `addons.list` answer; unreadable rows are skipped. */
|
|
42
|
+
function hubVersionsFromList(list) {
|
|
43
|
+
const out = new Map();
|
|
44
|
+
if (!Array.isArray(list))
|
|
45
|
+
return out;
|
|
46
|
+
for (const item of list) {
|
|
47
|
+
if (item === null || typeof item !== 'object')
|
|
48
|
+
continue;
|
|
49
|
+
const manifest = Reflect.get(item, 'manifest');
|
|
50
|
+
if (manifest === null || typeof manifest !== 'object')
|
|
51
|
+
continue;
|
|
52
|
+
const name = Reflect.get(manifest, 'packageName');
|
|
53
|
+
const version = Reflect.get(manifest, 'packageVersion');
|
|
54
|
+
if (typeof name === 'string' && typeof version === 'string' && !out.has(name)) {
|
|
55
|
+
out.set(name, version);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
/** True when the hub's version is strictly newer than this node's. */
|
|
61
|
+
function isBehindHub(installed, hub) {
|
|
62
|
+
return installed !== null && hub !== null && (0, index_js_1.compareSemver)(hub, installed) > 0;
|
|
63
|
+
}
|
|
64
|
+
/** The hub answered `{ success: false, error }`, or something unreadable. */
|
|
65
|
+
function failureOf(result) {
|
|
66
|
+
if (result === null || typeof result !== 'object')
|
|
67
|
+
return null;
|
|
68
|
+
if (Reflect.get(result, 'success') !== false)
|
|
69
|
+
return null;
|
|
70
|
+
const error = Reflect.get(result, 'error');
|
|
71
|
+
return typeof error === 'string' && error.length > 0 ? error : 'the hub reported a failure';
|
|
72
|
+
}
|
|
73
|
+
function createAgentHubAddonControls(deps) {
|
|
74
|
+
const states = new Map();
|
|
75
|
+
const getHubVersions = async () => {
|
|
76
|
+
try {
|
|
77
|
+
const list = await deps.callHub(exports.HUB_ADDON_LIST_ACTION, undefined, HUB_LIST_TIMEOUT_MS);
|
|
78
|
+
return { ok: true, versions: hubVersionsFromList(list) };
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
return { ok: false, error: errorMessage(err) };
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
const run = async (packageName, version) => {
|
|
85
|
+
try {
|
|
86
|
+
const result = await deps.callHub(exports.HUB_ADDON_UPDATE_ACTION, { name: packageName, version, nodeId: deps.nodeId }, exports.HUB_UPDATE_TIMEOUT_MS);
|
|
87
|
+
const failure = failureOf(result);
|
|
88
|
+
states.set(packageName, failure === null
|
|
89
|
+
? { status: 'done', targetVersion: version, message: null }
|
|
90
|
+
: { status: 'failed', targetVersion: version, message: failure });
|
|
91
|
+
if (failure !== null) {
|
|
92
|
+
deps.onProblem?.(`hub refused to update ${packageName} to ${version}: ${failure}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
const message = errorMessage(err);
|
|
97
|
+
states.set(packageName, { status: 'failed', targetVersion: version, message });
|
|
98
|
+
deps.onProblem?.(`update of ${packageName} to ${version} through the hub failed: ${message}`);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
return {
|
|
102
|
+
getHubVersions,
|
|
103
|
+
progress: () => states,
|
|
104
|
+
start: async (packageName, installedPackages) => {
|
|
105
|
+
if (!installedPackages.includes(packageName)) {
|
|
106
|
+
return { started: false, message: `${packageName} is not installed on this node.` };
|
|
107
|
+
}
|
|
108
|
+
if (states.get(packageName)?.status === 'running') {
|
|
109
|
+
return { started: false, message: `${packageName} is already being updated.` };
|
|
110
|
+
}
|
|
111
|
+
const hub = await getHubVersions();
|
|
112
|
+
if (!hub.ok) {
|
|
113
|
+
return {
|
|
114
|
+
started: false,
|
|
115
|
+
message: `The hub could not be asked which version this node should run: ${hub.error}`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const hubVersion = hub.versions.get(packageName);
|
|
119
|
+
if (hubVersion === undefined) {
|
|
120
|
+
return {
|
|
121
|
+
started: false,
|
|
122
|
+
message: `The hub does not install ${packageName}, so it has no version to bring this node to.`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
states.set(packageName, { status: 'running', targetVersion: hubVersion, message: null });
|
|
126
|
+
// Not awaited: a deploy + reload outlives any request. The UI polls.
|
|
127
|
+
void run(packageName, hubVersion);
|
|
128
|
+
return { started: true, message: null };
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ADDON_UPDATE_UNAVAILABLE_REASON = exports.SERVER_UPDATE_UNAVAILABLE_REASON = exports.APPLY_ALREADY_RUNNING_MESSAGE = void 0;
|
|
3
|
+
exports.ADDON_HUB_UPDATE_UNAVAILABLE_REASON = exports.ADDON_UPDATE_UNAVAILABLE_REASON = exports.SERVER_UPDATE_UNAVAILABLE_REASON = exports.APPLY_ALREADY_RUNNING_MESSAGE = void 0;
|
|
4
4
|
exports.createAgentServerUpdateControls = createAgentServerUpdateControls;
|
|
5
5
|
exports.registerAgentUpdateRoutes = registerAgentUpdateRoutes;
|
|
6
|
+
const agent_hub_addon_update_js_1 = require("./agent-hub-addon-update.js");
|
|
6
7
|
/** The message a second concurrent apply gets. Exported so the test names it once. */
|
|
7
8
|
exports.APPLY_ALREADY_RUNNING_MESSAGE = 'Refused: an update is already in flight on this node.';
|
|
8
9
|
/**
|
|
@@ -66,6 +67,25 @@ function createAgentServerUpdateControls(engine, onError) {
|
|
|
66
67
|
exports.SERVER_UPDATE_UNAVAILABLE_REASON = 'Framework updates are not available on this agent build.';
|
|
67
68
|
/** Why the addon report renders empty when nothing was wired. */
|
|
68
69
|
exports.ADDON_UPDATE_UNAVAILABLE_REASON = 'The addon roster is not available on this agent build.';
|
|
70
|
+
/** Why the Update action is absent when the hub channel was not wired. */
|
|
71
|
+
exports.ADDON_HUB_UPDATE_UNAVAILABLE_REASON = 'Updating an addon through the hub is not available on this agent build.';
|
|
72
|
+
async function withHubVersions(report, hub) {
|
|
73
|
+
const versions = hub === undefined ? null : await hub.getHubVersions();
|
|
74
|
+
const progress = hub?.progress();
|
|
75
|
+
return {
|
|
76
|
+
...report,
|
|
77
|
+
hubVersionsError: versions === null || versions.ok ? null : versions.error,
|
|
78
|
+
entries: report.entries.map((entry) => {
|
|
79
|
+
const hubVersion = versions?.ok === true ? (versions.versions.get(entry.packageName) ?? null) : null;
|
|
80
|
+
return {
|
|
81
|
+
...entry,
|
|
82
|
+
hubVersion,
|
|
83
|
+
behindHub: (0, agent_hub_addon_update_js_1.isBehindHub)(entry.installedVersion, hubVersion),
|
|
84
|
+
update: progress?.get(entry.packageName) ?? null,
|
|
85
|
+
};
|
|
86
|
+
}),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
69
89
|
/**
|
|
70
90
|
* Mount the update routes on an already-built agent HTTP server. Called from
|
|
71
91
|
* `createAgentHttpServer` AFTER the auth hook, so every path here inherits it.
|
|
@@ -96,6 +116,18 @@ function registerAgentUpdateRoutes(app, controls) {
|
|
|
96
116
|
if (addons === undefined) {
|
|
97
117
|
return { available: false, reason: exports.ADDON_UPDATE_UNAVAILABLE_REASON };
|
|
98
118
|
}
|
|
99
|
-
|
|
119
|
+
const report = await addons.getReport();
|
|
120
|
+
return { available: true, ...(await withHubVersions(report, addons.hub)) };
|
|
121
|
+
});
|
|
122
|
+
app.post('/api/agent/addon-updates/update', async (req, reply) => {
|
|
123
|
+
if (addons?.hub === undefined) {
|
|
124
|
+
return reply.status(503).send({ error: exports.ADDON_HUB_UPDATE_UNAVAILABLE_REASON });
|
|
125
|
+
}
|
|
126
|
+
const packageName = req.body?.packageName;
|
|
127
|
+
if (typeof packageName !== 'string' || packageName.length === 0) {
|
|
128
|
+
return reply.status(400).send({ error: 'packageName required' });
|
|
129
|
+
}
|
|
130
|
+
const report = await addons.getReport();
|
|
131
|
+
return addons.hub.start(packageName, report.entries.map((e) => e.packageName));
|
|
100
132
|
});
|
|
101
133
|
}
|
|
@@ -28,8 +28,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
28
28
|
exports.deriveHubUrlFromHubAddress = deriveHubUrlFromHubAddress;
|
|
29
29
|
exports.deriveHubUrlForExport = deriveHubUrlForExport;
|
|
30
30
|
exports.pickIpv4 = pickIpv4;
|
|
31
|
-
exports.normalizeObservedHost = normalizeObservedHost;
|
|
32
|
-
exports.deriveHubUrlFromRegistry = deriveHubUrlFromRegistry;
|
|
33
31
|
const HUB_API_PORT = 4443;
|
|
34
32
|
function deriveHubUrlFromHubAddress(hubAddress) {
|
|
35
33
|
const trimmed = hubAddress.trim();
|
|
@@ -72,15 +70,10 @@ function extractHostFromAuthority(authority) {
|
|
|
72
70
|
const host = value.split(':')[0] ?? '';
|
|
73
71
|
return host.length > 0 ? host : undefined;
|
|
74
72
|
}
|
|
75
|
-
/** Node id the hub broker always registers under. */
|
|
76
|
-
const HUB_NODE_ID = 'hub';
|
|
77
|
-
const IPV4_MAPPED_PREFIX = '::ffff:';
|
|
78
|
-
const IPV4_DOTTED_QUAD = /^\d{1,3}(?:\.\d{1,3}){3}$/;
|
|
79
73
|
/**
|
|
80
74
|
* First non-internal IPv4 in a Moleculer node's advertised IP list.
|
|
81
75
|
* Canonical home of this helper — `resolveHubEndpoint` in `agent-http.ts`
|
|
82
|
-
* (UI-facing `resolvedHubAddress`) re-exports and reuses it
|
|
83
|
-
* and the exported `CAMSTACK_HUB_URL` always agree.
|
|
76
|
+
* (UI-facing `resolvedHubAddress`) re-exports and reuses it.
|
|
84
77
|
*/
|
|
85
78
|
function pickIpv4(ipList) {
|
|
86
79
|
if (!ipList)
|
|
@@ -94,46 +87,3 @@ function pickIpv4(ipList) {
|
|
|
94
87
|
}
|
|
95
88
|
return null;
|
|
96
89
|
}
|
|
97
|
-
/**
|
|
98
|
-
* Normalize a socket-observed address so it survives a WHATWG-`URL` hostname
|
|
99
|
-
* write (`substituteRtspHost`): strip the `::ffff:` IPv4-mapped prefix,
|
|
100
|
-
* bracket bare IPv6 (an unbracketed IPv6 assigned to `url.hostname` is
|
|
101
|
-
* silently IGNORED — the URL would keep 127.0.0.1), pass plain IPv4 /
|
|
102
|
-
* hostnames / already-bracketed IPv6 through untouched.
|
|
103
|
-
*/
|
|
104
|
-
function normalizeObservedHost(raw) {
|
|
105
|
-
if (raw === undefined || raw === null)
|
|
106
|
-
return undefined;
|
|
107
|
-
const trimmed = raw.trim();
|
|
108
|
-
if (trimmed.length === 0)
|
|
109
|
-
return undefined;
|
|
110
|
-
if (trimmed.startsWith('['))
|
|
111
|
-
return trimmed; // already-bracketed IPv6
|
|
112
|
-
if (trimmed.toLowerCase().startsWith(IPV4_MAPPED_PREFIX)) {
|
|
113
|
-
const mapped = trimmed.slice(IPV4_MAPPED_PREFIX.length);
|
|
114
|
-
if (IPV4_DOTTED_QUAD.test(mapped))
|
|
115
|
-
return mapped;
|
|
116
|
-
}
|
|
117
|
-
// Any remaining colon means bare IPv6 — bracket it.
|
|
118
|
-
if (trimmed.includes(':'))
|
|
119
|
-
return `[${trimmed}]`;
|
|
120
|
-
return trimmed;
|
|
121
|
-
}
|
|
122
|
-
/**
|
|
123
|
-
* Best hub host from the agent-side Moleculer registry view:
|
|
124
|
-
* `udpAddress` (wire truth) → first non-loopback IPv4 in `ipList`
|
|
125
|
-
* (hub self-advertised; container-internal in bridge-mode Docker) →
|
|
126
|
-
* `hostname` (hub's `os.hostname()`; often unresolvable — last resort).
|
|
127
|
-
* Returns `undefined` when the hub node is not (yet) known. Emits the SAME
|
|
128
|
-
* `https://host:4443` shape as `deriveHubUrlFromHubAddress` — see the port
|
|
129
|
-
* note at the top of this file.
|
|
130
|
-
*/
|
|
131
|
-
function deriveHubUrlFromRegistry(nodes) {
|
|
132
|
-
const hub = nodes.find((node) => node.id === HUB_NODE_ID);
|
|
133
|
-
if (hub === undefined)
|
|
134
|
-
return undefined;
|
|
135
|
-
const host = normalizeObservedHost(hub.udpAddress) ?? pickIpv4(hub.ipList) ?? hub.hostname;
|
|
136
|
-
if (host === undefined || host.length === 0)
|
|
137
|
-
return undefined;
|
|
138
|
-
return `https://${host}:${HUB_API_PORT}`;
|
|
139
|
-
}
|
|
@@ -0,0 +1,100 @@
|
|
|
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.CONFIGURED_HUB_SOURCE = void 0;
|
|
37
|
+
exports.createHubsFinder = createHubsFinder;
|
|
38
|
+
const derive_hub_url_js_1 = require("./derive-hub-url.js");
|
|
39
|
+
/** Label of the configured hub in {@link FoundHub.sources}. */
|
|
40
|
+
exports.CONFIGURED_HUB_SOURCE = 'configured hub address';
|
|
41
|
+
/** A scan newer than this answers a refresh without scanning again. */
|
|
42
|
+
const MIN_RESCAN_INTERVAL_MS = 5_000;
|
|
43
|
+
const BROWSE_TIMEOUT_MS = 3_000;
|
|
44
|
+
function toFoundHub(candidate) {
|
|
45
|
+
return {
|
|
46
|
+
url: candidate.url,
|
|
47
|
+
host: candidate.host,
|
|
48
|
+
nodeId: candidate.nodeId,
|
|
49
|
+
version: candidate.version,
|
|
50
|
+
sources: candidate.sources,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
async function defaultDiscover(deps) {
|
|
54
|
+
// Lazy: the socket library is only loaded when an operator asks.
|
|
55
|
+
const { discoverHubs } = await Promise.resolve().then(() => __importStar(require('@camstack/develop/hub-discovery')));
|
|
56
|
+
return discoverHubs(deps);
|
|
57
|
+
}
|
|
58
|
+
function createHubsFinder(deps) {
|
|
59
|
+
const discover = deps.discover ?? defaultDiscover;
|
|
60
|
+
const now = deps.now ?? Date.now;
|
|
61
|
+
let hubs = [];
|
|
62
|
+
let scannedAt = null;
|
|
63
|
+
let inFlight = null;
|
|
64
|
+
const snapshot = () => ({ hubs, scannedAt, scanning: inFlight !== null });
|
|
65
|
+
const scan = async () => {
|
|
66
|
+
const configured = deps.getConfiguredHubAddress();
|
|
67
|
+
const configuredUrl = configured === null ? undefined : (0, derive_hub_url_js_1.deriveHubUrlFromHubAddress)(configured);
|
|
68
|
+
try {
|
|
69
|
+
const found = await discover({
|
|
70
|
+
mdnsTimeoutMs: BROWSE_TIMEOUT_MS,
|
|
71
|
+
known: configuredUrl === undefined
|
|
72
|
+
? []
|
|
73
|
+
: [{ url: configuredUrl, source: exports.CONFIGURED_HUB_SOURCE }],
|
|
74
|
+
onProblem: (message) => deps.log?.(`hub browse problem: ${message}`),
|
|
75
|
+
});
|
|
76
|
+
hubs = found.map(toFoundHub);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
// A failed scan keeps the previous list: unknown is not "no hub".
|
|
80
|
+
deps.log?.(`hub scan failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
81
|
+
}
|
|
82
|
+
scannedAt = now();
|
|
83
|
+
return { hubs, scannedAt, scanning: false };
|
|
84
|
+
};
|
|
85
|
+
return {
|
|
86
|
+
snapshot,
|
|
87
|
+
refresh: () => {
|
|
88
|
+
if (inFlight !== null)
|
|
89
|
+
return inFlight;
|
|
90
|
+
if (scannedAt !== null && now() - scannedAt < MIN_RESCAN_INTERVAL_MS) {
|
|
91
|
+
return Promise.resolve(snapshot());
|
|
92
|
+
}
|
|
93
|
+
const running = scan().finally(() => {
|
|
94
|
+
inFlight = null;
|
|
95
|
+
});
|
|
96
|
+
inFlight = running;
|
|
97
|
+
return running;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
package/dist/agent/main.js
CHANGED
|
@@ -51,6 +51,7 @@ const agent_group_runner_js_1 = require("./agent-group-runner.js");
|
|
|
51
51
|
const agent_http_js_1 = require("./agent-http.js");
|
|
52
52
|
const agent_infra_capabilities_js_1 = require("./agent-infra-capabilities.js");
|
|
53
53
|
const agent_service_js_1 = require("./agent-service.js");
|
|
54
|
+
const agent_hub_addon_update_js_1 = require("./agent-hub-addon-update.js");
|
|
54
55
|
const agent_update_routes_js_1 = require("./agent-update-routes.js");
|
|
55
56
|
const agent_update_service_js_1 = require("./agent-update-service.js");
|
|
56
57
|
const derive_hub_url_js_1 = require("./derive-hub-url.js");
|
|
@@ -122,7 +123,7 @@ async function startAgent(configPath) {
|
|
|
122
123
|
console.log(`[Agent] Starting node "${config.nodeId}" (name="${config.name}") connecting to ${config.hubAddress}`);
|
|
123
124
|
}
|
|
124
125
|
else {
|
|
125
|
-
console.log(`[Agent] Starting node "${config.nodeId}" (name="${config.name}")
|
|
126
|
+
console.log(`[Agent] Starting node "${config.nodeId}" (name="${config.name}") with no hub address — set one from the agent UI`);
|
|
126
127
|
}
|
|
127
128
|
console.log(`[Agent] Config: ${config.configPath}`);
|
|
128
129
|
console.log(`[Agent] Data dir: ${config.dataDir}`);
|
|
@@ -230,10 +231,9 @@ async function startAgent(configPath) {
|
|
|
230
231
|
}
|
|
231
232
|
// Reload config from file (UI may have written new hubAddress/secret)
|
|
232
233
|
const fresh = (0, agent_config_js_1.loadAgentConfig)(undefined, config.dataDir);
|
|
233
|
-
console.log(`[Agent] New config: hub=${fresh.hubAddress ?? '
|
|
234
|
+
console.log(`[Agent] New config: hub=${fresh.hubAddress ?? 'none'}, secret=${fresh.secret ? 'yes' : 'none'}, ` +
|
|
234
235
|
`mesh=${fresh.meshTls !== undefined ? 'tls' : 'clear'}`);
|
|
235
|
-
// Re-derive CAMSTACK_HUB_URL
|
|
236
|
-
// flow (config file freshly written a hubAddress). Explicit operator values
|
|
236
|
+
// Re-derive CAMSTACK_HUB_URL when the dashboard has just written a hubAddress. Explicit operator values
|
|
237
237
|
// still win via the boot-captured `hubUrlWasExplicit` flag.
|
|
238
238
|
// KNOWN LIMITATION (Stage-0): runners already spawned before this reconnect
|
|
239
239
|
// keep the env they inherited; picking up a changed hub address in live
|
|
@@ -279,7 +279,7 @@ async function startAgent(configPath) {
|
|
|
279
279
|
// regardless of registry state.
|
|
280
280
|
if (hubConnectionStateOverride !== null)
|
|
281
281
|
return hubConnectionStateOverride;
|
|
282
|
-
// Fall through to live registry-derived state (connected/
|
|
282
|
+
// Fall through to live registry-derived state (connected/unconfigured/disconnected).
|
|
283
283
|
// getRegistryNodes-equivalent inline: broker.registry.getNodeList
|
|
284
284
|
try {
|
|
285
285
|
const registry = broker.registry;
|
|
@@ -291,8 +291,8 @@ async function startAgent(configPath) {
|
|
|
291
291
|
/* fall through */
|
|
292
292
|
}
|
|
293
293
|
const configNow = readConfigFile(config.configPath);
|
|
294
|
-
const
|
|
295
|
-
return
|
|
294
|
+
const hubAddressConfigured = typeof configNow.hubAddress === 'string' && configNow.hubAddress.length > 0;
|
|
295
|
+
return hubAddressConfigured ? 'disconnected' : 'unconfigured';
|
|
296
296
|
}
|
|
297
297
|
function readConfigFile(p) {
|
|
298
298
|
if (!fs.existsSync(p))
|
|
@@ -773,45 +773,6 @@ async function startAgent(configPath) {
|
|
|
773
773
|
// discovery resolve (or time out cleanly via the read-blob fallback)
|
|
774
774
|
// so agent boot completes instead of hanging on the first infra addon.
|
|
775
775
|
await broker.start();
|
|
776
|
-
// ── Registry-derived CAMSTACK_HUB_URL (Option A — discovery-mode fallback) ──
|
|
777
|
-
// In UDP-discovery mode `config.hubAddress` is null, so the boot-time
|
|
778
|
-
// derivation above (Option B) produced nothing. The agent parent is the only
|
|
779
|
-
// agent-side process with a Moleculer registry (addon-runner children are
|
|
780
|
-
// broker-less), so derive the hub host from the LIVE hub connection —
|
|
781
|
-
// `udpAddress` is wire truth — and export it through the same env contract
|
|
782
|
-
// before the runner spawns in loadClusterCapableAddons below.
|
|
783
|
-
// Precedence: explicit operator env > config-derived (B) > registry (A).
|
|
784
|
-
// A fills only the empty case; on later hub (re)connects it refreshes only
|
|
785
|
-
// its OWN previous value (hub IP change), never an explicit or B-derived one.
|
|
786
|
-
// BOOT-ORDER LIMITATION (Stage-0): a runner spawned before the first hub
|
|
787
|
-
// connect in discovery mode inherits an empty env until it is respawned —
|
|
788
|
-
// same class as Option B's reconnect limitation documented above. The first
|
|
789
|
-
// hub connect normally precedes any orchestrator attach dispatch, but the
|
|
790
|
-
// child env snapshot is fixed at spawn time.
|
|
791
|
-
let hubUrlFromRegistry;
|
|
792
|
-
const reconcileHubUrlFromRegistry = () => {
|
|
793
|
-
if (hubUrlWasExplicit)
|
|
794
|
-
return;
|
|
795
|
-
const current = process.env['CAMSTACK_HUB_URL'];
|
|
796
|
-
if (current !== undefined && current !== hubUrlFromRegistry)
|
|
797
|
-
return;
|
|
798
|
-
const fromRegistry = (0, derive_hub_url_js_1.deriveHubUrlFromRegistry)((0, agent_http_js_1.getRegistryNodes)(broker));
|
|
799
|
-
if (fromRegistry === undefined || fromRegistry === current)
|
|
800
|
-
return;
|
|
801
|
-
process.env['CAMSTACK_HUB_URL'] = fromRegistry;
|
|
802
|
-
hubUrlFromRegistry = fromRegistry;
|
|
803
|
-
console.log(`[Agent] Derived CAMSTACK_HUB_URL=${fromRegistry} from live hub connection`);
|
|
804
|
-
};
|
|
805
|
-
// Immediate attempt (the hub may already be in the registry), then reconcile
|
|
806
|
-
// on every hub (re)connect via the `$node.connected` localBus idiom — NO
|
|
807
|
-
// timers, NO registry polling (CLAUDE.md invariant).
|
|
808
|
-
reconcileHubUrlFromRegistry();
|
|
809
|
-
broker.localBus.on('$node.connected', (data) => {
|
|
810
|
-
const node = data.node;
|
|
811
|
-
if (node?.id !== 'hub')
|
|
812
|
-
return;
|
|
813
|
-
reconcileHubUrlFromRegistry();
|
|
814
|
-
});
|
|
815
776
|
// C2: wire the UDS ↔ Moleculer event bridge so events emitted by UDS
|
|
816
777
|
// children fan to siblings and reach the cluster, and cluster / parent-
|
|
817
778
|
// local events propagate to every UDS child. Inert when agentUdsRegistry
|
|
@@ -870,6 +831,13 @@ async function startAgent(configPath) {
|
|
|
870
831
|
});
|
|
871
832
|
}),
|
|
872
833
|
addons: {
|
|
834
|
+
hub: (0, agent_hub_addon_update_js_1.createAgentHubAddonControls)({
|
|
835
|
+
nodeId: config.nodeId,
|
|
836
|
+
// The mesh channel the agent already holds; the hub names this caller
|
|
837
|
+
// `node:<id>` and answers per its remote privilege grant.
|
|
838
|
+
callHub: (action, params, timeoutMs) => broker.call(action, params, { nodeID: 'hub', timeout: timeoutMs }),
|
|
839
|
+
onProblem: (message) => consoleLogger.warn(message, { tags: { nodeId: config.nodeId } }),
|
|
840
|
+
}),
|
|
873
841
|
getReport: () => (0, agent_addon_updates_js_1.buildAgentAddonUpdateReport)({
|
|
874
842
|
addonsDir: config.addonsDir,
|
|
875
843
|
...(process.env['CAMSTACK_NPM_REGISTRY'] !== undefined
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* routes the call exactly like any addon-provided cap.
|
|
19
19
|
*/
|
|
20
20
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
-
exports.REMOTE_PRIVILEGE_LOG_ONLY = exports.REMOTE_PRIVILEGE_ALLOWLIST = exports.CORE_CAP_REMOTE_FORBIDDEN_TYPE = exports.REMOTE_DENIED_NAMESPACES = void 0;
|
|
21
|
+
exports.REMOTE_PRIVILEGE_LOG_ONLY = exports.REMOTE_UPDATE_PACKAGE_PATH = exports.REMOTE_PRIVILEGE_ALLOWLIST = exports.CORE_CAP_REMOTE_FORBIDDEN_TYPE = exports.REMOTE_DENIED_NAMESPACES = void 0;
|
|
22
22
|
exports.setRemotePrivilegeLogSink = setRemotePrivilegeLogSink;
|
|
23
23
|
exports.__resetRemotePrivilegeObservationForTests = __resetRemotePrivilegeObservationForTests;
|
|
24
24
|
exports.observeRemotePrivilege = observeRemotePrivilege;
|
|
@@ -28,6 +28,7 @@ exports.describeMeshOrigin = describeMeshOrigin;
|
|
|
28
28
|
exports.buildCoreCapService = buildCoreCapService;
|
|
29
29
|
const system_1 = require("@camstack/system");
|
|
30
30
|
const moleculer_1 = require("moleculer");
|
|
31
|
+
const remote_addon_update_grant_js_1 = require("./remote-addon-update-grant.js");
|
|
31
32
|
const http_request_census_singleton_js_1 = require("./http-request-census.singleton.js");
|
|
32
33
|
const server_provided_caps_js_1 = require("./server-provided-caps.js");
|
|
33
34
|
const trpc_context_js_1 = require("./trpc.context.js");
|
|
@@ -90,6 +91,8 @@ exports.REMOTE_PRIVILEGE_ALLOWLIST = new Set([
|
|
|
90
91
|
'addons.setCapabilityProviderEnabled',
|
|
91
92
|
'system.getSiteLocation',
|
|
92
93
|
]);
|
|
94
|
+
/** The one path outside the allowlist a node may call, under {@link authorizeRemoteUpdatePackage}. */
|
|
95
|
+
exports.REMOTE_UPDATE_PACKAGE_PATH = 'addons.updatePackage';
|
|
93
96
|
/**
|
|
94
97
|
* When true, remote bridged calls outside {@link REMOTE_PRIVILEGE_ALLOWLIST} are
|
|
95
98
|
* logged once per `(nodeId, path)` per process — still admin, not refused.
|
|
@@ -276,7 +279,7 @@ function buildCoreCapService(appRouter, census = (0, http_request_census_singlet
|
|
|
276
279
|
const method = path.slice(dot + 1);
|
|
277
280
|
actions.push({
|
|
278
281
|
actionName: `${toKebab(namespace)}.${method}`,
|
|
279
|
-
invoke: (input, origin) => {
|
|
282
|
+
invoke: async (input, origin) => {
|
|
280
283
|
countMeshCall(census, path, origin);
|
|
281
284
|
const remoteNode = remoteOrigin(origin, options.ownNodeId);
|
|
282
285
|
if (remoteNode !== null && exports.REMOTE_DENIED_NAMESPACES.has(namespace)) {
|
|
@@ -286,6 +289,12 @@ function buildCoreCapService(appRouter, census = (0, http_request_census_singlet
|
|
|
286
289
|
if (exports.REMOTE_PRIVILEGE_LOG_ONLY) {
|
|
287
290
|
observeRemotePrivilege(remoteNode, path);
|
|
288
291
|
}
|
|
292
|
+
else if (path === exports.REMOTE_UPDATE_PACKAGE_PATH && options.remoteUpdateGrant) {
|
|
293
|
+
const decision = await (0, remote_addon_update_grant_js_1.authorizeRemoteUpdatePackage)(remoteNode, input, options.remoteUpdateGrant);
|
|
294
|
+
if (!decision.allowed) {
|
|
295
|
+
throw new moleculer_1.Errors.MoleculerClientError(`"${path}" refused for caller "${remoteNode}": ${decision.reason ?? 'not permitted'}`, 403, exports.CORE_CAP_REMOTE_FORBIDDEN_TYPE);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
289
298
|
else if (!exports.REMOTE_PRIVILEGE_ALLOWLIST.has(path)) {
|
|
290
299
|
observeRemotePrivilegeRefused(remoteNode, path);
|
|
291
300
|
throw new moleculer_1.Errors.MoleculerClientError(`"${path}" is not on the remote privilege grant (caller "${remoteNode}")`, 403, exports.CORE_CAP_REMOTE_FORBIDDEN_TYPE);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The scoped grant that lets an agent update an addon on ITS OWN node (D842).
|
|
4
|
+
*
|
|
5
|
+
* `addons.updatePackage` is deliberately not on `REMOTE_PRIVILEGE_ALLOWLIST`:
|
|
6
|
+
* that list is by path, and a path grant would let any enrolled node deploy any
|
|
7
|
+
* package version to any other node. This rule grants the path only under three
|
|
8
|
+
* conditions, all decided from what the hub knows, never from what the caller
|
|
9
|
+
* says about itself:
|
|
10
|
+
*
|
|
11
|
+
* 1. the target `nodeId` IS the caller's authenticated node id (the mesh
|
|
12
|
+
* identity Moleculer reports for the calling node; the request body names a
|
|
13
|
+
* target, it never names the caller);
|
|
14
|
+
* 2. the version asked for IS the version the hub itself has installed for that
|
|
15
|
+
* package — the one its back-fill restores on every reconnect. An agent can
|
|
16
|
+
* therefore only converge to the hub's record, never inject an older, newer
|
|
17
|
+
* or unpublished build;
|
|
18
|
+
* 3. the package exists on the hub.
|
|
19
|
+
*
|
|
20
|
+
* Every decision, allowed or refused, is written to the auth audit trail.
|
|
21
|
+
*/
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.authorizeRemoteUpdatePackage = authorizeRemoteUpdatePackage;
|
|
24
|
+
function readRequest(input) {
|
|
25
|
+
if (input === null || typeof input !== 'object')
|
|
26
|
+
return null;
|
|
27
|
+
const name = Reflect.get(input, 'name');
|
|
28
|
+
const version = Reflect.get(input, 'version');
|
|
29
|
+
const nodeId = Reflect.get(input, 'nodeId');
|
|
30
|
+
if (typeof name !== 'string' || name === '')
|
|
31
|
+
return null;
|
|
32
|
+
if (typeof version !== 'string' || version === '')
|
|
33
|
+
return null;
|
|
34
|
+
if (typeof nodeId !== 'string' || nodeId === '')
|
|
35
|
+
return null;
|
|
36
|
+
return { name, version, nodeId };
|
|
37
|
+
}
|
|
38
|
+
function decide(callerNodeId, input, deps) {
|
|
39
|
+
const request = readRequest(input);
|
|
40
|
+
if (request === null) {
|
|
41
|
+
return {
|
|
42
|
+
allowed: false,
|
|
43
|
+
reason: 'addons.updatePackage from a node needs { name, version, nodeId } — all three',
|
|
44
|
+
detail: { reason: 'malformed' },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const detail = { name: request.name, version: request.version, target: request.nodeId };
|
|
48
|
+
if (request.nodeId !== callerNodeId) {
|
|
49
|
+
return {
|
|
50
|
+
allowed: false,
|
|
51
|
+
reason: `a node may update packages on its own node only: "${callerNodeId}" asked for "${request.nodeId}"`,
|
|
52
|
+
detail: { ...detail, reason: 'other-node' },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const hubVersion = deps.hubInstalledVersion(request.name);
|
|
56
|
+
if (hubVersion === null) {
|
|
57
|
+
return {
|
|
58
|
+
allowed: false,
|
|
59
|
+
reason: `the hub does not install ${request.name}, so it has no version to bring "${callerNodeId}" to`,
|
|
60
|
+
detail: { ...detail, reason: 'not-on-hub' },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (request.version !== hubVersion) {
|
|
64
|
+
return {
|
|
65
|
+
allowed: false,
|
|
66
|
+
reason: `a node can only be brought to the hub's version: the hub records ${request.name}@${hubVersion}, not ${request.version}`,
|
|
67
|
+
detail: { ...detail, reason: 'version-differs', hubVersion },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return { allowed: true, reason: null, detail };
|
|
71
|
+
}
|
|
72
|
+
/** Decide, audit, and return the decision. */
|
|
73
|
+
async function authorizeRemoteUpdatePackage(callerNodeId, input, deps) {
|
|
74
|
+
const { allowed, reason, detail } = decide(callerNodeId, input, deps);
|
|
75
|
+
await deps.audit({
|
|
76
|
+
action: allowed ? 'agent-addon-update' : 'agent-addon-update-refused',
|
|
77
|
+
userId: `node:${callerNodeId}`,
|
|
78
|
+
detail,
|
|
79
|
+
});
|
|
80
|
+
return { allowed, reason };
|
|
81
|
+
}
|
|
@@ -181,21 +181,18 @@ class MoleculerService {
|
|
|
181
181
|
this.streamProbe = streamProbe;
|
|
182
182
|
this.logger = this.logging.createLogger('moleculer');
|
|
183
183
|
// Optional port overrides. Live primarily for the e2e harness: when
|
|
184
|
-
// a developer's dev:full is up on the default 6000
|
|
185
|
-
// isolated test hub can't reuse
|
|
186
|
-
//
|
|
187
|
-
// without per-deploy config.
|
|
184
|
+
// a developer's dev:full is up on the default 6000 port, an
|
|
185
|
+
// isolated test hub can't reuse it. Production keeps the default so
|
|
186
|
+
// agent-to-hub connections keep working without per-deploy config.
|
|
188
187
|
const tcpPortEnv = process.env['CAMSTACK_HUB_TCP_PORT'];
|
|
189
|
-
const udpPortEnv = process.env['CAMSTACK_HUB_UDP_PORT'];
|
|
190
188
|
const tcpPort = tcpPortEnv ? Number(tcpPortEnv) : undefined;
|
|
191
|
-
const udpPort = udpPortEnv ? Number(udpPortEnv) : undefined;
|
|
192
189
|
// Two-step cast: createBroker's dist `.d.ts` chains through
|
|
193
190
|
// moleculer→eventemitter2 whose types are unresolvable at this
|
|
194
191
|
// boundary, so the inference falls to `error` and trips
|
|
195
192
|
// `no-unsafe-assignment`. Going via `unknown` documents the boundary.
|
|
196
193
|
// No cluster secret here (D797): the broker is created before the keychain
|
|
197
194
|
// can answer, and the Moleculer namespace was never authentication (TCP
|
|
198
|
-
// ignores it
|
|
195
|
+
// ignores it). The secret gates
|
|
199
196
|
// `$hub.registerNode`, read from the keychain at call time.
|
|
200
197
|
this.meshGuard = new hub_mesh_guard_js_1.HubMeshGuard('hub', this.logging.createLogger('mesh-auth'));
|
|
201
198
|
const broker = (0, system_1.createBroker)({
|
|
@@ -205,7 +202,6 @@ class MoleculerService {
|
|
|
205
202
|
middlewares: [this.meshGuard.middleware()],
|
|
206
203
|
logLevel: this.config.get('moleculer.logLevel') ?? 'warn',
|
|
207
204
|
...(tcpPort && !Number.isNaN(tcpPort) ? { tcpPort } : {}),
|
|
208
|
-
...(udpPort && !Number.isNaN(udpPort) ? { udpPort } : {}),
|
|
209
205
|
});
|
|
210
206
|
// `ServiceBroker` itself surfaces as `error`-typed at this boundary
|
|
211
207
|
// (eventemitter2 chain unresolvable). Documented + single-site cast.
|
package/dist/main.js
CHANGED
|
@@ -526,6 +526,34 @@ async function bootstrap() {
|
|
|
526
526
|
addonRegistry.setDataPlaneRegistry(dataPlaneRegistry);
|
|
527
527
|
// ── Configure the CapabilityRegistry (created in AddonRegistryService constructor) ──
|
|
528
528
|
const capabilityRegistry = addonRegistry.getCapabilityRegistry();
|
|
529
|
+
// D842: the scoped grant behind a node's own-node `addons.updatePackage`. The
|
|
530
|
+
// hub's record is its installed package version; every decision is audited.
|
|
531
|
+
const remoteUpdateGrantDeps = () => ({
|
|
532
|
+
hubInstalledVersion: (name) => {
|
|
533
|
+
try {
|
|
534
|
+
return (app
|
|
535
|
+
.get(addon_package_service_1.AddonPackageService)
|
|
536
|
+
.listInstalled()
|
|
537
|
+
.find((p) => p.name === name)?.version ?? null);
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
},
|
|
543
|
+
audit: async (event) => {
|
|
544
|
+
try {
|
|
545
|
+
const internal = (0, system_1.getUserManagementInternal)(capabilityRegistry.getSingleton('user-management'));
|
|
546
|
+
if (internal === null) {
|
|
547
|
+
console.warn('[bootstrap] agent addon update not audited: no user-management internal');
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
await internal.audit.record(event);
|
|
551
|
+
}
|
|
552
|
+
catch (err) {
|
|
553
|
+
console.warn('[bootstrap] agent addon update audit row not written:', err);
|
|
554
|
+
}
|
|
555
|
+
},
|
|
556
|
+
});
|
|
529
557
|
const sessionBacking = (0, hub_sessions_js_1.sessionBackingFrom)(capabilityRegistry, sessionLifetimes);
|
|
530
558
|
// From here on every verify of a session-grade token — tRPC, addon routes,
|
|
531
559
|
// the session cookie, OAuth authorize, uploads, health — requires its
|
|
@@ -647,7 +675,10 @@ async function bootstrap() {
|
|
|
647
675
|
{
|
|
648
676
|
const meshBroker = app.get(moleculer_service_1.MoleculerService);
|
|
649
677
|
// D802: another node's call runs as `node:<id>`, never the hub's own principal.
|
|
650
|
-
meshBroker.registerCoreCapService((0, core_cap_bridge_1.buildCoreCapService)(appRouter, undefined, {
|
|
678
|
+
meshBroker.registerCoreCapService((0, core_cap_bridge_1.buildCoreCapService)(appRouter, undefined, {
|
|
679
|
+
ownNodeId: meshBroker.nodeId,
|
|
680
|
+
remoteUpdateGrant: remoteUpdateGrantDeps(),
|
|
681
|
+
}));
|
|
651
682
|
}
|
|
652
683
|
console.log('[bootstrap] core-cap mesh bridge registered ($core-caps) — before addon boot');
|
|
653
684
|
}
|
|
@@ -742,7 +773,10 @@ async function bootstrap() {
|
|
|
742
773
|
{
|
|
743
774
|
const meshBroker = app.get(moleculer_service_1.MoleculerService);
|
|
744
775
|
// D802: another node's call runs as `node:<id>`, never the hub's own principal.
|
|
745
|
-
meshBroker.registerCoreCapService((0, core_cap_bridge_1.buildCoreCapService)(appRouter, undefined, {
|
|
776
|
+
meshBroker.registerCoreCapService((0, core_cap_bridge_1.buildCoreCapService)(appRouter, undefined, {
|
|
777
|
+
ownNodeId: meshBroker.nodeId,
|
|
778
|
+
remoteUpdateGrant: remoteUpdateGrantDeps(),
|
|
779
|
+
}));
|
|
746
780
|
}
|
|
747
781
|
console.log('[bootstrap] core-cap mesh bridge registered ($core-caps) — late fallback');
|
|
748
782
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.541",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"private": false,
|
|
6
6
|
"files": [
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"start": "node --env-file=.env dist/launcher.js",
|
|
25
25
|
"typecheck": "tsc --noEmit -p tsconfig.spec.json",
|
|
26
26
|
"test": "vitest run --reporter verbose",
|
|
27
|
-
"test:watch": "vitest"
|
|
27
|
+
"test:watch": "vitest",
|
|
28
|
+
"test:cluster": "vitest run -c vitest.cluster.config.ts --reporter verbose"
|
|
28
29
|
},
|
|
29
30
|
"camstack": {
|
|
30
31
|
"agentBootstrapAddons": [
|
|
@@ -37,17 +38,18 @@
|
|
|
37
38
|
]
|
|
38
39
|
},
|
|
39
40
|
"dependencies": {
|
|
40
|
-
"@camstack/addon-admin-ui": "1.2.
|
|
41
|
-
"@camstack/addon-agent-ui": "1.2.
|
|
41
|
+
"@camstack/addon-admin-ui": "1.2.407",
|
|
42
|
+
"@camstack/addon-agent-ui": "1.2.224",
|
|
42
43
|
"@camstack/addon-auth": "1.2.141",
|
|
43
44
|
"@camstack/addon-decoder-nodeav": "1.2.123",
|
|
44
45
|
"@camstack/addon-notifiers": "1.2.134",
|
|
45
|
-
"@camstack/addon-pipeline": "1.2.
|
|
46
|
+
"@camstack/addon-pipeline": "1.2.360",
|
|
46
47
|
"@camstack/addon-pipeline-orchestrator": "1.2.310",
|
|
47
|
-
"@camstack/addon-post-analysis": "1.2.
|
|
48
|
+
"@camstack/addon-post-analysis": "1.2.359",
|
|
49
|
+
"@camstack/develop": "0.1.5",
|
|
48
50
|
"@camstack/sdk": "1.2.131",
|
|
49
|
-
"@camstack/system": "1.2.
|
|
50
|
-
"@camstack/types": "1.2.
|
|
51
|
+
"@camstack/system": "1.2.417",
|
|
52
|
+
"@camstack/types": "1.2.355",
|
|
51
53
|
"@camstack/ui-library": "1.2.281",
|
|
52
54
|
"@fastify/compress": "^9.0.0",
|
|
53
55
|
"@fastify/cookie": "^11.0.2",
|