@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.
- package/.bongos-core.json +95 -40
- package/bin/bongos.js +1 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +14 -0
- package/clients/bongos-client/index.cjs +14 -0
- package/clients/bongos-client/index.d.ts +22 -2
- package/clients/bongos-client/index.mjs +14 -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/api/openapi.json +415 -3
- package/docs/api-reference.md +13 -2
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +1 -1
- package/modules/government/decision-rules.js +1 -0
- package/modules/lifecycle/first-read-db.js +152 -0
- package/modules/lifecycle/first-read.js +265 -0
- package/modules/lifecycle/kickoff-checklist.js +12 -3
- package/modules/lifecycle/migrations/lifecycle_022_repo_maps.sql +44 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/first-read.js +79 -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/public-landing/public/projects.html +101 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/scripts/bongos/first-read.js +116 -0
- 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/render-api.js +33 -3
- package/scripts/bongos/render-payload.js +39 -1
- package/scripts/gds/first-read.js +7 -0
- package/scripts/gds/provision-render-sandbox.js +6 -0
- package/src/module-api.js +1 -1
- package/tests/first_read.mjs +303 -0
- package/tests/government_act_alone_buildings.mjs +2 -0
- 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
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// provision-render-sandbox.js — the project's SANDBOX on Render: set it up beside the app,
|
|
3
|
+
// and remove it at teardown (task 1005289; ADR 0406, ADR 0327).
|
|
4
|
+
//
|
|
5
|
+
// WHY. Every project whose app runs on Render gets a second, free app from the same
|
|
6
|
+
// repository that follows `main` on every merge, so finished work reaches a test copy
|
|
7
|
+
// first and the live site moves only when a person promotes (ADR 0406 §2). This file is
|
|
8
|
+
// the runner half; the web tier queues it (modules/provisioning/render-sandbox.js) and the
|
|
9
|
+
// standup leg calls it after the app (provision-render.js), with the same borrowed key.
|
|
10
|
+
//
|
|
11
|
+
// THREE STAGES, kept apart so one failing never takes the others down:
|
|
12
|
+
// 1. BUILD — the app's own build settings: what the owner gave (new app), or read off
|
|
13
|
+
// their existing service (a linked one).
|
|
14
|
+
// 2. DATABASE — never Render's free database (it expires, one per workspace: ADR 0406
|
|
15
|
+
// §4). When the app has a PAID database we created, a second database is made INSIDE
|
|
16
|
+
// that server with plain SQL (Render's docs: "Adding multiple databases to a single
|
|
17
|
+
// instance" — there is no API call for it). No app database → no sandbox database.
|
|
18
|
+
// A failure here leaves the sandbox running with NO database and says why; it never
|
|
19
|
+
// falls back to buying one — that is the owner's call (ADR 0406 §6 choice 1).
|
|
20
|
+
// 3. SERVICE — a free web service named `<slug>-sandbox`, auto-deploying from `main`,
|
|
21
|
+
// with BONGOS_SANDBOX=1. Found by name first, so a re-run never makes a second one.
|
|
22
|
+
//
|
|
23
|
+
// The live service is not touched here: switching it to manual deploys belongs with the
|
|
24
|
+
// promote button that replaces them (task 1005290).
|
|
25
|
+
//
|
|
26
|
+
// TEARDOWN removes the sandbox service and the sandbox database only — never the live app
|
|
27
|
+
// or its data (ADR 0406 §7). It needs the owner's key; without one the sandbox is
|
|
28
|
+
// forgotten and the owner is told exactly what to delete.
|
|
29
|
+
|
|
30
|
+
const payload = require('./render-payload.js');
|
|
31
|
+
const { parseTargetRef } = require('./provision-repo-auth.js');
|
|
32
|
+
|
|
33
|
+
async function recordSandbox(db, instId, patch) {
|
|
34
|
+
await db.query(
|
|
35
|
+
`UPDATE provisioning_instances
|
|
36
|
+
SET app_host = COALESCE(app_host, '{}'::jsonb)
|
|
37
|
+
|| jsonb_build_object('sandbox', COALESCE(app_host->'sandbox', '{}'::jsonb) || $2::jsonb)
|
|
38
|
+
WHERE id = $1`,
|
|
39
|
+
[instId, JSON.stringify({ ...patch, updatedAt: new Date().toISOString() })]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Run SQL against the app's database SERVER from the runner. Uses the server's external
|
|
43
|
+
// address (the runner is outside Render's network). Injected in tests: no real database.
|
|
44
|
+
async function defaultSqlRunner(connectionString, statements) {
|
|
45
|
+
const { Client } = require('pg');
|
|
46
|
+
const c = new Client({ connectionString, ssl: { rejectUnauthorized: true }, connectionTimeoutMillis: 15000 });
|
|
47
|
+
await c.connect();
|
|
48
|
+
try {
|
|
49
|
+
const out = [];
|
|
50
|
+
for (const s of statements) out.push(await c.query(s.text, s.values || []));
|
|
51
|
+
return out;
|
|
52
|
+
} finally { await c.end().catch(() => {}); }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The same connection string pointed at another database on the same server.
|
|
56
|
+
function withDatabase(connectionString, dbName) {
|
|
57
|
+
const u = new URL(connectionString);
|
|
58
|
+
u.pathname = `/${dbName}`;
|
|
59
|
+
return u.toString();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Stage 2. Returns { state: 'ready', name, url } | { state: 'none' | 'failed', note }.
|
|
63
|
+
async function sandboxDatabase(api, inst, host, deps) {
|
|
64
|
+
if (!host.postgresId) {
|
|
65
|
+
return { state: 'none', note: host.mode === 'adopt'
|
|
66
|
+
? 'Bongos did not create your app’s database, so the sandbox has none. It runs your app without data.'
|
|
67
|
+
: 'Your app has no database, so its sandbox has none either.' };
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const pg = await api.getPostgres(host.postgresId);
|
|
71
|
+
if (pg.plan === 'free') return { state: 'none', note: 'Your app’s database is on Render’s free plan, which cannot hold a sandbox database, so the sandbox has none.' };
|
|
72
|
+
const conn = await api.postgresConnectionInfo(pg.id);
|
|
73
|
+
const ext = conn && conn.externalConnectionString;
|
|
74
|
+
const int = conn && conn.internalConnectionString;
|
|
75
|
+
if (!ext || !int) throw new Error('Render gave no connection address for your database');
|
|
76
|
+
const name = payload.sandboxDbName(inst);
|
|
77
|
+
const run = deps.sqlRunner || defaultSqlRunner;
|
|
78
|
+
const [exists] = await run(ext, [{ text: 'SELECT 1 FROM pg_database WHERE datname = $1', values: [name] }]);
|
|
79
|
+
if (!(exists && exists.rows && exists.rows.length)) await run(ext, [{ text: `CREATE DATABASE "${name}"` }]);
|
|
80
|
+
return { state: 'ready', name, url: withDatabase(int, name) };
|
|
81
|
+
} catch (e) {
|
|
82
|
+
return { state: 'failed', note: `The sandbox’s own database could not be made, so the sandbox runs with no data: ${String((e && e.message) || e).split('\n')[0].slice(0, 200)}` };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Stages 1-3. Never throws: a sandbox failure is recorded on the sandbox and never fails
|
|
87
|
+
// the app it sits beside. Returns { ok, error? }.
|
|
88
|
+
async function standupSandbox(api, inst, host, deps) {
|
|
89
|
+
const { db, log } = deps;
|
|
90
|
+
try {
|
|
91
|
+
const repo = parseTargetRef(inst.target_ref);
|
|
92
|
+
if (!repo) throw new Error('the project has no GitHub repository for the sandbox to build from');
|
|
93
|
+
// 1. build settings
|
|
94
|
+
let build, region = host.region || payload.DEFAULTS.region;
|
|
95
|
+
if (host.mode === 'adopt') {
|
|
96
|
+
const live = await api.getService(host.serviceId);
|
|
97
|
+
build = payload.buildFromService(live);
|
|
98
|
+
if (!build) throw new Error('Render did not say how your linked service is built, so the sandbox could not copy it');
|
|
99
|
+
region = (live.serviceDetails && live.serviceDetails.region) || region;
|
|
100
|
+
} else {
|
|
101
|
+
build = payload.buildFor(host);
|
|
102
|
+
}
|
|
103
|
+
await recordSandbox(db, inst.id, { state: 'creating', error: null });
|
|
104
|
+
// 2. database — its own stage; its failure is the sandbox's note, not its end
|
|
105
|
+
const sb = host.sandbox || {};
|
|
106
|
+
const dbStage = await sandboxDatabase(api, inst, host, deps);
|
|
107
|
+
await recordSandbox(db, inst.id, { database: dbStage.state, dbName: dbStage.name || null, dbNote: dbStage.note || null });
|
|
108
|
+
log(` [render] sandbox database: ${dbStage.state}${dbStage.name ? ` (${dbStage.name})` : ''}`);
|
|
109
|
+
// 3. the free service, found before created
|
|
110
|
+
const name = payload.resourceName(inst, 'sandbox');
|
|
111
|
+
let svc = sb.serviceId ? await api.getService(sb.serviceId).catch(() => null) : null;
|
|
112
|
+
if (!svc) svc = await api.findServiceByName(name);
|
|
113
|
+
if (!svc) {
|
|
114
|
+
const created = await api.createService(payload.sandboxServicePayload(inst, {
|
|
115
|
+
ownerId: api.ownerId, repoUrl: `https://github.com/${repo.owner}/${repo.name}`, region, build,
|
|
116
|
+
dbUrl: dbStage.state === 'ready' ? dbStage.url : null,
|
|
117
|
+
}));
|
|
118
|
+
svc = created.service;
|
|
119
|
+
log(` [render] created sandbox service ${svc.id} (free, follows main)`);
|
|
120
|
+
}
|
|
121
|
+
const url = (svc.serviceDetails && svc.serviceDetails.url) || null;
|
|
122
|
+
await recordSandbox(db, inst.id, { state: 'deploying', serviceId: svc.id, serviceUrl: url, error: null });
|
|
123
|
+
return { ok: true };
|
|
124
|
+
} catch (e) {
|
|
125
|
+
const error = `The sandbox could not be set up: ${String((e && e.message) || e).split('\n')[0].slice(0, 240)}`;
|
|
126
|
+
await recordSandbox(db, inst.id, { state: 'failed', error }).catch(() => {});
|
|
127
|
+
log(` ✖ [render] ${inst.slug} sandbox: ${error}`);
|
|
128
|
+
return { ok: false, error };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Teardown's half for the sandbox. `api` is a Render client built from the key the owner
|
|
133
|
+
// lent with the teardown, or null when they lent none.
|
|
134
|
+
async function removeSandbox(inst, { db, log, apply, sqlRunner }, api) {
|
|
135
|
+
const host = inst.app_host || {};
|
|
136
|
+
const sb = host.sandbox;
|
|
137
|
+
if (host.platform !== 'render' || !sb || (!sb.serviceId && !sb.dbName)) return { removed: false, reason: 'none' };
|
|
138
|
+
if (!api) {
|
|
139
|
+
const what = [sb.serviceId && `the service ${payload.resourceName(inst, 'sandbox')}`, sb.dbName && `the database ${sb.dbName} inside your app’s database`].filter(Boolean).join(' and ');
|
|
140
|
+
log(` [render] sandbox left on Render (no key lent with the teardown): ${what}`);
|
|
141
|
+
if (apply) await recordSandbox(db, inst.id, { state: 'left', error: `No Render key came with the teardown, so the sandbox is still on your Render account: remove ${what} there.` });
|
|
142
|
+
return { removed: false, reason: 'no_key' };
|
|
143
|
+
}
|
|
144
|
+
const notes = [];
|
|
145
|
+
let dbGone = !sb.dbName;
|
|
146
|
+
if (sb.serviceId) {
|
|
147
|
+
if (apply) await api.deleteSandboxService(sb.serviceId);
|
|
148
|
+
notes.push(`service ${sb.serviceId}`);
|
|
149
|
+
}
|
|
150
|
+
if (sb.dbName && host.postgresId && payload.SANDBOX_DB_RE.test(sb.dbName)) {
|
|
151
|
+
try {
|
|
152
|
+
const conn = await api.postgresConnectionInfo(host.postgresId);
|
|
153
|
+
if (apply && conn && conn.externalConnectionString) {
|
|
154
|
+
await (sqlRunner || defaultSqlRunner)(conn.externalConnectionString, [{ text: `DROP DATABASE IF EXISTS "${sb.dbName}" WITH (FORCE)` }]);
|
|
155
|
+
}
|
|
156
|
+
notes.push(`database ${sb.dbName}`);
|
|
157
|
+
dbGone = true;
|
|
158
|
+
} catch (e) {
|
|
159
|
+
log(` (sandbox database ${sb.dbName} not dropped: ${e && e.message})`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
log(` [render] ${apply ? 'removed' : 'would remove'} the sandbox (${notes.join(' and ')}); the live app is untouched`);
|
|
163
|
+
if (apply) {
|
|
164
|
+
await recordSandbox(db, inst.id, dbGone
|
|
165
|
+
? { state: 'removed', serviceId: null, serviceUrl: null, dbName: null, error: null }
|
|
166
|
+
: { state: 'removed', serviceId: null, serviceUrl: null, error: `The sandbox app was removed, but its database ${sb.dbName} is still inside your app’s database server.` });
|
|
167
|
+
}
|
|
168
|
+
return { removed: true };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = { standupSandbox, removeSandbox, sandboxDatabase, recordSandbox, withDatabase };
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
const { RenderApi } = require('./render-api.js');
|
|
35
35
|
const payload = require('./render-payload.js');
|
|
36
36
|
const { parseTargetRef } = require('./provision-repo-auth.js');
|
|
37
|
+
const sandbox = require('./provision-render-sandbox.js');
|
|
37
38
|
|
|
38
39
|
// A key waiting longer than this was lent for a run that never came. It is refused and
|
|
39
40
|
// cleared rather than spent late.
|
|
@@ -147,7 +148,18 @@ async function renderStandupInstance(inst, deps, intent) {
|
|
|
147
148
|
const owners = await api.listOwners();
|
|
148
149
|
if (!owners.some((o) => o.id === host.ownerId)) return refuse(`this Render key cannot reach workspace ${host.ownerId}`);
|
|
149
150
|
|
|
150
|
-
|
|
151
|
+
// An app already up that only asked for its sandbox (render-sandbox.js
|
|
152
|
+
// enqueueSandboxAdd) is not touched again: the run goes straight to the sandbox.
|
|
153
|
+
const sandboxOnly = !!(host.sandboxWanted && host.sandbox && host.sandbox.state === 'requested'
|
|
154
|
+
&& host.serviceId && ['deploying', 'linked', 'live'].includes(host.state));
|
|
155
|
+
const app = sandboxOnly ? { ok: true } : (host.mode === 'adopt' ? await adopt(api, inst, host, deps) : await greenfield(api, inst, host, deps));
|
|
156
|
+
// The sandbox (ADR 0406, task 1005289): after the app, with the same key. Its failure
|
|
157
|
+
// is recorded on the sandbox and never fails the app.
|
|
158
|
+
if (app.ok && host.sandboxWanted) {
|
|
159
|
+
const fresh = deps.provisioning && deps.provisioning.getInstanceById ? ((await deps.provisioning.getInstanceById(db, inst.id)) || inst) : inst;
|
|
160
|
+
await sandbox.standupSandbox(api, fresh, { ...host, ...(fresh.app_host || {}) }, deps);
|
|
161
|
+
}
|
|
162
|
+
return app;
|
|
151
163
|
} catch (e) {
|
|
152
164
|
return refuse(plainReason(e));
|
|
153
165
|
} finally {
|
|
@@ -190,9 +202,32 @@ async function afterProvision(inst, deps, intent, result) {
|
|
|
190
202
|
// Teardown's half (ADR 0327 §3): forget the Render app, never delete it. The ids stay on
|
|
191
203
|
// the row so an operator can still say which services were the project's; the owner
|
|
192
204
|
// deletes them in their own Render dashboard if they want to stop paying.
|
|
193
|
-
|
|
205
|
+
//
|
|
206
|
+
// The SANDBOX is the one exception (ADR 0406 §7, task 1005289): the platform made it, so
|
|
207
|
+
// the platform removes it — when the owner lent their key with the teardown (the running
|
|
208
|
+
// teardown intent's borrowed_key_enc). The key is cleared here whatever happens.
|
|
209
|
+
async function forgetRenderApp(inst, deps) {
|
|
210
|
+
const { db, log, apply } = deps;
|
|
194
211
|
const host = inst.app_host;
|
|
195
212
|
if (!host || host.platform !== 'render') return;
|
|
213
|
+
if (host.sandbox) {
|
|
214
|
+
let keyEnc = null, intentId = null;
|
|
215
|
+
try {
|
|
216
|
+
const { rows } = await db.query(
|
|
217
|
+
`SELECT id, borrowed_key_enc FROM provisioning_intents
|
|
218
|
+
WHERE instance_id = $1 AND action = 'teardown' AND state IN ('pending', 'running') AND borrowed_key_enc IS NOT NULL
|
|
219
|
+
ORDER BY created_at DESC LIMIT 1`, [inst.id]);
|
|
220
|
+
if (rows && rows[0]) { keyEnc = rows[0].borrowed_key_enc; intentId = rows[0].id; }
|
|
221
|
+
const secretBox = deps.secretBox || require('../../src/bongos/secret-box');
|
|
222
|
+
const api = keyEnc && host.ownerId ? renderApiFor(deps, secretBox.decrypt(keyEnc), host.ownerId) : null;
|
|
223
|
+
await sandbox.removeSandbox(inst, deps, api);
|
|
224
|
+
} catch (e) {
|
|
225
|
+
log(` (sandbox not removed: ${e && e.message})`);
|
|
226
|
+
if (apply) await sandbox.recordSandbox(db, inst.id, { state: 'left', error: `The sandbox could not be removed, so it is still on your Render account: ${String((e && e.message) || e).split('\n')[0].slice(0, 200)}` }).catch(() => {});
|
|
227
|
+
} finally {
|
|
228
|
+
if (intentId != null) await clearBorrowedKey(db, intentId);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
196
231
|
const what = [host.serviceId && `service ${host.serviceId}`, host.postgresId && `database ${host.postgresId}`].filter(Boolean).join(' and ') || 'nothing yet';
|
|
197
232
|
log(` [render] ${apply ? 'disconnected' : 'would disconnect'} from Render (${what}); nothing on Render is deleted — the owner removes it in their own dashboard`);
|
|
198
233
|
if (apply) await recordAppHost(db, inst.id, { state: 'disconnected' });
|
|
@@ -40,7 +40,7 @@ async function teardownInstance(inst, deps) {
|
|
|
40
40
|
if (inst.db_name) log(` (left for the operator: dropdb ${dbName(inst)}, prune ${CONFIG.backupDir}/${dbName(inst)}-*.sql.gz)`);
|
|
41
41
|
parkAddress(inst, { controlExec, log, apply });
|
|
42
42
|
// The app's Render services are the OWNER's: forget them, never delete them (ADR 0327 §3, task 1004183).
|
|
43
|
-
await require('./provision-render.js').forgetRenderApp(inst, { db, log, apply });
|
|
43
|
+
await require('./provision-render.js').forgetRenderApp(inst, { ...deps, db, log, apply }); // …except the sandbox, removed when a key came with the teardown (ADR 0406 §7)
|
|
44
44
|
// Revoke the instance's deploy key (ADR 0176) and remove its local halves. The
|
|
45
45
|
// checkout itself is deliberately left in place (data is precious), so the key
|
|
46
46
|
// files must NOT ride along — and a revoke skip must be LOUD: a torn-down project
|
|
@@ -24,9 +24,13 @@
|
|
|
24
24
|
// resource through the guard, so nothing is ever sent to or read from a resource whose
|
|
25
25
|
// workspace was not checked.
|
|
26
26
|
//
|
|
27
|
-
// NO DELETE. The platform must not hold the ability to destroy
|
|
28
|
-
// account it does not own (ADR 0327 §3)
|
|
29
|
-
//
|
|
27
|
+
// NO DELETE — with ONE narrow exception. The platform must not hold the ability to destroy
|
|
28
|
+
// infrastructure in an account it does not own (ADR 0327 §3), and _req refuses the verb
|
|
29
|
+
// outright. The exception is the project's SANDBOX (ADR 0406 §7, task 1005289): a free
|
|
30
|
+
// service the platform itself created, removed when the owner tears the project down with
|
|
31
|
+
// their key lent. deleteSandboxService reads the service through the guard first and
|
|
32
|
+
// refuses anything not named `…-sandbox` on the free plan, so it can never reach the
|
|
33
|
+
// owner's live app; it is the only path to a DELETE in this file.
|
|
30
34
|
|
|
31
35
|
'use strict';
|
|
32
36
|
|
|
@@ -133,6 +137,32 @@ class RenderApi extends JsonHttpApi {
|
|
|
133
137
|
return this._req('PUT', `/services/${encodeURIComponent(serviceId)}/env-vars/${encodeURIComponent(key)}`, { value }, { dryRunResult: { key } });
|
|
134
138
|
}
|
|
135
139
|
|
|
140
|
+
// The service of this exact name in this workspace, or null. Lets a re-run find a
|
|
141
|
+
// sandbox an earlier run created before its id reached the row, instead of making a
|
|
142
|
+
// second one. Every element of the list is checked through the guard.
|
|
143
|
+
async findServiceByName(name) {
|
|
144
|
+
const q = `/services?name=${encodeURIComponent(name)}&ownerId=${encodeURIComponent(this.ownerId)}&limit=20`;
|
|
145
|
+
const json = await this._req('GET', q, null, { dryRunResult: { items: [] } });
|
|
146
|
+
const items = Array.isArray(json) ? json : (json && json.items) || [];
|
|
147
|
+
const found = items.map((e) => (e && e.service) || e).filter((s) => s && typeof s.id === 'string');
|
|
148
|
+
found.forEach((s) => this.assertOwned(s, `service ${s.id}`));
|
|
149
|
+
return found.find((s) => s.name === name) || null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Remove a project's SANDBOX service — and nothing else (see the header). The service is
|
|
153
|
+
// read through the workspace guard, and refused unless it is named `…-sandbox` AND on
|
|
154
|
+
// the free plan: the owner's live app is never either.
|
|
155
|
+
async deleteSandboxService(serviceId) {
|
|
156
|
+
const svc = await this.getService(serviceId);
|
|
157
|
+
const plan = svc.serviceDetails && svc.serviceDetails.plan;
|
|
158
|
+
if (!/-sandbox$/.test(String(svc.name || '')) || plan !== 'free') {
|
|
159
|
+
throw new RenderApiError(`refusing to delete service ${serviceId}: it is not a free sandbox the platform created (ADR 0406 §7)`);
|
|
160
|
+
}
|
|
161
|
+
const path = `/services/${encodeURIComponent(serviceId)}`;
|
|
162
|
+
if (this.dryRun) { this.log(`[dry-run] Render DELETE ${path}`); return { dryRun: true }; }
|
|
163
|
+
return super._req('DELETE', path, null);
|
|
164
|
+
}
|
|
165
|
+
|
|
136
166
|
async triggerDeploy(serviceId) {
|
|
137
167
|
await this.getService(serviceId);
|
|
138
168
|
return this._req('POST', `/services/${encodeURIComponent(serviceId)}/deploys`, {}, { dryRunResult: { id: null } });
|
|
@@ -82,4 +82,42 @@ function renderServicePayload(inst, { ownerId, repoUrl, branch, webPlan, region,
|
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
// POST /services, for the project's SANDBOX (ADR 0406, task 1005289): the same repository
|
|
86
|
+
// and build as the app, on Render's FREE plan, following `main` on every merge. It runs
|
|
87
|
+
// with BONGOS_SANDBOX=1 so the app (and the starter's outbound gate, task 1005291) knows
|
|
88
|
+
// it is not the live site. `dbUrl` is the sandbox's OWN database, never the live one.
|
|
89
|
+
const SANDBOX_SUFFIX = 'sandbox';
|
|
90
|
+
function sandboxServicePayload(inst, { ownerId, repoUrl, region, build, dbUrl }) {
|
|
91
|
+
const p = renderServicePayload(inst, { ownerId, repoUrl, branch: 'main', webPlan: 'free', region, build, dbUrl });
|
|
92
|
+
p.name = resourceName(inst, SANDBOX_SUFFIX);
|
|
93
|
+
p.autoDeploy = 'yes';
|
|
94
|
+
p.envVars = [{ key: 'BONGOS_SANDBOX', value: '1' }, ...p.envVars];
|
|
95
|
+
return p;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The sandbox's database name inside the app's own database server: the project's slug
|
|
99
|
+
// with underscores, ending `_sandbox`. Checked against a strict shape because it is put
|
|
100
|
+
// into a CREATE / DROP DATABASE statement as an identifier.
|
|
101
|
+
const SANDBOX_DB_RE = /^[a-z][a-z0-9_]{0,40}_sandbox$/;
|
|
102
|
+
function sandboxDbName(inst) {
|
|
103
|
+
let base = resourceName(inst).replace(/-/g, '_').replace(/^[^a-z]+/, '').slice(0, 40);
|
|
104
|
+
if (!base) base = `project_${Number(inst.id) || 0}`;
|
|
105
|
+
const name = `${base}_sandbox`;
|
|
106
|
+
if (!SANDBOX_DB_RE.test(name)) throw new Error(`could not make a safe sandbox database name from '${inst.slug}'`);
|
|
107
|
+
return name;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// How an adopted service is built, read off Render's own answer, so the sandbox copies
|
|
111
|
+
// it (ADR 0406 §7: "the sandbox is a new free service copying its build settings").
|
|
112
|
+
// Returns null when the answer does not say enough to copy.
|
|
113
|
+
function buildFromService(svc) {
|
|
114
|
+
const d = (svc && svc.serviceDetails) || {};
|
|
115
|
+
const runtime = d.runtime || d.env;
|
|
116
|
+
if (!RUNTIMES.includes(runtime)) return null;
|
|
117
|
+
if (runtime === 'docker') return { runtime: 'docker' };
|
|
118
|
+
const e = d.envSpecificDetails || {};
|
|
119
|
+
if (!e.startCommand) return null;
|
|
120
|
+
return { runtime, buildCommand: e.buildCommand || BUILD_DEFAULTS[runtime], startCommand: e.startCommand };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { DEFAULTS, resourceName, plansFor, buildFor, renderPostgresPayload, renderServicePayload, sandboxServicePayload, sandboxDbName, buildFromService, SANDBOX_DB_RE };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/first-read.js — @bongos-forwarder (task 1004848). Generated by
|
|
3
|
+
// scripts/bongos/codemod-rename-scripts-gds.js; do not edit.
|
|
4
|
+
// The real script is scripts/bongos/first-read.js. This forwarder keeps callers that still name
|
|
5
|
+
// the old path working, and warns once so they get repointed. Task 1003706 removes it.
|
|
6
|
+
'use strict';
|
|
7
|
+
require('../bongos/forwarder.js').forward(module, 'first-read.js');
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// scripts/gds/provision-render-sandbox.js — @bongos-forwarder (task 1004848). Generated by
|
|
2
|
+
// scripts/bongos/codemod-rename-scripts-gds.js; do not edit.
|
|
3
|
+
// The real script is scripts/bongos/provision-render-sandbox.js. This forwarder keeps callers that still name
|
|
4
|
+
// the old path working, and warns once so they get repointed. Task 1003706 removes it.
|
|
5
|
+
'use strict';
|
|
6
|
+
require('../bongos/forwarder.js').forward(module, 'provision-render-sandbox.js');
|
package/src/module-api.js
CHANGED
|
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
|
|
|
81
81
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
82
82
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
83
83
|
|
|
84
|
-
const CORE_VERSION = '1.25.
|
|
84
|
+
const CORE_VERSION = '1.25.17'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
85
85
|
|
|
86
86
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
87
87
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|