@bongos/core 1.25.16 → 1.25.18
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 +200 -75
- package/bin/bongos.js +1 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +24 -0
- package/clients/bongos-client/index.cjs +24 -0
- package/clients/bongos-client/index.d.ts +36 -2
- package/clients/bongos-client/index.mjs +24 -0
- package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +8 -0
- package/docs/adr/0408-a-genesis-stage-is-three-doors-onto-one-record.md +1 -0
- package/docs/api/openapi.json +761 -112
- package/docs/api-reference.md +23 -3
- package/docs/copy-inventory.md +118 -108
- package/docs/copy-registry.json +315 -172
- package/docs/file-map.md +2 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +3 -0
- package/docs/page-readings.json +62 -62
- package/modules/app-host/CLAUDE.md +15 -0
- package/modules/app-host/module.json +20 -0
- package/modules/app-host/public/settings-widget.js +143 -0
- package/modules/app-host/routes/app-host.js +88 -0
- package/modules/government/charter-advice.js +103 -0
- package/modules/government/decision-rules.js +1 -0
- package/modules/government/presets.js +42 -0
- package/modules/government/recommendations.js +102 -7
- package/modules/government/routes/government.js +1 -0
- package/modules/lifecycle/first-read-catalog.js +62 -0
- package/modules/lifecycle/first-read-db.js +190 -0
- package/modules/lifecycle/first-read.js +330 -0
- package/modules/lifecycle/kickoff-checklist.js +12 -3
- package/modules/lifecycle/migrations/lifecycle_022_repo_maps.sql +44 -0
- package/modules/lifecycle/migrations/lifecycle_023_repo_map_dismissed.sql +19 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/first-read.js +101 -0
- package/modules/onboarding/genesis-work.js +101 -4
- package/modules/onboarding/routes/genesis-work.js +2 -2
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/routes/sso-app-status.js +65 -0
- package/modules/provisioning/module.json +1 -0
- package/modules/provisioning/pollers/app-liveness.js +23 -1
- package/modules/provisioning/render-sandbox.js +113 -0
- package/modules/provisioning/render-standup.js +3 -1
- package/modules/provisioning/routes/provisioning.js +4 -1
- package/modules/provisioning/routes/render-standup.js +49 -1
- package/modules/provisioning/seams.js +32 -1
- package/modules/provisioning/starter-bundles.js +1 -0
- package/modules/public-landing/public/assets/guide-core.js +475 -0
- package/modules/public-landing/public/assets/guide-hall.css +121 -0
- package/modules/public-landing/public/assets/guide-hall.js +164 -0
- package/modules/public-landing/public/assets/guide-scene.css +9 -0
- package/modules/public-landing/public/assets/guide-scene.js +58 -381
- package/modules/public-landing/public/index.html +46 -1
- package/modules/public-landing/public/projects.html +112 -6
- package/modules/ui-design/kit/serve.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +28 -0
- package/scripts/bongos/first-read.js +150 -0
- package/scripts/bongos/genesis.js +21 -1
- package/scripts/bongos/government-walk.js +4 -4
- package/scripts/bongos/provision-render-sandbox.js +171 -0
- package/scripts/bongos/provision-render.js +37 -2
- package/scripts/bongos/provision-teardown.js +1 -1
- package/scripts/bongos/publish-manifest.js +1 -0
- package/scripts/bongos/render-api.js +33 -3
- package/scripts/bongos/render-payload.js +39 -1
- package/scripts/bongos/seam-wiring-guard.js +3 -2
- package/scripts/gds/first-read.js +7 -0
- package/scripts/gds/provision-render-sandbox.js +6 -0
- package/scripts/hall-preview/server.js +2 -0
- package/src/bongos/auth-admission.js +64 -0
- package/src/bongos/auth.js +2 -1
- package/src/bongos/module-scope-map.js +4 -0
- package/src/bongos/routes/core-update.js +3 -16
- package/src/bongos/routes/modules.js +11 -0
- package/src/bongos/serve-internal.js +20 -6
- package/src/module-api.js +6 -1
- package/src/module-loader/loader.js +2 -2
- package/tests/app_host.mjs +413 -0
- package/tests/first_read.mjs +479 -0
- package/tests/genesis_work.mjs +74 -3
- package/tests/government_act_alone_buildings.mjs +2 -0
- package/tests/government_charter_advice.mjs +150 -0
- package/tests/landing_guide_hall.mjs +364 -0
- package/tests/landing_guide_scene.mjs +8 -5
- package/tests/module_api.mjs +1 -0
- package/tests/module_loader.mjs +1 -1
- package/tests/npm_release_where.mjs +13 -3
- package/tests/provisioning_app_status.mjs +3 -2
- package/tests/provisioning_render_route.mjs +1 -1
- package/tests/provisioning_teardown_intent.mjs +50 -0
- package/tests/render_api.mjs +5 -2
- package/tests/render_sandbox.mjs +447 -0
|
@@ -95,7 +95,7 @@ await ta('the owner queues it: ciphertext only, the request recorded on the row,
|
|
|
95
95
|
assert.equal(r.status, 200, r.raw);
|
|
96
96
|
assert.equal(enqueueCalls.length, 1);
|
|
97
97
|
assert.equal(enqueueCalls[0].keyEnc, `v1.sealed(${KEY.length})`);
|
|
98
|
-
assert.deepEqual(enqueueCalls[0].appHost, { platform: 'render', mode: 'greenfield', ownerId: 'tea-abcdefgh1234', state: 'requested', error: null,
|
|
98
|
+
assert.deepEqual(enqueueCalls[0].appHost, { platform: 'render', mode: 'greenfield', ownerId: 'tea-abcdefgh1234', state: 'requested', error: null, sandboxWanted: true,
|
|
99
99
|
runtime: 'node', startCommand: 'npm start', buildCommand: 'npm ci', database: true, webPlan: 'standard' });
|
|
100
100
|
assert.equal(events.length, 1);
|
|
101
101
|
noKeyAnywhere(enqueueCalls, events, r.body);
|
|
@@ -256,6 +256,56 @@ await ta('a mid-flight provision is a 409 conflict — never the false "teardown
|
|
|
256
256
|
assert.doesNotMatch(String(res.body.error.message), /already queued/i, 'the old lie is gone');
|
|
257
257
|
});
|
|
258
258
|
|
|
259
|
+
// The owner's Render key on a teardown removes the project's sandbox (task 1005289, ADR 0406 §7).
|
|
260
|
+
const renderSandbox = require('../modules/provisioning/render-sandbox.js');
|
|
261
|
+
const secretBox = require('../src/bongos/secret-box.js');
|
|
262
|
+
let lentCalls = [];
|
|
263
|
+
renderSandbox.lendKeyToTeardown = async (_db, intentId, keyEnc) => { lentCalls.push({ intentId, keyEnc }); return true; };
|
|
264
|
+
function postWithKey(path, key) {
|
|
265
|
+
return new Promise((resolve, reject) => {
|
|
266
|
+
const req = http.request(`${base}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': 2, 'x-render-key': key } }, (res) => {
|
|
267
|
+
let d = ''; res.on('data', (c) => { d += c; });
|
|
268
|
+
res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(d), raw: d }));
|
|
269
|
+
});
|
|
270
|
+
req.on('error', reject);
|
|
271
|
+
req.end('{}');
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
const RKEY = 'rnd_abcdefghijklmnopqrstuvwx';
|
|
275
|
+
console.log('\nPOST /provisioning/instances/:id/teardown — the Render key for the sandbox:');
|
|
276
|
+
await ta('a well-formed key is sealed and lent to the teardown just queued, never echoed', async () => {
|
|
277
|
+
const realConf = secretBox.isConfigured, realEnc = secretBox.encrypt;
|
|
278
|
+
secretBox.isConfigured = () => true; secretBox.encrypt = (p) => `v1.sealed(${p.length})`;
|
|
279
|
+
try {
|
|
280
|
+
lentCalls = [];
|
|
281
|
+
teardownOutcome = { intent: { id: 31, action: 'teardown', state: 'pending' }, created: true, superseded: false };
|
|
282
|
+
const res = await postWithKey('/provisioning/instances/16/teardown', RKEY);
|
|
283
|
+
assert.equal(res.status, 200, res.raw);
|
|
284
|
+
assert.equal(res.body.sandbox_key_lent, true);
|
|
285
|
+
assert.deepEqual(lentCalls, [{ intentId: 31, keyEnc: `v1.sealed(${RKEY.length})` }], 'ciphertext only, on THIS teardown');
|
|
286
|
+
assert.ok(!res.raw.includes(RKEY), 'the key is never sent back');
|
|
287
|
+
} finally { secretBox.isConfigured = realConf; secretBox.encrypt = realEnc; }
|
|
288
|
+
});
|
|
289
|
+
await ta('a malformed key, an already-queued teardown, or no safe seal: nothing lent, the teardown still answers', async () => {
|
|
290
|
+
const realConf = secretBox.isConfigured, realEnc = secretBox.encrypt;
|
|
291
|
+
secretBox.encrypt = (p) => `v1.sealed(${p.length})`;
|
|
292
|
+
try {
|
|
293
|
+
secretBox.isConfigured = () => true;
|
|
294
|
+
lentCalls = [];
|
|
295
|
+
teardownOutcome = { intent: { id: 32, action: 'teardown', state: 'pending' }, created: true, superseded: false };
|
|
296
|
+
let res = await postWithKey('/provisioning/instances/16/teardown', 'not-a-key');
|
|
297
|
+
assert.equal(res.status, 200); assert.equal(res.body.sandbox_key_lent, false);
|
|
298
|
+
teardownOutcome = { intent: { id: 32, action: 'teardown', state: 'pending' }, created: false, superseded: false };
|
|
299
|
+
res = await postWithKey('/provisioning/instances/16/teardown', RKEY);
|
|
300
|
+
assert.equal(res.status, 200); assert.equal(res.body.sandbox_key_lent, false, 'not on a teardown queued earlier');
|
|
301
|
+
secretBox.isConfigured = () => false;
|
|
302
|
+
teardownOutcome = { intent: { id: 33, action: 'teardown', state: 'pending' }, created: true, superseded: false };
|
|
303
|
+
res = await postWithKey('/provisioning/instances/16/teardown', RKEY);
|
|
304
|
+
assert.equal(res.status, 200); assert.equal(res.body.sandbox_key_lent, false, 'never stored unsealed');
|
|
305
|
+
assert.equal(lentCalls.length, 0);
|
|
306
|
+
} finally { secretBox.isConfigured = realConf; secretBox.encrypt = realEnc; }
|
|
307
|
+
});
|
|
308
|
+
|
|
259
309
|
console.log('\nPOST /provisioning/instances/:id/force-teardown — same truthful mapping (archon):');
|
|
260
310
|
|
|
261
311
|
await ta('force-teardown surfaces the same 409 conflict instead of a false success', async () => {
|
package/tests/render_api.mjs
CHANGED
|
@@ -127,10 +127,13 @@ await ta('listOwners returns every workspace the key reaches, unwrapped', async
|
|
|
127
127
|
});
|
|
128
128
|
|
|
129
129
|
console.log('\nno delete:');
|
|
130
|
-
await ta('there is no delete method, and the verb is refused', async () => {
|
|
130
|
+
await ta('there is no delete method but the sandbox one, and the verb is refused', async () => {
|
|
131
131
|
const { api, calls } = live({});
|
|
132
132
|
const methods = Object.getOwnPropertyNames(RenderApi.prototype);
|
|
133
|
-
|
|
133
|
+
// ADR 0406 §7 / task 1005289: the ONE destructive method removes a free `…-sandbox`
|
|
134
|
+
// service the platform made (tests/render_sandbox.mjs proves it refuses anything else).
|
|
135
|
+
const destructive = methods.filter((m) => /delete|destroy|remove|suspend/i.test(m));
|
|
136
|
+
assert.deepEqual(destructive, ['deleteSandboxService'], `no other destructive method: ${methods.join(', ')}`);
|
|
134
137
|
await assert.rejects(api._req('DELETE', '/services/srv-1'), /never deletes/);
|
|
135
138
|
assert.equal(calls.length, 0);
|
|
136
139
|
});
|
|
@@ -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);
|