@bongos/core 1.19.354 → 1.19.356
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 +48 -28
- 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/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/bongos/serve-internal.js +16 -2
- 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/landing_page.mjs +18 -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
|
@@ -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
|
}
|