@bongos/core 1.19.773 → 1.19.774

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.
Files changed (90) hide show
  1. package/.bongos-core.json +189 -74
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +5 -1
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0083-modular-architecture-strangler-build-plan.md +1 -0
  8. package/docs/adr/0177-goal-membership-kind.md +26 -2
  9. package/docs/adr/0181-teardown-state-gate-and-address-retention.md +2 -0
  10. package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +1 -1
  11. package/docs/adr/0288-the-goal-wall-has-a-shared-floor-for-paths-no-module-owns.md +33 -0
  12. package/docs/adr/0289-an-unconsumed-contribution-is-a-ci-failure-not-a-boot-refusal.md +33 -0
  13. package/docs/adr/0290-disconnect-is-the-owner-initiated-exception-to-address-retention.md +40 -0
  14. package/docs/adr/README.md +3 -0
  15. package/docs/api/openapi.json +102 -11
  16. package/docs/api-reference.md +5 -4
  17. package/docs/copy-inventory.md +205 -190
  18. package/docs/copy-registry.json +435 -269
  19. package/docs/file-map.md +1 -0
  20. package/docs/module-api-changelog.md +2 -0
  21. package/docs/modules-contract.md +1 -1
  22. package/modules/builder-settings/render-prefs.js +281 -33
  23. package/modules/builder-settings/routes/settings.js +20 -12
  24. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  25. package/modules/dev-box/module.json +4 -0
  26. package/modules/hall-ui/public/settings-render.js +138 -0
  27. package/modules/hall-ui/public/settings.html +16 -9
  28. package/modules/hall-ui/public/settings.js +3 -70
  29. package/modules/lifecycle/ship-card.js +49 -13
  30. package/modules/platform-identity/migrations/platform_identity_021_drop_legacy_role.sql +94 -0
  31. package/modules/platform-identity/platform-identity.js +8 -19
  32. package/modules/platform-identity/project-catalog.js +24 -0
  33. package/modules/platform-identity/routes/sso.js +11 -14
  34. package/modules/platform-identity/tests/platform-identity.mjs +7 -9
  35. package/modules/provisioning/capacity-gate.js +54 -0
  36. package/modules/provisioning/catalog-bridge.js +2 -0
  37. package/modules/provisioning/disconnect.js +35 -0
  38. package/modules/provisioning/migrations/provisioning_016_disconnect.sql +21 -0
  39. package/modules/provisioning/module.json +1 -1
  40. package/modules/provisioning/provisioning.js +3 -3
  41. package/modules/provisioning/routes/disconnect.js +63 -0
  42. package/modules/provisioning/routes/provisioning.js +6 -6
  43. package/modules/public-landing/public/projects.html +116 -11
  44. package/modules/public-landing/public/projects.probes.json +3 -0
  45. package/modules/ui-design/kit/serve.js +3 -2
  46. package/modules/ui-design/module.json +1 -0
  47. package/package-lock.json +2 -2
  48. package/package.json +1 -1
  49. package/scripts/gds/address-park-sweep.sh +78 -0
  50. package/scripts/gds/cli-lib.js +37 -0
  51. package/scripts/gds/fitness-migration-contraction.js +50 -0
  52. package/scripts/gds/fitness.js +18 -0
  53. package/scripts/gds/land-watch.js +8 -2
  54. package/scripts/gds/provision-config.js +4 -4
  55. package/scripts/gds/provision-disconnect.js +90 -0
  56. package/scripts/gds/provision-teardown.js +108 -0
  57. package/scripts/gds/provision.js +6 -65
  58. package/scripts/gds/seam-wiring-guard.js +273 -0
  59. package/scripts/gds/ship-finish.js +17 -10
  60. package/scripts/gds/ship-flow.js +5 -5
  61. package/scripts/gds/start.js +13 -7
  62. package/src/bongos/auth-admission.js +7 -8
  63. package/src/bongos/auth-config.js +3 -2
  64. package/src/bongos/goal-scope-check.js +23 -8
  65. package/src/bongos/module-scope-map.js +40 -0
  66. package/src/module-api.js +1 -1
  67. package/src/module-loader/manifest-schema.js +79 -1
  68. package/tests/catalog_only_client.mjs +8 -5
  69. package/tests/fitness.mjs +22 -0
  70. package/tests/goal_scope_check.mjs +63 -1
  71. package/tests/hall_settings_world.mjs +57 -1
  72. package/tests/hub_disconnect_ui.mjs +105 -0
  73. package/tests/land_watch.mjs +44 -0
  74. package/tests/membership_kind_lockstep.mjs +86 -27
  75. package/tests/module-scope-map.mjs +39 -0
  76. package/tests/module_exports_unique_keys.mjs +70 -0
  77. package/tests/module_manifest.mjs +85 -0
  78. package/tests/project_catalog_forget.mjs +83 -0
  79. package/tests/projects_hub.mjs +10 -4
  80. package/tests/projects_hub_pre_uat.mjs +5 -3
  81. package/tests/provision_disconnect.mjs +231 -0
  82. package/tests/provision_teardown_address.mjs +230 -0
  83. package/tests/provisioning_capacity_gate.mjs +192 -0
  84. package/tests/provisioning_disconnect.mjs +231 -0
  85. package/tests/provisioning_restart.mjs +5 -5
  86. package/tests/public_landing_projects.mjs +4 -3
  87. package/tests/render_prefs.mjs +152 -0
  88. package/tests/seam_wiring_guard.mjs +234 -0
  89. package/tests/ship_card.mjs +67 -1
  90. package/tests/sso_checkin_legacy_field_gone.mjs +246 -0
@@ -63,7 +63,8 @@ test('addressRow branches by state before it trusts the domain — torn down and
63
63
  const stateFirst = body.indexOf("inst.status === 'torn_down' || inst.status === 'tearing_down'");
64
64
  const domainTest = body.indexOf('if (inst.domain)');
65
65
  assert.ok(stateFirst >= 0 && stateFirst < domainTest, 'the torn-down test comes before the domain test');
66
- assert.match(body, /stays reserved for this project — it shows an error page until you bring it back/, 'torn down: the address is reserved, not answering');
66
+ assert.match(body, /stays reserved for this project — nothing answers there until you bring it back/, 'torn down: the address is reserved, and teardown parked it (task 1002897)');
67
+ assert.doesNotMatch(body, /shows an error page/, 'the pre-park promise is gone — teardown removes the proxy block that served it');
67
68
  assert.match(body, /if \(inst\.status === 'error'\) return 'Address <code>/, 'snagged has its own line');
68
69
  assert.match(body, /nothing is answering there yet/);
69
70
  assert.equal((body.match(/— it answers there once the instance is live\.'/g) || []).length, 1, 'the standup promise survives only for queued / standing-up');
@@ -428,13 +429,14 @@ test('the minute tick keeps a half-typed domain typed, and its receipt on the pa
428
429
  });
429
430
 
430
431
  /* ── the owner's five calls of 2026-08-24 (task 1003250) ── */
431
- test('decision 6 — the manage section is "Take it offline", and the teardown truth ends by saying nothing erases', () => {
432
+ test('decision 6 — the manage section is "Take it offline", and the teardown truth ends by saying taking it offline erases nothing', () => {
432
433
  const del = (HUB.match(/<div class="card mSec" id="mDelete">[\s\S]*?<\/details>\s*<\/div>/) || [''])[0];
433
434
  assert.ok(del, '#mDelete exists');
434
435
  assert.match(del, /<h2[^>]*>Take it offline<\/h2>/, 'the heading says what the control does');
435
436
  assert.doesNotMatch(HUB, /Delete the project/, 'the word "Delete" no longer promises an erase anywhere on the page');
436
437
  const truth = fn('teardownTruth');
437
- assert.equal((truth.match(/until you bring it back\. Nothing here erases a project\.'/g) || []).length, 2, 'both hosting shapes end on the same sentence');
438
+ // scoped to THIS control since task 1003249: the page now also carries the true delete
439
+ assert.equal((truth.match(/until you bring it back\. Taking it offline erases nothing\.'/g) || []).length, 2, 'both hosting shapes end on the same sentence');
438
440
  });
439
441
 
440
442
  test('decision 5 — the Type step says where the type can be changed (task 1003504)', () => {
@@ -0,0 +1,231 @@
1
+ // tests/provision_disconnect.mjs
2
+ //
3
+ // "Disconnect from Bongos entirely" — the runner leg (task 1003249, ADR 0290).
4
+ //
5
+ // The web tier only queues a 'disconnect' intent (tests/provisioning_disconnect.mjs). This is
6
+ // where the project actually goes: the teardown legs first (units, Caddy block, parked DNS,
7
+ // deploy key), then the erase a teardown deliberately never does — the database, its role, its
8
+ // nightly dumps, its env file, state dir, checkout and unix account — then its catalog,
9
+ // membership and client rows through the `project.catalog` port, and LAST the provisioning row,
10
+ // which releases the name, the port and the address.
11
+ //
12
+ // What these tests pin:
13
+ // • ORDER: nothing is erased until the teardown ran; the row is deleted only after every
14
+ // erase and the catalog forget succeeded, so a failure leaves a row the runner can retry
15
+ // • the dump erase matches the nightly filename EXACTLY, so `test` never erases `test-nk`
16
+ // • each shape erases where its data lives (standalone: control plane; co-tenant: the
17
+ // co-hosting box; dedicated: nothing — its droplet went whole)
18
+ // • a dry run erases nothing and deletes nothing; an invalid slug is refused before any command
19
+ //
20
+ // Hermetic: recorded executors, a fake db, a fake catalog port. No shell, no Postgres.
21
+ //
22
+ // Run: node tests/provision_disconnect.mjs
23
+
24
+ import { strict as assert } from 'node:assert';
25
+ import { createRequire } from 'node:module';
26
+
27
+ const require = createRequire(import.meta.url);
28
+ const P = require('../scripts/gds/provision.js');
29
+ const D = require('../scripts/gds/provision-disconnect.js');
30
+
31
+ let passed = 0, failed = 0;
32
+ function t(name, fn) {
33
+ try { fn(); passed++; console.log(` PASS ${name}`); }
34
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
35
+ }
36
+ async function ta(name, fn) {
37
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
38
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
39
+ }
40
+
41
+ const lantern = { id: 40, slug: 'lantern', hosting_shape: 'standalone', status: 'torn_down', owner_builder_id: 7, port: 3021, db_name: 'lantern', domain: 'lantern.cloudbongos.com', target_ref: null };
42
+
43
+ function rig({ apply = true, privileged = false, failOn = null, catalogThrows = false, catalog = true } = {}) {
44
+ const events = [], logs = [];
45
+ const record = (where) => (cmd, opts) => {
46
+ events.push({ where, cmd, env: opts && opts.env });
47
+ if (failOn && failOn.test(cmd)) throw new Error(`Command failed: ${cmd}`);
48
+ return { ok: true, stdout: '' };
49
+ };
50
+ const deps = {
51
+ apply, privileged, log: (m) => logs.push(String(m)),
52
+ db: {
53
+ query: async (sql, params) => {
54
+ if (/SELECT github_id FROM builders/.test(sql)) return { rows: [{ github_id: 4242 }] };
55
+ if (/DELETE FROM provisioning_instances/.test(sql)) { events.push({ where: 'db', cmd: 'delete-row', params }); return { rowCount: 1, rows: [] }; }
56
+ return { rows: [] };
57
+ },
58
+ },
59
+ provisioning: {
60
+ setInstanceStatus: async (_db, _id, status) => { events.push({ where: 'status', cmd: status }); },
61
+ recordEvent: async () => {},
62
+ },
63
+ exec: record('box'), controlExec: record('control'),
64
+ writeFile: () => ({ ok: true }), controlWriteFile: () => ({ ok: true }),
65
+ doApi: { deleteDroplet: async (id) => { events.push({ where: 'do', cmd: `delete-droplet ${id}` }); } },
66
+ projectCatalog: catalog ? {
67
+ forgetOwnedProject: async (target, opts) => {
68
+ events.push({ where: 'catalog', cmd: 'forget', target, pool: opts && opts.pool });
69
+ if (catalogThrows) throw new Error('catalog down');
70
+ return { project: 1, client: 1, memberships: 2 };
71
+ },
72
+ } : null,
73
+ };
74
+ const at = (pred) => events.findIndex(pred);
75
+ const cmds = (where) => events.filter((e) => e.where === where).map((e) => e.cmd);
76
+ return { deps, events, logs, at, cmds };
77
+ }
78
+
79
+ const saved = { hook: P.CONFIG.dnsHook };
80
+ P.CONFIG.dnsHook = '/opt/infra/box-dns-cloudflare.sh';
81
+
82
+ console.log('the erase, in order:');
83
+
84
+ await ta('standalone: teardown legs, then the database, its role, the dumps, env + state, checkout, account — then forget, then the row', async () => {
85
+ const r = rig();
86
+ const res = await D.disconnectInstance(lantern, r.deps);
87
+ assert.equal(res.ok, true, JSON.stringify(res));
88
+ const c = r.cmds('control');
89
+ const idx = (re) => r.at((e) => e.where === 'control' && re.test(e.cmd));
90
+ const order = [
91
+ /systemctl disable --now lantern$/,
92
+ /rm -f \/etc\/caddy\/sites\/lantern\.caddy/,
93
+ /^dropdb --if-exists --force lantern$/,
94
+ /^psql -v ON_ERROR_STOP=1 -d postgres -c 'DROP ROLE IF EXISTS "bongos-lantern"'$/,
95
+ /^find \/var\/backups\/gds -maxdepth 1 -type f -name 'lantern-\[0-9\]/,
96
+ /^rm -rf \/etc\/lantern \/var\/lib\/bongos-lantern$/,
97
+ /^rm -rf \/srv\/cloudbongos\/lantern$/,
98
+ /userdel bongos-lantern/,
99
+ ];
100
+ const positions = order.map((re) => idx(re));
101
+ positions.forEach((p, i) => assert.ok(p >= 0, `missing ${order[i]} in:\n${c.join('\n')}`));
102
+ for (let i = 1; i < positions.length; i++) assert.ok(positions[i - 1] < positions[i], `${order[i - 1]} before ${order[i]}`);
103
+ const forget = r.at((e) => e.cmd === 'forget');
104
+ const del = r.at((e) => e.cmd === 'delete-row');
105
+ assert.ok(positions[positions.length - 1] < forget && forget < del, 'erase, then forget, then the row — last');
106
+ assert.deepEqual(r.events[forget].target, { clientId: 'lantern', origin: 'https://lantern.cloudbongos.com', ownerGithubId: 4242 });
107
+ assert.equal(r.events[forget].pool, r.deps.db, 'the runner\'s own pool, not the web tier\'s');
108
+ assert.deepEqual(r.events[del].params, [40]);
109
+ assert.equal(r.cmds('box').length, 0, 'a standalone erases where it ran — the control plane');
110
+ });
111
+
112
+ await ta('privileged: postgres commands run as postgres, filesystem and account removal are sudo', async () => {
113
+ const r = rig({ privileged: true });
114
+ await D.disconnectInstance(lantern, r.deps);
115
+ const c = r.cmds('control');
116
+ assert.ok(c.includes('sudo -u postgres dropdb --if-exists --force lantern'), c.join('\n'));
117
+ assert.ok(c.some((x) => /^sudo -u postgres psql .*DROP ROLE IF EXISTS "bongos-lantern"/.test(x)));
118
+ assert.ok(c.some((x) => /^sudo find \/var\/backups\/gds /.test(x)));
119
+ assert.ok(c.includes('sudo rm -rf /etc/lantern /var/lib/bongos-lantern'));
120
+ assert.ok(c.includes('sudo rm -rf /srv/cloudbongos/lantern'));
121
+ assert.ok(c.some((x) => /sudo userdel bongos-lantern/.test(x)));
122
+ });
123
+
124
+ await ta('co-tenant: the erase runs on the co-hosting box, there is no per-project checkout to remove, DNS stays on the control plane', async () => {
125
+ const r = rig();
126
+ await D.disconnectInstance({ ...lantern, hosting_shape: 'co-tenant' }, r.deps);
127
+ const box = r.cmds('box');
128
+ assert.ok(box.includes('dropdb --if-exists --force lantern'), box.join('\n'));
129
+ assert.ok(!box.some((x) => /\/srv\/cloudbongos/.test(x)), 'a co-tenant runs from the shared checkout — never rm it');
130
+ assert.ok(!r.cmds('control').some((x) => /dropdb/.test(x)));
131
+ });
132
+
133
+ await ta('dedicated: its droplet went whole, so nothing is erased by command — the catalog and the row still go', async () => {
134
+ const r = rig();
135
+ const res = await D.disconnectInstance({ ...lantern, hosting_shape: 'dedicated', droplet_id: 9 }, r.deps);
136
+ assert.equal(res.ok, true);
137
+ assert.ok(!r.events.some((e) => /dropdb|rm -rf|userdel|find /.test(e.cmd || '')), r.events.map((e) => e.cmd).join('\n'));
138
+ assert.ok(r.at((e) => e.cmd === 'forget') >= 0 && r.at((e) => e.cmd === 'delete-row') >= 0);
139
+ });
140
+
141
+ console.log('\nthe dump erase is exact:');
142
+
143
+ t('the nightly filename matches, and a project whose name merely STARTS with this one\'s does not', () => {
144
+ const glob = D.dumpGlob({ slug: 'test', db_name: 'test' });
145
+ const re = new RegExp(`^${glob.replace(/\./g, '\\.').replace(/\[0-9\]/g, '[0-9]')}$`);
146
+ assert.ok(re.test('test-2026-09-16T031500Z.sql.gz'), 'the name db-backup-nightly.sh writes');
147
+ assert.ok(!re.test('test-nk-2026-09-16T031500Z.sql.gz'), 'another project\'s dump');
148
+ assert.ok(!re.test('test-2-2026-09-16T031500Z.sql.gz'), 'a project named test-2');
149
+ assert.ok(!re.test('test-2026-09-16T031500Z.sql'), 'not a finished dump');
150
+ });
151
+
152
+ console.log('\nfailure leaves a row to retry:');
153
+
154
+ await ta('a failed database drop returns a retryable failure: no forget, and the row is NOT deleted', async () => {
155
+ const r = rig({ failOn: /dropdb/ });
156
+ const res = await D.disconnectInstance(lantern, r.deps);
157
+ assert.equal(res.ok, false);
158
+ assert.match(res.error, /dropdb|database/);
159
+ assert.equal(r.at((e) => e.cmd === 'forget'), -1);
160
+ assert.equal(r.at((e) => e.cmd === 'delete-row'), -1);
161
+ });
162
+
163
+ await ta('a teardown that throws erases nothing, forgets nothing and keeps the row', async () => {
164
+ const r = rig();
165
+ r.deps.provisioning.setInstanceStatus = async () => { throw new Error('db down'); };
166
+ await assert.rejects(D.disconnectInstance(lantern, r.deps), /db down/);
167
+ assert.equal(r.at((e) => /dropdb|DROP ROLE|userdel|rm -rf/.test(e.cmd)), -1, r.events.map((e) => e.cmd).join(' | '));
168
+ assert.equal(r.at((e) => e.cmd === 'forget'), -1);
169
+ assert.equal(r.at((e) => e.cmd === 'delete-row'), -1);
170
+ });
171
+
172
+ await ta('a failed catalog forget also keeps the row — deleting it would orphan the map entry for good', async () => {
173
+ const r = rig({ catalogThrows: true });
174
+ const res = await D.disconnectInstance(lantern, r.deps);
175
+ assert.equal(res.ok, false);
176
+ assert.equal(r.at((e) => e.cmd === 'delete-row'), -1);
177
+ });
178
+
179
+ await ta('no catalog on this control plane (platform-identity off): nothing to forget, the row still goes', async () => {
180
+ const r = rig({ catalog: false });
181
+ const res = await D.disconnectInstance(lantern, r.deps);
182
+ assert.equal(res.ok, true);
183
+ assert.ok(r.at((e) => e.cmd === 'delete-row') >= 0);
184
+ });
185
+
186
+ await ta('no address: never catalogued, so nothing is forgotten', async () => {
187
+ const r = rig();
188
+ await D.disconnectInstance({ ...lantern, domain: null }, r.deps);
189
+ assert.equal(r.at((e) => e.cmd === 'forget'), -1);
190
+ assert.ok(r.at((e) => e.cmd === 'delete-row') >= 0);
191
+ });
192
+
193
+ console.log('\nguards:');
194
+
195
+ await ta('a dry run plans the erase and deletes nothing', async () => {
196
+ const r = rig({ apply: false });
197
+ const res = await D.disconnectInstance(lantern, r.deps);
198
+ assert.equal(res.ok, true);
199
+ assert.equal(r.at((e) => e.cmd === 'forget'), -1);
200
+ assert.equal(r.at((e) => e.cmd === 'delete-row'), -1);
201
+ assert.ok(r.logs.some((l) => /would delete the provisioning row/.test(l)), r.logs.join('\n'));
202
+ });
203
+
204
+ await ta('an invalid slug is refused before a single command runs — no rm -rf is ever built from it', async () => {
205
+ for (const slug of ['', '..', 'a/b', 'Lantern', '-x']) {
206
+ const r = rig();
207
+ const res = await D.disconnectInstance({ ...lantern, slug }, r.deps);
208
+ assert.equal(res.ok, false, slug);
209
+ assert.equal(r.events.length, 0, `${JSON.stringify(slug)} ran: ${r.events.map((e) => e.cmd).join(' | ')}`);
210
+ }
211
+ });
212
+
213
+ await ta('the drain loop dispatches a disconnect intent to this leg', async () => {
214
+ const r = rig();
215
+ let claims = 0, resolved = null;
216
+ r.deps.provisioning = {
217
+ ...r.deps.provisioning,
218
+ claimNextIntent: async () => (claims++ === 0 ? { id: 3, instance_id: 40, action: 'disconnect', attempts: 1 } : null),
219
+ getInstanceById: async () => ({ ...lantern }),
220
+ resolveIntent: async (_db, id, state) => { resolved = [id, state]; },
221
+ claimNextOAuthManifest: async () => null,
222
+ };
223
+ const out = await P.cmdRunIntents(r.deps);
224
+ assert.equal(out.ok, 1, JSON.stringify(out));
225
+ assert.deepEqual(resolved, [3, 'done'], 'resolving the (now cascaded) intent is a harmless no-op');
226
+ assert.ok(r.at((e) => e.cmd === 'delete-row') >= 0);
227
+ });
228
+
229
+ P.CONFIG.dnsHook = saved.hook;
230
+ console.log(`\nprovision_disconnect: ${passed} passed, ${failed} failed`);
231
+ process.exit(failed ? 1 : 0);
@@ -0,0 +1,230 @@
1
+ // tests/provision_teardown_address.mjs
2
+ //
3
+ // Teardown reclaims the address itself (task 1002897, ADR 0181 §2, ADR 0200 §2).
4
+ //
5
+ // Before this, a torn-down project's hostname kept resolving to the platform and its
6
+ // Caddy block kept proxying it to a port nothing listened on: every visitor got a bare
7
+ // 502, and teardown only LOGGED "remove the Caddy block" for an operator. Live on
8
+ // 2026-09-16, the five projects retired under task 1003946 all answered 502.
9
+ //
10
+ // What these tests pin: the teardown run removes the instance's <slug>.caddy snippet and
11
+ // reloads Caddy on the box the project ran on, and parks the row's hostname at the
12
+ // RFC 5737 blackhole through the same release env a re-point uses, from the control
13
+ // plane. Both legs are best-effort — a hook failure never strands the row. The row keeps
14
+ // its domain, so a revive stands the project back up at the same hostname. A platform-
15
+ // reserved hostname is never parked. The database and dumps stay operator-only.
16
+ //
17
+ // Hermetic: recorded executors, a fake db, no shell, no network.
18
+ //
19
+ // Run: node tests/provision_teardown_address.mjs
20
+
21
+ import { strict as assert } from 'node:assert';
22
+ import { createRequire } from 'node:module';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const P = require('../scripts/gds/provision.js');
26
+ const apexMod = require('../scripts/gds/instance-apex.js');
27
+
28
+ let passed = 0, failed = 0;
29
+ async function ta(name, fn) {
30
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
31
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
32
+ }
33
+
34
+ const HOOK = '/opt/infra/box-dns-cloudflare.sh';
35
+ const lantern = { id: 5, slug: 'lantern', hosting_shape: 'standalone', status: 'active', owner_builder_id: 7, port: 3021, db_name: 'lantern', domain: 'lantern.cloudbongos.com', target_ref: null };
36
+
37
+ // One ordered ledger across both executors and the status writes, so ordering is assertable.
38
+ function rig({ apply = true, privileged = false, hookThrows = false } = {}) {
39
+ const events = [], logs = [];
40
+ const deps = {
41
+ apply, privileged, log: (m) => logs.push(String(m)),
42
+ db: { query: async () => ({ rows: [] }) },
43
+ provisioning: {
44
+ setInstanceStatus: async (_db, _id, status, extra) => { events.push({ where: 'status', status, extra }); },
45
+ recordEvent: async () => {},
46
+ },
47
+ exec: (cmd, opts) => { events.push({ where: 'box', cmd, env: opts && opts.env }); return { ok: true, stdout: '' }; },
48
+ controlExec: (cmd, opts) => {
49
+ events.push({ where: 'control', cmd, env: opts && opts.env });
50
+ if (hookThrows && cmd === HOOK) throw new Error('zone not reachable');
51
+ return { ok: true, stdout: '' };
52
+ },
53
+ writeFile: () => ({ ok: true }), controlWriteFile: () => ({ ok: true }),
54
+ doApi: { deleteDroplet: async (id) => { events.push({ where: 'do', cmd: `delete ${id}` }); } },
55
+ };
56
+ const on = (where) => events.filter((e) => e.where === where).map((e) => e.cmd);
57
+ const hookCalls = () => events.filter((e) => e.cmd === HOOK);
58
+ const at = (pred) => events.findIndex(pred);
59
+ return { deps, events, logs, on, hookCalls, at };
60
+ }
61
+
62
+ async function withHook(hook, fn) {
63
+ const saved = P.CONFIG.dnsHook;
64
+ P.CONFIG.dnsHook = hook;
65
+ try { return await fn(); } finally { P.CONFIG.dnsHook = saved; }
66
+ }
67
+
68
+ console.log('the Caddy leg:');
69
+
70
+ await ta('standalone: the <slug>.caddy snippet is removed, THEN Caddy reloads — on the control plane, where it ran', async () => {
71
+ await withHook(HOOK, async () => {
72
+ const r = rig();
73
+ await P.teardownInstance(lantern, r.deps);
74
+ const control = r.on('control');
75
+ const rm = control.findIndex((c) => c === 'rm -f /etc/caddy/sites/lantern.caddy');
76
+ const reload = control.findIndex((c) => c === 'systemctl reload caddy');
77
+ assert.ok(rm >= 0, `snippet removed: ${control.join(' | ')}`);
78
+ assert.ok(reload > rm, 'reload after the rm, so Caddy forgets the site');
79
+ assert.equal(r.on('box').length, 0, 'nothing crosses to the co-hosting box for a standalone');
80
+ });
81
+ });
82
+
83
+ await ta('co-tenant: the snippet comes off the co-hosting box it was written to', async () => {
84
+ await withHook(HOOK, async () => {
85
+ const r = rig();
86
+ await P.teardownInstance({ ...lantern, hosting_shape: 'co-tenant' }, r.deps);
87
+ assert.ok(r.on('box').includes('rm -f /etc/caddy/sites/lantern.caddy'), r.on('box').join(' | '));
88
+ assert.ok(r.on('box').includes('systemctl reload caddy'));
89
+ });
90
+ });
91
+
92
+ await ta('privileged: the rm under /etc and the reload are sudo-prefixed', async () => {
93
+ await withHook(HOOK, async () => {
94
+ const r = rig({ privileged: true });
95
+ await P.teardownInstance(lantern, r.deps);
96
+ assert.ok(r.on('control').includes('sudo rm -f /etc/caddy/sites/lantern.caddy'));
97
+ assert.ok(r.on('control').includes('sudo systemctl reload caddy'));
98
+ });
99
+ });
100
+
101
+ await ta('dedicated: no Caddy steps — the droplet (and its Caddy) is deleted whole', async () => {
102
+ await withHook(HOOK, async () => {
103
+ const r = rig();
104
+ await P.teardownInstance({ ...lantern, hosting_shape: 'dedicated', droplet_id: 9 }, r.deps);
105
+ assert.ok(!r.events.some((e) => /caddy/.test(e.cmd || '')), r.events.map((e) => e.cmd).join(' | '));
106
+ });
107
+ });
108
+
109
+ console.log('\nthe DNS leg:');
110
+
111
+ await ta('the hostname is parked at the blackhole through the release env, from the control plane, before the row reads torn_down', async () => {
112
+ await withHook(HOOK, async () => {
113
+ const r = rig();
114
+ const res = await P.teardownInstance(lantern, r.deps);
115
+ assert.equal(res.ok, true);
116
+ const calls = r.hookCalls();
117
+ assert.equal(calls.length, 1);
118
+ assert.equal(calls[0].where, 'control', 'the Cloudflare token never leaves the control plane');
119
+ assert.deepEqual(calls[0].env, { BOX_HOSTNAME: 'lantern.cloudbongos.com', BOX_IP: '192.0.2.1', CLOUDFLARE_ZONE: 'cloudbongos.com', BOX_PROXIED: '0' });
120
+ assert.ok(r.at((e) => e.cmd === HOOK) < r.at((e) => e.status === 'torn_down'), 'parked before the row says torn_down');
121
+ });
122
+ });
123
+
124
+ await ta('dedicated: its hostname is parked too — the droplet IP it pointed at is gone', async () => {
125
+ await withHook(HOOK, async () => {
126
+ const r = rig();
127
+ await P.teardownInstance({ ...lantern, hosting_shape: 'dedicated', droplet_id: 9 }, r.deps);
128
+ assert.equal(r.hookCalls().length, 1);
129
+ assert.equal(r.hookCalls()[0].where, 'control');
130
+ });
131
+ });
132
+
133
+ await ta('best-effort: a hook that throws is logged and the row still reaches torn_down', async () => {
134
+ await withHook(HOOK, async () => {
135
+ const r = rig({ hookThrows: true });
136
+ const res = await P.teardownInstance(lantern, r.deps);
137
+ assert.equal(res.ok, true);
138
+ assert.equal(r.events.filter((e) => e.where === 'status').pop().status, 'torn_down');
139
+ assert.ok(r.logs.some((l) => /lantern\.cloudbongos\.com/.test(l) && /zone not reachable/.test(l)), r.logs.join('\n'));
140
+ });
141
+ });
142
+
143
+ await ta('no domain: nothing to park, and the snippet is still removed', async () => {
144
+ await withHook(HOOK, async () => {
145
+ const r = rig();
146
+ await P.teardownInstance({ ...lantern, domain: null }, r.deps);
147
+ assert.equal(r.hookCalls().length, 0);
148
+ assert.ok(r.on('control').includes('rm -f /etc/caddy/sites/lantern.caddy'));
149
+ });
150
+ });
151
+
152
+ await ta('no DNS hook configured: no call, and the log names the address to park by hand', async () => {
153
+ await withHook(null, async () => {
154
+ const r = rig();
155
+ await P.teardownInstance(lantern, r.deps);
156
+ assert.equal(r.events.filter((e) => e.env).length, 0);
157
+ assert.ok(r.logs.some((l) => /lantern\.cloudbongos\.com/.test(l) && /BOX_DNS_HOOK unset/.test(l)), r.logs.join('\n'));
158
+ });
159
+ });
160
+
161
+ await ta('dry run: the park is planned in the log, never executed', async () => {
162
+ await withHook(HOOK, async () => {
163
+ const r = rig({ apply: false });
164
+ await P.teardownInstance(lantern, r.deps);
165
+ assert.equal(r.hookCalls().length, 0);
166
+ assert.ok(r.logs.some((l) => /park lantern\.cloudbongos\.com -> 192\.0\.2\.1/.test(l)), r.logs.join('\n'));
167
+ });
168
+ });
169
+
170
+ await ta('a platform-reserved hostname (the apex, www, a deep label) is never parked', async () => {
171
+ const savedApex = apexMod.instanceApex;
172
+ apexMod.instanceApex = () => 'cloudbongos.com';
173
+ try {
174
+ await withHook(HOOK, async () => {
175
+ for (const domain of ['cloudbongos.com', 'www.cloudbongos.com', 'a.b.cloudbongos.com']) {
176
+ const r = rig();
177
+ await P.teardownInstance({ ...lantern, domain }, r.deps);
178
+ assert.equal(r.hookCalls().length, 0, `${domain} must not be blackholed`);
179
+ assert.ok(r.logs.some((l) => l.includes(domain) && /reserved/.test(l)), r.logs.join('\n'));
180
+ }
181
+ const ok = rig();
182
+ await P.teardownInstance(lantern, ok.deps);
183
+ assert.equal(ok.hookCalls().length, 1, 'an ordinary <slug>.<apex> is still parked');
184
+ });
185
+ } finally { apexMod.instanceApex = savedApex; }
186
+ });
187
+
188
+ console.log('\nwhat teardown still leaves, and what a revive gets back:');
189
+
190
+ await ta('the operator line names only the database and the dumps — never the Caddy block', async () => {
191
+ await withHook(HOOK, async () => {
192
+ const r = rig();
193
+ await P.teardownInstance(lantern, r.deps);
194
+ const line = r.logs.find((l) => /left for the operator/.test(l));
195
+ assert.ok(line, r.logs.join('\n'));
196
+ assert.match(line, /dropdb lantern/);
197
+ assert.match(line, /prune .*lantern-\*\.sql\.gz/);
198
+ assert.doesNotMatch(line, /Caddy/i);
199
+ assert.ok(!r.events.some((e) => /dropdb|\.sql\.gz/.test(e.cmd || '')), 'data is never destroyed by a teardown');
200
+ });
201
+ });
202
+
203
+ await ta('the row keeps its domain and port, and a revive re-UPSERTs the SAME hostname to the box and rewrites its snippet', async () => {
204
+ const savedCp = P.CONFIG.controlPlaneIp;
205
+ P.CONFIG.controlPlaneIp = '198.51.100.20';
206
+ try {
207
+ await withHook(HOOK, async () => {
208
+ const r = rig();
209
+ await P.teardownInstance({ ...lantern, target_ref: 'example-owner/lantern' }, r.deps);
210
+ for (const e of r.events.filter((x) => x.where === 'status')) {
211
+ assert.ok(!e.extra || (!('domain' in e.extra) && !('port' in e.extra)), `status write must not release the claim: ${JSON.stringify(e.extra)}`);
212
+ }
213
+ const dns = [], writes = [];
214
+ await P.provisionInstance({ ...lantern, status: 'torn_down', target_ref: 'example-owner/lantern' }, {
215
+ apply: false, log: () => {},
216
+ db: { query: async () => ({ rows: [] }) },
217
+ provisioning: { setInstanceStatus: async () => {}, recordEvent: async () => {} },
218
+ exec: () => ({ ok: true, stdout: '' }),
219
+ controlExec: (cmd, opts) => { if (cmd === HOOK) dns.push(opts.env); return { ok: true, stdout: '' }; },
220
+ writeFile: () => ({ ok: true }), controlWriteFile: (p) => { writes.push(p); return { ok: true }; },
221
+ doApi: null, checkDnsTokenScope: async () => ({ reachable: true, zone: 'cloudbongos.com' }),
222
+ });
223
+ assert.ok(dns.some((env) => env.BOX_HOSTNAME === 'lantern.cloudbongos.com' && env.BOX_IP === '198.51.100.20'), JSON.stringify(dns));
224
+ assert.ok(writes.some((p) => p.endsWith('/lantern.caddy')), writes.join(' | '));
225
+ });
226
+ } finally { P.CONFIG.controlPlaneIp = savedCp; }
227
+ });
228
+
229
+ console.log(`\nprovision_teardown_address: ${passed} passed, ${failed} failed`);
230
+ process.exit(failed ? 1 : 0);