@camstack/server 1.2.100 → 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 +19 -0
- package/dist/api/core/addon-settings.router.js +24 -0
- package/dist/api/core/system-events.router.js +11 -5
- package/dist/api/static/spa-static.js +27 -1
- package/dist/api/trpc/generated-cap-routers.js +45 -0
- package/dist/core/addon/addon-registry.service.js +77 -23
- package/dist/core/moleculer/moleculer.service.js +11 -0
- package/dist/first-boot-addon-plan.js +112 -0
- package/dist/launcher.js +97 -25
- package/dist/main.js +29 -3
- package/dist/package-inventory.js +114 -27
- package/dist/single-copy-cleanup-runner.js +128 -0
- package/dist/single-copy-cleanup.js +241 -0
- package/package.json +14 -14
|
@@ -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",
|