@camstack/server 1.2.13 → 1.2.15
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/api/addon-upload.js +2 -0
- package/dist/api/core/cap-providers.js +6 -0
- package/dist/api/trpc/generated-cap-mounts.js +3 -1
- package/dist/api/trpc/generated-cap-routers.js +190 -143
- package/dist/core/addon/addon-package.service.js +55 -51
- package/dist/core/addon/addon-registry.service.js +58 -0
- package/dist/core/agent/agent-addon-backfill.js +168 -0
- package/dist/core/agent/agent-registry.service.js +225 -9
- package/dist/core/agent/cluster-node-history-store.js +138 -1
- package/dist/launcher.js +14 -0
- package/dist/manual-boot.js +15 -0
- package/dist/server-root/index.js +3 -1
- package/package.json +11 -7
|
@@ -316,22 +316,24 @@ class AddonPackageService {
|
|
|
316
316
|
// version change itself, not the direction).
|
|
317
317
|
this.addonRegistry.refreshPackageVersion(name, rolledBackTo);
|
|
318
318
|
this.addonRegistry.emitUpdateEvent(name, previousVersion, rolledBackTo);
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
tags: { addonId },
|
|
325
|
-
meta: { rolledBackTo },
|
|
319
|
+
try {
|
|
320
|
+
const restart = await this.addonRegistry.restartPackage(name);
|
|
321
|
+
if (restart.success) {
|
|
322
|
+
this.logger.info('Addon(s) restarted after rollback', {
|
|
323
|
+
meta: { name, rolledBackTo, restarted: restart.restarted },
|
|
326
324
|
});
|
|
327
325
|
}
|
|
328
|
-
|
|
329
|
-
this.logger.warn('Restart
|
|
330
|
-
|
|
331
|
-
meta: { error: (0, types_1.errMsg)(reloadError) },
|
|
326
|
+
else {
|
|
327
|
+
this.logger.warn('Restart after rollback did not confirm', {
|
|
328
|
+
meta: { name, errors: restart.errors },
|
|
332
329
|
});
|
|
333
330
|
}
|
|
334
331
|
}
|
|
332
|
+
catch (reloadError) {
|
|
333
|
+
this.logger.warn('Restart failed after rollback', {
|
|
334
|
+
meta: { name, error: (0, types_1.errMsg)(reloadError) },
|
|
335
|
+
});
|
|
336
|
+
}
|
|
335
337
|
// Clear update cache so the UI reflects the new state.
|
|
336
338
|
this.cachedUpdates = null;
|
|
337
339
|
return { rolledBackTo };
|
|
@@ -780,6 +782,41 @@ class AddonPackageService {
|
|
|
780
782
|
// =========================================================================
|
|
781
783
|
// Update / restart
|
|
782
784
|
// =========================================================================
|
|
785
|
+
/**
|
|
786
|
+
* The post-update health check: restart every addon the package declares
|
|
787
|
+
* and decide what happens to the pre-update backup.
|
|
788
|
+
*
|
|
789
|
+
* SUCCESS ⇒ the backup is removed and `lastBackupDir` cleared. This is the
|
|
790
|
+
* only place that knows the new version actually came up; deleting inside
|
|
791
|
+
* `applyUpdate` would destroy the rollback exactly when it is needed.
|
|
792
|
+
* FAILURE ⇒ the backup is retained so the operator can roll back.
|
|
793
|
+
*
|
|
794
|
+
* Never throws. `clearBackup` swallows its own IO failures, and any
|
|
795
|
+
* surprise from the restart path is logged rather than propagated: an
|
|
796
|
+
* undeleted backup is a disk-space problem, a throw after a successful
|
|
797
|
+
* update is an outage.
|
|
798
|
+
*/
|
|
799
|
+
async confirmOrRetainBackup(name, backupDir) {
|
|
800
|
+
try {
|
|
801
|
+
const result = await this.addonRegistry.restartPackage(name);
|
|
802
|
+
if (result.success) {
|
|
803
|
+
this.logger.info('Addon(s) restarted after update', {
|
|
804
|
+
meta: { name, restarted: result.restarted },
|
|
805
|
+
});
|
|
806
|
+
if (backupDir != null)
|
|
807
|
+
await this.installer.clearBackup(name);
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
this.logger.warn('Restart after update did not confirm — backup retained for rollback', {
|
|
811
|
+
meta: { name, backupDir, errors: result.errors },
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
catch (err) {
|
|
815
|
+
this.logger.warn('Post-update confirmation failed — backup retained for rollback', {
|
|
816
|
+
meta: { name, backupDir, error: (0, types_1.errMsg)(err) },
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
}
|
|
783
820
|
/**
|
|
784
821
|
* Update a specific package. For addon packages, triggers hot-reload.
|
|
785
822
|
* For core packages, installs in the server root and returns requiresRestart: true.
|
|
@@ -835,25 +872,7 @@ class AddonPackageService {
|
|
|
835
872
|
this.logger.info('Addon package updated, triggering hot-reload', {
|
|
836
873
|
meta: { name, updatedVersion },
|
|
837
874
|
});
|
|
838
|
-
|
|
839
|
-
if (addonId) {
|
|
840
|
-
try {
|
|
841
|
-
await this.addonRegistry.restartAddon(addonId);
|
|
842
|
-
this.logger.info('Addon restarted after update', { tags: { addonId } });
|
|
843
|
-
// Restart succeeded — drop the backup. We keep it on failure
|
|
844
|
-
// (caller can roll back manually via UI).
|
|
845
|
-
if (result.backupDir != null) {
|
|
846
|
-
addonInstaller.clearBackup(name);
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
catch (reloadError) {
|
|
850
|
-
const msg = (0, types_1.errMsg)(reloadError);
|
|
851
|
-
this.logger.warn('Hot-reload failed for addon — backup retained for rollback', {
|
|
852
|
-
tags: { addonId },
|
|
853
|
-
meta: { error: msg, backupDir: result.backupDir },
|
|
854
|
-
});
|
|
855
|
-
}
|
|
856
|
-
}
|
|
875
|
+
await this.confirmOrRetainBackup(name, result.backupDir);
|
|
857
876
|
// Clear update cache so next check reflects new state
|
|
858
877
|
this.cachedUpdates = null;
|
|
859
878
|
this.sendUpdateNotification(name, updatedVersion);
|
|
@@ -927,22 +946,7 @@ class AddonPackageService {
|
|
|
927
946
|
this.logger.info('Staged addon swap complete, triggering hot-reload', {
|
|
928
947
|
meta: { name, updatedVersion },
|
|
929
948
|
});
|
|
930
|
-
|
|
931
|
-
if (addonId) {
|
|
932
|
-
try {
|
|
933
|
-
await this.addonRegistry.restartAddon(addonId);
|
|
934
|
-
this.logger.info('Addon restarted after staged update', { tags: { addonId } });
|
|
935
|
-
// Restart succeeded — drop the backup
|
|
936
|
-
addonInstaller.clearBackup(name);
|
|
937
|
-
}
|
|
938
|
-
catch (reloadError) {
|
|
939
|
-
const msg = (0, types_1.errMsg)(reloadError);
|
|
940
|
-
this.logger.warn('Hot-reload failed for addon — backup retained for rollback', {
|
|
941
|
-
tags: { addonId },
|
|
942
|
-
meta: { error: msg, backupDir: r.backupDir },
|
|
943
|
-
});
|
|
944
|
-
}
|
|
945
|
-
}
|
|
949
|
+
await this.confirmOrRetainBackup(name, r.backupDir);
|
|
946
950
|
// Clear update cache so next check reflects new state
|
|
947
951
|
this.cachedUpdates = null;
|
|
948
952
|
this.sendUpdateNotification(name, updatedVersion);
|
|
@@ -1533,11 +1537,11 @@ class AddonPackageService {
|
|
|
1533
1537
|
}
|
|
1534
1538
|
return name.includes('/addon-') ? 'addon' : 'core';
|
|
1535
1539
|
}
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1540
|
+
// `extractAddonId` (package name → single addon id) is GONE. It was wrong
|
|
1541
|
+
// for every multi-addon package — `@camstack/addon-pipeline` declares six
|
|
1542
|
+
// addons and none of them is `pipeline` — so the post-update restart it fed
|
|
1543
|
+
// silently targeted an addon that does not exist. Use
|
|
1544
|
+
// `addonRegistry.restartPackage(name)` / `listAddonIdsForPackage(name)`.
|
|
1541
1545
|
/** Resolve the addons directory from config or fall back to default */
|
|
1542
1546
|
resolveAddonsDir() {
|
|
1543
1547
|
// Must match where the runtime LOADS addons (launcher.ts +
|
|
@@ -1382,6 +1382,64 @@ class AddonRegistryService {
|
|
|
1382
1382
|
}
|
|
1383
1383
|
}
|
|
1384
1384
|
}
|
|
1385
|
+
/** Addon ids this node has loaded from `packageName`. */
|
|
1386
|
+
listAddonIdsForPackage(packageName) {
|
|
1387
|
+
const ids = [];
|
|
1388
|
+
for (const [id, entry] of this.addonEntries) {
|
|
1389
|
+
if (entry.packageName === packageName)
|
|
1390
|
+
ids.push(id);
|
|
1391
|
+
}
|
|
1392
|
+
return ids;
|
|
1393
|
+
}
|
|
1394
|
+
/**
|
|
1395
|
+
* Restart every addon a package declares, deduped by runner.
|
|
1396
|
+
*
|
|
1397
|
+
* A package is not an addon: `@camstack/addon-pipeline` declares six
|
|
1398
|
+
* (`pipeline-runner`, `detection-pipeline`, `motion-wasm`,
|
|
1399
|
+
* `audio-analyzer`, `stream-broker`, `recorder`), and NONE of them is
|
|
1400
|
+
* called `pipeline`. Deriving a single addon id from the package name
|
|
1401
|
+
* therefore restarts nothing for exactly the packages that matter most —
|
|
1402
|
+
* which is why the post-update confirmation had no idea whether the new
|
|
1403
|
+
* version actually came up.
|
|
1404
|
+
*
|
|
1405
|
+
* `restartAddon` respawns the WHOLE co-location runner (D2), so members
|
|
1406
|
+
* sharing a runner are collapsed into one restart.
|
|
1407
|
+
*
|
|
1408
|
+
* `success` is true only when every runner came back. The caller uses it
|
|
1409
|
+
* to decide whether to drop the rollback backup.
|
|
1410
|
+
*/
|
|
1411
|
+
async restartPackage(packageName) {
|
|
1412
|
+
const ids = this.listAddonIdsForPackage(packageName);
|
|
1413
|
+
if (ids.length === 0) {
|
|
1414
|
+
return {
|
|
1415
|
+
success: false,
|
|
1416
|
+
restarted: [],
|
|
1417
|
+
errors: [`No loaded addon belongs to package "${packageName}"`],
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
const restarted = [];
|
|
1421
|
+
const errors = [];
|
|
1422
|
+
const doneRunners = new Set();
|
|
1423
|
+
for (const addonId of ids) {
|
|
1424
|
+
const runnerId = this.childIdForAddon(addonId);
|
|
1425
|
+
if (doneRunners.has(runnerId)) {
|
|
1426
|
+
restarted.push(addonId);
|
|
1427
|
+
continue;
|
|
1428
|
+
}
|
|
1429
|
+
doneRunners.add(runnerId);
|
|
1430
|
+
try {
|
|
1431
|
+
const res = await this.restartAddon(addonId);
|
|
1432
|
+
if (res.success)
|
|
1433
|
+
restarted.push(addonId);
|
|
1434
|
+
else
|
|
1435
|
+
errors.push(res.error ?? `restart of "${addonId}" reported failure`);
|
|
1436
|
+
}
|
|
1437
|
+
catch (err) {
|
|
1438
|
+
errors.push(`restart of "${addonId}" threw: ${(0, types_1.errMsg)(err)}`);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
return { success: errors.length === 0, restarted, errors };
|
|
1442
|
+
}
|
|
1385
1443
|
/**
|
|
1386
1444
|
* Restart an addon by ID.
|
|
1387
1445
|
* Shuts down, unregisters capabilities, re-initializes, and re-wires.
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Node addon back-fill — converge a node to the addon roster THAT NODE last
|
|
4
|
+
* declared.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS EXISTS
|
|
7
|
+
* `AgentRegistryService.reconcileAgentAddons` used to be undeploy-only: it read
|
|
8
|
+
* the node's live roster, removed what no longer belonged, and had no branch for
|
|
9
|
+
* "installed on the hub but missing on the node". A node whose `<dataDir>/addons`
|
|
10
|
+
* had been wiped therefore came back with only its bootstrap packages
|
|
11
|
+
* (`@camstack/system` + `camstack.agentBootstrapAddons`) and the hub never sent
|
|
12
|
+
* the rest — measured live as 4 of 14 packages on `little-unraid`. Under the
|
|
13
|
+
* volume-only image (no addons baked in) an empty addons dir is the ORDINARY
|
|
14
|
+
* first-boot state of every new or rebuilt node, so that gap becomes the normal
|
|
15
|
+
* case rather than an anomaly.
|
|
16
|
+
*
|
|
17
|
+
* THE INTENDED SET
|
|
18
|
+
* A node's intended set is the roster the node itself last declared while
|
|
19
|
+
* online, remembered durably hub-side (`ClusterNodeHistoryStore`). It is not
|
|
20
|
+
* invented by the hub and it is not "every agent-deployable package the hub has
|
|
21
|
+
* installed" — that would over-install and would override deliberate per-node
|
|
22
|
+
* operator choices. The record GROWS when a node declares a package and SHRINKS
|
|
23
|
+
* only on an explicit undeploy (operator gesture or the stale-reconcile), so
|
|
24
|
+
* both mutations are attributable actions rather than heuristics.
|
|
25
|
+
*
|
|
26
|
+
* THE INTENDED VERSION
|
|
27
|
+
* The hub's own installed version wins, falling back to the version the node
|
|
28
|
+
* last reported. Never `latest` — resolving `latest` per node is exactly the
|
|
29
|
+
* silent per-node drift the bootstrap installer already suffers from
|
|
30
|
+
* (`addon-installer.ts` installs unpinned and `ensureRequiredPackages` never
|
|
31
|
+
* re-installs, so whatever `latest` meant at a node's first boot is what that
|
|
32
|
+
* node runs forever). A package with no resolvable version is reported, not
|
|
33
|
+
* guessed at.
|
|
34
|
+
*
|
|
35
|
+
* BOUNDING
|
|
36
|
+
* Delivery failures are counted per `(node, package, version)` and the pair is
|
|
37
|
+
* retired after {@link MAX_BACKFILL_ATTEMPTS}, exactly as `CrashSupervisor`
|
|
38
|
+
* bounds respawns (D6). A reconnect flap therefore cannot turn a broken package
|
|
39
|
+
* into an endless push loop. A different hub version resets the count — a new
|
|
40
|
+
* version is a new fact worth one more attempt.
|
|
41
|
+
*
|
|
42
|
+
* This module is pure/seam-driven on purpose: the decision of what a node should
|
|
43
|
+
* have is asserted in unit tests with no Moleculer, filesystem or npm registry.
|
|
44
|
+
*/
|
|
45
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
46
|
+
exports.BackfillAttemptTracker = exports.MAX_BACKFILL_ATTEMPTS = void 0;
|
|
47
|
+
exports.computeBackfillPlan = computeBackfillPlan;
|
|
48
|
+
exports.executeBackfill = executeBackfill;
|
|
49
|
+
/**
|
|
50
|
+
* Decide what a node is missing relative to its own last-declared roster.
|
|
51
|
+
* Pure — no I/O, no clock, no randomness.
|
|
52
|
+
*/
|
|
53
|
+
function computeBackfillPlan(input) {
|
|
54
|
+
const liveNames = new Set(input.live.map((p) => p.name));
|
|
55
|
+
const targets = [];
|
|
56
|
+
const retired = [];
|
|
57
|
+
const unresolvable = [];
|
|
58
|
+
for (const entry of input.recorded) {
|
|
59
|
+
if (liveNames.has(entry.name))
|
|
60
|
+
continue;
|
|
61
|
+
if (input.bootstrap.has(entry.name))
|
|
62
|
+
continue;
|
|
63
|
+
if (input.undeployed.has(entry.name))
|
|
64
|
+
continue;
|
|
65
|
+
if (input.retired.has(entry.name)) {
|
|
66
|
+
retired.push(entry.name);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const version = input.hubVersions.get(entry.name) ?? entry.version;
|
|
70
|
+
if (version === null || version === undefined || version.length === 0) {
|
|
71
|
+
unresolvable.push(entry.name);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
targets.push({ name: entry.name, version });
|
|
75
|
+
}
|
|
76
|
+
return { targets, retired, unresolvable };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* How many consecutive delivery failures retire a `(node, package)` pair.
|
|
80
|
+
* Mirrors `CrashSupervisor.MAX_CRASHES_IN_WINDOW` in intent: bounded, terminal,
|
|
81
|
+
* loud — never an infinite retry loop.
|
|
82
|
+
*/
|
|
83
|
+
exports.MAX_BACKFILL_ATTEMPTS = 3;
|
|
84
|
+
/**
|
|
85
|
+
* Per-`(node, package)` delivery circuit breaker. In-memory and process-scoped
|
|
86
|
+
* on purpose: it bounds retries within one hub lifetime, and a hub restart is an
|
|
87
|
+
* operator-visible event that legitimately re-opens the breaker (same shape as
|
|
88
|
+
* `CrashSupervisor`). It stores no intent — the durable record of what a node
|
|
89
|
+
* should have lives in `ClusterNodeHistoryStore`, not here.
|
|
90
|
+
*/
|
|
91
|
+
class BackfillAttemptTracker {
|
|
92
|
+
failures = new Map();
|
|
93
|
+
forNode(nodeId) {
|
|
94
|
+
const existing = this.failures.get(nodeId);
|
|
95
|
+
if (existing)
|
|
96
|
+
return existing;
|
|
97
|
+
const created = new Map();
|
|
98
|
+
this.failures.set(nodeId, created);
|
|
99
|
+
return created;
|
|
100
|
+
}
|
|
101
|
+
/** Packages this node has exhausted its attempts on. */
|
|
102
|
+
retiredFor(nodeId) {
|
|
103
|
+
const node = this.failures.get(nodeId);
|
|
104
|
+
if (!node)
|
|
105
|
+
return new Set();
|
|
106
|
+
const retired = new Set();
|
|
107
|
+
for (const [name, state] of node) {
|
|
108
|
+
if (state.attempts >= exports.MAX_BACKFILL_ATTEMPTS)
|
|
109
|
+
retired.add(name);
|
|
110
|
+
}
|
|
111
|
+
return retired;
|
|
112
|
+
}
|
|
113
|
+
/** Every non-clean delivery record for a node — attempted, failing or retired. */
|
|
114
|
+
failuresFor(nodeId) {
|
|
115
|
+
const node = this.failures.get(nodeId);
|
|
116
|
+
if (!node)
|
|
117
|
+
return [];
|
|
118
|
+
return [...node].map(([name, state]) => ({
|
|
119
|
+
name,
|
|
120
|
+
version: state.version,
|
|
121
|
+
attempts: state.attempts,
|
|
122
|
+
retired: state.attempts >= exports.MAX_BACKFILL_ATTEMPTS,
|
|
123
|
+
lastError: state.lastError,
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
recordFailure(nodeId, name, version, error = '') {
|
|
127
|
+
const node = this.forNode(nodeId);
|
|
128
|
+
const prior = node.get(name);
|
|
129
|
+
// A different hub version is a new fact — it earns a fresh set of attempts.
|
|
130
|
+
const attempts = prior && prior.version === version ? prior.attempts + 1 : 1;
|
|
131
|
+
node.set(name, { version, attempts, lastError: error });
|
|
132
|
+
return { attempts, retired: attempts >= exports.MAX_BACKFILL_ATTEMPTS };
|
|
133
|
+
}
|
|
134
|
+
recordSuccess(nodeId, name) {
|
|
135
|
+
this.failures.get(nodeId)?.delete(name);
|
|
136
|
+
}
|
|
137
|
+
/** Drop everything remembered about a node (e.g. "Forget node"). */
|
|
138
|
+
forget(nodeId) {
|
|
139
|
+
this.failures.delete(nodeId);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
exports.BackfillAttemptTracker = BackfillAttemptTracker;
|
|
143
|
+
/**
|
|
144
|
+
* Deliver every planned package, then reload the node ONCE. Per-package failures
|
|
145
|
+
* are isolated and counted — one broken package must not stop the others, and
|
|
146
|
+
* must not retry forever.
|
|
147
|
+
*/
|
|
148
|
+
async function executeBackfill(seam, nodeId, plan, tracker) {
|
|
149
|
+
const delivered = [];
|
|
150
|
+
const failed = [];
|
|
151
|
+
for (const target of plan.targets) {
|
|
152
|
+
try {
|
|
153
|
+
const bundle = await seam.pack(target.name, target.version);
|
|
154
|
+
await seam.deploy(nodeId, target.name, bundle);
|
|
155
|
+
tracker.recordSuccess(nodeId, target.name);
|
|
156
|
+
delivered.push(target);
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
160
|
+
const { retired } = tracker.recordFailure(nodeId, target.name, target.version, error);
|
|
161
|
+
failed.push({ name: target.name, version: target.version, error, retired });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (delivered.length > 0) {
|
|
165
|
+
await seam.reload(nodeId);
|
|
166
|
+
}
|
|
167
|
+
return { delivered, failed, skipped: plan.retired, unresolvable: plan.unresolvable };
|
|
168
|
+
}
|