@camstack/server 1.1.76 → 1.2.0
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,93 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Slice-5, Task 6 — agent-side Moleculer service for forwarding hub-dispatched
|
|
4
|
+
* cap calls to the agent's forked UDS children.
|
|
5
|
+
*
|
|
6
|
+
* The hub's `CapRouteResolver` dispatches an `agent-child-forward` route by
|
|
7
|
+
* calling `callWithServiceDiscovery(broker, AGENT_CAP_FWD_SERVICE,
|
|
8
|
+
* AGENT_CAP_FWD_ACTION, params, { nodeID: agentNodeId })`. This service
|
|
9
|
+
* receives those calls, resolves the child locally via the agent's
|
|
10
|
+
* `LocalChildRegistry`, and forwards the call over UDS.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.createAgentCapDispatchService = createAgentCapDispatchService;
|
|
14
|
+
const system_1 = require("@camstack/system");
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// ctx.params narrowing helpers
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
/**
|
|
19
|
+
* Moleculer types `ctx.params` loosely; narrow it to `AgentCapForwardParams`
|
|
20
|
+
* by checking the required string fields without unsafe casts.
|
|
21
|
+
*/
|
|
22
|
+
function narrowParams(raw) {
|
|
23
|
+
if (raw === null || typeof raw !== 'object') {
|
|
24
|
+
throw new Error('$agent-cap-fwd.forward: invalid params — capName and method are required strings');
|
|
25
|
+
}
|
|
26
|
+
const capName = Reflect.get(raw, 'capName');
|
|
27
|
+
const method = Reflect.get(raw, 'method');
|
|
28
|
+
if (typeof capName !== 'string' || typeof method !== 'string') {
|
|
29
|
+
throw new Error('$agent-cap-fwd.forward: invalid params — capName and method are required strings');
|
|
30
|
+
}
|
|
31
|
+
const childId = Reflect.get(raw, 'childId');
|
|
32
|
+
const deviceId = Reflect.get(raw, 'deviceId');
|
|
33
|
+
return {
|
|
34
|
+
capName,
|
|
35
|
+
method,
|
|
36
|
+
args: Reflect.get(raw, 'args'),
|
|
37
|
+
childId: typeof childId === 'string' ? childId : undefined,
|
|
38
|
+
deviceId: typeof deviceId === 'number' ? deviceId : undefined,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Factory
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
/**
|
|
45
|
+
* Creates the Moleculer `ServiceSchema` for the agent-side cap-dispatch service.
|
|
46
|
+
*
|
|
47
|
+
* @param agentNodeId The Moleculer nodeId of this agent (used in error messages).
|
|
48
|
+
* @param agentUdsRegistry The agent's `LocalChildRegistry` (or a compatible spy in tests).
|
|
49
|
+
* `LocalChildRegistry` structurally satisfies `HubLocalChildDispatcher`.
|
|
50
|
+
* @param inProcessLookup Optional resolver for caps hosted in the agent's OWN main process
|
|
51
|
+
* (core addons such as `platform-probe`, `metrics-provider` register
|
|
52
|
+
* their singleton providers in the agent's `CapabilityRegistry`, NOT
|
|
53
|
+
* as forked UDS children). Consulted ONLY when no UDS child provides
|
|
54
|
+
* the cap. Without it, agent in-process core caps are unroutable from
|
|
55
|
+
* the hub.
|
|
56
|
+
* @param logger Optional scoped logger; logs the no-provider path at INFO level.
|
|
57
|
+
*/
|
|
58
|
+
function createAgentCapDispatchService(agentNodeId, agentUdsRegistry, inProcessLookup, logger) {
|
|
59
|
+
return {
|
|
60
|
+
name: system_1.AGENT_CAP_FWD_SERVICE,
|
|
61
|
+
actions: {
|
|
62
|
+
forward: {
|
|
63
|
+
handler: async (ctx) => {
|
|
64
|
+
const params = narrowParams(ctx.params);
|
|
65
|
+
const { capName, method, args, deviceId } = params;
|
|
66
|
+
// Resolve the child: prefer the explicitly-provided childId (hub may
|
|
67
|
+
// know it from its HubNodeRegistry), otherwise resolve locally.
|
|
68
|
+
const childId = params.childId !== undefined
|
|
69
|
+
? params.childId
|
|
70
|
+
: agentUdsRegistry.resolveChildId(capName, deviceId);
|
|
71
|
+
if (childId == null) {
|
|
72
|
+
// No forked UDS child owns this cap. It may instead be hosted by a
|
|
73
|
+
// core addon running in the agent's OWN main process (platform-probe,
|
|
74
|
+
// metrics-provider, …) — resolve it against the in-process registry.
|
|
75
|
+
const ref = inProcessLookup?.(capName) ?? null;
|
|
76
|
+
if (ref !== null) {
|
|
77
|
+
return ref.invoke(method, args);
|
|
78
|
+
}
|
|
79
|
+
logger?.info(`agent ${agentNodeId}: no provider for cap "${capName}"${deviceId !== undefined ? ` (deviceId ${deviceId})` : ''}`);
|
|
80
|
+
throw new Error(`agent ${agentNodeId} has no provider for cap "${capName}"${deviceId !== undefined ? ` (deviceId ${deviceId})` : ''}`);
|
|
81
|
+
}
|
|
82
|
+
const input = {
|
|
83
|
+
capName,
|
|
84
|
+
method,
|
|
85
|
+
args,
|
|
86
|
+
...(deviceId !== undefined ? { deviceId } : {}),
|
|
87
|
+
};
|
|
88
|
+
return agentUdsRegistry.callCapOnChild(childId, input);
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
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.loadAgentConfig = loadAgentConfig;
|
|
37
|
+
const fs = __importStar(require("node:fs"));
|
|
38
|
+
const path = __importStar(require("node:path"));
|
|
39
|
+
const crypto = __importStar(require("node:crypto"));
|
|
40
|
+
const os = __importStar(require("node:os"));
|
|
41
|
+
/**
|
|
42
|
+
* Generate a stable nodeId and persist it to the config file.
|
|
43
|
+
*
|
|
44
|
+
* Resolution order (first match wins, persisted on first boot):
|
|
45
|
+
* 1. Existing `nodeId` in the config file — survives env var changes
|
|
46
|
+
* and host renames once seeded.
|
|
47
|
+
* 2. `CAMSTACK_NODE_ID` env var on first boot — lets dev workflows
|
|
48
|
+
* pin a stable, human-readable nodeId (e.g. `dev-agent-0`)
|
|
49
|
+
* without clobbering already-persisted random ids.
|
|
50
|
+
* 3. A fresh random hex (`agent-a1b2c3`) — production fallback when
|
|
51
|
+
* no env var is set.
|
|
52
|
+
*
|
|
53
|
+
* Seeded values get written to the config file so subsequent boots
|
|
54
|
+
* round-trip exactly the same identity (Moleculer nodeID, capability
|
|
55
|
+
* routing, persisted agentSettings — all keyed by this string).
|
|
56
|
+
*/
|
|
57
|
+
function ensurePersistedNodeId(configPath, dataDir) {
|
|
58
|
+
const resolvedPath = path.resolve(dataDir, configPath);
|
|
59
|
+
let raw = {};
|
|
60
|
+
if (fs.existsSync(resolvedPath)) {
|
|
61
|
+
try {
|
|
62
|
+
raw = JSON.parse(fs.readFileSync(resolvedPath, 'utf-8'));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
/* corrupt file */
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (typeof raw.nodeId === 'string' && raw.nodeId.length > 0) {
|
|
69
|
+
return raw.nodeId;
|
|
70
|
+
}
|
|
71
|
+
const envSeed = process.env.CAMSTACK_NODE_ID;
|
|
72
|
+
const id = envSeed && envSeed.length > 0 ? envSeed : `agent-${crypto.randomBytes(4).toString('hex')}`;
|
|
73
|
+
raw.nodeId = id;
|
|
74
|
+
try {
|
|
75
|
+
fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
|
|
76
|
+
fs.writeFileSync(resolvedPath, JSON.stringify(raw, null, 2), 'utf-8');
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
/* best-effort */
|
|
80
|
+
}
|
|
81
|
+
return id;
|
|
82
|
+
}
|
|
83
|
+
function loadAgentConfig(configPath, dataDirOverride) {
|
|
84
|
+
const filePath = configPath ?? process.env.CAMSTACK_AGENT_CONFIG ?? 'agent.json';
|
|
85
|
+
const dataDir = dataDirOverride ?? process.env.CAMSTACK_DATA ?? './camstack-data';
|
|
86
|
+
const resolvedDataDir = path.resolve(dataDir);
|
|
87
|
+
// Name: config file takes priority over env (allows UI rename to stick).
|
|
88
|
+
// Env is the initial seed, config file is the persisted override.
|
|
89
|
+
const envName = process.env.CAMSTACK_NODE_ID ??
|
|
90
|
+
process.env.CAMSTACK_AGENT_NAME ??
|
|
91
|
+
`${os.hostname()}-${os.arch()}`;
|
|
92
|
+
// Environment variables for hub connection (optional — agent starts without)
|
|
93
|
+
const envHubAddress = process.env.CAMSTACK_HUB_ADDRESS || undefined;
|
|
94
|
+
const envSecret = process.env.CAMSTACK_CLUSTER_SECRET || undefined;
|
|
95
|
+
const configFullPath = path.resolve(resolvedDataDir, filePath);
|
|
96
|
+
const nodeId = ensurePersistedNodeId(filePath, resolvedDataDir);
|
|
97
|
+
// Read persisted config — these override env vars
|
|
98
|
+
let fileHubAddress;
|
|
99
|
+
let fileName;
|
|
100
|
+
let fileSecret;
|
|
101
|
+
if (fs.existsSync(configFullPath)) {
|
|
102
|
+
try {
|
|
103
|
+
const raw = JSON.parse(fs.readFileSync(configFullPath, 'utf-8'));
|
|
104
|
+
fileHubAddress = typeof raw.hubAddress === 'string' ? raw.hubAddress : undefined;
|
|
105
|
+
fileName = typeof raw.name === 'string' ? raw.name : undefined;
|
|
106
|
+
fileSecret = typeof raw.secret === 'string' ? raw.secret : undefined;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
/* corrupt config */
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Config file wins over env for all user-editable fields
|
|
113
|
+
const effectiveName = fileName ?? envName;
|
|
114
|
+
const effectiveHub = fileHubAddress ?? envHubAddress;
|
|
115
|
+
const effectiveSecret = fileSecret ?? envSecret;
|
|
116
|
+
return {
|
|
117
|
+
nodeId,
|
|
118
|
+
name: effectiveName,
|
|
119
|
+
hubAddress: effectiveHub,
|
|
120
|
+
dataDir: resolvedDataDir,
|
|
121
|
+
addonsDir: path.resolve(resolvedDataDir, 'addons'),
|
|
122
|
+
logLevel: process.env.CAMSTACK_LOG_LEVEL ?? 'info',
|
|
123
|
+
secret: effectiveSecret,
|
|
124
|
+
configPath: configFullPath,
|
|
125
|
+
statusPort: Number(process.env.CAMSTACK_STATUS_PORT) || 4444,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
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.applyDeployedBundle = applyDeployedBundle;
|
|
37
|
+
const fs = __importStar(require("node:fs"));
|
|
38
|
+
const path = __importStar(require("node:path"));
|
|
39
|
+
const node_crypto_1 = require("node:crypto");
|
|
40
|
+
/** Recursively remove a path, ignoring a missing target. */
|
|
41
|
+
function rmrf(target) {
|
|
42
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Move `from` → `to` atomically when on the same filesystem; fall back to a
|
|
46
|
+
* recursive copy + remove on a cross-device boundary (`EXDEV`). The agent's
|
|
47
|
+
* `/data` addonsDir is frequently a different filesystem from the OS temp dir,
|
|
48
|
+
* so a bare `renameSync` would throw `EXDEV` — the same fallback
|
|
49
|
+
* `AddonInstaller.applyUpdateFromStaged` uses.
|
|
50
|
+
*/
|
|
51
|
+
function moveDir(from, to) {
|
|
52
|
+
try {
|
|
53
|
+
fs.renameSync(from, to);
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
const code = err.code;
|
|
57
|
+
if (code === 'EXDEV') {
|
|
58
|
+
fs.cpSync(from, to, { recursive: true });
|
|
59
|
+
rmrf(from);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Atomically install a deployed addon bundle into `<addonsDir>/<addonId>`.
|
|
68
|
+
*
|
|
69
|
+
* Unlike the old `$agent.deploy` body (`rm` the live dir, THEN untar into it),
|
|
70
|
+
* this never leaves the live dir missing: extraction happens in a sibling temp
|
|
71
|
+
* dir first, the live dir is swapped in by rename, and any failure restores the
|
|
72
|
+
* previous copy. A killed/timed-out extract (the non-atomic-deploy hazard that
|
|
73
|
+
* a contaminated propagation test hit) leaves the old version fully intact.
|
|
74
|
+
*
|
|
75
|
+
* No `AddonInstaller` manifest dependency — works for any deployed addon,
|
|
76
|
+
* including ones never tracked in the agent's install manifest.
|
|
77
|
+
*
|
|
78
|
+
* @returns the live addon directory path (`<addonsDir>/<addonId>`).
|
|
79
|
+
*/
|
|
80
|
+
async function applyDeployedBundle(input) {
|
|
81
|
+
const { addonsDir, addonId, bundle, extract, logger } = input;
|
|
82
|
+
fs.mkdirSync(addonsDir, { recursive: true });
|
|
83
|
+
const liveDir = path.join(addonsDir, addonId);
|
|
84
|
+
// Place the temp + backup dirs as SIBLINGS of the live dir (same parent →
|
|
85
|
+
// same filesystem → atomic rename). Deriving them from the live dir's parent
|
|
86
|
+
// and basename keeps a scoped `addonId` like "@camstack/addon-pipeline" — whose
|
|
87
|
+
// slash would otherwise turn `.<addonId>.next` into a nested path — correct.
|
|
88
|
+
const liveParent = path.dirname(liveDir);
|
|
89
|
+
const liveBase = path.basename(liveDir);
|
|
90
|
+
fs.mkdirSync(liveParent, { recursive: true });
|
|
91
|
+
const token = (0, node_crypto_1.randomBytes)(6).toString('hex');
|
|
92
|
+
const nextDir = path.join(liveParent, `.${liveBase}.next.${token}`);
|
|
93
|
+
const backupDir = path.join(liveParent, `.${liveBase}.backup.${token}`);
|
|
94
|
+
// 1. Extract into a temp dir. If extraction throws (e.g. a killed untar),
|
|
95
|
+
// drop the temp dir and rethrow — the live dir is never touched.
|
|
96
|
+
fs.mkdirSync(nextDir, { recursive: true });
|
|
97
|
+
try {
|
|
98
|
+
await extract(bundle, nextDir);
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
rmrf(nextDir);
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
// 2. Swap. Back up the live dir (if present), then move next → live. On any
|
|
105
|
+
// failure, restore the backup so the addon stays installed at the old
|
|
106
|
+
// version.
|
|
107
|
+
const hadLive = fs.existsSync(liveDir);
|
|
108
|
+
if (hadLive) {
|
|
109
|
+
fs.renameSync(liveDir, backupDir);
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
moveDir(nextDir, liveDir);
|
|
113
|
+
}
|
|
114
|
+
catch (swapErr) {
|
|
115
|
+
if (hadLive) {
|
|
116
|
+
try {
|
|
117
|
+
if (fs.existsSync(liveDir))
|
|
118
|
+
rmrf(liveDir);
|
|
119
|
+
fs.renameSync(backupDir, liveDir);
|
|
120
|
+
}
|
|
121
|
+
catch (restoreErr) {
|
|
122
|
+
logger.error(`agent deploy swap: restore of "${addonId}" failed after a failed swap — manual recovery may be needed`, {
|
|
123
|
+
meta: {
|
|
124
|
+
backupDir,
|
|
125
|
+
error: restoreErr instanceof Error ? restoreErr.message : String(restoreErr),
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
rmrf(nextDir);
|
|
131
|
+
throw swapErr;
|
|
132
|
+
}
|
|
133
|
+
// 3. Success — drop the backup.
|
|
134
|
+
if (hadLive)
|
|
135
|
+
rmrf(backupDir);
|
|
136
|
+
return { addonDir: liveDir };
|
|
137
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
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.readPackageManifestAddons = readPackageManifestAddons;
|
|
37
|
+
exports.isGroupRunnerAddon = isGroupRunnerAddon;
|
|
38
|
+
exports.registerGroupRunnerProviders = registerGroupRunnerProviders;
|
|
39
|
+
exports.ensureGroupRunner = ensureGroupRunner;
|
|
40
|
+
const fs = __importStar(require("node:fs"));
|
|
41
|
+
const path = __importStar(require("node:path"));
|
|
42
|
+
const types_1 = require("@camstack/types");
|
|
43
|
+
/**
|
|
44
|
+
* Read a package's `camstack.addons[]` declarations directly from its
|
|
45
|
+
* package.json — WITHOUT importing the addon entry module.
|
|
46
|
+
*
|
|
47
|
+
* The reload path must NOT `import()` heavy native addon entries (node-av, the
|
|
48
|
+
* onnxruntime bridge, …) on the agent's MAIN thread: doing so blocks the event
|
|
49
|
+
* loop long enough that `$agent.status` stops answering and the hub drops the
|
|
50
|
+
* node from topology — and a hung import wedges `$agent.reload` outright (the
|
|
51
|
+
* reload-wedge). A group-runner addon only needs its declaration (id,
|
|
52
|
+
* capabilities, execution) to be (re)dispatched to a runner SUBPROCESS, which
|
|
53
|
+
* loads the real module itself. Returns null on a missing/corrupt manifest.
|
|
54
|
+
*/
|
|
55
|
+
function readPackageManifestAddons(dir) {
|
|
56
|
+
try {
|
|
57
|
+
const pkgPath = path.join(dir, 'package.json');
|
|
58
|
+
if (!fs.existsSync(pkgPath))
|
|
59
|
+
return null;
|
|
60
|
+
// Documented JSON boundary: the package.json shape is validated field-by-field below.
|
|
61
|
+
const raw = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
62
|
+
const packageName = typeof raw.name === 'string' ? raw.name : '';
|
|
63
|
+
const packageVersion = typeof raw.version === 'string' ? raw.version : '0.0.0';
|
|
64
|
+
const entries = Array.isArray(raw.camstack?.addons) ? raw.camstack.addons : [];
|
|
65
|
+
const declarations = [];
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
if (entry !== null &&
|
|
68
|
+
typeof entry === 'object' &&
|
|
69
|
+
typeof entry.id === 'string') {
|
|
70
|
+
// Boundary cast: validated to have a string `id`; the runtime helpers
|
|
71
|
+
// (isDeployableToAgent / resolveRunnerId) read only `execution` / `id`.
|
|
72
|
+
declarations.push(entry);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!packageName || declarations.length === 0)
|
|
76
|
+
return null;
|
|
77
|
+
return { packageName, packageVersion, declarations };
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Whether a deployed addon must run in a forked group runner (one addon = one
|
|
85
|
+
* process) rather than in-process. True when the addon declares an `execution`
|
|
86
|
+
* block with a placement reachable on an agent (`any-node`/`agent-only`).
|
|
87
|
+
*
|
|
88
|
+
* The default placement is `hub-only`, so a declaration without `execution`
|
|
89
|
+
* is hub-only and not a group-runner addon — and, being hub-only, is never
|
|
90
|
+
* deployable to an agent in the first place.
|
|
91
|
+
*/
|
|
92
|
+
function isGroupRunnerAddon(decl) {
|
|
93
|
+
return decl.execution !== undefined && (0, types_1.resolveAddonPlacement)(decl) !== 'hub-only';
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Register the broker-call cap proxies + `loadedAddons` bookkeeping for a group
|
|
97
|
+
* of addons whose runner is (now) live. Extracted so the boot path
|
|
98
|
+
* (`loadClusterCapableAddons`) and the reload path (`ensureGroupRunner`) share
|
|
99
|
+
* ONE implementation — preventing the two from drifting (the original cause of
|
|
100
|
+
* the in-process-reload wedge).
|
|
101
|
+
*/
|
|
102
|
+
function registerGroupRunnerProviders(deps, addons) {
|
|
103
|
+
for (const a of addons) {
|
|
104
|
+
for (const cap of a.capabilities) {
|
|
105
|
+
const capName = typeof cap === 'string' ? cap : cap.name;
|
|
106
|
+
const proxy = new Proxy({}, {
|
|
107
|
+
get(_target, prop) {
|
|
108
|
+
if (prop === 'then' || typeof prop === 'symbol')
|
|
109
|
+
return undefined;
|
|
110
|
+
return (params) => deps.broker.call(`${a.addonId}.${capName}.${prop}`, params);
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
// Idempotent (re)registration. The reload path (`ensureGroupRunner`)
|
|
114
|
+
// re-runs this after a `$process.restart` of the SUBPROCESS, but the
|
|
115
|
+
// agent's central registry still holds the proxy from the previous
|
|
116
|
+
// registration — the subprocess restart never touched it. Since
|
|
117
|
+
// `registerProvider` throws on a duplicate `(cap, addonId)` pair (a
|
|
118
|
+
// deliberate guard against a double `registerProvider` in one init
|
|
119
|
+
// path), drop the stale proxy first so the fresh one installs cleanly.
|
|
120
|
+
// On the boot path nothing is registered yet, so this is a no-op.
|
|
121
|
+
// Without this, an in-place addon update/redeploy leaves the whole group
|
|
122
|
+
// stuck in `error` until a full agent (main-process) restart clears the
|
|
123
|
+
// registry.
|
|
124
|
+
if (deps.capabilityRegistry.hasProvider(capName, a.addonId)) {
|
|
125
|
+
deps.capabilityRegistry.unregisterProvider(capName, a.addonId);
|
|
126
|
+
}
|
|
127
|
+
deps.capabilityRegistry.registerProvider(capName, a.addonId, proxy);
|
|
128
|
+
}
|
|
129
|
+
deps.loadedAddons.set(a.addonId, {
|
|
130
|
+
id: a.addonId,
|
|
131
|
+
status: 'running',
|
|
132
|
+
version: a.version,
|
|
133
|
+
packageName: a.packageName,
|
|
134
|
+
packageVersion: a.packageVersion,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Ensure a group runner is live with the CURRENT on-disk addon code, then wire
|
|
140
|
+
* its providers.
|
|
141
|
+
*
|
|
142
|
+
* A redeploy's runner is already running (the deploy step swapped its on-disk
|
|
143
|
+
* code but never killed the process), and `$process.spawnRunner` throws
|
|
144
|
+
* "already running" for a live runnerId. So restart first — `$process.restart`
|
|
145
|
+
* stops the old child, evicts it from the Moleculer registry, and re-spawns it
|
|
146
|
+
* from the same dir (now holding the new code) in a subprocess, off the agent's
|
|
147
|
+
* main event loop. Only when no runner exists yet (first deploy →
|
|
148
|
+
* `{success:false, reason:'not found'}`) do we `$process.spawnRunner`.
|
|
149
|
+
*
|
|
150
|
+
* On a hard failure of both paths the addons are marked `error` (no providers
|
|
151
|
+
* registered) so `$agent.status` reports the degraded state.
|
|
152
|
+
*/
|
|
153
|
+
async function ensureGroupRunner(deps, groupId, addons) {
|
|
154
|
+
try {
|
|
155
|
+
const restart = await deps.broker.call('$process.restart', { name: groupId });
|
|
156
|
+
if (!restart.success) {
|
|
157
|
+
if (restart.reason !== undefined && restart.reason !== 'not found') {
|
|
158
|
+
deps.logger.warn(`group "${groupId}" restart returned "${restart.reason}" — spawning a fresh runner`);
|
|
159
|
+
}
|
|
160
|
+
await deps.broker.call('$process.spawnRunner', {
|
|
161
|
+
runnerId: groupId,
|
|
162
|
+
addons: addons.map((a) => ({ addonId: a.addonId, addonDir: a.addonDir })),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
registerGroupRunnerProviders(deps, addons);
|
|
166
|
+
deps.logger.info(`group "${groupId}" live with ${addons.length} addon(s): ${addons.map((a) => a.addonId).join(', ')}`);
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
const msg = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
170
|
+
deps.logger.error(`failed to ensure group "${groupId}": ${msg}`);
|
|
171
|
+
for (const a of addons) {
|
|
172
|
+
deps.loadedAddons.set(a.addonId, { id: a.addonId, status: 'error' });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Agent HTTP auth — pure request-authorization helpers for the agent's
|
|
4
|
+
* port-4444 API (mirrors the hub's /health hardening).
|
|
5
|
+
*
|
|
6
|
+
* The surface was fully unauthenticated on 0.0.0.0 — including
|
|
7
|
+
* `POST /api/agent/config`, which can rewrite `hubAddress` and the
|
|
8
|
+
* cluster secret itself. Model:
|
|
9
|
+
* - loopback callers (the Electron renderer, in-container probes) are
|
|
10
|
+
* always allowed — the desktop app keeps working with zero changes,
|
|
11
|
+
* - remote callers must present `Authorization: Bearer <cluster secret>`
|
|
12
|
+
* — the secret is the agent↔hub trust anchor and the only credential
|
|
13
|
+
* an agent can verify without a user database,
|
|
14
|
+
* - an UNPAIRED agent (no secret configured yet) can be set up remotely
|
|
15
|
+
* through the pairing surface only; everything else stays denied.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.isLoopbackAddress = isLoopbackAddress;
|
|
19
|
+
exports.bearerMatchesSecret = bearerMatchesSecret;
|
|
20
|
+
exports.isAuthorizedAgentRequest = isAuthorizedAgentRequest;
|
|
21
|
+
exports.isPairingRequest = isPairingRequest;
|
|
22
|
+
const node_crypto_1 = require("node:crypto");
|
|
23
|
+
/** IPv4/IPv6 loopback (incl. the IPv4-mapped IPv6 form Node reports). */
|
|
24
|
+
function isLoopbackAddress(addr) {
|
|
25
|
+
if (!addr)
|
|
26
|
+
return false;
|
|
27
|
+
if (addr === '::1')
|
|
28
|
+
return true;
|
|
29
|
+
const v4 = addr.startsWith('::ffff:') ? addr.slice('::ffff:'.length) : addr;
|
|
30
|
+
// 127.0.0.0/8 — every octet must be numeric so '127.evil.host' fails.
|
|
31
|
+
const parts = v4.split('.');
|
|
32
|
+
if (parts.length !== 4 || parts[0] !== '127')
|
|
33
|
+
return false;
|
|
34
|
+
return parts.every((p) => /^\d{1,3}$/.test(p));
|
|
35
|
+
}
|
|
36
|
+
/** Timing-safe `Authorization: Bearer <secret>` compare. Empty secrets never match. */
|
|
37
|
+
function bearerMatchesSecret(authorization, secret) {
|
|
38
|
+
if (secret.length === 0)
|
|
39
|
+
return false;
|
|
40
|
+
if (!authorization?.startsWith('Bearer '))
|
|
41
|
+
return false;
|
|
42
|
+
const token = Buffer.from(authorization.slice('Bearer '.length));
|
|
43
|
+
const expected = Buffer.from(secret);
|
|
44
|
+
return token.length === expected.length && (0, node_crypto_1.timingSafeEqual)(token, expected);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* True when the request may access the protected agent API. `clusterSecret`
|
|
48
|
+
* is the CURRENTLY configured secret (`null`/empty = unpaired agent — no
|
|
49
|
+
* remote credential can be verified, so remote access is denied except for
|
|
50
|
+
* the pairing surface, which the route layer allows via {@link isPairingRequest}).
|
|
51
|
+
*/
|
|
52
|
+
function isAuthorizedAgentRequest(input, clusterSecret) {
|
|
53
|
+
if (isLoopbackAddress(input.remoteAddress))
|
|
54
|
+
return true;
|
|
55
|
+
if (clusterSecret === null || clusterSecret.length === 0)
|
|
56
|
+
return false;
|
|
57
|
+
return bearerMatchesSecret(input.authorization, clusterSecret);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The first-boot pairing surface: what a remote browser needs to configure
|
|
61
|
+
* a factory-fresh agent (read status/config, write hub address + secret,
|
|
62
|
+
* trigger the reconnect). Process control and health details are NEVER
|
|
63
|
+
* part of it. Only consulted while the agent has no secret configured.
|
|
64
|
+
*/
|
|
65
|
+
function isPairingRequest(method, urlPath) {
|
|
66
|
+
const pathOnly = urlPath.split('?')[0] ?? urlPath;
|
|
67
|
+
if (method === 'GET') {
|
|
68
|
+
return (pathOnly === '/api/agent/status' ||
|
|
69
|
+
pathOnly === '/api/agent/config' ||
|
|
70
|
+
pathOnly === '/api/agent/discovered-nodes');
|
|
71
|
+
}
|
|
72
|
+
if (method === 'POST') {
|
|
73
|
+
return pathOnly === '/api/agent/config' || pathOnly === '/api/agent/restart';
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|