@bongos/core 1.19.1080 → 1.19.1081
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 +78 -28
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0343-a-module-score-is-a-security-gate-then-an-average-of-visible-parts.md +2 -0
- package/docs/adr/0347-every-store-module-ships-a-how-to.md +135 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +126 -3
- package/docs/api-reference.md +3 -2
- package/docs/module-api-changelog.md +2 -0
- package/modules/provisioning/migrations/provisioning_028_render_standup.sql +46 -0
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/provisioning.js +5 -5
- package/modules/provisioning/render-standup.js +91 -0
- package/modules/provisioning/routes/render-standup.js +90 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/scripts/gds/provision-render.js +158 -0
- package/scripts/gds/provision-teardown.js +2 -0
- package/scripts/gds/provision.js +2 -2
- package/scripts/gds/render-api.js +178 -0
- package/scripts/gds/render-payload.js +110 -0
- package/src/module-api.js +1 -1
- package/tests/provision_render.mjs +269 -0
- package/tests/provisioning_render_route.mjs +216 -0
- package/tests/provisioning_settings_apply.mjs +1 -1
- package/tests/provisioning_teardown_intent.mjs +2 -2
- package/tests/render_api.mjs +153 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// tests/provision_render.mjs — the Render standup leg (task 1004183; ADR 0327).
|
|
2
|
+
//
|
|
3
|
+
// scripts/gds/provision-render.js driven over the REAL Render client (render-api.js) and a
|
|
4
|
+
// recording fake of Render's API, so the workspace guard is exercised end to end rather
|
|
5
|
+
// than stubbed away. db and the secret box are fakes. No network, no DB.
|
|
6
|
+
//
|
|
7
|
+
// The load-bearing assertions — the task's DONE WHEN, clause by clause:
|
|
8
|
+
// • the borrowed key is CLEARED in the drain that spends it: on success, on a refusal,
|
|
9
|
+
// on a mismatched workspace, on a Render error and on a decrypt failure
|
|
10
|
+
// • the ownerId guard refuses a mismatched response, and the run stops there — nothing
|
|
11
|
+
// is created after it
|
|
12
|
+
// • ADOPT makes no write call to Render at all; GREENFIELD creates the database, then
|
|
13
|
+
// the service, sets the SVC_URL-dependent env vars, then deploys — in that order
|
|
14
|
+
// • the plan is read from the project's row, not a constant
|
|
15
|
+
// • a failure is TERMINAL: the runner retires it instead of re-queuing it, because the
|
|
16
|
+
// key is gone and the owner retries by entering it again
|
|
17
|
+
// • teardown forgets the Render app and never calls Render
|
|
18
|
+
//
|
|
19
|
+
// Run: node tests/provision_render.mjs
|
|
20
|
+
|
|
21
|
+
import { strict as assert } from 'node:assert';
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
import { readFileSync } from 'node:fs';
|
|
24
|
+
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
const { RenderApi } = require('../scripts/gds/render-api.js');
|
|
27
|
+
const { renderStandupInstance, forgetRenderApp, KEY_TTL_MS } = require('../scripts/gds/provision-render.js');
|
|
28
|
+
const P = require('../scripts/gds/provision.js');
|
|
29
|
+
|
|
30
|
+
let passed = 0, failed = 0;
|
|
31
|
+
async function ta(name, fn) {
|
|
32
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
33
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const OURS = 'tea-ours00000001';
|
|
37
|
+
const THEIRS = 'tea-theirs000001';
|
|
38
|
+
const SVC_URL = 'https://demo.onrender.com';
|
|
39
|
+
|
|
40
|
+
// A fake Render account. `over` replaces any answer; a function answer sees the body.
|
|
41
|
+
function fakeRender(over = {}) {
|
|
42
|
+
const calls = [];
|
|
43
|
+
const answers = {
|
|
44
|
+
'GET /owners?limit=100': [{ owner: { id: THEIRS } }, { owner: { id: OURS } }],
|
|
45
|
+
'POST /postgres': { id: 'dpg-new', status: 'creating', owner: { id: OURS } },
|
|
46
|
+
'GET /postgres/dpg-new': { id: 'dpg-new', status: 'available', owner: { id: OURS } },
|
|
47
|
+
'GET /postgres/dpg-new/connection-info': { internalConnectionString: 'postgres://internal' },
|
|
48
|
+
'POST /services': { deployId: 'dep-1', service: { id: 'srv-new', ownerId: OURS, serviceDetails: { url: SVC_URL } } },
|
|
49
|
+
'GET /services/srv-new': { id: 'srv-new', ownerId: OURS, serviceDetails: { url: SVC_URL } },
|
|
50
|
+
'GET /services/srv-old': { id: 'srv-old', ownerId: OURS, serviceDetails: { url: 'https://old.onrender.com' } },
|
|
51
|
+
'POST /services/srv-new/deploys': { id: 'dep-2' },
|
|
52
|
+
...over,
|
|
53
|
+
};
|
|
54
|
+
const fetchImpl = async (url, init) => {
|
|
55
|
+
const path = url.replace('https://api.render.com/v1', '');
|
|
56
|
+
const body = init.body ? JSON.parse(init.body) : null;
|
|
57
|
+
calls.push({ method: init.method, path, body });
|
|
58
|
+
let key = `${init.method} ${path}`;
|
|
59
|
+
if (init.method === 'PUT' && /\/env-vars\//.test(path)) key = 'PUT env';
|
|
60
|
+
const a = key in answers ? answers[key] : (key === 'PUT env' ? { ok: true } : undefined);
|
|
61
|
+
if (a === undefined) return { ok: false, status: 404, text: async () => '{"message":"not found"}' };
|
|
62
|
+
if (a instanceof Error) return { ok: false, status: 500, text: async () => JSON.stringify({ message: a.message }) };
|
|
63
|
+
return { ok: true, status: 200, text: async () => JSON.stringify(typeof a === 'function' ? a(body) : a) };
|
|
64
|
+
};
|
|
65
|
+
return { calls, fetchImpl };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function harness({ host = {}, intent = {}, render = {}, decryptThrows = false } = {}) {
|
|
69
|
+
const r = fakeRender(render);
|
|
70
|
+
const writes = [], logs = [];
|
|
71
|
+
const db = {
|
|
72
|
+
query: async (sql, params) => {
|
|
73
|
+
writes.push({ sql: sql.replace(/\s+/g, ' ').trim(), params });
|
|
74
|
+
if (/FROM builders/.test(sql)) return { rows: [{ github_login: 'owner-login' }] };
|
|
75
|
+
return { rows: [] };
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
const deps = {
|
|
79
|
+
db, apply: true, log: (l) => logs.push(l), envPrefix: 'BONGOS', sleepFn: async () => {},
|
|
80
|
+
secretBox: { decrypt: (blob) => { if (decryptThrows) throw new Error('secret-box: bad tag'); assert.equal(blob, 'v1.enc'); return 'rnd_borrowed'; } },
|
|
81
|
+
renderApiFactory: ({ token, ownerId }) => new RenderApi({ token, ownerId, dryRun: false, fetchImpl: r.fetchImpl }),
|
|
82
|
+
};
|
|
83
|
+
const inst = { id: 18, slug: 'demo', owner_builder_id: 7, target_ref: 'example-owner/demo', status: 'active',
|
|
84
|
+
app_host: { platform: 'render', mode: 'greenfield', ownerId: OURS, ...host } };
|
|
85
|
+
const it = { id: 44, action: 'render-standup', borrowed_key_enc: 'v1.enc', created_at: new Date().toISOString(), ...intent };
|
|
86
|
+
const cleared = () => writes.filter((w) => /UPDATE provisioning_intents SET borrowed_key_enc = NULL WHERE id = \$1/.test(w.sql) && w.params[0] === 44).length;
|
|
87
|
+
const appHost = () => writes.filter((w) => /SET app_host/.test(w.sql)).map((w) => JSON.parse(w.params[1]));
|
|
88
|
+
const lastHost = () => Object.assign({}, ...appHost());
|
|
89
|
+
return { r, writes, logs, deps, inst, it, cleared, lastHost, run: () => renderStandupInstance(inst, deps, it) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const writesTo = (calls) => calls.filter((c) => c.method !== 'GET');
|
|
93
|
+
|
|
94
|
+
console.log('greenfield:');
|
|
95
|
+
await ta('creates the database, then the service, sets the origins, then deploys — and clears the key', async () => {
|
|
96
|
+
const h = harness();
|
|
97
|
+
const out = await h.run();
|
|
98
|
+
assert.equal(out.ok, true, out.error);
|
|
99
|
+
assert.deepEqual(writesTo(h.r.calls).map((c) => c.method + ' ' + c.path.replace(/env-vars\/.*/, 'env-vars/*')), [
|
|
100
|
+
'POST /postgres', 'POST /services',
|
|
101
|
+
'PUT /services/srv-new/env-vars/*', 'PUT /services/srv-new/env-vars/*', 'PUT /services/srv-new/env-vars/*', 'PUT /services/srv-new/env-vars/*',
|
|
102
|
+
'POST /services/srv-new/deploys',
|
|
103
|
+
]);
|
|
104
|
+
const origins = h.r.calls.filter((c) => c.method === 'PUT').map((c) => [c.path.split('/').pop(), c.body.value]);
|
|
105
|
+
assert.deepEqual(origins, [['BONGOS_PUBLIC_ORIGIN', SVC_URL], ['BONGOS_BUILDERS_ORIGIN', SVC_URL], ['BONGOS_STATUS_ORIGIN', SVC_URL], ['BONGOS_OAUTH_ORIGIN', SVC_URL]]);
|
|
106
|
+
assert.equal(h.cleared(), 1, 'the key column is cleared');
|
|
107
|
+
const host = h.lastHost();
|
|
108
|
+
assert.deepEqual([host.postgresId, host.serviceId, host.state, host.serviceUrl, host.error], ['dpg-new', 'srv-new', 'deploying', SVC_URL, null], 'the row records what was created');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await ta('the service is the pilot\'s: node runtime, explicit start + migrate, HOST, the database SVC_URL, a fresh secret, the owner seated', async () => {
|
|
112
|
+
const h = harness();
|
|
113
|
+
await h.run();
|
|
114
|
+
const svc = h.r.calls.find((c) => c.method === 'POST' && c.path === '/services').body;
|
|
115
|
+
assert.equal(svc.ownerId, OURS);
|
|
116
|
+
assert.equal(svc.repo, 'https://github.com/example-owner/demo');
|
|
117
|
+
assert.equal(svc.serviceDetails.runtime, 'node');
|
|
118
|
+
assert.match(svc.serviceDetails.envSpecificDetails.startCommand, /platform-server\.js$/);
|
|
119
|
+
assert.match(svc.serviceDetails.envSpecificDetails.preDeployCommand, /migrate\.sh$/);
|
|
120
|
+
const env = Object.fromEntries(svc.envVars.map((v) => [v.key, v.value]));
|
|
121
|
+
assert.equal(env.HOST, '0.0.0.0');
|
|
122
|
+
assert.equal(env.DATABASE_URL, 'postgres://internal');
|
|
123
|
+
assert.equal(env.PGDATABASE, 'postgres://internal');
|
|
124
|
+
assert.match(env.BUILDER_SECRET_KEY, /^[0-9a-f]{64}$/);
|
|
125
|
+
assert.equal(env.BONGOS_FIRST_ADMIN, 'owner-login');
|
|
126
|
+
assert.ok(!('PORT' in env), 'Render injects PORT');
|
|
127
|
+
const stored = JSON.stringify(h.writes.map((w) => w.params));
|
|
128
|
+
assert.ok(!stored.includes(env.BUILDER_SECRET_KEY) && !stored.includes('postgres://internal') && !stored.includes('rnd_borrowed'),
|
|
129
|
+
'no env var value and no key is ever written to our database');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await ta('the plan is the project\'s own setting, not a constant', async () => {
|
|
133
|
+
const h = harness({ host: { webPlan: 'standard', dbPlan: 'basic_1gb', region: 'frankfurt' } });
|
|
134
|
+
await h.run();
|
|
135
|
+
const pg = h.r.calls.find((c) => c.path === '/postgres').body;
|
|
136
|
+
const svc = h.r.calls.find((c) => c.path === '/services').body;
|
|
137
|
+
assert.equal(pg.plan, 'basic_1gb');
|
|
138
|
+
assert.equal(pg.region, 'frankfurt');
|
|
139
|
+
assert.equal(svc.serviceDetails.plan, 'standard');
|
|
140
|
+
const d = harness();
|
|
141
|
+
await d.run();
|
|
142
|
+
assert.equal(d.r.calls.find((c) => c.path === '/postgres').body.plan, 'basic_256mb', 'the default is paid: free Postgres has no backups');
|
|
143
|
+
assert.equal(d.r.calls.find((c) => c.path === '/services').body.serviceDetails.plan, 'starter');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await ta('a retry resumes onto what an earlier run created — no second database or service', async () => {
|
|
147
|
+
const h = harness({ host: { postgresId: 'dpg-new', serviceId: 'srv-new' } });
|
|
148
|
+
const out = await h.run();
|
|
149
|
+
assert.equal(out.ok, true, out.error);
|
|
150
|
+
assert.ok(!h.r.calls.some((c) => c.method === 'POST' && (c.path === '/postgres' || c.path === '/services')));
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
console.log('\nadopt:');
|
|
154
|
+
await ta('links an existing service and makes NO write call to Render', async () => {
|
|
155
|
+
const h = harness({ host: { mode: 'adopt', serviceId: 'srv-old' } });
|
|
156
|
+
const out = await h.run();
|
|
157
|
+
assert.equal(out.ok, true, out.error);
|
|
158
|
+
assert.deepEqual(writesTo(h.r.calls), [], 'only reads');
|
|
159
|
+
assert.equal(h.lastHost().state, 'linked');
|
|
160
|
+
assert.equal(h.lastHost().serviceUrl, 'https://old.onrender.com');
|
|
161
|
+
assert.equal(h.cleared(), 1);
|
|
162
|
+
});
|
|
163
|
+
await ta('adopting a service from another workspace is refused, and the key still cleared', async () => {
|
|
164
|
+
const h = harness({ host: { mode: 'adopt', serviceId: 'srv-old' }, render: { 'GET /services/srv-old': { id: 'srv-old', ownerId: THEIRS } } });
|
|
165
|
+
const out = await h.run();
|
|
166
|
+
assert.equal(out.ok, false);
|
|
167
|
+
assert.equal(out.terminal, true);
|
|
168
|
+
assert.match(out.error, /from workspace tea-theirs/);
|
|
169
|
+
assert.equal(h.lastHost().state, 'failed');
|
|
170
|
+
assert.equal(h.cleared(), 1);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
console.log('\nthe guard stops the run:');
|
|
174
|
+
await ta('a database answered from another workspace stops everything after it', async () => {
|
|
175
|
+
const h = harness({ render: { 'POST /postgres': { id: 'dpg-x', status: 'creating', owner: { id: THEIRS } } } });
|
|
176
|
+
const out = await h.run();
|
|
177
|
+
assert.equal(out.ok, false);
|
|
178
|
+
assert.match(out.error, /refusing to go on/);
|
|
179
|
+
assert.deepEqual(writesTo(h.r.calls).map((c) => c.path), ['/postgres'], 'no service, no env vars, no deploy');
|
|
180
|
+
assert.equal(h.cleared(), 1);
|
|
181
|
+
});
|
|
182
|
+
await ta('a workspace the key cannot reach is refused before any other call', async () => {
|
|
183
|
+
const h = harness({ render: { 'GET /owners?limit=100': [{ owner: { id: THEIRS } }] } });
|
|
184
|
+
const out = await h.run();
|
|
185
|
+
assert.equal(out.ok, false);
|
|
186
|
+
assert.match(out.error, /cannot reach workspace/);
|
|
187
|
+
assert.equal(h.r.calls.length, 1);
|
|
188
|
+
assert.equal(h.cleared(), 1);
|
|
189
|
+
});
|
|
190
|
+
await ta('the workspace is never taken from the list — with none named, nothing is called', async () => {
|
|
191
|
+
const h = harness({ host: { ownerId: undefined } });
|
|
192
|
+
const out = await h.run();
|
|
193
|
+
assert.equal(out.ok, false);
|
|
194
|
+
assert.equal(h.r.calls.length, 0);
|
|
195
|
+
assert.equal(h.cleared(), 1);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
console.log('\nthe key is cleared whatever happens:');
|
|
199
|
+
for (const [name, opts, re] of [
|
|
200
|
+
['Render answers an error', { render: { 'POST /services': new Error('plan not allowed') } }, /plan not allowed/],
|
|
201
|
+
['the key cannot be decrypted', { decryptThrows: true }, /bad tag/],
|
|
202
|
+
['the key waited too long', { intent: { created_at: new Date(Date.now() - KEY_TTL_MS - 1000).toISOString() } }, /waited too long/],
|
|
203
|
+
['no key came with the request', { intent: { borrowed_key_enc: null } }, /no Render key/],
|
|
204
|
+
['the project is not set to Render', { host: { platform: 'vercel' } }, /not set to run on Render/],
|
|
205
|
+
['an unknown plan', { host: { webPlan: 'huge' } }, /unknown Render web plan/],
|
|
206
|
+
]) {
|
|
207
|
+
await ta(`${name} → terminal, reason recorded, key cleared`, async () => {
|
|
208
|
+
const h = harness(opts);
|
|
209
|
+
const out = await h.run();
|
|
210
|
+
assert.equal(out.ok, false);
|
|
211
|
+
assert.equal(out.terminal, true);
|
|
212
|
+
assert.match(out.error, re);
|
|
213
|
+
assert.equal(h.lastHost().state, 'failed');
|
|
214
|
+
assert.equal(h.cleared(), 1);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
console.log('\nthe runner retires a terminal failure — no silent retry:');
|
|
219
|
+
function drain(resultOfLeg) {
|
|
220
|
+
let claims = 0; const resolved = [], requeued = [];
|
|
221
|
+
const h = harness(resultOfLeg);
|
|
222
|
+
const deps = {
|
|
223
|
+
...h.deps, exec: () => '', controlExec: () => '', sleep: async () => {},
|
|
224
|
+
db: { query: async (sql, params) => { if (/state='pending'/.test(sql)) requeued.push(params); return h.deps.db.query(sql, params); } },
|
|
225
|
+
provisioning: {
|
|
226
|
+
claimNextIntent: async () => (claims++ === 0 ? { ...h.it, instance_id: 18, attempts: 1 } : null),
|
|
227
|
+
getInstanceById: async () => ({ ...h.inst }),
|
|
228
|
+
resolveIntent: async (_db, id, state, err) => { resolved.push({ id, state, err }); },
|
|
229
|
+
setInstanceStatus: async () => {}, recordEvent: async () => {},
|
|
230
|
+
claimNextOAuthManifest: async () => null, resolveOAuthManifest: async () => {},
|
|
231
|
+
},
|
|
232
|
+
writeFile: () => ({ ok: true }), doApi: null,
|
|
233
|
+
};
|
|
234
|
+
return { deps, resolved, requeued, h };
|
|
235
|
+
}
|
|
236
|
+
await ta("cmdRunIntents routes 'render-standup' to the leg, and a success resolves done", async () => {
|
|
237
|
+
const d = drain({ host: { mode: 'adopt', serviceId: 'srv-old' } });
|
|
238
|
+
const out = await P.cmdRunIntents(d.deps);
|
|
239
|
+
assert.equal(out.errors, 0, JSON.stringify(out));
|
|
240
|
+
assert.deepEqual(d.resolved, [{ id: 44, state: 'done', err: null }]);
|
|
241
|
+
});
|
|
242
|
+
await ta('a failure is retired on its FIRST attempt, never re-queued', async () => {
|
|
243
|
+
const d = drain({ render: { 'GET /owners?limit=100': [] } });
|
|
244
|
+
const out = await P.cmdRunIntents(d.deps);
|
|
245
|
+
assert.equal(out.errors, 1);
|
|
246
|
+
assert.deepEqual(d.requeued, [], 'not put back to pending');
|
|
247
|
+
assert.equal(d.resolved.length, 1);
|
|
248
|
+
assert.equal(d.resolved[0].state, 'error');
|
|
249
|
+
assert.match(d.resolved[0].err, /cannot reach workspace/);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
console.log('\nteardown forgets, never deletes:');
|
|
253
|
+
await ta('forgetRenderApp records the disconnect and calls nothing', async () => {
|
|
254
|
+
const writes = [], logs = [];
|
|
255
|
+
const inst = { id: 18, app_host: { platform: 'render', serviceId: 'srv-new', postgresId: 'dpg-new' } };
|
|
256
|
+
await forgetRenderApp(inst, { db: { query: async (sql, p) => { writes.push(p); return { rows: [] }; } }, log: (l) => logs.push(l), apply: true });
|
|
257
|
+
assert.equal(JSON.parse(writes[0][1]).state, 'disconnected');
|
|
258
|
+
assert.match(logs.join('\n'), /nothing on Render is deleted/);
|
|
259
|
+
await forgetRenderApp({ id: 19, app_host: null }, { db: { query: async () => { throw new Error('must not write'); } }, log: () => {}, apply: true });
|
|
260
|
+
});
|
|
261
|
+
await ta('the teardown leg reaches forgetRenderApp, and the leg has no path to a Render delete', () => {
|
|
262
|
+
const src = readFileSync(new URL('../scripts/gds/provision-teardown.js', import.meta.url), 'utf8');
|
|
263
|
+
assert.match(src, /forgetRenderApp\(inst/);
|
|
264
|
+
const leg = readFileSync(new URL('../scripts/gds/provision-render.js', import.meta.url), 'utf8');
|
|
265
|
+
assert.doesNotMatch(leg, /'DELETE'|\.delete[A-Z]/);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
269
|
+
if (failed) process.exit(1);
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// tests/provisioning_render_route.mjs — POST /provisioning/instances/:id/render and its
|
|
2
|
+
// queue seam (task 1004183; ADR 0327).
|
|
3
|
+
//
|
|
4
|
+
// The load-bearing assertions:
|
|
5
|
+
// • OWNER ONLY: the key and the Render account are the owner's, so an archon on someone
|
|
6
|
+
// else's project gets the same 404 as a stranger, and nothing is encrypted or queued
|
|
7
|
+
// • only CIPHERTEXT reaches the queue — the plaintext key is never passed on, never in
|
|
8
|
+
// the reply, never in the audit event
|
|
9
|
+
// • the shape is checked before anything is kept; the web tier makes no Render call
|
|
10
|
+
// • no secret box → 503, and the key is not kept
|
|
11
|
+
// • a conflicting open intent → 409, and the key is not kept
|
|
12
|
+
// • the queue seam writes the intent AND the row in ONE statement, so a collision on
|
|
13
|
+
// the open-intent index stores nothing at all
|
|
14
|
+
//
|
|
15
|
+
// DB-free: data access is stubbed on the cached module objects, over a real mounted
|
|
16
|
+
// router. No network beyond localhost. Run: node tests/provisioning_render_route.mjs
|
|
17
|
+
|
|
18
|
+
import { strict as assert } from 'node:assert';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
21
|
+
import http from 'node:http';
|
|
22
|
+
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
25
|
+
const renderStandup = require('../modules/provisioning/render-standup.js');
|
|
26
|
+
const secretBox = require('../src/bongos/secret-box.js');
|
|
27
|
+
const guard = require('../modules/provisioning/control-plane-guard.js');
|
|
28
|
+
|
|
29
|
+
let passed = 0, failed = 0;
|
|
30
|
+
async function ta(name, fn) {
|
|
31
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
32
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const KEY = 'rnd_abcdefghijklmnopqrstuvwx';
|
|
36
|
+
let instance = null, enqueueCalls = [], enqueueResult = null, events = [], boxOn = true;
|
|
37
|
+
provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
|
|
38
|
+
provisioning.recordEvent = async (_db, e) => { events.push(e); };
|
|
39
|
+
const realEnqueue = renderStandup.enqueueRenderStandup;
|
|
40
|
+
renderStandup.enqueueRenderStandup = async (_db, id, args) => {
|
|
41
|
+
enqueueCalls.push({ id, ...args });
|
|
42
|
+
return enqueueResult || { intent: { id: 9, action: 'render-standup', state: 'pending' } };
|
|
43
|
+
};
|
|
44
|
+
secretBox.isConfigured = () => boxOn;
|
|
45
|
+
secretBox.encrypt = (plain) => `v1.sealed(${plain.length})`;
|
|
46
|
+
|
|
47
|
+
const api = require('../src/module-api.js');
|
|
48
|
+
let builder = null;
|
|
49
|
+
api.requireBuilder = (req, _res, next) => { req.builder = builder; next(); };
|
|
50
|
+
api.logger = () => ({ error() {}, warn() {}, info() {}, debug() {} });
|
|
51
|
+
|
|
52
|
+
const express = require('express');
|
|
53
|
+
const buildRouter = require('../modules/provisioning/routes/render-standup.js');
|
|
54
|
+
const app = express();
|
|
55
|
+
app.use(express.json());
|
|
56
|
+
app.use((_req, res, next) => {
|
|
57
|
+
res.fail = (code, opts, details) => {
|
|
58
|
+
const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
|
|
59
|
+
res.status(status).json({ error: { code, ...(typeof opts === 'object' ? opts : {}), ...(Array.isArray(details) ? { details } : {}) } });
|
|
60
|
+
};
|
|
61
|
+
next();
|
|
62
|
+
});
|
|
63
|
+
app.use('/api/bongos', buildRouter());
|
|
64
|
+
const server = app.listen(0);
|
|
65
|
+
await new Promise((r) => server.once('listening', r));
|
|
66
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
67
|
+
|
|
68
|
+
function post(path, body) {
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
const payload = JSON.stringify(body);
|
|
71
|
+
const req = http.request(`${base}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }, (res) => {
|
|
72
|
+
let d = '';
|
|
73
|
+
res.on('data', (c) => { d += c; });
|
|
74
|
+
res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
|
|
75
|
+
});
|
|
76
|
+
req.on('error', reject);
|
|
77
|
+
req.end(payload);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const LIVE = { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'cloud-host', target_ref: 'example-owner/spike' };
|
|
82
|
+
function reset(patch = {}) {
|
|
83
|
+
instance = { ...LIVE, ...patch };
|
|
84
|
+
enqueueCalls = []; enqueueResult = null; events = []; boxOn = true;
|
|
85
|
+
builder = { id: 7, github_login: 'owner', rank: 'thetes' };
|
|
86
|
+
}
|
|
87
|
+
const PATH = '/provisioning/instances/18/render';
|
|
88
|
+
const GREEN = { key: KEY, owner_id: 'tea-abcdefgh1234', mode: 'greenfield' };
|
|
89
|
+
const noKeyAnywhere = (...xs) => xs.forEach((x) => assert.ok(!JSON.stringify(x).includes(KEY), 'the plaintext key leaked: ' + JSON.stringify(x)));
|
|
90
|
+
|
|
91
|
+
console.log('who may ask:');
|
|
92
|
+
await ta('the owner queues it: ciphertext only, the request recorded on the row, one audit event', async () => {
|
|
93
|
+
reset();
|
|
94
|
+
const r = await post(PATH, { ...GREEN, web_plan: 'standard' });
|
|
95
|
+
assert.equal(r.status, 200, r.raw);
|
|
96
|
+
assert.equal(enqueueCalls.length, 1);
|
|
97
|
+
assert.equal(enqueueCalls[0].keyEnc, `v1.sealed(${KEY.length})`);
|
|
98
|
+
assert.deepEqual(enqueueCalls[0].appHost, { platform: 'render', mode: 'greenfield', ownerId: 'tea-abcdefgh1234', state: 'requested', error: null, webPlan: 'standard' });
|
|
99
|
+
assert.equal(events.length, 1);
|
|
100
|
+
noKeyAnywhere(enqueueCalls, events, r.body);
|
|
101
|
+
});
|
|
102
|
+
await ta('an ARCHON on someone else\'s project gets 404 — nothing encrypted, queued or recorded', async () => {
|
|
103
|
+
reset();
|
|
104
|
+
builder = { id: 42, github_login: 'arch', rank: 'archon' };
|
|
105
|
+
let sealed = 0; const enc = secretBox.encrypt; secretBox.encrypt = (p) => { sealed++; return enc(p); };
|
|
106
|
+
const r = await post(PATH, GREEN);
|
|
107
|
+
secretBox.encrypt = enc;
|
|
108
|
+
assert.equal(r.status, 404, r.raw);
|
|
109
|
+
assert.equal(sealed, 0);
|
|
110
|
+
assert.equal(enqueueCalls.length, 0);
|
|
111
|
+
assert.equal(events.length, 0);
|
|
112
|
+
});
|
|
113
|
+
await ta('a missing project is 404', async () => {
|
|
114
|
+
reset(); instance = null;
|
|
115
|
+
assert.equal((await post(PATH, GREEN)).status, 404);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
console.log('\nthe shape is checked first:');
|
|
119
|
+
for (const [name, body, re] of [
|
|
120
|
+
['a key that is not a Render key', { ...GREEN, key: 'ghp_notrender' }, /Render API key/],
|
|
121
|
+
['a workspace id of the wrong shape', { ...GREEN, owner_id: 'srv-abcdefgh1234' }, /workspace/],
|
|
122
|
+
['an unknown mode', { ...GREEN, mode: 'migrate' }, /link a Render service/],
|
|
123
|
+
['adopt with no service', { ...GREEN, mode: 'adopt' }, /service to link/],
|
|
124
|
+
['greenfield naming a service', { ...GREEN, service_id: 'srv-abcdefgh1234' }, /leave the service out/],
|
|
125
|
+
['an unknown plan', { ...GREEN, db_plan: 'free' }, /not a Render database plan/],
|
|
126
|
+
]) {
|
|
127
|
+
await ta(`${name} → 400, nothing kept`, async () => {
|
|
128
|
+
reset();
|
|
129
|
+
const r = await post(PATH, body);
|
|
130
|
+
assert.equal(r.status, 400, r.raw);
|
|
131
|
+
assert.match(r.body.error.message, re);
|
|
132
|
+
assert.equal(enqueueCalls.length, 0);
|
|
133
|
+
noKeyAnywhere(r.body);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
await ta('an unknown field is refused by the strict validator', async () => {
|
|
137
|
+
reset();
|
|
138
|
+
const r = await post(PATH, { ...GREEN, value: 'x' });
|
|
139
|
+
assert.equal(r.status, 400, r.raw);
|
|
140
|
+
assert.equal(enqueueCalls.length, 0);
|
|
141
|
+
});
|
|
142
|
+
await ta('adopt with a service id is accepted and records it', async () => {
|
|
143
|
+
reset();
|
|
144
|
+
const r = await post(PATH, { ...GREEN, mode: 'adopt', service_id: 'srv-abcdefgh1234' });
|
|
145
|
+
assert.equal(r.status, 200, r.raw);
|
|
146
|
+
assert.equal(enqueueCalls[0].appHost.serviceId, 'srv-abcdefgh1234');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
console.log('\nrefusals keep nothing:');
|
|
150
|
+
await ta('greenfield with no repository → 409', async () => {
|
|
151
|
+
reset({ target_ref: null });
|
|
152
|
+
const r = await post(PATH, GREEN);
|
|
153
|
+
assert.equal(r.status, 409, r.raw);
|
|
154
|
+
assert.equal(enqueueCalls.length, 0);
|
|
155
|
+
});
|
|
156
|
+
await ta('a removed project → 409', async () => {
|
|
157
|
+
reset({ status: 'torn_down' });
|
|
158
|
+
assert.equal((await post(PATH, GREEN)).status, 409);
|
|
159
|
+
assert.equal(enqueueCalls.length, 0);
|
|
160
|
+
});
|
|
161
|
+
await ta('no secret box → 503, and the key is not kept', async () => {
|
|
162
|
+
reset(); boxOn = false;
|
|
163
|
+
const r = await post(PATH, GREEN);
|
|
164
|
+
assert.equal(r.status, 503, r.raw);
|
|
165
|
+
assert.equal(enqueueCalls.length, 0);
|
|
166
|
+
});
|
|
167
|
+
await ta('something already open → 409 saying the key was not kept', async () => {
|
|
168
|
+
reset(); enqueueResult = { conflict: 'open_intent', intent: { action: 'provision', state: 'running' } };
|
|
169
|
+
const r = await post(PATH, GREEN);
|
|
170
|
+
assert.equal(r.status, 409, r.raw);
|
|
171
|
+
assert.match(r.body.error.message, /not kept/);
|
|
172
|
+
assert.equal(events.length, 0);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
console.log('\nthe queue seam:');
|
|
176
|
+
const realGuard = guard.assertIntentAllowed;
|
|
177
|
+
guard.assertIntentAllowed = async () => {};
|
|
178
|
+
await ta('one statement writes the intent with the ciphertext and merges the row', async () => {
|
|
179
|
+
const sqls = [];
|
|
180
|
+
const db = { query: async (sql, params) => { sqls.push({ sql, params }); return { rows: [{ id: 5, action: 'render-standup', state: 'pending' }] }; } };
|
|
181
|
+
const out = await realEnqueue(db, 18, { keyEnc: 'v1.x', appHost: { platform: 'render', mode: 'greenfield', ownerId: 'tea-abcdefgh1234' }, requestedBy: 'api:self' });
|
|
182
|
+
assert.equal(out.intent.id, 5);
|
|
183
|
+
assert.equal(sqls.length, 1, 'a single statement — both land or neither does');
|
|
184
|
+
assert.match(sqls[0].sql, /INSERT INTO provisioning_intents[\s\S]*borrowed_key_enc[\s\S]*UPDATE provisioning_instances[\s\S]*app_host/);
|
|
185
|
+
assert.equal(sqls[0].params[2], 'v1.x');
|
|
186
|
+
assert.doesNotMatch(sqls[0].sql, /RETURNING \*[\s\S]*SELECT i\.\*/, 'the ciphertext is not read back');
|
|
187
|
+
});
|
|
188
|
+
await ta('a collision on the open-intent index stores nothing and names the blocker', async () => {
|
|
189
|
+
let n = 0;
|
|
190
|
+
const db = { query: async () => { if (n++ === 0) throw Object.assign(new Error('dup'), { code: '23505' }); return { rows: [{ id: 3, action: 'teardown', state: 'pending' }] }; } };
|
|
191
|
+
const out = await realEnqueue(db, 18, { keyEnc: 'v1.x', appHost: { mode: 'greenfield', ownerId: 'tea-abcdefgh1234' } });
|
|
192
|
+
assert.equal(out.conflict, 'open_intent');
|
|
193
|
+
assert.equal(out.intent.action, 'teardown');
|
|
194
|
+
});
|
|
195
|
+
guard.assertIntentAllowed = realGuard;
|
|
196
|
+
|
|
197
|
+
console.log('\nwiring:');
|
|
198
|
+
await ta('module.json mounts the route; the action is in the vocabulary and the newest CHECK', () => {
|
|
199
|
+
const manifest = JSON.parse(readFileSync(new URL('../modules/provisioning/module.json', import.meta.url), 'utf8'));
|
|
200
|
+
assert.ok(manifest.contributes.routes.includes('render-standup'));
|
|
201
|
+
assert.ok(provisioning.INTENT_ACTIONS.includes('render-standup'));
|
|
202
|
+
const mig = readFileSync(new URL('../modules/provisioning/migrations/provisioning_028_render_standup.sql', import.meta.url), 'utf8');
|
|
203
|
+
assert.match(mig, /borrowed_key_enc IS NULL OR state IN \('pending', 'running'\)/, 'a resolved intent cannot keep a key');
|
|
204
|
+
});
|
|
205
|
+
await ta('every path that resolves an intent clears the key', () => {
|
|
206
|
+
const src = readFileSync(new URL('../modules/provisioning/provisioning.js', import.meta.url), 'utf8');
|
|
207
|
+
const resolve = src.slice(src.indexOf('async function resolveIntent'), src.indexOf('async function resolveIntent') + 400);
|
|
208
|
+
assert.match(resolve, /borrowed_key_enc = NULL/);
|
|
209
|
+
const teardown = src.slice(src.indexOf('async function enqueueTeardown'), src.indexOf('async function enqueueTeardown') + 600);
|
|
210
|
+
assert.match(teardown, /borrowed_key_enc = NULL/, 'a superseded standup drops its key');
|
|
211
|
+
assert.match(teardown, /'render-standup'\) AND state = 'pending'/);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
server.close();
|
|
215
|
+
console.log(`\nprovisioning_render_route: ${passed} passed, ${failed} failed`);
|
|
216
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -169,7 +169,7 @@ await ta('enqueueTeardown supersedes a PENDING settings push (a push into a proj
|
|
|
169
169
|
const r = await provisioning.enqueueTeardown(db, 18, 'api:self');
|
|
170
170
|
assert.equal(r.created, true);
|
|
171
171
|
assert.equal(r.superseded, true, 'the queued push was cancelled so the teardown could take the slot');
|
|
172
|
-
assert.match(calls[0], /action IN \('provision', 'settings-apply', 'restart', 'repo-private'\) AND state = 'pending'/, 'pending-only, and only the supersedable actions');
|
|
172
|
+
assert.match(calls[0], /action IN \('provision', 'settings-apply', 'restart', 'repo-private', 'render-standup'\) AND state = 'pending'/, 'pending-only, and only the supersedable actions');
|
|
173
173
|
});
|
|
174
174
|
|
|
175
175
|
// ── the route, over a real mounted router (stubbed data access) ─────────────────
|
|
@@ -31,7 +31,7 @@ const require = createRequire(import.meta.url);
|
|
|
31
31
|
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
32
32
|
|
|
33
33
|
// The actions a teardown supersedes, mirroring enqueueTeardown's SQL.
|
|
34
|
-
const SUPERSEDABLE = new Set(['provision', 'settings-apply', 'restart', 'repo-private']);
|
|
34
|
+
const SUPERSEDABLE = new Set(['provision', 'settings-apply', 'restart', 'repo-private', 'render-standup']);
|
|
35
35
|
|
|
36
36
|
let passed = 0, failed = 0;
|
|
37
37
|
function t(name, fn) {
|
|
@@ -81,7 +81,7 @@ function fakeIntentDb(seed = [], { shape = 'co-tenant' } = {}) {
|
|
|
81
81
|
// dead work in front of a teardown (task 1003140 widened it to the settings
|
|
82
82
|
// push, task 1003238 to the restart; an open teardown is left alone for the
|
|
83
83
|
// idempotent path).
|
|
84
|
-
assert.match(sql, /action IN \('provision', 'settings-apply', 'restart', 'repo-private'\) AND state = 'pending'/);
|
|
84
|
+
assert.match(sql, /action IN \('provision', 'settings-apply', 'restart', 'repo-private', 'render-standup'\) AND state = 'pending'/);
|
|
85
85
|
updates++;
|
|
86
86
|
// The supersede must skip the platform's own row IN ITS OWN SQL (task 1004286): the
|
|
87
87
|
// guard refuses that teardown only afterwards, in enqueueIntent. Modelled, not assumed.
|