@bongos/core 1.19.401 → 1.19.402
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 +36 -26
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0200-re-point-a-bring-your-own-domain.md +105 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +3 -2
- package/modules/provisioning/migrations/provisioning_012_domain_repoint.sql +35 -0
- package/modules/provisioning/provisioning.js +68 -8
- package/modules/provisioning/routes/provisioning.js +50 -23
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +2 -0
- package/public-landing/projects.html +26 -7
- package/scripts/gds/provision-net.js +33 -0
- package/scripts/gds/provision-units.js +18 -0
- package/scripts/gds/provision.js +97 -5
- package/src/module-api.js +3 -1
- package/tests/projects_hub_pre_uat.mjs +28 -2
- package/tests/provision.mjs +185 -12
- package/tests/provisioning_default_domain.mjs +5 -3
- package/tests/provisioning_domain_attach.mjs +166 -17
- package/tests/public_landing_projects.mjs +5 -1
package/tests/provision.mjs
CHANGED
|
@@ -965,18 +965,21 @@ console.log('\nDNS UPSERT composition (P4, CONFIG override):');
|
|
|
965
965
|
|
|
966
966
|
// A recorder that also captures each exec's env (so we can assert the DNS hook env).
|
|
967
967
|
function dnsRecorder({ apply = false } = {}) {
|
|
968
|
-
const cmds = [], envs = [];
|
|
968
|
+
const cmds = [], envs = [], cleared = [];
|
|
969
969
|
const deps = {
|
|
970
970
|
apply, log: () => {},
|
|
971
971
|
db: { query: async () => ({ rows: [] }) },
|
|
972
|
-
provisioning: {
|
|
972
|
+
provisioning: {
|
|
973
|
+
setInstanceStatus: async () => {}, recordEvent: async () => {},
|
|
974
|
+
clearReleasedDomains: async (_db, id) => { cleared.push(id); },
|
|
975
|
+
},
|
|
973
976
|
exec: (cmd, opts) => { cmds.push(cmd); envs.push(opts && opts.env); return { ok: true, stdout: '' }; },
|
|
974
977
|
writeFile: () => ({ ok: true }), doApi: null,
|
|
975
978
|
// Preflight (task 1980) stubbed reachable so --apply DNS tests reach the step-5
|
|
976
979
|
// behavior they assert (the preflight itself is covered by its own test below).
|
|
977
980
|
checkDnsTokenScope: async () => ({ reachable: true, zone: 'example.com' }),
|
|
978
981
|
};
|
|
979
|
-
return { deps, cmds, envs };
|
|
982
|
+
return { deps, cmds, envs, cleared };
|
|
980
983
|
}
|
|
981
984
|
|
|
982
985
|
await ta('a co-tenant WITH a domain UPSERTs DNS: the hook is invoked BY PATH with the control-plane env (proxied=false)', async () => {
|
|
@@ -1008,6 +1011,40 @@ await ta('--apply THROWS if a domain has no target IP (task-1180: never report s
|
|
|
1008
1011
|
} finally { P.CONFIG.publicIp = savedIp; P.CONFIG.dnsHook = savedHook; }
|
|
1009
1012
|
});
|
|
1010
1013
|
|
|
1014
|
+
await ta('the PROVISION run drains the release queue too — a re-point on a pre-active project never reaches the attach leg (task 1003237)', async () => {
|
|
1015
|
+
const savedIp = P.CONFIG.publicIp, savedHook = P.CONFIG.dnsHook;
|
|
1016
|
+
P.CONFIG.publicIp = '203.0.113.5';
|
|
1017
|
+
P.CONFIG.dnsHook = '/opt/infra/box-dns-cloudflare.sh';
|
|
1018
|
+
try {
|
|
1019
|
+
// an `error` row is frequently a PARTIAL standup (ADR 0181 §1): it may already
|
|
1020
|
+
// have UPSERTed the address it is now leaving, and domainAttachInstance no-ops
|
|
1021
|
+
// for anything but 'active', so this run is the only one that can let it go
|
|
1022
|
+
const { deps, cmds, envs, cleared } = dnsRecorder({ apply: true });
|
|
1023
|
+
await P.provisionInstance(
|
|
1024
|
+
{ ...coTenant, domain: 'right.example.com', released_domains: ['typo.example.com'] }, deps);
|
|
1025
|
+
const hooks = cmds.map((c, i) => i).filter((i) => cmds[i] === '/opt/infra/box-dns-cloudflare.sh');
|
|
1026
|
+
assert.equal(hooks.length, 2, 'one UPSERT for the new address, one RELEASE for the old');
|
|
1027
|
+
assert.equal(envs[hooks[0]].BOX_HOSTNAME, 'right.example.com');
|
|
1028
|
+
assert.equal(envs[hooks[0]].BOX_IP, '203.0.113.5');
|
|
1029
|
+
assert.equal(envs[hooks[1]].BOX_HOSTNAME, 'typo.example.com');
|
|
1030
|
+
assert.equal(envs[hooks[1]].BOX_IP, '192.0.2.1', 'parked at the blackhole, not left pointing here');
|
|
1031
|
+
assert.deepEqual(cleared, [coTenant.id], 'and the queue is drained');
|
|
1032
|
+
} finally { P.CONFIG.publicIp = savedIp; P.CONFIG.dnsHook = savedHook; }
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
await ta('a provision with an EMPTY release queue never invokes the hook twice', async () => {
|
|
1036
|
+
const savedIp = P.CONFIG.publicIp, savedHook = P.CONFIG.dnsHook;
|
|
1037
|
+
P.CONFIG.publicIp = '203.0.113.5';
|
|
1038
|
+
P.CONFIG.dnsHook = '/opt/infra/box-dns-cloudflare.sh';
|
|
1039
|
+
try {
|
|
1040
|
+
const { deps, cmds, cleared } = dnsRecorder({ apply: true });
|
|
1041
|
+
await P.provisionInstance({ ...coTenant, domain: 'right.example.com' }, deps);
|
|
1042
|
+
assert.equal(cmds.filter((c) => c === '/opt/infra/box-dns-cloudflare.sh').length, 1,
|
|
1043
|
+
'the ordinary standup is unchanged — no release, no extra call');
|
|
1044
|
+
assert.deepEqual(cleared, [], 'and an empty queue is never "drained"');
|
|
1045
|
+
} finally { P.CONFIG.publicIp = savedIp; P.CONFIG.dnsHook = savedHook; }
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1011
1048
|
await ta('no domain → DNS is skipped even with a hook + IP configured', async () => {
|
|
1012
1049
|
const savedIp = P.CONFIG.publicIp, savedHook = P.CONFIG.dnsHook;
|
|
1013
1050
|
P.CONFIG.publicIp = '203.0.113.5';
|
|
@@ -1698,21 +1735,45 @@ console.log('\ndomain-attach leg (task 1002692 — give an ACTIVE instance its l
|
|
|
1698
1735
|
// Recorder for the attach leg: captures commands (with env), file writes (with
|
|
1699
1736
|
// bodies), and recorded events. No status writes to capture — domain-attach must
|
|
1700
1737
|
// never touch the instance lifecycle.
|
|
1701
|
-
|
|
1702
|
-
|
|
1738
|
+
// `envBody` is what a `cat <web.env>` returns — the attach leg patches the origin
|
|
1739
|
+
// lines IN PLACE (task 1003237), so a test that cares about the write has to hand
|
|
1740
|
+
// it a file to patch. Default: the shape a manifest-onboarded instance really has
|
|
1741
|
+
// — creds and a federation block the ROW does not know, which a template re-write
|
|
1742
|
+
// would blank (ADR 0190 §2 / idea 1000842). Values are obvious fixtures.
|
|
1743
|
+
const WEB_ENV_ON_BOX = [
|
|
1744
|
+
'PORT=3010',
|
|
1745
|
+
'PGDATABASE=spike',
|
|
1746
|
+
`${NEUTRAL_PREFIX}_PUBLIC_ORIGIN=https://old.example.com`,
|
|
1747
|
+
`${NEUTRAL_PREFIX}_BUILDERS_ORIGIN=https://old.example.com`,
|
|
1748
|
+
`${NEUTRAL_PREFIX}_OAUTH_CLIENT_ID=Iv1.fixture-client-id`,
|
|
1749
|
+
`${NEUTRAL_PREFIX}_OAUTH_CLIENT_SECRET=fixture-secret-placed-by-the-manifest-flow`,
|
|
1750
|
+
'GITHUB_APP_PRIVATE_KEY=fixture-pem-flattened-to-one-line',
|
|
1751
|
+
`${NEUTRAL_PREFIX}_HUB_CLIENT_SECRET=fixture-federation-secret`,
|
|
1752
|
+
'',
|
|
1753
|
+
].join('\n');
|
|
1754
|
+
|
|
1755
|
+
function attachRecorder({ apply = false, envBody = WEB_ENV_ON_BOX } = {}) {
|
|
1756
|
+
const cmds = [], envs = [], writes = [], events = [], dnsNotes = [], cleared = [];
|
|
1703
1757
|
const deps = {
|
|
1704
1758
|
apply, log: () => {},
|
|
1705
1759
|
db: { query: async () => ({ rows: [] }) },
|
|
1706
1760
|
provisioning: {
|
|
1707
1761
|
recordEvent: async (_db, e) => { events.push(e); },
|
|
1708
1762
|
recordDnsNote: async (_db, id, note) => { dnsNotes.push({ id, note }); },
|
|
1763
|
+
clearReleasedDomains: async (_db, id) => { cleared.push(id); },
|
|
1764
|
+
},
|
|
1765
|
+
exec: (cmd, opts = {}) => {
|
|
1766
|
+
cmds.push(cmd); envs.push((opts && opts.env) || null);
|
|
1767
|
+
if (/\bcat\b.*web\.env/.test(cmd)) {
|
|
1768
|
+
return envBody === null ? { ok: false, softFailed: true, stdout: '' } : { ok: true, stdout: envBody };
|
|
1769
|
+
}
|
|
1770
|
+
return { ok: true, stdout: '' };
|
|
1709
1771
|
},
|
|
1710
|
-
exec: (cmd, opts = {}) => { cmds.push(cmd); envs.push((opts && opts.env) || null); return { ok: true, stdout: '' }; },
|
|
1711
1772
|
writeFile: (p, body) => { writes.push({ path: p, body }); return { ok: true }; },
|
|
1712
1773
|
doApi: null,
|
|
1713
1774
|
checkDnsTokenScope: async () => ({ reachable: true, zone: 'cloudbongos.com' }),
|
|
1714
1775
|
};
|
|
1715
|
-
return { deps, cmds, envs, writes, events, dnsNotes };
|
|
1776
|
+
return { deps, cmds, envs, writes, events, dnsNotes, cleared };
|
|
1716
1777
|
}
|
|
1717
1778
|
|
|
1718
1779
|
const activeStandalone = {
|
|
@@ -1720,17 +1781,19 @@ const activeStandalone = {
|
|
|
1720
1781
|
port: 3010, db_name: 'spike', domain: 'spike.cloudbongos.com', target_ref: 'jaxri/spike-harbor',
|
|
1721
1782
|
};
|
|
1722
1783
|
|
|
1723
|
-
await ta('domainAttachInstance(active standalone): env
|
|
1784
|
+
await ta('domainAttachInstance(active standalone, --apply): env patch → restart → DNS upsert → caddy → healthz', async () => {
|
|
1724
1785
|
const savedPub = P.CONFIG.publicIp, savedHook = P.CONFIG.dnsHook;
|
|
1725
1786
|
P.CONFIG.publicIp = '203.0.113.5';
|
|
1726
1787
|
P.CONFIG.dnsHook = '/opt/infra/box-dns-cloudflare.sh';
|
|
1727
1788
|
try {
|
|
1728
|
-
const { deps, cmds, envs, writes } = attachRecorder();
|
|
1789
|
+
const { deps, cmds, envs, writes } = attachRecorder({ apply: true });
|
|
1729
1790
|
const r = await P.domainAttachInstance(activeStandalone, deps);
|
|
1730
1791
|
assert.equal(r.ok, true);
|
|
1731
1792
|
const env = writes.find((w) => /\/spike\/web\.env$/.test(w.path));
|
|
1732
|
-
assert.ok(env, '
|
|
1733
|
-
assert.match(env.body,
|
|
1793
|
+
assert.ok(env, 'writes web.env');
|
|
1794
|
+
assert.match(env.body, new RegExp(`${reEsc(NEUTRAL_PREFIX)}_PUBLIC_ORIGIN=https://spike\\.cloudbongos\\.com`),
|
|
1795
|
+
'the new address reaches the origins');
|
|
1796
|
+
assert.doesNotMatch(env.body, /old\.example\.com/, 'the stale origins are replaced, not appended');
|
|
1734
1797
|
const joined = cmds.join('\n');
|
|
1735
1798
|
assert.match(joined, /systemctl restart spike/, 'RESTART (not reload) so the process re-reads its env');
|
|
1736
1799
|
const di = cmds.indexOf('/opt/infra/box-dns-cloudflare.sh');
|
|
@@ -1745,6 +1808,113 @@ await ta('domainAttachInstance(active standalone): env re-write → restart →
|
|
|
1745
1808
|
} finally { P.CONFIG.publicIp = savedPub; P.CONFIG.dnsHook = savedHook; }
|
|
1746
1809
|
});
|
|
1747
1810
|
|
|
1811
|
+
// -- the web.env hazard this leg used to carry (task 1003237, idea 1000842) -----
|
|
1812
|
+
// domainAttachInstance re-wrote web.env from the TEMPLATE (webEnvBody), which
|
|
1813
|
+
// regenerates the file FROM THE ROW — and the row knows neither the OAuth creds
|
|
1814
|
+
// placeManifestCreds fetched, nor the App private key, nor the federation block.
|
|
1815
|
+
// A first attach was masked by wizard order (address first, GitHub App second).
|
|
1816
|
+
// A RE-POINT is not masked: it runs on a project the founder is already signed
|
|
1817
|
+
// in to, so the same move would break sign-in on the project they just corrected.
|
|
1818
|
+
|
|
1819
|
+
await ta('domainAttachInstance(--apply): patches ONLY the origin lines — creds, App key and federation survive', async () => {
|
|
1820
|
+
const savedHook = P.CONFIG.dnsHook; P.CONFIG.dnsHook = '';
|
|
1821
|
+
try {
|
|
1822
|
+
const { deps, writes } = attachRecorder({ apply: true });
|
|
1823
|
+
const r = await P.domainAttachInstance(activeStandalone, deps);
|
|
1824
|
+
assert.equal(r.ok, true);
|
|
1825
|
+
const env = writes.find((w) => /\/spike\/web\.env$/.test(w.path));
|
|
1826
|
+
const P_ = reEsc(NEUTRAL_PREFIX);
|
|
1827
|
+
assert.match(env.body, new RegExp(`${P_}_OAUTH_CLIENT_ID=Iv1\\.fixture-client-id`), 'the manifest-placed client id survives');
|
|
1828
|
+
assert.match(env.body, new RegExp(`${P_}_OAUTH_CLIENT_SECRET=fixture-secret-placed-by-the-manifest-flow`), 'the client secret survives');
|
|
1829
|
+
assert.match(env.body, /GITHUB_APP_PRIVATE_KEY=fixture-pem-flattened-to-one-line/, 'the App private key survives');
|
|
1830
|
+
assert.match(env.body, new RegExp(`${P_}_HUB_CLIENT_SECRET=fixture-federation-secret`), 'the federation block survives');
|
|
1831
|
+
assert.match(env.body, /^PORT=3010$/m, 'and every unrelated line is untouched');
|
|
1832
|
+
assert.match(env.body, new RegExp(`${P_}_STATUS_ORIGIN=https://spike\\.cloudbongos\\.com`), 'a missing origin line is APPENDED');
|
|
1833
|
+
} finally { P.CONFIG.dnsHook = savedHook; }
|
|
1834
|
+
});
|
|
1835
|
+
|
|
1836
|
+
await ta('domainAttachInstance(--apply): an unreadable web.env is REFUSED, never "patched" over', async () => {
|
|
1837
|
+
const { deps, writes } = attachRecorder({ apply: true, envBody: null });
|
|
1838
|
+
const r = await P.domainAttachInstance(activeStandalone, deps);
|
|
1839
|
+
assert.equal(r.ok, false);
|
|
1840
|
+
assert.match(r.error, /cannot read/i);
|
|
1841
|
+
assert.equal(writes.length, 0, 'four origins over a missing PORT/PGDATABASE would take the instance down');
|
|
1842
|
+
});
|
|
1843
|
+
|
|
1844
|
+
await ta('domainAttachInstance(dry-run): PLANS the patch — nothing is written', async () => {
|
|
1845
|
+
const { deps, writes } = attachRecorder();
|
|
1846
|
+
const r = await P.domainAttachInstance(activeStandalone, deps);
|
|
1847
|
+
assert.equal(r.ok, true);
|
|
1848
|
+
assert.ok(!writes.some((w) => /web\.env$/.test(w.path)), 'a dry run must not write a synthetic body');
|
|
1849
|
+
});
|
|
1850
|
+
|
|
1851
|
+
// -- releasing what a re-point moved off (task 1003237, ADR 0200) --------------
|
|
1852
|
+
|
|
1853
|
+
await ta('releasableDomains: the queue, minus the address the instance is CURRENTLY on', () => {
|
|
1854
|
+
assert.deepEqual(P.releasableDomains({ domain: 'right.example.com', released_domains: ['TYPO.example.com'] }),
|
|
1855
|
+
['typo.example.com'], 'normalized, so a case difference can never dodge the release');
|
|
1856
|
+
assert.deepEqual(P.releasableDomains({ domain: 'right.example.com', released_domains: ['right.example.com'] }),
|
|
1857
|
+
[], 'a row that somehow queued its own domain can never blackhole a live project');
|
|
1858
|
+
assert.deepEqual(P.releasableDomains({ domain: 'x.example.com' }), [], 'no queue is not a crash');
|
|
1859
|
+
assert.deepEqual(P.releasableDomains({ domain: 'x.example.com', released_domains: ['a.example.com', '', 'b.example.com'] }),
|
|
1860
|
+
['a.example.com', 'b.example.com'], 'every abandoned address is released, not just the last');
|
|
1861
|
+
});
|
|
1862
|
+
|
|
1863
|
+
await ta('domainAttachInstance(--apply): a re-point parks the old address at the blackhole, AFTER wiring the new one', async () => {
|
|
1864
|
+
const savedPub = P.CONFIG.publicIp, savedHook = P.CONFIG.dnsHook;
|
|
1865
|
+
P.CONFIG.publicIp = '203.0.113.5';
|
|
1866
|
+
P.CONFIG.dnsHook = '/opt/infra/box-dns-cloudflare.sh';
|
|
1867
|
+
try {
|
|
1868
|
+
const { deps, cmds, envs, cleared } = attachRecorder({ apply: true });
|
|
1869
|
+
const r = await P.domainAttachInstance({ ...activeStandalone, released_domains: ['typo.example.com'] }, deps);
|
|
1870
|
+
assert.equal(r.ok, true);
|
|
1871
|
+
const hooks = cmds.map((c, i) => i).filter((i) => cmds[i] === '/opt/infra/box-dns-cloudflare.sh');
|
|
1872
|
+
assert.equal(hooks.length, 2, 'one UPSERT for the new address, one RELEASE for the old');
|
|
1873
|
+
assert.equal(envs[hooks[0]].BOX_HOSTNAME, 'spike.cloudbongos.com');
|
|
1874
|
+
assert.equal(envs[hooks[0]].BOX_IP, '203.0.113.5', 'the new address is wired first');
|
|
1875
|
+
assert.equal(envs[hooks[1]].BOX_HOSTNAME, 'typo.example.com');
|
|
1876
|
+
assert.equal(envs[hooks[1]].BOX_IP, '192.0.2.1', 'RFC 5737 TEST-NET-1 — guaranteed never routable');
|
|
1877
|
+
assert.equal(envs[hooks[1]].BOX_PROXIED, '0', 'a parked record must never ride the CF edge');
|
|
1878
|
+
assert.equal(envs[hooks[1]].CLOUDFLARE_ZONE, 'example.com', 'the zone is derived from the OLD host, not the row');
|
|
1879
|
+
const caddy = cmds.findIndex((c) => /reload caddy/.test(c));
|
|
1880
|
+
assert.ok(caddy >= 0 && caddy < hooks[1], 'the old vhost is gone before its record is parked');
|
|
1881
|
+
assert.deepEqual(cleared, [18], 'the queue is drained only once every address on it was released');
|
|
1882
|
+
} finally { P.CONFIG.publicIp = savedPub; P.CONFIG.dnsHook = savedHook; }
|
|
1883
|
+
});
|
|
1884
|
+
|
|
1885
|
+
await ta('domainAttachInstance(--apply): a release the hook REFUSES keeps the queue — ok:true, the re-point still stands', async () => {
|
|
1886
|
+
const savedPub = P.CONFIG.publicIp, savedHook = P.CONFIG.dnsHook;
|
|
1887
|
+
P.CONFIG.publicIp = '203.0.113.5';
|
|
1888
|
+
P.CONFIG.dnsHook = '/opt/infra/box-dns-cloudflare.sh';
|
|
1889
|
+
try {
|
|
1890
|
+
const { deps, cleared } = attachRecorder({ apply: true });
|
|
1891
|
+
let seen = 0;
|
|
1892
|
+
const inner = deps.exec;
|
|
1893
|
+
deps.exec = (cmd, opts) => {
|
|
1894
|
+
if (cmd === '/opt/infra/box-dns-cloudflare.sh' && ++seen === 2) throw new Error('cloudflare said no');
|
|
1895
|
+
return inner(cmd, opts);
|
|
1896
|
+
};
|
|
1897
|
+
const r = await P.domainAttachInstance({ ...activeStandalone, released_domains: ['typo.example.com'] }, deps);
|
|
1898
|
+
assert.equal(r.ok, true, 'a failed release must never fail a re-point that already landed');
|
|
1899
|
+
assert.equal(r.releaseFailed, 'typo.example.com');
|
|
1900
|
+
assert.deepEqual(cleared, [], 'the queue survives, so the next attach retries the release');
|
|
1901
|
+
} finally { P.CONFIG.publicIp = savedPub; P.CONFIG.dnsHook = savedHook; }
|
|
1902
|
+
});
|
|
1903
|
+
|
|
1904
|
+
await ta('domainAttachInstance(--apply): no DNS hook → the release is named for the operator, and the queue is kept', async () => {
|
|
1905
|
+
const savedHook = P.CONFIG.dnsHook; P.CONFIG.dnsHook = '';
|
|
1906
|
+
try {
|
|
1907
|
+
const lines = [];
|
|
1908
|
+
const { deps, cleared } = attachRecorder({ apply: true });
|
|
1909
|
+
deps.log = (m) => lines.push(String(m));
|
|
1910
|
+
const r = await P.domainAttachInstance({ ...activeStandalone, released_domains: ['typo.example.com'] }, deps);
|
|
1911
|
+
assert.equal(r.ok, true);
|
|
1912
|
+
assert.ok(lines.some((l) => /typo\.example\.com/.test(l) && /BOX_DNS_HOOK unset/.test(l)),
|
|
1913
|
+
'the outstanding manual step is SAID, not swallowed');
|
|
1914
|
+
assert.deepEqual(cleared, [], 'nothing was released, so nothing is marked released');
|
|
1915
|
+
} finally { P.CONFIG.dnsHook = savedHook; }
|
|
1916
|
+
});
|
|
1917
|
+
|
|
1748
1918
|
await ta('domainAttachInstance: no domain recorded → ok:false, nothing runs', async () => {
|
|
1749
1919
|
const { deps, cmds } = attachRecorder();
|
|
1750
1920
|
const r = await P.domainAttachInstance({ ...activeStandalone, domain: null }, deps);
|
|
@@ -1797,7 +1967,10 @@ await ta('cmdRunIntents (--apply) routes a domain-attach intent to the attach le
|
|
|
1797
1967
|
claimNextOAuthManifest: async () => null,
|
|
1798
1968
|
resolveOAuthManifest: async () => {},
|
|
1799
1969
|
},
|
|
1800
|
-
|
|
1970
|
+
// the attach leg patches web.env IN PLACE, so a `cat` must return a real file
|
|
1971
|
+
// — an empty one is a deliberate refusal (task 1003237)
|
|
1972
|
+
exec: (cmd) => ({ ok: true, stdout: /\bcat\b.*web\.env/.test(String(cmd)) ? 'PORT=3010\n' : '' }),
|
|
1973
|
+
writeFile: () => ({ ok: true }), doApi: null,
|
|
1801
1974
|
checkDnsTokenScope: async () => ({ reachable: true, zone: 'cloudbongos.com' }),
|
|
1802
1975
|
};
|
|
1803
1976
|
const r = await P.cmdRunIntents(deps);
|
|
@@ -318,12 +318,14 @@ await ta('re-pointing AWAY from the platform-assigned address → recorded + dom
|
|
|
318
318
|
assert.deepEqual(enqueueCalls.map((c) => c.action), ['domain-attach']);
|
|
319
319
|
});
|
|
320
320
|
|
|
321
|
-
await ta('a genuine bring-your-own address
|
|
321
|
+
await ta('a genuine bring-your-own address RE-POINTS, and the one it leaves is queued for release (task 1003237)', async () => {
|
|
322
322
|
reset();
|
|
323
323
|
provisioning.getInstanceById = async (_db, id) => freshRow({ id, domain: 'spike.example.com', status: 'active' });
|
|
324
324
|
const res = await post('/provisioning/instances/41/domain', { domain: 'another.example.com' });
|
|
325
|
-
assert.equal(res.status,
|
|
326
|
-
assert.equal(res.body.
|
|
325
|
+
assert.equal(res.status, 200, res.raw);
|
|
326
|
+
assert.equal(res.body.domain, 'another.example.com');
|
|
327
|
+
assert.equal(res.body.replaced, 'spike.example.com',
|
|
328
|
+
'the owner decision of 2026-08-28: a mistyped BYO address is correctable, and the old one is let go');
|
|
327
329
|
});
|
|
328
330
|
|
|
329
331
|
server.close();
|
|
@@ -41,7 +41,7 @@ async function ta(name, fn) {
|
|
|
41
41
|
|
|
42
42
|
console.log('service slice — updateInstanceDomain / getInstanceByDomain:');
|
|
43
43
|
|
|
44
|
-
await ta('updateInstanceDomain: parameterized UPDATE of
|
|
44
|
+
await ta('updateInstanceDomain: parameterized UPDATE of the domain + the release queue, returns the row', async () => {
|
|
45
45
|
const calls = [];
|
|
46
46
|
const db = { query: async (sql, params) => {
|
|
47
47
|
calls.push({ sql, params });
|
|
@@ -50,8 +50,34 @@ await ta('updateInstanceDomain: parameterized UPDATE of only the domain (+update
|
|
|
50
50
|
const row = await provisioning.updateInstanceDomain(db, 18, 'spike.cloudbongos.com');
|
|
51
51
|
assert.equal(row.domain, 'spike.cloudbongos.com');
|
|
52
52
|
assert.match(calls[0].sql, /UPDATE provisioning_instances/);
|
|
53
|
-
assert.match(calls[0].sql, /SET domain = \$2
|
|
54
|
-
assert.
|
|
53
|
+
assert.match(calls[0].sql, /SET domain = \$2/);
|
|
54
|
+
assert.match(calls[0].sql, /released_domains/, 'the release queue rides the SAME statement (task 1003237)');
|
|
55
|
+
assert.match(calls[0].sql, /last_verified_up = CASE WHEN domain IS DISTINCT FROM \$2 THEN NULL/,
|
|
56
|
+
'a CHANGED address has never been probed - the overlay must not describe the address it left (F16)');
|
|
57
|
+
assert.doesNotMatch(calls[0].sql, /status/, 'still never drifts the lifecycle');
|
|
58
|
+
assert.match(calls[0].sql, /array_length\(released_domains, 1\), 0\) >= \$4/,
|
|
59
|
+
'the queue is CAPPED in SQL, not trusted from the caller - the runner drains it against a shared token');
|
|
60
|
+
assert.deepEqual(calls[0].params, [18, 'spike.cloudbongos.com', null, provisioning.MAX_RELEASED_DOMAINS],
|
|
61
|
+
'no `releasing` means a null 3rd param, and the CASE leaves the queue exactly as it stands');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await ta('updateInstanceDomain: a re-point APPENDS the old address, lowercased, and never duplicates it', async () => {
|
|
65
|
+
const calls = [];
|
|
66
|
+
const db = { query: async (sql, params) => { calls.push({ sql, params }); return { rows: [{ id: 18 }] }; } };
|
|
67
|
+
await provisioning.updateInstanceDomain(db, 18, 'right.example.com', 'TYPO.example.com');
|
|
68
|
+
assert.deepEqual(calls[0].params, [18, 'right.example.com', 'typo.example.com', provisioning.MAX_RELEASED_DOMAINS]);
|
|
69
|
+
assert.match(calls[0].sql, /array_append\(released_domains, \$3\)/, 'appends — a second correction must not drop the first');
|
|
70
|
+
assert.match(calls[0].sql, /\$3 = ANY\(released_domains\)/, 'idempotent: a retry never queues the same address twice');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await ta('clearReleasedDomains: empties the queue and touches nothing else', async () => {
|
|
74
|
+
const calls = [];
|
|
75
|
+
const db = { query: async (sql, params) => { calls.push({ sql, params }); return { rows: [{ id: 18 }] }; } };
|
|
76
|
+
await provisioning.clearReleasedDomains(db, 18);
|
|
77
|
+
assert.match(calls[0].sql, /SET released_domains = '\{\}'/);
|
|
78
|
+
assert.doesNotMatch(calls[0].sql, /updated_at|status|domain = /,
|
|
79
|
+
'draining a queue is not an owner-visible change (the clearErrorNote rule)');
|
|
80
|
+
assert.deepEqual(calls[0].params, [18]);
|
|
55
81
|
});
|
|
56
82
|
|
|
57
83
|
await ta('getInstanceByDomain: case-insensitive exact match, parameterized', async () => {
|
|
@@ -80,8 +106,8 @@ let createResult = null; // what createInstance reports (re-request test
|
|
|
80
106
|
let manifestRows = [];
|
|
81
107
|
provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
|
|
82
108
|
provisioning.getInstanceByDomain = async (_db, d) => domainHolder;
|
|
83
|
-
provisioning.updateInstanceDomain = async (_db, id, domain) => {
|
|
84
|
-
updateCalls.push({ id, domain });
|
|
109
|
+
provisioning.updateInstanceDomain = async (_db, id, domain, releasing = null) => {
|
|
110
|
+
updateCalls.push({ id, domain, releasing });
|
|
85
111
|
instance = { ...instance, domain };
|
|
86
112
|
return { ...instance, id };
|
|
87
113
|
};
|
|
@@ -100,10 +126,24 @@ const api = require('../src/module-api.js');
|
|
|
100
126
|
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
|
|
101
127
|
api.requirePermission = () => (_req, _res, next) => next();
|
|
102
128
|
api.branding = () => ({
|
|
103
|
-
|
|
129
|
+
// instanceBase is the platform APEX the route's reserved + in-apex-squat guards
|
|
130
|
+
// read (instanceApex()). Set here so the re-point suite below can exercise them
|
|
131
|
+
// against a real apex instead of the guard-free '' the stub used to imply.
|
|
132
|
+
domains: { publicOrigin: 'https://cb.test', instanceBase: 'cb.test' },
|
|
104
133
|
identity: { productName: 'Cloud Bongos' },
|
|
105
134
|
});
|
|
106
135
|
|
|
136
|
+
// The domain route is rate-limited PER BUILDER (task 1003237) and every test in
|
|
137
|
+
// this file posts as builder 7, so the real cap would throttle the suite rather
|
|
138
|
+
// than the behaviour each test is about. Raise it for the shared app below —
|
|
139
|
+
// `limiterMax = null` restores the route's own number, which the dedicated
|
|
140
|
+
// limiter test at the end of this file uses to exercise the real 429.
|
|
141
|
+
const rateLimit = require('../modules/provisioning/rate-limit.js');
|
|
142
|
+
const realMakeAskRateLimiter = rateLimit.makeAskRateLimiter;
|
|
143
|
+
let limiterMax = 10_000;
|
|
144
|
+
rateLimit.makeAskRateLimiter = (opts = {}) =>
|
|
145
|
+
realMakeAskRateLimiter({ ...opts, ...(limiterMax === null ? {} : { max: limiterMax }) });
|
|
146
|
+
|
|
107
147
|
const express = require('express');
|
|
108
148
|
const provisioningRoutes = require('../modules/provisioning/routes/provisioning.js');
|
|
109
149
|
const app = express();
|
|
@@ -121,10 +161,12 @@ const server = app.listen(0);
|
|
|
121
161
|
await new Promise((r) => server.once('listening', r));
|
|
122
162
|
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
123
163
|
|
|
124
|
-
function post(path, body = {}) {
|
|
164
|
+
function post(path, body = {}) { return postTo(base, path, body); }
|
|
165
|
+
|
|
166
|
+
function postTo(origin, path, body = {}) {
|
|
125
167
|
return new Promise((resolve, reject) => {
|
|
126
168
|
const payload = JSON.stringify(body);
|
|
127
|
-
const req = http.request(`${
|
|
169
|
+
const req = http.request(`${origin}${path}`, {
|
|
128
170
|
method: 'POST',
|
|
129
171
|
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
|
|
130
172
|
}, (res) => {
|
|
@@ -137,9 +179,9 @@ function post(path, body = {}) {
|
|
|
137
179
|
});
|
|
138
180
|
}
|
|
139
181
|
|
|
140
|
-
function reset({ inst } = {}) {
|
|
182
|
+
function reset({ inst, holder } = {}) {
|
|
141
183
|
instance = inst || null;
|
|
142
|
-
domainHolder = null;
|
|
184
|
+
domainHolder = holder || null;
|
|
143
185
|
updateCalls = [];
|
|
144
186
|
enqueueCalls = [];
|
|
145
187
|
enqueueResult = null;
|
|
@@ -155,7 +197,7 @@ await ta('ACTIVE domainless instance: domain is validated, recorded, and a domai
|
|
|
155
197
|
assert.equal(res.status, 200, res.raw);
|
|
156
198
|
assert.equal(res.body.ok, true);
|
|
157
199
|
assert.equal(res.body.domain, 'spike.cloudbongos.com', 'normalized before storing');
|
|
158
|
-
assert.deepEqual(updateCalls, [{ id: 18, domain: 'spike.cloudbongos.com' }]);
|
|
200
|
+
assert.deepEqual(updateCalls, [{ id: 18, domain: 'spike.cloudbongos.com', releasing: null }]);
|
|
159
201
|
assert.deepEqual(enqueueCalls.map((c) => c.action), ['domain-attach']);
|
|
160
202
|
assert.equal(res.body.queued, true);
|
|
161
203
|
});
|
|
@@ -229,21 +271,91 @@ await ta('re-kick honors enqueueIntent created:false — a racing enqueue is nev
|
|
|
229
271
|
} finally { provisioning.enqueueIntent = savedEnqueue; }
|
|
230
272
|
});
|
|
231
273
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
274
|
+
// -- the re-point (task 1003237, owner decision 2026-08-28 / ADR 0200) --------
|
|
275
|
+
// A mistyped bring-your-own address used to be a permanent 409 domain_already_set.
|
|
276
|
+
// It is now correctable, under exactly the guards the FIRST attach applied. The
|
|
277
|
+
// apex here is cb.test (the harness's branding publicOrigin), so "inside the
|
|
278
|
+
// apex" means *.cb.test.
|
|
279
|
+
|
|
280
|
+
await ta('a DIFFERENT bring-your-own domain RE-POINTS — the address changes and the old one is queued for release', async () => {
|
|
281
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'typo.example.com' } });
|
|
282
|
+
const res = await post('/provisioning/instances/18/domain', { domain: 'right.example.com' });
|
|
283
|
+
assert.equal(res.status, 200, res.raw);
|
|
284
|
+
assert.equal(res.body.changed, true);
|
|
285
|
+
assert.equal(res.body.queued, true, 'an ACTIVE instance re-points via a domain-attach intent');
|
|
286
|
+
assert.equal(res.body.domain, 'right.example.com');
|
|
287
|
+
assert.equal(res.body.replaced, 'typo.example.com', 'the response NAMES what is being let go');
|
|
288
|
+
assert.deepEqual(updateCalls, [{ id: 18, domain: 'right.example.com', releasing: 'typo.example.com' }],
|
|
289
|
+
'the new address and the release ride ONE write');
|
|
290
|
+
assert.match(res.body.message, /right\.example\.com/);
|
|
291
|
+
assert.match(res.body.message, /typo\.example\.com/, 'the sentence says the old address is going');
|
|
292
|
+
assert.deepEqual(enqueueCalls.map((c) => c.action), ['domain-attach']);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
await ta('a re-point onto a PLATFORM-RESERVED name is refused — the first attach guard, unweakened', async () => {
|
|
296
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'typo.example.com' } });
|
|
297
|
+
const res = await post('/provisioning/instances/18/domain', { domain: 'cb.test' });
|
|
298
|
+
assert.equal(res.status, 400, res.raw);
|
|
299
|
+
assert.equal(res.body.error.code, 'domain_reserved');
|
|
300
|
+
assert.equal(updateCalls.length, 0, 'nothing was written - the old address still stands');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
await ta('a re-point INSIDE the platform apex is refused as a squat (task 1002704) — except this slug own default', async () => {
|
|
304
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'typo.example.com' } });
|
|
305
|
+
const squat = await post('/provisioning/instances/18/domain', { domain: 'someone-else.cb.test' });
|
|
306
|
+
assert.equal(squat.status, 400, squat.raw);
|
|
307
|
+
assert.equal(squat.body.error.code, 'domain_reserved');
|
|
308
|
+
assert.equal(updateCalls.length, 0);
|
|
309
|
+
|
|
310
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'typo.example.com' } });
|
|
311
|
+
const own = await post('/provisioning/instances/18/domain', { domain: 'spike.cb.test' });
|
|
312
|
+
assert.equal(own.status, 200, own.raw);
|
|
313
|
+
assert.deepEqual(updateCalls, [{ id: 18, domain: 'spike.cb.test', releasing: 'typo.example.com' }],
|
|
314
|
+
'falling back to the assigned default still releases the BYO address it left');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
await ta('a re-point onto an address ANOTHER project holds is a truthful 409 domain_taken', async () => {
|
|
318
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'typo.example.com' },
|
|
319
|
+
holder: { id: 99, slug: 'other' } });
|
|
320
|
+
const res = await post('/provisioning/instances/18/domain', { domain: 'taken.example.com' });
|
|
235
321
|
assert.equal(res.status, 409, res.raw);
|
|
236
|
-
assert.equal(res.body.error.code, '
|
|
237
|
-
assert.equal(res.body.error.details.current, 'old.cloudbongos.com', 'names the current address');
|
|
322
|
+
assert.equal(res.body.error.code, 'domain_taken');
|
|
238
323
|
assert.equal(updateCalls.length, 0);
|
|
239
324
|
});
|
|
240
325
|
|
|
326
|
+
await ta('moving OFF the platform-assigned <slug>.<apex> queues NO release — that name is the platform own (ADR 0181)', async () => {
|
|
327
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'spike.cb.test' } });
|
|
328
|
+
const res = await post('/provisioning/instances/18/domain', { domain: 'mine.example.com' });
|
|
329
|
+
assert.equal(res.status, 200, res.raw);
|
|
330
|
+
assert.equal(res.body.replaced, null, 'the assigned default is never released - the revive path re-uses it');
|
|
331
|
+
assert.deepEqual(updateCalls, [{ id: 18, domain: 'mine.example.com', releasing: null }]);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
await ta('a re-point on a REQUESTED instance records without an intent, and releases NOTHING — it never stood up', async () => {
|
|
335
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'requested', domain: 'typo.example.com' } });
|
|
336
|
+
const res = await post('/provisioning/instances/18/domain', { domain: 'right.example.com' });
|
|
337
|
+
assert.equal(res.status, 200, res.raw);
|
|
338
|
+
assert.equal(res.body.queued, false);
|
|
339
|
+
assert.equal(res.body.replaced, null,
|
|
340
|
+
'nothing ever wrote an A record for the address it is leaving - parking that name would CREATE a junk record, not release one');
|
|
341
|
+
assert.deepEqual(updateCalls, [{ id: 18, domain: 'right.example.com', releasing: null }]);
|
|
342
|
+
assert.deepEqual(enqueueCalls, [], 'nothing to re-assert on an instance that has not stood up');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
await ta("an ERROR instance DOES release — a failed standup is frequently a PARTIAL one that already wrote the record (ADR 0181)", async () => {
|
|
346
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'error', domain: 'typo.example.com' } });
|
|
347
|
+
const res = await post('/provisioning/instances/18/domain', { domain: 'right.example.com' });
|
|
348
|
+
assert.equal(res.status, 200, res.raw);
|
|
349
|
+
assert.equal(res.body.replaced, 'typo.example.com');
|
|
350
|
+
assert.deepEqual(updateCalls, [{ id: 18, domain: 'right.example.com', releasing: 'typo.example.com' }]);
|
|
351
|
+
});
|
|
352
|
+
|
|
241
353
|
await ta('REQUESTED instance: domain records with NO intent — the provision run wires it', async () => {
|
|
242
354
|
reset({ inst: { owner_builder_id: 7, slug: 'fresh', status: 'requested', domain: null } });
|
|
243
355
|
const res = await post('/provisioning/instances/18/domain', { domain: 'fresh.cloudbongos.com' });
|
|
244
356
|
assert.equal(res.status, 200, res.raw);
|
|
245
357
|
assert.equal(res.body.queued, false);
|
|
246
|
-
assert.deepEqual(updateCalls, [{ id: 18, domain: 'fresh.cloudbongos.com' }]);
|
|
358
|
+
assert.deepEqual(updateCalls, [{ id: 18, domain: 'fresh.cloudbongos.com', releasing: null }]);
|
|
247
359
|
assert.equal(enqueueCalls.length, 0, 'provision will wire it — no separate intent');
|
|
248
360
|
});
|
|
249
361
|
|
|
@@ -310,6 +422,43 @@ await ta('a fresh create with a domain is untouched (the domain is stored at cre
|
|
|
310
422
|
assert.equal(res.body.domain_ignored, undefined);
|
|
311
423
|
});
|
|
312
424
|
|
|
425
|
+
console.log('\nPOST /provisioning/instances/:id/domain — the write is throttled per builder:');
|
|
426
|
+
|
|
427
|
+
await ta('the re-point write is rate-limited on the BUILDER, so one owner cannot spend the platform-wide DNS budget', async () => {
|
|
428
|
+
// A re-point QUEUES an address the runner later releases with a call against the
|
|
429
|
+
// SHARED control-plane Cloudflare token, so an unthrottled owner spends a
|
|
430
|
+
// platform resource, not merely their own. Keyed on the builder, not the IP:
|
|
431
|
+
// per-IP starves co-located builders while an attacker rotates IPs.
|
|
432
|
+
limiterMax = null; // the route's own number, whatever it is
|
|
433
|
+
const limited = express();
|
|
434
|
+
limited.use(express.json());
|
|
435
|
+
limited.use((_req, res, next) => {
|
|
436
|
+
res.fail = (code, opts, details) => {
|
|
437
|
+
const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
|
|
438
|
+
res.status(status).json({ error: { code, ...(typeof opts === 'object' ? opts : {}), ...(details || {}) } });
|
|
439
|
+
};
|
|
440
|
+
next();
|
|
441
|
+
});
|
|
442
|
+
limited.use('/api/bongos', provisioningRoutes());
|
|
443
|
+
const srv = limited.listen(0);
|
|
444
|
+
await new Promise((r) => srv.once('listening', r));
|
|
445
|
+
const at = `http://127.0.0.1:${srv.address().port}/api/bongos`;
|
|
446
|
+
try {
|
|
447
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'typo.example.com' } });
|
|
448
|
+
let sawLimit = false;
|
|
449
|
+
// well past any sane cap for a human correcting one typo
|
|
450
|
+
for (let i = 0; i < 60 && !sawLimit; i++) {
|
|
451
|
+
const res = await postTo(at, `/provisioning/instances/18/domain?i=${i}`, { domain: `h${i}.example.com` });
|
|
452
|
+
if (res.status === 429) {
|
|
453
|
+
sawLimit = true;
|
|
454
|
+
assert.equal(res.body.error.code || res.body.error, 'rate_limited');
|
|
455
|
+
assert.ok(i > 0, 'a first call is never throttled');
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
assert.ok(sawLimit, 'the route must shed a flood — an unthrottled owner can queue unbounded DNS work');
|
|
459
|
+
} finally { srv.close(); limiterMax = 10_000; }
|
|
460
|
+
});
|
|
461
|
+
|
|
313
462
|
console.log('\nPOST /provisioning/instances/:id/github-app — accepts the newly-addressed instance:');
|
|
314
463
|
|
|
315
464
|
await ta('domainless instance still 409s domain_required; once addressed, the manifest mints', async () => {
|
|
@@ -95,11 +95,15 @@ test('every truthful refusal the API can give is mapped to human copy, never swa
|
|
|
95
95
|
'not_teardownable', // teardown refused for the real state
|
|
96
96
|
'bad_domain', // malformed hostname
|
|
97
97
|
'domain_taken', // another instance already answers there
|
|
98
|
-
'
|
|
98
|
+
'domain_reserved', // the platform's own name, or a squat inside its apex
|
|
99
99
|
'domain_not_attachable', // mid-flight/torn-down states refuse truthfully
|
|
100
100
|
]) {
|
|
101
101
|
assert.match(body, new RegExp(code), `the page must handle '${code}' explicitly`);
|
|
102
102
|
}
|
|
103
|
+
// retired with the re-point (task 1003237): the server no longer sends it, so a
|
|
104
|
+
// branch for it would be copy the founder can never reach
|
|
105
|
+
assert.doesNotMatch(body, /domain_already_set/,
|
|
106
|
+
'the refusal the re-point removed must not linger as dead copy');
|
|
103
107
|
});
|
|
104
108
|
|
|
105
109
|
test('a domainless instance gets an honest no-address state', async () => {
|