@bongos/core 1.19.789 → 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.
- package/.bongos-core.json +110 -40
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +6 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0293-the-owners-deploy-door-is-an-intent-the-control-plane-drains.md +124 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +129 -1
- package/docs/api-reference.md +5 -2
- package/docs/copy-inventory.md +31 -14
- package/docs/copy-registry.json +205 -31
- package/docs/module-api-changelog.md +2 -0
- package/docs/recipes/core-release-pipeline.md +12 -0
- package/migrations/core_241_core_upgrades_actor.sql +65 -0
- package/migrations/core_242_core_version_snapshot.sql +54 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
- package/modules/government/catalog.js +8 -0
- package/modules/government/migrations/government_014_core_pin_move.sql +46 -0
- package/modules/hall-ui/public/deploy.html +64 -0
- package/modules/hall-ui/public/deploy.js +188 -0
- package/modules/hall-ui/public/shell.js +4 -0
- package/modules/provisioning/control-plane-guard.js +102 -0
- package/modules/provisioning/core-upgrade.js +289 -0
- package/modules/provisioning/migrations/provisioning_018_core_upgrade_intent.sql +98 -0
- package/modules/provisioning/module.json +1 -1
- package/modules/provisioning/provisioning.js +8 -8
- package/modules/provisioning/routes/core-upgrade.js +95 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/enroll-control-plane.js +101 -0
- package/scripts/gds/provision-core-upgrade.js +311 -0
- package/scripts/gds/provision.js +4 -2
- package/scripts/gds/upgrade.js +53 -14
- package/src/bongos/auth.js +19 -0
- package/src/bongos/serve-internal.js +21 -0
- package/src/module-api.js +1 -1
- package/tests/auth_resolve_failure.mjs +4 -2
- package/tests/core_upgrade_door.mjs +408 -0
- package/tests/core_upgrade_runner.mjs +377 -0
- package/tests/hall_nav.mjs +4 -1
- package/tests/hall_page_gate_map.mjs +7 -0
- package/tests/provisioning_disconnect.mjs +30 -5
- package/tests/provisioning_paid_shape_gate.mjs +14 -4
- package/tests/provisioning_restart.mjs +25 -9
- package/tests/provisioning_settings_apply.mjs +11 -2
- 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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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 };
|