@bongos/core 1.19.1079 → 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 +86 -31
- 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/architecture.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +7 -1
- 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 +2 -2
- package/release-notes.json +16 -0
- package/scripts/gds/module.js +176 -46
- 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/module_update.mjs +232 -0
- 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,110 @@
|
|
|
1
|
+
// scripts/gds/render-payload.js — WHAT the Render standup leg creates, kept apart from
|
|
2
|
+
// HOW it creates it (scripts/gds/provision-render.js) (task 1004183).
|
|
3
|
+
//
|
|
4
|
+
// Pure functions, no I/O. They are separate so the thing being stood up can change
|
|
5
|
+
// without touching the guard or the key plumbing: this file builds the Bongos instance
|
|
6
|
+
// the Render pilot proved (task 1004142, docs/recipes/render-pilot.md §4 and its
|
|
7
|
+
// "Corrections" box), and task 1004350 retargets it at the project's own app, per
|
|
8
|
+
// ADR 0345 (we host the hall; the owner's app is what goes to their Render account).
|
|
9
|
+
//
|
|
10
|
+
// The plans are per-instance settings (ADR 0327 §5), never constants. The defaults are
|
|
11
|
+
// what the pilot measured as enough for a new instance at idle: a `starter` web service
|
|
12
|
+
// (48-62 MiB of 512) and a paid `basic_256mb` Postgres, because Render's free Postgres
|
|
13
|
+
// has no backups of any kind.
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const crypto = require('node:crypto');
|
|
18
|
+
|
|
19
|
+
// The vocabulary lives with the web tier's validation, so the two can never disagree.
|
|
20
|
+
const { WEB_PLANS, DB_PLANS, REGIONS } = require('../../modules/provisioning/render-standup.js');
|
|
21
|
+
const DEFAULTS = Object.freeze({ webPlan: 'starter', dbPlan: 'basic_256mb', region: 'virginia' });
|
|
22
|
+
|
|
23
|
+
// A Render resource name from the project's slug. Render allows letters, digits and
|
|
24
|
+
// dashes; the slug already is that, this only makes sure.
|
|
25
|
+
function resourceName(inst, suffix) {
|
|
26
|
+
const base = String(inst.slug || `project-${inst.id}`).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50);
|
|
27
|
+
return suffix ? `${base}-${suffix}` : base;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The plan and region an instance asked for, with the defaults filled in. A value outside
|
|
31
|
+
// the vocabulary is refused rather than passed through for Render to reject.
|
|
32
|
+
function plansFor(appHost) {
|
|
33
|
+
const h = appHost || {};
|
|
34
|
+
const out = {
|
|
35
|
+
webPlan: h.webPlan || DEFAULTS.webPlan,
|
|
36
|
+
dbPlan: h.dbPlan || DEFAULTS.dbPlan,
|
|
37
|
+
region: h.region || DEFAULTS.region,
|
|
38
|
+
};
|
|
39
|
+
if (!WEB_PLANS.includes(out.webPlan)) throw new Error(`unknown Render web plan '${out.webPlan}'`);
|
|
40
|
+
if (!DB_PLANS.includes(out.dbPlan)) throw new Error(`unknown Render database plan '${out.dbPlan}'`);
|
|
41
|
+
if (!REGIONS.includes(out.region)) throw new Error(`unknown Render region '${out.region}'`);
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// POST /postgres. `plan` is never 'free' — see the header.
|
|
46
|
+
function renderPostgresPayload(inst, { ownerId, dbPlan, region }) {
|
|
47
|
+
return { name: resourceName(inst, 'db'), ownerId, plan: dbPlan, region, version: '16' };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// POST /services, for a Bongos instance from its repository. `dbUrl` is the database's
|
|
51
|
+
// internal connection string; `secretKey` is a fresh BUILDER_SECRET_KEY. Both are VALUES
|
|
52
|
+
// sent to Render and held there — the platform keeps neither.
|
|
53
|
+
//
|
|
54
|
+
// What each non-obvious line is for, from the pilot's corrections:
|
|
55
|
+
// - runtime + startCommand are explicit: a hybrid repo defaults to Python on Render, and
|
|
56
|
+
// an adopted instance has no `npm start`;
|
|
57
|
+
// - HOST=0.0.0.0: the server binds 127.0.0.1 otherwise and Render's router never reaches it;
|
|
58
|
+
// - PGDATABASE = the URL: migrate.sh addresses the database as `psql -d "$PGDATABASE"`;
|
|
59
|
+
// - BUILDER_SECRET_KEY: Render's disk is wiped each deploy, so the key-file fallback
|
|
60
|
+
// would mint a new key every time and orphan every stored per-builder secret;
|
|
61
|
+
// - no PORT: Render injects it.
|
|
62
|
+
// Bongos reads DATABASE_URL first (src/bongos/pool.js), so none of the PG* family the box
|
|
63
|
+
// writes is needed — the one simplification Render buys.
|
|
64
|
+
function renderServicePayload(inst, { ownerId, repoUrl, branch, webPlan, region, dbUrl, secretKey, envPrefix, firstAdmin }) {
|
|
65
|
+
const envVars = [
|
|
66
|
+
{ key: 'NODE_VERSION', value: '22' },
|
|
67
|
+
{ key: 'HOST', value: '0.0.0.0' },
|
|
68
|
+
{ key: 'DATABASE_URL', value: dbUrl },
|
|
69
|
+
{ key: 'PGDATABASE', value: dbUrl },
|
|
70
|
+
{ key: 'PGPOOL_MAX', value: '5' },
|
|
71
|
+
{ key: 'BUILDER_SECRET_KEY', value: secretKey },
|
|
72
|
+
];
|
|
73
|
+
if (firstAdmin) envVars.push({ key: `${envPrefix}_FIRST_ADMIN`, value: firstAdmin });
|
|
74
|
+
return {
|
|
75
|
+
type: 'web_service',
|
|
76
|
+
name: resourceName(inst),
|
|
77
|
+
ownerId,
|
|
78
|
+
repo: repoUrl,
|
|
79
|
+
branch: branch || 'main',
|
|
80
|
+
autoDeploy: 'yes',
|
|
81
|
+
serviceDetails: {
|
|
82
|
+
runtime: 'node',
|
|
83
|
+
plan: webPlan,
|
|
84
|
+
region,
|
|
85
|
+
healthCheckPath: '/healthz',
|
|
86
|
+
envSpecificDetails: {
|
|
87
|
+
buildCommand: 'npm ci',
|
|
88
|
+
startCommand: 'node node_modules/@bongos/core/src/platform-server.js',
|
|
89
|
+
preDeployCommand: 'bash node_modules/@bongos/core/scripts/migrate.sh',
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
envVars,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The env vars that can only be set once the service exists, because they name the URL
|
|
97
|
+
// Render gave it. The four origins must all be that one host: a single-host instance has
|
|
98
|
+
// no separate builders./status. subdomain, and without them the hall never loads.
|
|
99
|
+
function renderAfterCreateEnv(serviceUrl, { envPrefix }) {
|
|
100
|
+
const origin = String(serviceUrl || '').replace(/\/+$/, '');
|
|
101
|
+
if (!/^https:\/\//.test(origin)) throw new Error(`Render gave the service no https URL (${serviceUrl || 'none'})`);
|
|
102
|
+
return ['PUBLIC', 'BUILDERS', 'STATUS', 'OAUTH'].map((k) => ({ key: `${envPrefix}_${k}_ORIGIN`, value: origin }));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function freshSecretKey() { return crypto.randomBytes(32).toString('hex'); }
|
|
106
|
+
|
|
107
|
+
module.exports = {
|
|
108
|
+
WEB_PLANS, DB_PLANS, REGIONS, DEFAULTS,
|
|
109
|
+
resourceName, plansFor, renderPostgresPayload, renderServicePayload, renderAfterCreateEnv, freshSecretKey,
|
|
110
|
+
};
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.19.
|
|
78
|
+
const CORE_VERSION = '1.19.1081'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// tests/module_update.mjs — `bongos module update <key>` (task 1003786, ADR 0338 D1,
|
|
2
|
+
// criterion wa5-module-economy). No network, no database: cmdUpdate runs against a
|
|
3
|
+
// fake store serving REAL packed tarballs, so verification is the real
|
|
4
|
+
// verifyModuleArtifact.
|
|
5
|
+
//
|
|
6
|
+
// The criterion's teeth are the parity tests: an update to a paid module the caller
|
|
7
|
+
// holds and to a free one make the identical calls with the identical outcome, and
|
|
8
|
+
// the shared acquisition path carries no price-conditional code at all.
|
|
9
|
+
//
|
|
10
|
+
// Run: node tests/module_update.mjs
|
|
11
|
+
|
|
12
|
+
import { test } from 'node:test';
|
|
13
|
+
import { strict as assert } from 'node:assert';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
const { packModule, verifyModuleArtifact } = require('../scripts/gds/module-artifact.js');
|
|
23
|
+
const { readTar, buildTar } = require('../scripts/gds/artifact-format.js');
|
|
24
|
+
const { cmdUpdate, cmdInstall } = require('../scripts/gds/module.js');
|
|
25
|
+
|
|
26
|
+
const NOW = () => new Date('2026-09-29T00:00:00Z');
|
|
27
|
+
|
|
28
|
+
// A real packed tarball for weather@version, with `extra` files beside module.json.
|
|
29
|
+
async function packed(version, extra = {}, mj = {}) {
|
|
30
|
+
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-update-src-'));
|
|
31
|
+
const manifest = { key: 'weather', title: 'Weather', description: 'Shows the weather.', version, coreVersion: '^1.0.0', contributes: {}, ...mj };
|
|
32
|
+
const dir = path.join(src, 'weather');
|
|
33
|
+
const files = { 'module.json': JSON.stringify(manifest, null, 2), 'routes/weather.js': `module.exports = () => '${version}';\n`, ...extra };
|
|
34
|
+
for (const [rel, body] of Object.entries(files)) {
|
|
35
|
+
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
|
|
36
|
+
fs.writeFileSync(path.join(dir, rel), body);
|
|
37
|
+
}
|
|
38
|
+
const { tgz } = packModule('weather', { modulesDir: src, now: NOW, modeOf: () => '644' });
|
|
39
|
+
const v = await verifyModuleArtifact(tgz, { key: 'weather' });
|
|
40
|
+
assert.ok(v.ok);
|
|
41
|
+
return { tgz, v };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// An installed weather@version in a fresh modules dir.
|
|
45
|
+
function installedAt(version, extra = {}) {
|
|
46
|
+
const modulesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-update-dest-'));
|
|
47
|
+
const dir = path.join(modulesDir, 'weather');
|
|
48
|
+
const files = { 'module.json': JSON.stringify({ key: 'weather', version }), 'routes/weather.js': `module.exports = () => '${version}';\n`, ...extra };
|
|
49
|
+
for (const [rel, body] of Object.entries(files)) {
|
|
50
|
+
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
|
|
51
|
+
fs.writeFileSync(path.join(dir, rel), body);
|
|
52
|
+
}
|
|
53
|
+
return modulesDir;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A fake store. `source` is how the caller came to hold the module; the store's own
|
|
57
|
+
// acquire route never looks at price for a holder, so nothing else differs.
|
|
58
|
+
function fakeStore({ tgz, v }, { source = 'free', over = {} } = {}) {
|
|
59
|
+
const calls = [];
|
|
60
|
+
const request = async (method, urlPath, opts = {}) => {
|
|
61
|
+
calls.push({ method, urlPath, json: opts.json });
|
|
62
|
+
if (urlPath.endsWith('/acquire')) {
|
|
63
|
+
return over.acquire || { ok: true, status: 200, data: {
|
|
64
|
+
ok: true, module_key: 'weather', version: v.version, core_version: v.coreVersion,
|
|
65
|
+
tarball_sha256: v.tarballSha256, tree_sha256: v.treeSha256, tarball_bytes: v.bytes, channel: 'general',
|
|
66
|
+
entitlement: { module_key: 'weather', holder_kind: 'builder', holder_ref: '42', source, version: '1.2.0' },
|
|
67
|
+
} };
|
|
68
|
+
}
|
|
69
|
+
if (urlPath.endsWith('/tarball')) return { ok: true, status: 200, buf: over.buf || tgz };
|
|
70
|
+
if (urlPath.endsWith('/acquired')) return { ok: true, status: 200, data: { ok: true } };
|
|
71
|
+
throw new Error(`unexpected ${method} ${urlPath}`);
|
|
72
|
+
};
|
|
73
|
+
return { calls, request };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const quiet = () => { const out = []; return { out, log: (m) => out.push(m), errlog: (m) => out.push(m) }; };
|
|
77
|
+
const opts = (modulesDir, store, q = quiet()) => ({ ...q, modulesDir, roots: [], request: store.request, core: '1.19.0' });
|
|
78
|
+
const route = (modulesDir) => fs.readFileSync(path.join(modulesDir, 'weather', 'routes', 'weather.js'), 'utf8');
|
|
79
|
+
|
|
80
|
+
// ---- update ----
|
|
81
|
+
|
|
82
|
+
test('updates an installed module to the newest version: files replaced, old-only files gone, version recorded', async () => {
|
|
83
|
+
const modulesDir = installedAt('1.2.0', { 'old-only.js': 'x' });
|
|
84
|
+
const store = fakeStore(await packed('1.3.0'));
|
|
85
|
+
const q = quiet();
|
|
86
|
+
assert.equal(await cmdUpdate(['weather'], opts(modulesDir, store, q)), 0, q.out.join('\n'));
|
|
87
|
+
assert.equal(route(modulesDir), "module.exports = () => '1.3.0';\n");
|
|
88
|
+
assert.ok(!fs.existsSync(path.join(modulesDir, 'weather', 'old-only.js')), 'the new version is exactly what was published');
|
|
89
|
+
assert.deepEqual(fs.readdirSync(modulesDir), ['weather'], 'no staging or previous dir left behind');
|
|
90
|
+
assert.deepEqual(store.calls.map((c) => `${c.method} ${c.urlPath}`), [
|
|
91
|
+
'POST /store/modules/weather/acquire',
|
|
92
|
+
'GET /store/modules/weather/versions/1.3.0/tarball',
|
|
93
|
+
'POST /store/modules/weather/acquired',
|
|
94
|
+
]);
|
|
95
|
+
assert.deepEqual(store.calls[2].json, { version: '1.3.0' });
|
|
96
|
+
assert.match(q.out.join('\n'), /1\.2\.0 → 1\.3\.0\. Its on\/off switch is unchanged/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('already on the newest version: says so and downloads nothing', async () => {
|
|
100
|
+
const modulesDir = installedAt('1.3.0');
|
|
101
|
+
const store = fakeStore(await packed('1.3.0'));
|
|
102
|
+
const q = quiet();
|
|
103
|
+
assert.equal(await cmdUpdate(['weather'], opts(modulesDir, store, q)), 0);
|
|
104
|
+
assert.deepEqual(store.calls.map((c) => c.urlPath), ['/store/modules/weather/acquire']);
|
|
105
|
+
assert.match(q.out.join('\n'), /up to date at 1\.3\.0/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('an older --version is refused before the store is asked: update only moves forward', async () => {
|
|
109
|
+
const modulesDir = installedAt('1.3.0');
|
|
110
|
+
const store = fakeStore(await packed('1.2.0'));
|
|
111
|
+
assert.equal(await cmdUpdate(['weather', '--version', '1.2.0'], opts(modulesDir, store)), 1);
|
|
112
|
+
assert.equal(store.calls.length, 0);
|
|
113
|
+
assert.equal(route(modulesDir), "module.exports = () => '1.3.0';\n");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('--version asks the store for that exact version', async () => {
|
|
117
|
+
const modulesDir = installedAt('1.2.0');
|
|
118
|
+
const store = fakeStore(await packed('1.4.0'));
|
|
119
|
+
assert.equal(await cmdUpdate(['weather', '--version', '1.4.0'], opts(modulesDir, store)), 0);
|
|
120
|
+
assert.deepEqual(store.calls[0].json, { version: '1.4.0' });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('a module that is not installed is refused, pointing at install', async () => {
|
|
124
|
+
const modulesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-update-empty-'));
|
|
125
|
+
const store = fakeStore(await packed('1.3.0'));
|
|
126
|
+
const q = quiet();
|
|
127
|
+
assert.equal(await cmdUpdate(['weather'], opts(modulesDir, store, q)), 1);
|
|
128
|
+
assert.match(q.out.join('\n'), /not installed here\. Run `bongos module install weather`/);
|
|
129
|
+
assert.equal(store.calls.length, 0);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('a module bundled with the core is refused: it updates with the core', async () => {
|
|
133
|
+
const modulesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-update-empty-'));
|
|
134
|
+
const coreRoot = installedAt('1.0.0');
|
|
135
|
+
const store = fakeStore(await packed('1.3.0'));
|
|
136
|
+
const q = quiet();
|
|
137
|
+
assert.equal(await cmdUpdate(['weather'], { ...opts(modulesDir, store, q), roots: [coreRoot] }), 1);
|
|
138
|
+
assert.match(q.out.join('\n'), /comes with the core, not the store/);
|
|
139
|
+
assert.equal(store.calls.length, 0);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('a tampered download leaves the installed version untouched', async () => {
|
|
143
|
+
const modulesDir = installedAt('1.2.0');
|
|
144
|
+
const next = await packed('1.3.0');
|
|
145
|
+
const entries = readTar(next.tgz);
|
|
146
|
+
entries.find((e) => e.name.endsWith('routes/weather.js')).buf = Buffer.from('evil();\n');
|
|
147
|
+
const store = fakeStore(next, { over: { buf: buildTar(entries) } });
|
|
148
|
+
const q = quiet();
|
|
149
|
+
assert.equal(await cmdUpdate(['weather'], opts(modulesDir, store, q)), 1);
|
|
150
|
+
assert.match(q.out.join('\n'), /failed verification \(hash_mismatch\)/);
|
|
151
|
+
assert.equal(route(modulesDir), "module.exports = () => '1.2.0';\n");
|
|
152
|
+
assert.deepEqual(fs.readdirSync(modulesDir), ['weather']);
|
|
153
|
+
assert.ok(!store.calls.some((c) => c.urlPath.endsWith('/acquired')));
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('a version this core cannot mount is refused before download, the installed one kept', async () => {
|
|
157
|
+
const modulesDir = installedAt('1.2.0');
|
|
158
|
+
const store = fakeStore(await packed('2.0.0', {}, { coreVersion: '^2.0.0' }));
|
|
159
|
+
assert.equal(await cmdUpdate(['weather'], opts(modulesDir, store)), 1);
|
|
160
|
+
assert.equal(store.calls.length, 1);
|
|
161
|
+
assert.equal(route(modulesDir), "module.exports = () => '1.2.0';\n");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('a store refusal (e.g. a delisted module) keeps the installed version', async () => {
|
|
165
|
+
const modulesDir = installedAt('1.2.0');
|
|
166
|
+
const store = fakeStore(await packed('1.3.0'), { over: { acquire: { ok: false, status: 409, data: { error: { code: 'module_delisted', message: 'delisted' } } } } });
|
|
167
|
+
const q = quiet();
|
|
168
|
+
assert.equal(await cmdUpdate(['weather'], opts(modulesDir, store, q)), 1);
|
|
169
|
+
assert.match(q.out.join('\n'), /HTTP 409\): delisted/);
|
|
170
|
+
assert.equal(route(modulesDir), "module.exports = () => '1.2.0';\n");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ---- parity: paid and free take one path (criterion wa5-module-economy) ----
|
|
174
|
+
|
|
175
|
+
test('a paid module the caller holds and a free one update through identical calls with an identical outcome', async () => {
|
|
176
|
+
const next = await packed('1.3.0');
|
|
177
|
+
const runs = {};
|
|
178
|
+
for (const source of ['free', 'purchase', 'grant', 'core']) {
|
|
179
|
+
const modulesDir = installedAt('1.2.0');
|
|
180
|
+
const store = fakeStore(next, { source });
|
|
181
|
+
const q = quiet();
|
|
182
|
+
const code = await cmdUpdate(['weather'], opts(modulesDir, store, q));
|
|
183
|
+
runs[source] = { code, calls: store.calls, out: q.out.join('\n').replaceAll(modulesDir, '<dir>'), route: route(modulesDir) };
|
|
184
|
+
}
|
|
185
|
+
for (const source of ['purchase', 'grant', 'core']) {
|
|
186
|
+
assert.deepEqual(runs[source], runs.free, `an update of a module held as '${source}' must not differ from a free one`);
|
|
187
|
+
}
|
|
188
|
+
assert.equal(runs.free.code, 0);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Strip comments so only code is scanned; a word in an explanation is not a branch.
|
|
192
|
+
function codeOf(src, fnName) {
|
|
193
|
+
const start = src.indexOf(`function ${fnName}(`);
|
|
194
|
+
assert.ok(start !== -1, `${fnName} exists`);
|
|
195
|
+
const next = src.indexOf('\nfunction ', start + 1);
|
|
196
|
+
const nextAsync = src.indexOf('\nasync function ', start + 1);
|
|
197
|
+
const end = [next, nextAsync].filter((i) => i > start).sort((a, b) => a - b)[0] ?? src.length;
|
|
198
|
+
return src.slice(start, end).replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
test('the shared acquisition path, install and update carry no price-conditional code', () => {
|
|
202
|
+
const src = fs.readFileSync(path.join(ROOT, 'scripts/gds/module.js'), 'utf8');
|
|
203
|
+
for (const fn of ['fetchVerified', 'recordPlaced', 'cmdInstall', 'cmdUpdate', 'replaceModule', 'placeModule']) {
|
|
204
|
+
assert.doesNotMatch(codeOf(src, fn), /\b(price|priced|paid|cents|credits|free|purchase|source)\b/i, `${fn} must not read how the module was obtained`);
|
|
205
|
+
}
|
|
206
|
+
// Both commands go through the one path, not two copies of it.
|
|
207
|
+
assert.match(codeOf(src, 'cmdInstall'), /await fetchVerified\(key,/);
|
|
208
|
+
assert.match(codeOf(src, 'cmdUpdate'), /await fetchVerified\(key,/);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('the store only looks at price for a caller who holds no entitlement, so a holder is never charged again', () => {
|
|
212
|
+
const src = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/modules.js'), 'utf8');
|
|
213
|
+
const start = src.indexOf("router.post('/store/modules/:key/acquire'");
|
|
214
|
+
const block = src.slice(start, src.indexOf('router.get(', start));
|
|
215
|
+
const priceAt = block.indexOf('r.free');
|
|
216
|
+
const guardAt = block.indexOf('if (!held.entitled) {');
|
|
217
|
+
assert.ok(guardAt !== -1 && priceAt > guardAt, 'the price check sits inside the not-entitled branch');
|
|
218
|
+
assert.equal(block.split('r.free').length - 1, 1, 'price is read in exactly one place');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ---- install: the grader's finding on task 1003785 ----
|
|
222
|
+
|
|
223
|
+
test('install refuses a key already present in another discovery root (a module bundled with the core)', async () => {
|
|
224
|
+
const modulesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-install-empty-'));
|
|
225
|
+
const coreRoot = installedAt('1.0.0');
|
|
226
|
+
const store = fakeStore(await packed('1.3.0'));
|
|
227
|
+
const q = quiet();
|
|
228
|
+
assert.equal(await cmdInstall(['weather'], { ...opts(modulesDir, store, q), roots: [coreRoot] }), 1);
|
|
229
|
+
assert.match(q.out.join('\n'), /already on disk at .*run `bongos module update weather`/);
|
|
230
|
+
assert.equal(store.calls.length, 0);
|
|
231
|
+
assert.deepEqual(fs.readdirSync(modulesDir), []);
|
|
232
|
+
});
|
|
@@ -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);
|