@bongos/core 1.25.20 → 1.25.22
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 +213 -78
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +18 -0
- package/clients/bongos-client/index.cjs +18 -0
- package/clients/bongos-client/index.d.ts +34 -8
- package/clients/bongos-client/index.mjs +18 -0
- package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +18 -0
- package/docs/adr/0407-a-project-grant-is-made-by-the-owner-not-witnessed-at-sign-in.md +3 -1
- package/docs/api/openapi.json +693 -125
- package/docs/api-reference.md +13 -4
- package/docs/copy-inventory.md +432 -427
- package/docs/copy-registry.json +571 -526
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +350 -344
- package/modules/government/board.js +8 -7
- package/modules/government/charter-advice.js +10 -3
- package/modules/government/charter.js +88 -4
- package/modules/government/migrations/government_039_vote_cast_via_filed.sql +22 -0
- package/modules/government/routes/government.js +12 -12
- package/modules/government/sole-member.js +75 -0
- package/modules/hall-ui/public/genesis-home.js +12 -5
- package/modules/lifecycle/kickoff-checklist.js +1 -1
- package/modules/lifecycle/routes/pay-type-gates.js +7 -2
- package/modules/onboarding/genesis-home.js +14 -2
- package/modules/onboarding/project-facts.js +318 -0
- package/modules/onboarding/routes/genesis-work.js +16 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/project-standing.js +49 -0
- package/modules/platform-identity/routes/my-projects.js +8 -0
- package/modules/provisioning/grants.js +126 -0
- package/modules/provisioning/migrations/provisioning_038_render_promote.sql +43 -0
- package/modules/provisioning/migrations/provisioning_039_project_grants.sql +40 -0
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/project-facts.js +55 -0
- package/modules/provisioning/project-refusal.js +107 -0
- package/modules/provisioning/provisioning.js +6 -3
- package/modules/provisioning/render-lookup.js +36 -1
- package/modules/provisioning/render-promote.js +80 -0
- package/modules/provisioning/render-sandbox.js +6 -0
- package/modules/provisioning/route-helpers.js +70 -16
- package/modules/provisioning/routes/env-manifest.js +21 -5
- package/modules/provisioning/routes/grants.js +141 -0
- package/modules/provisioning/routes/provisioning.js +8 -7
- package/modules/provisioning/routes/render-standup.js +82 -3
- package/modules/public-landing/public/projects.html +290 -32
- package/package-lock.json +2 -2
- package/package.json +2 -1
- package/release-notes.json +32 -0
- package/scripts/bongos/genesis.js +49 -3
- package/scripts/bongos/government-walk-sole.js +41 -0
- package/scripts/bongos/government-walk.js +15 -16
- package/scripts/bongos/init.js +13 -0
- package/scripts/bongos/package-core.js +2 -0
- package/scripts/bongos/provision-render-common.js +48 -0
- package/scripts/bongos/provision-render-promote.js +93 -0
- package/scripts/bongos/provision-render-sandbox.js +19 -3
- package/scripts/bongos/provision-render.js +4 -36
- package/scripts/bongos/provision.js +1 -0
- package/scripts/bongos/render-api.js +17 -2
- package/scripts/bongos/render-payload.js +5 -2
- package/scripts/gds/government-walk-sole.js +6 -0
- package/scripts/gds/provision-render-common.js +6 -0
- package/scripts/gds/provision-render-promote.js +6 -0
- package/src/bongos/folder-upload.js +243 -0
- package/src/bongos/routes/github.js +109 -16
- package/src/bongos/routes.js +3 -0
- package/src/branding.js +4 -0
- package/src/module-api.js +1 -1
- package/src/sandbox/gate.js +164 -0
- package/src/sandbox/index.js +144 -0
- package/tests/fleet_cost_view.mjs +2 -0
- package/tests/folder_upload_project.mjs +396 -0
- package/tests/genesis_government.mjs +5 -1
- package/tests/genesis_home.mjs +17 -0
- package/tests/genesis_work_routes.mjs +25 -1
- package/tests/government_charter_advice.mjs +49 -0
- package/tests/government_charter_forms.mjs +14 -3
- package/tests/government_seed.mjs +1 -0
- package/tests/government_sole_member.mjs +153 -0
- package/tests/project_catalog_routes.mjs +2 -0
- package/tests/project_facts.mjs +158 -0
- package/tests/projects_hub.mjs +1 -1
- package/tests/provision_settings_apply.mjs +2 -1
- package/tests/provisioning_env_manifest.mjs +4 -0
- package/tests/provisioning_env_manifest_edit_delete.mjs +2 -0
- package/tests/provisioning_project_grants.mjs +458 -0
- package/tests/provisioning_project_owner_gate.mjs +3 -1
- package/tests/provisioning_project_refusal.mjs +316 -0
- package/tests/provisioning_render_route.mjs +1 -1
- package/tests/provisioning_restart.mjs +2 -0
- package/tests/provisioning_settings.mjs +2 -0
- package/tests/provisioning_settings_apply.mjs +1 -1
- package/tests/provisioning_settings_env.mjs +2 -2
- package/tests/provisioning_teardown_intent.mjs +2 -2
- package/tests/public_landing_projects.mjs +27 -5
- package/tests/render_promote.mjs +309 -0
- package/tests/render_sandbox.mjs +16 -8
- package/tests/sandbox_gate.mjs +182 -0
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
const fs = require('fs');
|
|
50
50
|
const os = require('os');
|
|
51
51
|
const path = require('path');
|
|
52
|
+
const { decidedOf, adoptedByFiling, soleMemberRows } = require('./government-walk-sole'); // task 1005130: a board of one decides by filing
|
|
52
53
|
|
|
53
54
|
const ROOT = path.resolve(__dirname, '..', '..');
|
|
54
55
|
const DOC_PATH = path.join(ROOT, 'docs', 'governance', 'government-walk.md');
|
|
@@ -262,9 +263,9 @@ function makeStore(catalog) {
|
|
|
262
263
|
}
|
|
263
264
|
return item.opened_by ?? null;
|
|
264
265
|
},
|
|
265
|
-
async insertBoardVote({ itemId, voterId, direction, reasonMd = null, sectionKey = null }) {
|
|
266
|
+
async insertBoardVote({ itemId, voterId, direction, reasonMd = null, sectionKey = null, castVia = 'ballot' }) {
|
|
266
267
|
if (s.votes.some((v) => v.item_id === String(itemId) && v.voter_id === String(voterId))) return null;
|
|
267
|
-
const row = { id: next(), item_id: String(itemId), voter_id: String(voterId), direction, reason_md: reasonMd, section_key: sectionKey, cast_at: tick() };
|
|
268
|
+
const row = { id: next(), item_id: String(itemId), voter_id: String(voterId), direction, reason_md: reasonMd, section_key: sectionKey, cast_via: castVia, cast_at: tick() };
|
|
268
269
|
s.votes.push(row);
|
|
269
270
|
return copy(row);
|
|
270
271
|
},
|
|
@@ -774,20 +775,17 @@ async function runWalk() {
|
|
|
774
775
|
return { got: `${r.status}, stage ${stage}`, pass: r.status === 200 && stage === 'decide' };
|
|
775
776
|
});
|
|
776
777
|
let charterItem = null;
|
|
777
|
-
await check(`Deciding with ${CHOSEN} saves the plan and files the pick as a charter amendment`, 'plan saved, charter
|
|
778
|
+
await check(`Deciding with ${CHOSEN} saves the plan and files the pick as a charter amendment`, 'plan saved, charter adopted', async () => {
|
|
778
779
|
const r = await call(FOUNDER, 'POST', '/founding/plan', { government: CHOSEN });
|
|
779
780
|
const c = (r.json && r.json.charter) || {};
|
|
780
781
|
charterItem = c.item_id || null;
|
|
781
|
-
return { got: `${r.status}, charter ${c.state}
|
|
782
|
+
return { got: `${r.status}, charter ${c.state}`, pass: r.status === 200 && c.state === 'adopted' && !!charterItem };
|
|
782
783
|
});
|
|
783
784
|
await check('Saving the plan moved the project to its first stage', 'stage scope', async () => {
|
|
784
785
|
const g = await genesis.readGenesis();
|
|
785
786
|
return { got: `stage ${g && g.stage}`, pass: !!g && g.stage === 'scope' };
|
|
786
787
|
});
|
|
787
|
-
await check('The founder\'s one yes ratifies the pick under the Monarchy it is born with', 'passed', async () => {
|
|
788
|
-
const r = await vote(FOUNDER, charterItem);
|
|
789
|
-
return { got: `${r.status} ${r.json && r.json.outcome}`, pass: r.status === 201 && r.json.outcome === 'passed' };
|
|
790
|
-
});
|
|
788
|
+
await check('The founder\'s one yes ratifies the pick under the Monarchy it is born with: as its only member, choosing it was that yes', 'passed; the founder\'s yes, cast by filing', async () => adoptedByFiling({ store, stateOf, itemId: charterItem, voterId: FOUNDER }));
|
|
791
789
|
await check('The constitution now reads as the government chosen at decide', `reads as ${CHOSEN}, no divergence`, async () => {
|
|
792
790
|
const c = await constitution();
|
|
793
791
|
const f = c && c.charter && c.charter.reads_as && c.charter.reads_as.form;
|
|
@@ -928,8 +926,8 @@ async function runWalk() {
|
|
|
928
926
|
const r = await call(FOUNDER, 'POST', '/government/board/decision-rules', { rules });
|
|
929
927
|
const itemId = r.json && r.json.item && r.json.item.id;
|
|
930
928
|
if (r.status !== 201 || !itemId) return { ok: false, why: `${r.status} ${code(r)}` };
|
|
931
|
-
const
|
|
932
|
-
return { ok:
|
|
929
|
+
const d = decidedOf(r); // the founder is the Monarchy's only member, so filing it is their one yes
|
|
930
|
+
return { ok: d.closed === true && d.outcome === 'passed', why: `filed ${d.outcome || 'undecided'}` };
|
|
933
931
|
}
|
|
934
932
|
const rowOf = async (area) => {
|
|
935
933
|
const c = await constitution();
|
|
@@ -1094,12 +1092,13 @@ async function walkNewMachinery({ call, code, vote, voteAll, check, section, sta
|
|
|
1094
1092
|
const s = await positionsPort().lawsForSession();
|
|
1095
1093
|
return { got: `${book.in_force} in force; ${s.inject ? 'a laws block' : 'no laws block'}`, pass: book.in_force === 0 && s.inject === false };
|
|
1096
1094
|
});
|
|
1097
|
-
await check('A law is proposed: filed as a laws amendment, and proposing writes nothing', '201, sitting open; still 0 in force', async () => {
|
|
1098
|
-
const r = await call(
|
|
1095
|
+
await check('A law is proposed by a builder the board does not seat: filed as a laws amendment, and proposing writes nothing', '201, sitting open; still 0 in force', async () => {
|
|
1096
|
+
const r = await call(BEN, 'POST', '/government/board/laws', { enact: [SHORT_LAW] });
|
|
1099
1097
|
lawItem = itemIdOf(r);
|
|
1100
1098
|
const book = await lawsInForce();
|
|
1101
1099
|
return { got: `${r.status}, sitting ${stateOf(lawItem)}; still ${book.in_force} in force`, pass: r.status === 201 && stateOf(lawItem) === 'open' && book.in_force === 0 };
|
|
1102
1100
|
});
|
|
1101
|
+
await soleMemberRows({ check, call, vote, code, store, stateOf, itemIdOf, ids: { EVE, BEN, FOUNDER_ID }, SHORT_LAW });
|
|
1103
1102
|
await check('Someone the board does not seat cannot pass it', '403 not_board_member', async () => {
|
|
1104
1103
|
const r = await vote(BEN, lawItem);
|
|
1105
1104
|
return { got: `${r.status} ${code(r)}`, pass: r.status === 403 && code(r) === 'not_board_member' };
|
|
@@ -1134,9 +1133,9 @@ async function walkNewMachinery({ call, code, vote, voteAll, check, section, sta
|
|
|
1134
1133
|
const r = await call(BEN, 'PATCH', '/me/interaction', { verbosity: 'concise' });
|
|
1135
1134
|
return { got: `${r.status}`, pass: r.status === 200 };
|
|
1136
1135
|
});
|
|
1137
|
-
await check('Repealed by
|
|
1136
|
+
await check('Repealed by the founder, the board\'s only member, whose filing is their one yes: the law leaves every session and unlocks the setting', 'passed; 0 in force; no laws block; 200', async () => {
|
|
1138
1137
|
const r = await call(FOUNDER_ID, 'POST', '/government/board/laws', { repeal: [lawId] });
|
|
1139
|
-
const v =
|
|
1138
|
+
const v = { json: { outcome: decidedOf(r).outcome } };
|
|
1140
1139
|
const book = await lawsInForce();
|
|
1141
1140
|
const s = await positionsPort().lawsForSession();
|
|
1142
1141
|
const w = await call(BEN, 'PATCH', '/me/interaction', { verbosity: 'detailed' });
|
|
@@ -1152,7 +1151,7 @@ async function walkNewMachinery({ call, code, vote, voteAll, check, section, sta
|
|
|
1152
1151
|
await check(`Strictness: ${from} to ${key} is one vote, and its locks bite`, `one sitting; passed; ${key} in force; exploratory wandering ${wander}`, async () => {
|
|
1153
1152
|
const r = await call(FOUNDER_ID, 'POST', '/government/board/presets', { preset: key });
|
|
1154
1153
|
const id = itemIdOf(r);
|
|
1155
|
-
const v =
|
|
1154
|
+
const v = { json: { outcome: decidedOf(r).outcome } }; // the only member's filing is their one yes
|
|
1156
1155
|
const p = await presetNow();
|
|
1157
1156
|
const w = await call(BEN, 'PATCH', '/me/wandering', { wandering: 3 });
|
|
1158
1157
|
const ws = `${w.status}${w.status === 200 ? '' : ` ${code(w)}`}`;
|
|
@@ -1162,7 +1161,7 @@ async function walkNewMachinery({ call, code, vote, voteAll, check, section, sta
|
|
|
1162
1161
|
await check('Every preset law is repealed by one more vote, so the book ends empty', 'passed; 0 in force; no preset', async () => {
|
|
1163
1162
|
const ids = ((await lawsInForce()).laws || []).map((l) => l.id);
|
|
1164
1163
|
const r = await call(FOUNDER_ID, 'POST', '/government/board/laws', { repeal: ids });
|
|
1165
|
-
const v =
|
|
1164
|
+
const v = { json: { outcome: decidedOf(r).outcome } }; // the only member's filing is their one yes
|
|
1166
1165
|
const book = await lawsInForce();
|
|
1167
1166
|
const p = await presetNow();
|
|
1168
1167
|
return { got: `${v.json && v.json.outcome}; ${book.in_force} in force; ${p.current ? `${p.current} in force` : 'no preset'}`, pass: book.in_force === 0 && p.current == null };
|
package/scripts/bongos/init.js
CHANGED
|
@@ -545,6 +545,19 @@ function buildReadme(branding = {}) {
|
|
|
545
545
|
'',
|
|
546
546
|
'Requires Node >= 22 and Postgres installed locally (macOS/Linux; on Windows use WSL). If anything looks off, `bongos doctor` checks your setup and prints the fix.',
|
|
547
547
|
'',
|
|
548
|
+
// The project's sandbox (task 1005291, ADR 0406 §5): the one line that makes the app
|
|
549
|
+
// behave safely when it runs as its sandbox copy, and nothing at all on the live site.
|
|
550
|
+
"## Your app's sandbox",
|
|
551
|
+
'',
|
|
552
|
+
"If your app runs on Render, Bongos also runs a free sandbox copy of it that takes every finished change first. Put this at the top of a Node app so the sandbox only reads from payment, email and messaging providers, and shows a \"Sandbox\" band on every page:",
|
|
553
|
+
'',
|
|
554
|
+
'```js',
|
|
555
|
+
"const sandbox = require('@bongos/core/sandbox').install(); // does nothing on the live site",
|
|
556
|
+
'app.use(sandbox.middleware); // Express-style apps: the band, and /__bongos/sandbox',
|
|
557
|
+
'```',
|
|
558
|
+
'',
|
|
559
|
+
'Seed test data when `BONGOS_SANDBOX=1`, and give the sandbox test keys. If you lend a real key, lend a read-only one.',
|
|
560
|
+
'',
|
|
548
561
|
'## Where to look next',
|
|
549
562
|
'',
|
|
550
563
|
'- `CLAUDE.md` — how this project is built (the operating manual agents read first).',
|
|
@@ -330,6 +330,8 @@ function buildPackageJson({ version, sourceDeps, sourceOverrides }) {
|
|
|
330
330
|
'./bongos/api-path-404': './src/bongos/api-path-404.js',
|
|
331
331
|
'./module-loader/loader': './src/module-loader/loader.js',
|
|
332
332
|
'./modules': './src/modules.js',
|
|
333
|
+
// A project's app loads this to behave as its sandbox (task 1005291, ADR 0406 §5).
|
|
334
|
+
'./sandbox': './src/sandbox/index.js',
|
|
333
335
|
'./module-seams': './src/module-seams.js',
|
|
334
336
|
},
|
|
335
337
|
engines: { node: '>=22' },
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// provision-render-common.js — the pieces every Render runner leg shares: the client for a
|
|
3
|
+
// lent key, the one app_host writer, clearing the key, and an owner-readable reason
|
|
4
|
+
// (task 1005290; first written in provision-render.js by task 1004183).
|
|
5
|
+
//
|
|
6
|
+
// WHY ITS OWN FILE. The standup leg (provision-render.js), the sandbox leg
|
|
7
|
+
// (provision-render-sandbox.js) and the promote leg (provision-render-promote.js) all
|
|
8
|
+
// need these. Kept in the standup file, the sandbox leg reaching back for them made a
|
|
9
|
+
// require cycle (the standup file requires the sandbox file), which the fitness ratchet
|
|
10
|
+
// rightly refuses. Here, every leg depends on this file and on nothing of each other's.
|
|
11
|
+
|
|
12
|
+
const { RenderApi } = require('./render-api.js');
|
|
13
|
+
|
|
14
|
+
// A key waiting longer than this was lent for a run that never came. It is refused and
|
|
15
|
+
// cleared rather than spent late.
|
|
16
|
+
const KEY_TTL_MS = 30 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
function renderApiFor(deps, token, ownerId) {
|
|
19
|
+
if (deps.renderApiFactory) return deps.renderApiFactory({ token, ownerId });
|
|
20
|
+
return new RenderApi({ token, ownerId, dryRun: !deps.apply, fetchImpl: deps.fetchImpl, log: deps.log });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Merge `patch` into the row's app_host. The runner is the only writer while an intent
|
|
24
|
+
// runs, and the web tier only writes it when enqueuing, which the open-intent index
|
|
25
|
+
// serialises — so a merge needs no read-modify-write.
|
|
26
|
+
async function recordAppHost(db, instId, patch) {
|
|
27
|
+
await db.query(
|
|
28
|
+
`UPDATE provisioning_instances SET app_host = COALESCE(app_host, '{}'::jsonb) || $2::jsonb WHERE id = $1`,
|
|
29
|
+
[instId, JSON.stringify({ ...patch, updatedAt: new Date().toISOString() })]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function clearBorrowedKey(db, intentId) {
|
|
33
|
+
await db.query(`UPDATE provisioning_intents SET borrowed_key_enc = NULL WHERE id = $1`, [intentId]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The owner reads this reason on the setup screen (app-status.js oneSentence). A refusal
|
|
37
|
+
// from Render's API used to arrive as its raw line — "Render POST /services failed: …" —
|
|
38
|
+
// so an answer from Render is re-said as a sentence with Render's own reason kept whole
|
|
39
|
+
// (task 1004464). Anything else is already the leg's own words.
|
|
40
|
+
function plainReason(e) {
|
|
41
|
+
const msg = e && e.message ? e.message : String(e);
|
|
42
|
+
const said = e && e.status != null && e.body && typeof e.body.message === 'string' ? e.body.message.trim() : '';
|
|
43
|
+
if (said) return `Render would not set up the app: ${said}`;
|
|
44
|
+
const m = /^Render (?:GET|POST|PATCH|PUT|DELETE) \S+ failed: ([\s\S]+)$/.exec(msg);
|
|
45
|
+
return m ? `Render would not set up the app: ${m[1].trim()}` : msg;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { KEY_TTL_MS, renderApiFor, recordAppHost, clearBorrowedKey, plainReason };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// provision-render-promote.js — a person promotes what ran on the sandbox to the live app:
|
|
3
|
+
// the runner half of the `render-promote` intent (task 1005290; ADR 0406 §2, ADR 0327).
|
|
4
|
+
//
|
|
5
|
+
// WHY. Once a project has a sandbox, finished work lands on the sandbox only (it follows
|
|
6
|
+
// `main`), and the live Render service no longer deploys on every merge. The live app
|
|
7
|
+
// moves only here, when a person pressed promote and lent their Render key for that one
|
|
8
|
+
// action. That is the project law "Land only. Ask before going live" made physical: no
|
|
9
|
+
// ship, in Auto or Manual mode, reaches this file; only an owner's press does.
|
|
10
|
+
//
|
|
11
|
+
// WHAT IT DOES, in order, and why the order matters:
|
|
12
|
+
// 1. checks the chosen change RAN on the sandbox (it is one of the sandbox's own deploys
|
|
13
|
+
// that went live there), and that the live app still runs what the owner was shown;
|
|
14
|
+
// 2. RECORDS the promotion (provisioning_promotions) — before anything is sent, so
|
|
15
|
+
// nothing reaches the live app through Bongos without a row saying who and what;
|
|
16
|
+
// 3. switches the live service to manual deploys (idempotent), so the next merge cannot
|
|
17
|
+
// reach it by itself;
|
|
18
|
+
// 4. asks Render to deploy exactly that commit to the live service.
|
|
19
|
+
//
|
|
20
|
+
// Like the standup leg: the key is decrypted here, used for this run, and cleared in a
|
|
21
|
+
// `finally`; every failure is terminal (the owner presses again with the key); every call
|
|
22
|
+
// goes through scripts/bongos/render-api.js, which pins the workspace.
|
|
23
|
+
|
|
24
|
+
const COMMIT_RE = /^[0-9a-f]{7,40}$/;
|
|
25
|
+
// Only a deploy that served on the sandbox can be promoted: one still building, or one
|
|
26
|
+
// whose build failed, never ran there for anyone to look at.
|
|
27
|
+
const SERVED = ['live', 'deactivated'];
|
|
28
|
+
|
|
29
|
+
const commitOf = (d) => (d && d.commit && d.commit.id) || null;
|
|
30
|
+
|
|
31
|
+
async function promoteInstance(inst, deps, intent) {
|
|
32
|
+
const { db, log } = deps;
|
|
33
|
+
const render = require('./provision-render-common.js');
|
|
34
|
+
const secretBox = deps.secretBox || require('../../src/bongos/secret-box');
|
|
35
|
+
const host = inst.app_host || {};
|
|
36
|
+
const ask = host.promote || {};
|
|
37
|
+
let promotionId = null;
|
|
38
|
+
try {
|
|
39
|
+
if (!intent || !intent.borrowed_key_enc) return await refuse('no Render key came with the promotion — press promote again');
|
|
40
|
+
const age = Date.now() - new Date(intent.created_at || 0).getTime();
|
|
41
|
+
if (!(age < render.KEY_TTL_MS)) return await refuse('the Render key waited too long to be used and was cleared — press promote again');
|
|
42
|
+
if (host.platform !== 'render' || !host.serviceId || !host.ownerId) return await refuse('this project has no app on Render to promote to');
|
|
43
|
+
const sb = host.sandbox || {};
|
|
44
|
+
if (!sb.serviceId) return await refuse('this project has no sandbox to promote from');
|
|
45
|
+
if (!COMMIT_RE.test(String(ask.commitId || ''))) return await refuse('no change was chosen to promote');
|
|
46
|
+
|
|
47
|
+
const api = render.renderApiFor(deps, secretBox.decrypt(intent.borrowed_key_enc), host.ownerId);
|
|
48
|
+
const owners = await api.listOwners();
|
|
49
|
+
if (!owners.some((o) => o.id === host.ownerId)) return await refuse(`this Render key cannot reach workspace ${host.ownerId}`);
|
|
50
|
+
|
|
51
|
+
// 1. it ran on the sandbox, and live is what the owner was shown
|
|
52
|
+
const sandboxDeploys = await api.listDeploys(sb.serviceId, { limit: 50 });
|
|
53
|
+
const ran = sandboxDeploys.find((d) => commitOf(d) === ask.commitId && SERVED.includes(d.status));
|
|
54
|
+
if (!ran) return await refuse('that change has not run on the sandbox, so it cannot be promoted');
|
|
55
|
+
const liveDeploys = await api.listDeploys(host.serviceId, { limit: 10 });
|
|
56
|
+
const liveCommit = commitOf(liveDeploys.find((d) => d.status === 'live'));
|
|
57
|
+
if ((ask.expectLiveCommit || null) !== liveCommit) return await refuse('what your live app runs changed since you looked, so nothing was done — look again and promote');
|
|
58
|
+
if (liveCommit === ask.commitId) return await refuse('that change is already on your live app');
|
|
59
|
+
|
|
60
|
+
// 2. the record comes first
|
|
61
|
+
const message = String((ran.commit && ran.commit.message) || '').split('\n')[0].slice(0, 300);
|
|
62
|
+
const { rows } = await db.query(
|
|
63
|
+
`INSERT INTO provisioning_promotions (instance_id, commit_id, commit_message, from_commit_id, sandbox_deploy_id, requested_by, state)
|
|
64
|
+
VALUES ($1, $2, $3, $4, $5, $6, 'started') RETURNING id`,
|
|
65
|
+
[inst.id, ask.commitId, message, liveCommit, ran.id || null, ask.requestedBy == null ? null : String(ask.requestedBy)]);
|
|
66
|
+
promotionId = rows && rows[0] ? rows[0].id : null;
|
|
67
|
+
|
|
68
|
+
// 3. live stops following main, so only promotions move it
|
|
69
|
+
await api.setAutoDeploy(host.serviceId, false);
|
|
70
|
+
// 4. exactly that commit
|
|
71
|
+
const dep = await api.triggerDeploy(host.serviceId, { commitId: ask.commitId });
|
|
72
|
+
const deployId = (dep && dep.id) || null;
|
|
73
|
+
await db.query(`UPDATE provisioning_promotions SET state = 'sent', render_deploy_id = $2, updated_at = now() WHERE id = $1`, [promotionId, deployId]);
|
|
74
|
+
await render.recordAppHost(db, inst.id, { liveAutoDeploy: 'off', promote: { ...ask, state: 'sent', error: null, renderDeployId: deployId } });
|
|
75
|
+
log(` [render] ${inst.slug}: promoted ${ask.commitId.slice(0, 7)} from the sandbox to the live app (deploy ${deployId || 'started'})`);
|
|
76
|
+
return { ok: true };
|
|
77
|
+
} catch (e) {
|
|
78
|
+
return await refuse(render.plainReason(e).replace(/^Render would not set up the app/, 'Render would not promote it'));
|
|
79
|
+
} finally {
|
|
80
|
+
if (intent && intent.id != null) await render.clearBorrowedKey(db, intent.id);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function refuse(error) {
|
|
84
|
+
if (promotionId != null) {
|
|
85
|
+
await db.query(`UPDATE provisioning_promotions SET state = 'failed', error = $2, updated_at = now() WHERE id = $1`, [promotionId, error]).catch(() => {});
|
|
86
|
+
}
|
|
87
|
+
await render.recordAppHost(db, inst.id, { promote: { ...ask, state: 'failed', error } }).catch(() => {});
|
|
88
|
+
log(` ✖ [render] ${inst.slug} promote: ${error}`);
|
|
89
|
+
return { ok: false, terminal: true, error };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { promoteInstance, COMMIT_RE };
|
|
@@ -20,14 +20,15 @@
|
|
|
20
20
|
// 3. SERVICE — a free web service named `<slug>-sandbox`, auto-deploying from `main`,
|
|
21
21
|
// with BONGOS_SANDBOX=1. Found by name first, so a re-run never makes a second one.
|
|
22
22
|
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
23
|
+
// Once the sandbox stands, the LIVE service is switched to manual deploys, so only a
|
|
24
|
+
// person's promotion moves it (task 1005290; scripts/bongos/provision-render-promote.js).
|
|
25
25
|
//
|
|
26
26
|
// TEARDOWN removes the sandbox service and the sandbox database only — never the live app
|
|
27
27
|
// or its data (ADR 0406 §7). It needs the owner's key; without one the sandbox is
|
|
28
28
|
// forgotten and the owner is told exactly what to delete.
|
|
29
29
|
|
|
30
30
|
const payload = require('./render-payload.js');
|
|
31
|
+
const { recordAppHost } = require('./provision-render-common.js');
|
|
31
32
|
const { parseTargetRef } = require('./provision-repo-auth.js');
|
|
32
33
|
|
|
33
34
|
async function recordSandbox(db, instId, patch) {
|
|
@@ -111,15 +112,30 @@ async function standupSandbox(api, inst, host, deps) {
|
|
|
111
112
|
let svc = sb.serviceId ? await api.getService(sb.serviceId).catch(() => null) : null;
|
|
112
113
|
if (!svc) svc = await api.findServiceByName(name);
|
|
113
114
|
if (!svc) {
|
|
115
|
+
// The sandbox page's key (task 1005291): kept on the row, which only the owner's
|
|
116
|
+
// project page reads, and set on the sandbox, which checks it.
|
|
117
|
+
const pageToken = require('node:crypto').randomBytes(24).toString('hex');
|
|
114
118
|
const created = await api.createService(payload.sandboxServicePayload(inst, {
|
|
115
119
|
ownerId: api.ownerId, repoUrl: `https://github.com/${repo.owner}/${repo.name}`, region, build,
|
|
116
|
-
dbUrl: dbStage.state === 'ready' ? dbStage.url : null,
|
|
120
|
+
dbUrl: dbStage.state === 'ready' ? dbStage.url : null, pageToken,
|
|
117
121
|
}));
|
|
122
|
+
await recordSandbox(db, inst.id, { pageKey: pageToken });
|
|
118
123
|
svc = created.service;
|
|
119
124
|
log(` [render] created sandbox service ${svc.id} (free, follows main)`);
|
|
120
125
|
}
|
|
121
126
|
const url = (svc.serviceDetails && svc.serviceDetails.url) || null;
|
|
122
127
|
await recordSandbox(db, inst.id, { state: 'deploying', serviceId: svc.id, serviceUrl: url, error: null });
|
|
128
|
+
// From here on finished work reaches the sandbox only: the live service stops deploying
|
|
129
|
+
// on every merge, and moves when a person promotes (task 1005290, ADR 0406 §2). Only once
|
|
130
|
+
// the sandbox exists, so live is never left with nowhere for work to land. A failure is
|
|
131
|
+
// said on the row and the sandbox stands; the next promotion switches it again.
|
|
132
|
+
try {
|
|
133
|
+
await api.setAutoDeploy(host.serviceId, false);
|
|
134
|
+
await recordAppHost(db, inst.id, { liveAutoDeploy: 'off' }); // the one app_host writer
|
|
135
|
+
log(' [render] the live app now moves only when a person promotes');
|
|
136
|
+
} catch (e) {
|
|
137
|
+
log(` (the live app still deploys every merge: ${e && e.message})`);
|
|
138
|
+
}
|
|
123
139
|
return { ok: true };
|
|
124
140
|
} catch (e) {
|
|
125
141
|
const error = `The sandbox could not be set up: ${String((e && e.message) || e).split('\n')[0].slice(0, 240)}`;
|
|
@@ -31,32 +31,12 @@
|
|
|
31
31
|
// Every Render call goes through scripts/bongos/render-api.js, which pins the workspace and
|
|
32
32
|
// asserts it on every response, and which cannot delete.
|
|
33
33
|
|
|
34
|
-
const { RenderApi } = require('./render-api.js');
|
|
35
34
|
const payload = require('./render-payload.js');
|
|
36
35
|
const { parseTargetRef } = require('./provision-repo-auth.js');
|
|
37
36
|
const sandbox = require('./provision-render-sandbox.js');
|
|
38
|
-
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
const KEY_TTL_MS = 30 * 60 * 1000;
|
|
42
|
-
|
|
43
|
-
function renderApiFor(deps, token, ownerId) {
|
|
44
|
-
if (deps.renderApiFactory) return deps.renderApiFactory({ token, ownerId });
|
|
45
|
-
return new RenderApi({ token, ownerId, dryRun: !deps.apply, fetchImpl: deps.fetchImpl, log: deps.log });
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Merge `patch` into the row's app_host. The runner is the only writer while an intent
|
|
49
|
-
// runs, and the web tier only writes it when enqueuing, which the open-intent index
|
|
50
|
-
// serialises — so a merge needs no read-modify-write.
|
|
51
|
-
async function recordAppHost(db, instId, patch) {
|
|
52
|
-
await db.query(
|
|
53
|
-
`UPDATE provisioning_instances SET app_host = COALESCE(app_host, '{}'::jsonb) || $2::jsonb WHERE id = $1`,
|
|
54
|
-
[instId, JSON.stringify({ ...patch, updatedAt: new Date().toISOString() })]);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
async function clearBorrowedKey(db, intentId) {
|
|
58
|
-
await db.query(`UPDATE provisioning_intents SET borrowed_key_enc = NULL WHERE id = $1`, [intentId]);
|
|
59
|
-
}
|
|
37
|
+
// The pieces every Render leg shares live in their own file, so the sandbox and promote
|
|
38
|
+
// legs can use them without a require cycle back into this one (task 1005290).
|
|
39
|
+
const { KEY_TTL_MS, renderApiFor, recordAppHost, clearBorrowedKey, plainReason } = require('./provision-render-common.js');
|
|
60
40
|
|
|
61
41
|
// Whether a Render service's repository is this project's. Render reports it as a URL;
|
|
62
42
|
// compared as owner/name, case-insensitively, the way GitHub resolves them.
|
|
@@ -118,18 +98,6 @@ async function greenfield(api, inst, host, deps) {
|
|
|
118
98
|
return { ok: true };
|
|
119
99
|
}
|
|
120
100
|
|
|
121
|
-
// The owner reads this reason on the setup screen (app-status.js oneSentence). A refusal
|
|
122
|
-
// from Render's API used to arrive as its raw line — "Render POST /services failed: …" —
|
|
123
|
-
// so an answer from Render is re-said as a sentence with Render's own reason kept whole
|
|
124
|
-
// (task 1004464). Anything else is already the leg's own words.
|
|
125
|
-
function plainReason(e) {
|
|
126
|
-
const msg = e && e.message ? e.message : String(e);
|
|
127
|
-
const said = e && e.status != null && e.body && typeof e.body.message === 'string' ? e.body.message.trim() : '';
|
|
128
|
-
if (said) return `Render would not set up the app: ${said}`;
|
|
129
|
-
const m = /^Render (?:GET|POST|PATCH|PUT|DELETE) \S+ failed: ([\s\S]+)$/.exec(msg);
|
|
130
|
-
return m ? `Render would not set up the app: ${m[1].trim()}` : msg;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
101
|
async function renderStandupInstance(inst, deps, intent) {
|
|
134
102
|
const { db, log } = deps;
|
|
135
103
|
const secretBox = deps.secretBox || require('../../src/bongos/secret-box');
|
|
@@ -233,4 +201,4 @@ async function forgetRenderApp(inst, deps) {
|
|
|
233
201
|
if (apply) await recordAppHost(db, inst.id, { state: 'disconnected' });
|
|
234
202
|
}
|
|
235
203
|
|
|
236
|
-
module.exports = { renderStandupInstance, afterProvision, forgetRenderApp, clearBorrowedKey, recordAppHost, KEY_TTL_MS };
|
|
204
|
+
module.exports = { renderStandupInstance, afterProvision, forgetRenderApp, clearBorrowedKey, recordAppHost, renderApiFor, plainReason, KEY_TTL_MS };
|
|
@@ -1255,6 +1255,7 @@ async function cmdRunIntents(deps) {
|
|
|
1255
1255
|
else if (intent.action === 'core-upgrade') result = await coreUpgradeInstance(inst, deps, intent); // also reads the INTENT: the version the owner previewed (ADR 0293 D4)
|
|
1256
1256
|
else if (intent.action === 'core-upgrade-preflight') result = await preflightCoreUpgrade(inst, deps, intent); // changes nothing; caches what the move WOULD say (task 1004121)
|
|
1257
1257
|
else if (intent.action === 'repo-private' || intent.action === 'render-standup') result = intent.action === 'repo-private' ? await require('./provision-repo-private.js').repoPrivateInstance(inst, deps) : await require('./provision-render.js').renderStandupInstance(inst, deps, intent); // wire the deploy key, THEN flip (task 1004193) · spend the borrowed Render key once (task 1004183); required here — this file is at the size ratchet
|
|
1258
|
+
else if (intent.action === 'render-promote') result = await require('./provision-render-promote.js').promoteInstance(inst, deps, intent); // a person's promotion of a sandbox change to the live app (task 1005290, ADR 0406)
|
|
1258
1259
|
else result = { ok: false, error: `unknown_action '${intent.action}' — runner core ${coreVersionSafe() || '?'} does not know it (runner too old?); refusing to guess` };
|
|
1259
1260
|
if (result && result.ok === false) {
|
|
1260
1261
|
// WAIT OR RETIRE (task 1004447, intent-retry.js): a transient failure waits ~15m/1h/6h; a
|
|
@@ -163,9 +163,24 @@ class RenderApi extends JsonHttpApi {
|
|
|
163
163
|
return super._req('DELETE', path, null);
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
|
|
166
|
+
// Start a deploy. With `commitId` Render builds THAT commit rather than the branch's
|
|
167
|
+
// newest — how a promotion moves the live app to exactly what ran on the sandbox (ADR
|
|
168
|
+
// 0406 §2, task 1005290). The answer carries no owner, so the service is read first.
|
|
169
|
+
async triggerDeploy(serviceId, { commitId } = {}) {
|
|
167
170
|
await this.getService(serviceId);
|
|
168
|
-
|
|
171
|
+
const body = commitId ? { commitId } : {};
|
|
172
|
+
return this._req('POST', `/services/${encodeURIComponent(serviceId)}/deploys`, body, { dryRunResult: { id: null } });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Whether the service deploys on every push to its branch. The live app is switched to
|
|
176
|
+
// `no` once its project has a sandbox, so only a person's promotion moves it (ADR 0406
|
|
177
|
+
// §2, task 1005290). A write to the owner's service, through the guard, nothing else.
|
|
178
|
+
async setAutoDeploy(serviceId, on) {
|
|
179
|
+
await this.getService(serviceId);
|
|
180
|
+
const json = await this._req('PATCH', `/services/${encodeURIComponent(serviceId)}`, { autoDeploy: on ? 'yes' : 'no' }, {
|
|
181
|
+
dryRunResult: { id: serviceId, ownerId: this.ownerId, autoDeploy: on ? 'yes' : 'no' },
|
|
182
|
+
});
|
|
183
|
+
return this.assertOwned(json && json.service ? json.service : json, `service ${serviceId}`);
|
|
169
184
|
}
|
|
170
185
|
|
|
171
186
|
// A service's deploys, newest first, as the bare deploy objects (Render wraps each in
|
|
@@ -87,11 +87,14 @@ function renderServicePayload(inst, { ownerId, repoUrl, branch, webPlan, region,
|
|
|
87
87
|
// with BONGOS_SANDBOX=1 so the app (and the starter's outbound gate, task 1005291) knows
|
|
88
88
|
// it is not the live site. `dbUrl` is the sandbox's OWN database, never the live one.
|
|
89
89
|
const SANDBOX_SUFFIX = 'sandbox';
|
|
90
|
-
|
|
90
|
+
// `pageToken` is the sandbox's own page key: its /__bongos/sandbox shows what was refused
|
|
91
|
+
// and which real keys are lent only to a visitor who carries it (the owner, via their
|
|
92
|
+
// owner-only project page). Not a credential to anything else (task 1005291).
|
|
93
|
+
function sandboxServicePayload(inst, { ownerId, repoUrl, region, build, dbUrl, pageToken }) {
|
|
91
94
|
const p = renderServicePayload(inst, { ownerId, repoUrl, branch: 'main', webPlan: 'free', region, build, dbUrl });
|
|
92
95
|
p.name = resourceName(inst, SANDBOX_SUFFIX);
|
|
93
96
|
p.autoDeploy = 'yes';
|
|
94
|
-
p.envVars = [{ key: 'BONGOS_SANDBOX', value: '1' }, ...p.envVars];
|
|
97
|
+
p.envVars = [{ key: 'BONGOS_SANDBOX', value: '1' }, ...(pageToken ? [{ key: 'BONGOS_SANDBOX_PAGE_KEY', value: pageToken }] : []), ...p.envVars];
|
|
95
98
|
return p;
|
|
96
99
|
}
|
|
97
100
|
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// scripts/gds/government-walk-sole.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/government-walk-sole.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, 'government-walk-sole.js');
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// scripts/gds/provision-render-common.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-common.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-common.js');
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// scripts/gds/provision-render-promote.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-promote.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-promote.js');
|