@bongos/core 1.19.126 → 1.19.128
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 +28 -23
- package/modules/builder-settings/builder-needs.js +59 -2
- package/modules/provisioning/migrations/provisioning_008_dns_note.sql +15 -0
- package/modules/provisioning/provisioning.js +28 -1
- package/modules/provisioning/routes/provisioning.js +26 -1
- package/modules/provisioning/tests/provisioning.mjs +21 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/projects.html +88 -18
- package/scripts/gds/provision.js +115 -19
- package/src/bongos/db-kernel.js +15 -0
- package/src/bongos/db.js +2 -1
- package/src/bongos/routes/me.js +11 -1
- package/src/module-api.js +5 -1
- package/tests/builder_needs.mjs +114 -0
- package/tests/provision.mjs +272 -6
- package/tests/provisioning_domain_attach.mjs +40 -0
- package/tests/public_landing_projects.mjs +33 -0
package/tests/provision.mjs
CHANGED
|
@@ -1033,17 +1033,40 @@ await ta('placeManifestCreds rejects a dedicated droplet (no post-hoc exec path)
|
|
|
1033
1033
|
assert.equal(noCode.ok, false); assert.match(noCode.error, /no github_code/);
|
|
1034
1034
|
});
|
|
1035
1035
|
|
|
1036
|
-
await ta('
|
|
1036
|
+
await ta('verification timeout reports the code as SPENT and does not disown the placement (F23)', async () => {
|
|
1037
1037
|
const inst = { ...coTenant, domain: 'mercury.example.com' };
|
|
1038
1038
|
const deps = {
|
|
1039
|
-
apply: true, privileged: false, log: () => {},
|
|
1039
|
+
apply: true, privileged: false, log: () => {}, sleep: async () => {},
|
|
1040
1040
|
convertManifestCode: async () => ({ clientId: 'Iv1.abcdef0123456789', clientSecret: 'z'.repeat(40), appId: 1, appSlug: 's' }),
|
|
1041
1041
|
exec: () => ({ ok: true, stdout: '{"ok":true,"auth_configured":false}' }),
|
|
1042
1042
|
writeFile: () => ({ ok: true }),
|
|
1043
1043
|
};
|
|
1044
1044
|
const r = await P.placeManifestCreds(inst, { github_code: 'C', attempts: 1 }, deps);
|
|
1045
1045
|
assert.equal(r.ok, false);
|
|
1046
|
-
assert.
|
|
1046
|
+
assert.equal(r.codeSpent, true, 'the conversion happened — the single-use code is burned');
|
|
1047
|
+
assert.match(r.error, /credentials are placed/, 'the error owns what DID happen');
|
|
1048
|
+
assert.doesNotMatch(r.error, /\b\d+s\b|minute|second/i, 'no wall-clock promises (§5.2)');
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
await ta('verification PATIENCE wins the boot race — one conversion, success (F23)', async () => {
|
|
1052
|
+
const inst = { ...coTenant, domain: 'mercury.example.com' };
|
|
1053
|
+
let probes = 0, converts = 0;
|
|
1054
|
+
const deps = {
|
|
1055
|
+
apply: true, privileged: false, log: () => {}, sleep: async () => {},
|
|
1056
|
+
convertManifestCode: async () => { converts++; return { clientId: 'Iv1.abcdef0123456789', clientSecret: 'z'.repeat(40), appId: 1, appSlug: 's' }; },
|
|
1057
|
+
exec: (cmd) => {
|
|
1058
|
+
if (/healthz/.test(cmd)) {
|
|
1059
|
+
probes++;
|
|
1060
|
+
return { ok: true, stdout: probes > 20 ? '{"auth_configured":true}' : '{"auth_configured":false}' };
|
|
1061
|
+
}
|
|
1062
|
+
return { ok: true, stdout: '' };
|
|
1063
|
+
},
|
|
1064
|
+
writeFile: () => ({ ok: true }),
|
|
1065
|
+
};
|
|
1066
|
+
const r = await P.placeManifestCreds(inst, { github_code: 'C', attempts: 1 }, deps);
|
|
1067
|
+
assert.equal(r.ok, true, 'a slow child boot is waited out, not recorded as a failure');
|
|
1068
|
+
assert.equal(converts, 1, 'the single-use code is converted exactly once');
|
|
1069
|
+
assert.ok(probes > 10, 'the old 10-probe budget would have lost this race');
|
|
1047
1070
|
});
|
|
1048
1071
|
|
|
1049
1072
|
await ta('drainOAuthManifests (--apply): claims a code_received row, places creds, resolves done', async () => {
|
|
@@ -1064,9 +1087,57 @@ await ta('drainOAuthManifests (--apply): claims a code_received row, places cred
|
|
|
1064
1087
|
assert.equal(resolved.length, 1);
|
|
1065
1088
|
assert.equal(resolved[0].status, 'done');
|
|
1066
1089
|
assert.equal(resolved[0].patch.app_slug, 'app-3');
|
|
1090
|
+
assert.equal(resolved[0].patch.github_code, null, 'a done row holds no re-convertible code (F23)');
|
|
1067
1091
|
assert.ok(events.includes('oauth-manifest-done'));
|
|
1068
1092
|
});
|
|
1069
1093
|
|
|
1094
|
+
await ta('a SPENT code is never re-converted — terminal error, no code_received reset (F23)', async () => {
|
|
1095
|
+
// A stateful one-row store: claimNext honors status, resolve writes it back —
|
|
1096
|
+
// exactly the reset loop that used to re-convert a burned code on the next tick.
|
|
1097
|
+
const row = { id: 21, instance_id: 1, github_code: 'BURNED', attempts: 1, status: 'code_received' };
|
|
1098
|
+
const resolves = []; let converts = 0;
|
|
1099
|
+
const deps = {
|
|
1100
|
+
apply: true, privileged: false, log: () => {}, sleep: async () => {},
|
|
1101
|
+
db: {}, writeFile: () => ({ ok: true }),
|
|
1102
|
+
exec: (cmd) => (/healthz/.test(cmd) ? { ok: true, stdout: '{"auth_configured":false}' } : { ok: true, stdout: '' }),
|
|
1103
|
+
convertManifestCode: async () => { converts++; return { clientId: 'Iv1.abcdef0123456789', clientSecret: 'z'.repeat(40), appId: 3, appSlug: 'a' }; },
|
|
1104
|
+
provisioning: {
|
|
1105
|
+
claimNextOAuthManifest: async () => (row.status === 'code_received' ? { ...row } : null),
|
|
1106
|
+
getInstanceById: async () => ({ ...coTenant, domain: 'mercury.example.com' }),
|
|
1107
|
+
resolveOAuthManifest: async (_db, _id, status, patch = {}) => {
|
|
1108
|
+
resolves.push({ status, patch });
|
|
1109
|
+
row.status = status;
|
|
1110
|
+
if ('github_code' in patch) row.github_code = patch.github_code;
|
|
1111
|
+
},
|
|
1112
|
+
recordEvent: async () => {},
|
|
1113
|
+
},
|
|
1114
|
+
};
|
|
1115
|
+
await P.drainOAuthManifests(deps); // tick 1: converts, placement verification times out
|
|
1116
|
+
await P.drainOAuthManifests(deps); // tick 2: must find nothing to claim
|
|
1117
|
+
assert.equal(converts, 1, 'the single-use code was converted exactly once across ticks');
|
|
1118
|
+
assert.equal(row.status, 'error', 'a spent code is terminal');
|
|
1119
|
+
assert.equal(row.github_code, null, 'the burned code is scrubbed from the row');
|
|
1120
|
+
assert.ok(!resolves.some((x) => x.status === 'code_received'), 'never reset for a doomed retry');
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
await ta('a PRE-conversion failure stays retryable — the narrowing spared the retry path (F23)', async () => {
|
|
1124
|
+
let claims = 0; const resolves = [];
|
|
1125
|
+
const deps = {
|
|
1126
|
+
apply: true, privileged: false, log: () => {}, sleep: async () => {},
|
|
1127
|
+
db: {}, exec: () => ({ ok: true, stdout: '' }), writeFile: () => ({ ok: true }),
|
|
1128
|
+
provisioning: {
|
|
1129
|
+
claimNextOAuthManifest: async () => (claims++ === 0 ? { id: 22, instance_id: 1, github_code: 'C', attempts: 1 } : null),
|
|
1130
|
+
// dedicated shape → placeManifestCreds refuses BEFORE converting (codeSpent absent)
|
|
1131
|
+
getInstanceById: async () => ({ ...coTenant, hosting_shape: 'dedicated', domain: 'd.example.com' }),
|
|
1132
|
+
resolveOAuthManifest: async (_db, _id, status, patch = {}) => { resolves.push({ status, patch }); },
|
|
1133
|
+
recordEvent: async () => {},
|
|
1134
|
+
},
|
|
1135
|
+
};
|
|
1136
|
+
await P.drainOAuthManifests(deps);
|
|
1137
|
+
assert.equal(resolves.length, 1);
|
|
1138
|
+
assert.equal(resolves[0].status, 'code_received', 'an unconverted code may retry on a later tick');
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1070
1141
|
console.log('\nprivileged mode (PROVISION_SUDO — task 2066):');
|
|
1071
1142
|
|
|
1072
1143
|
t('dbCreateCmd: privileged runs createdb as the postgres role; default is bare (peer auth)', () => {
|
|
@@ -1502,17 +1573,20 @@ console.log('\ndomain-attach leg (task 1002692 — give an ACTIVE instance its l
|
|
|
1502
1573
|
// bodies), and recorded events. No status writes to capture — domain-attach must
|
|
1503
1574
|
// never touch the instance lifecycle.
|
|
1504
1575
|
function attachRecorder({ apply = false } = {}) {
|
|
1505
|
-
const cmds = [], envs = [], writes = [], events = [];
|
|
1576
|
+
const cmds = [], envs = [], writes = [], events = [], dnsNotes = [];
|
|
1506
1577
|
const deps = {
|
|
1507
1578
|
apply, log: () => {},
|
|
1508
1579
|
db: { query: async () => ({ rows: [] }) },
|
|
1509
|
-
provisioning: {
|
|
1580
|
+
provisioning: {
|
|
1581
|
+
recordEvent: async (_db, e) => { events.push(e); },
|
|
1582
|
+
recordDnsNote: async (_db, id, note) => { dnsNotes.push({ id, note }); },
|
|
1583
|
+
},
|
|
1510
1584
|
exec: (cmd, opts = {}) => { cmds.push(cmd); envs.push((opts && opts.env) || null); return { ok: true, stdout: '' }; },
|
|
1511
1585
|
writeFile: (p, body) => { writes.push({ path: p, body }); return { ok: true }; },
|
|
1512
1586
|
doApi: null,
|
|
1513
1587
|
checkDnsTokenScope: async () => ({ reachable: true, zone: 'cloudbongos.com' }),
|
|
1514
1588
|
};
|
|
1515
|
-
return { deps, cmds, envs, writes, events };
|
|
1589
|
+
return { deps, cmds, envs, writes, events, dnsNotes };
|
|
1516
1590
|
}
|
|
1517
1591
|
|
|
1518
1592
|
const activeStandalone = {
|
|
@@ -1630,5 +1704,197 @@ await ta('cmdRunIntents (--apply): an UNKNOWN intent action never falls through
|
|
|
1630
1704
|
assert.ok(!cmds.some((c) => /systemctl (stop|disable)/.test(c)), 'no teardown commands ran');
|
|
1631
1705
|
});
|
|
1632
1706
|
|
|
1707
|
+
console.log('\nprovisioning truthfulness (task 1002720 — QA-walk F15/F16/F19):');
|
|
1708
|
+
|
|
1709
|
+
// An apply-mode recorder for provisionInstance: every command succeeds except
|
|
1710
|
+
// (optionally) the healthz probe, which soft-fails the way a refused connection
|
|
1711
|
+
// does under allowFail.
|
|
1712
|
+
function truthRecorder({ healthzFails = false } = {}) {
|
|
1713
|
+
const cmds = [], statuses = [], patches = [], probes = [], logs = [], events = [], dnsNotes = [];
|
|
1714
|
+
const deps = {
|
|
1715
|
+
apply: true, privileged: false, log: (m) => logs.push(String(m)), sleep: async () => {},
|
|
1716
|
+
db: { query: async () => ({ rows: [] }) },
|
|
1717
|
+
provisioning: {
|
|
1718
|
+
setInstanceStatus: async (_db, _id, s, patch) => { statuses.push(s); patches.push(patch || {}); },
|
|
1719
|
+
recordLivenessProbe: async (_db, _id, up) => { probes.push(up); },
|
|
1720
|
+
recordDnsNote: async (_db, _id, note) => { dnsNotes.push(note); },
|
|
1721
|
+
recordEvent: async (_db, e) => { events.push(e); },
|
|
1722
|
+
getInstanceBySlug: async () => null, getInstanceById: async () => null,
|
|
1723
|
+
claimNextIntent: async () => null, resolveIntent: async () => {},
|
|
1724
|
+
enqueueIntent: async () => ({ created: true }),
|
|
1725
|
+
claimNextOAuthManifest: async () => null, resolveOAuthManifest: async () => {},
|
|
1726
|
+
},
|
|
1727
|
+
exec: (cmd) => {
|
|
1728
|
+
cmds.push(cmd);
|
|
1729
|
+
if (healthzFails && /healthz/.test(cmd)) return { ok: true, stdout: '', softFailed: true };
|
|
1730
|
+
return { ok: true, stdout: '' };
|
|
1731
|
+
},
|
|
1732
|
+
writeFile: () => ({ ok: true }),
|
|
1733
|
+
doApi: null,
|
|
1734
|
+
checkDnsTokenScope: async () => ({ reachable: true, zone: 'cloudbongos.com' }),
|
|
1735
|
+
getGithubToken: async () => null,
|
|
1736
|
+
};
|
|
1737
|
+
return { deps, cmds, statuses, patches, probes, logs, events, dnsNotes };
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
await ta('F15: a refused /healthz is RECORDED — probe handed to the overlay, note cleared, success line qualified', async () => {
|
|
1741
|
+
const { deps, cmds, statuses, patches, probes, logs, events } = truthRecorder({ healthzFails: true });
|
|
1742
|
+
const r = await P.provisionInstance({ ...coTenant }, deps);
|
|
1743
|
+
assert.equal(r.ok, true, 'a failed probe must NOT fail the run — that would burn the intent retry budget');
|
|
1744
|
+
assert.equal(r.healthy, false);
|
|
1745
|
+
assert.equal(statuses[statuses.length - 1], 'active', 'the lifecycle still activates (the overlay carries the truth)');
|
|
1746
|
+
assert.equal(patches[patches.length - 1].error_note, null, 'a stale error_note never rides into active');
|
|
1747
|
+
assert.deepEqual(probes, [false], 'the runner hands its observation to the liveness overlay');
|
|
1748
|
+
assert.ok(cmds.filter((c) => /healthz/.test(c)).length >= 2, 'a slow boot gets a bounded retry, not one shot');
|
|
1749
|
+
assert.ok(logs.some((l) => /not answering/.test(l)), 'the operator line is qualified, not a bare ✓');
|
|
1750
|
+
const activeEvent = events.find((e) => e.event === 'active');
|
|
1751
|
+
assert.match(activeEvent.detail, /not answering/, 'the event detail carries the observation');
|
|
1752
|
+
});
|
|
1753
|
+
|
|
1754
|
+
await ta('F15: a passing /healthz records up=true immediately — no five-minute "live?" limbo', async () => {
|
|
1755
|
+
const { deps, cmds, probes, logs } = truthRecorder();
|
|
1756
|
+
const r = await P.provisionInstance({ ...coTenant }, deps);
|
|
1757
|
+
assert.equal(r.ok, true);
|
|
1758
|
+
assert.equal(r.healthy, true);
|
|
1759
|
+
assert.deepEqual(probes, [true]);
|
|
1760
|
+
assert.equal(cmds.filter((c) => /healthz/.test(c)).length, 1, 'a pass ends the loop on the first probe');
|
|
1761
|
+
assert.ok(logs.some((l) => /✓ mercury provisioned$/.test(l)), 'the plain success line survives');
|
|
1762
|
+
});
|
|
1763
|
+
|
|
1764
|
+
await ta('F16: a no-hook attach is honest — ok:true, dns_note written, event says SKIPPED', async () => {
|
|
1765
|
+
const savedHook = P.CONFIG.dnsHook, savedCp = P.CONFIG.controlPlaneIp, savedRemote = P.CONFIG.remoteHost;
|
|
1766
|
+
P.CONFIG.dnsHook = null; P.CONFIG.controlPlaneIp = '203.0.113.9'; P.CONFIG.remoteHost = null;
|
|
1767
|
+
try {
|
|
1768
|
+
const { deps, events, dnsNotes } = attachRecorder({ apply: true });
|
|
1769
|
+
const r = await P.domainAttachInstance({ ...activeStandalone }, deps);
|
|
1770
|
+
assert.equal(r.ok, true, 'an unset hook is unretryable — ok:false would burn the retry budget');
|
|
1771
|
+
assert.equal(dnsNotes.length, 1);
|
|
1772
|
+
assert.match(dnsNotes[0].note, /A record/, 'the owner is told the outstanding manual step');
|
|
1773
|
+
assert.match(dnsNotes[0].note, /203\.0\.113\.9/, 'the note names the target IP');
|
|
1774
|
+
assert.doesNotMatch(dnsNotes[0].note, /shortly|minute|second/i, 'no wall-clock promises (§5.2)');
|
|
1775
|
+
const detail = events.map((e) => e.detail).join('\n');
|
|
1776
|
+
assert.match(detail, /DNS SKIPPED/, 'the event stops claiming DNS was re-asserted');
|
|
1777
|
+
} finally { P.CONFIG.dnsHook = savedHook; P.CONFIG.controlPlaneIp = savedCp; P.CONFIG.remoteHost = savedRemote; }
|
|
1778
|
+
});
|
|
1779
|
+
|
|
1780
|
+
await ta('F16: a hooked attach CLEARS a stale dns_note', async () => {
|
|
1781
|
+
const savedHook = P.CONFIG.dnsHook, savedCp = P.CONFIG.controlPlaneIp, savedRemote = P.CONFIG.remoteHost;
|
|
1782
|
+
P.CONFIG.dnsHook = '/opt/infra/box-dns-cloudflare.sh'; P.CONFIG.controlPlaneIp = '203.0.113.9'; P.CONFIG.remoteHost = null;
|
|
1783
|
+
try {
|
|
1784
|
+
const { deps, dnsNotes } = attachRecorder({ apply: true });
|
|
1785
|
+
const r = await P.domainAttachInstance({ ...activeStandalone }, deps);
|
|
1786
|
+
assert.equal(r.ok, true);
|
|
1787
|
+
assert.deepEqual(dnsNotes, [{ id: activeStandalone.id, note: null }], 'a successful attach retires the note');
|
|
1788
|
+
} finally { P.CONFIG.dnsHook = savedHook; P.CONFIG.controlPlaneIp = savedCp; P.CONFIG.remoteHost = savedRemote; }
|
|
1789
|
+
});
|
|
1790
|
+
|
|
1791
|
+
await ta('F19: a RETIRED intent records its reason on the instance without changing its status', async () => {
|
|
1792
|
+
let claims = 0; const resolved = []; const statusWrites = [];
|
|
1793
|
+
const deps = {
|
|
1794
|
+
apply: true, log: () => {},
|
|
1795
|
+
db: { query: async () => ({ rows: [] }) },
|
|
1796
|
+
provisioning: {
|
|
1797
|
+
claimNextIntent: async () => (claims++ === 0
|
|
1798
|
+
? { id: 9, instance_id: 18, action: 'domain-attach', attempts: P.MAX_INTENT_ATTEMPTS } : null),
|
|
1799
|
+
// an ACTIVE instance with no domain → the attach leg returns ok:false (unretryable)
|
|
1800
|
+
getInstanceById: async () => ({ ...activeStandalone, domain: null }),
|
|
1801
|
+
resolveIntent: async (_db, id, state, err) => { resolved.push({ id, state, err }); },
|
|
1802
|
+
setInstanceStatus: async (_db, _id, s, patch) => { statusWrites.push({ s, patch }); },
|
|
1803
|
+
recordEvent: async () => {},
|
|
1804
|
+
claimNextOAuthManifest: async () => null, resolveOAuthManifest: async () => {},
|
|
1805
|
+
},
|
|
1806
|
+
exec: () => ({ ok: true }), writeFile: () => ({ ok: true }), doApi: null,
|
|
1807
|
+
checkDnsTokenScope: async () => ({ reachable: true, zone: 'z' }),
|
|
1808
|
+
};
|
|
1809
|
+
const r = await P.cmdRunIntents(deps);
|
|
1810
|
+
assert.equal(r.errors, 1);
|
|
1811
|
+
assert.equal(resolved[0].state, 'error', 'the intent retires');
|
|
1812
|
+
assert.equal(statusWrites.length, 1, 'the retire is surfaced on the instance');
|
|
1813
|
+
assert.equal(statusWrites[0].s, 'active', 'status write is a NO-OP on status — a healthy instance is never marked broken');
|
|
1814
|
+
assert.match(statusWrites[0].patch.error_note, /no domain recorded/, 'the reason survives where the card reads it');
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
await ta('F19: a NON-final failure re-pends the intent and writes nothing on the instance', async () => {
|
|
1818
|
+
let claims = 0; const resolved = []; const statusWrites = []; const updates = [];
|
|
1819
|
+
const deps = {
|
|
1820
|
+
apply: true, log: () => {},
|
|
1821
|
+
db: { query: async (sql) => { if (/UPDATE provisioning_intents/.test(sql)) updates.push(sql); return { rows: [] }; } },
|
|
1822
|
+
provisioning: {
|
|
1823
|
+
claimNextIntent: async () => (claims++ === 0
|
|
1824
|
+
? { id: 10, instance_id: 18, action: 'domain-attach', attempts: 1 } : null),
|
|
1825
|
+
getInstanceById: async () => ({ ...activeStandalone, domain: null }),
|
|
1826
|
+
resolveIntent: async (_db, id, state, err) => { resolved.push({ id, state, err }); },
|
|
1827
|
+
setInstanceStatus: async (_db, _id, s, patch) => { statusWrites.push({ s, patch }); },
|
|
1828
|
+
recordEvent: async () => {},
|
|
1829
|
+
claimNextOAuthManifest: async () => null, resolveOAuthManifest: async () => {},
|
|
1830
|
+
},
|
|
1831
|
+
exec: () => ({ ok: true }), writeFile: () => ({ ok: true }), doApi: null,
|
|
1832
|
+
checkDnsTokenScope: async () => ({ reachable: true, zone: 'z' }),
|
|
1833
|
+
};
|
|
1834
|
+
await P.cmdRunIntents(deps);
|
|
1835
|
+
assert.equal(resolved.length, 0, 'not resolved — it re-pends for a later tick');
|
|
1836
|
+
assert.equal(updates.length, 1, 're-pended via the intents table');
|
|
1837
|
+
assert.equal(statusWrites.length, 0, 'no instance write until the intent actually retires');
|
|
1838
|
+
});
|
|
1839
|
+
|
|
1840
|
+
console.log('\nreview-panel hardening (task 1002720 — the loopback/latch/birth-note seams):');
|
|
1841
|
+
|
|
1842
|
+
await ta('a PASSING loopback probe on a DOMAIN-bearing instance records NO verdict — the address claim belongs to the sweep', async () => {
|
|
1843
|
+
const { deps, probes } = truthRecorder();
|
|
1844
|
+
const r = await P.provisionInstance({ ...coTenant, domain: 'mercury.example.com' }, deps);
|
|
1845
|
+
assert.equal(r.ok, true);
|
|
1846
|
+
assert.equal(r.healthy, true);
|
|
1847
|
+
assert.deepEqual(probes, [], 'loopback-up proves the process, not the domain — "Answering at" must stay unearned');
|
|
1848
|
+
});
|
|
1849
|
+
|
|
1850
|
+
await ta('a FAILED loopback probe is a verdict on any shape — recorded even with a domain', async () => {
|
|
1851
|
+
const { deps, probes } = truthRecorder({ healthzFails: true });
|
|
1852
|
+
const r = await P.provisionInstance({ ...coTenant, domain: 'mercury.example.com' }, deps);
|
|
1853
|
+
assert.equal(r.healthy, false);
|
|
1854
|
+
assert.deepEqual(probes, [false], 'a dead process answers nowhere — down is down');
|
|
1855
|
+
});
|
|
1856
|
+
|
|
1857
|
+
await ta('birth-path dns_note: written when the hook is unset, CLEARED when a later run has one', async () => {
|
|
1858
|
+
const savedHook = P.CONFIG.dnsHook, savedPub = P.CONFIG.publicIp;
|
|
1859
|
+
P.CONFIG.publicIp = '203.0.113.5';
|
|
1860
|
+
try {
|
|
1861
|
+
P.CONFIG.dnsHook = null;
|
|
1862
|
+
const a = truthRecorder();
|
|
1863
|
+
await P.provisionInstance({ ...coTenant, domain: 'mercury.example.com' }, a.deps);
|
|
1864
|
+
assert.equal(a.dnsNotes.length, 1, 'the no-hook birth writes the note');
|
|
1865
|
+
assert.match(a.dnsNotes[0], /A record/);
|
|
1866
|
+
P.CONFIG.dnsHook = '/opt/infra/box-dns-cloudflare.sh';
|
|
1867
|
+
const b = truthRecorder();
|
|
1868
|
+
await P.provisionInstance({ ...coTenant, domain: 'mercury.example.com' }, b.deps);
|
|
1869
|
+
assert.deepEqual(b.dnsNotes, [null], 'a hooked run (e.g. a revive) retires the stale note');
|
|
1870
|
+
} finally { P.CONFIG.dnsHook = savedHook; P.CONFIG.publicIp = savedPub; }
|
|
1871
|
+
});
|
|
1872
|
+
|
|
1873
|
+
await ta('a successful domain-attach releases the F19 error_note latch', async () => {
|
|
1874
|
+
const savedHook = P.CONFIG.dnsHook, savedCp = P.CONFIG.controlPlaneIp, savedRemote = P.CONFIG.remoteHost;
|
|
1875
|
+
P.CONFIG.dnsHook = '/opt/infra/box-dns-cloudflare.sh'; P.CONFIG.controlPlaneIp = '203.0.113.9'; P.CONFIG.remoteHost = null;
|
|
1876
|
+
try {
|
|
1877
|
+
const { deps } = attachRecorder({ apply: true });
|
|
1878
|
+
const cleared = [];
|
|
1879
|
+
deps.provisioning.clearErrorNote = async (_db, id) => { cleared.push(id); };
|
|
1880
|
+
const r = await P.domainAttachInstance({ ...activeStandalone, error_note: 'DNS preflight failed' }, deps);
|
|
1881
|
+
assert.equal(r.ok, true);
|
|
1882
|
+
assert.deepEqual(cleared, [activeStandalone.id], 'the "we stopped trying" paragraph must not outlive its recovery');
|
|
1883
|
+
} finally { P.CONFIG.dnsHook = savedHook; P.CONFIG.controlPlaneIp = savedCp; P.CONFIG.remoteHost = savedRemote; }
|
|
1884
|
+
});
|
|
1885
|
+
|
|
1886
|
+
await ta('UNUSABLE creds from a successful conversion still report codeSpent — no re-POST of a burned code', async () => {
|
|
1887
|
+
const inst = { ...coTenant, domain: 'mercury.example.com' };
|
|
1888
|
+
const deps = {
|
|
1889
|
+
apply: true, privileged: false, log: () => {}, sleep: async () => {},
|
|
1890
|
+
convertManifestCode: async () => ({ clientId: 'short', clientSecret: 'also-short' }),
|
|
1891
|
+
exec: () => ({ ok: true, stdout: '' }), writeFile: () => ({ ok: true }),
|
|
1892
|
+
};
|
|
1893
|
+
const r = await P.placeManifestCreds(inst, { github_code: 'C', attempts: 1 }, deps);
|
|
1894
|
+
assert.equal(r.ok, false);
|
|
1895
|
+
assert.match(r.error, /unusable creds/);
|
|
1896
|
+
assert.equal(r.codeSpent, true, 'the conversion RESOLVED — GitHub burned the code even though the creds are junk');
|
|
1897
|
+
});
|
|
1898
|
+
|
|
1633
1899
|
console.log(`\nprovision: ${passed} passed, ${failed} failed`);
|
|
1634
1900
|
process.exit(failed ? 1 : 0);
|
|
@@ -189,6 +189,46 @@ await ta('same domain again is idempotent — 200, changed:false, nothing re-que
|
|
|
189
189
|
assert.equal(enqueueCalls.length, 0);
|
|
190
190
|
});
|
|
191
191
|
|
|
192
|
+
await ta('same domain again RE-ENQUEUES when the wiring stopped — the owner re-kick (task 1002720 / F19)', async () => {
|
|
193
|
+
// A retired domain-attach intent left its error_note; re-sending the same
|
|
194
|
+
// address is the owner's re-kick. The idempotent no-op above still holds for
|
|
195
|
+
// a healthy instance — this branch needs the demonstrated failure.
|
|
196
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'spike.cloudbongos.com', error_note: 'DNS preflight failed' } });
|
|
197
|
+
const res = await post('/provisioning/instances/18/domain', { domain: 'spike.cloudbongos.com' });
|
|
198
|
+
assert.equal(res.status, 200, res.raw);
|
|
199
|
+
assert.equal(res.body.changed, false);
|
|
200
|
+
assert.equal(res.body.queued, true, 'the attach intent re-queues');
|
|
201
|
+
assert.deepEqual(enqueueCalls.map((c) => c.action), ['domain-attach']);
|
|
202
|
+
assert.equal(updateCalls.length, 0, 'the domain itself is untouched');
|
|
203
|
+
assert.match(res.body.message, /Wiring that address again/);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
await ta('a verified-DOWN address re-enqueues too; an open intent keeps the no-op', async () => {
|
|
207
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'spike.cloudbongos.com', last_verified_up: false } });
|
|
208
|
+
const res = await post('/provisioning/instances/18/domain', { domain: 'spike.cloudbongos.com' });
|
|
209
|
+
assert.equal(res.body.queued, true, 'probed-down wiring may be re-kicked');
|
|
210
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'spike.cloudbongos.com', error_note: 'x' } });
|
|
211
|
+
const savedGetOpen = provisioning.getOpenIntent;
|
|
212
|
+
provisioning.getOpenIntent = async () => ({ id: 5, action: 'domain-attach' });
|
|
213
|
+
try {
|
|
214
|
+
const res2 = await post('/provisioning/instances/18/domain', { domain: 'spike.cloudbongos.com' });
|
|
215
|
+
assert.equal(res2.body.queued, false, 'an in-flight intent is never doubled');
|
|
216
|
+
assert.equal(enqueueCalls.length, 0);
|
|
217
|
+
} finally { provisioning.getOpenIntent = savedGetOpen; }
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await ta('re-kick honors enqueueIntent created:false — a racing enqueue is never double-reported', async () => {
|
|
221
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'spike.cloudbongos.com', error_note: 'x' } });
|
|
222
|
+
const savedEnqueue = provisioning.enqueueIntent;
|
|
223
|
+
provisioning.enqueueIntent = async (...a) => { enqueueCalls.push({ action: a[2] }); return { intent: { id: 5 }, created: false }; };
|
|
224
|
+
try {
|
|
225
|
+
const res = await post('/provisioning/instances/18/domain', { domain: 'spike.cloudbongos.com' });
|
|
226
|
+
assert.equal(res.status, 200, res.raw);
|
|
227
|
+
assert.equal(res.body.queued, false, 'queued mirrors created, like the sibling attach path');
|
|
228
|
+
assert.match(res.body.message, /already being wired/);
|
|
229
|
+
} finally { provisioning.enqueueIntent = savedEnqueue; }
|
|
230
|
+
});
|
|
231
|
+
|
|
192
232
|
await ta('a DIFFERENT domain already set is a truthful 409 domain_already_set (re-pointing is not this route)', async () => {
|
|
193
233
|
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'old.cloudbongos.com' } });
|
|
194
234
|
const res = await post('/provisioning/instances/18/domain', { domain: 'new.cloudbongos.com' });
|
|
@@ -117,6 +117,39 @@ test('the empty state points a new owner at the wizard view — never at a hall'
|
|
|
117
117
|
assert.doesNotMatch(body, /Enter the Hall/i);
|
|
118
118
|
});
|
|
119
119
|
|
|
120
|
+
test('teardown copy states what the runner actually does, per hosting shape (F17)', async () => {
|
|
121
|
+
const { body } = await request('/projects', 'game.test.local');
|
|
122
|
+
// the DECIDED policy: shared shapes keep the database; a dedicated machine
|
|
123
|
+
// takes its database with it — the copy branches on the shape field
|
|
124
|
+
assert.match(body, /The database is kept/, 'the kept-shape promise is present');
|
|
125
|
+
assert.match(body, /database goes with (it|the machine)/, 'the dedicated branch exists');
|
|
126
|
+
assert.match(body, /hosting_shape === 'dedicated'/, 'shape-driven, not flat prose');
|
|
127
|
+
assert.match(body, /repository is untouched|repository stays yours|repository is still yours/i);
|
|
128
|
+
// the pre-walk over-claim can never come back
|
|
129
|
+
assert.doesNotMatch(body, /server and database go away/i);
|
|
130
|
+
assert.doesNotMatch(body, /database (is|will be) (deleted|dropped|destroyed|removed)/i);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('the done panel reads the open intent — a queued revive is never told to start another (F22)', async () => {
|
|
134
|
+
const { body } = await request('/projects', 'game.test.local');
|
|
135
|
+
const fn = (body.match(/function renderProgress\(inst\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
136
|
+
assert.ok(fn, 'renderProgress() must be extractable');
|
|
137
|
+
assert.match(fn, /open_intent/, 'the strip is chosen from the open intent, not status alone');
|
|
138
|
+
assert.match(fn, /revive/, 'a queued revive on a torn-down instance gets the revive vocabulary');
|
|
139
|
+
// a requested instance ALWAYS has its provision intent open — the status
|
|
140
|
+
// branch must win there or it goes dead and diverges from the mine card
|
|
141
|
+
assert.match(fn, /!== 'requested'/, 'requested speaks its status, not its (always-present) intent');
|
|
142
|
+
assert.match(body, /inst\.open_intent = data\.open_intent \|\| null/, 'the watch hands the intent through');
|
|
143
|
+
// teardown speaks its own stage names in the done panel, in its own palette
|
|
144
|
+
assert.match(fn, /'teardown'/, 'the teardown flow is used');
|
|
145
|
+
assert.match(body, /\.prog\.flowDown/, 'the .prog strip has the teardown palette (transcribed from .strip.flowDown)');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('the address row surfaces the manual-DNS note the runner recorded (F16)', async () => {
|
|
149
|
+
const { body } = await request('/projects', 'game.test.local');
|
|
150
|
+
assert.match(body, /inst\.dns_note/, 'the card reads the dns_note the runner writes');
|
|
151
|
+
});
|
|
152
|
+
|
|
120
153
|
test('the watch give-up hands the owner their projects view — "the hall has it" is dead', async () => {
|
|
121
154
|
// the wizard (and its 33-minute watch cap) lives on /projects now
|
|
122
155
|
const { body } = await request('/projects', 'game.test.local');
|