@bongos/core 1.19.399 → 1.19.400

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.
@@ -0,0 +1,242 @@
1
+ // tests/provision_restart.mjs
2
+ //
3
+ // The RUNNER half of the restart path for an unreachable ACTIVE project (task
4
+ // 1003238, goal 1000058): scripts/gds/provision.js restartInstance and the
5
+ // run-intents dispatch that reaches it. Hermetic — exec / db are recording
6
+ // fakes; no shell, no DB, no network (the tests/provision_settings_apply.mjs
7
+ // pattern).
8
+ //
9
+ // The load-bearing assertions:
10
+ // • it is the settings-apply leg MINUS the env patch: one systemctl restart,
11
+ // then the patient healthz verify — and it NEVER reads or writes web.env,
12
+ // because nothing about the instance's configuration is changing
13
+ // • a restart that THROWS returns ok:false so the drain loop re-pends it, and
14
+ // an instance that is merely unreachable is never relabelled 'error' by the
15
+ // very action meant to recover it (ADR 0190 §2)
16
+ // • the healthz budget is the PATIENT one — a false "down" here would tell the
17
+ // owner their restart failed
18
+ // • the probe verdict keeps the settings-apply narrowness: a loopback PASS on a
19
+ // DOMAINED instance is not an up-verdict (the sweep probes the public
20
+ // address), and the event text says which one was actually observed
21
+ // • a project that stays down is ok:true — a truthful observation for the card,
22
+ // not a failure to retry four more times
23
+ // • the state gate is a clean no-op; dedicated is refused with the command
24
+ // • cmdRunIntents routes 'restart' to this leg
25
+ //
26
+ // Run: node tests/provision_restart.mjs
27
+
28
+ import { strict as assert } from 'node:assert';
29
+ import { createRequire } from 'node:module';
30
+
31
+ const require = createRequire(import.meta.url);
32
+ const P = require('../scripts/gds/provision.js');
33
+ const { MANIFEST_VERIFY_TRIES } = require('../scripts/gds/provision-config.js');
34
+
35
+ let passed = 0, failed = 0;
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
+ function recorder({ apply = true, healthzFails = false, privileged = false, restartThrows = false } = {}) {
42
+ const cmds = [], writes = [], events = [], probes = [], cleared = [], logs = [];
43
+ const exec = (cmd) => {
44
+ cmds.push(cmd);
45
+ if (/healthz/.test(cmd)) return healthzFails ? { ok: true, stdout: '', softFailed: true } : { ok: true, stdout: '{"ok":true}' };
46
+ if (/systemctl restart/.test(cmd) && restartThrows && apply) throw new Error(`Command failed: ${cmd}`);
47
+ return { ok: true, stdout: '' };
48
+ };
49
+ const writer = (via) => (p, body, opts) => { writes.push({ via, path: p, body, opts }); return { ok: true }; };
50
+ const deps = {
51
+ apply, privileged, log: (l) => logs.push(String(l)), sleep: async () => {},
52
+ db: { query: async () => ({ rows: [] }) },
53
+ provisioning: {
54
+ recordEvent: async (_db, e) => { events.push(e); },
55
+ recordLivenessProbe: async (_db, id, up) => { probes.push({ id, up }); },
56
+ clearErrorNote: async (_db, id) => { cleared.push(id); },
57
+ },
58
+ exec,
59
+ writeFile: writer('box'), controlWriteFile: writer('control'),
60
+ doApi: null,
61
+ };
62
+ return { deps, cmds, writes, events, probes, cleared, logs };
63
+ }
64
+
65
+ const activeStandalone = {
66
+ id: 18, slug: 'spike', hosting_shape: 'standalone', status: 'active', owner_builder_id: 7,
67
+ port: 3010, db_name: 'spike', domain: 'spike.cloudbongos.com', target_ref: 'jaxri/spike-harbor',
68
+ };
69
+
70
+ console.log('restartInstance — the leg:');
71
+
72
+ await ta('--apply, active standalone: ONE systemctl restart, then healthz — and web.env is never read or written', async () => {
73
+ const { deps, cmds, writes } = recorder();
74
+ const r = await P.restartInstance(activeStandalone, deps);
75
+ assert.equal(r.ok, true, JSON.stringify(r));
76
+ assert.equal(writes.length, 0, 'nothing about the configuration changes, so nothing is written');
77
+ assert.ok(!cmds.some((c) => /\bcat /.test(c)), 'no web.env read — this is not the settings push');
78
+ const restarts = cmds.filter((c) => /systemctl restart spike/.test(c));
79
+ assert.equal(restarts.length, 1, 'exactly one restart');
80
+ const idx = (re) => cmds.findIndex((c) => re.test(c));
81
+ assert.ok(idx(/systemctl restart spike/) < idx(/healthz/), 'healthz comes after the restart');
82
+ assert.ok(!cmds.some((c) => /systemctl (stop|disable)|caddy|box-dns|dropdb/.test(c)), 'no teardown / DNS / caddy legs');
83
+ });
84
+
85
+ await ta('the healthz verify is the PATIENT budget — a false "down" would tell the owner their restart failed', async () => {
86
+ const { deps, cmds } = recorder({ healthzFails: true });
87
+ const r = await P.restartInstance(activeStandalone, deps);
88
+ assert.equal(r.ok, true);
89
+ assert.equal(r.healthy, false);
90
+ assert.equal(cmds.filter((c) => /healthz/.test(c)).length, MANIFEST_VERIFY_TRIES, 'the whole budget is spent before the verdict');
91
+ });
92
+
93
+ await ta('a project that stays down is ok:true — a truthful observation for the card, not a failure to retry four more times', async () => {
94
+ const { deps, events, probes } = recorder({ healthzFails: true });
95
+ const r = await P.restartInstance(activeStandalone, deps);
96
+ assert.equal(r.ok, true, 'restarting a broken instance again fixes nothing and would end by stamping an error_note over the owner’s own action');
97
+ assert.equal(r.healthy, false);
98
+ assert.deepEqual(probes, [{ id: 18, up: false }], 'silence after the whole budget IS a verdict');
99
+ assert.equal(events[0].event, 'restarted');
100
+ assert.match(events[0].detail, /not answering on the box/);
101
+ });
102
+
103
+ await ta('a restart that THROWS returns ok:false — the drain loop re-pends it and the instance keeps its status', async () => {
104
+ const { deps, events, probes } = recorder({ restartThrows: true });
105
+ const r = await P.restartInstance(activeStandalone, deps);
106
+ assert.equal(r.ok, false);
107
+ assert.match(r.error, /could not restart spike/);
108
+ assert.equal(events.length, 0, 'nothing claimed about an attempt that did not run');
109
+ assert.equal(probes.length, 0, 'and no liveness verdict invented from it');
110
+ });
111
+
112
+ await ta('a loopback PASS on a DOMAINED instance is NOT an up-verdict, and the event says which probe actually answered', async () => {
113
+ const { deps, events, probes } = recorder();
114
+ const r = await P.restartInstance(activeStandalone, deps);
115
+ assert.equal(r.healthy, true);
116
+ assert.equal(probes.length, 0, 'the sweep probes the PUBLIC address — loopback proves the process, not the address');
117
+ assert.match(events[0].detail, /answering on the box/, 'names what was observed');
118
+ assert.match(events[0].detail, /public address/, 'and is explicit that the public verdict is still outstanding');
119
+ assert.doesNotMatch(events[0].detail, /answering again/, 'never a claim the card would contradict with "unreachable"');
120
+ });
121
+
122
+ await ta('with NO domain, loopback IS the whole claim — the up-verdict is recorded and the error latch released', async () => {
123
+ const { deps, events, probes, cleared } = recorder();
124
+ const r = await P.restartInstance({ ...activeStandalone, domain: null }, deps);
125
+ assert.equal(r.healthy, true);
126
+ assert.deepEqual(probes, [{ id: 18, up: true }]);
127
+ assert.deepEqual(cleared, [18], 'a restart that worked retires a stale "we stopped trying" note');
128
+ assert.match(events[0].detail, /answering again/);
129
+ });
130
+
131
+ await ta('a DOMAINED instance that answers on loopback still releases the latch — the note asks whether work stopped, not whether the address answers', async () => {
132
+ const { deps, probes, cleared } = recorder();
133
+ const r = await P.restartInstance(activeStandalone, deps);
134
+ assert.equal(r.healthy, true);
135
+ assert.equal(probes.length, 0, 'no up-VERDICT is claimed — that one belongs to the public sweep');
136
+ assert.deepEqual(cleared, [18],
137
+ 'but the F19 latch clears: holding it until the sweep agrees would leave a stale "nothing more will happen" on a project that just recovered');
138
+ });
139
+
140
+ await ta('a failed restart does NOT release the error latch', async () => {
141
+ const { deps, cleared } = recorder({ healthzFails: true });
142
+ await P.restartInstance({ ...activeStandalone, domain: null }, deps);
143
+ assert.equal(cleared.length, 0, 'the note outliving its own recovery is the bug — but so is clearing it without one');
144
+ });
145
+
146
+ await ta('every non-active state is a clean no-op, not an error — a raced teardown never burns the retry budget', async () => {
147
+ for (const status of ['requested', 'provisioning', 'tearing_down', 'torn_down', 'error']) {
148
+ const { deps, cmds } = recorder();
149
+ const r = await P.restartInstance({ ...activeStandalone, status }, deps);
150
+ assert.equal(r.ok, true, status);
151
+ assert.equal(r.noop, true, status);
152
+ assert.equal(cmds.length, 0, `${status} ran no command`);
153
+ }
154
+ });
155
+
156
+ await ta('a dedicated droplet is refused with the exact command, and nothing is run', async () => {
157
+ const { deps, cmds } = recorder();
158
+ const r = await P.restartInstance({ ...activeStandalone, hosting_shape: 'dedicated' }, deps);
159
+ assert.equal(r.ok, false);
160
+ assert.match(r.error, /systemctl restart spike/);
161
+ assert.equal(cmds.length, 0);
162
+ });
163
+
164
+ await ta('dry-run runs no real work and observes nothing', async () => {
165
+ const { deps, writes } = recorder({ apply: false });
166
+ const r = await P.restartInstance(activeStandalone, deps);
167
+ assert.equal(r.ok, true);
168
+ assert.equal(r.healthy, null, 'nothing ran, nothing observed');
169
+ assert.equal(writes.length, 0);
170
+ });
171
+
172
+ await ta('a co-tenant restarts on the co-hosting box; a standalone on the control plane', async () => {
173
+ const seen = {};
174
+ for (const shape of ['co-tenant', 'standalone']) {
175
+ const box = [], control = [];
176
+ const { deps } = recorder();
177
+ deps.exec = (cmd) => { box.push(cmd); return /healthz/.test(cmd) ? { ok: true, stdout: '{}' } : { ok: true, stdout: '' }; };
178
+ deps.controlExec = (cmd) => { control.push(cmd); return /healthz/.test(cmd) ? { ok: true, stdout: '{}' } : { ok: true, stdout: '' }; };
179
+ await P.restartInstance({ ...activeStandalone, hosting_shape: shape }, deps);
180
+ seen[shape] = { box: box.length, control: control.length };
181
+ }
182
+ assert.ok(seen['co-tenant'].box > 0 && seen['co-tenant'].control === 0, 'a co-tenant runs on the box');
183
+ assert.ok(seen.standalone.control > 0 && seen.standalone.box === 0, 'a standalone ran control-plane-local (task 2074) — restart it THERE');
184
+ });
185
+
186
+ await ta('privileged mode escalates the restart', async () => {
187
+ const { deps, cmds } = recorder({ privileged: true });
188
+ await P.restartInstance(activeStandalone, deps);
189
+ assert.ok(cmds.some((c) => /^sudo systemctl restart spike$/.test(c)), cmds.join(' | '));
190
+ });
191
+
192
+ console.log('\ncmdRunIntents — the dispatch:');
193
+
194
+ await ta("cmdRunIntents (--apply) routes 'restart' to restartInstance and resolves it done", async () => {
195
+ let claims = 0; const resolved = []; const statusWrites = []; const cmds = [];
196
+ const deps = {
197
+ apply: true, log: () => {}, sleep: async () => {},
198
+ db: { query: async () => ({ rows: [] }) },
199
+ provisioning: {
200
+ claimNextIntent: async () => (claims++ === 0 ? { id: 12, instance_id: 18, action: 'restart', attempts: 1 } : null),
201
+ getInstanceById: async () => ({ ...activeStandalone }),
202
+ resolveIntent: async (_db, id, state, err) => { resolved.push({ id, state, err }); },
203
+ setInstanceStatus: async (_db, _id, s, patch) => { statusWrites.push({ s, patch }); },
204
+ recordEvent: async () => {}, recordLivenessProbe: async () => {}, clearErrorNote: async () => {},
205
+ claimNextOAuthManifest: async () => null, resolveOAuthManifest: async () => {},
206
+ },
207
+ exec: (cmd) => { cmds.push(cmd); return /healthz/.test(cmd) ? { ok: true, stdout: '{}' } : { ok: true, stdout: '' }; },
208
+ writeFile: () => ({ ok: true }), doApi: null,
209
+ };
210
+ const r = await P.cmdRunIntents(deps);
211
+ assert.equal(r.errors, 0, JSON.stringify(r));
212
+ assert.equal(resolved[0].state, 'done');
213
+ assert.ok(cmds.some((c) => /systemctl restart spike/.test(c)), 'it really reached the restart leg');
214
+ assert.equal(statusWrites.length, 0, 'a restart never touches lifecycle status');
215
+ });
216
+
217
+ await ta('cmdRunIntents (--apply): a restart that throws re-pends and never marks a healthy ACTIVE instance broken', async () => {
218
+ let claims = 0; const resolved = []; const statusWrites = []; const updates = [];
219
+ const deps = {
220
+ apply: true, log: () => {}, sleep: async () => {},
221
+ db: { query: async (sql, params) => { if (/UPDATE provisioning_intents/.test(sql)) updates.push({ sql, params }); return { rows: [] }; } },
222
+ provisioning: {
223
+ claimNextIntent: async () => (claims++ === 0 ? { id: 12, instance_id: 18, action: 'restart', attempts: 1 } : null),
224
+ getInstanceById: async () => ({ ...activeStandalone }),
225
+ resolveIntent: async (_db, id, state, err) => { resolved.push({ id, state, err }); },
226
+ setInstanceStatus: async (_db, _id, s, patch) => { statusWrites.push({ s, patch }); },
227
+ recordEvent: async () => {}, recordLivenessProbe: async () => {}, clearErrorNote: async () => {},
228
+ claimNextOAuthManifest: async () => null, resolveOAuthManifest: async () => {},
229
+ },
230
+ exec: (cmd) => { if (/systemctl restart/.test(cmd)) throw new Error('Command failed: sudo systemctl restart spike'); return { ok: true, stdout: '' }; },
231
+ writeFile: () => ({ ok: true }), doApi: null,
232
+ };
233
+ const r = await P.cmdRunIntents(deps);
234
+ assert.equal(r.errors, 1);
235
+ assert.equal(resolved.length, 0, 'not resolved — it re-pends for a later tick');
236
+ assert.match(updates[0].sql, /state='pending'/);
237
+ assert.match(updates[0].params[1], /could not restart spike/, 'the reason rides last_error');
238
+ assert.equal(statusWrites.length, 0, 'an unreachable instance is not relabelled broken by the action meant to recover it');
239
+ });
240
+
241
+ console.log(`\nprovision_restart: ${passed} passed, ${failed} failed`);
242
+ process.exit(failed ? 1 : 0);
@@ -0,0 +1,285 @@
1
+ // tests/provisioning_restart.mjs
2
+ //
3
+ // The RESTART path for an unreachable ACTIVE project (task 1003238, goal
4
+ // 1000058) — web-tier half. The liveness overlay (task 1002696) can report a
5
+ // project unreachable while its lifecycle status is still 'active', and until
6
+ // this route existed that reading came with no action the owner could take:
7
+ // PROVISION_ENQUEUE_FROM deliberately excludes 'active' (task 1002691), so a
8
+ // Restart wired to the re-provision path would have refused on every press.
9
+ //
10
+ // Covers (the task's done-when, web-tier side):
11
+ // • the STATE GATE — RESTART_FROM is exactly {active}, and the premise it rests
12
+ // on (PROVISION_ENQUEUE_FROM excludes 'active') is asserted, not assumed
13
+ // • the SHAPE REFUSAL — a dedicated droplet is refused honestly and told the
14
+ // command to run, never queued a restart that cannot land (the F16 rule)
15
+ // • the open-slot arbitration — a foreign intent → 409 intent_conflict naming
16
+ // the real blocker; an already-open RESTART is idempotent in either state,
17
+ // which is where it differs from the settings push
18
+ // • a pending restart is superseded by a teardown (the 1002691 class)
19
+ // • every refusal names the action that IS available in that state, and no
20
+ // branch promises a duration
21
+ // • INTENT_ACTIONS <-> migration provisioning_011 lockstep (the strict
22
+ // set-equality check lives with the NEWEST migration)
23
+ // • the archon override is own-scoped and audited (api:archon:<id>)
24
+ //
25
+ // DB-free: data-access fns are stubbed on the cached module object (the
26
+ // provisioning_settings_apply.mjs pattern). No network beyond localhost.
27
+ //
28
+ // Run: node tests/provisioning_restart.mjs
29
+
30
+ import { strict as assert } from 'node:assert';
31
+ import { createRequire } from 'node:module';
32
+ import { readFileSync } from 'node:fs';
33
+ import http from 'node:http';
34
+
35
+ const require = createRequire(import.meta.url);
36
+ const provisioning = require('../modules/provisioning/provisioning.js');
37
+
38
+ let passed = 0, failed = 0;
39
+ function t(name, fn) {
40
+ try { fn(); passed++; console.log(` PASS ${name}`); }
41
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
42
+ }
43
+ async function ta(name, fn) {
44
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
45
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
46
+ }
47
+
48
+ // No wall-clock or duration promise anywhere a message is rendered (F16). A
49
+ // restart's downtime is precisely what the web tier cannot know.
50
+ const PROMISE = /shortly|briefly|minute|second|soon\b/i;
51
+
52
+ console.log('the module slice — constants, the migration, the enqueue classifier:');
53
+
54
+ t("INTENT_ACTIONS includes 'restart', in lockstep with the provisioning_011 CHECK", () => {
55
+ assert.ok(provisioning.INTENT_ACTIONS.includes('restart'));
56
+ const sql = readFileSync(new URL('../modules/provisioning/migrations/provisioning_011_restart.sql', import.meta.url), 'utf8');
57
+ const m = /CHECK \(action IN \(([^)]*)\)\)/.exec(sql);
58
+ assert.ok(m, 'the migration widens the action CHECK');
59
+ const inSql = m[1].split(',').map((s) => s.trim().replace(/^'|'$/g, '')).sort();
60
+ // The NEWEST migration owns the strict set equality — the CHECK a live DB
61
+ // ends up with is the last one applied, so this is the assertion that would
62
+ // catch an action added in code but never admitted by the schema.
63
+ assert.deepEqual(inSql, [...provisioning.INTENT_ACTIONS].sort(), 'the CHECK and INTENT_ACTIONS carry the same set');
64
+ assert.match(sql, /INSERT INTO schema_migrations \(version\) VALUES \('provisioning_011_restart'\)/, 'self-registers');
65
+ });
66
+
67
+ t('RESTART_FROM is exactly {active} — every other state is already served by the provision path', () => {
68
+ assert.deepEqual([...provisioning.RESTART_FROM], ['active']);
69
+ // The whole reason this route exists: the re-provision door cannot cover it.
70
+ assert.ok(!provisioning.PROVISION_ENQUEUE_FROM.has('active'), 'the premise the gate rests on');
71
+ for (const s of ['requested', 'provisioning', 'error', 'torn_down']) {
72
+ assert.ok(provisioning.PROVISION_ENQUEUE_FROM.has(s), `${s} has the re-provision door instead`);
73
+ }
74
+ });
75
+
76
+ t('RESTART_SHAPES is the runner-reachable pair — dedicated is out, and the set is a subset of HOSTING_SHAPES', () => {
77
+ assert.deepEqual([...provisioning.RESTART_SHAPES].sort(), ['co-tenant', 'standalone']);
78
+ assert.ok(!provisioning.RESTART_SHAPES.has('dedicated'));
79
+ for (const s of provisioning.RESTART_SHAPES) assert.ok(provisioning.HOSTING_SHAPES.includes(s), `${s} is a real shape`);
80
+ });
81
+
82
+ await ta('enqueueRestart: a free slot creates the intent', async () => {
83
+ const db = { query: async (_sql, params) => ({ rows: [{ id: 9, action: 'restart', state: 'pending', instance_id: params[0] }] }) };
84
+ const r = await provisioning.enqueueRestart(db, 18, 'api:self');
85
+ assert.equal(r.created, true);
86
+ assert.equal(r.intent.action, 'restart');
87
+ });
88
+
89
+ await ta('enqueueRestart: an already-open restart is idempotent in EITHER state — a restart carries no payload, so one already running IS the ask', async () => {
90
+ for (const state of ['pending', 'running']) {
91
+ const db = { query: async (sql) => {
92
+ if (/INSERT/.test(sql)) { const e = new Error('dup'); e.code = '23505'; throw e; }
93
+ return { rows: [{ id: 5, action: 'restart', state }] };
94
+ } };
95
+ const r = await provisioning.enqueueRestart(db, 18, 'api:self');
96
+ assert.equal(r.created, false, state);
97
+ assert.equal(r.conflict, undefined, `a running restart is not a conflict (unlike a settings push, which may have read the row already) — ${state}`);
98
+ }
99
+ });
100
+
101
+ await ta('enqueueRestart: any OTHER action holding the slot → conflict, naming the real blocker', async () => {
102
+ for (const open of [{ action: 'teardown', state: 'pending' }, { action: 'domain-attach', state: 'running' }, { action: 'settings-apply', state: 'pending' }]) {
103
+ const db = { query: async (sql) => {
104
+ if (/INSERT/.test(sql)) { const e = new Error('dup'); e.code = '23505'; throw e; }
105
+ return { rows: [{ id: 5, ...open }] };
106
+ } };
107
+ const r = await provisioning.enqueueRestart(db, 18, 'api:self');
108
+ assert.equal(r.created, false, JSON.stringify(open));
109
+ assert.equal(r.conflict, 'open_intent', JSON.stringify(open));
110
+ assert.equal(r.intent.action, open.action, 'names the real blocker');
111
+ }
112
+ });
113
+
114
+ await ta('enqueueTeardown supersedes a PENDING restart — the owner reaches for Restart when a project looks broken, which is also when they give up on it', async () => {
115
+ const calls = [];
116
+ const db = { query: async (sql, params) => {
117
+ calls.push(sql);
118
+ if (/UPDATE provisioning_intents/.test(sql)) return { rows: [{ id: 5 }] };
119
+ return { rows: [{ id: 6, action: 'teardown', state: 'pending', instance_id: params[0] }] };
120
+ } };
121
+ const r = await provisioning.enqueueTeardown(db, 18, 'api:self');
122
+ assert.equal(r.superseded, true);
123
+ assert.match(calls[0], /'restart'/, 'restart joined the supersedable list');
124
+ assert.match(calls[0], /state = 'pending'/, 'pending only — a RUNNING intent is never yanked');
125
+ });
126
+
127
+ // ── the route, over a real mounted router (stubbed data access) ─────────────────
128
+ let instance = null;
129
+ let enqueueCalls = [];
130
+ let enqueueResult = null; // what enqueueRestart reports (null → created:true)
131
+ let events = [];
132
+ provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
133
+ provisioning.enqueueRestart = async (_db, id, requestedBy) => {
134
+ enqueueCalls.push({ id, requestedBy });
135
+ return enqueueResult || { intent: { id: 9, action: 'restart', state: 'pending' }, created: true };
136
+ };
137
+ provisioning.recordEvent = async (_db, e) => { events.push(e); };
138
+ provisioning.getOpenIntent = async () => null;
139
+ provisioning.getLatestOAuthManifestForInstance = async () => null;
140
+
141
+ const api = require('../src/module-api.js');
142
+ let builder = { id: 7, github_login: 'owner', rank: 'thetes' };
143
+ api.requireBuilder = (req, _res, next) => { req.builder = builder; next(); };
144
+ api.requirePermission = () => (_req, _res, next) => next();
145
+ api.branding = () => ({ domains: { publicOrigin: 'https://cb.test' }, identity: { productName: 'Cloud Bongos' } });
146
+ api.logger = () => ({ error() {}, warn() {}, info() {}, debug() {} });
147
+
148
+ const express = require('express');
149
+ const provisioningRoutes = require('../modules/provisioning/routes/provisioning.js');
150
+ const app = express();
151
+ app.use(express.json());
152
+ app.use((_req, res, next) => {
153
+ res.fail = (code, opts, details) => {
154
+ const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
155
+ res.status(status).json({ error: { code, ...(typeof opts === 'object' ? opts : {}), ...(details || {}) } });
156
+ };
157
+ next();
158
+ });
159
+ app.use('/api/bongos', provisioningRoutes());
160
+
161
+ const server = app.listen(0);
162
+ await new Promise((r) => server.once('listening', r));
163
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
164
+
165
+ function post(path) {
166
+ return new Promise((resolve, reject) => {
167
+ const req = http.request(`${base}${path}`, { method: 'POST' }, (res) => {
168
+ let d = '';
169
+ res.on('data', (c) => { d += c; });
170
+ res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
171
+ });
172
+ req.on('error', reject);
173
+ req.end();
174
+ });
175
+ }
176
+
177
+ const ACTIVE = { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'standalone', domain: 'spike.cloudbongos.com', settings: {} };
178
+ function reset(patch = {}) {
179
+ instance = { ...ACTIVE, ...patch };
180
+ enqueueCalls = []; enqueueResult = null; events = [];
181
+ builder = { id: 7, github_login: 'owner', rank: 'thetes' };
182
+ }
183
+
184
+ console.log('\nPOST /provisioning/instances/:id/restart:');
185
+
186
+ await ta('an ACTIVE reachable project queues the restart and answers a receipt with no duration promise', async () => {
187
+ reset();
188
+ const r = await post('/provisioning/instances/18/restart');
189
+ assert.equal(r.status, 200, r.raw);
190
+ assert.equal(r.body.ok, true);
191
+ assert.equal(r.body.queued, true);
192
+ assert.equal(r.body.action, 'restart');
193
+ assert.equal(enqueueCalls.length, 1, 'exactly one enqueue');
194
+ assert.equal(enqueueCalls[0].requestedBy, 'api:self');
195
+ assert.doesNotMatch(r.body.message, PROMISE, `no wall-clock promise: ${r.body.message}`);
196
+ assert.equal(events.length, 1, 'the request is audited');
197
+ assert.equal(events[0].event, 'restart');
198
+ assert.equal(events[0].actor, 'api:self');
199
+ });
200
+
201
+ await ta('an already-open restart answers truthfully and records NO second event', async () => {
202
+ reset();
203
+ enqueueResult = { intent: { id: 9, action: 'restart', state: 'running' }, created: false };
204
+ const r = await post('/provisioning/instances/18/restart');
205
+ assert.equal(r.status, 200, r.raw);
206
+ assert.equal(r.body.queued, false);
207
+ assert.match(r.body.message, /already/i);
208
+ assert.equal(events.length, 0, 'no event for a request that queued nothing');
209
+ });
210
+
211
+ await ta('a foreign open intent → 409 intent_conflict naming the blocker', async () => {
212
+ reset();
213
+ enqueueResult = { intent: { id: 9, action: 'teardown', state: 'pending' }, created: false, conflict: 'open_intent' };
214
+ const r = await post('/provisioning/instances/18/restart');
215
+ assert.equal(r.status, 409, r.raw);
216
+ assert.equal(r.body.error.code, 'intent_conflict');
217
+ assert.equal(r.body.error.details.action, 'teardown');
218
+ assert.match(r.body.error.message, /teardown/, 'says WHAT is in the way');
219
+ assert.doesNotMatch(r.body.error.message, PROMISE);
220
+ });
221
+
222
+ await ta('every non-active state refuses with the action that IS available there — and queues nothing', async () => {
223
+ for (const [status, wanted] of [
224
+ ['torn_down', /stand it up again/i],
225
+ ['tearing_down', /stand it up again/i],
226
+ ['error', /stand it up again/i],
227
+ ['requested', /hasn’t finished standing up/i],
228
+ ['provisioning', /hasn’t finished standing up/i],
229
+ ]) {
230
+ reset({ status });
231
+ const r = await post('/provisioning/instances/18/restart');
232
+ assert.equal(r.status, 409, `${status}: ${r.raw}`);
233
+ assert.equal(r.body.error.code, 'not_restartable', status);
234
+ assert.equal(r.body.error.details.status, status, 'the refusal carries the state it read');
235
+ assert.match(r.body.error.message, wanted, `${status}: ${r.body.error.message}`);
236
+ assert.doesNotMatch(r.body.error.message, PROMISE, status);
237
+ assert.equal(enqueueCalls.length, 0, `${status} queued nothing`);
238
+ }
239
+ });
240
+
241
+ await ta('a dedicated droplet is refused HONESTLY and told the command to run — never queued a restart that cannot land', async () => {
242
+ reset({ hosting_shape: 'dedicated' });
243
+ const r = await post('/provisioning/instances/18/restart');
244
+ assert.equal(r.status, 409, r.raw);
245
+ assert.equal(r.body.error.code, 'restart_unreachable');
246
+ assert.equal(r.body.error.details.hosting_shape, 'dedicated');
247
+ assert.match(r.body.error.message, /systemctl restart spike/, 'names the exact command, on the machine that must run it');
248
+ assert.equal(enqueueCalls.length, 0, 'nothing queued');
249
+ });
250
+
251
+ await ta('the state gate is checked BEFORE the shape gate — a torn-down droplet hears about its state, not its shape', async () => {
252
+ reset({ status: 'torn_down', hosting_shape: 'dedicated' });
253
+ const r = await post('/provisioning/instances/18/restart');
254
+ assert.equal(r.body.error.code, 'not_restartable', 'the more useful refusal wins');
255
+ });
256
+
257
+ await ta("another builder's project is 404 — no existence leak", async () => {
258
+ reset();
259
+ builder = { id: 99, github_login: 'stranger', rank: 'thetes' };
260
+ const r = await post('/provisioning/instances/18/restart');
261
+ assert.equal(r.status, 404, r.raw);
262
+ assert.equal(r.body.error.code, 'instance_not_found');
263
+ assert.equal(enqueueCalls.length, 0);
264
+ });
265
+
266
+ await ta('an archon may restart any project, and the override is stamped with WHICH archon', async () => {
267
+ reset();
268
+ builder = { id: 42, github_login: 'arch', rank: 'archon' };
269
+ const r = await post('/provisioning/instances/18/restart');
270
+ assert.equal(r.status, 200, r.raw);
271
+ assert.equal(enqueueCalls[0].requestedBy, 'api:archon:42', 'the intent carries the override');
272
+ assert.equal(events[0].actor, 'api:archon:42', 'and so does the audit event');
273
+ assert.equal(events[0].ownerBuilderId, 7, 'the event still belongs to the owner');
274
+ });
275
+
276
+ await ta('a missing instance is 404, not a 500', async () => {
277
+ reset();
278
+ instance = null;
279
+ const r = await post('/provisioning/instances/18/restart');
280
+ assert.equal(r.status, 404, r.raw);
281
+ });
282
+
283
+ server.close();
284
+ console.log(`\nprovisioning_restart: ${passed} passed, ${failed} failed`);
285
+ process.exit(failed ? 1 : 0);
@@ -57,8 +57,13 @@ t("INTENT_ACTIONS includes 'settings-apply' (lockstep with the provisioning_010
57
57
  const sql = readFileSync(new URL('../modules/provisioning/migrations/provisioning_010_settings_apply.sql', import.meta.url), 'utf8');
58
58
  const m = /CHECK \(action IN \(([^)]*)\)\)/.exec(sql);
59
59
  assert.ok(m, 'the migration widens the action CHECK');
60
- const inSql = m[1].split(',').map((s) => s.trim().replace(/^'|'$/g, '')).sort();
61
- assert.deepEqual(inSql, [...provisioning.INTENT_ACTIONS].sort(), 'the CHECK and INTENT_ACTIONS carry the same set');
60
+ const inSql = m[1].split(',').map((s) => s.trim().replace(/^'|'$/g, ''));
61
+ assert.ok(inSql.includes('settings-apply'), 'this migration is the one that admitted it');
62
+ // Every value 010 permits is still a live action — the drift this guards. The
63
+ // strict set-equality lockstep lives with the NEWEST migration (a later one
64
+ // widens the CHECK again, and 010 is then deliberately a prefix of the set);
65
+ // asserting equality here would make every future action fail this test.
66
+ for (const a of inSql) assert.ok(provisioning.INTENT_ACTIONS.includes(a), `${a} is still an INTENT_ACTION`);
62
67
  assert.match(sql, /INSERT INTO schema_migrations \(version\) VALUES \('provisioning_010_settings_apply'\)/, 'self-registers');
63
68
  });
64
69
 
@@ -117,7 +122,7 @@ await ta('enqueueTeardown supersedes a PENDING settings push (a push into a proj
117
122
  const r = await provisioning.enqueueTeardown(db, 18, 'api:self');
118
123
  assert.equal(r.created, true);
119
124
  assert.equal(r.superseded, true, 'the queued push was cancelled so the teardown could take the slot');
120
- assert.match(calls[0], /action IN \('provision', 'settings-apply'\) AND state = 'pending'/, 'pending-only, and only the two supersedable actions');
125
+ assert.match(calls[0], /action IN \('provision', 'settings-apply', 'restart'\) AND state = 'pending'/, 'pending-only, and only the supersedable actions');
121
126
  });
122
127
 
123
128
  // ── the route, over a real mounted router (stubbed data access) ─────────────────
@@ -30,6 +30,9 @@ import http from 'node:http';
30
30
  const require = createRequire(import.meta.url);
31
31
  const provisioning = require('../modules/provisioning/provisioning.js');
32
32
 
33
+ // The actions a teardown supersedes, mirroring enqueueTeardown's SQL.
34
+ const SUPERSEDABLE = new Set(['provision', 'settings-apply', 'restart']);
35
+
33
36
  let passed = 0, failed = 0;
34
37
  function t(name, fn) {
35
38
  try { fn(); passed++; console.log(` PASS ${name}`); }
@@ -65,12 +68,13 @@ function fakeIntentDb(seed = []) {
65
68
  }
66
69
  if (/UPDATE provisioning_intents/.test(sql) && /state = 'error'/.test(sql)) {
67
70
  // The supersede's guard must live in the SQL itself: pending-only (a running
68
- // intent is the runner's mid-flight work) and provision-or-settings-push only
69
- // (task 1003140 widened it: a queued push into a project about to be torn
70
- // down is dead work; an open teardown is left alone for the idempotent path).
71
- assert.match(sql, /action IN \('provision', 'settings-apply'\) AND state = 'pending'/);
71
+ // intent is the runner's mid-flight work) and only the actions that become
72
+ // dead work in front of a teardown (task 1003140 widened it to the settings
73
+ // push, task 1003238 to the restart; an open teardown is left alone for the
74
+ // idempotent path).
75
+ assert.match(sql, /action IN \('provision', 'settings-apply', 'restart'\) AND state = 'pending'/);
72
76
  const hit = rows.filter((r) =>
73
- String(r.instance_id) === String(params[0]) && (r.action === 'provision' || r.action === 'settings-apply') && r.state === 'pending');
77
+ String(r.instance_id) === String(params[0]) && SUPERSEDABLE.has(r.action) && r.state === 'pending');
74
78
  for (const r of hit) { r.state = 'error'; r.last_error = 'superseded: teardown requested'; }
75
79
  return { rows: hit.map((r) => ({ id: r.id })) };
76
80
  }