@bongos/core 1.19.923 → 1.19.925
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 +29 -24
- package/docs/copy-inventory.md +22 -15
- package/docs/copy-registry.json +81 -18
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/deploy.js +103 -3
- package/modules/hall-ui/public/project-settings.css +39 -0
- package/modules/provisioning/core-upgrade.js +24 -5
- package/modules/provisioning/migrations/provisioning_020_core_upgrade_preflight.sql +53 -0
- package/modules/provisioning/provisioning.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/provision-core-upgrade.js +157 -70
- package/scripts/gds/provision-repo.js +26 -0
- package/scripts/gds/provision.js +2 -1
- package/src/module-api.js +1 -1
- package/tests/core_upgrade_door.mjs +56 -4
- package/tests/core_upgrade_runner.mjs +176 -0
- package/tests/deploy_page_projects.mjs +104 -5
|
@@ -50,6 +50,19 @@
|
|
|
50
50
|
// instance_id -> 'preview' | 'confirm'. Held per box so opening the confirm on one does
|
|
51
51
|
// not arm it on another.
|
|
52
52
|
const steps = new Map();
|
|
53
|
+
// Rows whose dry run is in flight — per box, like `steps`, because a page-wide flag
|
|
54
|
+
// would spin every other project's row on one project's check.
|
|
55
|
+
const checking = new Set();
|
|
56
|
+
// How long the page waits for the runner to answer. Read off `window` when present so
|
|
57
|
+
// a harness can drive the loop without sleeping through it — a test that injects the
|
|
58
|
+
// real timer spends 30s of wall-clock per non-converging case and keeps repainting a
|
|
59
|
+
// sandbox that has already been torn down.
|
|
60
|
+
// `|| 2000` would be a bug here: an override of 0 — the one a harness wants — is
|
|
61
|
+
// falsy, so the fallback would swallow it and the test would sleep the full interval
|
|
62
|
+
// anyway while appearing to be driven.
|
|
63
|
+
const num = (v, dflt) => (Number.isFinite(v) ? v : dflt);
|
|
64
|
+
const POLL_MS = typeof window !== 'undefined' ? num(window.__deployPollMs, 2000) : 2000;
|
|
65
|
+
const POLL_TRIES = typeof window !== 'undefined' ? num(window.__deployPollTries, 15) : 15;
|
|
53
66
|
|
|
54
67
|
async function fetchJson(path) {
|
|
55
68
|
const res = await api.request('GET', `${API}${path}`);
|
|
@@ -117,10 +130,94 @@
|
|
|
117
130
|
// to that button closes over this same preview object — so the owner confirms the thing
|
|
118
131
|
// they were shown rather than "the newest", which is a different act by the time it is
|
|
119
132
|
// resolved.
|
|
133
|
+
// What the dry run said, from the runner's own tick (task 1004121). Before this, step
|
|
134
|
+
// one made NO network call — it redrew a fixed sentence and called that a preview. The
|
|
135
|
+
// owner pressed it four times on 2026-09-20, restarted four live boxes, and learned
|
|
136
|
+
// three different pre-flight refusals one production restart at a time. Every one of
|
|
137
|
+
// them was in this verdict already.
|
|
138
|
+
//
|
|
139
|
+
// A REFUSAL DOES NOT DISABLE THE BUTTON. The runner refuses on its own, and this
|
|
140
|
+
// reading can be minutes old; a control that blocked on a stale ✖ would be lying in
|
|
141
|
+
// the other direction. Say what will happen and let the owner decide.
|
|
142
|
+
function preflightHtml(p) {
|
|
143
|
+
const pf = p.preflight;
|
|
144
|
+
if (checking.has(String(p.instance_id)) && !(pf && pf.target === p.recommended_version)) {
|
|
145
|
+
return `<p class="ov-row__sub dp-preflight dp-preflight--checking">Checking what this would do…</p>`;
|
|
146
|
+
}
|
|
147
|
+
// No verdict is its own state, and it is NOT "fine". A silent gap here is how the
|
|
148
|
+
// page used to promise a preview it never performed.
|
|
149
|
+
if (!pf) {
|
|
150
|
+
return `<p class="ov-row__sub dp-preflight dp-preflight--none">Not checked yet — the runner takes this reading on its own tick.</p>`;
|
|
151
|
+
}
|
|
152
|
+
const when = pf && p.preflight_at ? ` <span class="dp-preflight__age">(checked ${escapeHtml(ago(p.preflight_at))})</span>` : '';
|
|
153
|
+
if (pf.ok) {
|
|
154
|
+
const lines = (pf.lines || []).map((l) => `<li>${escapeHtml(String(l))}</li>`).join('');
|
|
155
|
+
return `<div class="dp-preflight dp-preflight--ok">
|
|
156
|
+
<p class="ov-row__sub">Checked against ${escapeHtml(String(pf.target))} — nothing blocks this move.${when}</p>
|
|
157
|
+
${lines ? `<ul class="dp-preflight__list">${lines}</ul>` : ''}
|
|
158
|
+
</div>`;
|
|
159
|
+
}
|
|
160
|
+
return `<div class="dp-preflight dp-preflight--blocked">
|
|
161
|
+
<p class="ov-row__sub"><strong>This would fail.</strong> The upgrade checked ${escapeHtml(String(pf.target))} and refused:${when}</p>
|
|
162
|
+
<pre class="dp-preflight__error">${escapeHtml(String(pf.error || 'no reason given'))}</pre>
|
|
163
|
+
<p class="ov-row__sub">You can still press Move — the runner will refuse it the same way, and the box is left where it is.</p>
|
|
164
|
+
</div>`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Arming ASKS for the check (task 1004121). The web tier has no shell — it queues the
|
|
168
|
+
// dry run as an intent and the runner answers, which is the same division of labour as
|
|
169
|
+
// every other fact on this page. Then it re-reads the preview until the verdict lands.
|
|
170
|
+
//
|
|
171
|
+
// Only when there isn't already a fresh one: a verdict for THIS target is the answer,
|
|
172
|
+
// and re-asking would spend a subprocess to reprint it.
|
|
173
|
+
const asked = new Set();
|
|
174
|
+
async function askPreflight(p) {
|
|
175
|
+
const id = String(p.instance_id);
|
|
176
|
+
if (p.preflight && p.preflight.target === p.recommended_version) return;
|
|
177
|
+
if (asked.has(id)) return;
|
|
178
|
+
asked.add(id);
|
|
179
|
+
checking.add(id);
|
|
180
|
+
repaint();
|
|
181
|
+
try {
|
|
182
|
+
await sendJson('POST', `/provisioning/instances/${id}/core-upgrade`, {
|
|
183
|
+
to: p.recommended_version, dry_run: true,
|
|
184
|
+
});
|
|
185
|
+
} catch (e) {
|
|
186
|
+
if (e && e.kind === '401') return;
|
|
187
|
+
// A refused REQUEST is not a refused upgrade, and must not be painted as one:
|
|
188
|
+
// fall back to "not checked", which is honest about what is known.
|
|
189
|
+
checking.delete(id); asked.delete(id); repaint();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
// The runner drains on its own tick, so this waits rather than assuming. Bounded:
|
|
193
|
+
// a check that never lands leaves the row saying it was never checked, which is
|
|
194
|
+
// true, instead of spinning for ever.
|
|
195
|
+
//
|
|
196
|
+
// AND IT STOPS WHEN NOBODY IS LOOKING. The loop is started from a click and not
|
|
197
|
+
// awaited by it, so without this it outlives whatever closed the row — cancelling,
|
|
198
|
+
// confirming, or a reload — and goes on repainting a step the owner has left. It
|
|
199
|
+
// also made every test that never converges sit through the full ~30s in real time.
|
|
200
|
+
for (let i = 0; i < POLL_TRIES; i++) {
|
|
201
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
202
|
+
if (steps.get(p.instance_id) !== 'confirm') break; // the row was closed; stop.
|
|
203
|
+
try {
|
|
204
|
+
await refreshOne(p, { keepStep: true });
|
|
205
|
+
const now = (view.self && String(view.self.instance_id) === id)
|
|
206
|
+
? view.self
|
|
207
|
+
: view.boxes.find((b) => String(b.instance_id) === id);
|
|
208
|
+
if (now && now.preflight && now.preflight.target === now.recommended_version) break;
|
|
209
|
+
} catch (e) { break; }
|
|
210
|
+
}
|
|
211
|
+
checking.delete(id);
|
|
212
|
+
asked.delete(id);
|
|
213
|
+
repaint();
|
|
214
|
+
}
|
|
215
|
+
|
|
120
216
|
function stepTwoHtml(p, isSelf) {
|
|
121
217
|
const id = escapeHtml(String(p.instance_id));
|
|
122
218
|
return `
|
|
123
219
|
<div class="ps-controls">
|
|
220
|
+
${preflightHtml(p)}
|
|
124
221
|
<p class="ov-row__sub">This restarts ${isSelf ? 'the platform' : escapeHtml(String(p.slug))} onto core
|
|
125
222
|
<strong>${escapeHtml(String(p.recommended_version))}</strong>. It waits for the box to answer and
|
|
126
223
|
puts itself back on ${escapeHtml(String(p.served_version))} if it does not.</p>
|
|
@@ -166,7 +263,7 @@
|
|
|
166
263
|
function wire(p) {
|
|
167
264
|
const id = String(p.instance_id);
|
|
168
265
|
const preview = $(`dp-preview-${id}`);
|
|
169
|
-
if (preview) preview.addEventListener('click', () => { steps.set(p.instance_id, 'confirm'); repaint(); });
|
|
266
|
+
if (preview) preview.addEventListener('click', () => { steps.set(p.instance_id, 'confirm'); repaint(); askPreflight(p); });
|
|
170
267
|
const cancel = $(`dp-cancel-${id}`);
|
|
171
268
|
if (cancel) cancel.addEventListener('click', () => { steps.set(p.instance_id, 'preview'); repaint(); });
|
|
172
269
|
const confirm = $(`dp-confirm-${id}`);
|
|
@@ -223,14 +320,17 @@
|
|
|
223
320
|
// far wider than the change — it also re-probes boxes the owner never touched. A
|
|
224
321
|
// failure here falls back to the full read, because a stale row on a deploy page is
|
|
225
322
|
// the one thing worse than an extra request.
|
|
226
|
-
|
|
323
|
+
// `keepStep` is for the pre-flight poll (task 1004121): a refresh AFTER a move should
|
|
324
|
+
// return the row to step one, but a refresh DURING the wait must not — it would close
|
|
325
|
+
// the panel the owner is reading, under them, every two seconds.
|
|
326
|
+
async function refreshOne(p, { keepStep = false } = {}) {
|
|
227
327
|
const id = String(p.instance_id);
|
|
228
328
|
const isSelf = view.self && String(view.self.instance_id) === id;
|
|
229
329
|
try {
|
|
230
330
|
const d = await fetchJson(isSelf ? '/provisioning/control-plane' : `/provisioning/instances/${id}/core-upgrade`);
|
|
231
331
|
if (isSelf) view.self = d.preview;
|
|
232
332
|
else view.boxes = view.boxes.map((b) => (String(b.instance_id) === id ? d.preview : b));
|
|
233
|
-
steps.set(p.instance_id, 'preview');
|
|
333
|
+
if (!keepStep) steps.set(p.instance_id, 'preview');
|
|
234
334
|
repaint();
|
|
235
335
|
} catch (e) {
|
|
236
336
|
if (e && e.kind === '401') return;
|
|
@@ -88,3 +88,42 @@
|
|
|
88
88
|
color: var(--ink-soft);
|
|
89
89
|
}
|
|
90
90
|
.ps-sibling strong { color: inherit; font-weight: 600; }
|
|
91
|
+
|
|
92
|
+
/* The deploy control's pre-flight verdict (task 1004121) — what the upgrade says it
|
|
93
|
+
would do, taken by the runner's dry run. Three states, and "not checked" is one of
|
|
94
|
+
them: the page used to show a confident sentence where it had performed no check at
|
|
95
|
+
all, so silence here must look like silence, not like a pass. */
|
|
96
|
+
.dp-preflight { margin: 0 0 0.6rem; }
|
|
97
|
+
.dp-preflight--none,
|
|
98
|
+
.dp-preflight--checking { color: var(--ink-soft); font-style: italic; }
|
|
99
|
+
/* The pass state earns a mark of its own. Without one, "nothing blocks this" and "not
|
|
100
|
+
checked" differ only in wording, and the three-state legibility this file claims is
|
|
101
|
+
two states and a sentence. */
|
|
102
|
+
.dp-preflight--ok {
|
|
103
|
+
border-left: 3px solid var(--ok);
|
|
104
|
+
padding-left: 0.7rem;
|
|
105
|
+
}
|
|
106
|
+
.dp-preflight__age { color: var(--ink-soft); font-weight: normal; }
|
|
107
|
+
.dp-preflight__list {
|
|
108
|
+
margin: 0.25rem 0 0;
|
|
109
|
+
padding-left: 1.1rem;
|
|
110
|
+
color: var(--ink-soft);
|
|
111
|
+
font-size: 0.86rem;
|
|
112
|
+
line-height: 1.5;
|
|
113
|
+
}
|
|
114
|
+
.dp-preflight--blocked {
|
|
115
|
+
border-left: 3px solid var(--danger);
|
|
116
|
+
padding-left: 0.7rem;
|
|
117
|
+
}
|
|
118
|
+
/* The refusal is upgrade.js's own words, multi-line and already indented to name the
|
|
119
|
+
fix — preserved rather than reflowed, because the wording IS the instruction. */
|
|
120
|
+
.dp-preflight__error {
|
|
121
|
+
margin: 0.35rem 0;
|
|
122
|
+
padding: 0.5rem 0.6rem;
|
|
123
|
+
background: var(--surface-2);
|
|
124
|
+
border-radius: 6px;
|
|
125
|
+
font-size: 0.82rem;
|
|
126
|
+
line-height: 1.45;
|
|
127
|
+
white-space: pre-wrap;
|
|
128
|
+
overflow-x: auto;
|
|
129
|
+
}
|
|
@@ -124,8 +124,8 @@ function coreUpgradeFault(inst, to, live = null) {
|
|
|
124
124
|
* is. So a second request is a conflict the route names, including when the open
|
|
125
125
|
* intent is itself a core-upgrade.
|
|
126
126
|
*/
|
|
127
|
-
async function enqueueCoreUpgrade(provisioning, db, instanceId, to, requestedBy) {
|
|
128
|
-
const { intent, created } = await provisioning.enqueueIntent(db, instanceId,
|
|
127
|
+
async function enqueueCoreUpgrade(provisioning, db, instanceId, to, requestedBy, action = 'core-upgrade') {
|
|
128
|
+
const { intent, created } = await provisioning.enqueueIntent(db, instanceId, action, requestedBy, to);
|
|
129
129
|
if (created) return { intent, created: true };
|
|
130
130
|
return { intent, created: false, conflict: 'open_intent' };
|
|
131
131
|
}
|
|
@@ -172,7 +172,13 @@ function makeCoreUpgradeHandler({ provisioning, pool, parseId, validateOrRespond
|
|
|
172
172
|
// core_upgrades ledger (ADR 0293 D5): a privileged action with an
|
|
173
173
|
// unattributed row is not acceptable here.
|
|
174
174
|
const actor = `api:archon:${req.builder.id}`;
|
|
175
|
-
|
|
175
|
+
// `dry_run: true` asks what the move WOULD do and moves nothing (task 1004121).
|
|
176
|
+
// Queued the same way as the move, through the same single-open-intent slot, so a
|
|
177
|
+
// preview cannot be requested on top of a move that is already running — and, just
|
|
178
|
+
// as deliberately, a preview occupies that slot only for as long as it takes.
|
|
179
|
+
const dryRun = req.body && req.body.dry_run === true;
|
|
180
|
+
const action = dryRun ? 'core-upgrade-preflight' : 'core-upgrade';
|
|
181
|
+
const r = await enqueueCoreUpgrade(provisioning, pool, id, to, actor, action);
|
|
176
182
|
if (r.conflict) {
|
|
177
183
|
return res.fail('intent_conflict', {
|
|
178
184
|
status: 409,
|
|
@@ -181,9 +187,15 @@ function makeCoreUpgradeHandler({ provisioning, pool, parseId, validateOrRespond
|
|
|
181
187
|
});
|
|
182
188
|
}
|
|
183
189
|
await provisioning.recordEvent(pool, {
|
|
184
|
-
instanceId: id, ownerBuilderId: inst.owner_builder_id, event:
|
|
185
|
-
detail: `core pin move to ${to} requested`, actor,
|
|
190
|
+
instanceId: id, ownerBuilderId: inst.owner_builder_id, event: action,
|
|
191
|
+
detail: dryRun ? `pre-flight check for ${to} requested` : `core pin move to ${to} requested`, actor,
|
|
186
192
|
}).catch(() => {});
|
|
193
|
+
if (dryRun) {
|
|
194
|
+
return res.json({
|
|
195
|
+
ok: true, queued: true, action, target_version: to,
|
|
196
|
+
message: `Checking what moving to ${to} would do. Nothing is changed by this — the answer appears here when the check finishes.`,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
187
199
|
// No duration is promised and no success is claimed: this says the request
|
|
188
200
|
// was RECORDED, which is the only thing true at this moment (ADR 0190 §7).
|
|
189
201
|
return res.json({
|
|
@@ -213,6 +225,7 @@ function makeCoreUpgradeHandler({ provisioning, pool, parseId, validateOrRespond
|
|
|
213
225
|
*/
|
|
214
226
|
function previewFromSnapshot(inst, snap, openIntent) {
|
|
215
227
|
const served = (snap && snap.served_version) || null;
|
|
228
|
+
const pf = (snap && snap.preflight) || null;
|
|
216
229
|
const available = (snap && Array.isArray(snap.available) ? snap.available : []);
|
|
217
230
|
const recommended = (snap && snap.recommended) || null;
|
|
218
231
|
// Only what a move could actually go TO: newer than what is running, and admitted by
|
|
@@ -230,6 +243,12 @@ function previewFromSnapshot(inst, snap, openIntent) {
|
|
|
230
243
|
// Named so the UI cannot accidentally render "up to date" over a missing reading.
|
|
231
244
|
reading: served ? 'ok' : 'no_answer',
|
|
232
245
|
observed_at: (snap && snap.observed_at) || null,
|
|
246
|
+
// What the upgrade SAYS it would do, from the runner's dry run (task 1004121).
|
|
247
|
+
// Withheld unless it was taken against the move now on offer: a verdict about
|
|
248
|
+
// 1.19.920 tells the owner nothing true about pressing a button that says 1.19.921,
|
|
249
|
+
// and a stale pass is worse than no answer because it reads as a guarantee.
|
|
250
|
+
preflight: pf && recommended && pf.target === recommended ? pf : null,
|
|
251
|
+
preflight_at: pf && recommended && pf.target === recommended ? (snap.preflight_at || null) : null,
|
|
233
252
|
open_intent: openIntent ? { action: openIntent.action, state: openIntent.state, target_version: openIntent.target_version || null } : null,
|
|
234
253
|
};
|
|
235
254
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
-- provisioning_020_core_upgrade_preflight.sql — what the upgrade would say if you pressed it
|
|
2
|
+
-- (task 1004121, ADR 0293 D4).
|
|
3
|
+
--
|
|
4
|
+
-- WHY THIS EXISTS. Step one of the deploy control's two-step was labelled "see what would
|
|
5
|
+
-- change" and made no network call at all: it flipped a local flag and redrew one fixed
|
|
6
|
+
-- sentence naming the two versions. Nothing was computed and no precondition was checked.
|
|
7
|
+
--
|
|
8
|
+
-- What that cost, on 2026-09-20: the owner pressed Upgrade four times across four
|
|
9
|
+
-- instances. Every press restarted a live box, failed in pre-flight and rolled back, and
|
|
10
|
+
-- the four failures were three DIFFERENT causes — a root Postgres role, an unresolvable
|
|
11
|
+
-- database name (both task 1004119) and an unclean working tree (task 1004122). Each one
|
|
12
|
+
-- is a pre-flight check, and `upgrade.js --dry-run` runs the whole pre-flight before it
|
|
13
|
+
-- returns. Every one of them was reportable without restarting anything.
|
|
14
|
+
--
|
|
15
|
+
-- So the runner now performs that dry run on its own tick, beside the version probe it
|
|
16
|
+
-- already takes, and caches the verdict here. Same division of labour as the columns
|
|
17
|
+
-- next door (provisioning_019): facts the web tier may not go and look for itself, taken
|
|
18
|
+
-- by the process that has a shell and read by the process that does not.
|
|
19
|
+
--
|
|
20
|
+
-- KEYED BY THE TARGET IT WAS RUN AGAINST. A verdict is only about one move. `target` is
|
|
21
|
+
-- stored with it so the control can tell a live answer from one taken against a version
|
|
22
|
+
-- that has since been superseded, rather than showing a stale pass over a new release.
|
|
23
|
+
|
|
24
|
+
BEGIN;
|
|
25
|
+
|
|
26
|
+
ALTER TABLE provisioning_core_version_snapshot
|
|
27
|
+
-- { target, ok, lines[], error } as the dry run reported it, or NULL when there was
|
|
28
|
+
-- nothing to preview (the box is current, so no move exists to check).
|
|
29
|
+
ADD COLUMN IF NOT EXISTS preflight jsonb,
|
|
30
|
+
-- When the dry run ran, which is NOT observed_at: the version probe is taken every
|
|
31
|
+
-- tick and this is only re-taken when there is a move to check. A control that
|
|
32
|
+
-- rendered one timestamp for both would age-stamp the verdict with the probe's clock.
|
|
33
|
+
ADD COLUMN IF NOT EXISTS preflight_at timestamptz;
|
|
34
|
+
|
|
35
|
+
-- The intent vocabulary gains the ASKING action. The JS list (INTENT_ACTIONS) and this
|
|
36
|
+
-- CHECK are two spellings of one vocabulary, and a widening that lands in only one of
|
|
37
|
+
-- them fails at insert time, in production, on the first real use — which is exactly what
|
|
38
|
+
-- tests/core_upgrade_door.mjs asserts against, and what it caught here.
|
|
39
|
+
--
|
|
40
|
+
-- The target_version CHECK from provisioning_018 is re-stated, not just left alone: it
|
|
41
|
+
-- read `(action = 'core-upgrade') = (target_version IS NOT NULL)`, which would REFUSE a
|
|
42
|
+
-- preflight intent for naming the version it is about to check.
|
|
43
|
+
ALTER TABLE provisioning_intents DROP CONSTRAINT IF EXISTS provisioning_intents_action_check;
|
|
44
|
+
ALTER TABLE provisioning_intents ADD CONSTRAINT provisioning_intents_action_check
|
|
45
|
+
CHECK (action IN ('provision', 'teardown', 'oauth-manifest', 'domain-attach',
|
|
46
|
+
'settings-apply', 'restart', 'disconnect', 'core-upgrade',
|
|
47
|
+
'core-upgrade-preflight'));
|
|
48
|
+
|
|
49
|
+
ALTER TABLE provisioning_intents DROP CONSTRAINT IF EXISTS provisioning_intents_target_version_chk;
|
|
50
|
+
ALTER TABLE provisioning_intents ADD CONSTRAINT provisioning_intents_target_version_chk
|
|
51
|
+
CHECK ((action IN ('core-upgrade', 'core-upgrade-preflight')) = (target_version IS NOT NULL));
|
|
52
|
+
|
|
53
|
+
COMMIT;
|
|
@@ -61,7 +61,7 @@ const PROJECT_TYPES = ['game', 'research', 'business', 'non-profit', 'not-sure']
|
|
|
61
61
|
// 'disconnect' (task 1003249, migration provisioning_016): the owner's true delete — ./disconnect.js.
|
|
62
62
|
// 'core-upgrade' (task 1003159, ADR 0293): move this instance's core pin — the owner's
|
|
63
63
|
// deploy door. The web tier only QUEUES it; the control-plane runner performs it.
|
|
64
|
-
const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart', 'disconnect', 'core-upgrade'];
|
|
64
|
+
const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart', 'disconnect', 'core-upgrade', 'core-upgrade-preflight'];
|
|
65
65
|
// Every state a teardown can be asked for — i.e. everything except the one
|
|
66
66
|
// state where there is provably nothing left to release. DERIVED from
|
|
67
67
|
// INSTANCE_STATES on purpose (task 1003042, walk finding N5): the old hand-kept
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.925",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.925",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.925",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
|
|
31
31
|
const path = require('path');
|
|
32
32
|
const { CONFIG, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, REPO_ROOT } = require('./provision-config.js');
|
|
33
|
-
const { APP_USER, dbName, instanceUser, standaloneRoot } = require('./provision-repo.js');
|
|
33
|
+
const { APP_USER, dbName, instanceUser, selfInstanceRoot, standaloneRoot } = require('./provision-repo.js');
|
|
34
34
|
const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
|
|
35
35
|
|
|
36
36
|
// The shapes whose box this runner can actually reach. Mirrors UPGRADE_SHAPES in the
|
|
@@ -96,7 +96,19 @@ function targetFault(to, available, installed, channel, channelAllows) {
|
|
|
96
96
|
if (!available.includes(to)) {
|
|
97
97
|
return `core ${to} is not published yet — nothing was changed. A release tag appears several minutes before the package itself does; try again once it lands.`;
|
|
98
98
|
}
|
|
99
|
-
|
|
99
|
+
// AN UNREADABLE `installed` IS A FAULT, NOT A FREE PASS (task 1004123).
|
|
100
|
+
//
|
|
101
|
+
// This guard used to be written `if (installed && …)`, so a null installed version
|
|
102
|
+
// skipped the channel check entirely rather than failing it. That is backwards: not
|
|
103
|
+
// knowing where an instance is today is the one state in which "is this jump
|
|
104
|
+
// permitted" cannot be answered, and the channel exists precisely to refuse jumps
|
|
105
|
+
// nobody has looked at. It also hid the defect that motivated this: the control
|
|
106
|
+
// plane read its installed version from the wrong directory for weeks and got null
|
|
107
|
+
// every time, and every press sailed past this line.
|
|
108
|
+
if (!installed) {
|
|
109
|
+
return `could not read the core version installed at this instance — nothing was changed. An upgrade that cannot say where it is starting from cannot be bounded by the '${channel}' channel.`;
|
|
110
|
+
}
|
|
111
|
+
if (channelAllows && !channelAllows(installed, to, channel)) {
|
|
100
112
|
return `core ${to} is outside this project's '${channel}' update channel — nothing was changed. Change the channel first if this is deliberate.`;
|
|
101
113
|
}
|
|
102
114
|
return null;
|
|
@@ -156,7 +168,8 @@ async function coreUpgradeInstance(inst, deps, intent) {
|
|
|
156
168
|
|
|
157
169
|
// ---- 2. is the target real, and permitted -----------------------------------------
|
|
158
170
|
const channelMod = deps.updateChannel || require('./update-channel.js');
|
|
159
|
-
const
|
|
171
|
+
const selfRoot = deps.selfInstanceRoot || selfInstanceRoot;
|
|
172
|
+
const instanceDir = inst.hosting_shape === 'standalone' ? standaloneRoot(inst) : selfRoot();
|
|
160
173
|
const readInstalled = deps.readInstalledCoreVersion || require('./upgrade.js').readInstalledCoreVersion;
|
|
161
174
|
const installed = readInstalled(instanceDir);
|
|
162
175
|
// THE PLATFORM DEFAULT, NOT A PER-INSTANCE CHANNEL — and saying so beats implying
|
|
@@ -184,73 +197,24 @@ async function coreUpgradeInstance(inst, deps, intent) {
|
|
|
184
197
|
// the two attribution flags (ADR 0293 D5). --commit-pin is NOT optional here: without
|
|
185
198
|
// it the pin lives only in the working tree, and the next pull-deploy resets it away —
|
|
186
199
|
// the silent thirteen-release revert upgrade.js's own header records.
|
|
187
|
-
const healthUrl = `http://127.0.0.1:${inst.port}/healthz`;
|
|
188
200
|
const versionUrl = `http://127.0.0.1:${inst.port}/version`;
|
|
189
|
-
// WHO THE UPGRADE RUNS AS, AND WHICH DATABASE IT NAMES (task 1004119).
|
|
190
|
-
//
|
|
191
|
-
// A bare `sudo` runs as ROOT, and both halves of the upgrade resolve against the
|
|
192
|
-
// CURRENT OS USER rather than a flag:
|
|
193
|
-
//
|
|
194
|
-
// • migrate.sh peer-auths over the local socket as whoever invoked it, and `root`
|
|
195
|
-
// is almost never a Postgres role — pre-flight refuses with "Postgres has no
|
|
196
|
-
// role root" before the pin moves (correctly; the pin must not move for a
|
|
197
|
-
// migrate that cannot run).
|
|
198
|
-
// • with no PGDATABASE, resolveInstanceDb() has to FIND the database by loading
|
|
199
|
-
// node_modules/@bongos/core/src/bongos/pool from the instance root. An adopted
|
|
200
|
-
// repo that installs its workspaces rather than its root has no core there, so
|
|
201
|
-
// the name cannot be resolved at all.
|
|
202
|
-
//
|
|
203
|
-
// Both were live on 2026-09-20: four presses of the owner's Upgrade button, four
|
|
204
|
-
// pre-flight refusals, no instance ever moved. The helpers to fix it already sat in
|
|
205
|
-
// provision-repo.js — this leg simply never used them.
|
|
206
|
-
//
|
|
207
|
-
// The account is the instance's own (`bongos-<slug>`), which is ALSO the name of its
|
|
208
|
-
// Postgres login role (instanceDbRole), so peer auth lands on a role that owns
|
|
209
|
-
// exactly that instance's database. The control plane is the exception: it is not a
|
|
210
|
-
// provisioned tenant and has no `bongos-` account — it runs as APP_USER, the operator
|
|
211
|
-
// the recipe's gate-3 invocation names.
|
|
212
|
-
const runAs = inst.hosting_shape === 'control-plane' ? APP_USER : instanceUser(inst);
|
|
213
|
-
const sudoP = privileged ? `sudo -u ${runAs} env PGDATABASE=${dbName(inst)} ` : '';
|
|
214
|
-
// WHERE THE CLI LIVES AND WHERE THE TARGET LIVES ARE DIFFERENT PLACES (task 1004053).
|
|
215
|
-
//
|
|
216
|
-
// `scripts/gds/upgrade.js` ships in the CORE checkout. A provisioned instance root holds
|
|
217
|
-
// no scripts/ directory at all — its core sits under node_modules/@bongos/core — so
|
|
218
|
-
// running the CLI with cwd = the instance root resolved nothing, and EVERY standalone
|
|
219
|
-
// upgrade died before it began:
|
|
220
|
-
//
|
|
221
|
-
// Error: Cannot find module '/srv/<base>/<slug>/scripts/gds/upgrade.js'
|
|
222
|
-
//
|
|
223
|
-
// It worked only where instanceDir HAPPENED to be this checkout — the control-plane self
|
|
224
|
-
// row and a co-tenant — which is why it survived to production: the shapes that are
|
|
225
|
-
// upgraded by hand are the shapes the relative path fits. Every ACTIVE tenant is
|
|
226
|
-
// standalone, so the deploy door could not move a single one of them.
|
|
227
|
-
//
|
|
228
|
-
// upgrade.js's own header states the contract this restores: "Runs from the consumer
|
|
229
|
-
// instance root (default) or `--instance <dir>`. This script ships IN the core … against
|
|
230
|
-
// `--instance <consumer>`." So: run it where it exists, and NAME the target.
|
|
231
|
-
const runDir = REPO_ROOT;
|
|
232
|
-
const targetArg = instanceDir === REPO_ROOT ? '' : `--instance ${instanceDir}`;
|
|
233
|
-
const cli = path.posix.join('scripts', 'gds', 'upgrade.js');
|
|
234
201
|
const requestedBy = intent.requested_by && /^api:archon:(\d+)$/.test(intent.requested_by)
|
|
235
202
|
? intent.requested_by.replace(/^api:archon:/, '')
|
|
236
203
|
: '';
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
`--to ${to}`,
|
|
241
|
-
targetArg,
|
|
242
|
-
`--service ${inst.slug}`,
|
|
243
|
-
`--health-url ${healthUrl}`,
|
|
244
|
-
`--version-url ${versionUrl}`,
|
|
245
|
-
'--commit-pin',
|
|
246
|
-
requestedBy ? `--requested-by ${requestedBy}` : '',
|
|
247
|
-
requestedBy ? '--upgrade-source owner-control' : '',
|
|
248
|
-
].filter(Boolean).join(' ');
|
|
204
|
+
// Built by the SAME function the preview uses, so "what you were shown" and "what runs"
|
|
205
|
+
// cannot drift apart into two hand-maintained flag lists (task 1004121).
|
|
206
|
+
const { cmd, runDir } = upgradeInvocation(inst, to, { privileged, requestedBy, selfRoot });
|
|
249
207
|
|
|
250
208
|
if (!apply) {
|
|
251
|
-
|
|
209
|
+
// A --dry-run runner tick used to only PRINT this command. It now runs the CLI's own
|
|
210
|
+
// dry run, so `provision.js --dry-run` reports what the upgrade would actually refuse
|
|
211
|
+
// rather than a command string the reader has to evaluate in their head.
|
|
212
|
+
log(` ✓ would run (in ${runDir}): ${cmd}`);
|
|
213
|
+
const verdict = (deps.preflightUpgrade || preflightUpgrade)(inst, to, deps);
|
|
214
|
+
for (const line of verdict.lines || []) log(` ✓ pre-flight: ${line}`);
|
|
215
|
+
if (!verdict.ok) log(` ✖ ${verdict.error}`);
|
|
252
216
|
log(` ✓ would then read ${versionUrl} on the box and require it to say ${to}`);
|
|
253
|
-
return { ok: true, served: null };
|
|
217
|
+
return { ok: true, served: null, preflight: verdict };
|
|
254
218
|
}
|
|
255
219
|
try {
|
|
256
220
|
log(` [upgrade] (in ${runDir}) ${cmd}`);
|
|
@@ -290,6 +254,116 @@ async function coreUpgradeInstance(inst, deps, intent) {
|
|
|
290
254
|
return { ok: true, served, confirmed: served === to };
|
|
291
255
|
}
|
|
292
256
|
|
|
257
|
+
// The pre-flight lines a dry run emits, and the refusal when it has one. upgrade.js writes
|
|
258
|
+
// its pre-flight to stdout as ` ✓ pre-flight: …` and returns a `✖`-prefixed multi-line
|
|
259
|
+
// error on a refusal, so BOTH streams matter: a pass is only legible from stdout, and a
|
|
260
|
+
// refusal only from the failure text. PURE given its input.
|
|
261
|
+
function parsePreflight(out) {
|
|
262
|
+
const text = String(out || '');
|
|
263
|
+
const lines = text.split('\n').map((l) => l.trim());
|
|
264
|
+
const checks = lines.filter((l) => /^[✓✖]\s*pre-flight:/.test(l)).map((l) => l.replace(/^[✓✖]\s*/, ''));
|
|
265
|
+
// A refusal is whatever upgrade.js said after the ✖ — reported verbatim rather than
|
|
266
|
+
// re-worded, because its wording already names the fix ("Set PGDATABASE, or …").
|
|
267
|
+
const failAt = text.indexOf('✖');
|
|
268
|
+
const error = failAt >= 0 ? text.slice(failAt + 1).trim() : null;
|
|
269
|
+
return { checks, error };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ONE BUILDER FOR BOTH LEGS (task 1004121).
|
|
273
|
+
//
|
|
274
|
+
// The preview and the move must be the same command or the preview is worthless — and
|
|
275
|
+
// two hand-copied builders are exactly how they stop being the same. The mode decides
|
|
276
|
+
// only the TAIL: a dry run stops after pre-flight, so the flags that describe what to do
|
|
277
|
+
// AFTER the pin moves (restart, health, version, pin-commit, attribution) would be
|
|
278
|
+
// inert on it. Everything that decides whether the upgrade is ALLOWED — the account, the
|
|
279
|
+
// database, the target, the instance — is shared, which is the half a preview is about.
|
|
280
|
+
//
|
|
281
|
+
// The slug rides into a shell command through instanceUser()/dbName(), so it is checked
|
|
282
|
+
// HERE rather than left to instanceUser()'s own throw: that throw is correct today, but
|
|
283
|
+
// a call site whose safety depends on a helper's internals is one refactor away from a
|
|
284
|
+
// shell-injection path (and the apply leg's own guard sits at the route, not here).
|
|
285
|
+
function upgradeInvocation(inst, to, { privileged = false, dryRun = false, requestedBy = '', selfRoot = selfInstanceRoot } = {}) {
|
|
286
|
+
if (!isValidSlug(inst && inst.slug)) {
|
|
287
|
+
throw new Error(`upgradeInvocation: refusing to build a shell command from an invalid slug ${JSON.stringify(inst && inst.slug)}`);
|
|
288
|
+
}
|
|
289
|
+
// WHERE THE CLI LIVES AND WHERE THE TARGET LIVES ARE DIFFERENT PLACES, TWICE OVER.
|
|
290
|
+
//
|
|
291
|
+
// `upgrade.js` ships in the CORE, and a provisioned instance root holds no scripts/
|
|
292
|
+
// directory at all — so the CLI runs from REPO_ROOT and NAMES its target (task
|
|
293
|
+
// 1004053, which fixed every standalone tenant).
|
|
294
|
+
//
|
|
295
|
+
// The second half is that REPO_ROOT is not the instance either (task 1004123). It
|
|
296
|
+
// names the copy of the core that is EXECUTING, which coincides with the instance
|
|
297
|
+
// only while the core is a checkout run in place. Once the control plane's runner
|
|
298
|
+
// moved into its vendored core the two diverged silently — REPO_ROOT still resolved,
|
|
299
|
+
// just to node_modules/@bongos/core — and the door aimed at the package instead of
|
|
300
|
+
// the project. So ask, rather than assume; in the legacy shape the answer is
|
|
301
|
+
// REPO_ROOT and the command below is byte-identical to what it always was.
|
|
302
|
+
const instanceDir = inst.hosting_shape === 'standalone' ? standaloneRoot(inst) : selfRoot();
|
|
303
|
+
// The control plane is not a provisioned tenant and has no `bongos-` account; every
|
|
304
|
+
// other shape runs as its own, which is also its Postgres login role (task 1004119).
|
|
305
|
+
const runAs = inst.hosting_shape === 'control-plane' ? APP_USER : instanceUser(inst);
|
|
306
|
+
const sudoP = privileged ? `sudo -u ${runAs} env PGDATABASE=${dbName(inst)} ` : '';
|
|
307
|
+
const cli = path.posix.join('scripts', 'gds', 'upgrade.js');
|
|
308
|
+
const shared = [`${sudoP}node ${cli}`, '--registry', `--to ${to}`,
|
|
309
|
+
instanceDir === REPO_ROOT ? '' : `--instance ${instanceDir}`]; // REPO_ROOT is where the CLI runs (runDir below)
|
|
310
|
+
const tail = dryRun
|
|
311
|
+
? ['--dry-run']
|
|
312
|
+
: [`--service ${inst.slug}`,
|
|
313
|
+
`--health-url http://127.0.0.1:${inst.port}/healthz`,
|
|
314
|
+
`--version-url http://127.0.0.1:${inst.port}/version`,
|
|
315
|
+
'--commit-pin',
|
|
316
|
+
requestedBy ? `--requested-by ${requestedBy}` : '',
|
|
317
|
+
requestedBy ? '--upgrade-source owner-control' : ''];
|
|
318
|
+
return { cmd: [...shared, ...tail].filter(Boolean).join(' '), runDir: REPO_ROOT, instanceDir, runAs };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Ask the upgrade CLI what it WOULD do, changing nothing. `allowFail` because a refusal
|
|
322
|
+
// is the interesting answer, not an exception: pre-flight refusing is what this is for.
|
|
323
|
+
function preflightUpgrade(inst, to, deps) {
|
|
324
|
+
const { exec, privileged } = deps;
|
|
325
|
+
const boxExec = inst.hosting_shape === 'standalone' ? (deps.controlExec || exec) : exec;
|
|
326
|
+
// A distinct local name: `deps.selfInstanceRoot || selfInstanceRoot` with a const of
|
|
327
|
+
// the same name would TDZ-shadow the import and crash every uninjected call.
|
|
328
|
+
const selfRoot = deps.selfInstanceRoot || selfInstanceRoot;
|
|
329
|
+
const { cmd, runDir } = upgradeInvocation(inst, to, { privileged, dryRun: true, selfRoot });
|
|
330
|
+
const r = boxExec(cmd, { cwd: runDir, allowFail: true });
|
|
331
|
+
const out = `${(r && r.stdout) || ''}\n${(r && r.stderr) || ''}`;
|
|
332
|
+
const { checks, error } = parsePreflight(out);
|
|
333
|
+
// ok is the RUN's verdict, not the parser's: a command that exited non-zero without a
|
|
334
|
+
// recognisable ✖ is still a refusal, and reporting it as a pass is the failure mode
|
|
335
|
+
// this whole task exists to remove.
|
|
336
|
+
const ok = !!(r && !r.softFailed && r.ok !== false) && !error;
|
|
337
|
+
return { target: to, ok, lines: checks, error: error || (ok ? null : 'the dry run did not complete') };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Run a dry run for one instance and cache its verdict — the ON-DEMAND leg, drained from
|
|
341
|
+
// a `core-upgrade-preflight` intent the owner's arming queues (task 1004121). The tick
|
|
342
|
+
// does not do this: a verdict that is only ever produced on a background schedule is
|
|
343
|
+
// missing at exactly the moment it is wanted (a fresh release, a new instance), and
|
|
344
|
+
// re-running it every tick for a box that has been behind for hours spends a subprocess
|
|
345
|
+
// to re-learn the same answer. Asked for, then cached, is both cheaper and timelier.
|
|
346
|
+
async function preflightCoreUpgrade(inst, deps, intent) {
|
|
347
|
+
const { db, log } = deps;
|
|
348
|
+
const to = intent && intent.target_version;
|
|
349
|
+
if (!UPGRADE_SHAPES.has(inst.hosting_shape) || inst.status !== 'active') {
|
|
350
|
+
return { ok: false, error: `cannot preview a ${inst.hosting_shape} instance` };
|
|
351
|
+
}
|
|
352
|
+
if (!to) return { ok: false, error: 'no target version to preview' };
|
|
353
|
+
const verdict = (deps.preflightUpgrade || preflightUpgrade)(inst, to, deps);
|
|
354
|
+
await db.query(
|
|
355
|
+
`INSERT INTO provisioning_core_version_snapshot (instance_id, preflight, preflight_at, observed_at)
|
|
356
|
+
VALUES ($1, $2::jsonb, now(), now())
|
|
357
|
+
ON CONFLICT (instance_id) DO UPDATE
|
|
358
|
+
SET preflight = EXCLUDED.preflight, preflight_at = now()`,
|
|
359
|
+
[inst.id, JSON.stringify(verdict)]);
|
|
360
|
+
log(` ${verdict.ok ? '✓' : '✖'} ${inst.slug}: pre-flight for ${to} — ${verdict.ok ? 'clear' : verdict.error}`);
|
|
361
|
+
// ok:true means THE PREVIEW RAN. A refusal is a successful preview of a refusal — the
|
|
362
|
+
// intent must not go to 'error', or the owner's next arming is blocked by a stale open
|
|
363
|
+
// intent describing a check that worked perfectly.
|
|
364
|
+
return { ok: true, preflight: verdict };
|
|
365
|
+
}
|
|
366
|
+
|
|
293
367
|
/**
|
|
294
368
|
* Observe one instance and cache what the deploy control needs, without acting.
|
|
295
369
|
*
|
|
@@ -308,7 +382,8 @@ async function snapshotCoreVersions(inst, deps) {
|
|
|
308
382
|
if (!UPGRADE_SHAPES.has(inst.hosting_shape) || inst.status !== 'active') return { ok: true, skipped: true };
|
|
309
383
|
const boxExec = inst.hosting_shape === 'standalone' ? (deps.controlExec || exec) : exec;
|
|
310
384
|
const channelMod = deps.updateChannel || require('./update-channel.js');
|
|
311
|
-
const
|
|
385
|
+
const selfRoot = deps.selfInstanceRoot || selfInstanceRoot;
|
|
386
|
+
const instanceDir = inst.hosting_shape === 'standalone' ? standaloneRoot(inst) : selfRoot();
|
|
312
387
|
try {
|
|
313
388
|
const probe = boxExec(versionCmd(inst), { allowFail: true });
|
|
314
389
|
const served = parseServedVersion(probe && !probe.softFailed ? (probe.stdout || '') : null);
|
|
@@ -323,14 +398,26 @@ async function snapshotCoreVersions(inst, deps) {
|
|
|
323
398
|
const recommended = served
|
|
324
399
|
? channelMod.resolveChannelTarget({ installed: served, channel: channelMod.DEFAULT_CHANNEL, available })
|
|
325
400
|
: null;
|
|
401
|
+
// THE TICK DOES NOT DRY-RUN. The verdict is produced on demand (preflightCoreUpgrade),
|
|
402
|
+
// because a check re-run every tick for a box that has been behind for hours spends a
|
|
403
|
+
// subprocess to re-learn the same answer, and is still missing in the one window that
|
|
404
|
+
// matters — the minutes after a release, which is when someone presses the button.
|
|
405
|
+
// The version probe below is cheap and genuinely per-tick; a dry run is not.
|
|
406
|
+
const preflight = null;
|
|
326
407
|
await db.query(
|
|
327
|
-
`INSERT INTO provisioning_core_version_snapshot
|
|
328
|
-
|
|
408
|
+
`INSERT INTO provisioning_core_version_snapshot
|
|
409
|
+
(instance_id, served_version, available, recommended, observed_at, preflight, preflight_at)
|
|
410
|
+
VALUES ($1, $2, $3::jsonb, $4, now(), $5::jsonb, $6)
|
|
329
411
|
ON CONFLICT (instance_id) DO UPDATE
|
|
330
412
|
SET served_version = EXCLUDED.served_version, available = EXCLUDED.available,
|
|
331
|
-
recommended = EXCLUDED.recommended, observed_at = now()
|
|
332
|
-
|
|
333
|
-
|
|
413
|
+
recommended = EXCLUDED.recommended, observed_at = now(),
|
|
414
|
+
-- A tick with nothing to preview must not ERASE the last verdict; it simply
|
|
415
|
+
-- has nothing newer to say. Only a real dry run overwrites one.
|
|
416
|
+
preflight = COALESCE(EXCLUDED.preflight, provisioning_core_version_snapshot.preflight),
|
|
417
|
+
preflight_at = COALESCE(EXCLUDED.preflight_at, provisioning_core_version_snapshot.preflight_at)`,
|
|
418
|
+
[inst.id, served, JSON.stringify(available), recommended || null,
|
|
419
|
+
preflight ? JSON.stringify(preflight) : null, preflight ? new Date() : null]);
|
|
420
|
+
return { ok: true, served, available: available.length, preflight };
|
|
334
421
|
} catch (e) {
|
|
335
422
|
log(` ~ core-version snapshot for ${inst.slug} skipped (${(e && e.message) || e})`);
|
|
336
423
|
return { ok: true, skipped: true };
|
|
@@ -352,4 +439,4 @@ async function snapshotAllCoreVersions(deps) {
|
|
|
352
439
|
return { seen };
|
|
353
440
|
}
|
|
354
441
|
|
|
355
|
-
module.exports = { UPGRADE_SHAPES, versionCmd, parseServedVersion, targetFault, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
|
|
442
|
+
module.exports = { UPGRADE_SHAPES, versionCmd, parseServedVersion, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
|