@bongos/core 1.20.37 → 1.20.39
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 +119 -64
- package/.claude/skills/backlog-review/SKILL.md +1 -1
- package/.claude/skills/bongos-feedback/SKILL.md +66 -0
- package/.claude/skills/collab-review/SKILL.md +1 -1
- package/.claude/skills/priority-session/SKILL.md +1 -1
- package/.claude/skills/session-handoff/SKILL.md +1 -1
- package/.claude/skills/status/SKILL.md +1 -1
- package/bin/bongos.js +1 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +12 -1
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/adr/0136-update-channel-subscription-policy.md +1 -1
- package/docs/adr/0357-the-update-rule-set-on-deploy-is-the-one-the-sweep-follows.md +41 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +188 -2
- package/docs/api-reference.md +12 -4
- package/docs/copy-inventory.md +10 -10
- package/docs/copy-registry.json +18 -18
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +253 -250
- package/docs/recipes/upgrading-the-core.md +2 -0
- package/modules/autonomy/db.js +39 -8
- package/modules/autonomy/fence.js +26 -4
- package/modules/autonomy/migrations/autonomy_003_priority_goal.sql +32 -0
- package/modules/autonomy/routes/autonomy.js +28 -1
- package/modules/hall-ui/public/gate.js +33 -4
- package/modules/ideas/routing.js +8 -1
- package/modules/npm-release/preview/childlog.js +95 -0
- package/modules/npm-release/preview/commands.js +46 -5
- package/modules/npm-release/preview/env.js +18 -7
- package/modules/npm-release/preview/runtime.js +43 -9
- package/modules/npm-release/preview/supervisor.js +170 -31
- package/modules/npm-release/public/work.css +3 -0
- package/modules/npm-release/public/work.js +6 -1
- package/modules/npm-release/routes/preview.js +1 -0
- package/modules/platform-identity/instance-feedback.js +142 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/routes/sso.js +62 -0
- package/modules/provisioning/render-standup.js +13 -1
- package/modules/public-landing/public/projects.html +22 -8
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +30 -0
- package/scripts/gds/autobongos-run.js +14 -4
- package/scripts/gds/feedback-send.js +131 -0
- package/scripts/gds/provision-render.js +13 -1
- package/scripts/gds/render-payload.js +6 -5
- package/scripts/gds/update-channel.js +82 -0
- package/src/bongos/routes/feedback.js +110 -0
- package/src/bongos/routes.js +2 -0
- package/src/module-api.js +1 -1
- package/tests/autobongos_fence.mjs +62 -0
- package/tests/autobongos_loop.mjs +76 -0
- package/tests/autonomy_fence_priority.mjs +211 -0
- package/tests/bongos_cli.mjs +7 -1
- package/tests/filing_abuse_matrix.mjs +7 -2
- package/tests/idea_routing.mjs +1 -1
- package/tests/instance_feedback.mjs +327 -0
- package/tests/npm_release_preview_commands.mjs +1 -1
- package/tests/npm_release_preview_diagnosis.mjs +376 -0
- package/tests/npm_release_preview_supervisor.mjs +4 -4
- package/tests/projects_hub_app_step.mjs +49 -0
- package/tests/projects_hub_render_connect.mjs +2 -1
- package/tests/provision_render.mjs +23 -2
- package/tests/update_channel_db.mjs +124 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
// tests/npm_release_preview_diagnosis.mjs — a preview that cannot serve data must say so (task 1004465).
|
|
2
|
+
//
|
|
3
|
+
// WHAT WENT WRONG LIVE. The first real preview started, answered /healthz, was reported
|
|
4
|
+
// running, and then returned 500 on every page; the child's output went nowhere (stdio
|
|
5
|
+
// 'ignore'), so nobody could say why. Stopping it left the copy database behind, because the
|
|
6
|
+
// child was killed without waiting and the failed dropdb was ignored. These tests pin the four
|
|
7
|
+
// repairs: the child's output is kept and redacted, the health check proves data can be read,
|
|
8
|
+
// a stop waits for the child before dropping, and a drop that fails is reported, never idle.
|
|
9
|
+
// Every spawn, command, probe, clock and file is injected.
|
|
10
|
+
//
|
|
11
|
+
// Run: node --test tests/npm_release_preview_diagnosis.mjs
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { test } from 'node:test';
|
|
14
|
+
import { EventEmitter } from 'node:events';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const { createSupervisor, copyInstance } = require('../modules/npm-release/preview/supervisor.js');
|
|
22
|
+
const cmds = require('../modules/npm-release/preview/commands.js');
|
|
23
|
+
const { createChildLog, redact, secretValues } = require('../modules/npm-release/preview/childlog.js');
|
|
24
|
+
const { probeHealth } = require('../modules/npm-release/preview/runtime.js');
|
|
25
|
+
const { PREVIEW_DB, buildPreviewEnv } = require('../modules/npm-release/preview/env.js');
|
|
26
|
+
|
|
27
|
+
const fwd = (x) => String(x).split(String.fromCharCode(92)).join('/');
|
|
28
|
+
// Secret-shaped strings assembled at run time, so no scanner mistakes this file for a leak.
|
|
29
|
+
const FAKE_GH = ['gh', 'p_', 'abcdefghijklmnopqrstuvwxyz0123'].join('');
|
|
30
|
+
const FAKE_URL = ['post', 'gres://svc:pw9999@db/x'].join('');
|
|
31
|
+
|
|
32
|
+
// `sleepMode`: 'instant' returns at once (the health loop's waits); 'never' returns only for
|
|
33
|
+
// waits under a second, so a stop can be released ONLY by the child's real exit.
|
|
34
|
+
function rig({ probe = async () => true, failRun = () => false, killBehaviour = 'exit', sleepMode = 'instant', instanceRoot = null, env, liveDb = 'live_db', copy } = {}) {
|
|
35
|
+
const events = [];
|
|
36
|
+
const calls = [];
|
|
37
|
+
const fake = { spawned: [], rm: [] };
|
|
38
|
+
const sup = createSupervisor({
|
|
39
|
+
run: async (cmd) => {
|
|
40
|
+
calls.push(cmd);
|
|
41
|
+
events.push(cmd.cmd);
|
|
42
|
+
return failRun(cmd) ? { code: 3, stderr: 'boom from ' + cmd.cmd } : { code: 0, stderr: '' };
|
|
43
|
+
},
|
|
44
|
+
spawn: (bin, args, opts) => {
|
|
45
|
+
const child = new EventEmitter();
|
|
46
|
+
child.stdout = new EventEmitter();
|
|
47
|
+
child.stderr = new EventEmitter();
|
|
48
|
+
child.bin = bin; child.args = args; child.opts = opts;
|
|
49
|
+
child.kill = (sig) => {
|
|
50
|
+
events.push('kill:' + sig);
|
|
51
|
+
if (killBehaviour === 'exit') setImmediate(() => { events.push('exit'); child.emit('exit', 0); });
|
|
52
|
+
if (killBehaviour === 'on-sigkill' && sig === 'SIGKILL') setImmediate(() => { events.push('exit'); child.emit('exit', null); });
|
|
53
|
+
};
|
|
54
|
+
fake.spawned.push(child);
|
|
55
|
+
return child;
|
|
56
|
+
},
|
|
57
|
+
probe,
|
|
58
|
+
sleep: (ms) => (sleepMode === 'instant' || ms < 1000 ? Promise.resolve() : sleepMode === 'short' ? new Promise((r) => setTimeout(r, 10)) : new Promise(() => {})),
|
|
59
|
+
now: () => 1_000_000,
|
|
60
|
+
fsImpl: { mkdtempSync: (p) => p + 'XYZ', mkdirSync() {}, rmSync: (p) => fake.rm.push(p), writeFile() {}, existsSync: () => false, readFileSync: () => { throw new Error('none'); } },
|
|
61
|
+
tmpdir: '/tmp', liveDb, cacheRoot: '/cache', port: 3190, instanceRoot, copy,
|
|
62
|
+
env: env || { PATH: '/bin' },
|
|
63
|
+
});
|
|
64
|
+
return { sup, events, calls, fake };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---- the child's output: kept, capped, redacted ---------------------------------------
|
|
68
|
+
|
|
69
|
+
test('what the child printed is kept and shown when the preview cannot serve data', async () => {
|
|
70
|
+
const r = rig({
|
|
71
|
+
env: { PATH: '/bin', PGPASSWORD: 'hunter2pass', GITHUB_TOKEN: FAKE_GH },
|
|
72
|
+
probe: async () => {
|
|
73
|
+
r.fake.spawned[0].stderr.emit('data', 'error: relation "builders" does not exist\n');
|
|
74
|
+
r.fake.spawned[0].stdout.emit('data', `connecting with password=abc123 to ${FAKE_URL} using hunter2pass and ${FAKE_GH}\n`);
|
|
75
|
+
return { ok: false, reason: 'a database-backed read (/api/bongos/me) answered 500' };
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
await r.sup.start('1.19.1000').done;
|
|
79
|
+
const s = r.sup.status();
|
|
80
|
+
assert.equal(s.state, 'failed');
|
|
81
|
+
const tail = s.log_tail.join('\n');
|
|
82
|
+
assert.match(tail, /relation "builders" does not exist/);
|
|
83
|
+
assert.match(s.error, /Its last output:.*relation "builders" does not exist/);
|
|
84
|
+
for (const secret of ['hunter2pass', 'abc123', 'pw9999', FAKE_GH]) {
|
|
85
|
+
assert.ok(!tail.includes(secret), 'the tail leaks a secret');
|
|
86
|
+
assert.ok(!s.error.includes(secret), 'the error leaks a secret');
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('the tail is at most twenty lines and each line is short', () => {
|
|
91
|
+
const lg = createChildLog({});
|
|
92
|
+
lg.append(Array.from({ length: 50 }, (_, i) => 'line ' + i).join('\n') + '\n' + 'x'.repeat(1000) + '\n');
|
|
93
|
+
const t = lg.tail();
|
|
94
|
+
assert.equal(t.length, 20);
|
|
95
|
+
assert.ok(t.every((l) => l.length <= 301));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('the log is capped, keeping the newest output', () => {
|
|
99
|
+
const lg = createChildLog({ cap: 100 });
|
|
100
|
+
lg.append('a'.repeat(500));
|
|
101
|
+
lg.append('THE-END');
|
|
102
|
+
assert.ok(lg.size() <= 100);
|
|
103
|
+
assert.match(lg.tail().join('\n'), /THE-END$/);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('redaction takes out listed values and the common secret shapes', () => {
|
|
107
|
+
const secrets = secretValues({ SESSION_SECRET: 'sekrit-value-1', NOT_ONE: 'visible-value', PGPASSWORD: 'p' });
|
|
108
|
+
assert.deepEqual(secrets, ['sekrit-value-1'], 'only secret-named, long-enough values');
|
|
109
|
+
const out = redact(`x sekrit-value-1 y Bearer abc.def-ghi ${FAKE_URL} token=zzz visible-value`, secrets);
|
|
110
|
+
assert.ok(!/sekrit-value-1|abc\.def|pw9999|zzz/.test(out), out);
|
|
111
|
+
assert.match(out, /visible-value/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('the log is also written to a file in the scratch directory, and a failing disk does not matter', () => {
|
|
115
|
+
const writes = [];
|
|
116
|
+
const lg = createChildLog({ fsImpl: { writeFile: (f, b, cb) => { writes.push([f, b]); cb(); } }, file: '/tmp/x/preview.log', flushMs: 1e6 });
|
|
117
|
+
lg.append('hello');
|
|
118
|
+
lg.flush();
|
|
119
|
+
assert.deepEqual(writes, [['/tmp/x/preview.log', 'hello']]);
|
|
120
|
+
lg.close();
|
|
121
|
+
const broken = createChildLog({ fsImpl: { writeFile() { throw new Error('disk'); } }, file: '/f', flushMs: 1e6 });
|
|
122
|
+
broken.append('still fine');
|
|
123
|
+
assert.doesNotThrow(() => broken.flush());
|
|
124
|
+
broken.close();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('a burst of output costs one asynchronous write, never one write per chunk, and none after close', async () => {
|
|
128
|
+
const writes = [];
|
|
129
|
+
const lg = createChildLog({ fsImpl: { writeFile: (f, b, cb) => { writes.push(b); cb(); } }, file: '/f', flushMs: 5 });
|
|
130
|
+
for (let i = 0; i < 500; i++) lg.append('line ' + i + '\n');
|
|
131
|
+
assert.equal(writes.length, 0, 'nothing is written inside the data handler');
|
|
132
|
+
await new Promise((r) => setTimeout(r, 40));
|
|
133
|
+
assert.equal(writes.length, 1, 'the burst was written once');
|
|
134
|
+
assert.match(writes[0], /line 499/);
|
|
135
|
+
lg.append('after');
|
|
136
|
+
lg.close();
|
|
137
|
+
await new Promise((r) => setTimeout(r, 40));
|
|
138
|
+
assert.equal(writes.length, 1, 'closing cancels a pending write');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ---- ready means it can serve data ----------------------------------------------------
|
|
142
|
+
|
|
143
|
+
test('a probe that sees a database failure fails the preview, says why, and cleans up', async () => {
|
|
144
|
+
const { sup, events, fake, calls } = rig({ probe: async () => ({ ok: false, reason: '/version reports schemaPending null: the database copy could not be read' }) });
|
|
145
|
+
await sup.start('1.19.1000').done;
|
|
146
|
+
const s = sup.status();
|
|
147
|
+
assert.equal(s.state, 'failed');
|
|
148
|
+
assert.match(s.error, /could not serve data/);
|
|
149
|
+
assert.match(s.error, /schemaPending null/);
|
|
150
|
+
assert.ok(events.includes('kill:SIGTERM'), 'the child was stopped');
|
|
151
|
+
assert.deepEqual(calls[calls.length - 1], cmds.dropDbCommand(), 'the copy was dropped');
|
|
152
|
+
assert.equal(fake.rm.length, 1, 'the scratch directory was removed');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('a preview is running only once the probe says it can serve data', async () => {
|
|
156
|
+
const answers = [{ ok: false, reason: 'warming up' }, { ok: false, reason: 'warming up' }, { ok: true }];
|
|
157
|
+
const { sup } = rig({ probe: async () => answers.shift() });
|
|
158
|
+
await sup.start('1.19.1000').done;
|
|
159
|
+
assert.equal(sup.status().state, 'running');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('a bare true from the probe still counts as healthy (older callers)', async () => {
|
|
163
|
+
const { sup } = rig({ probe: async () => true });
|
|
164
|
+
await sup.start('1.19.1000').done;
|
|
165
|
+
assert.equal(sup.status().state, 'running');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('a child that exits while starting fails the start at once, with its output', async () => {
|
|
169
|
+
let probes = 0;
|
|
170
|
+
const r = rig({
|
|
171
|
+
probe: async () => {
|
|
172
|
+
probes += 1;
|
|
173
|
+
r.fake.spawned[0].stderr.emit('data', 'Error: Cannot find module "./nope"\n');
|
|
174
|
+
r.fake.spawned[0].emit('exit', 1);
|
|
175
|
+
return false;
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
await r.sup.start('1.19.1000').done;
|
|
179
|
+
assert.equal(probes, 1, 'it did not keep probing a dead child');
|
|
180
|
+
assert.match(r.sup.status().error, /exited \(1\) before it could serve data/);
|
|
181
|
+
assert.match(r.sup.status().error, /Cannot find module/);
|
|
182
|
+
assert.equal(r.sup.status().state, 'failed');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ---- the probe itself ------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
function fakeGet(map) {
|
|
188
|
+
return async (port, p) => map[p] || { error: 'refused' };
|
|
189
|
+
}
|
|
190
|
+
const HEALTHZ = { status: 200, body: '{}' };
|
|
191
|
+
const OK = { status: 200, body: '{"coreVersion":"1.19.1000","schemaPending":0}' };
|
|
192
|
+
|
|
193
|
+
test('the probe passes only when healthz, /version and a database-backed read all answer', async () => {
|
|
194
|
+
const r = await probeHealth(3190, fakeGet({ '/healthz': HEALTHZ, '/version': OK, '/api/bongos/me': { status: 401, body: '{}' } }));
|
|
195
|
+
assert.deepEqual(r, { ok: true });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('schemaPending null (the database read failed) fails the probe even though /healthz is 200', async () => {
|
|
199
|
+
const r = await probeHealth(3190, fakeGet({ '/healthz': HEALTHZ, '/version': { status: 200, body: '{"schemaPending":null,"commit":null}' }, '/api/bongos/me': { status: 401, body: '' } }));
|
|
200
|
+
assert.equal(r.ok, false);
|
|
201
|
+
assert.match(r.reason, /schemaPending null/);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('a 500 from a database-backed route fails the probe', async () => {
|
|
205
|
+
const r = await probeHealth(3190, fakeGet({ '/healthz': HEALTHZ, '/version': OK, '/api/bongos/me': { status: 500, body: '' } }));
|
|
206
|
+
assert.equal(r.ok, false);
|
|
207
|
+
assert.match(r.reason, /\/api\/bongos\/me\) answered 500/);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('a version too old to report schemaPending is judged by the database-backed read alone', async () => {
|
|
211
|
+
const old = { status: 200, body: '{"coreVersion":"1.0.1"}' };
|
|
212
|
+
const good = await probeHealth(3190, fakeGet({ '/healthz': HEALTHZ, '/version': old, '/api/bongos/me': { status: 401, body: '' } }));
|
|
213
|
+
assert.equal(good.ok, true);
|
|
214
|
+
const bad = await probeHealth(3190, fakeGet({ '/healthz': HEALTHZ, '/version': old, '/api/bongos/me': { status: 500, body: '' } }));
|
|
215
|
+
assert.equal(bad.ok, false);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('nothing answering, or /healthz not 200, fails the probe', async () => {
|
|
219
|
+
assert.equal((await probeHealth(3190, fakeGet({}))).ok, false);
|
|
220
|
+
assert.equal((await probeHealth(3190, fakeGet({ '/healthz': { status: 503, body: '' } }))).ok, false);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// ---- the child's working directory is an instance --------------------------------------
|
|
224
|
+
|
|
225
|
+
test('the child runs in a scratch instance holding the live config, and is told so', async () => {
|
|
226
|
+
const copied = [];
|
|
227
|
+
const { sup, fake, calls } = rig({ instanceRoot: '/live/root', copy: (o) => copied.push([o.from, fwd(o.to)]) });
|
|
228
|
+
await sup.start('1.19.1000').done;
|
|
229
|
+
assert.deepEqual(copied, [['/live/root', '/tmp/bongos-preview-XYZ/instance']]);
|
|
230
|
+
const child = fake.spawned[0];
|
|
231
|
+
assert.equal(fwd(child.opts.cwd), '/tmp/bongos-preview-XYZ/instance');
|
|
232
|
+
assert.equal(fwd(child.opts.env.BONGOS_INSTANCE_ROOT), '/tmp/bongos-preview-XYZ/instance');
|
|
233
|
+
const migrate = calls.find((c) => c.cmd === 'bash');
|
|
234
|
+
assert.equal(fwd(migrate.cwd), '/tmp/bongos-preview-XYZ/instance');
|
|
235
|
+
assert.equal(fwd(migrate.env.INIT_CWD), '/tmp/bongos-preview-XYZ/instance');
|
|
236
|
+
assert.equal(migrate.env.PGDATABASE, PREVIEW_DB);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('without an instance root the child still runs from its install directory', async () => {
|
|
240
|
+
const { sup, fake } = rig();
|
|
241
|
+
await sup.start('1.19.1000').done;
|
|
242
|
+
assert.equal(fwd(fake.spawned[0].opts.cwd), '/cache/1.19.1000');
|
|
243
|
+
assert.equal(fake.spawned[0].opts.env.BONGOS_INSTANCE_ROOT, undefined);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('copying the instance takes the boot packs and instance migrations, and nothing else from config', () => {
|
|
247
|
+
const from = fs.mkdtempSync(path.join(os.tmpdir(), 'pv-from-'));
|
|
248
|
+
const to = fs.mkdtempSync(path.join(os.tmpdir(), 'pv-to-'));
|
|
249
|
+
try {
|
|
250
|
+
fs.mkdirSync(path.join(from, 'config'));
|
|
251
|
+
fs.mkdirSync(path.join(from, 'migrations', 'instance'), { recursive: true });
|
|
252
|
+
fs.writeFileSync(path.join(from, 'config', 'branding.json'), '{"envPrefix":"ACME"}');
|
|
253
|
+
fs.writeFileSync(path.join(from, 'config', 'modules.json'), '{}');
|
|
254
|
+
// files that are not on the allow-list stay behind whatever they are called
|
|
255
|
+
for (const name of ['go-live.json', 'push-token.json', 'apikey.json', 'db-creds.json', 'notes.txt']) fs.writeFileSync(path.join(from, 'config', name), '{}');
|
|
256
|
+
fs.writeFileSync(path.join(from, 'migrations', 'instance', '0001_x.sql'), 'select 1;');
|
|
257
|
+
copyInstance({ fsImpl: fs, from, to });
|
|
258
|
+
assert.deepEqual(fs.readdirSync(path.join(to, 'config')).sort(), ['branding.json', 'modules.json']);
|
|
259
|
+
assert.deepEqual(fs.readdirSync(path.join(to, 'migrations', 'instance')), ['0001_x.sql']);
|
|
260
|
+
// an instance with neither directory is not an error
|
|
261
|
+
copyInstance({ fsImpl: fs, from: path.join(from, 'nowhere'), to });
|
|
262
|
+
} finally {
|
|
263
|
+
fs.rmSync(from, { recursive: true, force: true });
|
|
264
|
+
fs.rmSync(to, { recursive: true, force: true });
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('the module switches cover every env prefix the core resolves (a tripwire against drift)', () => {
|
|
269
|
+
const ic = require('../src/instance-config');
|
|
270
|
+
const { MODULES_OFF } = require('../modules/npm-release/preview/env.js');
|
|
271
|
+
for (const pfx of [ic.FALLBACK_ENV_PREFIX, ...ic.LEGACY_ENV_PREFIXES]) {
|
|
272
|
+
assert.ok(MODULES_OFF.includes(pfx + '_MODULE_DISCORD'), 'no switch under the ' + pfx + ' prefix: a prefix the core reads would leave the module on');
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('the module switches are set under every prefix, including the copied instance own', () => {
|
|
277
|
+
const env = buildPreviewEnv({ port: 3190, scratchHome: '/h', instanceRoot: '/i', envPrefix: 'ACME' });
|
|
278
|
+
for (const pfx of ['BONGOS', 'CLOUDBONGOS', 'OTB', 'GDS', 'PMS', 'ACME']) {
|
|
279
|
+
assert.equal(env[pfx + '_MODULE_DISCORD'], '0', pfx);
|
|
280
|
+
assert.equal(env[pfx + '_MODULE_AGENTS'], '0', pfx);
|
|
281
|
+
}
|
|
282
|
+
assert.equal(buildPreviewEnv({ port: 1, scratchHome: '/h', envPrefix: 'bad prefix; rm' }).ACME_MODULE_DISCORD, undefined);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// ---- stopping really drops the copy ------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
test('stop waits for the child to exit before it drops the copy', async () => {
|
|
288
|
+
// sleepMode "never": nothing but the child's own exit can let the stop go on.
|
|
289
|
+
const { sup, events } = rig({ sleepMode: 'never' });
|
|
290
|
+
await sup.start('1.19.1000').done;
|
|
291
|
+
events.length = 0;
|
|
292
|
+
await sup.stop();
|
|
293
|
+
assert.deepEqual(events, ['kill:SIGTERM', 'exit', 'dropdb']);
|
|
294
|
+
assert.equal(sup.status().state, 'idle');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('a child that ignores SIGTERM is killed with SIGKILL before the drop', async () => {
|
|
298
|
+
const { sup, events } = rig({ killBehaviour: 'on-sigkill', sleepMode: 'short' });
|
|
299
|
+
await sup.start('1.19.1000').done;
|
|
300
|
+
events.length = 0;
|
|
301
|
+
await sup.stop();
|
|
302
|
+
assert.deepEqual(events, ['kill:SIGTERM', 'kill:SIGKILL', 'exit', 'dropdb']);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('a failed dropdb is retried after ending the copy backends, then reported instead of idle', async () => {
|
|
306
|
+
let failing = false;
|
|
307
|
+
const { sup, calls } = rig({ failRun: (c) => failing && c.cmd === 'dropdb' });
|
|
308
|
+
await sup.start('1.19.1000').done;
|
|
309
|
+
failing = true;
|
|
310
|
+
calls.length = 0;
|
|
311
|
+
const s = await sup.stop();
|
|
312
|
+
assert.deepEqual(calls.map((c) => c.cmd), ['dropdb', 'psql', 'dropdb']);
|
|
313
|
+
assert.equal(calls[0].args.includes('--force'), true);
|
|
314
|
+
assert.equal(calls[2].args.includes('--force'), false, 'the retry is plain');
|
|
315
|
+
assert.equal(s.state, 'failed');
|
|
316
|
+
assert.equal(s.stage, 'drop');
|
|
317
|
+
assert.match(s.error, /could not be dropped/);
|
|
318
|
+
assert.equal(sup.status().state, 'failed', 'and it stays reported until a drop works');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test('a dropdb that fails only for want of --force is rescued by the fallback', async () => {
|
|
322
|
+
let forceFails = false;
|
|
323
|
+
const { sup } = rig({ failRun: (c) => forceFails && c.cmd === 'dropdb' && c.args.includes('--force') });
|
|
324
|
+
await sup.start('1.19.1000').done;
|
|
325
|
+
forceFails = true;
|
|
326
|
+
const s = await sup.stop();
|
|
327
|
+
assert.equal(s.state, 'idle');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('a failed start whose copy will not drop says both things', async () => {
|
|
331
|
+
let dropsFail = false;
|
|
332
|
+
const { sup } = rig({
|
|
333
|
+
probe: async () => { dropsFail = true; return { ok: false, reason: 'the data path is broken' }; },
|
|
334
|
+
failRun: (c) => dropsFail && c.cmd === 'dropdb',
|
|
335
|
+
});
|
|
336
|
+
await sup.start('1.19.1000').done;
|
|
337
|
+
const s = sup.status();
|
|
338
|
+
assert.equal(s.state, 'failed');
|
|
339
|
+
assert.match(s.error, /the data path is broken/);
|
|
340
|
+
assert.match(s.error, /could not be dropped/);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// ---- the drop can never name the live database ---------------------------------------------
|
|
344
|
+
|
|
345
|
+
test('a supervisor whose live database is the preview name cannot be built', () => {
|
|
346
|
+
assert.throws(() => rig({ liveDb: PREVIEW_DB }), /cannot be named bongos_preview/);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test('every drop command names the fixed copy, and nothing else passes the guard', () => {
|
|
350
|
+
for (const c of [cmds.dropDbCommand(), cmds.dropDbPlainCommand(), cmds.terminateBackendsCommand()]) {
|
|
351
|
+
assert.equal(cmds.targetsOnlyPreviewDb(c), true, JSON.stringify(c));
|
|
352
|
+
assert.ok(JSON.stringify(c).includes(PREVIEW_DB));
|
|
353
|
+
}
|
|
354
|
+
for (const bad of [
|
|
355
|
+
{ cmd: 'dropdb', args: ['--if-exists', 'live_db'] },
|
|
356
|
+
{ cmd: 'dropdb', args: ['live_db', PREVIEW_DB] },
|
|
357
|
+
{ cmd: 'dropdb', args: ['--if-exists', '--force', 'live_db'] },
|
|
358
|
+
{ cmd: 'psql', args: ['-d', 'postgres', '-c', 'DROP DATABASE live_db'] },
|
|
359
|
+
{ cmd: 'psql', args: ['-X', '-d', 'postgres', '-tAc', "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'live_db'"] },
|
|
360
|
+
{ cmd: 'bash', args: ['-c', 'dropdb ' + PREVIEW_DB] },
|
|
361
|
+
null,
|
|
362
|
+
]) assert.equal(cmds.targetsOnlyPreviewDb(bad), false, JSON.stringify(bad));
|
|
363
|
+
assert.match(cmds.terminateBackendsCommand().args.join(' '), /datname = 'bongos_preview' AND pid <> pg_backend_pid\(\)/);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test('every drop-capable command a start and stop runs is the guarded kind, none naming the live database', async () => {
|
|
367
|
+
const { sup, calls } = rig({ failRun: (c) => c.cmd === 'dropdb' });
|
|
368
|
+
await sup.start('1.19.1000').done;
|
|
369
|
+
await sup.stop();
|
|
370
|
+
const risky = calls.filter((x) => x.cmd === 'dropdb' || x.cmd === 'psql');
|
|
371
|
+
assert.ok(risky.length > 0);
|
|
372
|
+
for (const c of risky) {
|
|
373
|
+
assert.equal(cmds.targetsOnlyPreviewDb(c), true, JSON.stringify(c));
|
|
374
|
+
assert.ok(!JSON.stringify(c.args).includes('live_db'));
|
|
375
|
+
}
|
|
376
|
+
});
|
|
@@ -28,7 +28,7 @@ function rig({ failOn, probeResults, env } = {}) {
|
|
|
28
28
|
},
|
|
29
29
|
spawn: (bin, args, opts) => {
|
|
30
30
|
const handlers = {};
|
|
31
|
-
const child = { bin, args, opts, on: (ev, fn) => { handlers[ev] = fn; }, kill: () => { fake.killed += 1; }, emit: (ev, ...a) => handlers[ev] && handlers[ev](...a) };
|
|
31
|
+
const child = { bin, args, opts, on: (ev, fn) => { handlers[ev] = fn; }, kill: (sig) => { fake.killed += 1; (fake.signals ||= []).push(sig); if (handlers.exit) handlers.exit(0); }, emit: (ev, ...a) => handlers[ev] && handlers[ev](...a) };
|
|
32
32
|
fake.spawned.push(child);
|
|
33
33
|
return child;
|
|
34
34
|
},
|
|
@@ -46,7 +46,7 @@ function rig({ failOn, probeResults, env } = {}) {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
const fwd = (x) => String(x).split(String.fromCharCode(92)).join('/');
|
|
49
|
-
const DROP = { cmd: 'dropdb', args: ['--if-exists', PREVIEW_DB] };
|
|
49
|
+
const DROP = { cmd: 'dropdb', args: ['--if-exists', '--force', PREVIEW_DB] };
|
|
50
50
|
|
|
51
51
|
test('a start runs install, copy, restore and migrate, in that order, then launches and waits for health', async () => {
|
|
52
52
|
const { sup, calls, fake } = rig();
|
|
@@ -89,9 +89,9 @@ for (const failOn of ['npm', 'dropdb', 'createdb', 'dump', 'pg_restore', 'bash']
|
|
|
89
89
|
await sup.start('1.19.1000').done;
|
|
90
90
|
const s = sup.status();
|
|
91
91
|
assert.equal(s.state, 'failed');
|
|
92
|
-
assert.match(s.error, /failed
|
|
92
|
+
assert.match(s.error, failOn === 'dropdb' ? /could not be dropped/ : /failed [(]exit 3[)]/);
|
|
93
93
|
assert.equal(fake.spawned.length, 0, 'no process was started');
|
|
94
|
-
assert.deepEqual(calls[calls.length - 1], DROP, 'the copy is dropped last');
|
|
94
|
+
if (failOn !== 'dropdb') assert.deepEqual(calls[calls.length - 1], DROP, 'the copy is dropped last');
|
|
95
95
|
assert.equal(rmCalls.length, 1, 'the scratch directory is removed');
|
|
96
96
|
});
|
|
97
97
|
}
|
|
@@ -23,6 +23,8 @@ import fs from 'node:fs';
|
|
|
23
23
|
import path from 'node:path';
|
|
24
24
|
import vm from 'node:vm';
|
|
25
25
|
import { fileURLToPath } from 'node:url';
|
|
26
|
+
import { createRequire } from 'node:module';
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
26
28
|
|
|
27
29
|
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
28
30
|
const HUB = fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
@@ -301,6 +303,53 @@ test('typing a start command clears the "how your app starts" ask at once', asyn
|
|
|
301
303
|
assert.doesNotMatch(w.$('h-render').textContent, /how your app starts/);
|
|
302
304
|
});
|
|
303
305
|
|
|
306
|
+
// task 1004464 — Render refused a native app with no build command, live, on project 43.
|
|
307
|
+
test('the build command starts as the runtime\'s usual one, follows the runtime, and yields to the owner\'s own', async () => {
|
|
308
|
+
const w = load({ answers: ANSWERS, draft: { appPlatform: 'render' } });
|
|
309
|
+
w.typeKey(KEY);
|
|
310
|
+
await w.check();
|
|
311
|
+
await w.pickWorkspace(0);
|
|
312
|
+
w.pickService({ value: 'new' });
|
|
313
|
+
assert.equal(w.$('f-renderBuild').value, 'npm install', 'Node.js starts with npm install');
|
|
314
|
+
w.$('f-renderRuntime').value = 'python';
|
|
315
|
+
w.$('renderNew').fire('change', { target: w.$('f-renderRuntime') });
|
|
316
|
+
assert.equal(w.$('f-renderBuild').value, 'pip install -r requirements.txt', 'it follows the runtime');
|
|
317
|
+
w.$('f-renderBuild').value = 'make build';
|
|
318
|
+
w.$('renderNew').fire('input', { target: w.$('f-renderBuild') });
|
|
319
|
+
w.$('f-renderRuntime').value = 'ruby';
|
|
320
|
+
w.$('renderNew').fire('change', { target: w.$('f-renderRuntime') });
|
|
321
|
+
assert.equal(w.$('f-renderBuild').value, 'make build', 'the owner\'s own command is never overwritten');
|
|
322
|
+
w.state.renderStart = 'bundle exec puma';
|
|
323
|
+
assert.equal(w.renderRequestBody().build_command, 'make build');
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test('an emptied build command is refused before Render ever sees it, and the ask clears on typing', async () => {
|
|
327
|
+
const w = load({ answers: ANSWERS, draft: { appPlatform: 'render' } });
|
|
328
|
+
w.typeKey(KEY);
|
|
329
|
+
await w.check();
|
|
330
|
+
await w.pickWorkspace(0);
|
|
331
|
+
w.pickService({ value: 'new' });
|
|
332
|
+
w.state.renderStart = 'npm start';
|
|
333
|
+
w.$('f-renderBuild').value = '';
|
|
334
|
+
w.$('renderNew').fire('input', { target: w.$('f-renderBuild') });
|
|
335
|
+
assert.equal(w.validateRender(), false);
|
|
336
|
+
assert.match(w.$('h-render').textContent, /how your app is built/);
|
|
337
|
+
w.$('f-renderBuild').value = 'npm ci';
|
|
338
|
+
w.$('renderNew').fire('input', { target: w.$('f-renderBuild') });
|
|
339
|
+
assert.doesNotMatch(w.$('h-render').className, /err/);
|
|
340
|
+
assert.equal(w.validateRender(), true);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('the page\'s build defaults are the runner\'s, key for key', () => {
|
|
344
|
+
const { BUILD_DEFAULTS, RUNTIMES } = require('../modules/provisioning/render-standup.js');
|
|
345
|
+
const src = HUB.replace(/\r\n/g, '\n').match(/var RENDER_BUILD_DEFAULTS = (\{[\s\S]*?\});/);
|
|
346
|
+
assert.ok(src, 'the page carries RENDER_BUILD_DEFAULTS');
|
|
347
|
+
const page = vm.runInNewContext('(' + src[1] + ')');
|
|
348
|
+
assert.deepEqual({ ...page }, { ...BUILD_DEFAULTS });
|
|
349
|
+
assert.deepEqual(Object.keys(page).sort(), RUNTIMES.filter((r) => r !== 'docker').sort());
|
|
350
|
+
assert.doesNotMatch(HUB, /leave empty if there is none/);
|
|
351
|
+
});
|
|
352
|
+
|
|
304
353
|
test('the empty command boxes read as examples, not as filled-in values', () => {
|
|
305
354
|
assert.match(HUB, /id="f-renderBuild"[^>]*placeholder="for example: npm ci"/);
|
|
306
355
|
assert.match(HUB, /id="f-renderStart"[^>]*placeholder="for example: npm start"/);
|
|
@@ -250,7 +250,8 @@ test('Connect sends the key once, to this project, forgets it, and the card show
|
|
|
250
250
|
const sent = w.calls.filter((c) => c.url === RENDER);
|
|
251
251
|
assert.equal(sent.length, 1);
|
|
252
252
|
assert.equal(sent[0].method, 'POST');
|
|
253
|
-
|
|
253
|
+
// build_command: the runtime's usual one, prefilled — Render refuses a native app without one (task 1004464)
|
|
254
|
+
assert.deepEqual(sent[0].body, { key: KEY, owner_id: TEA_A, mode: 'greenfield', runtime: 'node', start_command: 'npm start', build_command: 'npm install', web_plan: 'starter', database: false });
|
|
254
255
|
for (const c of w.calls) assert.ok(!c.url.includes(KEY), 'no URL carries the key');
|
|
255
256
|
assert.equal(w.q('f-renderKey').value, '', 'the field is emptied');
|
|
256
257
|
assert.equal(w.byId('mAppConnectForm').hidden, true, 'the form closes');
|
|
@@ -335,11 +335,32 @@ await ta('a link needs a repository to check the service against (1004350 review
|
|
|
335
335
|
assert.equal(out.ok, false);
|
|
336
336
|
assert.equal(h.cleared(), 1);
|
|
337
337
|
});
|
|
338
|
-
|
|
338
|
+
// The 1004350 review asked for NO build command here; Render refused exactly that live
|
|
339
|
+
// ("buildCommand is required for non-static, non-docker services", task 1004464), so a
|
|
340
|
+
// row saved without one — every row from before the fix included — gets the runtime's
|
|
341
|
+
// default, and a Retry of such a row now works.
|
|
342
|
+
await ta('an app saved with no build command gets its runtime\'s default, as Render requires (task 1004464)', async () => {
|
|
339
343
|
const h = harness({ host: { buildCommand: undefined } });
|
|
340
344
|
await h.run();
|
|
341
345
|
const svc = h.r.calls.find((c) => c.method === 'POST' && c.path === '/services').body;
|
|
342
|
-
assert.deepEqual(svc.serviceDetails.envSpecificDetails, { startCommand: 'npm start' });
|
|
346
|
+
assert.deepEqual(svc.serviceDetails.envSpecificDetails, { buildCommand: 'npm install', startCommand: 'npm start' });
|
|
347
|
+
});
|
|
348
|
+
await ta('every native runtime has a default build command; docker still sends none (task 1004464)', () => {
|
|
349
|
+
const { RUNTIMES, BUILD_DEFAULTS } = require('../modules/provisioning/render-standup.js');
|
|
350
|
+
const { buildFor } = require('../scripts/gds/render-payload.js');
|
|
351
|
+
for (const rt of RUNTIMES.filter((r) => r !== 'docker')) {
|
|
352
|
+
assert.ok(BUILD_DEFAULTS[rt], `${rt} has a default`);
|
|
353
|
+
assert.equal(buildFor({ runtime: rt, startCommand: 'x' }).buildCommand, BUILD_DEFAULTS[rt]);
|
|
354
|
+
assert.equal(buildFor({ runtime: rt, startCommand: 'x', buildCommand: 'make' }).buildCommand, 'make', 'the owner\'s own wins');
|
|
355
|
+
}
|
|
356
|
+
assert.deepEqual(buildFor({ runtime: 'docker' }), { runtime: 'docker' });
|
|
357
|
+
});
|
|
358
|
+
await ta('a refusal from Render reads as a sentence, with Render\'s own reason kept (task 1004464)', async () => {
|
|
359
|
+
const h = harness({ host: { database: false }, render: { 'POST /services': new Error('buildCommand is required for non-static, non-docker services.') } });
|
|
360
|
+
const out = await h.run();
|
|
361
|
+
assert.equal(out.ok, false);
|
|
362
|
+
assert.equal(h.lastHost().error, 'Render would not set up the app: buildCommand is required for non-static, non-docker services.');
|
|
363
|
+
assert.doesNotMatch(h.lastHost().error, /POST \/services/, 'no raw HTTP line reaches the owner');
|
|
343
364
|
});
|
|
344
365
|
|
|
345
366
|
console.log('\nteardown forgets, never deletes:');
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// tests/update_channel_db.mjs — the unattended sweep follows the update rule set on /deploy
|
|
2
|
+
// (task 1004468, ADR 0357).
|
|
3
|
+
//
|
|
4
|
+
// WHY. /deploy writes provisioning_instances.update_channel (task 1004445), and the Upgrade button
|
|
5
|
+
// obeys it — but the sweep read only the roster file, so cloudbongos.com was set to `minor` on the
|
|
6
|
+
// site and still swept as `patch` (task 1004451's failed proof). These pin the rule: a project row
|
|
7
|
+
// wins, the roster is the fallback, and every fallback says why.
|
|
8
|
+
//
|
|
9
|
+
// Run: node --test tests/update_channel_db.mjs
|
|
10
|
+
|
|
11
|
+
import { test } from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
const uc = require('../scripts/gds/update-channel.js');
|
|
21
|
+
|
|
22
|
+
const AVAILABLE = ['1.20.36', '1.20.37', '1.21.0', '2.0.0'];
|
|
23
|
+
const entry = (over = {}) => ({ slug: 'cloudbongos', dir: '/home/lars/cloudbongos', channel: 'patch', env: {}, ...over });
|
|
24
|
+
const dbOf = (rows) => ({ ok: true, channels: new Map(Object.entries(rows)) });
|
|
25
|
+
const targetFor = (inst) => uc.resolveChannelTarget({ installed: '1.20.37', channel: inst.channel, available: AVAILABLE });
|
|
26
|
+
|
|
27
|
+
test('DB minor + roster patch: the /deploy rule wins and a 1.21.0 target is chosen', () => {
|
|
28
|
+
const [inst] = uc.applyDbChannels([entry()], dbOf({ cloudbongos: 'minor' }));
|
|
29
|
+
assert.equal(inst.channel, 'minor');
|
|
30
|
+
assert.equal(inst.channelSource, uc.CHANNEL_SOURCE_DEPLOY);
|
|
31
|
+
assert.equal(inst.rosterChannel, 'patch');
|
|
32
|
+
assert.match(inst.channelNote, /set on \/deploy \(roster says patch, ignored\)/);
|
|
33
|
+
assert.equal(targetFor(inst), '1.21.0');
|
|
34
|
+
// Control: the roster alone would have held it on the patch line.
|
|
35
|
+
assert.equal(targetFor(entry()), null);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('no project row for the slug: the roster wins, and the log says why', () => {
|
|
39
|
+
const [inst] = uc.applyDbChannels([entry({ slug: 'charter', channel: 'patch' })], dbOf({ cloudbongos: 'minor' }));
|
|
40
|
+
assert.equal(inst.channel, 'patch');
|
|
41
|
+
assert.equal(inst.channelSource, uc.CHANNEL_SOURCE_ROSTER);
|
|
42
|
+
assert.match(inst.channelNote, /no project row for this slug/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('DB pinned: the sweep skips it even when the roster says minor', () => {
|
|
46
|
+
const [inst] = uc.applyDbChannels([entry({ channel: 'minor' })], dbOf({ cloudbongos: 'pinned' }));
|
|
47
|
+
assert.equal(inst.channel, 'pinned');
|
|
48
|
+
assert.equal(targetFor(inst), null);
|
|
49
|
+
// subscribe.js skips on exactly this value, and decides it AFTER the DB rule is applied.
|
|
50
|
+
const src = readFileSync(path.join(ROOT, '.claude/scheduled-tasks/core-update-subscription/subscribe.js'), 'utf8');
|
|
51
|
+
const applied = src.indexOf('channel.applyDbChannels(');
|
|
52
|
+
const skip = src.indexOf("inst.channel === 'pinned'");
|
|
53
|
+
assert.ok(applied > 0 && skip > applied, 'the pinned skip must read the channel after the /deploy rule is applied');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('database unreachable: every entry keeps its roster channel and carries the reason', () => {
|
|
57
|
+
const insts = uc.applyDbChannels([entry(), entry({ slug: 'charter', channel: 'minor' })], { ok: false, reason: 'psql not found' });
|
|
58
|
+
assert.deepEqual(insts.map((i) => i.channel), ['patch', 'minor']);
|
|
59
|
+
for (const i of insts) assert.match(i.channelNote, /from the roster — psql not found/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('an unknown value in the row normalises like any channel (never widens)', () => {
|
|
63
|
+
const [inst] = uc.applyDbChannels([entry()], dbOf({ cloudbongos: 'major' }));
|
|
64
|
+
assert.equal(inst.channel, uc.normalizeChannel('major'));
|
|
65
|
+
assert.notEqual(inst.channel, 'minor');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('readDbChannels: no database named → not read, with the reason, and psql is never spawned', () => {
|
|
69
|
+
let spawned = false;
|
|
70
|
+
const r = uc.readDbChannels({ env: {}, run: () => { spawned = true; } });
|
|
71
|
+
assert.equal(r.ok, false);
|
|
72
|
+
assert.match(r.reason, /no DATABASE_URL or PGDATABASE/);
|
|
73
|
+
assert.equal(spawned, false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('readDbChannels: parses psql -At tab output into slug → channel, from the sweep env only', () => {
|
|
77
|
+
let args;
|
|
78
|
+
const run = (cmd, a) => { args = [cmd, ...a]; return { status: 0, stdout: 'cloudbongos\tminor\ncharter\tpatch\n\n', stderr: '' }; };
|
|
79
|
+
const r = uc.readDbChannels({ env: { PGDATABASE: 'cloudbongos' }, run });
|
|
80
|
+
assert.equal(r.ok, true);
|
|
81
|
+
assert.equal(r.channels.get('cloudbongos'), 'minor');
|
|
82
|
+
assert.equal(r.channels.get('charter'), 'patch');
|
|
83
|
+
assert.equal(r.channels.size, 2);
|
|
84
|
+
assert.deepEqual(args.slice(0, 3), ['psql', '-d', 'cloudbongos']);
|
|
85
|
+
assert.ok(args.includes('ON_ERROR_STOP=1'));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('readDbChannels: DATABASE_URL is preferred over PGDATABASE', () => {
|
|
89
|
+
let target;
|
|
90
|
+
uc.readDbChannels({ env: { DATABASE_URL: 'postgres://u@h/db', PGDATABASE: 'x' }, run: (_c, a) => { target = a[1]; return { status: 0, stdout: '' }; } });
|
|
91
|
+
assert.equal(target, 'postgres://u@h/db');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('readDbChannels: a missing table or psql fails soft with a one-line reason', () => {
|
|
95
|
+
const noTable = uc.readDbChannels({ env: { PGDATABASE: 'db' }, run: () => ({ status: 1, stdout: '', stderr: 'ERROR: relation "provisioning_instances" does not exist\nLINE 1: ...' }) });
|
|
96
|
+
assert.equal(noTable.ok, false);
|
|
97
|
+
assert.equal(noTable.reason, 'psql exit 1: no provisioning_instances table (provisioning module off?)');
|
|
98
|
+
const noPsql = uc.readDbChannels({ env: { PGDATABASE: 'db' }, run: () => ({ error: Object.assign(new Error('spawn psql ENOENT'), { code: 'ENOENT' }) }) });
|
|
99
|
+
assert.deepEqual(noPsql, { ok: false, reason: 'psql not found' });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('readDbChannels: psql stderr never reaches the reason (it can name a DB user or host)', () => {
|
|
103
|
+
const stderr = 'psql: error: connection to server at "db.internal.example" (10.0.0.5), port 5432 failed: FATAL: password authentication failed for user "cloudbongos_admin"';
|
|
104
|
+
const r = uc.readDbChannels({ env: { DATABASE_URL: 'postgres://x' }, run: () => ({ status: 2, stdout: '', stderr }) });
|
|
105
|
+
assert.equal(r.reason, "psql exit 2: the sweep's database login was refused");
|
|
106
|
+
for (const leak of ['db.internal.example', '10.0.0.5', 'cloudbongos_admin']) assert.ok(!r.reason.includes(leak), `reason leaked ${leak}`);
|
|
107
|
+
assert.equal(uc.psqlFailureClass('something nobody anticipated for user "secret_user"'), 'query failed');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('channelLine: the sweep log names the channel and where it came from', () => {
|
|
111
|
+
const [fromDeploy, fromRoster] = uc.applyDbChannels(
|
|
112
|
+
[entry(), entry({ slug: 'charter', channel: 'patch' })], dbOf({ cloudbongos: 'minor' }));
|
|
113
|
+
assert.equal(uc.channelLine(fromDeploy), ' • cloudbongos: channel minor, set on /deploy (roster says patch, ignored)');
|
|
114
|
+
assert.equal(uc.channelLine(fromRoster), ' • charter: channel patch, from the roster — no project row for this slug');
|
|
115
|
+
const src = readFileSync(path.join(ROOT, '.claude/scheduled-tasks/core-update-subscription/subscribe.js'), 'utf8');
|
|
116
|
+
assert.match(src, /console\.log\(channel\.channelLine\(inst\)\)/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('subscribe.js reads the DB from its own env, never an entry env (which names that instance\'s DB)', () => {
|
|
120
|
+
const src = readFileSync(path.join(ROOT, '.claude/scheduled-tasks/core-update-subscription/subscribe.js'), 'utf8');
|
|
121
|
+
assert.match(src, /channel\.readDbChannels\(\{ env: process\.env \}\)/);
|
|
122
|
+
// A core too old to carry the reader must keep the roster rather than crash the sweep.
|
|
123
|
+
assert.match(src, /typeof channel\.readDbChannels === 'function'/);
|
|
124
|
+
});
|