@camstack/server 1.2.101 → 1.2.102
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/main.js +13 -0
- package/dist/first-boot-addon-plan.js +43 -140
- package/dist/launcher.js +12 -12
- package/dist/main.js +18 -0
- package/dist/single-copy-cleanup-runner.js +128 -0
- package/dist/single-copy-cleanup.js +241 -0
- package/package.json +14 -14
package/dist/agent/main.js
CHANGED
|
@@ -37,6 +37,7 @@ exports.startAgent = startAgent;
|
|
|
37
37
|
const fs = __importStar(require("node:fs"));
|
|
38
38
|
const path = __importStar(require("node:path"));
|
|
39
39
|
const agent_http_js_1 = require("./agent-http.js");
|
|
40
|
+
const single_copy_cleanup_runner_js_1 = require("../single-copy-cleanup-runner.js");
|
|
40
41
|
const derive_hub_url_js_1 = require("./derive-hub-url.js");
|
|
41
42
|
const system_1 = require("@camstack/system");
|
|
42
43
|
const types_1 = require("@camstack/types");
|
|
@@ -381,6 +382,18 @@ async function startAgent(configPath) {
|
|
|
381
382
|
catch (err) {
|
|
382
383
|
consoleLogger.warn(`agent root boot confirmation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
383
384
|
}
|
|
385
|
+
// ONE COPY PER NODE — the same sweep the hub runs, at this role's own
|
|
386
|
+
// healthy-boot moment (D45, task 32). Operator, 2026-08-13: "l'agente deve
|
|
387
|
+
// essere identico all'hub". An agent is where the redundant copies actually
|
|
388
|
+
// are: `little-unraid` still holds `<dataDir>/addons/@camstack/system@1.2.78`
|
|
389
|
+
// — a copy nothing loads (both roles pass `['@camstack/system']` as the
|
|
390
|
+
// loader's skip list) and that WINS over the closure for anything resolving
|
|
391
|
+
// through it.
|
|
392
|
+
void (0, single_copy_cleanup_runner_js_1.runSingleCopyCleanup)({
|
|
393
|
+
dataDir: config.dataDir,
|
|
394
|
+
addonRoot: config.addonsDir,
|
|
395
|
+
log: (line) => consoleLogger.info(line),
|
|
396
|
+
});
|
|
384
397
|
};
|
|
385
398
|
// Logger factory — creates scoped loggers from the shared LogManager.
|
|
386
399
|
// All entries flow through registered ILogDestination providers (hub-forwarder).
|
|
@@ -1,114 +1,50 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* What a node
|
|
4
|
-
* addon seed tree — [D45](../../../docs/decisions/adr-0045.md),
|
|
3
|
+
* What a node installs at first boot once the image stops carrying a
|
|
4
|
+
* NON-system addon seed tree — [D45](../../../docs/decisions/adr-0045.md),
|
|
5
|
+
* task 32.
|
|
5
6
|
*
|
|
6
|
-
* Operator
|
|
7
|
-
* and the system packages ("una copia di server e dei pacchetti di sistema"),
|
|
8
|
-
* and *"tutti gli altri addons devono rimanere copia unica, sempre, installati
|
|
9
|
-
* durante l'avvio iniziale"*. Dropping `/opt/camstack-seed-addons` moves the
|
|
10
|
-
* version decision from image-build time — where the Dockerfile installs the
|
|
11
|
-
* exact version the hub closure resolved — to boot time. Something then has to
|
|
12
|
-
* answer two questions the seed answered implicitly:
|
|
7
|
+
* Operator decisions, 2026-08-12/13:
|
|
13
8
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* ([D90](../../../docs/decisions/adr-0090.md)). The live hub runs
|
|
22
|
-
* `addon-pipeline` 1.2.69 against an image seed of 1.2.64; replacing a
|
|
23
|
-
* deployed copy with the closure's number is exactly the rollback the
|
|
24
|
-
* unconditional `cp -a` caused. So an installed copy is ADOPTED — and a gap
|
|
25
|
-
* against the pin is reported, never repaired behind the operator's back.
|
|
9
|
+
* - the docker seed stays for `@camstack/server` and the SYSTEM packages, as
|
|
10
|
+
* the first-boot fallback source; every other addon is single-copy, always,
|
|
11
|
+
* installed during the initial boot;
|
|
12
|
+
* - **no pinning at all** — *"il sistema è designato per essere
|
|
13
|
+
* addons-agnostic, latest va sempre bene al primo boot"*. There is no version
|
|
14
|
+
* contract between the closure and an addon, so first boot asks for `latest`
|
|
15
|
+
* and the copy that lands is the one the node keeps.
|
|
26
16
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
17
|
+
* So this module answers exactly one question per package — *fetch it, adopt
|
|
18
|
+
* what is already here, or leave it to the closure* — and reports the version
|
|
19
|
+
* the running closure happens to carry beside the installed one, as INFO. That
|
|
20
|
+
* comparison is not a pin and never drives an action: the installed copy wins,
|
|
21
|
+
* always ([D90](../../../docs/decisions/adr-0090.md)). It exists because the
|
|
22
|
+
* same package having two truthful versions on one node is what task 32 is
|
|
23
|
+
* about — the hub runs `addon-pipeline` 1.2.69 while its own closure carries
|
|
24
|
+
* 1.2.64, and nothing said so out loud until now.
|
|
25
|
+
*
|
|
26
|
+
* Pure: the caller supplies the roster, what is on disk and what the closure
|
|
27
|
+
* carries. It installs nothing and can never end a boot.
|
|
31
28
|
*/
|
|
32
29
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
33
|
-
exports.resolveWantedVersion = resolveWantedVersion;
|
|
34
|
-
exports.compareVersions = compareVersions;
|
|
35
|
-
exports.bootstrapPinsFrom = bootstrapPinsFrom;
|
|
36
30
|
exports.planFirstBootAddons = planFirstBootAddons;
|
|
37
31
|
exports.formatFirstBootPlan = formatFirstBootPlan;
|
|
38
|
-
/** A plain `major.minor.patch`, optionally with a prerelease/build suffix. */
|
|
39
|
-
const EXACT_VERSION = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z-.]+)?$/;
|
|
40
|
-
/**
|
|
41
|
-
* The version a fetch should ask for, given whatever the closure declared.
|
|
42
|
-
*
|
|
43
|
-
* Only an EXACT version is a pin. A range (`^1.2.0`) is deliberately not
|
|
44
|
-
* honoured: `installFromNpm`'s registry path resolves exact versions and
|
|
45
|
-
* dist-tags only (ranges fall back to `npm pack`), so pretending to support one
|
|
46
|
-
* here would produce a spec the fetch cannot use. Everything else — `*`, a
|
|
47
|
-
* range, an absent dep — becomes `latest`, flagged as unpinned so the caller
|
|
48
|
-
* can say it out loud.
|
|
49
|
-
*/
|
|
50
|
-
function resolveWantedVersion(raw) {
|
|
51
|
-
if (raw !== undefined && EXACT_VERSION.test(raw))
|
|
52
|
-
return { spec: raw, pinned: true };
|
|
53
|
-
return { spec: 'latest', pinned: false };
|
|
54
|
-
}
|
|
55
|
-
/**
|
|
56
|
-
* Compare two plain versions. `1` when `a` is newer, `-1` when older, `0` when
|
|
57
|
-
* equal, `null` when either side is not a plain `x.y.z`.
|
|
58
|
-
*
|
|
59
|
-
* A local comparator rather than `@camstack/node-root`'s: that package is
|
|
60
|
-
* `private: true` and is ABSENT from the server closure, and this module is
|
|
61
|
-
* reached from `launcher.ts`, which is `tsc` output rather than a bundle — an
|
|
62
|
-
* import of it would be an unresolvable `require` at boot (the trap recorded in
|
|
63
|
-
* `docs/architecture/update-and-release.md`). Prerelease ordering is not
|
|
64
|
-
* modelled: nothing in the bootstrap roster ships one, and guessing at
|
|
65
|
-
* prerelease precedence would be a second, subtly different semver.
|
|
66
|
-
*/
|
|
67
|
-
function compareVersions(a, b) {
|
|
68
|
-
if (!EXACT_VERSION.test(a) || !EXACT_VERSION.test(b))
|
|
69
|
-
return null;
|
|
70
|
-
const parse = (v) => v
|
|
71
|
-
.split(/[-+]/)[0]
|
|
72
|
-
.split('.')
|
|
73
|
-
.map((n) => Number.parseInt(n, 10));
|
|
74
|
-
const left = parse(a);
|
|
75
|
-
const right = parse(b);
|
|
76
|
-
for (let i = 0; i < 3; i++) {
|
|
77
|
-
const l = left[i] ?? 0;
|
|
78
|
-
const r = right[i] ?? 0;
|
|
79
|
-
if (l !== r)
|
|
80
|
-
return l > r ? 1 : -1;
|
|
81
|
-
}
|
|
82
|
-
return 0;
|
|
83
|
-
}
|
|
84
|
-
/** The `@camstack/*` half of a closure manifest's dependencies — the pins. */
|
|
85
|
-
function bootstrapPinsFrom(manifest) {
|
|
86
|
-
const out = {};
|
|
87
|
-
for (const [name, spec] of Object.entries(manifest.dependencies ?? {})) {
|
|
88
|
-
if (name.startsWith('@camstack/'))
|
|
89
|
-
out[name] = spec;
|
|
90
|
-
}
|
|
91
|
-
return out;
|
|
92
|
-
}
|
|
93
32
|
/** The plan. Pure; installs nothing. */
|
|
94
33
|
function planFirstBootAddons(input) {
|
|
95
34
|
const provided = new Set(input.closureProvided);
|
|
96
|
-
const decisions = input.required.map((pkg) => decide(pkg, input.
|
|
35
|
+
const decisions = input.required.map((pkg) => decide(pkg, input.installed, input.closureVersions, provided));
|
|
97
36
|
const wouldInstall = decisions.filter((d) => d.action === 'install-npm').map((d) => d.pkg);
|
|
98
37
|
return {
|
|
99
38
|
decisions,
|
|
100
39
|
wouldInstall,
|
|
101
|
-
|
|
102
|
-
.filter((d) => d.action === 'install-npm' && !d.want.pinned)
|
|
103
|
-
.map((d) => d.pkg),
|
|
104
|
-
divergent: decisions.filter((d) => d.divergent).map((d) => d.pkg),
|
|
40
|
+
twoVersions: decisions.filter((d) => d.twoVersions).map((d) => d.pkg),
|
|
105
41
|
needsRegistry: wouldInstall.length > 0,
|
|
106
42
|
};
|
|
107
43
|
}
|
|
108
|
-
function decide(pkg,
|
|
109
|
-
const want = resolveWantedVersion(pin);
|
|
44
|
+
function decide(pkg, installed, closureVersions, closureProvided) {
|
|
110
45
|
const hasEntry = Object.hasOwn(installed, pkg);
|
|
111
46
|
const version = installed[pkg] ?? null;
|
|
47
|
+
const closureVersion = closureVersions[pkg] ?? null;
|
|
112
48
|
// Host-provided and reachable: a copy under the addon root WINS over the
|
|
113
49
|
// closure and nothing refreshes it — the shape that left both agents 17
|
|
114
50
|
// versions behind. Same rule as `shouldSkipClosureProvidedSeed`.
|
|
@@ -116,9 +52,9 @@ function decide(pkg, pin, installed, closureProvided) {
|
|
|
116
52
|
return {
|
|
117
53
|
pkg,
|
|
118
54
|
action: 'skip-closure-provided',
|
|
119
|
-
want,
|
|
120
55
|
installedVersion: version,
|
|
121
|
-
|
|
56
|
+
closureVersion,
|
|
57
|
+
twoVersions: false,
|
|
122
58
|
reason: 'provided by the server closure — a copy here would shadow it (D15)',
|
|
123
59
|
};
|
|
124
60
|
}
|
|
@@ -126,56 +62,24 @@ function decide(pkg, pin, installed, closureProvided) {
|
|
|
126
62
|
return {
|
|
127
63
|
pkg,
|
|
128
64
|
action: 'install-npm',
|
|
129
|
-
want,
|
|
130
65
|
installedVersion: null,
|
|
131
|
-
|
|
66
|
+
closureVersion,
|
|
67
|
+
twoVersions: false,
|
|
132
68
|
reason: hasEntry
|
|
133
|
-
?
|
|
134
|
-
:
|
|
135
|
-
? `absent — fetch ${want.spec}, the version the running closure pins`
|
|
136
|
-
: 'absent, and the closure carries no pin — fetching latest',
|
|
69
|
+
? 'installed copy has an unreadable package.json — fetching latest'
|
|
70
|
+
: 'absent — fetching latest, which is the whole version contract (addons-agnostic)',
|
|
137
71
|
};
|
|
138
72
|
}
|
|
73
|
+
const twoVersions = closureVersion !== null && closureVersion !== version;
|
|
139
74
|
return {
|
|
140
75
|
pkg,
|
|
141
76
|
action: 'adopt-installed',
|
|
142
|
-
want,
|
|
143
77
|
installedVersion: version,
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
* which direction. The copy is adopted either way: `<dataDir>/addons` is the
|
|
150
|
-
* runtime authority and an image number must never outrank it (D90). What
|
|
151
|
-
* changes is whether the boot says something about it.
|
|
152
|
-
*/
|
|
153
|
-
function adoptionVerdict(version, want) {
|
|
154
|
-
if (!want.pinned) {
|
|
155
|
-
return { divergent: false, reason: `installed ${version}; the closure carries no pin to check` };
|
|
156
|
-
}
|
|
157
|
-
const order = compareVersions(version, want.spec);
|
|
158
|
-
if (order === null) {
|
|
159
|
-
return { divergent: true, reason: `installed ${version} is not comparable to pin ${want.spec}` };
|
|
160
|
-
}
|
|
161
|
-
if (order === 0)
|
|
162
|
-
return { divergent: false, reason: `installed ${version} matches the pin` };
|
|
163
|
-
const sameMajor = version.split('.')[0] === want.spec.split('.')[0];
|
|
164
|
-
if (!sameMajor) {
|
|
165
|
-
return {
|
|
166
|
-
divergent: true,
|
|
167
|
-
reason: `installed ${version} is a different major from the pinned ${want.spec} — a framework contract, not a version gap`,
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
if (order > 0) {
|
|
171
|
-
return {
|
|
172
|
-
divergent: false,
|
|
173
|
-
reason: `installed ${version} is ahead of the pinned ${want.spec} — a deploy outranks the image (D90)`,
|
|
174
|
-
};
|
|
175
|
-
}
|
|
176
|
-
return {
|
|
177
|
-
divergent: true,
|
|
178
|
-
reason: `installed ${version} is BEHIND the pinned ${want.spec} — kept, because replacing it is an un-deploy`,
|
|
78
|
+
closureVersion,
|
|
79
|
+
twoVersions,
|
|
80
|
+
reason: twoVersions
|
|
81
|
+
? `installed ${version}; this closure carries ${closureVersion} — the installed copy is what runs (D90)`
|
|
82
|
+
: `installed ${version} — kept`,
|
|
179
83
|
};
|
|
180
84
|
}
|
|
181
85
|
/**
|
|
@@ -189,18 +93,17 @@ function adoptionVerdict(version, want) {
|
|
|
189
93
|
*/
|
|
190
94
|
function formatFirstBootPlan(plan, mode) {
|
|
191
95
|
const header = `first-boot addon plan (${mode}) — ${plan.decisions.length} package(s), ` +
|
|
192
|
-
`${plan.wouldInstall.length} to install
|
|
96
|
+
`${plan.wouldInstall.length} to install`;
|
|
193
97
|
const lines = [
|
|
194
98
|
mode === 'observe' ? `${header}; nothing was installed from this plan` : header,
|
|
195
99
|
];
|
|
196
100
|
for (const d of plan.decisions) {
|
|
197
|
-
const target = d.action === 'install-npm' ?
|
|
101
|
+
const target = d.action === 'install-npm' ? '@latest' : '';
|
|
198
102
|
lines.push(` ${d.pkg}${target} — ${d.action}: ${d.reason}`);
|
|
199
103
|
}
|
|
200
|
-
if (plan.
|
|
201
|
-
lines.push(`
|
|
202
|
-
'the
|
|
203
|
-
'up with depends on when it booted');
|
|
104
|
+
if (plan.twoVersions.length > 0) {
|
|
105
|
+
lines.push(` two versions on this node (installed vs this closure's own copy): ${plan.twoVersions.join(', ')} — ` +
|
|
106
|
+
'INFO: the installed copy is the one that runs');
|
|
204
107
|
}
|
|
205
108
|
if (!plan.needsRegistry) {
|
|
206
109
|
lines.push(' no registry access needed — every required addon is already on disk');
|
package/dist/launcher.js
CHANGED
|
@@ -394,25 +394,25 @@ async function launch() {
|
|
|
394
394
|
const roleDefaultBootstrap = role === 'agent' ? AddonInstaller.AGENT_PACKAGES : deriveBootstrapFromSelf();
|
|
395
395
|
const bootstrapRequired = readBootstrapRequiredAddons(dataDir, bootstrapSchema) ?? roleDefaultBootstrap;
|
|
396
396
|
console.log(`[launcher] bootstrap (${role}): ${bootstrapRequired.length} required package(s)`);
|
|
397
|
-
//
|
|
397
|
+
// THE FIRST-BOOT ADDON PLAN — reported every boot (D45 task 32).
|
|
398
398
|
//
|
|
399
|
-
// The image
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
399
|
+
// The image no longer bakes a NON-system addon tree, so this roster comes
|
|
400
|
+
// from the registry on a fresh node: "tutti gli altri addons devono rimanere
|
|
401
|
+
// copia unica, sempre, installati durante l'avvio iniziale" (2026-08-12), at
|
|
402
|
+
// `latest`, because "il sistema e' designato per essere addons-agnostic"
|
|
403
|
+
// (2026-08-13). The plan below is what `ensureRequiredPackages` is about to
|
|
404
|
+
// do, said out loud BEFORE it happens: how many packages this node must fetch
|
|
405
|
+
// (i.e. whether this boot needs the registry at all), and where an installed
|
|
406
|
+
// copy differs from the one inside this very closure — one node, two truthful
|
|
407
|
+
// versions, which is the condition task 32 exists to end.
|
|
408
408
|
//
|
|
409
409
|
// Diagnostics, so it can never end a boot: same policy as the inventories.
|
|
410
410
|
try {
|
|
411
|
-
const
|
|
411
|
+
const closureNodeModules = path.resolve(__dirname, '..', 'node_modules');
|
|
412
412
|
const plan = (0, first_boot_addon_plan_js_1.planFirstBootAddons)({
|
|
413
413
|
required: bootstrapRequired,
|
|
414
|
-
closurePins: ownManifest === null ? {} : (0, first_boot_addon_plan_js_1.bootstrapPinsFrom)(ownManifest),
|
|
415
414
|
installed: readInstalledAddonVersions(addonsDir, bootstrapRequired),
|
|
415
|
+
closureVersions: readInstalledAddonVersions(closureNodeModules, bootstrapRequired),
|
|
416
416
|
// Mirrors `shouldSkipClosureProvidedSeed`: only `@camstack/system`, and
|
|
417
417
|
// only while it actually resolves.
|
|
418
418
|
closureProvided: (() => {
|
package/dist/main.js
CHANGED
|
@@ -46,6 +46,7 @@ const ws_2 = require("ws");
|
|
|
46
46
|
const fs = __importStar(require("node:fs"));
|
|
47
47
|
const path = __importStar(require("node:path"));
|
|
48
48
|
const node_child_process_1 = require("node:child_process");
|
|
49
|
+
const single_copy_cleanup_runner_js_1 = require("./single-copy-cleanup-runner.js");
|
|
49
50
|
const logging_service_1 = require("./core/logging/logging.service");
|
|
50
51
|
const event_bus_service_1 = require("./core/events/event-bus.service");
|
|
51
52
|
const config_service_1 = require("./core/config/config.service");
|
|
@@ -1290,6 +1291,23 @@ async function bootstrap() {
|
|
|
1290
1291
|
meta: { error: err instanceof Error ? err.message : String(err) },
|
|
1291
1292
|
});
|
|
1292
1293
|
}
|
|
1294
|
+
// ONE COPY PER NODE — the sweep, at the only moment it is safe (D45, task 32).
|
|
1295
|
+
//
|
|
1296
|
+
// The boot above is confirmed healthy and the hub is serving, so the legacy
|
|
1297
|
+
// `<dataDir>/framework` tree and any host-provided package under the addon
|
|
1298
|
+
// root are redundant: nothing loads them, and each is a resolution race that
|
|
1299
|
+
// has already cost this project three wrong diagnoses in one evening.
|
|
1300
|
+
//
|
|
1301
|
+
// It runs HERE and not in the launcher because "healthy" is the gate. Before
|
|
1302
|
+
// the hub is up, the copies this removes are the recovery path — the image
|
|
1303
|
+
// seeds them for exactly the boot that would otherwise fail. The agent runs
|
|
1304
|
+
// the SAME function at its own confirmation point; neither role owns a
|
|
1305
|
+
// private idea of what may be deleted.
|
|
1306
|
+
void (0, single_copy_cleanup_runner_js_1.runSingleCopyCleanup)({
|
|
1307
|
+
dataDir: dataPath,
|
|
1308
|
+
addonRoot: process.env['CAMSTACK_ADDONS_DIR'] ?? path.join(dataPath, 'addons'),
|
|
1309
|
+
log: (line) => logger.info(line),
|
|
1310
|
+
});
|
|
1293
1311
|
// One-time backfill: stamp integrationId on devices created before the
|
|
1294
1312
|
// device-manager forwarder started stamping it (legacy camera providers),
|
|
1295
1313
|
// so deleting their integration cascades them. Idempotent — only touches
|
|
@@ -0,0 +1,128 @@
|
|
|
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.runSingleCopyCleanup = runSingleCopyCleanup;
|
|
37
|
+
/**
|
|
38
|
+
* The one adapter that runs the post-boot single-copy sweep on BOTH roles.
|
|
39
|
+
*
|
|
40
|
+
* Operator, 2026-08-13: *"l'agente deve essere identico all'hub"*. Two code
|
|
41
|
+
* paths that must agree get one implementation — the hub calls this after its
|
|
42
|
+
* boot-health confirmation, the agent after its first acked `registerNode`, and
|
|
43
|
+
* neither owns a private idea of what may be deleted.
|
|
44
|
+
*
|
|
45
|
+
* Everything decidable lives in `single-copy-cleanup.ts` and is pure. This file
|
|
46
|
+
* only reads the real world: the active-root env, where `@camstack/system`
|
|
47
|
+
* actually resolved, the effective `NODE_PATH`, and the disk.
|
|
48
|
+
*
|
|
49
|
+
* It never throws. A sweep that fails leaves a node with an extra directory; a
|
|
50
|
+
* sweep that ends a process is an outage, and the copies it removes exist
|
|
51
|
+
* precisely because a node once could not boot.
|
|
52
|
+
*/
|
|
53
|
+
const fs = __importStar(require("node:fs"));
|
|
54
|
+
const path = __importStar(require("node:path"));
|
|
55
|
+
const single_copy_cleanup_js_1 = require("./single-copy-cleanup.js");
|
|
56
|
+
const EMPTY_RESULT = { removed: [], failed: [], refused: true };
|
|
57
|
+
/**
|
|
58
|
+
* Discover, plan, report, sweep. Called only from a point where the role has
|
|
59
|
+
* already confirmed its boot healthy — that confirmation IS the gate, and it is
|
|
60
|
+
* re-stated as `bootHealthy: true` here so the pure planner can be exercised
|
|
61
|
+
* from both sides in a test.
|
|
62
|
+
*/
|
|
63
|
+
async function runSingleCopyCleanup(opts) {
|
|
64
|
+
try {
|
|
65
|
+
if (!(0, single_copy_cleanup_js_1.isCleanupEnabled)(process.env)) {
|
|
66
|
+
opts.log('single-copy cleanup disabled by CAMSTACK_SINGLE_COPY_CLEANUP — nothing removed');
|
|
67
|
+
return EMPTY_RESULT;
|
|
68
|
+
}
|
|
69
|
+
const candidates = (0, single_copy_cleanup_js_1.discoverRedundantCopies)({
|
|
70
|
+
dataDir: opts.dataDir,
|
|
71
|
+
addonRoot: opts.addonRoot,
|
|
72
|
+
closureProvides: (pkg) => {
|
|
73
|
+
try {
|
|
74
|
+
require.resolve(`${pkg}/package.json`);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
}, {
|
|
82
|
+
exists: (p) => fs.existsSync(p),
|
|
83
|
+
readVersion: (dir) => readVersion(dir),
|
|
84
|
+
});
|
|
85
|
+
const plan = (0, single_copy_cleanup_js_1.planSingleCopyCleanup)({
|
|
86
|
+
activeRoot: process.env['CAMSTACK_SERVER_ACTIVE_ROOT'] ?? null,
|
|
87
|
+
closureResolvedFrom: resolveSystemPath(),
|
|
88
|
+
bootHealthy: true,
|
|
89
|
+
nodePathEntries: (process.env['NODE_PATH'] ?? '')
|
|
90
|
+
.split(process.platform === 'win32' ? ';' : ':')
|
|
91
|
+
.map((entry) => entry.trim())
|
|
92
|
+
.filter((entry) => entry.length > 0),
|
|
93
|
+
candidates,
|
|
94
|
+
});
|
|
95
|
+
for (const line of (0, single_copy_cleanup_js_1.formatCleanupPlan)(plan))
|
|
96
|
+
opts.log(line);
|
|
97
|
+
return await (0, single_copy_cleanup_js_1.executeSingleCopyCleanup)(plan, {
|
|
98
|
+
exists: (p) => fs.existsSync(p),
|
|
99
|
+
rename: (from, to) => fs.renameSync(from, to),
|
|
100
|
+
remove: async (p) => {
|
|
101
|
+
await fs.promises.rm(p, { recursive: true, force: true });
|
|
102
|
+
},
|
|
103
|
+
}, opts.log);
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
opts.log(`single-copy cleanup failed: ${err instanceof Error ? err.message : String(err)} — nothing was removed`);
|
|
107
|
+
return EMPTY_RESULT;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** Where this process actually resolved `@camstack/system`, or `null`. */
|
|
111
|
+
function resolveSystemPath() {
|
|
112
|
+
try {
|
|
113
|
+
return require.resolve('@camstack/system/package.json');
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function readVersion(packageDir) {
|
|
120
|
+
try {
|
|
121
|
+
const raw = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf-8'));
|
|
122
|
+
const version = typeof raw === 'object' && raw !== null ? raw.version : undefined;
|
|
123
|
+
return typeof version === 'string' ? version : null;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Post-boot convergence to ONE copy per node — [D45](../../../docs/decisions/adr-0045.md),
|
|
4
|
+
* task 32.
|
|
5
|
+
*
|
|
6
|
+
* Operator decisions, 2026-08-13:
|
|
7
|
+
*
|
|
8
|
+
* - the SYSTEM seed stays in the image, as the first-boot and fallback source
|
|
9
|
+
* (*"teniamo gli addons di sistema nell'immagine per il primo boot, da usare
|
|
10
|
+
* in caso di fallback"*);
|
|
11
|
+
* - once a node **has booted correctly**, the redundant DATA-side copies are
|
|
12
|
+
* removed — the legacy `<dataDir>/framework` tree and any host-provided
|
|
13
|
+
* package sitting under the addon root;
|
|
14
|
+
* - the end state is one version of everything per node, and an agent is
|
|
15
|
+
* identical to a hub.
|
|
16
|
+
*
|
|
17
|
+
* The removal is the easy half. The dangerous half is WHEN, because every copy
|
|
18
|
+
* this deletes is one that something could still be resolving:
|
|
19
|
+
*
|
|
20
|
+
* | Situation | Why cleaning would brick the node |
|
|
21
|
+
* | --- | --- |
|
|
22
|
+
* | BAKED mode (no `<dataDir>/server-root/current`) | the node is running FROM the fallback tree |
|
|
23
|
+
* | `@camstack/system` resolved outside the active closure | the copy being deleted may be the one in memory |
|
|
24
|
+
* | the tree is still on `NODE_PATH` | a live resolution path, whatever the mode says |
|
|
25
|
+
* | boot not confirmed healthy | the copies are the recovery path, needed exactly now |
|
|
26
|
+
*
|
|
27
|
+
* So the plan is gated four ways, the executor refuses a blocked plan a second
|
|
28
|
+
* time, and a refusal is LOUD — a sweep that quietly did nothing is
|
|
29
|
+
* indistinguishable from a node that was already clean.
|
|
30
|
+
*
|
|
31
|
+
* Removal is rename-then-delete, the pattern `evictInstallDir` already uses on
|
|
32
|
+
* this FUSE mount: the rename is what ends the shadowing and it succeeds with
|
|
33
|
+
* inodes still open; the delete is best-effort. A copy that could not even be
|
|
34
|
+
* renamed is REPORTED, never swallowed.
|
|
35
|
+
*
|
|
36
|
+
* Pure planning + an injected filesystem, so the whole thing is testable
|
|
37
|
+
* against a described layout rather than a real disk.
|
|
38
|
+
*/
|
|
39
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
|
+
exports.CLOSURE_PROVIDED_PACKAGES = void 0;
|
|
41
|
+
exports.planSingleCopyCleanup = planSingleCopyCleanup;
|
|
42
|
+
exports.executeSingleCopyCleanup = executeSingleCopyCleanup;
|
|
43
|
+
exports.formatCleanupPlan = formatCleanupPlan;
|
|
44
|
+
exports.discoverRedundantCopies = discoverRedundantCopies;
|
|
45
|
+
exports.isCleanupEnabled = isCleanupEnabled;
|
|
46
|
+
/** Image trees. The fallback source lives here and is never data-side. */
|
|
47
|
+
const IMAGE_PREFIX = '/opt/';
|
|
48
|
+
/**
|
|
49
|
+
* Decide what may be removed. Pure.
|
|
50
|
+
*
|
|
51
|
+
* The node-wide gates come first and are absolute: a blocked plan removes
|
|
52
|
+
* nothing at all, whatever the individual candidates look like.
|
|
53
|
+
*/
|
|
54
|
+
function planSingleCopyCleanup(input) {
|
|
55
|
+
const blockedReason = nodeWideBlock(input);
|
|
56
|
+
if (blockedReason !== null) {
|
|
57
|
+
return {
|
|
58
|
+
remove: [],
|
|
59
|
+
keep: input.candidates.map((candidate) => ({ candidate, reason: blockedReason })),
|
|
60
|
+
blocked: true,
|
|
61
|
+
blockedReason,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const remove = [];
|
|
65
|
+
const keep = [];
|
|
66
|
+
for (const candidate of input.candidates) {
|
|
67
|
+
const refusal = perCandidateRefusal(candidate, input);
|
|
68
|
+
if (refusal === null)
|
|
69
|
+
remove.push(candidate);
|
|
70
|
+
else
|
|
71
|
+
keep.push({ candidate, reason: refusal });
|
|
72
|
+
}
|
|
73
|
+
return { remove, keep, blocked: false, blockedReason: null };
|
|
74
|
+
}
|
|
75
|
+
/** The reasons NOTHING may be removed this boot. */
|
|
76
|
+
function nodeWideBlock(input) {
|
|
77
|
+
if (!input.bootHealthy) {
|
|
78
|
+
return 'this boot is not confirmed healthy — the redundant copies are the recovery path and stay';
|
|
79
|
+
}
|
|
80
|
+
if (input.activeRoot === null) {
|
|
81
|
+
return 'baked mode: there is no active closure, so the node is running FROM the fallback tree';
|
|
82
|
+
}
|
|
83
|
+
if (input.closureResolvedFrom === null) {
|
|
84
|
+
return 'the process resolved no @camstack/system at all — nothing here is safe to remove';
|
|
85
|
+
}
|
|
86
|
+
if (!isInside(input.closureResolvedFrom, input.activeRoot)) {
|
|
87
|
+
return (`@camstack/system resolved from ${input.closureResolvedFrom}, which is OUTSIDE the active ` +
|
|
88
|
+
'closure — the copy being removed could be the one in memory');
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
/** Why this ONE copy stays, or `null` when it may go. */
|
|
93
|
+
function perCandidateRefusal(candidate, input) {
|
|
94
|
+
if (input.activeRoot !== null && isInside(candidate.path, input.activeRoot)) {
|
|
95
|
+
return 'inside the active closure — this is the copy that runs, not a redundant one';
|
|
96
|
+
}
|
|
97
|
+
if (candidate.path.startsWith(IMAGE_PREFIX)) {
|
|
98
|
+
return 'an image tree — the operator keeps it as the first-boot and fallback source';
|
|
99
|
+
}
|
|
100
|
+
const onNodePath = input.nodePathEntries.some((entry) => entry === candidate.path || isInside(entry, candidate.path));
|
|
101
|
+
if (onNodePath) {
|
|
102
|
+
return 'still on NODE_PATH — a live resolution path, whatever the boot mode says';
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
/** Is `child` the same path as `parent`, or under it? Separator-aware. */
|
|
107
|
+
function isInside(child, parent) {
|
|
108
|
+
const normalise = (p) => (p.endsWith('/') ? p.slice(0, -1) : p);
|
|
109
|
+
const c = normalise(child);
|
|
110
|
+
const p = normalise(parent);
|
|
111
|
+
return c === p || c.startsWith(`${p}/`);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Carry out a plan. Never throws: a cleanup that fails is a node with an extra
|
|
115
|
+
* directory, and a cleanup that ends a process is an outage.
|
|
116
|
+
*
|
|
117
|
+
* The blocked check is repeated here on purpose. `planSingleCopyCleanup` is the
|
|
118
|
+
* only intended producer, but a hand-assembled plan must not be able to walk
|
|
119
|
+
* past the one gate that keeps this from running at the wrong moment.
|
|
120
|
+
*/
|
|
121
|
+
async function executeSingleCopyCleanup(plan, fs, log) {
|
|
122
|
+
if (plan.blocked) {
|
|
123
|
+
log(`single-copy cleanup REFUSED — ${plan.blockedReason ?? 'blocked'}`);
|
|
124
|
+
return { removed: [], failed: [], refused: true };
|
|
125
|
+
}
|
|
126
|
+
const removed = [];
|
|
127
|
+
const failed = [];
|
|
128
|
+
for (const candidate of plan.remove) {
|
|
129
|
+
if (!fs.exists(candidate.path))
|
|
130
|
+
continue;
|
|
131
|
+
const label = `${candidate.pkg ?? candidate.kind} ${candidate.version ?? '<unknown version>'}`;
|
|
132
|
+
const aside = `${candidate.path}.removing-${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
133
|
+
try {
|
|
134
|
+
await fs.rename(candidate.path, aside);
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
failed.push(candidate.path);
|
|
138
|
+
log(`single-copy cleanup — could NOT remove ${candidate.path} (${label}): ${errMsg(err)}`);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
// Past this line the copy can no longer be resolved by anything, which is
|
|
142
|
+
// the whole point; the bytes are a separate, best-effort concern.
|
|
143
|
+
removed.push(candidate.path);
|
|
144
|
+
log(`single-copy cleanup — removed ${candidate.path} (${label})`);
|
|
145
|
+
try {
|
|
146
|
+
await fs.remove(aside);
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
log(`single-copy cleanup — ${aside} still on disk (held open?): ${errMsg(err)}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { removed, failed, refused: false };
|
|
153
|
+
}
|
|
154
|
+
function errMsg(err) {
|
|
155
|
+
return err instanceof Error ? err.message : String(err);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The report. Always says how many copies it looked at, so a boot that removed
|
|
159
|
+
* nothing is distinguishable from a boot where the sweep never ran — and a
|
|
160
|
+
* BLOCKED sweep names every copy it left behind, because those are exactly the
|
|
161
|
+
* ones an operator would otherwise believe were gone.
|
|
162
|
+
*/
|
|
163
|
+
function formatCleanupPlan(plan) {
|
|
164
|
+
const lines = [];
|
|
165
|
+
if (plan.blocked) {
|
|
166
|
+
lines.push(`single-copy cleanup blocked — ${plan.blockedReason ?? 'unknown reason'}; ` +
|
|
167
|
+
`${plan.keep.length} copy(ies) kept`);
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
lines.push(`single-copy cleanup — ${plan.remove.length} copy(ies) to remove`);
|
|
171
|
+
}
|
|
172
|
+
for (const c of plan.remove) {
|
|
173
|
+
lines.push(` remove ${c.path} — ${c.pkg ?? c.kind} ${c.version ?? '<unknown version>'}`);
|
|
174
|
+
}
|
|
175
|
+
for (const k of plan.keep) {
|
|
176
|
+
lines.push(` keep ${k.candidate.path} — ${k.candidate.pkg ?? k.candidate.kind} ` +
|
|
177
|
+
`${k.candidate.version ?? '<unknown version>'}: ${k.reason}`);
|
|
178
|
+
}
|
|
179
|
+
return lines;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The host-provided packages the closure carries. A copy of any of these under
|
|
183
|
+
* the addon root WINS over the closure and is never refreshed — the shape that
|
|
184
|
+
* left both agents 17 versions behind ([D107](../../../docs/decisions/adr-0107.md)),
|
|
185
|
+
* and the reason `<dataDir>/addons/@camstack/system` is on the agent's disk at
|
|
186
|
+
* 1.2.78 while it runs a newer closure.
|
|
187
|
+
*/
|
|
188
|
+
exports.CLOSURE_PROVIDED_PACKAGES = [
|
|
189
|
+
'@camstack/system',
|
|
190
|
+
'@camstack/types',
|
|
191
|
+
'@camstack/sdk',
|
|
192
|
+
'@camstack/shm-ring',
|
|
193
|
+
'@camstack/ui-library',
|
|
194
|
+
];
|
|
195
|
+
/**
|
|
196
|
+
* Every redundant DATA-side copy on this node, in removal order: the legacy
|
|
197
|
+
* framework tree first, then the host-provided packages under the addon root.
|
|
198
|
+
*
|
|
199
|
+
* A copy whose `package.json` will not parse is still reported, with a null
|
|
200
|
+
* version — dropping it would under-report exactly the kind that has been
|
|
201
|
+
* sitting somewhere since an interrupted install.
|
|
202
|
+
*/
|
|
203
|
+
function discoverRedundantCopies(input, fs) {
|
|
204
|
+
const found = [];
|
|
205
|
+
const legacyFramework = `${trimSlash(input.dataDir)}/framework`;
|
|
206
|
+
if (fs.exists(legacyFramework)) {
|
|
207
|
+
found.push({
|
|
208
|
+
kind: 'legacy-framework-tree',
|
|
209
|
+
path: legacyFramework,
|
|
210
|
+
pkg: null,
|
|
211
|
+
version: fs.readVersion(`${legacyFramework}/node_modules/@camstack/system`),
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
for (const pkg of exports.CLOSURE_PROVIDED_PACKAGES) {
|
|
215
|
+
if (!input.closureProvides(pkg))
|
|
216
|
+
continue;
|
|
217
|
+
const dir = `${trimSlash(input.addonRoot)}/${pkg}`;
|
|
218
|
+
if (!fs.exists(dir))
|
|
219
|
+
continue;
|
|
220
|
+
found.push({
|
|
221
|
+
kind: 'addon-root-closure-copy',
|
|
222
|
+
path: dir,
|
|
223
|
+
pkg,
|
|
224
|
+
version: fs.readVersion(dir),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
return found;
|
|
228
|
+
}
|
|
229
|
+
function trimSlash(p) {
|
|
230
|
+
return p.endsWith('/') ? p.slice(0, -1) : p;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* The kill switch. Default ON — the operator asked for convergence to one copy
|
|
234
|
+
* — but a destructive sweep that cannot be turned off from the outside is one
|
|
235
|
+
* an operator has to edit code to stop. `CAMSTACK_SINGLE_COPY_CLEANUP=off`
|
|
236
|
+
* (or `0` / `false`) leaves every copy where it is, and the boot says so.
|
|
237
|
+
*/
|
|
238
|
+
function isCleanupEnabled(env) {
|
|
239
|
+
const raw = env['CAMSTACK_SINGLE_COPY_CLEANUP']?.trim().toLowerCase();
|
|
240
|
+
return raw !== 'off' && raw !== '0' && raw !== 'false';
|
|
241
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.102",
|
|
4
4
|
"private": false,
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -33,19 +33,19 @@
|
|
|
33
33
|
]
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@camstack/addon-admin-ui": "1.2.
|
|
37
|
-
"@camstack/addon-agent-ui": "1.2.
|
|
38
|
-
"@camstack/addon-auth": "1.2.
|
|
39
|
-
"@camstack/addon-decoder-nodeav": "1.2.
|
|
40
|
-
"@camstack/addon-notifiers": "1.2.
|
|
41
|
-
"@camstack/addon-pipeline": "1.2.
|
|
42
|
-
"@camstack/addon-pipeline-orchestrator": "1.2.
|
|
43
|
-
"@camstack/addon-post-analysis": "1.2.
|
|
44
|
-
"@camstack/sdk": "1.2.
|
|
45
|
-
"@camstack/shm-ring": "1.1.
|
|
46
|
-
"@camstack/system": "1.2.
|
|
47
|
-
"@camstack/types": "1.2.
|
|
48
|
-
"@camstack/ui-library": "1.2.
|
|
36
|
+
"@camstack/addon-admin-ui": "1.2.52",
|
|
37
|
+
"@camstack/addon-agent-ui": "1.2.15",
|
|
38
|
+
"@camstack/addon-auth": "1.2.16",
|
|
39
|
+
"@camstack/addon-decoder-nodeav": "1.2.14",
|
|
40
|
+
"@camstack/addon-notifiers": "1.2.19",
|
|
41
|
+
"@camstack/addon-pipeline": "1.2.71",
|
|
42
|
+
"@camstack/addon-pipeline-orchestrator": "1.2.51",
|
|
43
|
+
"@camstack/addon-post-analysis": "1.2.68",
|
|
44
|
+
"@camstack/sdk": "1.2.16",
|
|
45
|
+
"@camstack/shm-ring": "1.1.14",
|
|
46
|
+
"@camstack/system": "1.2.86",
|
|
47
|
+
"@camstack/types": "1.2.65",
|
|
48
|
+
"@camstack/ui-library": "1.2.44",
|
|
49
49
|
"@fastify/compress": "^9.0.0",
|
|
50
50
|
"@fastify/cookie": "^11.0.2",
|
|
51
51
|
"@fastify/cors": "^11.2.0",
|