@bongos/core 1.19.398 → 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.
- package/.bongos-core.json +47 -27
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0199-restart-an-unreachable-active-project.md +49 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +74 -3
- package/docs/api-reference.md +3 -2
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/provisioning/migrations/provisioning_011_restart.sql +28 -0
- package/modules/provisioning/provisioning.js +42 -4
- package/modules/provisioning/routes/provisioning.js +59 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/projects.html +124 -6
- package/scripts/gds/provision.js +104 -0
- package/src/module-api.js +5 -1
- package/tests/projects_hub_pre_uat.mjs +77 -0
- package/tests/provision_restart.mjs +242 -0
- package/tests/provisioning_restart.mjs +285 -0
- package/tests/provisioning_settings_apply.mjs +8 -3
- package/tests/provisioning_teardown_intent.mjs +9 -5
|
@@ -54,6 +54,83 @@ test('addressRow branches by state before it trusts the domain — torn down and
|
|
|
54
54
|
assert.match(body, /We keep checking every few minutes/, 'an unreachable project says what the platform is doing');
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
+
/* ── the restart door for an unreachable project (task 1003238) ── */
|
|
58
|
+
test('the restart door appears ONLY where it has a reason to exist, and mirrors the server gate', () => {
|
|
59
|
+
const body = fn('canRestart');
|
|
60
|
+
assert.match(body, /inst\.status === 'active'/, "RESTART_FROM is {active}");
|
|
61
|
+
assert.match(body, /inst\.last_verified_up === false/, 'the probe verdict is the trigger — a healthy card grows no Restart button');
|
|
62
|
+
assert.doesNotMatch(body, /last_verified_up === null|!inst\.last_verified_up/,
|
|
63
|
+
'null (never probed) is deliberately NOT the same claim as false — publicInstance keeps the two apart');
|
|
64
|
+
assert.match(body, /inst\.hosting_shape !== 'dedicated'/, 'RESTART_SHAPES — the runner has no exec path to a droplet');
|
|
65
|
+
assert.match(body, /!inst\.open_intent/, 'the strip tells the story while work is in flight');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('the restart door is offered on BOTH surfaces, and the address row points at it', () => {
|
|
69
|
+
assert.match(fn('renderCard'), /canRestart\(inst\)[\s\S]*?data-restart=/, 'the list card');
|
|
70
|
+
assert.match(fn('renderManage'), /canRestart\(inst\)[\s\S]*?data-m-restart/, 'the project’s own page — the two never disagree');
|
|
71
|
+
// the door and the sentence that sends the owner to it must ship together —
|
|
72
|
+
// and the sentence must be gated by the SAME predicate, or a project with no
|
|
73
|
+
// button (dedicated shape, or work already queued) is told to scroll to one
|
|
74
|
+
const addr = fn('addressRow');
|
|
75
|
+
assert.match(addr, /or restart it yourself below/, 'the unreachable line names the action now that one exists');
|
|
76
|
+
assert.match(addr, /canRestart\(inst\) \? ' — or restart it yourself below\.' : '\.'/,
|
|
77
|
+
'the pointer is conditioned on canRestart(), not on last_verified_up alone (ADR 0199 §8)');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a queued restart has its own strip — never the generic "a <action> run is queued" fallthrough', () => {
|
|
81
|
+
assert.match(SCRIPT, /restart: \{ names: \['Restart queued', 'Restarting', 'Answering again'\]/, 'the flow exists');
|
|
82
|
+
const body = fn('stripFor');
|
|
83
|
+
assert.match(body, /a === 'restart'/, 'stripFor branches on it');
|
|
84
|
+
assert.match(body, /Restarting the project — it goes down and comes back up\./, 'the running state says what is happening');
|
|
85
|
+
// the third stage is "Answering again" — it must not be lit by the strip alone,
|
|
86
|
+
// exactly like every other flow here (the probe earns that claim, not the queue)
|
|
87
|
+
assert.doesNotMatch(body, /stripMarkup\('restart', 2, 2/, 'the queue never lights the final stage');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('the restart sender posts to the restart ROUTE — re-POSTing the slug would be refused on every press', () => {
|
|
91
|
+
const body = fn('doRestart');
|
|
92
|
+
assert.match(body, /\/restart'/, 'its own endpoint');
|
|
93
|
+
assert.doesNotMatch(body, /provisioning\/instances',/, 'NOT the create/re-provision POST — PROVISION_ENQUEUE_FROM excludes active');
|
|
94
|
+
assert.match(body, /quietActions\(id\)/, 'the row goes quiet while it is in flight');
|
|
95
|
+
assert.match(fn('mRestart'), /\/restart'/, 'and the manage page sends the same request');
|
|
96
|
+
});
|
|
97
|
+
/* ── the moment a standup lands (task 1003243) ── */
|
|
98
|
+
test('the landed moment is earned by a VERIFIED probe inside a window, not invented state', () => {
|
|
99
|
+
const body = fn('justLive');
|
|
100
|
+
assert.match(body, /freshUp\(inst\)/, '"live" stays ONE rule — a lit third stage beside a "live?" chip is the contradiction §3 forbids');
|
|
101
|
+
assert.match(body, /LIVE_MOMENT_MS/, 'and it is time-boxed');
|
|
102
|
+
assert.match(body, /inst\.status === 'active'/);
|
|
103
|
+
assert.match(body, /!inst\.open_intent/, 'work in flight tells its own story');
|
|
104
|
+
assert.match(SCRIPT, /var LIVE_MOMENT_MS = [\s\S]{0,40}?;/, 'the window is a named constant');
|
|
105
|
+
// derived from data the card already holds — a sticky per-card flag would not
|
|
106
|
+
// survive a reload, and would keep claiming the moment after the project died
|
|
107
|
+
assert.match(fn('provisionedAt'), /inst\.provisioned_at/, 'the runner re-stamps this on every standup, so a revive earns it too');
|
|
108
|
+
assert.doesNotMatch(body, /localStorage|sessionStorage|sawLive|_seen/, 'no invented client-side state');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('the landed moment lights all three stages green — and only stripFor may do it', () => {
|
|
112
|
+
const body = fn('stripFor');
|
|
113
|
+
assert.match(body, /justLive\(inst\)/, 'the active fallthrough is no longer a bare return');
|
|
114
|
+
assert.match(body, /stripMarkup\(revive \? 'revive' : 'standup', -1, 3,[\s\S]*?'isLive'\)/,
|
|
115
|
+
'all three done (stagesDone=3), nothing in progress (stageNow=-1), painted in the landed palette');
|
|
116
|
+
// the strip must still vanish once the moment passes — a permanent progress
|
|
117
|
+
// strip on a long-live project is exactly what the pre-UAT review flagged
|
|
118
|
+
assert.match(body, /\n return '';\n \}/, "the fallthrough still ends at ''");
|
|
119
|
+
assert.match(SCRIPT, /function stripMarkup\(flow, stageNow, stagesDone, note, extraCls\)/, 'one markup fn, not a second copy');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('the landed note says the EVENT — the address row already carries the address', () => {
|
|
123
|
+
const body = fn('stripFor');
|
|
124
|
+
assert.match(body, /Standing up finished — your project is answering\./, 'a first standup');
|
|
125
|
+
assert.match(body, /It’s back — your project is answering again\./, 'and a revive says the right thing');
|
|
126
|
+
assert.doesNotMatch(body, /justLive[\s\S]{0,400}?esc\(inst\.domain\)/, 'the note does not repeat the domain');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('the moment clears itself even if the heartbeat stalls — sig() renders from its own age', () => {
|
|
130
|
+
assert.match(fn('sig'), /justLive\(inst\)/,
|
|
131
|
+
"last_verified_at's per-minute bucket stops moving when the sweep stalls; without this the strip would freeze on screen");
|
|
132
|
+
});
|
|
133
|
+
|
|
57
134
|
test('"live" is ONE rule — chip, orb and address row all read freshUp()', () => {
|
|
58
135
|
assert.match(SCRIPT, /function freshUp\(inst\)/);
|
|
59
136
|
assert.match(fn('chipFor'), /freshUp\(inst\)/, 'the chip');
|
|
@@ -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, ''))
|
|
61
|
-
assert.
|
|
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
|
|
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) ─────────────────
|