@bongos/core 1.19.839 → 1.19.841
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 +73 -33
- package/clients/bongos-client/index.d.ts +1 -1
- package/config/scheduled-routines.json +29 -0
- package/docs/adr/0303-provisioning-verifies-the-base-caddyfile-and-never-edits-it.md +49 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +2 -0
- package/docs/architecture.md +2 -2
- package/docs/module-api-changelog.md +4 -0
- package/modules/government/protected-surfaces.json +2 -1
- package/modules/lifecycle/github-push.js +2 -2
- package/modules/lifecycle/pr-assign.js +77 -0
- package/modules/lifecycle/routes/tasks.js +16 -1
- package/modules/provisioning/pollers/liveness-notify.js +110 -0
- package/modules/provisioning/pollers/liveness-sweep.js +27 -2
- package/modules/provisioning/provisioning.js +9 -9
- package/modules/provisioning/tests/liveness-notify.mjs +199 -0
- package/modules/provisioning/tests/liveness.mjs +4 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/paste-token.js +65 -14
- package/scripts/gds/provision-config.js +3 -0
- package/scripts/gds/provision-net.js +32 -0
- package/scripts/gds/provision.js +21 -4
- package/scripts/gds/publish-manifest.js +1 -0
- package/scripts/gds/ship-land.js +12 -0
- package/src/bongos/routes/auth.js +24 -3
- package/src/module-api.js +1 -1
- package/tests/bongos_cli.mjs +13 -4
- package/tests/cli_token_reissue.mjs +54 -7
- package/tests/government_protected_surfaces.mjs +181 -0
- package/tests/paste_token_api_base.mjs +87 -0
- package/tests/pr_assign.mjs +189 -0
- package/tests/provision.mjs +12 -0
- package/tests/provision_caddy_wiring.mjs +148 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// tests/pr_assign.mjs — a shipped pull request is assigned to the builder who wrote it
|
|
2
|
+
// (task 1003991).
|
|
3
|
+
//
|
|
4
|
+
// The server opens every builder-box PR with its own credential, so the PR's AUTHOR is
|
|
5
|
+
// the owner account: 259 of the last 300 PRs. The builder who wrote the work found
|
|
6
|
+
// "created by me" empty on GitHub — and the PR is where CI and merge state live. The
|
|
7
|
+
// server now assigns the PR to the task's claim holder and names them in the body.
|
|
8
|
+
//
|
|
9
|
+
// Two layers, no DB / network:
|
|
10
|
+
// 1. modules/lifecycle/pr-assign.js through its injectable fetch — the assignment call
|
|
11
|
+
// and every fail-soft shape (it must NEVER reject: an assignment is a courtesy).
|
|
12
|
+
// 2. The real POST /tasks/:id/publish-branch router with the data layer, the auth gate
|
|
13
|
+
// and the git/GitHub calls stubbed at the module boundary — proving the route assigns
|
|
14
|
+
// the CLAIM HOLDER (not the Archon who re-shipped it), names them in the body, and
|
|
15
|
+
// still answers 201 when the assignment fails.
|
|
16
|
+
//
|
|
17
|
+
// Run: node tests/pr_assign.mjs
|
|
18
|
+
|
|
19
|
+
import { strict as assert } from 'node:assert';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import http from 'node:http';
|
|
22
|
+
import { prAssigneeLine } from '../scripts/gds/ship-land.js';
|
|
23
|
+
|
|
24
|
+
process.env.NODE_ENV = 'test';
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
|
|
27
|
+
const express = require('express');
|
|
28
|
+
const api = require('../src/module-api.js');
|
|
29
|
+
const db = require('../modules/lifecycle/db.js');
|
|
30
|
+
const gp = require('../modules/lifecycle/github-push.js');
|
|
31
|
+
const prAssign = require('../modules/lifecycle/pr-assign.js');
|
|
32
|
+
|
|
33
|
+
let passed = 0, failed = 0;
|
|
34
|
+
function t(name, fn) {
|
|
35
|
+
return Promise.resolve().then(fn)
|
|
36
|
+
.then(() => { passed++; console.log(` PASS ${name}`); })
|
|
37
|
+
.catch((e) => { failed++; console.log(` FAIL ${name}\n ${e.stack || e.message}`); });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const REPO = { owner: 'someowner', repo: 'somerepo', branch: 'main' };
|
|
41
|
+
const okJson = (body, status = 201) => ({ ok: status < 300, status, json: async () => body, text: async () => JSON.stringify(body) });
|
|
42
|
+
const quiet = { warn() {} };
|
|
43
|
+
|
|
44
|
+
// ---- layer 1: the assignment call -----------------------------------------
|
|
45
|
+
await t('assignPullRequest POSTs the login to /issues/:number/assignees', async () => {
|
|
46
|
+
let seen = null;
|
|
47
|
+
const fetchImpl = async (url, opts) => { seen = { url, method: opts.method, body: JSON.parse(opts.body), auth: opts.headers.Authorization, signal: opts.signal }; return okJson({ assignees: [{ login: 'Some-Builder' }] }); };
|
|
48
|
+
const out = await prAssign.assignPullRequest({ number: 946, login: 'some-builder' }, { token: 'T', repoInfo: REPO, fetchImpl, logImpl: quiet });
|
|
49
|
+
assert.deepEqual(out, { assigned: true });
|
|
50
|
+
assert.equal(seen.url, 'https://api.github.com/repos/someowner/somerepo/issues/946/assignees');
|
|
51
|
+
assert.equal(seen.method, 'POST');
|
|
52
|
+
assert.deepEqual(seen.body, { assignees: ['some-builder'] });
|
|
53
|
+
assert.equal(seen.auth, 'Bearer T');
|
|
54
|
+
assert.ok(seen.signal instanceof AbortSignal, 'the one optional call before the 201 is time-boxed');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await t('fail-soft: every failure RESOLVES with a reason — it never rejects', async () => {
|
|
58
|
+
const run = (args, deps) => prAssign.assignPullRequest(args, { token: 'T', repoInfo: REPO, logImpl: quiet, ...deps });
|
|
59
|
+
const never = async () => { throw new Error('must not fetch'); };
|
|
60
|
+
assert.deepEqual(await run({ number: 1, login: 'x' }, { fetchImpl: async () => okJson({ message: 'Resource not accessible by integration' }, 403) }), { assigned: false, reason: 'http_403' });
|
|
61
|
+
assert.deepEqual(await run({ number: 1, login: 'x' }, { fetchImpl: async () => { throw new Error('socket hang up'); } }), { assigned: false, reason: 'threw' });
|
|
62
|
+
// GitHub answers 201 and silently drops a login that cannot be assigned (not a collaborator).
|
|
63
|
+
assert.deepEqual(await run({ number: 1, login: 'x' }, { fetchImpl: async () => okJson({ assignees: [] }) }), { assigned: false, reason: 'not_assignable' });
|
|
64
|
+
assert.deepEqual(await run({ number: 1, login: 'x' }, { token: '', fetchImpl: never }), { assigned: false, reason: 'unconfigured' });
|
|
65
|
+
assert.deepEqual(await run({ number: 1, login: 'x' }, { repoInfo: { error: 'nope' }, fetchImpl: never }), { assigned: false, reason: 'unconfigured' });
|
|
66
|
+
assert.deepEqual(await run({ number: 'abc', login: 'x' }, { fetchImpl: never }), { assigned: false, reason: 'bad_pr_number' });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await t('a login that is not a GitHub login is refused before any request, and never reaches a PR body', async () => {
|
|
70
|
+
const never = async () => { throw new Error('must not fetch'); };
|
|
71
|
+
for (const login of ['', null, undefined, 'a b', '-lead', 'x/../y', 'a'.repeat(40), 'name\n@everyone', '@already']) {
|
|
72
|
+
assert.deepEqual(await prAssign.assignPullRequest({ number: 1, login }, { token: 'T', repoInfo: REPO, fetchImpl: never, logImpl: quiet }), { assigned: false, reason: 'bad_login' }, JSON.stringify(login));
|
|
73
|
+
assert.equal(prAssign.prBuilderLine(login), '', JSON.stringify(login));
|
|
74
|
+
}
|
|
75
|
+
assert.equal(prAssign.prBuilderLine('Some-Builder1'), 'Built by @Some-Builder1.');
|
|
76
|
+
assert.equal(prAssign.prBuilderLine('name_shortcode'), 'Built by @name_shortcode.', 'a managed-user login carries an underscore');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await t('a failure is logged without the credential', async () => {
|
|
80
|
+
const lines = [];
|
|
81
|
+
await prAssign.assignPullRequest({ number: 1, login: 'x' }, {
|
|
82
|
+
token: 'SECRET_TOKEN', repoInfo: REPO, logImpl: { warn: (...a) => lines.push(JSON.stringify(a)) },
|
|
83
|
+
fetchImpl: async () => { throw new Error('connect https://x-access-token:SECRET_TOKEN@github.com failed'); },
|
|
84
|
+
});
|
|
85
|
+
assert.equal(lines.length, 1);
|
|
86
|
+
assert.ok(!lines[0].includes('SECRET_TOKEN'), lines[0]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await t('the ship CLI says where the PR is findable, and says nothing when nobody was assigned', () => {
|
|
90
|
+
assert.match(prAssigneeLine('some-builder'), /assigned to @some-builder .*Assigned to you/);
|
|
91
|
+
for (const v of [null, undefined, '', 'a b', 'x\n[otb-plain] forged', 'a'.repeat(40)]) assert.equal(prAssigneeLine(v), null, JSON.stringify(v));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ---- layer 2: the real publish route --------------------------------------
|
|
95
|
+
const HEAD = 'a'.repeat(40);
|
|
96
|
+
let caller = null; // req.builder
|
|
97
|
+
let holder = null; // db.getLastClaimHolder
|
|
98
|
+
let assignResult = null; // what the GitHub assignees call answers
|
|
99
|
+
let opened = null; // the PR the route asked for
|
|
100
|
+
let assignCalls = [];
|
|
101
|
+
|
|
102
|
+
api.requireBuilder = (req, res, next) => { req.builder = caller; next(); };
|
|
103
|
+
db.getLastClaimHolder = async () => holder;
|
|
104
|
+
db.setClaimPublishedSha = async () => {};
|
|
105
|
+
gp.isConfigured = () => true;
|
|
106
|
+
gp.pushBundle = async () => ({ head_sha: HEAD });
|
|
107
|
+
gp.openOrFindPullRequest = async (args) => { opened = args; return { number: 77, url: 'https://github.com/o/r/pull/77', node_id: 'N' }; };
|
|
108
|
+
gp.enableAutoMergeDetailed = async () => ({ ok: true });
|
|
109
|
+
gp.setCommitStatus = async () => true;
|
|
110
|
+
prAssign.assignPullRequest = async (args) => { assignCalls.push(args); if (assignResult instanceof Error) throw assignResult; return assignResult; };
|
|
111
|
+
|
|
112
|
+
const buildTasksRouter = require('../modules/lifecycle/routes/tasks.js');
|
|
113
|
+
const app = express();
|
|
114
|
+
app.use((req, res, next) => {
|
|
115
|
+
res.fail = (code, status, extra) => res.status(typeof status === 'number' ? status : (status && status.status) || 500).json({ error: code, ...(extra || {}) });
|
|
116
|
+
next();
|
|
117
|
+
});
|
|
118
|
+
app.use(buildTasksRouter());
|
|
119
|
+
const server = http.createServer(app);
|
|
120
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
121
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
122
|
+
|
|
123
|
+
async function publish() {
|
|
124
|
+
opened = null; assignCalls = [];
|
|
125
|
+
const res = await fetch(`${base}/tasks/1003848/publish-branch`, {
|
|
126
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
127
|
+
body: JSON.stringify({ branch: 'task-1003848', head_sha: HEAD, bundle_b64: Buffer.from('bundle').toString('base64'), value_summary: 'A thing got better.' }),
|
|
128
|
+
});
|
|
129
|
+
return { status: res.status, body: await res.json() };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
await t('the route assigns the PR to the claim holder and names them in the body', async () => {
|
|
133
|
+
caller = { id: 5, rank: 'thetes', github_login: 'some-builder' };
|
|
134
|
+
holder = { builder_id: 5, github_login: 'some-builder' };
|
|
135
|
+
assignResult = { assigned: true };
|
|
136
|
+
const r = await publish();
|
|
137
|
+
assert.equal(r.status, 201, JSON.stringify(r.body));
|
|
138
|
+
assert.deepEqual(assignCalls, [{ number: 77, login: 'some-builder' }]);
|
|
139
|
+
assert.match(opened.body, /Built by @some-builder\./);
|
|
140
|
+
assert.equal(r.body.pr_assignee, 'some-builder');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await t('an Archon re-shipping someone else\'s task assigns the AUTHOR, not themselves', async () => {
|
|
144
|
+
caller = { id: 1, rank: 'archon', github_login: 'the-archon' };
|
|
145
|
+
holder = { builder_id: 5, github_login: 'some-builder' };
|
|
146
|
+
assignResult = { assigned: true };
|
|
147
|
+
const r = await publish();
|
|
148
|
+
assert.equal(r.status, 201);
|
|
149
|
+
assert.equal(assignCalls[0].login, 'some-builder');
|
|
150
|
+
assert.ok(!opened.body.includes('the-archon'));
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
await t('a failed assignment never fails the ship: still 201, pr_assignee null', async () => {
|
|
154
|
+
caller = { id: 5, rank: 'thetes', github_login: 'some-builder' };
|
|
155
|
+
holder = { builder_id: 5, github_login: 'some-builder' };
|
|
156
|
+
for (const result of [{ assigned: false, reason: 'http_403' }, new Error('assign blew up')]) {
|
|
157
|
+
assignResult = result;
|
|
158
|
+
const r = await publish();
|
|
159
|
+
assert.equal(r.status, 201, JSON.stringify(r.body));
|
|
160
|
+
assert.equal(r.body.pr_url, 'https://github.com/o/r/pull/77');
|
|
161
|
+
assert.equal(r.body.pr_assignee, null);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await t('a never-claimed task falls back to the caller', async () => {
|
|
166
|
+
caller = { id: 1, rank: 'archon', github_login: 'the-archon' };
|
|
167
|
+
holder = null;
|
|
168
|
+
assignResult = { assigned: true };
|
|
169
|
+
const r = await publish();
|
|
170
|
+
assert.equal(r.status, 201, JSON.stringify(r.body));
|
|
171
|
+
assert.equal(assignCalls[0].login, 'the-archon');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// A PR body is never rewritten, so a wrong name would be permanent: a lookup that FAILS
|
|
175
|
+
// must name nobody rather than credit the Archon who happened to re-ship it.
|
|
176
|
+
await t('a claim-holder lookup that THROWS names nobody, and still ships', async () => {
|
|
177
|
+
caller = { id: 1, rank: 'archon', github_login: 'the-archon' };
|
|
178
|
+
db.getLastClaimHolder = async () => { throw new Error('simulated DB outage'); };
|
|
179
|
+
assignResult = { assigned: false, reason: 'bad_login' };
|
|
180
|
+
const r = await publish();
|
|
181
|
+
assert.equal(r.status, 201, JSON.stringify(r.body));
|
|
182
|
+
assert.equal(assignCalls[0].login, null);
|
|
183
|
+
assert.ok(!opened.body.includes('Built by'), opened.body);
|
|
184
|
+
assert.equal(r.body.pr_assignee, null);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
server.close();
|
|
188
|
+
console.log(`\npr_assign: ${passed} passed, ${failed} failed`);
|
|
189
|
+
process.exitCode = failed ? 1 : 0;
|
package/tests/provision.mjs
CHANGED
|
@@ -415,6 +415,8 @@ function recorder({ apply = false } = {}) {
|
|
|
415
415
|
// Stub the DNS token-scope preflight (task 1980) so the composition stays hermetic
|
|
416
416
|
// (no real CF call). Default: reachable; a test overrides it to exercise the block.
|
|
417
417
|
checkDnsTokenScope: async () => ({ reachable: true, zone: 'example.com' }),
|
|
418
|
+
// Caddy's resolved-config check (task 1002860) stubbed wired; tests/provision_caddy_wiring.mjs covers it.
|
|
419
|
+
checkCaddyWiring: () => null,
|
|
418
420
|
// Owner OAuth token lookup for the scaffold push (task 1002317). Default: none →
|
|
419
421
|
// the scaffold takes the SSH `origin` fallback (so existing push assertions hold
|
|
420
422
|
// without touching the real db module). A test overrides it to exercise the token path.
|
|
@@ -1115,6 +1117,7 @@ function dnsRecorder({ apply = false } = {}) {
|
|
|
1115
1117
|
// Preflight (task 1980) stubbed reachable so --apply DNS tests reach the step-5
|
|
1116
1118
|
// behavior they assert (the preflight itself is covered by its own test below).
|
|
1117
1119
|
checkDnsTokenScope: async () => ({ reachable: true, zone: 'example.com' }),
|
|
1120
|
+
checkCaddyWiring: () => null,
|
|
1118
1121
|
};
|
|
1119
1122
|
return { deps, cmds, envs, cleared };
|
|
1120
1123
|
}
|
|
@@ -1525,6 +1528,7 @@ function privRecorder({ apply = false } = {}) {
|
|
|
1525
1528
|
writeFile: (p, _body, opts) => { writes.push({ p, opts: opts || {} }); return { ok: true }; },
|
|
1526
1529
|
doApi: null,
|
|
1527
1530
|
checkDnsTokenScope: async () => ({ reachable: true, zone: 'example.com' }),
|
|
1531
|
+
checkCaddyWiring: () => null,
|
|
1528
1532
|
};
|
|
1529
1533
|
return { deps, cmds, writes };
|
|
1530
1534
|
}
|
|
@@ -1666,6 +1670,7 @@ await ta('provisionInstance(co-tenant, remote): box steps use the box `exec`, bu
|
|
|
1666
1670
|
controlWriteFile: () => ({ ok: true }),
|
|
1667
1671
|
doApi: null,
|
|
1668
1672
|
checkDnsTokenScope: async () => ({ reachable: true, zone: 'example.com' }),
|
|
1673
|
+
checkCaddyWiring: () => null,
|
|
1669
1674
|
};
|
|
1670
1675
|
await P.provisionInstance({ ...coTenant, domain: 'mercury.example.com' }, deps);
|
|
1671
1676
|
// box-mutating steps → the box executor (which, in prod, ssh's to the co-hosting box)
|
|
@@ -1697,6 +1702,7 @@ await ta('provisionInstance(standalone, remote): EVERY box step runs control-pla
|
|
|
1697
1702
|
controlWriteFile: (p) => { controlWrites.push(p); return { ok: true }; }, // the control-plane writer
|
|
1698
1703
|
doApi: null,
|
|
1699
1704
|
checkDnsTokenScope: async () => ({ reachable: true, zone: 'cloudbongos.com' }),
|
|
1705
|
+
checkCaddyWiring: () => null,
|
|
1700
1706
|
};
|
|
1701
1707
|
await P.provisionInstance({ ...coTenant, hosting_shape: 'standalone', target_ref: 'example-owner/mercury', domain: 'mercury.cloudbongos.com' }, deps);
|
|
1702
1708
|
// The bug (task 2074): these landed on the REMOTE box, which never had the scaffolded
|
|
@@ -1736,6 +1742,7 @@ await ta('provisionInstance(standalone, remote, --apply): FAILS LOUD when the co
|
|
|
1736
1742
|
exec: () => ({ ok: true, stdout: '' }), controlExec: () => ({ ok: true, stdout: '' }),
|
|
1737
1743
|
writeFile: () => ({ ok: true }), controlWriteFile: () => ({ ok: true }), doApi: null,
|
|
1738
1744
|
checkDnsTokenScope: async () => ({ reachable: true, zone: 'cloudbongos.com' }),
|
|
1745
|
+
checkCaddyWiring: () => null,
|
|
1739
1746
|
};
|
|
1740
1747
|
await assert.rejects(
|
|
1741
1748
|
() => P.provisionInstance({ ...coTenant, hosting_shape: 'standalone', target_ref: 'example-owner/mercury', domain: 'mercury.cloudbongos.com' }, deps),
|
|
@@ -1980,6 +1987,7 @@ function attachRecorder({ apply = false, envBody = WEB_ENV_ON_BOX } = {}) {
|
|
|
1980
1987
|
writeFile: (p, body) => { writes.push({ path: p, body }); return { ok: true }; },
|
|
1981
1988
|
doApi: null,
|
|
1982
1989
|
checkDnsTokenScope: async () => ({ reachable: true, zone: 'cloudbongos.com' }),
|
|
1990
|
+
checkCaddyWiring: () => null,
|
|
1983
1991
|
};
|
|
1984
1992
|
return { deps, cmds, envs, writes, events, dnsNotes, cleared };
|
|
1985
1993
|
}
|
|
@@ -2226,6 +2234,7 @@ await ta('cmdRunIntents (--apply) routes a domain-attach intent to the attach le
|
|
|
2226
2234
|
exec: (cmd) => ({ ok: true, stdout: /\bcat\b.*web\.env/.test(String(cmd)) ? 'PORT=3010\n' : '' }),
|
|
2227
2235
|
writeFile: () => ({ ok: true }), doApi: null,
|
|
2228
2236
|
checkDnsTokenScope: async () => ({ reachable: true, zone: 'cloudbongos.com' }),
|
|
2237
|
+
checkCaddyWiring: () => null,
|
|
2229
2238
|
};
|
|
2230
2239
|
const r = await P.cmdRunIntents(deps);
|
|
2231
2240
|
assert.equal(r.ran, 1);
|
|
@@ -2288,6 +2297,7 @@ function truthRecorder({ healthzFails = false } = {}) {
|
|
|
2288
2297
|
writeFile: () => ({ ok: true }),
|
|
2289
2298
|
doApi: null,
|
|
2290
2299
|
checkDnsTokenScope: async () => ({ reachable: true, zone: 'cloudbongos.com' }),
|
|
2300
|
+
checkCaddyWiring: () => null,
|
|
2291
2301
|
getGithubToken: async () => null,
|
|
2292
2302
|
};
|
|
2293
2303
|
return { deps, cmds, statuses, patches, probes, logs, events, dnsNotes };
|
|
@@ -2361,6 +2371,7 @@ await ta('F19: a RETIRED intent records its reason on the instance without chang
|
|
|
2361
2371
|
},
|
|
2362
2372
|
exec: () => ({ ok: true }), writeFile: () => ({ ok: true }), doApi: null,
|
|
2363
2373
|
checkDnsTokenScope: async () => ({ reachable: true, zone: 'z' }),
|
|
2374
|
+
checkCaddyWiring: () => null,
|
|
2364
2375
|
};
|
|
2365
2376
|
const r = await P.cmdRunIntents(deps);
|
|
2366
2377
|
assert.equal(r.errors, 1);
|
|
@@ -2386,6 +2397,7 @@ await ta('F19: a NON-final failure re-pends the intent and writes nothing on the
|
|
|
2386
2397
|
},
|
|
2387
2398
|
exec: () => ({ ok: true }), writeFile: () => ({ ok: true }), doApi: null,
|
|
2388
2399
|
checkDnsTokenScope: async () => ({ reachable: true, zone: 'z' }),
|
|
2400
|
+
checkCaddyWiring: () => null,
|
|
2389
2401
|
};
|
|
2390
2402
|
await P.cmdRunIntents(deps);
|
|
2391
2403
|
assert.equal(resolved.length, 0, 'not resolved — it re-pends for a later tick');
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// tests/provision_caddy_wiring.mjs — provisioning checks the base Caddyfile carries the
|
|
2
|
+
// on-demand TLS ask and imports the site snippets before it reports a domain wired
|
|
3
|
+
// (task 1002860, ADR 0303). Before it, a box missing either directive provisioned
|
|
4
|
+
// "successfully" and the instance was simply unreachable. Hermetic: exec is a fake that
|
|
5
|
+
// answers `caddy adapt` with a fixture of Caddy's resolved JSON.
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { test } from 'node:test';
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const P = require('../scripts/gds/provision.js');
|
|
12
|
+
const { caddyWiringGap, checkCaddyWiring } = require('../scripts/gds/provision-net.js');
|
|
13
|
+
|
|
14
|
+
const DOMAIN = 'mercury.example.com';
|
|
15
|
+
// What `caddy adapt` prints: 2.7 writes the ask as a string, 2.8+ as a permission module.
|
|
16
|
+
const adapted = ({ onDemand, hosts = [] } = {}) => JSON.stringify({
|
|
17
|
+
apps: {
|
|
18
|
+
http: { servers: { srv0: { routes: hosts.map((h) => ({ match: [{ host: [h] }] })) } } },
|
|
19
|
+
...(onDemand ? { tls: { automation: { on_demand: onDemand } } } : {}),
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
const ASK_27 = { ask: 'http://127.0.0.1:3002/api/bongos/provisioning/tls-check' };
|
|
23
|
+
const ASK_28 = { permission: { module: 'http', endpoint: 'http://127.0.0.1:3002/api/gds/provisioning/tls-check' } };
|
|
24
|
+
const OPTS = { domain: DOMAIN, caddyfile: '/etc/caddy/Caddyfile', snippetDir: '/etc/caddy/sites' };
|
|
25
|
+
|
|
26
|
+
test('a wired config passes in both the 2.7 ask and the 2.8 permission shape', () => {
|
|
27
|
+
for (const onDemand of [ASK_27, ASK_28]) {
|
|
28
|
+
assert.equal(caddyWiringGap(adapted({ onDemand, hosts: [DOMAIN] }), { ...OPTS, requireHost: true }), null);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('no on-demand ask, or one aimed elsewhere, names the directive and the file to add it to', () => {
|
|
33
|
+
for (const onDemand of [undefined, { ask: 'http://127.0.0.1:9000/some-other-check' }]) {
|
|
34
|
+
const gap = caddyWiringGap(adapted({ onDemand, hosts: [DOMAIN] }), OPTS);
|
|
35
|
+
assert.match(gap, /on_demand_tls/);
|
|
36
|
+
assert.match(gap, /\/etc\/caddy\/Caddyfile/);
|
|
37
|
+
assert.match(gap, /\/provisioning\/tls-check/);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('a domain Caddy does not serve after the snippet is written names the missing import', () => {
|
|
42
|
+
const gap = caddyWiringGap(adapted({ onDemand: ASK_27, hosts: ['other.example.com'] }), { ...OPTS, requireHost: true });
|
|
43
|
+
assert.match(gap, /import \/etc\/caddy\/sites\/\*\.caddy/);
|
|
44
|
+
assert.match(gap, /mercury\.example\.com/);
|
|
45
|
+
assert.equal(caddyWiringGap(adapted({ onDemand: ASK_27 }), OPTS), null, 'the preflight asks only for the ask — no snippet exists yet');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('output that is not Caddy JSON, or a failed adapt, is a gap rather than a pass or a crash', () => {
|
|
49
|
+
assert.match(caddyWiringGap('', OPTS), /caddy adapt/);
|
|
50
|
+
assert.match(caddyWiringGap('null', OPTS), /caddy adapt/);
|
|
51
|
+
const boom = () => { const e = new Error('Command failed'); e.stderr = Buffer.from('Error: adapting config: Caddyfile:3 - unrecognized directive'); throw e; };
|
|
52
|
+
assert.match(checkCaddyWiring(boom, OPTS), /unrecognized directive/);
|
|
53
|
+
const cmds = [];
|
|
54
|
+
checkCaddyWiring((c) => { cmds.push(c); return { ok: true, stdout: adapted({ onDemand: ASK_27 }) }; }, OPTS);
|
|
55
|
+
assert.deepEqual(cmds, ['caddy adapt --config /etc/caddy/Caddyfile --adapter caddyfile'], 'unprivileged and read-only');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// -- the runner legs --------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
const coTenant = { id: 1, slug: 'mercury', hosting_shape: 'co-tenant', status: 'requested', owner_builder_id: 7, port: 3003, db_name: 'mercury', domain: DOMAIN };
|
|
61
|
+
const activeStandalone = { id: 18, slug: 'spike', hosting_shape: 'standalone', status: 'active', owner_builder_id: 7, port: 3010, db_name: 'spike', domain: 'spike.example.com' };
|
|
62
|
+
const WEB_ENV = 'PORT=3010\nBONGOS_PUBLIC_ORIGIN=https://old.example.com\n';
|
|
63
|
+
|
|
64
|
+
// `served` = the hosts Caddy's resolved config carries once a snippet is written — the
|
|
65
|
+
// fake flips to them after the first .caddy write, like the real import would.
|
|
66
|
+
function harness({ onDemand = ASK_27, importsSnippets = true, reloadFails = false } = {}) {
|
|
67
|
+
const cmds = [], writes = [], statuses = [];
|
|
68
|
+
let snippetWritten = null;
|
|
69
|
+
const exec = (cmd, opts = {}) => {
|
|
70
|
+
cmds.push({ cmd, opts });
|
|
71
|
+
if (cmd.startsWith('caddy adapt')) return { ok: true, stdout: adapted({ onDemand, hosts: importsSnippets && snippetWritten ? [snippetWritten] : [] }) };
|
|
72
|
+
if (/cat\b.*web\.env/.test(cmd)) return { ok: true, stdout: WEB_ENV };
|
|
73
|
+
if (/reload caddy/.test(cmd) && reloadFails) throw new Error('Job for caddy.service failed');
|
|
74
|
+
return { ok: true, stdout: '' };
|
|
75
|
+
};
|
|
76
|
+
const deps = {
|
|
77
|
+
apply: true, log: () => {}, sleep: async () => {},
|
|
78
|
+
db: { query: async () => ({ rows: [] }) },
|
|
79
|
+
provisioning: {
|
|
80
|
+
setInstanceStatus: async (_db, _id, s) => { statuses.push(s); }, recordEvent: async () => {},
|
|
81
|
+
recordDnsNote: async () => {}, clearReleasedDomains: async () => {},
|
|
82
|
+
},
|
|
83
|
+
exec,
|
|
84
|
+
writeFile: (p, body) => { writes.push(p); if (p.endsWith('.caddy')) snippetWritten = (/^(\S+) \{/.exec(body) || [])[1] || null; return { ok: true }; },
|
|
85
|
+
doApi: null,
|
|
86
|
+
checkDnsTokenScope: async () => ({ reachable: true, zone: 'example.com' }),
|
|
87
|
+
getGithubToken: async () => null,
|
|
88
|
+
};
|
|
89
|
+
const ran = (re) => cmds.some((c) => re.test(c.cmd));
|
|
90
|
+
return { deps, cmds, writes, statuses, ran };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
test('provision: a box with no tls-check ask is refused before anything on it changes', async () => {
|
|
94
|
+
const h = harness({ onDemand: null });
|
|
95
|
+
const r = await P.provisionInstance(coTenant, h.deps);
|
|
96
|
+
assert.equal(r.ok, false);
|
|
97
|
+
assert.match(r.error, /^Caddy preflight failed: .*on_demand_tls/);
|
|
98
|
+
assert.equal(h.writes.length, 0, 'no file written');
|
|
99
|
+
assert.ok(!h.ran(/createdb|systemctl/), 'no database or service touched');
|
|
100
|
+
assert.deepEqual(h.statuses, [], 'the instance never flips to provisioning');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('provision: a snippet Caddy never imports fails the run instead of reporting success, and Caddy is not reloaded', async () => {
|
|
104
|
+
const h = harness({ importsSnippets: false });
|
|
105
|
+
const r = await P.provisionInstance(coTenant, h.deps);
|
|
106
|
+
assert.equal(r.ok, false);
|
|
107
|
+
assert.match(r.error, /^Caddy wiring failed: .*import \/etc\/caddy\/sites\/\*\.caddy/);
|
|
108
|
+
assert.ok(h.writes.some((p) => p.endsWith('/mercury.caddy')), 'the snippet was written first — that is what the check proves Caddy reads');
|
|
109
|
+
assert.ok(!h.ran(/reload caddy/));
|
|
110
|
+
assert.ok(!h.ran(/healthz/), 'never reaches the healthz step');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('provision: a wired box reloads Caddy loudly — a rejected reload fails the run', async () => {
|
|
114
|
+
const ok = harness();
|
|
115
|
+
assert.equal((await P.provisionInstance(coTenant, ok.deps)).ok, true);
|
|
116
|
+
const reload = ok.cmds.find((c) => /reload caddy/.test(c.cmd));
|
|
117
|
+
assert.ok(reload, 'reloads');
|
|
118
|
+
assert.notEqual(reload.opts.allowFail, true, 'not allowFail');
|
|
119
|
+
await assert.rejects(P.provisionInstance(coTenant, harness({ reloadFails: true }).deps), /caddy\.service failed/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('provision: a domainless instance and a dry run never call caddy adapt', async () => {
|
|
123
|
+
const bare = harness({ onDemand: null });
|
|
124
|
+
assert.equal((await P.provisionInstance({ ...coTenant, domain: null }, bare.deps)).ok, true);
|
|
125
|
+
const dry = harness({ onDemand: null });
|
|
126
|
+
dry.deps.apply = false;
|
|
127
|
+
await P.provisionInstance(coTenant, dry.deps);
|
|
128
|
+
assert.ok(!bare.ran(/caddy adapt/) && !dry.ran(/caddy adapt/));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('domain-attach: refused before the env patch without the ask; fails loudly when the snippet is not imported', async () => {
|
|
132
|
+
const noAsk = harness({ onDemand: null });
|
|
133
|
+
const r1 = await P.domainAttachInstance(activeStandalone, noAsk.deps);
|
|
134
|
+
assert.equal(r1.ok, false);
|
|
135
|
+
assert.match(r1.error, /^Caddy preflight failed/);
|
|
136
|
+
assert.equal(noAsk.writes.length, 0, 'web.env untouched');
|
|
137
|
+
assert.ok(!noAsk.ran(/systemctl/));
|
|
138
|
+
|
|
139
|
+
const noImport = harness({ importsSnippets: false });
|
|
140
|
+
const r2 = await P.domainAttachInstance(activeStandalone, noImport.deps);
|
|
141
|
+
assert.equal(r2.ok, false);
|
|
142
|
+
assert.match(r2.error, /^Caddy wiring failed: .*spike\.example\.com/);
|
|
143
|
+
assert.ok(!noImport.ran(/reload caddy/));
|
|
144
|
+
|
|
145
|
+
const wired = harness();
|
|
146
|
+
assert.equal((await P.domainAttachInstance(activeStandalone, wired.deps)).ok, true);
|
|
147
|
+
assert.notEqual(wired.cmds.find((c) => /reload caddy/.test(c.cmd)).opts.allowFail, true);
|
|
148
|
+
});
|