@bongos/core 1.25.20 → 1.25.21
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 +157 -62
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +10 -0
- package/clients/bongos-client/index.cjs +10 -0
- package/clients/bongos-client/index.d.ts +23 -8
- package/clients/bongos-client/index.mjs +10 -0
- package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +10 -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 +409 -36
- package/docs/api-reference.md +7 -2
- package/docs/copy-inventory.md +20 -20
- package/docs/copy-registry.json +23 -23
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +2 -2
- 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/onboarding/genesis-home.js +14 -2
- 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-refusal.js +107 -0
- package/modules/provisioning/provisioning.js +2 -2
- package/modules/provisioning/render-lookup.js +36 -1
- package/modules/provisioning/render-promote.js +80 -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 +99 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +18 -0
- package/scripts/bongos/genesis.js +11 -1
- package/scripts/bongos/government-walk-sole.js +41 -0
- package/scripts/bongos/government-walk.js +15 -16
- 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 +14 -2
- 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/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/module-api.js +1 -1
- package/tests/fleet_cost_view.mjs +2 -0
- package/tests/genesis_government.mjs +5 -1
- package/tests/genesis_home.mjs +17 -0
- 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/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_teardown_intent.mjs +2 -2
- package/tests/render_promote.mjs +309 -0
- package/tests/render_sandbox.mjs +9 -4
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// tests/render_promote.mjs — a person promotes what ran on the sandbox to the live app
|
|
2
|
+
// (task 1005290; ADR 0406 §2). The runner leg (scripts/bongos/provision-render-promote.js)
|
|
3
|
+
// is driven over the REAL Render client and a recording fake of Render's API; the web
|
|
4
|
+
// tier's look (render-lookup.js lookupPromotion) over a fake fetch; the routes over
|
|
5
|
+
// express with the module's seams stubbed. No network, no database, no spend.
|
|
6
|
+
//
|
|
7
|
+
// The task's DONE WHEN:
|
|
8
|
+
// • a shipped change is on the sandbox while the main site is unchanged — the sandbox
|
|
9
|
+
// setup switches the live service to manual deploys (tests/render_sandbox.mjs asserts
|
|
10
|
+
// the PATCH), and the look lists what ran on the sandbox since live's change;
|
|
11
|
+
// • promoting changes the main site — the live service is deployed at EXACTLY the chosen
|
|
12
|
+
// commit;
|
|
13
|
+
// • nothing reaches the main site without a recorded promotion — the record is written
|
|
14
|
+
// BEFORE Render is asked, a refusal sends nothing, and only the owner may press.
|
|
15
|
+
//
|
|
16
|
+
// Run: node tests/render_promote.mjs
|
|
17
|
+
|
|
18
|
+
import { strict as assert } from 'node:assert';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import http from 'node:http';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const { RenderApi } = require('../scripts/bongos/render-api.js');
|
|
24
|
+
const { promoteInstance } = require('../scripts/bongos/provision-render-promote.js');
|
|
25
|
+
const { lookupPromotion } = require('../modules/provisioning/render-lookup.js');
|
|
26
|
+
const renderPromote = require('../modules/provisioning/render-promote.js');
|
|
27
|
+
|
|
28
|
+
let passed = 0, failed = 0;
|
|
29
|
+
async function ta(name, fn) {
|
|
30
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
31
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const OURS = 'tea-ours00000001';
|
|
35
|
+
const OLD = 'aaaaaaa1111111111111111111111111111111aa';
|
|
36
|
+
const MID = 'bbbbbbb2222222222222222222222222222222bb';
|
|
37
|
+
const NEW = 'ccccccc3333333333333333333333333333333cc';
|
|
38
|
+
const BAD = 'ddddddd4444444444444444444444444444444dd';
|
|
39
|
+
const dep = (id, commit, status, message = `task 100 - change ${commit.slice(0, 3)}`) => ({ deploy: { id, status, commit: { id: commit, message, createdAt: '2026-11-13T10:00:00Z' } } });
|
|
40
|
+
|
|
41
|
+
function fakeRender(over = {}) {
|
|
42
|
+
const calls = [];
|
|
43
|
+
const answers = {
|
|
44
|
+
'GET /owners?limit=100': [{ owner: { id: OURS } }],
|
|
45
|
+
'GET /services/srv-live0001': { id: 'srv-live0001', name: 'demo', ownerId: OURS },
|
|
46
|
+
'GET /services/srv-sbx00001': { id: 'srv-sbx00001', name: 'demo-sandbox', ownerId: OURS },
|
|
47
|
+
'GET /services/srv-sbx00001/deploys?limit=50': [dep('dep-s4', BAD, 'build_failed'), dep('dep-s3', NEW, 'live'), dep('dep-s2', MID, 'deactivated'), dep('dep-s1', OLD, 'deactivated')],
|
|
48
|
+
'GET /services/srv-live0001/deploys?limit=10': [dep('dep-l1', OLD, 'live')],
|
|
49
|
+
'PATCH /services/srv-live0001': { id: 'srv-live0001', ownerId: OURS, autoDeploy: 'no' },
|
|
50
|
+
'POST /services/srv-live0001/deploys': { id: 'dep-live-new' },
|
|
51
|
+
...over,
|
|
52
|
+
};
|
|
53
|
+
const fetchImpl = async (url, init) => {
|
|
54
|
+
const path = url.replace('https://api.render.com/v1', '');
|
|
55
|
+
const body = init.body ? JSON.parse(init.body) : null;
|
|
56
|
+
calls.push({ method: init.method, path, body });
|
|
57
|
+
const a = answers[`${init.method} ${path}`];
|
|
58
|
+
if (a === undefined) return { ok: false, status: 404, text: async () => '{"message":"not found"}' };
|
|
59
|
+
if (a instanceof Error) return { ok: false, status: 500, text: async () => JSON.stringify({ message: a.message }) };
|
|
60
|
+
return { ok: true, status: 200, text: async () => JSON.stringify(a) };
|
|
61
|
+
};
|
|
62
|
+
return { calls, fetchImpl };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function harness({ ask = {}, host = {}, render = {}, intent = {} } = {}) {
|
|
66
|
+
const r = fakeRender(render);
|
|
67
|
+
const order = [], promotions = [], cleared = [], logs = [];
|
|
68
|
+
let nextId = 1;
|
|
69
|
+
const inst = { id: 18, slug: 'demo', app_host: { platform: 'render', ownerId: OURS, serviceId: 'srv-live0001', state: 'live',
|
|
70
|
+
sandbox: { state: 'live', serviceId: 'srv-sbx00001' }, promote: { state: 'requested', commitId: NEW, expectLiveCommit: OLD, requestedBy: '7', ...ask }, ...host } };
|
|
71
|
+
const db = { query: async (text, params) => {
|
|
72
|
+
const t = text.replace(/\s+/g, ' ');
|
|
73
|
+
if (/INSERT INTO provisioning_promotions/.test(t)) {
|
|
74
|
+
order.push('record');
|
|
75
|
+
const row = { id: nextId++, commit_id: params[1], commit_message: params[2], from_commit_id: params[3], sandbox_deploy_id: params[4], requested_by: params[5], state: 'started' };
|
|
76
|
+
promotions.push(row);
|
|
77
|
+
return { rows: [{ id: row.id }] };
|
|
78
|
+
}
|
|
79
|
+
if (/UPDATE provisioning_promotions SET state = 'sent'/.test(t)) { Object.assign(promotions.find((p) => p.id === params[0]), { state: 'sent', render_deploy_id: params[1] }); return { rows: [] }; }
|
|
80
|
+
if (/UPDATE provisioning_promotions SET state = 'failed'/.test(t)) { Object.assign(promotions.find((p) => p.id === params[0]), { state: 'failed', error: params[1] }); return { rows: [] }; }
|
|
81
|
+
if (/SET app_host = COALESCE\(app_host, '\{\}'::jsonb\) \|\| \$2::jsonb/.test(t)) { inst.app_host = { ...inst.app_host, ...JSON.parse(params[1]) }; return { rows: [] }; }
|
|
82
|
+
if (/borrowed_key_enc = NULL/.test(t)) { cleared.push(params[0]); return { rows: [] }; }
|
|
83
|
+
return { rows: [] };
|
|
84
|
+
} };
|
|
85
|
+
const wrapped = { calls: r.calls, fetchImpl: async (url, init) => { if (init.method !== 'GET') order.push(`${init.method} ${url.replace('https://api.render.com/v1', '')}`); return r.fetchImpl(url, init); } };
|
|
86
|
+
const deps = { db, log: (l) => logs.push(l), secretBox: { decrypt: () => 'rnd_borrowed' },
|
|
87
|
+
renderApiFactory: ({ token, ownerId }) => new RenderApi({ token, ownerId, dryRun: false, fetchImpl: wrapped.fetchImpl }) };
|
|
88
|
+
const it = { id: 61, action: 'render-promote', borrowed_key_enc: 'v1.enc', created_at: new Date().toISOString(), ...intent };
|
|
89
|
+
return { r, inst, order, promotions, cleared, run: () => promoteInstance(inst, deps, it) };
|
|
90
|
+
}
|
|
91
|
+
const writes = (h) => h.r.calls.filter((c) => c.method !== 'GET').map((c) => `${c.method} ${c.path}`);
|
|
92
|
+
|
|
93
|
+
console.log('the runner — promote:');
|
|
94
|
+
await ta('promoting deploys EXACTLY the chosen commit to the live app, recorded first, live switched to manual', async () => {
|
|
95
|
+
const h = harness();
|
|
96
|
+
const out = await h.run();
|
|
97
|
+
assert.equal(out.ok, true, out.error);
|
|
98
|
+
assert.deepEqual(h.order, ['record', 'PATCH /services/srv-live0001', 'POST /services/srv-live0001/deploys'], 'the record comes before anything is sent');
|
|
99
|
+
const deploy = h.r.calls.find((c) => c.method === 'POST');
|
|
100
|
+
assert.deepEqual(deploy.body, { commitId: NEW }, 'that commit, not whatever main is now');
|
|
101
|
+
assert.deepEqual(h.r.calls.find((c) => c.method === 'PATCH').body, { autoDeploy: 'no' });
|
|
102
|
+
assert.equal(h.promotions.length, 1);
|
|
103
|
+
assert.deepEqual([h.promotions[0].commit_id, h.promotions[0].from_commit_id, h.promotions[0].requested_by, h.promotions[0].state, h.promotions[0].render_deploy_id],
|
|
104
|
+
[NEW, OLD, '7', 'sent', 'dep-live-new'], 'who, which change, from which, and what Render answered');
|
|
105
|
+
assert.equal(h.inst.app_host.liveAutoDeploy, 'off');
|
|
106
|
+
assert.equal(h.inst.app_host.promote.state, 'sent');
|
|
107
|
+
assert.deepEqual(h.cleared, [61], 'the key is cleared');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await ta('an older change that ran on the sandbox can be promoted too', async () => {
|
|
111
|
+
const h = harness({ ask: { commitId: MID } });
|
|
112
|
+
assert.equal((await h.run()).ok, true);
|
|
113
|
+
assert.deepEqual(h.r.calls.find((c) => c.method === 'POST').body, { commitId: MID });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await ta('a change that never ran on the sandbox is refused, and nothing is sent or recorded', async () => {
|
|
117
|
+
for (const commitId of [BAD, 'eeeeeee5555555555555555555555555555555ee']) {
|
|
118
|
+
const h = harness({ ask: { commitId } });
|
|
119
|
+
const out = await h.run();
|
|
120
|
+
assert.equal(out.ok, false);
|
|
121
|
+
assert.equal(out.terminal, true);
|
|
122
|
+
assert.match(out.error, /has not run on the sandbox/);
|
|
123
|
+
assert.deepEqual(writes(h), [], 'no write to Render');
|
|
124
|
+
assert.equal(h.promotions.length, 0);
|
|
125
|
+
assert.deepEqual(h.cleared, [61]);
|
|
126
|
+
assert.equal(h.inst.app_host.promote.state, 'failed');
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await ta('if the live app moved since the owner looked, nothing is done', async () => {
|
|
131
|
+
const h = harness({ ask: { expectLiveCommit: MID } });
|
|
132
|
+
const out = await h.run();
|
|
133
|
+
assert.equal(out.ok, false);
|
|
134
|
+
assert.match(out.error, /changed since you looked/);
|
|
135
|
+
assert.deepEqual(writes(h), []);
|
|
136
|
+
assert.equal(h.promotions.length, 0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await ta('a change already live is refused', async () => {
|
|
140
|
+
const h = harness({ ask: { commitId: OLD } });
|
|
141
|
+
const out = await h.run();
|
|
142
|
+
assert.match(out.error, /already on your live app/);
|
|
143
|
+
assert.deepEqual(writes(h), []);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await ta('Render refusing the deploy leaves a failed record, said plainly', async () => {
|
|
147
|
+
const h = harness({ render: { 'POST /services/srv-live0001/deploys': new Error('build minutes exhausted') } });
|
|
148
|
+
const out = await h.run();
|
|
149
|
+
assert.equal(out.ok, false);
|
|
150
|
+
assert.match(out.error, /build minutes exhausted/);
|
|
151
|
+
assert.equal(h.promotions[0].state, 'failed');
|
|
152
|
+
assert.match(h.promotions[0].error, /build minutes exhausted/);
|
|
153
|
+
assert.deepEqual(h.cleared, [61]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await ta('no key, a stale key, no sandbox or no app: refused before Render is called', async () => {
|
|
157
|
+
const cases = [
|
|
158
|
+
[{ intent: { borrowed_key_enc: null } }, /no Render key/],
|
|
159
|
+
[{ intent: { created_at: new Date(Date.now() - 3600_000).toISOString() } }, /waited too long/],
|
|
160
|
+
[{ host: { sandbox: {} } }, /no sandbox/],
|
|
161
|
+
[{ host: { serviceId: null } }, /no app on Render/],
|
|
162
|
+
[{ ask: { commitId: 'not-a-commit' } }, /no change was chosen/],
|
|
163
|
+
];
|
|
164
|
+
for (const [opts, re] of cases) {
|
|
165
|
+
const h = harness(opts);
|
|
166
|
+
const out = await h.run();
|
|
167
|
+
assert.equal(out.ok, false);
|
|
168
|
+
assert.match(out.error, re);
|
|
169
|
+
assert.equal(h.r.calls.length, 0, `no call to Render for ${re}`);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
console.log('\nthe look — what is waiting:');
|
|
174
|
+
function lookFetch(over = {}) {
|
|
175
|
+
const answers = {
|
|
176
|
+
'/services/srv-live0001': { id: 'srv-live0001', ownerId: OURS },
|
|
177
|
+
'/services/srv-sbx00001': { id: 'srv-sbx00001', ownerId: OURS },
|
|
178
|
+
'/services/srv-live0001/deploys?limit=10': [dep('dep-l1', OLD, 'live')],
|
|
179
|
+
'/services/srv-sbx00001/deploys?limit=30': [dep('dep-s4', BAD, 'build_failed'), dep('dep-s3', NEW, 'live'), dep('dep-s2', MID, 'deactivated'), dep('dep-s1', OLD, 'deactivated')],
|
|
180
|
+
...over,
|
|
181
|
+
};
|
|
182
|
+
const seen = [];
|
|
183
|
+
const fetchImpl = async (url, init) => {
|
|
184
|
+
const path = url.replace('https://api.render.com/v1', '');
|
|
185
|
+
seen.push({ method: init.method, path });
|
|
186
|
+
const a = answers[path];
|
|
187
|
+
return { ok: a !== undefined, status: a !== undefined ? 200 : 404, json: async () => a };
|
|
188
|
+
};
|
|
189
|
+
return { seen, fetchImpl };
|
|
190
|
+
}
|
|
191
|
+
await ta('lists what ran on the sandbox since the change live runs, newest first, read-only', async () => {
|
|
192
|
+
const f = lookFetch();
|
|
193
|
+
const out = await lookupPromotion('rnd_k', { ownerId: OURS, liveServiceId: 'srv-live0001', sandboxServiceId: 'srv-sbx00001', fetchImpl: f.fetchImpl });
|
|
194
|
+
assert.equal(out.live.commit_id, OLD);
|
|
195
|
+
assert.deepEqual(out.waiting.map((w) => w.commit_id), [NEW, MID], 'the failed build is not waiting, and live’s own change is not');
|
|
196
|
+
assert.equal(out.waiting[0].short, NEW.slice(0, 7));
|
|
197
|
+
assert.ok(f.seen.every((c) => c.method === 'GET'), 'GET only');
|
|
198
|
+
});
|
|
199
|
+
await ta('a service from another workspace stops the look', async () => {
|
|
200
|
+
const f = lookFetch({ '/services/srv-sbx00001': { id: 'srv-sbx00001', ownerId: 'tea-theirs000001' } });
|
|
201
|
+
await assert.rejects(lookupPromotion('rnd_k', { ownerId: OURS, liveServiceId: 'srv-live0001', sandboxServiceId: 'srv-sbx00001', fetchImpl: f.fetchImpl }), /another workspace/);
|
|
202
|
+
});
|
|
203
|
+
await ta('live in step with the sandbox: nothing waits', async () => {
|
|
204
|
+
const f = lookFetch({ '/services/srv-live0001/deploys?limit=10': [dep('dep-l2', NEW, 'live')] });
|
|
205
|
+
const out = await lookupPromotion('rnd_k', { ownerId: OURS, liveServiceId: 'srv-live0001', sandboxServiceId: 'srv-sbx00001', fetchImpl: f.fetchImpl });
|
|
206
|
+
assert.deepEqual(out.waiting, []);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
console.log('\nthe web tier:');
|
|
210
|
+
await ta('enqueuePromote: the intent with the key and the chosen change on the row, in one statement', async () => {
|
|
211
|
+
const guard = require('../modules/provisioning/control-plane-guard.js');
|
|
212
|
+
const orig = guard.assertIntentAllowed;
|
|
213
|
+
guard.assertIntentAllowed = async () => {};
|
|
214
|
+
try {
|
|
215
|
+
const seen = [];
|
|
216
|
+
const db = { query: async (t, p) => { seen.push({ t: t.replace(/\s+/g, ' '), p }); return { rows: [{ id: 5 }] }; } };
|
|
217
|
+
await renderPromote.enqueuePromote(db, 18, { keyEnc: 'v1.enc', commitId: NEW, expectLiveCommit: OLD, requestedBy: 7 });
|
|
218
|
+
assert.equal(seen.length, 1);
|
|
219
|
+
assert.match(seen[0].t, /'render-promote'/);
|
|
220
|
+
const ask = JSON.parse(seen[0].p[3]);
|
|
221
|
+
assert.deepEqual([ask.state, ask.commitId, ask.expectLiveCommit, ask.requestedBy], ['requested', NEW, OLD, '7']);
|
|
222
|
+
} finally { guard.assertIntentAllowed = orig; }
|
|
223
|
+
});
|
|
224
|
+
await ta('promoteView: whether the owner can, and whether live still follows main', () => {
|
|
225
|
+
const ok = renderPromote.promoteView({ status: 'active', app_host: { platform: 'render', serviceId: 'srv-live0001', liveAutoDeploy: 'off', sandbox: { state: 'live', serviceId: 'srv-sbx00001' } } });
|
|
226
|
+
assert.deepEqual([ok.can, ok.live_follows_main], [true, false]);
|
|
227
|
+
const no = renderPromote.promoteView({ status: 'active', app_host: { platform: 'render', serviceId: 'srv-live0001' } });
|
|
228
|
+
assert.equal(no.can, false);
|
|
229
|
+
assert.match(no.reason, /no sandbox/);
|
|
230
|
+
assert.equal(no.live_follows_main, true);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// ---- the routes ----
|
|
234
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
235
|
+
const secretBox = require('../src/bongos/secret-box.js');
|
|
236
|
+
const kapi = require('../src/module-api.js');
|
|
237
|
+
let instance = null, enq = [], builder = { id: 7 };
|
|
238
|
+
provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
|
|
239
|
+
provisioning.recordEvent = async () => {};
|
|
240
|
+
renderPromote.enqueuePromote = async (_db, id, args) => { enq.push({ id, ...args }); return { intent: { id: 9 } }; };
|
|
241
|
+
secretBox.isConfigured = () => true;
|
|
242
|
+
secretBox.encrypt = (plain) => `v1.sealed(${plain.length})`;
|
|
243
|
+
kapi.requireBuilder = (req, _res, next) => { req.builder = builder; next(); };
|
|
244
|
+
kapi.logger = () => ({ error() {}, warn() {}, info() {}, debug() {} });
|
|
245
|
+
Object.defineProperty(kapi, 'pool', { configurable: true, get: () => ({ query: async () => ({ rows: [] }) }) });
|
|
246
|
+
require('../modules/provisioning/env-manifest.js').listEnvVars = async () => [];
|
|
247
|
+
const f = lookFetch();
|
|
248
|
+
const express = require('express');
|
|
249
|
+
const app = express();
|
|
250
|
+
app.use(express.json());
|
|
251
|
+
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(); });
|
|
252
|
+
app.use('/api/bongos', require('../modules/provisioning/routes/render-standup.js')({ fetchImpl: f.fetchImpl }));
|
|
253
|
+
const server = app.listen(0);
|
|
254
|
+
await new Promise((r) => server.once('listening', r));
|
|
255
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
256
|
+
function call(method, path, body) {
|
|
257
|
+
return new Promise((resolve, reject) => {
|
|
258
|
+
const req = http.request(`${base}${path}`, { method, headers: { 'Content-Type': 'application/json' } }, (res) => {
|
|
259
|
+
let d = ''; res.on('data', (c) => { d += c; });
|
|
260
|
+
res.on('end', () => resolve({ status: res.statusCode, body: d ? JSON.parse(d) : null, raw: d }));
|
|
261
|
+
});
|
|
262
|
+
req.on('error', reject);
|
|
263
|
+
req.end(body ? JSON.stringify(body) : undefined);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
const KEY = 'rnd_abcdefghijklmnopqrstuvwx';
|
|
267
|
+
const READY = { owner_builder_id: 7, slug: 'demo', status: 'active', app_host: { platform: 'render', ownerId: OURS, serviceId: 'srv-live0001', state: 'live', sandbox: { state: 'live', serviceId: 'srv-sbx00001' } } };
|
|
268
|
+
|
|
269
|
+
await ta('POST …/render/promote/look by the owner: what is waiting, the key never sent back', async () => {
|
|
270
|
+
instance = READY;
|
|
271
|
+
const r = await call('POST', '/provisioning/instances/18/render/promote/look', { key: KEY });
|
|
272
|
+
assert.equal(r.status, 200, r.raw);
|
|
273
|
+
assert.deepEqual(r.body.waiting.map((w) => w.commit_id), [NEW, MID]);
|
|
274
|
+
assert.ok(!r.raw.includes(KEY));
|
|
275
|
+
});
|
|
276
|
+
await ta('POST …/render/promote by the owner: queued with ciphertext, the change and what live ran', async () => {
|
|
277
|
+
instance = READY; enq = [];
|
|
278
|
+
const r = await call('POST', '/provisioning/instances/18/render/promote', { key: KEY, commit_id: NEW, expect_live_commit: OLD });
|
|
279
|
+
assert.equal(r.status, 200, r.raw);
|
|
280
|
+
assert.deepEqual(enq, [{ id: 18, keyEnc: `v1.sealed(${KEY.length})`, commitId: NEW, expectLiveCommit: OLD, requestedBy: 7 }]);
|
|
281
|
+
assert.ok(!r.raw.includes(KEY));
|
|
282
|
+
});
|
|
283
|
+
await ta('nobody but the owner can promote or look (the stranger’s 404)', async () => {
|
|
284
|
+
instance = { ...READY, owner_builder_id: 99 }; enq = [];
|
|
285
|
+
assert.equal((await call('POST', '/provisioning/instances/18/render/promote', { key: KEY, commit_id: NEW })).status, 404);
|
|
286
|
+
assert.equal((await call('POST', '/provisioning/instances/18/render/promote/look', { key: KEY })).status, 404);
|
|
287
|
+
assert.equal(enq.length, 0);
|
|
288
|
+
});
|
|
289
|
+
await ta('no sandbox, or no chosen change: refused, nothing queued', async () => {
|
|
290
|
+
instance = { ...READY, app_host: { ...READY.app_host, sandbox: null } }; enq = [];
|
|
291
|
+
let r = await call('POST', '/provisioning/instances/18/render/promote', { key: KEY, commit_id: NEW });
|
|
292
|
+
assert.equal(r.status, 409);
|
|
293
|
+
assert.match(r.body.error.message, /no sandbox/);
|
|
294
|
+
instance = READY;
|
|
295
|
+
r = await call('POST', '/provisioning/instances/18/render/promote', { key: KEY, commit_id: 'main' });
|
|
296
|
+
assert.equal(r.status, 400);
|
|
297
|
+
assert.equal(enq.length, 0);
|
|
298
|
+
});
|
|
299
|
+
await ta('GET …/render carries whether the owner can promote', async () => {
|
|
300
|
+
instance = READY;
|
|
301
|
+
const r = await call('GET', '/provisioning/instances/18/render');
|
|
302
|
+
assert.equal(r.status, 200, r.raw);
|
|
303
|
+
assert.equal(r.body.promote.can, true);
|
|
304
|
+
assert.deepEqual(r.body.promote.recent, []);
|
|
305
|
+
});
|
|
306
|
+
server.close();
|
|
307
|
+
|
|
308
|
+
console.log(`\nrender_promote: ${passed} passed, ${failed} failed`);
|
|
309
|
+
process.exit(failed ? 1 : 0);
|
package/tests/render_sandbox.mjs
CHANGED
|
@@ -59,6 +59,7 @@ function fakeRender(over = {}) {
|
|
|
59
59
|
serviceDetails: { url: LIVE_URL, plan: 'starter', runtime: 'python', region: 'oregon', envSpecificDetails: { buildCommand: 'pip install -r requirements.txt', startCommand: 'gunicorn app:app' } } },
|
|
60
60
|
'GET /services/srv-sbx00001': { id: 'srv-sbx00001', name: 'demo-sandbox', ownerId: OURS, serviceDetails: { url: SB_URL, plan: 'free' } },
|
|
61
61
|
'DELETE /services/srv-sbx00001': null,
|
|
62
|
+
'PATCH /services/srv-live0001': { id: 'srv-live0001', ownerId: OURS, autoDeploy: 'no' },
|
|
62
63
|
[SB_NAME_Q]: [],
|
|
63
64
|
...over,
|
|
64
65
|
};
|
|
@@ -122,7 +123,10 @@ await ta('a new app with a database: the app, then its sandbox — free, follows
|
|
|
122
123
|
const h = harness();
|
|
123
124
|
const out = await h.run();
|
|
124
125
|
assert.equal(out.ok, true, out.error);
|
|
125
|
-
assert.deepEqual(writesTo(h.r.calls), ['POST /postgres', 'POST /services', 'POST /services'
|
|
126
|
+
assert.deepEqual(writesTo(h.r.calls), ['POST /postgres', 'POST /services', 'POST /services', 'PATCH /services/srv-live0001'],
|
|
127
|
+
'database, live app, sandbox, then the live app switched to manual deploys (task 1005290) — and nothing else written');
|
|
128
|
+
assert.deepEqual(h.r.calls.find((c) => c.method === 'PATCH').body, { autoDeploy: 'no' }, 'from now on only a promotion moves the live app');
|
|
129
|
+
assert.equal(h.inst.app_host.liveAutoDeploy, 'off');
|
|
126
130
|
const sb = created(h, 'demo-sandbox').body;
|
|
127
131
|
assert.equal(sb.serviceDetails.plan, 'free');
|
|
128
132
|
assert.equal(sb.branch, 'main');
|
|
@@ -153,11 +157,11 @@ await ta('an app with no database: the sandbox has none either, and says so', as
|
|
|
153
157
|
assert.match(h.inst.app_host.sandbox.dbNote, /no database, so its sandbox has none/);
|
|
154
158
|
});
|
|
155
159
|
|
|
156
|
-
await ta('a linked (adopted) app: the sandbox copies its build, and the live service
|
|
160
|
+
await ta('a linked (adopted) app: the sandbox copies its build, and the live service only stops auto-deploying', async () => {
|
|
157
161
|
const h = harness({ host: { mode: 'adopt', serviceId: 'srv-live0001', runtime: undefined, buildCommand: undefined, startCommand: undefined, database: undefined } });
|
|
158
162
|
const out = await h.run();
|
|
159
163
|
assert.equal(out.ok, true, out.error);
|
|
160
|
-
assert.deepEqual(writesTo(h.r.calls), ['POST /services'], 'the
|
|
164
|
+
assert.deepEqual(writesTo(h.r.calls), ['POST /services', 'PATCH /services/srv-live0001'], 'the sandbox, and live switched to manual deploys');
|
|
161
165
|
const sb = created(h, 'demo-sandbox').body;
|
|
162
166
|
assert.equal(sb.serviceDetails.runtime, 'python');
|
|
163
167
|
assert.equal(sb.serviceDetails.region, 'oregon');
|
|
@@ -186,6 +190,7 @@ await ta('the sandbox failing never fails the app, and the key is still cleared'
|
|
|
186
190
|
assert.equal(h.inst.app_host.state, 'deploying');
|
|
187
191
|
assert.equal(h.inst.app_host.sandbox.state, 'failed');
|
|
188
192
|
assert.match(h.inst.app_host.sandbox.error, /free instance limit reached/);
|
|
193
|
+
assert.ok(!h.r.calls.some((c) => c.method === 'PATCH'), 'with no sandbox, live keeps deploying every merge — work always has somewhere to land');
|
|
189
194
|
assert.deepEqual(h.intentWrites, [44], 'the key is cleared');
|
|
190
195
|
});
|
|
191
196
|
|
|
@@ -210,7 +215,7 @@ await ta('an app already live adds its sandbox: the live app is not created, rea
|
|
|
210
215
|
const h = harness({ host: { postgresId: 'dpg-new', serviceId: 'srv-live0001', state: 'live', sandbox: { state: 'requested' } } });
|
|
211
216
|
const out = await h.run();
|
|
212
217
|
assert.equal(out.ok, true, out.error);
|
|
213
|
-
assert.deepEqual(writesTo(h.r.calls), ['POST /services'
|
|
218
|
+
assert.deepEqual(writesTo(h.r.calls), ['POST /services', 'PATCH /services/srv-live0001'], 'the sandbox, then live switched to manual deploys — never redeployed');
|
|
214
219
|
assert.ok(created(h, 'demo-sandbox'));
|
|
215
220
|
assert.equal(h.inst.app_host.state, 'live', 'the app stays live — not knocked back to building');
|
|
216
221
|
assert.equal(h.inst.app_host.sandbox.state, 'deploying');
|