@bongos/core 1.25.16 → 1.25.17

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.
Files changed (45) hide show
  1. package/.bongos-core.json +95 -40
  2. package/bin/bongos.js +1 -0
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +14 -0
  5. package/clients/bongos-client/index.cjs +14 -0
  6. package/clients/bongos-client/index.d.ts +22 -2
  7. package/clients/bongos-client/index.mjs +14 -0
  8. package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +8 -0
  9. package/docs/api/openapi.json +415 -3
  10. package/docs/api-reference.md +13 -2
  11. package/docs/module-api-changelog.md +2 -0
  12. package/docs/page-readings.json +1 -1
  13. package/modules/government/decision-rules.js +1 -0
  14. package/modules/lifecycle/first-read-db.js +152 -0
  15. package/modules/lifecycle/first-read.js +265 -0
  16. package/modules/lifecycle/kickoff-checklist.js +12 -3
  17. package/modules/lifecycle/migrations/lifecycle_022_repo_maps.sql +44 -0
  18. package/modules/lifecycle/module.json +2 -1
  19. package/modules/lifecycle/routes/first-read.js +79 -0
  20. package/modules/provisioning/pollers/app-liveness.js +23 -1
  21. package/modules/provisioning/render-sandbox.js +113 -0
  22. package/modules/provisioning/render-standup.js +3 -1
  23. package/modules/provisioning/routes/provisioning.js +4 -1
  24. package/modules/provisioning/routes/render-standup.js +49 -1
  25. package/modules/public-landing/public/projects.html +101 -4
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/release-notes.json +10 -0
  29. package/scripts/bongos/first-read.js +116 -0
  30. package/scripts/bongos/government-walk.js +4 -4
  31. package/scripts/bongos/provision-render-sandbox.js +171 -0
  32. package/scripts/bongos/provision-render.js +37 -2
  33. package/scripts/bongos/provision-teardown.js +1 -1
  34. package/scripts/bongos/render-api.js +33 -3
  35. package/scripts/bongos/render-payload.js +39 -1
  36. package/scripts/gds/first-read.js +7 -0
  37. package/scripts/gds/provision-render-sandbox.js +6 -0
  38. package/src/module-api.js +1 -1
  39. package/tests/first_read.mjs +303 -0
  40. package/tests/government_act_alone_buildings.mjs +2 -0
  41. package/tests/provisioning_app_status.mjs +3 -2
  42. package/tests/provisioning_render_route.mjs +1 -1
  43. package/tests/provisioning_teardown_intent.mjs +50 -0
  44. package/tests/render_api.mjs +5 -2
  45. package/tests/render_sandbox.mjs +447 -0
@@ -0,0 +1,447 @@
1
+ // tests/render_sandbox.mjs — every project on Render gets a sandbox (task 1005289; ADR 0406).
2
+ //
3
+ // The runner legs (scripts/bongos/provision-render.js + provision-render-sandbox.js) are
4
+ // driven over the REAL Render client (render-api.js) and a recording fake of Render's API,
5
+ // so the workspace guard and the sandbox-only delete are exercised end to end. The SQL that
6
+ // makes the sandbox's database is a recording fake too. No network, no database, no spend.
7
+ //
8
+ // The task's DONE WHEN, case by case:
9
+ // • create-with-sandbox — a new app gets a free `<slug>-sandbox` service following main,
10
+ // with BONGOS_SANDBOX=1 and its OWN database inside the app's paid server; the live
11
+ // service is never written to after it is made
12
+ // • add-to-existing — an app already up adds a sandbox with one action and the live app
13
+ // is not touched (no create, no write)
14
+ // • status read — the sandbox's address and state beside the app's, in its shape
15
+ // • teardown — removes the sandbox service and database, never the live app; without a
16
+ // key it is forgotten and the owner is told what to remove
17
+ // • no app — a project with no app gets no sandbox and says so plainly
18
+ // Plus: the database stage is separate — when it fails the sandbox still runs, with no
19
+ // database, and nothing paid is bought in its place.
20
+ //
21
+ // Run: node tests/render_sandbox.mjs
22
+
23
+ import { strict as assert } from 'node:assert';
24
+ import { createRequire } from 'node:module';
25
+ import http from 'node:http';
26
+
27
+ const require = createRequire(import.meta.url);
28
+ const { RenderApi } = require('../scripts/bongos/render-api.js');
29
+ const { renderStandupInstance, forgetRenderApp } = require('../scripts/bongos/provision-render.js');
30
+ const payload = require('../scripts/bongos/render-payload.js');
31
+ const renderSandbox = require('../modules/provisioning/render-sandbox.js');
32
+
33
+ let passed = 0, failed = 0;
34
+ async function ta(name, fn) {
35
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
36
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
37
+ }
38
+
39
+ const OURS = 'tea-ours00000001';
40
+ const LIVE_URL = 'https://demo.onrender.com';
41
+ const SB_URL = 'https://demo-sandbox.onrender.com';
42
+ const SB_NAME_Q = `GET /services?name=demo-sandbox&ownerId=${OURS}&limit=20`;
43
+
44
+ // A fake Render account. `over` replaces any answer; a function answer sees the body.
45
+ function fakeRender(over = {}) {
46
+ const calls = [];
47
+ const answers = {
48
+ 'GET /owners?limit=100': [{ owner: { id: OURS } }],
49
+ 'POST /postgres': { id: 'dpg-new', status: 'creating', plan: 'basic_256mb', owner: { id: OURS } },
50
+ 'GET /postgres/dpg-new': { id: 'dpg-new', status: 'available', plan: 'basic_256mb', owner: { id: OURS } },
51
+ 'GET /postgres/dpg-new/connection-info': {
52
+ internalConnectionString: 'postgresql://u:p@dpg-new-a/demo_db',
53
+ externalConnectionString: 'postgresql://u:p@dpg-new-a.virginia-postgres.render.com/demo_db',
54
+ },
55
+ 'POST /services': (b) => (b.name === 'demo-sandbox'
56
+ ? { deployId: 'dep-s', service: { id: 'srv-sbx00001', name: 'demo-sandbox', ownerId: OURS, serviceDetails: { url: SB_URL, plan: 'free' } } }
57
+ : { deployId: 'dep-1', service: { id: 'srv-live0001', name: 'demo', ownerId: OURS, serviceDetails: { url: LIVE_URL, plan: 'starter' } } }),
58
+ 'GET /services/srv-live0001': { id: 'srv-live0001', name: 'demo', ownerId: OURS, repo: 'https://github.com/example-owner/demo',
59
+ serviceDetails: { url: LIVE_URL, plan: 'starter', runtime: 'python', region: 'oregon', envSpecificDetails: { buildCommand: 'pip install -r requirements.txt', startCommand: 'gunicorn app:app' } } },
60
+ 'GET /services/srv-sbx00001': { id: 'srv-sbx00001', name: 'demo-sandbox', ownerId: OURS, serviceDetails: { url: SB_URL, plan: 'free' } },
61
+ 'DELETE /services/srv-sbx00001': null,
62
+ [SB_NAME_Q]: [],
63
+ ...over,
64
+ };
65
+ const fetchImpl = async (url, init) => {
66
+ const path = url.replace('https://api.render.com/v1', '');
67
+ const body = init.body ? JSON.parse(init.body) : null;
68
+ calls.push({ method: init.method, path, body });
69
+ const key = `${init.method} ${path}`;
70
+ const a0 = answers[key];
71
+ const a = typeof a0 === 'function' ? a0(body) : a0;
72
+ if (a === undefined) return { ok: false, status: 404, text: async () => '{"message":"not found"}' };
73
+ if (a instanceof Error) return { ok: false, status: 500, text: async () => JSON.stringify({ message: a.message }) };
74
+ if (a === null) return { ok: true, status: 204, text: async () => '' };
75
+ return { ok: true, status: 200, text: async () => JSON.stringify(a) };
76
+ };
77
+ return { calls, fetchImpl };
78
+ }
79
+
80
+ // The row, kept in memory: the two app_host writes the legs make (the app's merge and the
81
+ // sandbox's nested merge) are applied the way Postgres would.
82
+ function harness({ host = {}, render = {}, sql = null, intentKey = 'v1.enc', teardownKey = null } = {}) {
83
+ const r = fakeRender(render);
84
+ const logs = [], sqlRan = [], intentWrites = [];
85
+ const inst = { id: 18, slug: 'demo', owner_builder_id: 7, target_ref: 'example-owner/demo', status: 'active',
86
+ app_host: { platform: 'render', mode: 'greenfield', ownerId: OURS, runtime: 'node', buildCommand: 'npm ci', startCommand: 'npm start', database: true, sandboxWanted: true, ...host } };
87
+ const db = {
88
+ query: async (text, params) => {
89
+ const t = text.replace(/\s+/g, ' ');
90
+ if (/jsonb_build_object\('sandbox'/.test(t)) {
91
+ const patch = JSON.parse(params[1]);
92
+ inst.app_host = { ...inst.app_host, sandbox: { ...(inst.app_host.sandbox || {}), ...patch } };
93
+ return { rows: [] };
94
+ }
95
+ if (/SET app_host = COALESCE\(app_host, '\{\}'::jsonb\) \|\| \$2::jsonb/.test(t)) {
96
+ inst.app_host = { ...inst.app_host, ...JSON.parse(params[1]) };
97
+ return { rows: [] };
98
+ }
99
+ if (/FROM provisioning_intents/.test(t) && /action = 'teardown'/.test(t)) return { rows: teardownKey ? [{ id: 77, borrowed_key_enc: teardownKey }] : [] };
100
+ if (/UPDATE provisioning_intents SET borrowed_key_enc = NULL/.test(t)) { intentWrites.push(params[0]); return { rows: [] }; }
101
+ return { rows: [] };
102
+ },
103
+ };
104
+ const sqlRunner = sql || (async (conn, statements) => {
105
+ sqlRan.push({ conn, statements: statements.map((s) => s.text) });
106
+ return statements.map(() => ({ rows: [] }));
107
+ });
108
+ const deps = {
109
+ db, apply: true, log: (l) => logs.push(l), sleepFn: async () => {}, sqlRunner,
110
+ secretBox: { decrypt: () => 'rnd_borrowed' },
111
+ renderApiFactory: ({ token, ownerId }) => new RenderApi({ token, ownerId, dryRun: false, fetchImpl: r.fetchImpl }),
112
+ provisioning: { getInstanceById: async () => ({ ...inst, app_host: { ...inst.app_host } }) },
113
+ };
114
+ const it = { id: 44, action: 'render-standup', borrowed_key_enc: intentKey, created_at: new Date().toISOString() };
115
+ return { r, inst, logs, sqlRan, intentWrites, deps, run: () => renderStandupInstance(inst, deps, it) };
116
+ }
117
+ const writesTo = (calls) => calls.filter((c) => c.method !== 'GET').map((c) => `${c.method} ${c.path}`);
118
+ const created = (h, name) => h.r.calls.find((c) => c.method === 'POST' && c.path === '/services' && c.body.name === name);
119
+
120
+ console.log('create with a sandbox:');
121
+ await ta('a new app with a database: the app, then its sandbox — free, follows main, its own database', async () => {
122
+ const h = harness();
123
+ const out = await h.run();
124
+ assert.equal(out.ok, true, out.error);
125
+ assert.deepEqual(writesTo(h.r.calls), ['POST /postgres', 'POST /services', 'POST /services'], 'database, live app, sandbox — and nothing else written');
126
+ const sb = created(h, 'demo-sandbox').body;
127
+ assert.equal(sb.serviceDetails.plan, 'free');
128
+ assert.equal(sb.branch, 'main');
129
+ assert.equal(sb.autoDeploy, 'yes', 'the sandbox follows every merge');
130
+ assert.equal(sb.repo, 'https://github.com/example-owner/demo');
131
+ assert.deepEqual(sb.envVars, [
132
+ { key: 'BONGOS_SANDBOX', value: '1' },
133
+ { key: 'DATABASE_URL', value: 'postgresql://u:p@dpg-new-a/demo_sandbox' },
134
+ ], 'its own database on the same server — never the live one');
135
+ // the second database is made with SQL on the app's server, existence checked first
136
+ assert.equal(h.sqlRan.length, 2);
137
+ assert.match(h.sqlRan[0].statements[0], /FROM pg_database WHERE datname = \$1/);
138
+ assert.equal(h.sqlRan[1].statements[0], 'CREATE DATABASE "demo_sandbox"');
139
+ assert.ok(h.sqlRan.every((s) => s.conn.includes('render.com/demo_db')), 'over the server’s external address');
140
+ // no paid database bought for it: one POST /postgres in all, the app's own
141
+ assert.equal(h.r.calls.filter((c) => c.method === 'POST' && c.path === '/postgres').length, 1);
142
+ const s = h.inst.app_host.sandbox;
143
+ assert.deepEqual([s.state, s.serviceId, s.serviceUrl, s.database, s.dbName], ['deploying', 'srv-sbx00001', SB_URL, 'ready', 'demo_sandbox']);
144
+ assert.equal(h.inst.app_host.state, 'deploying', 'the app’s own state is its own');
145
+ });
146
+
147
+ await ta('an app with no database: the sandbox has none either, and says so', async () => {
148
+ const h = harness({ host: { database: false } });
149
+ assert.equal((await h.run()).ok, true);
150
+ assert.deepEqual(created(h, 'demo-sandbox').body.envVars, [{ key: 'BONGOS_SANDBOX', value: '1' }]);
151
+ assert.equal(h.sqlRan.length, 0);
152
+ assert.equal(h.inst.app_host.sandbox.database, 'none');
153
+ assert.match(h.inst.app_host.sandbox.dbNote, /no database, so its sandbox has none/);
154
+ });
155
+
156
+ await ta('a linked (adopted) app: the sandbox copies its build, and the live service gets no write', async () => {
157
+ const h = harness({ host: { mode: 'adopt', serviceId: 'srv-live0001', runtime: undefined, buildCommand: undefined, startCommand: undefined, database: undefined } });
158
+ const out = await h.run();
159
+ assert.equal(out.ok, true, out.error);
160
+ assert.deepEqual(writesTo(h.r.calls), ['POST /services'], 'the only write is the sandbox');
161
+ const sb = created(h, 'demo-sandbox').body;
162
+ assert.equal(sb.serviceDetails.runtime, 'python');
163
+ assert.equal(sb.serviceDetails.region, 'oregon');
164
+ assert.deepEqual(sb.serviceDetails.envSpecificDetails, { buildCommand: 'pip install -r requirements.txt', startCommand: 'gunicorn app:app' });
165
+ assert.equal(h.inst.app_host.sandbox.database, 'none', 'we do not know a linked app’s database, so none');
166
+ });
167
+
168
+ await ta('the database stage fails: the sandbox still runs, with no database, and nothing paid is bought', async () => {
169
+ const h = harness({ sql: async () => { throw new Error('permission denied to create database'); } });
170
+ const out = await h.run();
171
+ assert.equal(out.ok, true);
172
+ assert.deepEqual(created(h, 'demo-sandbox').body.envVars, [{ key: 'BONGOS_SANDBOX', value: '1' }], 'no DATABASE_URL at all — never the live one');
173
+ const s = h.inst.app_host.sandbox;
174
+ assert.equal(s.state, 'deploying');
175
+ assert.equal(s.database, 'failed');
176
+ assert.match(s.dbNote, /runs with no data: permission denied/);
177
+ assert.equal(h.r.calls.filter((c) => c.path === '/postgres' && c.method === 'POST').length, 1, 'only the app’s own database');
178
+ });
179
+
180
+ await ta('the sandbox failing never fails the app, and the key is still cleared', async () => {
181
+ const h = harness({ host: { database: false }, render: { 'POST /services': (b) => (b.name === 'demo-sandbox'
182
+ ? new Error('free instance limit reached')
183
+ : { service: { id: 'srv-live0001', ownerId: OURS, serviceDetails: { url: LIVE_URL } } }) } });
184
+ const out = await h.run();
185
+ assert.equal(out.ok, true, 'the app is up');
186
+ assert.equal(h.inst.app_host.state, 'deploying');
187
+ assert.equal(h.inst.app_host.sandbox.state, 'failed');
188
+ assert.match(h.inst.app_host.sandbox.error, /free instance limit reached/);
189
+ assert.deepEqual(h.intentWrites, [44], 'the key is cleared');
190
+ });
191
+
192
+ await ta('a re-run finds the sandbox by name and never makes a second one', async () => {
193
+ const h = harness({ host: { postgresId: 'dpg-new', serviceId: 'srv-live0001', state: 'deploying', sandbox: { state: 'requested' } },
194
+ render: { [SB_NAME_Q]: [{ service: { id: 'srv-sbx00001', name: 'demo-sandbox', ownerId: OURS, serviceDetails: { url: SB_URL } } }] } });
195
+ assert.equal((await h.run()).ok, true);
196
+ assert.ok(!created(h, 'demo-sandbox'), 'no second sandbox');
197
+ assert.equal(h.inst.app_host.sandbox.serviceId, 'srv-sbx00001');
198
+ });
199
+
200
+ await ta('a sandbox found under another workspace is refused by the guard', async () => {
201
+ const h = harness({ host: { database: false }, render: { [SB_NAME_Q]: [{ service: { id: 'srv-x', name: 'demo-sandbox', ownerId: 'tea-theirs000001' } }] } });
202
+ assert.equal((await h.run()).ok, true, 'the app stands');
203
+ assert.equal(h.inst.app_host.sandbox.state, 'failed');
204
+ assert.match(h.inst.app_host.sandbox.error, /workspace/);
205
+ assert.ok(!created(h, 'demo-sandbox'));
206
+ });
207
+
208
+ console.log('\nadd to an existing project:');
209
+ await ta('an app already live adds its sandbox: the live app is not created, read-written or redeployed', async () => {
210
+ const h = harness({ host: { postgresId: 'dpg-new', serviceId: 'srv-live0001', state: 'live', sandbox: { state: 'requested' } } });
211
+ const out = await h.run();
212
+ assert.equal(out.ok, true, out.error);
213
+ assert.deepEqual(writesTo(h.r.calls), ['POST /services'], 'one write: the sandbox');
214
+ assert.ok(created(h, 'demo-sandbox'));
215
+ assert.equal(h.inst.app_host.state, 'live', 'the app stays live — not knocked back to building');
216
+ assert.equal(h.inst.app_host.sandbox.state, 'deploying');
217
+ });
218
+
219
+ await ta('enqueueSandboxAdd: one statement queues the key and marks the row; a collision keeps nothing', async () => {
220
+ const seen = [];
221
+ const guard = require('../modules/provisioning/control-plane-guard.js');
222
+ const orig = guard.assertIntentAllowed;
223
+ guard.assertIntentAllowed = async () => {};
224
+ try {
225
+ const db = { query: async (t, p) => { seen.push({ t: t.replace(/\s+/g, ' '), p }); return { rows: [{ id: 5, action: 'render-standup', state: 'pending' }] }; } };
226
+ const r = await renderSandbox.enqueueSandboxAdd(db, 18, { keyEnc: 'v1.enc', requestedBy: 'api:self' });
227
+ assert.equal(r.intent.id, 5);
228
+ assert.equal(seen.length, 1);
229
+ assert.match(seen[0].t, /INSERT INTO provisioning_intents .* 'render-standup'/);
230
+ assert.match(seen[0].t, /'sandboxWanted', true/);
231
+ assert.deepEqual(seen[0].p, [18, 'api:self', 'v1.enc']);
232
+ let n = 0;
233
+ const busy = { query: async () => { if (n++ === 0) { const e = new Error('dup'); e.code = '23505'; throw e; } return { rows: [{ id: 3, action: 'provision', state: 'running' }] }; } };
234
+ const c = await renderSandbox.enqueueSandboxAdd(busy, 18, { keyEnc: 'v1.enc' });
235
+ assert.equal(c.conflict, 'open_intent');
236
+ assert.equal(c.intent.action, 'provision');
237
+ } finally { guard.assertIntentAllowed = orig; }
238
+ });
239
+
240
+ console.log('\nteardown:');
241
+ await ta('with the key: the sandbox service and its database are removed — the live app is not', async () => {
242
+ const h = harness({ teardownKey: 'v1.td', host: { postgresId: 'dpg-new', serviceId: 'srv-live0001', state: 'live',
243
+ sandbox: { state: 'live', serviceId: 'srv-sbx00001', serviceUrl: SB_URL, dbName: 'demo_sandbox' } } });
244
+ await forgetRenderApp(h.inst, h.deps);
245
+ assert.deepEqual(writesTo(h.r.calls), ['DELETE /services/srv-sbx00001'], 'one delete, the sandbox; nothing on the live app');
246
+ assert.deepEqual(h.sqlRan.map((s) => s.statements[0]), ['DROP DATABASE IF EXISTS "demo_sandbox" WITH (FORCE)']);
247
+ assert.deepEqual(h.intentWrites, [77], 'the teardown’s lent key is cleared');
248
+ const s = h.inst.app_host.sandbox;
249
+ assert.deepEqual([s.state, s.serviceId, s.dbName], ['removed', null, null]);
250
+ assert.equal(h.inst.app_host.state, 'disconnected', 'the live app is forgotten, as before — never deleted');
251
+ assert.equal(h.inst.app_host.serviceId, 'srv-live0001', 'its id stays on the row');
252
+ });
253
+
254
+ await ta('the delete refuses the live app even if it is named as the sandbox', async () => {
255
+ const h = harness({ teardownKey: 'v1.td', host: { serviceId: 'srv-live0001', state: 'live', sandbox: { state: 'live', serviceId: 'srv-live0001' } } });
256
+ await forgetRenderApp(h.inst, h.deps);
257
+ assert.ok(!h.r.calls.some((c) => c.method === 'DELETE'), 'no DELETE was sent');
258
+ assert.equal(h.inst.app_host.sandbox.state, 'left');
259
+ assert.match(h.inst.app_host.sandbox.error, /not a free sandbox/);
260
+ assert.deepEqual(h.intentWrites, [77], 'the key is cleared on a refusal too');
261
+ });
262
+
263
+ await ta('deleteSandboxService: refuses a paid service and one not named -sandbox', async () => {
264
+ const r = fakeRender({ 'GET /services/srv-paid0001': { id: 'srv-paid0001', name: 'demo-sandbox', ownerId: OURS, serviceDetails: { plan: 'starter' } } });
265
+ const api = new RenderApi({ token: 'rnd_x', ownerId: OURS, dryRun: false, fetchImpl: r.fetchImpl });
266
+ await assert.rejects(api.deleteSandboxService('srv-paid0001'), /not a free sandbox/);
267
+ await assert.rejects(api.deleteSandboxService('srv-live0001'), /not a free sandbox/);
268
+ assert.ok(!r.calls.some((c) => c.method === 'DELETE'));
269
+ await assert.rejects(api._req('DELETE', '/services/srv-sbx00001'), /never deletes/, 'the plain verb is still refused');
270
+ });
271
+
272
+ await ta('without a key: no Render call, the sandbox is left and the owner is told what to remove', async () => {
273
+ const h = harness({ host: { serviceId: 'srv-live0001', state: 'live', sandbox: { state: 'live', serviceId: 'srv-sbx00001', dbName: 'demo_sandbox' } } });
274
+ await forgetRenderApp(h.inst, h.deps);
275
+ assert.equal(h.r.calls.length, 0);
276
+ assert.equal(h.inst.app_host.sandbox.state, 'left');
277
+ assert.match(h.inst.app_host.sandbox.error, /demo-sandbox.*demo_sandbox/);
278
+ });
279
+
280
+ await ta('lendKeyToTeardown puts the key only on a PENDING teardown', async () => {
281
+ let seen;
282
+ const db = { query: async (t, p) => { seen = { t: t.replace(/\s+/g, ' '), p }; return { rowCount: 1 }; } };
283
+ assert.equal(await renderSandbox.lendKeyToTeardown(db, 77, 'v1.td'), true);
284
+ assert.match(seen.t, /WHERE id = \$1 AND action = 'teardown' AND state = 'pending'/);
285
+ });
286
+
287
+ console.log('\nstatus read:');
288
+ const row = (app_host, status = 'active') => ({ id: 18, status, app_host });
289
+ await ta('a running sandbox: its address, links and stage in the app-status shape, plus its database', async () => {
290
+ const s = renderSandbox.sandboxStatus(row({ platform: 'render', state: 'live', serviceId: 'srv-live0001', sandboxWanted: true,
291
+ sandbox: { state: 'live', serviceId: 'srv-sbx00001', serviceUrl: SB_URL, database: 'ready', updatedAt: new Date().toISOString() } }));
292
+ assert.equal(s.stage, 'live');
293
+ assert.equal(s.url, SB_URL);
294
+ assert.equal(s.links.service, 'https://dashboard.render.com/web/srv-sbx00001');
295
+ assert.equal(s.plan, 'free');
296
+ assert.equal(s.follows, 'main');
297
+ assert.equal(s.database.state, 'ready');
298
+ assert.equal(s.can_add, false);
299
+ });
300
+ await ta('waiting, failed (with a retry) and left read plainly', async () => {
301
+ const base = { platform: 'render', state: 'live', serviceId: 'srv-live0001', sandboxWanted: true };
302
+ assert.equal(renderSandbox.sandboxStatus(row({ ...base, sandbox: { state: 'requested' } })).stage, 'creating');
303
+ const f = renderSandbox.sandboxStatus(row({ ...base, sandbox: { state: 'failed', error: 'The sandbox could not be set up: x' } }));
304
+ assert.equal(f.error, 'The sandbox could not be set up: x.');
305
+ assert.equal(f.can_add, true, 'the owner can try again');
306
+ assert.equal(renderSandbox.sandboxStatus(row({ ...base, sandbox: { state: 'left', error: 'remove it' } })).error, 'remove it');
307
+ });
308
+ await ta('a ready sandbox database says plainly that it starts empty', () => {
309
+ const s = renderSandbox.sandboxStatus(row({ platform: 'render', state: 'live', serviceId: 'srv-live0001', sandboxWanted: true,
310
+ sandbox: { state: 'live', serviceId: 'srv-sbx00001', serviceUrl: SB_URL, database: 'ready' } }));
311
+ assert.match(s.database.note, /separate from your live data/);
312
+ assert.match(s.database.note, /starts empty/);
313
+ });
314
+ await ta('the sandbox knocks run a few at a time too', async () => {
315
+ const liveness = require('../modules/provisioning/pollers/app-liveness.js');
316
+ let inFlight = 0, peak = 0;
317
+ const sbs = Array.from({ length: 12 }, (_, i) => ({ id: i + 1, slug: `s${i}`, sb: { state: 'deploying', serviceUrl: `https://s${i}-sandbox.onrender.com` } }));
318
+ const db = { query: async (sql) => (/app_host->'sandbox'->>'state' = 'deploying'/.test(sql) && /^\s*SELECT/.test(sql) ? { rows: sbs } : /^\s*SELECT/.test(sql) ? { rows: [] } : { rowCount: 1 }) };
319
+ const fetchImpl = async () => { inFlight++; peak = Math.max(peak, inFlight); await new Promise((r) => setTimeout(r, 5)); inFlight--; return { status: 200, headers: { get: () => null } }; };
320
+ const out = await liveness.sweepOnce({ pool: db, fetchImpl, log: () => {} });
321
+ assert.deepEqual(out, { checked: 12, live: 12 });
322
+ assert.equal(peak, 5);
323
+ });
324
+ await ta('an existing app with no sandbox yet can add one', async () => {
325
+ const s = renderSandbox.sandboxStatus(row({ platform: 'render', state: 'live', serviceId: 'srv-live0001' }));
326
+ assert.equal(s.state, 'none');
327
+ assert.equal(s.can_add, true);
328
+ });
329
+
330
+ console.log('\nno app:');
331
+ await ta('a project with no app gets no sandbox, and says so plainly', async () => {
332
+ const s = renderSandbox.sandboxStatus(row(null));
333
+ assert.deepEqual([s.state, s.can_add], ['none', false]);
334
+ assert.match(s.message, /no app yet, so there is nothing to run in a sandbox/);
335
+ assert.match(renderSandbox.noSandboxReason(row({ platform: 'vercel' })), /Render only/);
336
+ assert.match(renderSandbox.noSandboxReason(row({ platform: 'render', state: 'failed' })), /not set up on Render yet/);
337
+ });
338
+
339
+ // ---- the routes, over express, with the module's own pieces stubbed at their seams ----
340
+ const provisioning = require('../modules/provisioning/provisioning.js');
341
+ const secretBox = require('../src/bongos/secret-box.js');
342
+ const kapi = require('../src/module-api.js');
343
+ let instance = null, enq = [], builder = { id: 7 };
344
+ provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
345
+ provisioning.recordEvent = async () => {};
346
+ renderSandbox.enqueueSandboxAdd = async (_db, id, args) => { enq.push({ id, ...args }); return { intent: { id: 9 } }; };
347
+ secretBox.isConfigured = () => true;
348
+ secretBox.encrypt = (plain) => `v1.sealed(${plain.length})`;
349
+ kapi.requireBuilder = (req, _res, next) => { req.builder = builder; next(); };
350
+ kapi.logger = () => ({ error() {}, warn() {}, info() {}, debug() {} });
351
+ Object.defineProperty(kapi, 'pool', { configurable: true, get: () => ({ query: async () => ({ rows: [{ waiting: false }] }) }) });
352
+ const envManifest = require('../modules/provisioning/env-manifest.js');
353
+ envManifest.listEnvVars = async () => [];
354
+ const express = require('express');
355
+ const app = express();
356
+ app.use(express.json());
357
+ app.use((_req, res, next) => { res.fail = (code, o) => res.status(typeof o === 'number' ? o : (o && o.status) || 400).json({ error: { code, ...(typeof o === 'object' ? o : {}) } }); next(); });
358
+ app.use('/api/bongos', require('../modules/provisioning/routes/render-standup.js')());
359
+ const server = app.listen(0);
360
+ await new Promise((r) => server.once('listening', r));
361
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
362
+ function call(method, path, body) {
363
+ return new Promise((resolve, reject) => {
364
+ const req = http.request(`${base}${path}`, { method, headers: { 'Content-Type': 'application/json' } }, (res) => {
365
+ let d = ''; res.on('data', (c) => { d += c; });
366
+ res.on('end', () => resolve({ status: res.statusCode, body: d ? JSON.parse(d) : null, raw: d }));
367
+ });
368
+ req.on('error', reject);
369
+ req.end(body ? JSON.stringify(body) : undefined);
370
+ });
371
+ }
372
+ const KEY = 'rnd_abcdefghijklmnopqrstuvwx';
373
+ const OWNED = { owner_builder_id: 7, slug: 'demo', status: 'active', target_ref: 'example-owner/demo' };
374
+
375
+ await ta('POST …/render/sandbox on a project with no app: 409, said plainly, nothing queued', async () => {
376
+ instance = { ...OWNED, app_host: null }; enq = [];
377
+ const r = await call('POST', '/provisioning/instances/18/render/sandbox', { key: KEY });
378
+ assert.equal(r.status, 409);
379
+ assert.equal(r.body.error.code, 'sandbox_unavailable');
380
+ assert.match(r.body.error.message, /no app yet/);
381
+ assert.equal(enq.length, 0);
382
+ });
383
+ await ta('POST …/render/sandbox by the owner of a live app: queued with ciphertext only', async () => {
384
+ instance = { ...OWNED, app_host: { platform: 'render', state: 'live', serviceId: 'srv-live0001' } }; enq = [];
385
+ const r = await call('POST', '/provisioning/instances/18/render/sandbox', { key: KEY });
386
+ assert.equal(r.status, 200, r.raw);
387
+ assert.equal(enq.length, 1);
388
+ assert.equal(enq[0].keyEnc, `v1.sealed(${KEY.length})`);
389
+ assert.ok(!r.raw.includes(KEY), 'the key is never sent back');
390
+ });
391
+ await ta('POST …/render/sandbox by anyone else: the stranger’s 404', async () => {
392
+ instance = { ...OWNED, owner_builder_id: 99, app_host: { platform: 'render', state: 'live', serviceId: 'srv-live0001' } }; enq = [];
393
+ const r = await call('POST', '/provisioning/instances/18/render/sandbox', { key: KEY });
394
+ assert.equal(r.status, 404);
395
+ assert.equal(enq.length, 0);
396
+ });
397
+ await ta('POST …/render/sandbox when one already runs: refused', async () => {
398
+ instance = { ...OWNED, app_host: { platform: 'render', state: 'live', serviceId: 'srv-live0001', sandbox: { state: 'live' } } }; enq = [];
399
+ const r = await call('POST', '/provisioning/instances/18/render/sandbox', { key: KEY });
400
+ assert.equal(r.status, 409);
401
+ assert.equal(r.body.error.code, 'sandbox_exists');
402
+ });
403
+ await ta('GET …/render carries the sandbox beside the app', async () => {
404
+ instance = { ...OWNED, app_host: { platform: 'render', state: 'live', serviceId: 'srv-live0001', serviceUrl: LIVE_URL, sandboxWanted: true,
405
+ sandbox: { state: 'deploying', serviceId: 'srv-sbx00001', serviceUrl: SB_URL, database: 'none', dbNote: 'Your app has no database, so its sandbox has none either.', updatedAt: new Date().toISOString() } } };
406
+ const r = await call('GET', '/provisioning/instances/18/render');
407
+ assert.equal(r.status, 200, r.raw);
408
+ assert.equal(r.body.app.url, LIVE_URL);
409
+ assert.equal(r.body.sandbox.url, SB_URL);
410
+ assert.equal(r.body.sandbox.stage, 'building');
411
+ assert.match(r.body.sandbox.database.note, /no database/);
412
+ });
413
+ await ta('GET …/render with no app: the sandbox says why it has none', async () => {
414
+ instance = { ...OWNED, app_host: null };
415
+ const r = await call('GET', '/provisioning/instances/18/render');
416
+ assert.equal(r.body.app, null);
417
+ assert.equal(r.body.sandbox.state, 'none');
418
+ assert.match(r.body.sandbox.message, /no app yet/);
419
+ });
420
+ server.close();
421
+
422
+ console.log('\nthe liveness poller:');
423
+ await ta('a deploying sandbox that answers is marked live, compare-and-set', async () => {
424
+ const liveness = require('../modules/provisioning/pollers/app-liveness.js');
425
+ const writes = [];
426
+ const db = { query: async (sql, params) => {
427
+ if (/app_host->'sandbox'->>'state' = 'deploying'/.test(sql) && /^\s*SELECT/.test(sql)) return { rows: [{ id: 18, slug: 'demo', sb: { state: 'deploying', serviceUrl: SB_URL } }] };
428
+ if (/^\s*SELECT/.test(sql)) return { rows: [] };
429
+ writes.push({ sql: sql.replace(/\s+/g, ' '), params });
430
+ return { rowCount: 1 };
431
+ } };
432
+ const fetchImpl = async () => ({ status: 200, headers: { get: () => null } });
433
+ const out = await liveness.sweepOnce({ pool: db, fetchImpl, log: () => {} });
434
+ assert.deepEqual(out, { checked: 1, live: 1 });
435
+ assert.match(writes[0].sql, /jsonb_set\(app_host, '\{sandbox\}'/);
436
+ assert.match(writes[0].sql, /AND app_host->'sandbox'->>'state' = 'deploying'/);
437
+ });
438
+
439
+ console.log('\npayload:');
440
+ await ta('the sandbox database name is a safe identifier', () => {
441
+ assert.equal(payload.sandboxDbName({ id: 1, slug: 'my-app' }), 'my_app_sandbox');
442
+ assert.equal(payload.sandboxDbName({ id: 2, slug: '9lives' }), 'lives_sandbox');
443
+ assert.ok(payload.SANDBOX_DB_RE.test(payload.sandboxDbName({ id: 3, slug: 'x'.repeat(80) })));
444
+ });
445
+
446
+ console.log(`\nrender_sandbox: ${passed} passed, ${failed} failed`);
447
+ process.exit(failed ? 1 : 0);