@bongos/core 1.19.355 → 1.19.357
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 +57 -27
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0190-settings-apply-in-place-env-patch.md +59 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +5 -3
- package/modules/memory/module.json +11 -2
- package/modules/memory/pollers/search-index.js +201 -0
- package/modules/provisioning/migrations/provisioning_010_settings_apply.sql +24 -0
- package/modules/provisioning/provisioning.js +65 -23
- package/modules/provisioning/routes/provisioning.js +75 -7
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/projects.html +15 -2
- package/scripts/gds/provision-units.js +58 -0
- package/scripts/gds/provision.js +142 -12
- package/src/branding.js +11 -0
- package/src/module-api.js +5 -1
- package/tests/currency_label.mjs +12 -1
- package/tests/instance_manifest.mjs +8 -0
- package/tests/projects_hub_pre_uat.mjs +14 -0
- package/tests/provision_settings_apply.mjs +464 -0
- package/tests/provisioning_settings.mjs +8 -4
- package/tests/provisioning_settings_apply.mjs +372 -0
- package/tests/provisioning_settings_env.mjs +26 -9
- package/tests/provisioning_teardown_intent.mjs +5 -4
- package/tests/search_index_poller.mjs +161 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
// tests/provisioning_settings_apply.mjs
|
|
2
|
+
//
|
|
3
|
+
// The LIVE-PUSH half of the platform->instance settings channel (task 1003140,
|
|
4
|
+
// goal 1000058). Task 1003139 made a NEW instance read platform_visibility /
|
|
5
|
+
// joinability from the web.env the runner writes; this is the part that makes
|
|
6
|
+
// CHANGING one on an already-running instance take effect: PATCH /settings now
|
|
7
|
+
// enqueues a 'settings-apply' intent for an ACTIVE instance, and the runner
|
|
8
|
+
// patches the instance's web.env in place + restarts it.
|
|
9
|
+
//
|
|
10
|
+
// Covers (the task's done-when, web-tier side):
|
|
11
|
+
// • the ENQUEUE — an active co-tenant / standalone instance queues 'settings-apply'
|
|
12
|
+
// AFTER the row is written (the runner reads the row, so the write must come first)
|
|
13
|
+
// • a save that changes NOTHING writes nothing and restarts nothing — unless it is
|
|
14
|
+
// the owner re-kick after a retired push (error_note), the domain route's F19 shape
|
|
15
|
+
// • the STATE GATE — pre-active / snagged / offline states store the value, queue
|
|
16
|
+
// nothing, and say plainly when it takes effect
|
|
17
|
+
// • the SHAPE REFUSAL — a dedicated droplet is refused honestly (stored, no push)
|
|
18
|
+
// • the open-slot arbitration — foreign intent → 409 intent_conflict with
|
|
19
|
+
// recorded:true; an identical PENDING push is idempotent; a RUNNING one conflicts;
|
|
20
|
+
// an enqueue that THROWS still answers "saved" (never a 500 denying the write)
|
|
21
|
+
// • the response is a RECEIPT: `saved` + `push` {queued, restart, reachable, message}
|
|
22
|
+
// on every branch — it says the restart happens, never that the value is live,
|
|
23
|
+
// with no wall-clock or duration promises (the F16 rule)
|
|
24
|
+
// • INTENT_ACTIONS ↔ migration provisioning_010 lockstep; a pending push is
|
|
25
|
+
// superseded by a teardown (the 1002691 class)
|
|
26
|
+
//
|
|
27
|
+
// DB-free: data-access fns are stubbed on the cached module object (the
|
|
28
|
+
// provisioning_domain_attach.mjs pattern). No network beyond localhost.
|
|
29
|
+
//
|
|
30
|
+
// Run: node tests/provisioning_settings_apply.mjs
|
|
31
|
+
|
|
32
|
+
import { strict as assert } from 'node:assert';
|
|
33
|
+
import { createRequire } from 'node:module';
|
|
34
|
+
import { readFileSync } from 'node:fs';
|
|
35
|
+
import http from 'node:http';
|
|
36
|
+
|
|
37
|
+
const require = createRequire(import.meta.url);
|
|
38
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
39
|
+
|
|
40
|
+
let passed = 0, failed = 0;
|
|
41
|
+
function t(name, fn) {
|
|
42
|
+
try { fn(); passed++; console.log(` PASS ${name}`); }
|
|
43
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
44
|
+
}
|
|
45
|
+
async function ta(name, fn) {
|
|
46
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
47
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// No wall-clock or duration promise anywhere a receipt is rendered (F16).
|
|
51
|
+
const PROMISE = /shortly|briefly|minute|second|soon\b/i;
|
|
52
|
+
|
|
53
|
+
console.log('the module slice — constants, the migration, the enqueue classifier:');
|
|
54
|
+
|
|
55
|
+
t("INTENT_ACTIONS includes 'settings-apply' (lockstep with the provisioning_010 CHECK)", () => {
|
|
56
|
+
assert.ok(provisioning.INTENT_ACTIONS.includes('settings-apply'));
|
|
57
|
+
const sql = readFileSync(new URL('../modules/provisioning/migrations/provisioning_010_settings_apply.sql', import.meta.url), 'utf8');
|
|
58
|
+
const m = /CHECK \(action IN \(([^)]*)\)\)/.exec(sql);
|
|
59
|
+
assert.ok(m, 'the migration widens the action CHECK');
|
|
60
|
+
const inSql = m[1].split(',').map((s) => s.trim().replace(/^'|'$/g, '')).sort();
|
|
61
|
+
assert.deepEqual(inSql, [...provisioning.INTENT_ACTIONS].sort(), 'the CHECK and INTENT_ACTIONS carry the same set');
|
|
62
|
+
assert.match(sql, /INSERT INTO schema_migrations \(version\) VALUES \('provisioning_010_settings_apply'\)/, 'self-registers');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
t('SETTINGS_APPLY_FROM is exactly {active} — the provision intent cannot stand in (PROVISION_ENQUEUE_FROM excludes active)', () => {
|
|
66
|
+
assert.deepEqual([...provisioning.SETTINGS_APPLY_FROM], ['active']);
|
|
67
|
+
assert.ok(!provisioning.PROVISION_ENQUEUE_FROM.has('active'), 'the premise the gate rests on');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
t('SETTINGS_APPLY_SHAPES is the reachable pair — dedicated is out, and the set is a subset of HOSTING_SHAPES', () => {
|
|
71
|
+
assert.deepEqual([...provisioning.SETTINGS_APPLY_SHAPES].sort(), ['co-tenant', 'standalone']);
|
|
72
|
+
for (const s of provisioning.SETTINGS_APPLY_SHAPES) assert.ok(provisioning.HOSTING_SHAPES.includes(s), s);
|
|
73
|
+
assert.ok(!provisioning.SETTINGS_APPLY_SHAPES.has('dedicated'));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await ta('enqueueSettingsApply: a fresh intent → created:true; the INSERT carries the settings-apply action', async () => {
|
|
77
|
+
const calls = [];
|
|
78
|
+
const db = { query: async (sql, params) => { calls.push({ sql, params }); return { rows: [{ id: 1, action: params[1], state: 'pending' }] }; } };
|
|
79
|
+
const r = await provisioning.enqueueSettingsApply(db, 18, 'api:self');
|
|
80
|
+
assert.equal(r.created, true);
|
|
81
|
+
assert.equal(r.conflict, undefined);
|
|
82
|
+
assert.match(calls[0].sql, /INSERT INTO provisioning_intents/);
|
|
83
|
+
assert.deepEqual(calls[0].params, [18, 'settings-apply', 'api:self']);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await ta('enqueueSettingsApply: an identical PENDING push already open → created:false, no conflict (it carries this change)', async () => {
|
|
87
|
+
const db = { query: async (sql) => {
|
|
88
|
+
if (/INSERT/.test(sql)) { const e = new Error('dup'); e.code = '23505'; throw e; }
|
|
89
|
+
return { rows: [{ id: 5, action: 'settings-apply', state: 'pending' }] };
|
|
90
|
+
} };
|
|
91
|
+
const r = await provisioning.enqueueSettingsApply(db, 18, 'api:self');
|
|
92
|
+
assert.equal(r.created, false);
|
|
93
|
+
assert.equal(r.conflict, undefined);
|
|
94
|
+
assert.equal(r.intent.id, 5);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await ta('enqueueSettingsApply: a RUNNING push, or any other action holding the slot → conflict (it may have read the row already)', async () => {
|
|
98
|
+
for (const open of [{ action: 'settings-apply', state: 'running' }, { action: 'teardown', state: 'pending' }, { action: 'domain-attach', state: 'running' }]) {
|
|
99
|
+
const db = { query: async (sql) => {
|
|
100
|
+
if (/INSERT/.test(sql)) { const e = new Error('dup'); e.code = '23505'; throw e; }
|
|
101
|
+
return { rows: [{ id: 5, ...open }] };
|
|
102
|
+
} };
|
|
103
|
+
const r = await provisioning.enqueueSettingsApply(db, 18, 'api:self');
|
|
104
|
+
assert.equal(r.created, false, JSON.stringify(open));
|
|
105
|
+
assert.equal(r.conflict, 'open_intent', JSON.stringify(open));
|
|
106
|
+
assert.equal(r.intent.action, open.action, 'names the real blocker');
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await ta('enqueueTeardown supersedes a PENDING settings push (a push into a project about to be torn down is dead work — the 1002691 class)', async () => {
|
|
111
|
+
const calls = [];
|
|
112
|
+
const db = { query: async (sql, params) => {
|
|
113
|
+
calls.push(sql);
|
|
114
|
+
if (/UPDATE provisioning_intents/.test(sql)) return { rows: [{ id: 5 }] };
|
|
115
|
+
return { rows: [{ id: 6, action: 'teardown', state: 'pending', instance_id: params[0] }] };
|
|
116
|
+
} };
|
|
117
|
+
const r = await provisioning.enqueueTeardown(db, 18, 'api:self');
|
|
118
|
+
assert.equal(r.created, true);
|
|
119
|
+
assert.equal(r.superseded, true, 'the queued push was cancelled so the teardown could take the slot');
|
|
120
|
+
assert.match(calls[0], /action IN \('provision', 'settings-apply'\) AND state = 'pending'/, 'pending-only, and only the two supersedable actions');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── the route, over a real mounted router (stubbed data access) ─────────────────
|
|
124
|
+
let instance = null;
|
|
125
|
+
let updateCalls = [];
|
|
126
|
+
let enqueueCalls = [];
|
|
127
|
+
let enqueueResult = null; // what enqueueSettingsApply reports (null → created:true)
|
|
128
|
+
let enqueueThrows = null; // an Error to throw from enqueueSettingsApply
|
|
129
|
+
let events = [];
|
|
130
|
+
provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
|
|
131
|
+
provisioning.updateInstanceSettings = async (_db, id, patch) => {
|
|
132
|
+
updateCalls.push({ id, patch, enqueuedSoFar: enqueueCalls.length });
|
|
133
|
+
instance = { ...instance, settings: { ...(instance.settings || {}), ...patch } };
|
|
134
|
+
return { ...instance, id };
|
|
135
|
+
};
|
|
136
|
+
provisioning.enqueueSettingsApply = async (_db, id, requestedBy) => {
|
|
137
|
+
enqueueCalls.push({ id, requestedBy });
|
|
138
|
+
if (enqueueThrows) throw enqueueThrows;
|
|
139
|
+
return enqueueResult || { intent: { id: 9, action: 'settings-apply', state: 'pending' }, created: true };
|
|
140
|
+
};
|
|
141
|
+
provisioning.recordEvent = async (_db, e) => { events.push(e); };
|
|
142
|
+
provisioning.getOpenIntent = async () => null;
|
|
143
|
+
provisioning.getLatestOAuthManifestForInstance = async () => null;
|
|
144
|
+
|
|
145
|
+
const api = require('../src/module-api.js');
|
|
146
|
+
let builder = { id: 7, github_login: 'owner', rank: 'thetes' };
|
|
147
|
+
api.requireBuilder = (req, _res, next) => { req.builder = builder; next(); };
|
|
148
|
+
api.requirePermission = () => (_req, _res, next) => next();
|
|
149
|
+
api.branding = () => ({ domains: { publicOrigin: 'https://cb.test' }, identity: { productName: 'Cloud Bongos' } });
|
|
150
|
+
api.logger = () => ({ error() {}, warn() {}, info() {}, debug() {} });
|
|
151
|
+
|
|
152
|
+
const express = require('express');
|
|
153
|
+
const provisioningRoutes = require('../modules/provisioning/routes/provisioning.js');
|
|
154
|
+
const app = express();
|
|
155
|
+
app.use(express.json());
|
|
156
|
+
app.use((_req, res, next) => {
|
|
157
|
+
res.fail = (code, opts, details) => {
|
|
158
|
+
const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
|
|
159
|
+
res.status(status).json({ error: { code, ...(typeof opts === 'object' ? opts : {}), ...(details || {}) } });
|
|
160
|
+
};
|
|
161
|
+
next();
|
|
162
|
+
});
|
|
163
|
+
app.use('/api/bongos', provisioningRoutes());
|
|
164
|
+
|
|
165
|
+
const server = app.listen(0);
|
|
166
|
+
await new Promise((r) => server.once('listening', r));
|
|
167
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
168
|
+
|
|
169
|
+
function patch(path, body) {
|
|
170
|
+
return new Promise((resolve, reject) => {
|
|
171
|
+
const payload = JSON.stringify(body);
|
|
172
|
+
const req = http.request(`${base}${path}`, {
|
|
173
|
+
method: 'PATCH',
|
|
174
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
|
|
175
|
+
}, (res) => {
|
|
176
|
+
let d = '';
|
|
177
|
+
res.on('data', (c) => { d += c; });
|
|
178
|
+
res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
|
|
179
|
+
});
|
|
180
|
+
req.on('error', reject);
|
|
181
|
+
req.end(payload);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const ACTIVE = { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'standalone', domain: 'spike.cloudbongos.com', settings: {} };
|
|
186
|
+
function reset({ inst, as } = {}) {
|
|
187
|
+
instance = inst !== undefined ? inst : { ...ACTIVE };
|
|
188
|
+
builder = as || { id: 7, github_login: 'owner', rank: 'thetes' };
|
|
189
|
+
updateCalls = [];
|
|
190
|
+
enqueueCalls = [];
|
|
191
|
+
enqueueResult = null;
|
|
192
|
+
enqueueThrows = null;
|
|
193
|
+
events = [];
|
|
194
|
+
}
|
|
195
|
+
// every receipt carries the same four keys, and never a duration promise
|
|
196
|
+
function receiptShape(push, { queued, restart, reachable }) {
|
|
197
|
+
assert.deepEqual(Object.keys(push).sort(), ['message', 'queued', 'reachable', 'restart'], 'one receipt shape on every branch');
|
|
198
|
+
assert.equal(push.queued, queued, 'queued');
|
|
199
|
+
assert.equal(push.restart, restart, 'restart');
|
|
200
|
+
assert.equal(push.reachable, reachable, 'reachable');
|
|
201
|
+
assert.doesNotMatch(push.message, PROMISE, `no wall-clock/duration promise: ${push.message}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
console.log('\nPATCH /provisioning/instances/:id/settings — the enqueue:');
|
|
205
|
+
|
|
206
|
+
await ta('ACTIVE standalone: the row is written FIRST, then a settings-apply intent queues; the receipt says a restart is coming', async () => {
|
|
207
|
+
reset();
|
|
208
|
+
const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated' });
|
|
209
|
+
assert.equal(r.status, 200, r.raw);
|
|
210
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'apply' }, 'the SAVED (requested) value — never `settings`, which a form would render as live');
|
|
211
|
+
assert.equal(r.body.settings, undefined);
|
|
212
|
+
assert.deepEqual(updateCalls.map((c) => c.patch), [{ platform_visibility: 'gated' }]);
|
|
213
|
+
assert.equal(updateCalls[0].enqueuedSoFar, 0, 'the runner reads the row — the write must precede the intent');
|
|
214
|
+
assert.deepEqual(enqueueCalls, [{ id: 18, requestedBy: 'api:self' }]);
|
|
215
|
+
receiptShape(r.body.push, { queued: true, restart: true, reachable: true });
|
|
216
|
+
assert.match(r.body.push.message, /restart/i, 'the surface SAYS a restart happens (owner decision 2026-08-19)');
|
|
217
|
+
assert.match(r.body.push.message, /keeps its current setting/i, 'a receipt, not a claim the value is live');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await ta('ACTIVE co-tenant queues too; the Archon override names WHICH archon on the event and the intent', async () => {
|
|
221
|
+
reset({ inst: { ...ACTIVE, hosting_shape: 'co-tenant', owner_builder_id: 7 }, as: { id: 999, github_login: 'archon', rank: 'archon' } });
|
|
222
|
+
const r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
|
|
223
|
+
assert.equal(r.status, 200, r.raw);
|
|
224
|
+
assert.equal(r.body.push.queued, true);
|
|
225
|
+
assert.deepEqual(enqueueCalls, [{ id: 18, requestedBy: 'api:archon:999' }], 'the force-teardown convention: api:archon:<id>');
|
|
226
|
+
assert.equal(events[0].actor, 'api:archon:999');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
await ta('an identical PENDING push already open → 200, queued:false, "already queued … will carry this change"', async () => {
|
|
230
|
+
reset();
|
|
231
|
+
enqueueResult = { intent: { id: 5, action: 'settings-apply', state: 'pending' }, created: false };
|
|
232
|
+
const r = await patch('/provisioning/instances/18/settings', { joinability: 'invite_only' });
|
|
233
|
+
assert.equal(r.status, 200, r.raw);
|
|
234
|
+
receiptShape(r.body.push, { queued: false, restart: true, reachable: true });
|
|
235
|
+
assert.match(r.body.push.message, /already queued/i);
|
|
236
|
+
assert.equal(updateCalls.length, 1, 'the value is still saved');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await ta('the open slot held by OTHER work → 409 intent_conflict with recorded:true (the change IS saved)', async () => {
|
|
240
|
+
for (const open of [{ action: 'teardown', state: 'pending' }, { action: 'settings-apply', state: 'running' }]) {
|
|
241
|
+
reset();
|
|
242
|
+
enqueueResult = { intent: { id: 5, ...open }, created: false, conflict: 'open_intent' };
|
|
243
|
+
const r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
|
|
244
|
+
assert.equal(r.status, 409, `${open.action}: ${r.raw}`);
|
|
245
|
+
assert.equal(r.body.error.code, 'intent_conflict');
|
|
246
|
+
assert.equal(r.body.error.details.action, open.action, 'names the real blocker');
|
|
247
|
+
assert.equal(r.body.error.details.state, open.state);
|
|
248
|
+
assert.equal(r.body.error.details.recorded, true, 'the row was written before the slot was contested');
|
|
249
|
+
assert.match(r.body.error.message, /saved/i);
|
|
250
|
+
assert.equal(updateCalls.length, 1, `${open.action}: stored`);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
await ta('an enqueue that THROWS still answers 200 "saved" — never a 500 that denies a durable write', async () => {
|
|
255
|
+
reset();
|
|
256
|
+
enqueueThrows = new Error('db down');
|
|
257
|
+
const r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
|
|
258
|
+
assert.equal(r.status, 200, r.raw);
|
|
259
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open' });
|
|
260
|
+
assert.equal(updateCalls.length, 1, 'the write landed');
|
|
261
|
+
receiptShape(r.body.push, { queued: false, restart: true, reachable: true });
|
|
262
|
+
assert.match(r.body.push.message, /Saved, but the restart could not be queued/);
|
|
263
|
+
assert.match(r.body.push.message, /send it again/i, 'tells the owner the recovery');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
console.log('\na save that changes nothing:');
|
|
267
|
+
|
|
268
|
+
await ta('same values as already stored → no write, no event, no intent, "No change"', async () => {
|
|
269
|
+
reset({ inst: { ...ACTIVE, settings: { platform_visibility: 'gated', joinability: 'open' } } });
|
|
270
|
+
const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated', joinability: 'open' });
|
|
271
|
+
assert.equal(r.status, 200, r.raw);
|
|
272
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open' });
|
|
273
|
+
assert.equal(updateCalls.length, 0, 'nothing written (no updated_at reshuffle)');
|
|
274
|
+
assert.equal(events.length, 0, 'no "settings changed" event for a change that is not one');
|
|
275
|
+
assert.equal(enqueueCalls.length, 0, 'a whole-form save must not bounce a live project');
|
|
276
|
+
receiptShape(r.body.push, { queued: false, restart: false, reachable: true });
|
|
277
|
+
assert.match(r.body.push.message, /No change/);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
await ta('the default sent explicitly on an empty row is also "no change" (effective, not stored, is what counts)', async () => {
|
|
281
|
+
reset();
|
|
282
|
+
const r = await patch('/provisioning/instances/18/settings', { joinability: 'apply' });
|
|
283
|
+
assert.equal(r.status, 200, r.raw);
|
|
284
|
+
assert.equal(updateCalls.length, 0);
|
|
285
|
+
assert.equal(enqueueCalls.length, 0);
|
|
286
|
+
assert.match(r.body.push.message, /No change/);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
await ta('the owner re-kick: same values re-sent while a retired push left its error_note → the push re-queues, nothing re-written (F19 shape)', async () => {
|
|
290
|
+
reset({ inst: { ...ACTIVE, settings: { platform_visibility: 'gated' }, error_note: 'could not apply the settings on spike: …' } });
|
|
291
|
+
const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated' });
|
|
292
|
+
assert.equal(r.status, 200, r.raw);
|
|
293
|
+
assert.equal(updateCalls.length, 0, 'the value is unchanged');
|
|
294
|
+
assert.equal(events.length, 0);
|
|
295
|
+
assert.deepEqual(enqueueCalls.map((c) => c.id), [18], 'the push is re-kicked');
|
|
296
|
+
receiptShape(r.body.push, { queued: true, restart: true, reachable: true });
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
console.log('\nthe state gate — store, queue nothing, say when it takes effect:');
|
|
300
|
+
|
|
301
|
+
await ta('requested / provisioning: stored, NO intent (standup writes the same values), message says so', async () => {
|
|
302
|
+
for (const status of ['requested', 'provisioning']) {
|
|
303
|
+
reset({ inst: { ...ACTIVE, status } });
|
|
304
|
+
const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated' });
|
|
305
|
+
assert.equal(r.status, 200, `${status}: ${r.raw}`);
|
|
306
|
+
assert.equal(updateCalls.length, 1, `${status}: stored`);
|
|
307
|
+
assert.equal(enqueueCalls.length, 0, `${status}: no push`);
|
|
308
|
+
receiptShape(r.body.push, { queued: false, restart: false, reachable: true });
|
|
309
|
+
assert.match(r.body.push.message, /standing up/i, status);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
await ta('error (a snagged standup): stored, NO intent, and the message does not promise a standup that is not happening', async () => {
|
|
314
|
+
reset({ inst: { ...ACTIVE, status: 'error', error_note: 'DNS preflight failed' } });
|
|
315
|
+
const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated' });
|
|
316
|
+
assert.equal(r.status, 200, r.raw);
|
|
317
|
+
assert.equal(updateCalls.length, 1);
|
|
318
|
+
assert.equal(enqueueCalls.length, 0);
|
|
319
|
+
receiptShape(r.body.push, { queued: false, restart: false, reachable: true });
|
|
320
|
+
assert.match(r.body.push.message, /stopped partway/i);
|
|
321
|
+
assert.match(r.body.push.message, /next time the project is stood up/i);
|
|
322
|
+
assert.doesNotMatch(r.body.push.message, /finishes standing up/i, 'nothing is finishing — the card says "we stopped trying"');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
await ta('torn_down / tearing_down: stored, NO intent (nothing to restart), message says the project is offline', async () => {
|
|
326
|
+
for (const status of ['torn_down', 'tearing_down']) {
|
|
327
|
+
reset({ inst: { ...ACTIVE, status } });
|
|
328
|
+
const r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
|
|
329
|
+
assert.equal(r.status, 200, `${status}: ${r.raw}`);
|
|
330
|
+
assert.equal(updateCalls.length, 1);
|
|
331
|
+
assert.equal(enqueueCalls.length, 0, `${status}: no push`);
|
|
332
|
+
receiptShape(r.body.push, { queued: false, restart: false, reachable: true });
|
|
333
|
+
assert.match(r.body.push.message, /offline/i, status);
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
console.log('\nthe shape refusal — a dedicated droplet is refused honestly:');
|
|
338
|
+
|
|
339
|
+
await ta('ACTIVE dedicated: stored, NO intent, reachable:false, restart:true (a restart IS what applies it — just not by us), and the copy names the real difference', async () => {
|
|
340
|
+
reset({ inst: { ...ACTIVE, hosting_shape: 'dedicated' } });
|
|
341
|
+
const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated' });
|
|
342
|
+
assert.equal(r.status, 200, r.raw);
|
|
343
|
+
assert.equal(updateCalls.length, 1, 'the platform record is still the truth');
|
|
344
|
+
assert.equal(enqueueCalls.length, 0, 'never queue a push that cannot land');
|
|
345
|
+
receiptShape(r.body.push, { queued: false, restart: true, reachable: false });
|
|
346
|
+
assert.match(r.body.push.message, /doesn.t manage/i, 'what differs: the platform does not manage that machine');
|
|
347
|
+
assert.match(r.body.push.message, /can.t be restarted from here/i);
|
|
348
|
+
assert.doesNotMatch(r.body.push.message, /own server/i, '"its own server" is the hub\'s label for STANDALONE too — which is pushable');
|
|
349
|
+
assert.doesNotMatch(r.body.push.message, /web\.env/, 'no file the owner has never seen');
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
console.log('\nthe walls that did not move:');
|
|
353
|
+
|
|
354
|
+
await ta("another builder's PATCH → 404; nothing stored, nothing queued", async () => {
|
|
355
|
+
reset({ as: { id: 8, github_login: 'stranger', rank: 'thetes' } });
|
|
356
|
+
const r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
|
|
357
|
+
assert.equal(r.status, 404, r.raw);
|
|
358
|
+
assert.equal(updateCalls.length, 0);
|
|
359
|
+
assert.equal(enqueueCalls.length, 0);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
await ta('an off-vocabulary value → 400 before anything is written or queued', async () => {
|
|
363
|
+
reset();
|
|
364
|
+
const r = await patch('/provisioning/instances/18/settings', { joinability: 'whenever' });
|
|
365
|
+
assert.equal(r.status, 400, r.raw);
|
|
366
|
+
assert.equal(updateCalls.length, 0);
|
|
367
|
+
assert.equal(enqueueCalls.length, 0);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
server.close();
|
|
371
|
+
console.log(`\nprovisioning_settings_apply: ${passed} passed, ${failed} failed`);
|
|
372
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -12,11 +12,11 @@
|
|
|
12
12
|
// branding pack, so the runner now writes them there at standup and src/branding.js
|
|
13
13
|
// resolves them onto project.*.
|
|
14
14
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// pins that the generated file
|
|
15
|
+
// The LIVE-PUSH leg — a CHANGED setting reaching an already-running instance via
|
|
16
|
+
// the 'settings-apply' intent (task 1003140) — is covered by
|
|
17
|
+
// tests/provisioning_settings_apply.mjs (route + module) and
|
|
18
|
+
// tests/provision_settings_apply.mjs (runner). These tests assert the standup leg,
|
|
19
|
+
// and one of them pins that the generated file names the push truthfully.
|
|
20
20
|
//
|
|
21
21
|
// DB-free and hermetic: pure builders + a child process with env set. No network.
|
|
22
22
|
//
|
|
@@ -79,11 +79,20 @@ t('every emitted key is in SETTINGS_VOCAB — the two lists cannot drift', () =>
|
|
|
79
79
|
|
|
80
80
|
console.log('\nThe generated file tells the truth about what it is:');
|
|
81
81
|
|
|
82
|
-
t('it
|
|
82
|
+
t('it names the live push by the intent the runner actually dispatches (task 1003140)', () => {
|
|
83
83
|
const s = provisioning.settingsEnvLines({}, 'X');
|
|
84
|
-
assert.
|
|
85
|
-
|
|
86
|
-
assert.match(s, /
|
|
84
|
+
assert.match(s, /settings-apply/, 'the comment may name the intent only because INTENT_ACTIONS carries it');
|
|
85
|
+
assert.ok(provisioning.INTENT_ACTIONS.includes('settings-apply'), 'lockstep: the named action is dispatchable');
|
|
86
|
+
assert.match(s, /restarts this service/i, 'it says plainly that applying a change restarts the instance');
|
|
87
|
+
assert.doesNotMatch(s, /not built yet|next provision run/i, 'the old "not built" disclaimer is gone with the gap');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
t('settingsEnvVars is the single source both legs derive from — same keys, same values as the lines', () => {
|
|
91
|
+
const row = { settings: { platform_visibility: 'gated', joinability: 'open' } };
|
|
92
|
+
const vars = provisioning.settingsEnvVars(row, 'CLOUDBONGOS');
|
|
93
|
+
assert.deepEqual(vars, { CLOUDBONGOS_PLATFORM_VISIBILITY: 'gated', CLOUDBONGOS_JOINABILITY: 'open' });
|
|
94
|
+
assert.deepEqual(varsOf(provisioning.settingsEnvLines(row, 'CLOUDBONGOS')), vars, 'the standup lines ARE the vars');
|
|
95
|
+
assert.deepEqual(provisioning.settingsEnvVars({}, 'X'), { X_PLATFORM_VISIBILITY: 'public', X_JOINABILITY: 'apply' }, 'defaults, explicitly');
|
|
87
96
|
});
|
|
88
97
|
|
|
89
98
|
console.log('\nwebEnvBody — the settings ride the real per-instance env file:');
|
|
@@ -106,6 +115,14 @@ t('the neutral pack ships today\'s behavior, so an un-pushed instance is unchang
|
|
|
106
115
|
assert.equal(pack.project.joinability, 'apply');
|
|
107
116
|
});
|
|
108
117
|
|
|
118
|
+
t('clientBranding\'s project fallbacks are DEFAULT_SETTINGS, key for key — the read-back cannot drift from the platform default (task 1003140)', () => {
|
|
119
|
+
const { clientBranding } = require('../src/branding.js');
|
|
120
|
+
const published = clientBranding({}).project;
|
|
121
|
+
const camel = (k) => k.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
122
|
+
for (const [k, v] of Object.entries(provisioning.DEFAULT_SETTINGS)) assert.equal(published[camel(k)], v, k);
|
|
123
|
+
assert.equal(Object.keys(published).length, Object.keys(provisioning.DEFAULT_SETTINGS).length, 'same key set');
|
|
124
|
+
});
|
|
125
|
+
|
|
109
126
|
t('the env override resolves onto project.* in a real process', () => {
|
|
110
127
|
const read = (env) => JSON.parse(execFileSync(process.execPath, ['-e',
|
|
111
128
|
"const b=require('./src/branding.js');const r=(b.branding||b)();console.log(JSON.stringify({v:r.project.platformVisibility,j:r.project.joinability}))"],
|
|
@@ -65,11 +65,12 @@ function fakeIntentDb(seed = []) {
|
|
|
65
65
|
}
|
|
66
66
|
if (/UPDATE provisioning_intents/.test(sql) && /state = 'error'/.test(sql)) {
|
|
67
67
|
// The supersede's guard must live in the SQL itself: pending-only (a running
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
|
|
68
|
+
// intent is the runner's mid-flight work) and provision-or-settings-push only
|
|
69
|
+
// (task 1003140 widened it: a queued push into a project about to be torn
|
|
70
|
+
// down is dead work; an open teardown is left alone for the idempotent path).
|
|
71
|
+
assert.match(sql, /action IN \('provision', 'settings-apply'\) AND state = 'pending'/);
|
|
71
72
|
const hit = rows.filter((r) =>
|
|
72
|
-
String(r.instance_id) === String(params[0]) && r.action === 'provision' && r.state === 'pending');
|
|
73
|
+
String(r.instance_id) === String(params[0]) && (r.action === 'provision' || r.action === 'settings-apply') && r.state === 'pending');
|
|
73
74
|
for (const r of hit) { r.state = 'error'; r.last_error = 'superseded: teardown requested'; }
|
|
74
75
|
return { rows: hit.map((r) => ({ id: r.id })) };
|
|
75
76
|
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// tests/search_index_poller.mjs — the recall index keeps itself, and says so
|
|
2
|
+
// (task 1003162).
|
|
3
|
+
//
|
|
4
|
+
// The outage this guards: /recall returned `index_empty` on live for words that
|
|
5
|
+
// demonstrably existed in the corpus, because NOTHING ran the ingesters. ADR 0060
|
|
6
|
+
// claims a deploy hook and a nightly timer; both are box-side, and neither could be
|
|
7
|
+
// shown to have ever run.
|
|
8
|
+
//
|
|
9
|
+
// Everything here is pure or dependency-injected — no database, no child process,
|
|
10
|
+
// no clock. The sweep is driven with a fake pool and a fake execFile so the
|
|
11
|
+
// DECISIONS are tested, not the plumbing.
|
|
12
|
+
//
|
|
13
|
+
// Run: node tests/search_index_poller.mjs
|
|
14
|
+
|
|
15
|
+
import { strict as assert } from 'node:assert';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const poller = require('../modules/memory/pollers/search-index.js');
|
|
20
|
+
const { describeIndex, describeIngest, sweep } = poller;
|
|
21
|
+
|
|
22
|
+
let passed = 0;
|
|
23
|
+
let failed = 0;
|
|
24
|
+
async function test(name, fn) {
|
|
25
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
26
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const NOW = Date.parse('2026-08-25T12:00:00Z');
|
|
30
|
+
const daysAgo = (d) => new Date(NOW - d * 86400000).toISOString();
|
|
31
|
+
const quietLog = { log() {}, error() {} };
|
|
32
|
+
const okExec = (_p, _a, _o, cb) => cb(null, '', '');
|
|
33
|
+
|
|
34
|
+
// --- READING THE INDEX ------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
await test('an EMPTY index is an alarm, and says recall returns nothing AT ANY RANK', async () => {
|
|
37
|
+
const d = describeIndex({ chunks: 0, now: NOW });
|
|
38
|
+
assert.equal(d.level, 'alarm');
|
|
39
|
+
assert.equal(d.state, 'empty');
|
|
40
|
+
// Rank-independence is the whole reason `index_empty` is diagnostic: it rules
|
|
41
|
+
// out "nothing visible to YOU", which is what made the live report trustworthy.
|
|
42
|
+
assert.match(d.message, /any rank/i);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
await test('an UNREADABLE count is an alarm — unknown is not healthy', async () => {
|
|
46
|
+
for (const bad of [NaN, undefined, null, -1, 'lots']) {
|
|
47
|
+
const d = describeIndex({ chunks: bad, now: NOW });
|
|
48
|
+
assert.equal(d.level, 'alarm', `${bad} must not read as healthy`);
|
|
49
|
+
assert.equal(d.state, 'unreadable');
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
await test('a populated, fresh index is ok and says how much it holds', async () => {
|
|
54
|
+
const d = describeIndex({ chunks: 4812, lastIngestedAt: daysAgo(1), now: NOW });
|
|
55
|
+
assert.equal(d.level, 'ok');
|
|
56
|
+
assert.match(d.message, /4812/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await test('a populated but WEEK-OLD index warns — the sweep may have stopped', async () => {
|
|
60
|
+
const d = describeIndex({ chunks: 4812, lastIngestedAt: daysAgo(9), now: NOW });
|
|
61
|
+
assert.equal(d.level, 'warn');
|
|
62
|
+
assert.equal(d.state, 'stale');
|
|
63
|
+
assert.equal(d.age_days, 9);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await test('staleness is not guessed when nothing says when it last ran', async () => {
|
|
67
|
+
const d = describeIndex({ chunks: 10, lastIngestedAt: null, now: NOW });
|
|
68
|
+
assert.equal(d.level, 'ok', 'an unknown age must not be reported as stale');
|
|
69
|
+
assert.equal(d.age_days, null);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// --- READING THE INGESTERS' EXIT CODES --------------------------------------
|
|
73
|
+
|
|
74
|
+
await test('a NON-ZERO exit is an alarm carrying the TAIL of stderr', async () => {
|
|
75
|
+
// Task 1002859 made a partial ingest exit non-zero and NAME every skipped
|
|
76
|
+
// source — at the end of its output. A head-clip would drop exactly that.
|
|
77
|
+
const d = describeIngest({
|
|
78
|
+
name: 'search-ingest-md', code: 1,
|
|
79
|
+
stderr: ['noise', 'noise', 'noise', 'noise', 'noise', 'noise', 'skipped: docs/adr/0060.md (NUL byte)'].join('\n'),
|
|
80
|
+
});
|
|
81
|
+
assert.equal(d.ok, false);
|
|
82
|
+
assert.equal(d.level, 'alarm');
|
|
83
|
+
assert.match(d.message, /skipped: docs\/adr\/0060\.md/, 'the last line is the one that matters');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await test('a KILLED ingester is reported as killed, not as a clean failure', async () => {
|
|
87
|
+
const d = describeIngest({ name: 'search-ingest-db', code: null, signal: 'SIGTERM' });
|
|
88
|
+
assert.equal(d.ok, false);
|
|
89
|
+
assert.match(d.message, /KILLED by SIGTERM/);
|
|
90
|
+
assert.match(d.message, /partially written/, 'a half-written index is the real risk');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
await test('exit 0 is ok', async () => {
|
|
94
|
+
assert.equal(describeIngest({ name: 'x', code: 0 }).ok, true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// --- THE SWEEP --------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
function fakePool(seq) {
|
|
100
|
+
let i = 0;
|
|
101
|
+
return { query: async () => ({ rows: [seq[Math.min(i++, seq.length - 1)]] }) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
await test('the sweep runs BOTH ingesters — one failing must not skip the other', async () => {
|
|
105
|
+
const called = [];
|
|
106
|
+
const exec = (_p, args, _o, cb) => {
|
|
107
|
+
called.push(args[0].split('/').pop());
|
|
108
|
+
cb(args[0].includes('md') ? Object.assign(new Error('x'), { code: 1 }) : null, '', 'boom');
|
|
109
|
+
};
|
|
110
|
+
const r = await sweep({ pool: fakePool([{ chunks: 0, last: null }]), log: quietLog, execFileImpl: exec, now: NOW });
|
|
111
|
+
assert.deepEqual(called, ['search-ingest-md.js', 'search-ingest-db.js']);
|
|
112
|
+
assert.equal(r.results.length, 2);
|
|
113
|
+
assert.equal(r.results[0].ok, false);
|
|
114
|
+
assert.equal(r.results[1].ok, true, 'the db ingest still ran after the md ingest failed');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await test('the sweep reports the AFTER count — exit 0 that wrote nothing is a failure shape', async () => {
|
|
118
|
+
// The precise trap: an ingester can exit 0 and leave the table empty. Without
|
|
119
|
+
// an after-picture that reads as success.
|
|
120
|
+
const r = await sweep({
|
|
121
|
+
pool: fakePool([{ chunks: 0, last: null }, { chunks: 0, last: null }]),
|
|
122
|
+
log: quietLog, execFileImpl: okExec, now: NOW,
|
|
123
|
+
});
|
|
124
|
+
assert.equal(r.after.state, 'empty', 'still empty after a "successful" ingest');
|
|
125
|
+
assert.equal(r.after.level, 'alarm');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await test('a healthy sweep still SPEAKS — silence must not be the healthy state', async () => {
|
|
129
|
+
// The reconciler's lesson: a sweep that only logs on failure is
|
|
130
|
+
// indistinguishable from a sweep that is not running at all.
|
|
131
|
+
const lines = [];
|
|
132
|
+
await sweep({
|
|
133
|
+
pool: fakePool([{ chunks: 4812, last: daysAgo(1) }]),
|
|
134
|
+
log: { log: (m) => lines.push(m), error: (m) => lines.push(m) },
|
|
135
|
+
execFileImpl: okExec, now: NOW,
|
|
136
|
+
});
|
|
137
|
+
assert.ok(lines.length >= 2, 'a healthy sweep leaves a disposition line');
|
|
138
|
+
assert.ok(lines.some((l) => /4812/.test(l)), 'and says what it found');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await test('a DB read failure never throws out of the sweep', async () => {
|
|
142
|
+
const r = await sweep({
|
|
143
|
+
pool: { query: async () => { throw new Error('connection refused'); } },
|
|
144
|
+
log: quietLog, execFileImpl: okExec, now: NOW,
|
|
145
|
+
});
|
|
146
|
+
assert.equal(r.health.state, 'unreadable');
|
|
147
|
+
assert.equal(r.health.level, 'alarm');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await test('the poller is declared in the memory manifest — a file nothing loads is dormant', async () => {
|
|
151
|
+
// The exact failure goal 1000076 hit five times: a surface that exists, is
|
|
152
|
+
// documented, and is never wired.
|
|
153
|
+
const manifest = require('../modules/memory/module.json');
|
|
154
|
+
assert.ok(
|
|
155
|
+
(manifest.contributes.pollers || []).includes('search-index'),
|
|
156
|
+
'modules/memory/module.json must declare the poller for the loader to start it',
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
console.log(`\nsearch_index_poller: ${passed} passed, ${failed} failed`);
|
|
161
|
+
process.exit(failed ? 1 : 0);
|