@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.
@@ -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.100",
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.50",
37
- "@camstack/addon-agent-ui": "1.2.13",
38
- "@camstack/addon-auth": "1.2.14",
39
- "@camstack/addon-decoder-nodeav": "1.2.12",
40
- "@camstack/addon-notifiers": "1.2.17",
41
- "@camstack/addon-pipeline": "1.2.69",
42
- "@camstack/addon-pipeline-orchestrator": "1.2.49",
43
- "@camstack/addon-post-analysis": "1.2.66",
44
- "@camstack/sdk": "1.2.14",
45
- "@camstack/shm-ring": "1.1.12",
46
- "@camstack/system": "1.2.84",
47
- "@camstack/types": "1.2.63",
48
- "@camstack/ui-library": "1.2.42",
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",