@bongos/core 1.20.38 → 1.20.40
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 +47 -37
- package/docs/adr/0349-a-version-preview-is-a-sandboxed-child-the-web-tier-launches.md +1 -0
- package/docs/copy-inventory.md +1 -1
- package/docs/copy-registry.json +9 -9
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +251 -248
- package/modules/npm-release/preview/childlog.js +95 -0
- package/modules/npm-release/preview/commands.js +46 -5
- package/modules/npm-release/preview/divert.js +16 -6
- package/modules/npm-release/preview/env.js +18 -7
- package/modules/npm-release/preview/proxy.js +4 -1
- 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 +7 -5
- 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 +16 -0
- package/scripts/gds/provision-render.js +13 -1
- package/scripts/gds/render-payload.js +6 -5
- package/src/module-api.js +1 -1
- package/tests/npm_release_preview_commands.mjs +1 -1
- package/tests/npm_release_preview_diagnosis.mjs +376 -0
- package/tests/npm_release_preview_divert.mjs +10 -0
- package/tests/npm_release_preview_proxy.mjs +26 -0
- package/tests/npm_release_preview_routes.mjs +15 -2
- 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
|
@@ -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
|
+
});
|
|
@@ -159,6 +159,16 @@ test('a cookie for a preview that is not running is dropped and the live hall an
|
|
|
159
159
|
}
|
|
160
160
|
});
|
|
161
161
|
|
|
162
|
+
test('the clearing cookie repeats the set attributes, Secure included (task 1004466)', () => {
|
|
163
|
+
const { setCookie } = require('../modules/npm-release/preview/divert.js');
|
|
164
|
+
for (const secure of [false, true]) {
|
|
165
|
+
const req = { secure };
|
|
166
|
+
const attrs = (c) => c.split('; ').slice(1).filter((a) => !/^Max-Age=/.test(a)).join('; ');
|
|
167
|
+
assert.equal(attrs(clearCookie(req)), attrs(setCookie('1.19.1000', req)), 'secure=' + secure);
|
|
168
|
+
assert.equal(/; Secure$/.test(clearCookie(req)), secure);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
162
172
|
test('the clearing cookie expires the steering cookie for the whole site', () => {
|
|
163
173
|
assert.match(clearCookie(), /^bongos_preview=; Path=\/; Max-Age=0/);
|
|
164
174
|
});
|
|
@@ -137,6 +137,32 @@ test('JSON and other non-page answers pass through untouched, headers included',
|
|
|
137
137
|
assert.doesNotMatch(res.text, /banner/);
|
|
138
138
|
});
|
|
139
139
|
|
|
140
|
+
test('a Set-Cookie from the preview never reaches the browser (it would overwrite the live hall cookies), task 1004466', async () => {
|
|
141
|
+
const cookies = ['cb_stealth=child; Path=/', '__Host-sid=child; Path=/; Secure', 'bongos_preview=; Max-Age=0'];
|
|
142
|
+
for (const [type, body] of [['application/json', '{}'], ['text/html; charset=utf-8', '<html><body>x</body></html>']]) {
|
|
143
|
+
const http = fakeHttp(() => ({ headers: { 'content-type': type, 'set-cookie': cookies, 'x-keep': 'yes' }, body }));
|
|
144
|
+
const res = new FakeRes();
|
|
145
|
+
make(http)(request(), res);
|
|
146
|
+
await finish(res);
|
|
147
|
+
assert.equal(Object.keys(res.headers).filter((k) => k.toLowerCase() === 'set-cookie').length, 0, type);
|
|
148
|
+
assert.equal(res.headers['x-keep'], 'yes', 'other headers still pass');
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('a streamed answer with a Set-Cookie is still streamed, cookie dropped', async () => {
|
|
153
|
+
const stream = new PassThrough();
|
|
154
|
+
const http = fakeHttp(() => ({ headers: { 'content-type': 'text/event-stream', 'set-cookie': 'cb_stealth=child' }, stream }));
|
|
155
|
+
const res = new FakeRes();
|
|
156
|
+
make(http)(request(), res);
|
|
157
|
+
await tick();
|
|
158
|
+
stream.write('data: one\n\n');
|
|
159
|
+
await tick();
|
|
160
|
+
assert.equal(res.headers['set-cookie'], undefined);
|
|
161
|
+
assert.equal(res.text, 'data: one\n\n');
|
|
162
|
+
stream.end();
|
|
163
|
+
await finish(res);
|
|
164
|
+
});
|
|
165
|
+
|
|
140
166
|
test('an event stream is streamed, not held: the first chunk arrives before the stream ends', async () => {
|
|
141
167
|
const stream = new PassThrough();
|
|
142
168
|
const http = fakeHttp(() => ({ headers: { 'content-type': 'text/event-stream' }, stream }));
|
|
@@ -52,6 +52,7 @@ let base;
|
|
|
52
52
|
before(async () => {
|
|
53
53
|
state = { state: 'idle', version: null };
|
|
54
54
|
const app = express();
|
|
55
|
+
app.set('trust proxy', true);
|
|
55
56
|
app.use(express.json());
|
|
56
57
|
app.use('/api/bongos', buildRouter({ supervisor, listPublished: async () => registry, runningVersion: () => '1.19.1040' }));
|
|
57
58
|
server = http.createServer(app);
|
|
@@ -60,10 +61,10 @@ before(async () => {
|
|
|
60
61
|
});
|
|
61
62
|
after(() => new Promise((r) => server.close(r)));
|
|
62
63
|
|
|
63
|
-
function call(method, url, { cookie = 'sid=good', body } = {}) {
|
|
64
|
+
function call(method, url, { cookie = 'sid=good', body, extra } = {}) {
|
|
64
65
|
return new Promise((resolve, reject) => {
|
|
65
66
|
const data = body === undefined ? null : JSON.stringify(body);
|
|
66
|
-
const headers = { host: 'builders.example.test' };
|
|
67
|
+
const headers = { host: 'builders.example.test', ...(extra || {}) };
|
|
67
68
|
if (cookie) headers.cookie = cookie;
|
|
68
69
|
if (data) { headers['content-type'] = 'application/json'; headers['content-length'] = Buffer.byteLength(data); }
|
|
69
70
|
const r = http.request({ host: '127.0.0.1', port: base, path: '/api/bongos' + url, method, headers }, (res) => {
|
|
@@ -178,6 +179,18 @@ test('enter sets the steering cookie for the running version and goes to the hal
|
|
|
178
179
|
assert.match(c, /^bongos_preview=1\.19\.1030; Path=\/; HttpOnly; SameSite=Lax/);
|
|
179
180
|
});
|
|
180
181
|
|
|
182
|
+
test('exit clears with the same attributes enter set, Secure included, and is not cached (task 1004466)', async () => {
|
|
183
|
+
state = { state: 'running', version: '1.19.1030' };
|
|
184
|
+
for (const extra of [undefined, { 'x-forwarded-proto': 'https' }]) {
|
|
185
|
+
const e = await call('GET', '/npm-release/preview/enter?v=1.19.1030', { extra });
|
|
186
|
+
const x = await call('GET', '/npm-release/preview/exit', { extra });
|
|
187
|
+
const attrs = (r) => [].concat(r.headers['set-cookie'])[0].split('; ').slice(1).filter((a) => !/^Max-Age=/.test(a)).join('; ');
|
|
188
|
+
assert.equal(attrs(x), attrs(e));
|
|
189
|
+
assert.equal(/Secure/.test(attrs(x)), Boolean(extra));
|
|
190
|
+
assert.equal(x.headers['cache-control'], 'no-store');
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
181
194
|
test('enter refuses a preview that is not the one running, and sets no cookie', async () => {
|
|
182
195
|
for (const [st, v] of [[{ state: 'idle', version: null }, '1.19.1030'], [{ state: 'starting', version: '1.19.1030' }, '1.19.1030'], [{ state: 'running', version: '1.19.1031' }, '1.19.1030'], [{ state: 'running', version: '1.19.1030' }, 'zzz']]) {
|
|
183
196
|
state = st;
|
|
@@ -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:');
|