@bongos/core 1.21.126 → 1.21.128
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/.bongos-core.json +73 -33
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +8 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +6 -0
- package/docs/adr/0354-a-builder-hosts-one-project-free-and-more-takes-the-fleet-permission.md +1 -1
- package/docs/api/openapi.json +147 -2
- package/docs/api-reference.md +5 -2
- package/docs/module-api-changelog.md +4 -0
- package/modules/onboarding/howto-state.js +108 -0
- package/modules/onboarding/migrations/onboarding_001_howto_reads.sql +33 -0
- package/modules/onboarding/module.json +1 -1
- package/modules/onboarding/routes/onboarding.js +48 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/exec-path-guard.js +1 -1
- package/scripts/gds/fitness.js +1 -0
- package/scripts/gds/module-error-report.js +148 -0
- package/scripts/gds/module.js +1 -0
- package/scripts/gds/provision-caps.js +63 -0
- package/scripts/gds/provision-process.js +3 -0
- package/scripts/gds/provision-units.js +3 -0
- package/scripts/gds/provision.js +3 -1
- package/src/module-api.js +1 -1
- package/src/module-loader/error-tally.js +188 -0
- package/src/module-loader/loader.js +52 -4
- package/src/platform-server.js +7 -0
- package/tests/howto_state.mjs +178 -0
- package/tests/module_error_counts.mjs +265 -0
- package/tests/own_scope_guard.mjs +6 -0
- package/tests/provision.mjs +2 -0
- package/tests/provision_resource_caps.mjs +117 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// tests/module_error_counts.mjs — instances count each store module's runtime
|
|
2
|
+
// errors per day and send the daily count with the builder sign-in on the box
|
|
3
|
+
// (task 1004637): the loader's attribution, the on-disk tally, and the sender.
|
|
4
|
+
// Hermetic — temp dirs, a fake store client, an in-process express app.
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import http from 'node:http';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
16
|
+
const loader = require(path.join(ROOT, 'src', 'module-loader', 'loader.js'));
|
|
17
|
+
const { seed, record, readTally, start, startDailySend, _reset } = require(path.join(ROOT, 'src', 'module-loader', 'error-tally.js'));
|
|
18
|
+
// the in-memory tally is read the way tooling reads it: through the loader's snapshot
|
|
19
|
+
const tally = { seed, record, readTally, start, startDailySend, _reset, snapshot: () => loader.loaderSnapshot().errorCounts };
|
|
20
|
+
const sender = require(path.join(ROOT, 'scripts', 'gds', 'module-error-report.js'));
|
|
21
|
+
const express = require('express');
|
|
22
|
+
|
|
23
|
+
function tmpDir(prefix) { return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); }
|
|
24
|
+
|
|
25
|
+
function fixtureRoot(spec) {
|
|
26
|
+
const root = tmpDir('bongos-errmods-');
|
|
27
|
+
for (const [key, def] of Object.entries(spec)) {
|
|
28
|
+
const d = path.join(root, key);
|
|
29
|
+
fs.mkdirSync(d, { recursive: true });
|
|
30
|
+
fs.writeFileSync(path.join(d, 'module.json'), JSON.stringify({
|
|
31
|
+
key, title: key, description: 'd', version: def.version === undefined ? '1.2.0' : def.version,
|
|
32
|
+
coreVersion: '^1.0.0', contributes: def.contributes || {},
|
|
33
|
+
}));
|
|
34
|
+
for (const [rel, content] of Object.entries(def.files || {})) {
|
|
35
|
+
const fp = path.join(d, rel);
|
|
36
|
+
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
|
37
|
+
fs.writeFileSync(fp, content);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return root;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const TODAY = new Date().toISOString().slice(0, 10);
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// The tally
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
test('seed gives every exactly-versioned module a 0 today; record counts only those', () => {
|
|
50
|
+
tally._reset();
|
|
51
|
+
const n = tally.seed([
|
|
52
|
+
{ key: 'alpha', manifest: { version: '1.2.0' } },
|
|
53
|
+
{ key: 'loose', manifest: { version: '^1.0.0' } },
|
|
54
|
+
{ key: 'none', manifest: {} },
|
|
55
|
+
]);
|
|
56
|
+
assert.equal(n, 1);
|
|
57
|
+
assert.deepEqual(tally.snapshot()[TODAY], { 'alpha@1.2.0': 0 }, 'a clean day is a 0, not a missing row');
|
|
58
|
+
assert.equal(tally.record('alpha'), true);
|
|
59
|
+
assert.equal(tally.record('alpha'), true);
|
|
60
|
+
assert.equal(tally.record('loose'), false, 'no exact version, nothing to file it against');
|
|
61
|
+
assert.equal(tally.record('ghost'), false, 'a stray key never invents a module');
|
|
62
|
+
assert.deepEqual(tally.snapshot()[TODAY], { 'alpha@1.2.0': 2 });
|
|
63
|
+
tally._reset();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('nothing touches disk until start(); start merges the file (higher count wins) and prunes old days', () => {
|
|
67
|
+
tally._reset();
|
|
68
|
+
const dir = tmpDir('bongos-tally-');
|
|
69
|
+
const file = path.join(dir, 'module-error-counts.json');
|
|
70
|
+
tally.seed([{ key: 'alpha', manifest: { version: '1.2.0' } }]);
|
|
71
|
+
tally.record('alpha');
|
|
72
|
+
assert.equal(fs.existsSync(file), false);
|
|
73
|
+
fs.writeFileSync(file, JSON.stringify({ days: {
|
|
74
|
+
[TODAY]: { 'alpha@1.2.0': 5 },
|
|
75
|
+
'2000-01-01': { 'alpha@1.0.0': 9 },
|
|
76
|
+
} }));
|
|
77
|
+
tally.start({ file });
|
|
78
|
+
const onDisk = tally.readTally(file);
|
|
79
|
+
assert.equal(onDisk[TODAY]['alpha@1.2.0'], 5, 'a restart mid-day keeps the morning count');
|
|
80
|
+
assert.equal(onDisk['2000-01-01'], undefined, 'days past the window are pruned');
|
|
81
|
+
tally._reset();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('the tally on disk holds counts only — key@version and a number, nothing else', () => {
|
|
85
|
+
tally._reset();
|
|
86
|
+
const file = path.join(tmpDir('bongos-tally-'), 'c.json');
|
|
87
|
+
tally.seed([{ key: 'alpha', manifest: { version: '1.2.0' } }]);
|
|
88
|
+
tally.record('alpha');
|
|
89
|
+
tally.start({ file });
|
|
90
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
91
|
+
assert.deepEqual(Object.keys(raw), ['days']);
|
|
92
|
+
for (const row of Object.values(raw.days)) {
|
|
93
|
+
for (const [k, v] of Object.entries(row)) {
|
|
94
|
+
assert.match(k, /^[a-z0-9-]+@\d+\.\d+\.\d+$/);
|
|
95
|
+
assert.ok(Number.isInteger(v));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
tally._reset();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// The loader attributes a 500 to the module that answered it
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
async function withServer(app, fn) {
|
|
106
|
+
const server = http.createServer(app);
|
|
107
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
108
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
109
|
+
try { return await fn(base); } finally { await new Promise((r) => server.close(r)); }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
test('a module route that throws or answers 500 counts against it; a 4xx, a 503 and a fall-through do not', async () => {
|
|
113
|
+
loader._reset();
|
|
114
|
+
const root = fixtureRoot({
|
|
115
|
+
alpha: {
|
|
116
|
+
contributes: { routes: ['main'] },
|
|
117
|
+
files: { 'routes/main.js': `
|
|
118
|
+
const express = require(${JSON.stringify(require.resolve('express'))});
|
|
119
|
+
module.exports = () => {
|
|
120
|
+
const r = express.Router();
|
|
121
|
+
r.get('/alpha/boom', () => { throw new Error('boom'); });
|
|
122
|
+
r.get('/alpha/fail', (req, res) => res.status(500).json({ error: 'x' }));
|
|
123
|
+
r.get('/alpha/bad', (req, res) => res.status(400).json({ error: 'x' }));
|
|
124
|
+
r.get('/alpha/off', (req, res) => res.status(503).json({ error: 'not set up' }));
|
|
125
|
+
r.get('/alpha/pass', (req, res, next) => next(new Error('handed on')));
|
|
126
|
+
return r;
|
|
127
|
+
};` },
|
|
128
|
+
},
|
|
129
|
+
beta: { contributes: {} },
|
|
130
|
+
});
|
|
131
|
+
const app = express();
|
|
132
|
+
const router = express.Router();
|
|
133
|
+
loader.mountModuleRoutes(router, { isEnabled: () => true }, { roots: [root], core: '1.0.0' });
|
|
134
|
+
router.get('/kernel/boom', () => { throw new Error('kernel'); });
|
|
135
|
+
app.use(router);
|
|
136
|
+
app.use((err, req, res, next) => res.status(500).json({ error: 'internal' })); // eslint-disable-line no-unused-vars
|
|
137
|
+
|
|
138
|
+
await withServer(app, async (base) => {
|
|
139
|
+
for (const p of ['/alpha/boom', '/alpha/fail', '/alpha/bad', '/alpha/off', '/alpha/pass', '/kernel/boom']) {
|
|
140
|
+
await (await fetch(base + p)).text();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
await new Promise((r) => setImmediate(r));
|
|
144
|
+
const day = tally.snapshot()[TODAY];
|
|
145
|
+
assert.equal(day['alpha@1.2.0'], 3, 'boom + fail + next(err) — not the 400, not the 503, not the kernel route');
|
|
146
|
+
assert.equal(day['beta@1.2.0'], 0, 'an enabled module with no routes still files its clean day');
|
|
147
|
+
loader._reset();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('the wrapped router still exposes the module’s own routes to tooling', () => {
|
|
151
|
+
loader._reset();
|
|
152
|
+
const root = fixtureRoot({
|
|
153
|
+
alpha: {
|
|
154
|
+
contributes: { routes: ['main'] },
|
|
155
|
+
files: { 'routes/main.js': `
|
|
156
|
+
const express = require(${JSON.stringify(require.resolve('express'))});
|
|
157
|
+
module.exports = () => { const r = express.Router(); r.get('/alpha/x', (q, s) => s.end()); return r; };` },
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
const used = [];
|
|
161
|
+
loader.mountModuleRoutes({ use: (r) => used.push(r) }, { isEnabled: () => true }, { roots: [root], core: '1.0.0' });
|
|
162
|
+
assert.equal(used.length, 1);
|
|
163
|
+
const paths = (used[0].stack || []).filter((l) => l.route).map((l) => l.route.path);
|
|
164
|
+
assert.deepEqual(paths, ['/alpha/x']);
|
|
165
|
+
loader._reset();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('a poller that throws on start counts, and a running poller can report through reportError', async () => {
|
|
169
|
+
loader._reset();
|
|
170
|
+
const root = fixtureRoot({
|
|
171
|
+
broken: { contributes: { pollers: ['p'] }, files: { 'pollers/p.js': 'module.exports = { start() { throw new Error("x"); } };' } },
|
|
172
|
+
rejecting: { contributes: { pollers: ['p'] }, files: { 'pollers/p.js': 'module.exports = { start() { return Promise.reject(new Error("x")); } };' } },
|
|
173
|
+
reporting: { contributes: { pollers: ['p'] }, files: { 'pollers/p.js': 'module.exports = { start(deps) { deps.reportError(); deps.reportError(); } };' } },
|
|
174
|
+
});
|
|
175
|
+
const opts = { roots: [root], core: '1.0.0' };
|
|
176
|
+
loader.mountModuleRoutes({ use: () => {} }, { isEnabled: () => true }, opts);
|
|
177
|
+
const origErr = console.error;
|
|
178
|
+
console.error = () => {};
|
|
179
|
+
try {
|
|
180
|
+
loader.startModulePollers({}, { isEnabled: () => true }, opts);
|
|
181
|
+
await new Promise((r) => setImmediate(r));
|
|
182
|
+
} finally { console.error = origErr; }
|
|
183
|
+
const day = tally.snapshot()[TODAY];
|
|
184
|
+
assert.equal(day['broken@1.2.0'], 1);
|
|
185
|
+
assert.equal(day['rejecting@1.2.0'], 1);
|
|
186
|
+
assert.equal(day['reporting@1.2.0'], 2);
|
|
187
|
+
loader._reset();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// The sender
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
function fakeStore({ enabled = true, known = ['alpha'], post = () => ({ ok: true, status: 200 }) } = {}) {
|
|
195
|
+
const calls = [];
|
|
196
|
+
const call = async (method, p, opts = {}) => {
|
|
197
|
+
calls.push({ method, p, json: opts.json });
|
|
198
|
+
if (p === '/store/usage-reporting') return { ok: true, status: 200, data: { enabled } };
|
|
199
|
+
if (p.startsWith('/store/modules/latest')) {
|
|
200
|
+
return { ok: true, status: 200, data: { latest: Object.fromEntries(known.map((k) => [k, { version: '1.2.0' }])) } };
|
|
201
|
+
}
|
|
202
|
+
return post(p, opts.json);
|
|
203
|
+
};
|
|
204
|
+
return { call, calls };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
test('pendingDays: finished, unsent days inside the store window, oldest first', () => {
|
|
208
|
+
const days = { '2026-10-14': {}, '2026-10-15': {}, '2026-10-12': {}, '2026-09-01': {} };
|
|
209
|
+
assert.deepEqual(sender.pendingDays(days, { '2026-10-12': true }, '2026-10-15'), ['2026-10-14']);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('sendPending sends every count for a store module, zeros included, and skips modules the store does not know', async () => {
|
|
213
|
+
const { call, calls } = fakeStore();
|
|
214
|
+
const days = { '2026-10-14': { 'alpha@1.2.0': 0, 'core-thing@3.0.0': 4 } };
|
|
215
|
+
const out = await sender.sendPending({ days, today: '2026-10-15', call });
|
|
216
|
+
const posts = calls.filter((c) => c.method === 'POST');
|
|
217
|
+
assert.equal(posts.length, 1);
|
|
218
|
+
assert.equal(posts[0].p, '/store/modules/alpha/versions/1.2.0/error-count');
|
|
219
|
+
assert.deepEqual(posts[0].json, { day: '2026-10-14', count: 0 }, 'a clean day goes as a 0');
|
|
220
|
+
assert.deepEqual(out.sentDays, ['2026-10-14']);
|
|
221
|
+
assert.equal(out.skipped, 1);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('an opted-out builder sends nothing, and the days are marked done', async () => {
|
|
225
|
+
const { call, calls } = fakeStore({ enabled: false });
|
|
226
|
+
const out = await sender.sendPending({ days: { '2026-10-14': { 'alpha@1.2.0': 2 } }, today: '2026-10-15', call });
|
|
227
|
+
assert.equal(calls.filter((c) => c.method === 'POST').length, 0);
|
|
228
|
+
assert.equal(out.optedOut, true);
|
|
229
|
+
assert.deepEqual(out.sentDays, ['2026-10-14']);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('a refusal for good (4xx) is done; a server error leaves the day for the next run', async () => {
|
|
233
|
+
const refused = fakeStore({ post: () => ({ ok: false, status: 403 }) });
|
|
234
|
+
const a = await sender.sendPending({ days: { '2026-10-14': { 'alpha@1.2.0': 1 } }, today: '2026-10-15', call: refused.call });
|
|
235
|
+
assert.deepEqual(a.sentDays, ['2026-10-14']);
|
|
236
|
+
const down = fakeStore({ post: () => ({ ok: false, status: 502 }) });
|
|
237
|
+
const b = await sender.sendPending({ days: { '2026-10-14': { 'alpha@1.2.0': 1 } }, today: '2026-10-15', call: down.call });
|
|
238
|
+
assert.deepEqual(b.sentDays, []);
|
|
239
|
+
assert.equal(b.retryLater, true);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('main records the days it sent, and a second run sends nothing again', async () => {
|
|
243
|
+
const dir = tmpDir('bongos-sender-');
|
|
244
|
+
const tallyFile = path.join(dir, 'counts.json');
|
|
245
|
+
const sentFile = path.join(dir, 'sent.json');
|
|
246
|
+
fs.writeFileSync(tallyFile, JSON.stringify({ days: { '2026-10-14': { 'alpha@1.2.0': 3 }, '2026-10-15': { 'alpha@1.2.0': 1 } } }));
|
|
247
|
+
const first = fakeStore();
|
|
248
|
+
const log = () => {};
|
|
249
|
+
assert.equal(await sender.main([], { log, request: first.call, tallyFile, sentFile, today: '2026-10-15' }), 0);
|
|
250
|
+
assert.equal(first.calls.filter((c) => c.method === 'POST').length, 1, 'today is not finished, so only yesterday goes');
|
|
251
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(sentFile, 'utf8')).sent, { '2026-10-14': true });
|
|
252
|
+
const second = fakeStore();
|
|
253
|
+
assert.equal(await sender.main([], { log, request: second.call, tallyFile, sentFile, today: '2026-10-15' }), 0);
|
|
254
|
+
assert.equal(second.calls.length, 0);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('the server runs the sender as a child process, never in its own process', () => {
|
|
258
|
+
tally._reset();
|
|
259
|
+
const ran = [];
|
|
260
|
+
const timers = tally.startDailySend({ spawnImpl: (s) => ran.push(s) });
|
|
261
|
+
assert.equal(timers.length, 2, 'a first run soon after boot, then every few hours');
|
|
262
|
+
tally._reset();
|
|
263
|
+
const src = fs.readFileSync(path.join(ROOT, 'src', 'module-loader', 'error-tally.js'), 'utf8');
|
|
264
|
+
assert.doesNotMatch(src, /require\([^)]*cli-lib/, 'the session plumbing never loads in the server');
|
|
265
|
+
});
|
|
@@ -164,6 +164,12 @@ const OWN_SCOPED_SURFACES = [
|
|
|
164
164
|
},
|
|
165
165
|
],
|
|
166
166
|
},
|
|
167
|
+
{
|
|
168
|
+
file: 'modules/onboarding/routes/onboarding.js',
|
|
169
|
+
why: "the how-to read state (task 1004634). GET /me/howto and POST /me/howto/read|reset read and write the CALLER's own marks, keyed on req.builder.id; the body names step ids, never a builder. The rest of the router is /founding/*, whose one path param names a founding STAGE, not a builder.",
|
|
170
|
+
minOwnScoped: 1,
|
|
171
|
+
crossOwner: [],
|
|
172
|
+
},
|
|
167
173
|
];
|
|
168
174
|
|
|
169
175
|
// Floors that keep this guard from going quietly vacuous (a regex that stops
|
package/tests/provision.mjs
CHANGED
|
@@ -637,6 +637,8 @@ t('systemd manager: emits byte-identical commands + unit paths to the inline str
|
|
|
637
637
|
'sudo rm -f /etc/systemd/system/demo.service [allowFail]',
|
|
638
638
|
'sudo systemctl disable --now demo-db-backup.timer [allowFail]',
|
|
639
639
|
'sudo rm -f /etc/systemd/system/demo-db-backup.service /etc/systemd/system/demo-db-backup.timer [allowFail]',
|
|
640
|
+
'sudo rm -f /etc/systemd/system.control/demo.service.d/50-*.conf [allowFail]', // the caps drop-in (task 1004414)
|
|
641
|
+
'sudo rmdir /etc/systemd/system.control/demo.service.d [allowFail]',
|
|
640
642
|
'sudo systemctl daemon-reload [allowFail]',
|
|
641
643
|
'sudo systemctl reset-failed demo.service demo-db-backup.service [allowFail]',
|
|
642
644
|
], 'including reset-failed — a removed unit\'s FAILED state outlives its file (task 1003940)');
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// tests/provision_resource_caps.mjs
|
|
2
|
+
//
|
|
3
|
+
// Every hosted project's service is capped (task 1004414). A cloud-host project runs its
|
|
4
|
+
// owner's code on the box that also serves the hub, and ADR 0354 lets any signed-in
|
|
5
|
+
// builder host one, so one runaway project must not starve the others or the hub.
|
|
6
|
+
//
|
|
7
|
+
// Covers:
|
|
8
|
+
// • the numbers are the owner's (2026-10-15): MemoryHigh 160M, MemoryMax 256M,
|
|
9
|
+
// CPUQuota 50%, TasksMax 256 — pinned here so a change is a decision, not a drift
|
|
10
|
+
// • a NEW unit carries all four, rendered from the same table
|
|
11
|
+
// • an EXISTING unit takes them live with `systemctl set-property` (no restart, and no
|
|
12
|
+
// rewrite of a unit whose User= predates the per-instance accounts)
|
|
13
|
+
// • `provision.js apply-caps` reaches every ACTIVE cloud-host project, on the control
|
|
14
|
+
// plane, carries on past one that fails, and says which failed
|
|
15
|
+
// • teardown removes the drop-in set-property wrote
|
|
16
|
+
//
|
|
17
|
+
// Run: node tests/provision_resource_caps.mjs
|
|
18
|
+
|
|
19
|
+
import assert from 'node:assert/strict';
|
|
20
|
+
import { test } from 'node:test';
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
// ESM named imports, so knip credits these exports to their test (a createRequire read is not)
|
|
24
|
+
import { RESOURCE_CAPS, capDirectives, setCapsCmd, cmdApplyCaps } from '../scripts/gds/provision-caps.js';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const CAPS = { RESOURCE_CAPS, capDirectives, setCapsCmd, cmdApplyCaps };
|
|
28
|
+
const { serviceUnit } = require('../scripts/gds/provision-units.js');
|
|
29
|
+
const PM = require('../scripts/gds/provision-process.js');
|
|
30
|
+
// the REAL shape makeExec answers an allowFail'd failure with: ok:true, softFailed:true
|
|
31
|
+
const { softFailResult } = require('../scripts/gds/provision-config.js');
|
|
32
|
+
|
|
33
|
+
const CLOUD = { id: 18, slug: 'mercury', hosting_shape: 'cloud-host', port: 4100, db_name: 'mercury', target_ref: 'owner/mercury' };
|
|
34
|
+
const OWNER_NUMBERS = ['MemoryHigh=160M', 'MemoryMax=256M', 'CPUQuota=50%', 'TasksMax=256'];
|
|
35
|
+
|
|
36
|
+
test('the caps are the owner’s numbers (2026-10-15)', () => {
|
|
37
|
+
assert.deepEqual(CAPS.capDirectives(), OWNER_NUMBERS);
|
|
38
|
+
assert.ok(Object.isFrozen(CAPS.RESOURCE_CAPS), 'the table cannot be edited at runtime');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('a new hosted project’s unit carries all four caps in [Service]', () => {
|
|
42
|
+
const unit = serviceUnit(CLOUD, { etcBase: '/etc' });
|
|
43
|
+
const service = unit.slice(unit.indexOf('[Service]'), unit.indexOf('[Install]'));
|
|
44
|
+
for (const d of OWNER_NUMBERS) assert.match(service, new RegExp(`^${d.replace(/[.*+?^${}()|[\]\\%]/g, '\\$&')}$`, 'm'), d);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('an existing unit takes the caps live through set-property', () => {
|
|
48
|
+
assert.equal(CAPS.setCapsCmd(CLOUD, { sudoP: 'sudo ' }),
|
|
49
|
+
'sudo systemctl set-property mercury.service MemoryHigh=160M MemoryMax=256M CPUQuota=50% TasksMax=256');
|
|
50
|
+
assert.equal(CAPS.setCapsCmd(CLOUD), 'systemctl set-property mercury.service MemoryHigh=160M MemoryMax=256M CPUQuota=50% TasksMax=256');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
function fleet(rows, failSlug = null) {
|
|
54
|
+
const queries = [];
|
|
55
|
+
const ran = [];
|
|
56
|
+
const logs = [];
|
|
57
|
+
const exec = (cmd, opts = {}) => {
|
|
58
|
+
ran.push({ cmd, allowFail: !!opts.allowFail, on: 'control' });
|
|
59
|
+
return cmd.includes(` ${failSlug}.service `) ? softFailResult(new Error(`Unit ${failSlug}.service not loaded.`), false) : { ok: true, stdout: '' };
|
|
60
|
+
};
|
|
61
|
+
const deps = {
|
|
62
|
+
db: { query: async (sql, params) => { queries.push({ sql, params }); return { rows }; } },
|
|
63
|
+
controlExec: exec,
|
|
64
|
+
exec: () => { throw new Error('the box executor is for co-tenant; a cloud-host runs on the control plane'); },
|
|
65
|
+
privileged: true,
|
|
66
|
+
log: (s) => logs.push(s),
|
|
67
|
+
};
|
|
68
|
+
return { deps, queries, ran, logs };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
test('apply-caps reaches every active cloud-host project on the control plane', async () => {
|
|
72
|
+
const f = fleet([{ slug: 'mercury' }, { slug: 'venus' }]);
|
|
73
|
+
const r = await CAPS.cmdApplyCaps(f.deps);
|
|
74
|
+
assert.match(f.queries[0].sql, /hosting_shape = 'cloud-host'/);
|
|
75
|
+
assert.match(f.queries[0].sql, /status = 'active'/);
|
|
76
|
+
assert.deepEqual(f.ran.map((x) => x.cmd), [
|
|
77
|
+
'sudo systemctl set-property mercury.service MemoryHigh=160M MemoryMax=256M CPUQuota=50% TasksMax=256',
|
|
78
|
+
'sudo systemctl set-property venus.service MemoryHigh=160M MemoryMax=256M CPUQuota=50% TasksMax=256',
|
|
79
|
+
]);
|
|
80
|
+
assert.ok(f.ran.every((x) => x.allowFail), 'one project that refuses must not stop the rest');
|
|
81
|
+
assert.deepEqual(r, { ok: true, capped: ['mercury', 'venus'], failed: [] });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('apply-caps carries on past a failure and names it', async () => {
|
|
85
|
+
const f = fleet([{ slug: 'mercury' }, { slug: 'venus' }, { slug: 'pluto' }], 'venus');
|
|
86
|
+
const r = await CAPS.cmdApplyCaps(f.deps);
|
|
87
|
+
assert.equal(f.ran.length, 3);
|
|
88
|
+
assert.deepEqual(r, { ok: false, capped: ['mercury', 'pluto'], failed: ['venus'] });
|
|
89
|
+
assert.ok(f.logs.some((l) => /venus/.test(l)), 'the failure is said out loud');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('a slug outside the create route’s charset is refused, never run as root', () => {
|
|
93
|
+
for (const slug of ['a;rm -rf /', '../etc', 'Mercury', '-x']) assert.throws(() => CAPS.setCapsCmd({ slug }), /unsafe slug/, slug);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('apply-caps on an empty fleet does nothing and succeeds', async () => {
|
|
97
|
+
const f = fleet([]);
|
|
98
|
+
assert.deepEqual(await CAPS.cmdApplyCaps(f.deps), { ok: true, capped: [], failed: [] });
|
|
99
|
+
assert.equal(f.ran.length, 0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('provision.js lists apply-caps as a command', () => {
|
|
103
|
+
const r = spawnSync(process.execPath, ['scripts/gds/provision.js', 'help'], { encoding: 'utf8' });
|
|
104
|
+
assert.equal(r.status, 0, r.stderr);
|
|
105
|
+
assert.match(r.stdout, /apply-caps/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('teardown removes the drop-in set-property wrote, before the reload', () => {
|
|
109
|
+
const cmds = [];
|
|
110
|
+
const exec = (cmd, opts = {}) => { cmds.push(cmd); return { ok: true }; };
|
|
111
|
+
PM.systemd.removeService(CLOUD, { exec, sudoP: 'sudo ' });
|
|
112
|
+
const rm = cmds.indexOf('sudo rm -f /etc/systemd/system.control/mercury.service.d/50-*.conf');
|
|
113
|
+
assert.ok(rm >= 0, cmds.join('\n'));
|
|
114
|
+
assert.ok(cmds.includes('sudo rmdir /etc/systemd/system.control/mercury.service.d'), 'the emptied dir goes too');
|
|
115
|
+
assert.ok(!cmds.some((c) => /rm -rf/.test(c)), 'never a recursive delete');
|
|
116
|
+
assert.ok(rm < cmds.indexOf('sudo systemctl daemon-reload'), 'removed before the reload that forgets the unit');
|
|
117
|
+
});
|