@bongos/core 1.19.788 → 1.19.790

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.bongos-core.json +111 -41
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +6 -0
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0293-the-owners-deploy-door-is-an-intent-the-control-plane-drains.md +124 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +129 -1
  10. package/docs/api-reference.md +5 -2
  11. package/docs/copy-inventory.md +31 -14
  12. package/docs/copy-registry.json +205 -31
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/recipes/core-release-pipeline.md +12 -0
  15. package/docs/recipes/upgrading-the-core.md +7 -1
  16. package/migrations/core_241_core_upgrades_actor.sql +65 -0
  17. package/migrations/core_242_core_version_snapshot.sql +54 -0
  18. package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
  19. package/modules/government/catalog.js +8 -0
  20. package/modules/government/migrations/government_014_core_pin_move.sql +46 -0
  21. package/modules/hall-ui/public/deploy.html +64 -0
  22. package/modules/hall-ui/public/deploy.js +188 -0
  23. package/modules/hall-ui/public/shell.js +4 -0
  24. package/modules/provisioning/control-plane-guard.js +102 -0
  25. package/modules/provisioning/core-upgrade.js +289 -0
  26. package/modules/provisioning/migrations/provisioning_018_core_upgrade_intent.sql +98 -0
  27. package/modules/provisioning/module.json +1 -1
  28. package/modules/provisioning/provisioning.js +8 -8
  29. package/modules/provisioning/routes/core-upgrade.js +95 -0
  30. package/package-lock.json +2 -2
  31. package/package.json +1 -1
  32. package/scripts/gds/enroll-control-plane.js +101 -0
  33. package/scripts/gds/provision-core-upgrade.js +311 -0
  34. package/scripts/gds/provision.js +4 -2
  35. package/scripts/gds/upgrade.js +53 -14
  36. package/src/bongos/auth.js +19 -0
  37. package/src/bongos/serve-internal.js +21 -0
  38. package/src/module-api.js +1 -1
  39. package/tests/auth_resolve_failure.mjs +4 -2
  40. package/tests/core_upgrade_door.mjs +408 -0
  41. package/tests/core_upgrade_runner.mjs +377 -0
  42. package/tests/hall_nav.mjs +4 -1
  43. package/tests/hall_page_gate_map.mjs +7 -0
  44. package/tests/provisioning_disconnect.mjs +30 -5
  45. package/tests/provisioning_paid_shape_gate.mjs +14 -4
  46. package/tests/provisioning_restart.mjs +25 -9
  47. package/tests/provisioning_settings_apply.mjs +11 -2
  48. package/tests/provisioning_teardown_intent.mjs +7 -0
@@ -0,0 +1,289 @@
1
+ 'use strict';
2
+
3
+ // modules/provisioning/core-upgrade.js — the owner's deploy door, web-tier half
4
+ // (task 1003159, ADR 0293).
5
+ //
6
+ // Moving an instance's core pin is gate 3 of the release pipeline and the only act
7
+ // that makes shipped work visible to users. It was reachable from exactly one
8
+ // place — a shell on the control-plane box with an ssh key — so the owner could not
9
+ // take it, and neither could an agent (the permission classifier blocks a mutating
10
+ // `sudo -u lars` over ssh by design).
11
+ //
12
+ // THIS FILE ONLY ASKS. It validates and queues an intent; the control-plane runner
13
+ // drains it and drives the proven `bongos upgrade` path with its health gate and
14
+ // auto-rollback intact. The web process gains no sudo and no ssh key — that split
15
+ // is ADR 0111 §2's rule and ADR 0293 D1's decision, and it is why a stolen builder
16
+ // token can request a deploy but never perform one.
17
+ //
18
+ // WHY THE REGISTRY CHECK IS NOT HERE, though ADR 0293 D3 demands one. The only
19
+ // tool that can answer "is this version published" is update-channel.js's
20
+ // `listAvailableVersions`, which shells out via spawnSync — it would block the
21
+ // entire event loop for the seconds npm takes, stalling every other request on a
22
+ // single-process server. It also lives in scripts/gds/, which a module may not
23
+ // require (ADR 0083). So publication and channel policy are enforced by the RUNNER,
24
+ // which is a CLI process where both are free. What is enforced here is everything
25
+ // that can be decided without the network: shape, state, format, and the guard.
26
+ //
27
+ // Lives in its own file for the mechanical reason paid-shape-gate.js and
28
+ // credential-preflight.js do: provisioning.js and routes/provisioning.js both sit
29
+ // AT the 1500-line fitness ratchet, and at that ceiling a new function costs more
30
+ // than the build will give back.
31
+
32
+
33
+ // Exactly the states in which there is a running instance whose pin can move.
34
+ // Narrower than RESTART_FROM on purpose: an instance mid-standup has no settled pin
35
+ // to move, and one that is tearing down is on its way out.
36
+ const CORE_UPGRADE_FROM = Object.freeze(new Set(['active']));
37
+
38
+ // The shapes whose box the runner can actually reach to perform an upgrade.
39
+ // Mirrors RESTART_SHAPES and adds the control plane (which the runner IS on).
40
+ //
41
+ // A 'dedicated' droplet is stood up by cloud-init and the runner has no post-hoc
42
+ // exec path to it — the same fact the restart route refuses on. Queueing an
43
+ // upgrade for one would be the F16 lie: a request that is accepted and never
44
+ // lands. This is a SEPARATE question from the control-plane guard, which asks
45
+ // what may be aimed at the platform itself; a first cut conflated the two and
46
+ // silently accepted upgrades for dedicated droplets.
47
+ const UPGRADE_SHAPES = Object.freeze(new Set(['co-tenant', 'standalone', 'control-plane']));
48
+
49
+ // The shape of a core version as the pin records it. Deliberately strict and
50
+ // three-part: the column's own CHECK carries the same pattern, so a value that
51
+ // slips past one still cannot reach the table. This says the string is WELL-FORMED,
52
+ // never that the version exists — see the header.
53
+ const VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+$/;
54
+
55
+ const isCoreVersion = (v) => typeof v === 'string' && VERSION_RE.test(v);
56
+
57
+ /**
58
+ * Why this core-upgrade request must be refused, or null to admit it. PURE.
59
+ *
60
+ * Returns the shape credential-preflight.js established — `{ code, status, message }`
61
+ * for the route to `res.fail` with — so the refusal text lives beside the rule that
62
+ * produced it rather than being re-derived at the call site. Every message names
63
+ * what the caller can actually DO instead, which is the ADR 0190 §7 convention, and
64
+ * none of them promises a duration: how long a deploy takes is exactly what this
65
+ * layer cannot know.
66
+ *
67
+ * @param {object} inst the provisioning_instances row
68
+ * @param {string} to the requested target core version
69
+ * @param {string?} live the version the instance currently serves, when known
70
+ */
71
+ function coreUpgradeFault(inst, to, live = null) {
72
+ // UPGRADE_SHAPES IS THE WHOLE SHAPE CHECK, and the self-row guard is deliberately
73
+ // NOT consulted here. `intentAllowedForShape('core-upgrade', …)` returns true for
74
+ // every shape there is — for a tenant shape unconditionally, and for the control
75
+ // plane because 'core-upgrade' is permanently a member of CONTROL_PLANE_ACTIONS.
76
+ // A first cut called it anyway, which read as a second gate and could not refuse
77
+ // anything: the same "present but unreachable" failure this file's own header
78
+ // records about `inst.core_version ||`. The guard's real job is refusing the OTHER
79
+ // actions on the self row, and it does that at enqueueIntent, where every intent
80
+ // passes. Naming it a third time here would only make the dead branch look alive.
81
+ if (!UPGRADE_SHAPES.has(inst.hosting_shape)) {
82
+ return {
83
+ code: 'core_upgrade_unavailable',
84
+ status: 409,
85
+ message: `This project runs on a machine the platform doesn’t manage, so its core can’t be moved from here — whoever runs that machine has to upgrade it there.`,
86
+ };
87
+ }
88
+ if (!CORE_UPGRADE_FROM.has(inst.status)) {
89
+ const offline = inst.status === 'torn_down' || inst.status === 'tearing_down';
90
+ return {
91
+ code: 'not_upgradable',
92
+ status: 409,
93
+ message: offline
94
+ ? 'This project is offline, so there is no running core to move — stand it up again first.'
95
+ : 'This project hasn’t finished standing up yet, so it has no settled core version to move.',
96
+ };
97
+ }
98
+ if (!isCoreVersion(to)) {
99
+ return {
100
+ code: 'bad_target_version',
101
+ status: 400,
102
+ message: 'Name the exact core version to move to, as three numbers — for example 1.19.757.',
103
+ };
104
+ }
105
+ // Asking for the version already running is not an error worth a 4xx on its own,
106
+ // but queueing it would restart the service to change nothing. Say so instead.
107
+ if (live && String(live) === String(to)) {
108
+ return {
109
+ code: 'already_on_version',
110
+ status: 409,
111
+ message: `This project already runs core ${to}, so there is nothing to move — it would restart and land exactly where it is.`,
112
+ };
113
+ }
114
+ return null;
115
+ }
116
+
117
+ /**
118
+ * Queue the upgrade. Mirrors enqueueRestart's arbitration of the single open slot.
119
+ *
120
+ * A core-upgrade already open is NOT reported as success the way a duplicate
121
+ * restart is: a restart carries no payload, so "already under way" is the truth,
122
+ * but this one names a TARGET and the open intent may name a different one.
123
+ * Reporting that as done would tell the owner their version is coming when another
124
+ * is. So a second request is a conflict the route names, including when the open
125
+ * intent is itself a core-upgrade.
126
+ */
127
+ async function enqueueCoreUpgrade(provisioning, db, instanceId, to, requestedBy) {
128
+ const { intent, created } = await provisioning.enqueueIntent(db, instanceId, 'core-upgrade', requestedBy, to);
129
+ if (created) return { intent, created: true };
130
+ return { intent, created: false, conflict: 'open_intent' };
131
+ }
132
+
133
+ /**
134
+ * The POST handler, as a factory over its dependencies.
135
+ *
136
+ * It lives here rather than inline in routes/provisioning.js because that file is
137
+ * AT the 1500-line fitness ratchet — the same reason callback-page.js and
138
+ * body-validators.js were carved out of it (see the module's CLAUDE.md). The
139
+ * router keeps the declaration and its gate, which is the part a reader scanning
140
+ * the route table needs to see; the body is one require away.
141
+ *
142
+ * THE SECOND STEP IS THE BODY. The owner previews, then confirms by naming the
143
+ * version they saw. Requiring an explicit `to` IS the confirmation (ADR 0293 D4):
144
+ * a request that cannot fire without naming its target cannot fire from a stray
145
+ * click, and "move to whatever is newest", resolved at drain time, would be a
146
+ * different act from the one that was previewed — main moves between the two.
147
+ */
148
+ function makeCoreUpgradeHandler({ provisioning, pool, parseId, validateOrRespond, log }) {
149
+ return async function coreUpgradeHandler(req, res) {
150
+ const id = parseId(req, res);
151
+ if (!id) return;
152
+ if (validateOrRespond(req, res, { to: { type: 'string', maxLength: 32 } }, { strict: true })) return;
153
+ try {
154
+ const inst = await provisioning.getInstanceById(pool, id);
155
+ if (!inst) return res.fail('instance_not_found', 404);
156
+ const to = req.body && typeof req.body.to === 'string' ? req.body.to.trim() : '';
157
+ // THE LIVE VERSION IS NOT KNOWN HERE, and saying so beats pretending.
158
+ // provisioning_instances carries no core_version column, so the first cut's
159
+ // `inst.core_version || null` was always undefined and the already-on-version
160
+ // refusal could never fire from this route — a guard that reads as present
161
+ // and is unreachable, which is worse than no guard at all.
162
+ //
163
+ // What actually refuses a same-version move is `bongos upgrade`'s own
164
+ // same-version guard, in the runner, which is also what makes re-running a
165
+ // settled-but-unconfirmed intent a no-op (ADR 0293 Consequences). The
166
+ // parameter stays because the PREVIEW does know the live version — it reads
167
+ // the box's own /version (ADR 0293 D6) — and passes it.
168
+ const fault = coreUpgradeFault(inst, to, null);
169
+ if (fault) return res.fail(fault.code, { status: fault.status, message: fault.message });
170
+
171
+ // The actor rides the intent, and the runner carries it through to the
172
+ // core_upgrades ledger (ADR 0293 D5): a privileged action with an
173
+ // unattributed row is not acceptable here.
174
+ const actor = `api:archon:${req.builder.id}`;
175
+ const r = await enqueueCoreUpgrade(provisioning, pool, id, to, actor);
176
+ if (r.conflict) {
177
+ return res.fail('intent_conflict', {
178
+ status: 409,
179
+ message: `A '${r.intent.action}' run is currently ${r.intent.state} for this project — the core move could not be queued; try again once that settles.`,
180
+ details: { action: r.intent.action, state: r.intent.state, target_version: r.intent.target_version || null },
181
+ });
182
+ }
183
+ await provisioning.recordEvent(pool, {
184
+ instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'core-upgrade',
185
+ detail: `core pin move to ${to} requested`, actor,
186
+ }).catch(() => {});
187
+ // No duration is promised and no success is claimed: this says the request
188
+ // was RECORDED, which is the only thing true at this moment (ADR 0190 §7).
189
+ return res.json({
190
+ ok: true, queued: true, action: 'core-upgrade', target_version: to,
191
+ message: `Queued a move to core ${to}. The project will restart; this page reports the version it actually serves afterwards, and rolls back on its own if the new one does not answer.`,
192
+ });
193
+ } catch (err) {
194
+ log.error('[provisioning] POST /provisioning/instances/:id/core-upgrade', err);
195
+ return res.fail('core_upgrade_failed', { status: 500, message: 'internal error' });
196
+ }
197
+ };
198
+ }
199
+
200
+ /**
201
+ * What the control shows BEFORE anything is pressed — step one of the two-step (D4).
202
+ *
203
+ * Reads the snapshot the control-plane runner writes (migration core_242), because
204
+ * neither fact is reachable from here: the served version lives on the box's loopback
205
+ * and the available versions need a blocking `npm view`. The web tier asks; it does not
206
+ * go and look.
207
+ *
208
+ * EVERY FIELD THAT COULD BE STALE CARRIES ITS AGE. `observed_at` is returned, not hidden,
209
+ * and a missing snapshot returns `observed_at: null` rather than a confident-looking
210
+ * empty one — a control that renders a cache as though it were live is how someone is
211
+ * told their box is on a version it left an hour ago.
212
+ */
213
+ function previewFromSnapshot(inst, snap, openIntent) {
214
+ const served = (snap && snap.served_version) || null;
215
+ const available = (snap && Array.isArray(snap.available) ? snap.available : []);
216
+ const recommended = (snap && snap.recommended) || null;
217
+ // Only what a move could actually go TO: newer than what is running, and admitted by
218
+ // the channel the runner will apply. Listing anything else would offer the owner a
219
+ // button whose press the runner then refuses.
220
+ const behind = served && recommended && recommended !== served;
221
+ return {
222
+ instance_id: inst.id,
223
+ slug: inst.slug,
224
+ hosting_shape: inst.hosting_shape,
225
+ served_version: served,
226
+ recommended_version: recommended,
227
+ available_count: available.length,
228
+ behind: !!behind,
229
+ // Named so the UI cannot accidentally render "up to date" over a missing reading.
230
+ reading: served ? 'ok' : 'no_answer',
231
+ observed_at: (snap && snap.observed_at) || null,
232
+ open_intent: openIntent ? { action: openIntent.action, state: openIntent.state, target_version: openIntent.target_version || null } : null,
233
+ };
234
+ }
235
+
236
+ async function loadPreview(pool, provisioning, inst) {
237
+ const { rows } = await pool.query('SELECT * FROM core_version_snapshot WHERE instance_id = $1', [inst.id]);
238
+ const openIntent = provisioning.getOpenIntent ? await provisioning.getOpenIntent(pool, inst.id) : null;
239
+ return previewFromSnapshot(inst, rows[0], openIntent);
240
+ }
241
+
242
+ function makeCoreUpgradePreviewHandler({ provisioning, pool, parseId, log }) {
243
+ return async function coreUpgradePreviewHandler(req, res) {
244
+ const id = parseId(req, res);
245
+ if (!id) return;
246
+ try {
247
+ const inst = await provisioning.getInstanceById(pool, id);
248
+ if (!inst) return res.fail('instance_not_found', 404);
249
+ return res.json({ ok: true, preview: await loadPreview(pool, provisioning, inst) });
250
+ } catch (err) {
251
+ log.error('[provisioning] GET /provisioning/instances/:id/core-upgrade', err);
252
+ return res.fail('core_upgrade_preview_failed', { status: 500, message: 'internal error' });
253
+ }
254
+ };
255
+ }
256
+
257
+ /**
258
+ * Where the deploy page starts: "which row is this platform?"
259
+ *
260
+ * THE PAGE CANNOT BE EXPECTED TO KNOW THE ID. The self row is created once, by hand, at
261
+ * install time (scripts/gds/enroll-control-plane.js) — so its id differs per install and
262
+ * is nobody's to memorise. The alternative was to have the page list the fleet and filter
263
+ * it client-side, which would make a page about ONE box read every project row to find it,
264
+ * and would answer nothing at all for an Archon who does not own the control-plane row.
265
+ *
266
+ * A MISSING SELF ROW IS A 404 WITH AN INSTRUCTION, not an error. It is the expected state
267
+ * of an install that has not run the enrolment yet, and the only useful thing to say is
268
+ * which command creates it.
269
+ */
270
+ function makeControlPlaneHandler({ provisioning, pool, log }) {
271
+ return async function controlPlaneHandler(req, res) {
272
+ try {
273
+ const { rows } = await pool.query("SELECT * FROM provisioning_instances WHERE hosting_shape = 'control-plane' LIMIT 1");
274
+ if (!rows[0]) {
275
+ return res.fail('control_plane_not_enrolled', {
276
+ status: 404,
277
+ message: 'This platform has no deploy door yet. Whoever administers the box runs `node scripts/gds/enroll-control-plane.js --owner <builderId> --apply` once, and it appears here.',
278
+ });
279
+ }
280
+ return res.json({ ok: true, preview: await loadPreview(pool, provisioning, rows[0]) });
281
+ } catch (err) {
282
+ log.error('[provisioning] GET /provisioning/control-plane', err);
283
+ return res.fail('control_plane_read_failed', { status: 500, message: 'internal error' });
284
+ }
285
+ };
286
+ }
287
+
288
+ module.exports = { CORE_UPGRADE_FROM, UPGRADE_SHAPES, VERSION_RE, isCoreVersion, coreUpgradeFault, enqueueCoreUpgrade, makeCoreUpgradeHandler, previewFromSnapshot, makeCoreUpgradePreviewHandler, makeControlPlaneHandler };
289
+
@@ -0,0 +1,98 @@
1
+ -- provisioning_018_core_upgrade_intent.sql — the owner's deploy door (task
2
+ -- 1003159, ADR 0293).
3
+ --
4
+ -- NUMBERED 018, NOT 016: this shipped as 016 on a branch while 016_disconnect and
5
+ -- 017_domain_lookup_index landed on main. Two files claiming one number is not a
6
+ -- merge artefact to tidy after the fact — migrations run in name order, and the
7
+ -- action CHECK below REPLACES the one 016_disconnect writes, so running them in
8
+ -- the wrong order would drop 'disconnect' from the vocabulary. It goes last, and
9
+ -- it carries the whole set.
10
+ --
11
+ -- Moving an instance's core pin is gate 3 of the release pipeline and the only act
12
+ -- that makes shipped work visible to users. Until now it was reachable from exactly
13
+ -- one place: a shell on the control-plane box holding an ssh key. The owner said so
14
+ -- twice in a month — "I don't think I can make anything live", then "this is above
15
+ -- my paygrade" — and an agent could not close the loop either, because the
16
+ -- permission classifier blocks a mutating `sudo -u lars` over ssh by design.
17
+ --
18
+ -- Two widenings, and they are two halves of one door:
19
+ --
20
+ -- 1. 'core-upgrade' joins the intent vocabulary. The web tier QUEUES it; the
21
+ -- control-plane runner drains it and drives the proven `bongos upgrade` path
22
+ -- with its health gate and auto-rollback intact. The web process gains no
23
+ -- sudo and no ssh key — it gains the ability to ASK (ADR 0293 D1).
24
+ --
25
+ -- 2. 'control-plane' joins the hosting shapes. `provisioning_intents.instance_id`
26
+ -- is NOT NULL REFERENCES provisioning_instances(id), so an intent must name a
27
+ -- row, and the box this software runs on has never had one — the table holds
28
+ -- only tenant projects. Enrolling it is what gives the door something to point
29
+ -- at (ADR 0293 D2).
30
+ --
31
+ -- THE SELF ROW IS THE DANGEROUS HALF, AND THE GUARD IS IN CODE, NOT HERE.
32
+ -- 'teardown' aimed at the control-plane row would destroy the platform from inside
33
+ -- the platform's own UI. `intentAllowedForShape()` in modules/provisioning/
34
+ -- provisioning.js is the single place that decides, as an ALLOW-list
35
+ -- (CONTROL_PLANE_ACTIONS = core-upgrade, restart) rather than a deny-list of the
36
+ -- destructive ones — so an action added to INTENT_ACTIONS later is refused on the
37
+ -- self row until someone states it is safe there. That is the direction which
38
+ -- cannot destroy the platform by omission.
39
+ --
40
+ -- A CHECK constraint cannot express that rule (it would have to see both tables at
41
+ -- once), which is exactly why the shape is a distinct VALUE: it makes the rule
42
+ -- expressible in the one place that can enforce it.
43
+ --
44
+ -- Additive and namespaced (ADR 0083): no down-migration, every object
45
+ -- provisioning_-prefixed, idempotent — safe to re-run.
46
+
47
+ BEGIN;
48
+
49
+ -- 1. the intent vocabulary
50
+ ALTER TABLE provisioning_intents DROP CONSTRAINT IF EXISTS provisioning_intents_action_check;
51
+ ALTER TABLE provisioning_intents ADD CONSTRAINT provisioning_intents_action_check
52
+ CHECK (action IN ('provision', 'teardown', 'oauth-manifest', 'domain-attach',
53
+ 'settings-apply', 'restart', 'disconnect', 'core-upgrade'));
54
+
55
+ -- 2. the hosting shapes
56
+ ALTER TABLE provisioning_instances DROP CONSTRAINT IF EXISTS provisioning_instances_hosting_shape_check;
57
+ ALTER TABLE provisioning_instances ADD CONSTRAINT provisioning_instances_hosting_shape_check
58
+ CHECK (hosting_shape IN ('co-tenant', 'dedicated', 'standalone', 'control-plane'));
59
+
60
+ -- 3. the target a core-upgrade names.
61
+ --
62
+ -- No existing action carries a payload: settings-apply re-reads the instance row,
63
+ -- restart carries nothing. A core-upgrade MUST name a version, because "upgrade to
64
+ -- whatever is newest" resolved at drain time is a DIFFERENT act from the one the
65
+ -- owner previewed and confirmed (ADR 0293 D4) — main can move between the two.
66
+ --
67
+ -- The invariant is an EQUIVALENCE, and both directions are load-bearing: a
68
+ -- core-upgrade with no target is an intent the runner cannot execute, and a target
69
+ -- on any other action is a value nothing reads — which is how a payload column
70
+ -- quietly becomes a junk drawer. Valid immediately rather than NOT VALID: every
71
+ -- pre-existing row has a non-core-upgrade action and a NULL target, so it holds.
72
+ --
73
+ -- The format CHECK keeps free text out at the DB layer as well as the route's. The
74
+ -- REGISTRY is still the authority on whether the version exists; that check lives
75
+ -- in the runner, which can reach npm without blocking a web request.
76
+ ALTER TABLE provisioning_intents ADD COLUMN IF NOT EXISTS target_version text;
77
+
78
+ DO $$
79
+ BEGIN
80
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'provisioning_intents_target_version_chk') THEN
81
+ ALTER TABLE provisioning_intents ADD CONSTRAINT provisioning_intents_target_version_chk
82
+ CHECK ((action = 'core-upgrade') = (target_version IS NOT NULL));
83
+ END IF;
84
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'provisioning_intents_target_version_fmt_chk') THEN
85
+ ALTER TABLE provisioning_intents ADD CONSTRAINT provisioning_intents_target_version_fmt_chk
86
+ CHECK (target_version IS NULL OR target_version ~ '^[0-9]+\.[0-9]+\.[0-9]+$');
87
+ END IF;
88
+ END $$;
89
+
90
+ -- AT MOST ONE control-plane row, ever. The self row means "the box this process is
91
+ -- running on"; two of them would make "which one is me?" a question with no answer,
92
+ -- and the deploy door would point at whichever the query happened to return first.
93
+ -- A partial unique index over a constant is the standard way to say "at most one".
94
+ CREATE UNIQUE INDEX IF NOT EXISTS provisioning_instances_one_control_plane_idx
95
+ ON provisioning_instances ((hosting_shape))
96
+ WHERE hosting_shape = 'control-plane';
97
+
98
+ COMMIT;
@@ -10,7 +10,7 @@
10
10
  "maintainer": "Off the Boats maintainers",
11
11
  "maintenance": { "status": "core-maintained" },
12
12
  "contributes": {
13
- "routes": ["provisioning", "disconnect"],
13
+ "routes": ["provisioning", "disconnect", "core-upgrade"],
14
14
  "migrations": true,
15
15
  "pollers": ["liveness-sweep", "catalog-backfill"]
16
16
  },
@@ -28,7 +28,7 @@ const INSTANCE_STATES = ['requested', 'provisioning', 'active', 'tearing_down',
28
28
  // dedicated — its own DigitalOcean droplet (priced by tier)
29
29
  // standalone — its OWN repo checkout + pinned @cloudbongos/core npm dep, co-located on
30
30
  // the box (ADR 0108 destination model; owner-hosted, $0). See provision.js.
31
- const HOSTING_SHAPES = ['co-tenant', 'dedicated', 'standalone'];
31
+ const HOSTING_SHAPES = ['co-tenant', 'dedicated', 'standalone', 'control-plane'];
32
32
  // How the instance's repo was onboarded (migration provisioning_004, ADR 0121):
33
33
  // greenfield — a fresh EMPTY repo; the runner scaffolds it and pushes the first commit
34
34
  // adopt — a repo the owner ALREADY has; the runner must LAYER onto it, preserving
@@ -59,7 +59,9 @@ const PROJECT_TYPES = ['game', 'research', 'business', 'non-profit', 'not-sure']
59
59
  // env patch. Its own action because no existing one fits: provision refuses
60
60
  // 'active' and settings-apply requires a value that actually changed.
61
61
  // 'disconnect' (task 1003249, migration provisioning_016): the owner's true delete — ./disconnect.js.
62
- const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart', 'disconnect'];
62
+ // 'core-upgrade' (task 1003159, ADR 0293): move this instance's core pin — the owner's
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'];
63
65
  // Every state a teardown can be asked for — i.e. everything except the one
64
66
  // state where there is provably nothing left to release. DERIVED from
65
67
  // INSTANCE_STATES on purpose (task 1003042, walk finding N5): the old hand-kept
@@ -1134,13 +1136,11 @@ async function setInstanceStatus(db, id, status, patch = {}) {
1134
1136
  // Enqueue a provision/teardown intent. Idempotent: the partial unique index
1135
1137
  // ux_provisioning_intents_open makes a second open enqueue collide (23505); we
1136
1138
  // then return the already-open intent with created:false (mirrors boxes.enqueueIntent).
1137
- async function enqueueIntent(db, instanceId, action, requestedBy) {
1139
+ async function enqueueIntent(db, instanceId, action, requestedBy, targetVersion = null) {
1140
+ await require('./control-plane-guard').assertIntentAllowed(db, instanceId, action); // ADR 0293 D2; required HERE because this file is at the size ratchet
1138
1141
  try {
1139
- const { rows } = await db.query(
1140
- `INSERT INTO provisioning_intents (instance_id, action, requested_by, state)
1141
- VALUES ($1, $2, $3, 'pending') RETURNING *`,
1142
- [instanceId, action, requestedBy ?? null]
1143
- );
1142
+ const { rows } = await db.query(`INSERT INTO provisioning_intents (instance_id, action, requested_by, state, target_version)
1143
+ VALUES ($1, $2, $3, 'pending', $4) RETURNING *`, [instanceId, action, requestedBy ?? null, targetVersion ?? null]);
1144
1144
  return { intent: rows[0], created: true };
1145
1145
  } catch (e) {
1146
1146
  if (e && e.code === '23505') {
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+
3
+ // modules/provisioning/routes/core-upgrade.js — the owner's deploy door
4
+ // (task 1003159, ADR 0293).
5
+ //
6
+ // Moving an instance's core pin is gate 3 of the release pipeline (ADR 0137) and
7
+ // the ONLY act that makes shipped work visible to users. Until this route it was
8
+ // reachable from exactly one place — a shell on the control-plane box holding an
9
+ // ssh key — which is why the owner said "I don't think I can make anything live",
10
+ // and why an agent could not close the loop for them either: the permission
11
+ // classifier blocks a mutating `sudo -u lars` over ssh by design.
12
+ //
13
+ // THE WEB TIER ONLY ASKS. This queues an intent; the control-plane runner drains it
14
+ // and drives the proven `bongos upgrade` path with its health gate and auto-rollback
15
+ // intact. This process gains no sudo, no ssh key, no new capability — ADR 0111 §2's
16
+ // rule and ADR 0293 D1's decision, and what keeps a stolen builder token able to
17
+ // REQUEST a deploy and never to perform one.
18
+ //
19
+ // ITS OWN ROUTER, not a block inside routes/provisioning.js, for the mechanical
20
+ // reason that file's own CLAUDE.md records: it sits AT the 1500-line fitness
21
+ // ratchet, and at that ceiling a route costs more lines than the build will give
22
+ // back. Multi-router modules are the norm here (lifecycle contributes eighteen), so
23
+ // this is the cheap, conventional shape rather than a workaround.
24
+
25
+ const express = require('express');
26
+ const api = require('../../../src/module-api');
27
+ const provisioning = require('../provisioning');
28
+ const { makeCoreUpgradeHandler, makeCoreUpgradePreviewHandler, makeControlPlaneHandler } = require('../core-upgrade');
29
+
30
+ const log = api.logger('provisioning');
31
+
32
+ module.exports = function buildCoreUpgradeRouter() {
33
+ const router = express.Router();
34
+
35
+ // POST /provisioning/instances/:id/core-upgrade — move the core pin.
36
+ //
37
+ // rank: `core.pin.move` — archon floor AND system:true, so the catalog's own
38
+ // invariant forbids granting it to metic and no custom rank can be handed it
39
+ // without a deliberate, visible edit. BV1.R105 / ADR 0151 is why this is an atom
40
+ // and not `requireRank`: tests/government_require_permission.mjs fails the build
41
+ // on a new requireRank in a modules/*/routes gate.
42
+ //
43
+ // Body: { to: "1.19.757" } — and requiring it IS the second step of the two-step
44
+ // the owner asked for (ADR 0293 D4). A request that cannot fire without naming
45
+ // its target cannot fire from a stray click, and "move to whatever is newest",
46
+ // resolved at drain time, would be a different act from the one previewed: main
47
+ // moves between the preview and the confirm.
48
+ //
49
+ // What this does NOT check: whether the version is PUBLISHED, and whether the
50
+ // channel policy admits it. Both need the registry, and the only tool that reads
51
+ // it (update-channel.js `listAvailableVersions`) shells out synchronously — it
52
+ // would block the event loop for every other request on this process. It also
53
+ // lives in scripts/gds/, which a module may not require (ADR 0083). The runner
54
+ // enforces both, where it is a CLI and both are free. See ../core-upgrade.js.
55
+ router.post(
56
+ '/provisioning/instances/:id/core-upgrade',
57
+ api.requireBuilder,
58
+ api.requirePermission('core.pin.move'),
59
+ makeCoreUpgradeHandler({
60
+ provisioning,
61
+ pool: api.pool,
62
+ parseId: api.parseId,
63
+ validateOrRespond: api.validateOrRespond,
64
+ log,
65
+ }),
66
+ );
67
+
68
+ // GET the same path — step ONE of the two-step (ADR 0293 D4): what a move would do.
69
+ //
70
+ // GATED THE SAME AS THE POST, deliberately. Reading it discloses the instance's exact
71
+ // core version and how far behind it is, which is a vulnerability-window disclosure —
72
+ // precisely the sort of thing ADR 0043 keeps off unauthenticated surfaces. A preview
73
+ // anyone could read would also let an attacker time a request against a known-stale
74
+ // window. The read being no cheaper than the write costs the owner nothing: they hold
75
+ // the atom by construction, because they are the only one who can press the button.
76
+ router.get(
77
+ '/provisioning/instances/:id/core-upgrade',
78
+ api.requireBuilder,
79
+ api.requirePermission('core.pin.move'),
80
+ makeCoreUpgradePreviewHandler({ provisioning, pool: api.pool, parseId: api.parseId, log }),
81
+ );
82
+
83
+ // GET /provisioning/control-plane — "which row is this platform, and where does it
84
+ // stand?" The deploy page's first call: the self row is created once by hand at
85
+ // install time, so its id is per-install and nobody's to memorise. Same atom again,
86
+ // so the entrance is no wider than the door it leads to.
87
+ router.get(
88
+ '/provisioning/control-plane',
89
+ api.requireBuilder,
90
+ api.requirePermission('core.pin.move'),
91
+ makeControlPlaneHandler({ provisioning, pool: api.pool, log }),
92
+ );
93
+
94
+ return router;
95
+ };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.788",
3
+ "version": "1.19.790",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.788",
9
+ "version": "1.19.790",
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.788",
3
+ "version": "1.19.790",
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",
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // scripts/gds/enroll-control-plane.js — give the platform a row so its own pin can move
5
+ // (task 1003159, ADR 0293 D2).
6
+ //
7
+ // WHY THIS EXISTS AT ALL. `provisioning_intents.instance_id` is NOT NULL REFERENCES
8
+ // provisioning_instances(id), so every intent must name a row. That table has only ever
9
+ // held tenant projects — the box this software runs on has no row — which is the
10
+ // mechanical reason the owner had no deploy door: there was nothing to point the door at.
11
+ //
12
+ // IDEMPOTENT, AND ONLY EVER ONE. provisioning_instances_one_control_plane_idx makes a
13
+ // second control-plane row impossible at the database, because "which row is me?" must
14
+ // have exactly one answer; this script resolves the existing row and reports it rather
15
+ // than racing that index.
16
+ //
17
+ // RUN ONCE PER INSTALL, by whoever already has the box — the same person who runs
18
+ // migrations. This is deliberately NOT something the web tier does for itself: a process
19
+ // that could mint its own control-plane row could mint a second one, and the whole value
20
+ // of the self row is that it is a thing someone decided to create. See
21
+ // docs/recipes/core-release-pipeline.md.
22
+ //
23
+ // node scripts/gds/enroll-control-plane.js --owner <builderId> [--slug cloudbongos]
24
+ // [--port 3002] [--domain cloudbongos.com] [--apply]
25
+ //
26
+ // Without --apply it prints what it would do and changes nothing, matching provision.js.
27
+
28
+ const { Pool } = require('pg');
29
+ const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
30
+
31
+ function arg(flag, argv, fallback = null) {
32
+ const i = argv.indexOf(flag);
33
+ return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : fallback;
34
+ }
35
+ const hasFlag = (flag, argv) => argv.includes(flag);
36
+
37
+ async function enrollControlPlane(db, { ownerBuilderId, slug, port, domain, apply }) {
38
+ // The slug ends up as a systemd unit name and inside the runner's shell commands,
39
+ // so it is validated at the door rather than left to whoever runs this. The runner
40
+ // re-checks it at the point of use too — this one is so the operator hears about a
41
+ // typo now, instead of as a refused upgrade weeks later.
42
+ if (!isValidSlug(slug)) {
43
+ return { ok: false, created: false, error: `'${slug}' is not a valid slug — lowercase letters, digits and single hyphens, 2-40 characters` };
44
+ }
45
+ const { rows: existing } = await db.query(
46
+ "SELECT * FROM provisioning_instances WHERE hosting_shape = 'control-plane'");
47
+ if (existing[0]) {
48
+ return { ok: true, created: false, instance: existing[0], reason: 'already enrolled' };
49
+ }
50
+ if (!apply) return { ok: true, created: false, wouldCreate: { slug, port, domain, ownerBuilderId } };
51
+ // status 'active' because it IS active — this process is the proof. A row parked at
52
+ // 'requested' would be refused by the upgrade leg's own status gate, so enrolling it
53
+ // any other way would produce a door that is visible and permanently refuses.
54
+ const { rows } = await db.query(
55
+ `INSERT INTO provisioning_instances
56
+ (owner_builder_id, slug, hosting_shape, domain, port, status, onboard_mode, description)
57
+ VALUES ($1, $2, 'control-plane', $3, $4, 'active', 'adopt', $5)
58
+ RETURNING *`,
59
+ [ownerBuilderId, slug, domain || null, port,
60
+ 'The control plane itself — the box this platform runs on. Enrolled so its core pin can be moved from the hall (ADR 0293); it is not a tenant project and cannot be torn down.']);
61
+ return { ok: true, created: true, instance: rows[0] };
62
+ }
63
+
64
+ async function main() {
65
+ const argv = process.argv.slice(2);
66
+ if (hasFlag('--help', argv)) {
67
+ console.log('usage: node scripts/gds/enroll-control-plane.js --owner <builderId> [--slug <slug>] [--port <port>] [--domain <domain>] [--apply]');
68
+ return 0;
69
+ }
70
+ const ownerBuilderId = arg('--owner', argv);
71
+ if (!ownerBuilderId) {
72
+ console.error('enroll-control-plane: --owner <builderId> is required — the self row needs an owner like any other, and an unattributed one is exactly what ADR 0293 D5 refuses.');
73
+ return 2;
74
+ }
75
+ const url = process.env.DATABASE_URL;
76
+ if (!url) { console.error('enroll-control-plane: DATABASE_URL is not set.'); return 2; }
77
+ const apply = hasFlag('--apply', argv);
78
+ const pool = new Pool({ connectionString: url });
79
+ try {
80
+ const r = await enrollControlPlane(pool, {
81
+ ownerBuilderId,
82
+ slug: arg('--slug', argv, 'cloudbongos'),
83
+ port: Number(arg('--port', argv, '3002')),
84
+ domain: arg('--domain', argv),
85
+ apply,
86
+ });
87
+ if (r.error) { console.error(`enroll-control-plane: ${r.error}`); return 2; }
88
+ if (r.created) console.log(`enrolled the control plane as instance #${r.instance.id} (${r.instance.slug}, port ${r.instance.port})`);
89
+ else if (r.instance) console.log(`already enrolled: instance #${r.instance.id} (${r.instance.slug}, port ${r.instance.port})`);
90
+ else console.log(`would enroll the control plane as ${JSON.stringify(r.wouldCreate)} — re-run with --apply`);
91
+ return 0;
92
+ } finally {
93
+ try { await pool.end(); } catch { /* ignore */ }
94
+ }
95
+ }
96
+
97
+ if (require.main === module) {
98
+ main().then((c) => process.exit(c)).catch((e) => { console.error(e.message); process.exit(1); });
99
+ }
100
+
101
+ module.exports = { enrollControlPlane };