@bongos/core 1.19.950 → 1.19.952
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 +50 -35
- package/docs/module-api-changelog.md +4 -0
- package/modules/provisioning/capacity-gate.js +7 -1
- package/modules/provisioning/capacity.js +7 -1
- package/modules/provisioning/migrations/provisioning_021_byo_host_shape.sql +37 -0
- package/modules/provisioning/onboard-plan.js +11 -4
- package/modules/provisioning/paid-shape-gate.js +37 -8
- package/modules/provisioning/provisioning.js +6 -6
- package/modules/provisioning/routes/provisioning.js +15 -5
- package/modules/public-landing/public/projects.html +17 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/adopt-preflight.js +1 -1
- package/scripts/gds/device-flow-help.js +1 -1
- package/scripts/gds/init.js +25 -10
- package/scripts/gds/login.js +1 -1
- package/scripts/gds/provision-byo-host.js +68 -0
- package/scripts/gds/provision.js +2 -0
- package/scripts/gds/setup.js +1 -1
- package/src/module-api.js +1 -1
- package/tests/adopt_preflight.mjs +1 -1
- package/tests/bongos_login.mjs +1 -1
- package/tests/discord_approvals.mjs +1 -1
- package/tests/init.mjs +9 -7
- package/tests/module_api.mjs +1 -1
- package/tests/projects_hub_pre_uat.mjs +5 -1
- package/tests/provision_byo_host.mjs +122 -0
- package/tests/provisioning_capacity.mjs +5 -0
- package/tests/provisioning_capacity_gate.mjs +27 -7
- package/tests/provisioning_module_selection.mjs +9 -9
- package/tests/provisioning_onboard_mode.mjs +5 -5
- package/tests/provisioning_paid_shape_gate.mjs +76 -6
package/scripts/gds/init.js
CHANGED
|
@@ -87,7 +87,13 @@ const KNOWN_MODULES = ['game', 'art-pipeline', 'discord', 'dev-box'];
|
|
|
87
87
|
// request (POST /provisioning/instances validates slug/shape, and returns 404 when
|
|
88
88
|
// the module isn't enabled on the target instance — init then falls back to filing
|
|
89
89
|
// the manual production-topology task). ADR 0111 §3.
|
|
90
|
-
|
|
90
|
+
// ADR 0323: 'byo-host' is the free front door and the default; 'co-tenant' is RETIRED
|
|
91
|
+
// and the server refuses it outright, so it is absent here DELIBERATELY. Leaving it in
|
|
92
|
+
// would let init write a spec the create route then 403s — a local list that accepts
|
|
93
|
+
// more than the server does is worse than no list, because the refusal arrives a step
|
|
94
|
+
// later with no useful message. Dropping it means `--shape co-tenant` fails here, naming
|
|
95
|
+
// the shapes that work.
|
|
96
|
+
const HOSTING_SHAPES = ['byo-host', 'dedicated', 'standalone'];
|
|
91
97
|
|
|
92
98
|
// The Cloud Bongos core npm package an instance pins as a dependency (ADR 0108).
|
|
93
99
|
// `bongos init --adopt` LAYERS this onto an existing repo's package.json; `bongos
|
|
@@ -205,11 +211,11 @@ function provisioningSlug(spec) {
|
|
|
205
211
|
// block). THE ADR 0111 §3 ARM: infra answers become a real provisioning REQUEST (a
|
|
206
212
|
// provisioning_intents row the control-plane runner drains) instead of only a manual
|
|
207
213
|
// runbook task. Pure — no I/O; the caller POSTs it. Shape defaults to the cheapest
|
|
208
|
-
//
|
|
214
|
+
// byo-host ($0 to us — the owner supplies the host) per the budget DNA (ADR 0323).
|
|
209
215
|
function provisioningRequest(spec) {
|
|
210
216
|
const p = spec && spec.provisioning;
|
|
211
217
|
if (!isObj(p)) return null;
|
|
212
|
-
const shape = HOSTING_SHAPES.includes(p.shape) ? p.shape : '
|
|
218
|
+
const shape = HOSTING_SHAPES.includes(p.shape) ? p.shape : 'byo-host';
|
|
213
219
|
const body = { slug: provisioningSlug(spec), hosting_shape: shape };
|
|
214
220
|
if (p.domain) body.domain = String(p.domain).trim();
|
|
215
221
|
if (p.targetRef) body.target_ref = String(p.targetRef).trim();
|
|
@@ -334,12 +340,12 @@ function normalizeSpec(raw = {}) {
|
|
|
334
340
|
spec.modules = Array.isArray(spec.modules) ? spec.modules.filter((m) => KNOWN_MODULES.includes(m)) : [];
|
|
335
341
|
if (!spec.version.status) spec.version.status = 'building';
|
|
336
342
|
// Provisioning (ADR 0111 §3) — a block ⇒ the owner opted in. Coerce the shape to a
|
|
337
|
-
// known value (default the
|
|
343
|
+
// known value (default byo-host, the free front door) and trim the free-text refs; drop the
|
|
338
344
|
// block entirely when absent so provisioningRequest() reads it as "not requested".
|
|
339
345
|
if (isObj(spec.provisioning)) {
|
|
340
346
|
const p = spec.provisioning;
|
|
341
347
|
spec.provisioning = {
|
|
342
|
-
shape: HOSTING_SHAPES.includes(p.shape) ? p.shape : '
|
|
348
|
+
shape: HOSTING_SHAPES.includes(p.shape) ? p.shape : 'byo-host',
|
|
343
349
|
...(p.slug ? { slug: String(p.slug).trim() } : {}),
|
|
344
350
|
...(p.domain ? { domain: String(p.domain).trim() } : {}),
|
|
345
351
|
...(p.targetRef ? { targetRef: String(p.targetRef).trim() } : {}),
|
|
@@ -446,13 +452,20 @@ async function interview({ adopt = false } = {}) {
|
|
|
446
452
|
let provisioning;
|
|
447
453
|
const wantProv = (await ask(rl, ' Provision production hosting now? (y/N)', 'N')).toLowerCase();
|
|
448
454
|
if (wantProv === 'y' || wantProv === 'yes') {
|
|
449
|
-
|
|
455
|
+
// Describes each shape by WHAT IT DOES and never by its owner-facing product
|
|
456
|
+
// name. The stored value → owner-facing label translation lives in exactly one
|
|
457
|
+
// place — SHAPE_LABEL in modules/public-landing/public/projects.html (ADR 0323,
|
|
458
|
+
// and this module's CLAUDE.md says so) — so glossing '"cloud host"' here would
|
|
459
|
+
// be a second copy of it, in a file no designer renaming the product would
|
|
460
|
+
// think to open. A CLI prompt naming the stored value it is about to write is
|
|
461
|
+
// the right register anyway: this asks for a column value, not for a purchase.
|
|
462
|
+
const shape = (await ask(rl, ' Hosting shape? (byo-host = you run it on your own host / standalone = its own repo + pinned @bongos/core on the platform\'s box / dedicated = the same on its own droplet, priced by tier)', 'byo-host')).toLowerCase();
|
|
450
463
|
const domain = await ask(rl, ' Domain you own — bring-your-own (blank = the platform assigns <slug>.<its apex> when configured)?');
|
|
451
464
|
const targetRef = await ask(rl, ' Target credential ref? (which control-plane creds the runner should use; optional)');
|
|
452
465
|
let tier = '';
|
|
453
466
|
if (shape === 'dedicated') tier = await ask(rl, ' Droplet tier? (e.g. s-1vcpu-2gb; optional)');
|
|
454
467
|
provisioning = {
|
|
455
|
-
shape: HOSTING_SHAPES.includes(shape) ? shape : '
|
|
468
|
+
shape: HOSTING_SHAPES.includes(shape) ? shape : 'byo-host',
|
|
456
469
|
domain: domain || undefined,
|
|
457
470
|
targetRef: targetRef || undefined,
|
|
458
471
|
tier: tier || undefined,
|
|
@@ -484,7 +497,8 @@ async function interview({ adopt = false } = {}) {
|
|
|
484
497
|
// ---------------------------------------------------------------------------
|
|
485
498
|
|
|
486
499
|
// PURE (exported for tests) — the root START-HERE guide (task 1002404, goal
|
|
487
|
-
// 1000052
|
|
500
|
+
// 1000052; task 1003881 made it LAYER onto an existing README instead of
|
|
501
|
+
// skipping one — see layerReadme below). A freshly-CLONED instance must tell a human what to run FIRST: the
|
|
488
502
|
// `bongos` CLI ships INSIDE the not-yet-installed @bongos/core package, so
|
|
489
503
|
// `npm install` has to come before any `bongos` command (including `bongos dev`).
|
|
490
504
|
// `bongos init` runs npm install during scaffolding, but a later `git clone` of
|
|
@@ -624,8 +638,9 @@ function writeConfigs(spec, { dir, force, dryRun, coreDep, log = console.log })
|
|
|
624
638
|
log(` ✓ wrote ${path.relative(dir, modulesPath)}`);
|
|
625
639
|
log(` ✓ wrote ${path.relative(dir, hierarchyPath)}`);
|
|
626
640
|
log(` ✓ wrote ${path.relative(dir, pkgPath)} (pins ${CORE_PKG} → ${pinNote})`);
|
|
627
|
-
// README START-HERE (task 1002404): mint it when
|
|
628
|
-
// section. Never a blind overwrite — an owner's own
|
|
641
|
+
// README START-HERE (task 1002404, layering added by task 1003881): mint it when
|
|
642
|
+
// absent, else APPEND the marked section. Never a blind overwrite — an owner's own
|
|
643
|
+
// README content always survives, which is why the guide stopped being skipped.
|
|
629
644
|
layerReadme(branding, { dir, dryRun: false, log });
|
|
630
645
|
return { brandingPath, modulesPath, hierarchyPath, pkgPath, readmePath, wrote: true };
|
|
631
646
|
}
|
package/scripts/gds/login.js
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
const readline = require('node:readline');
|
|
24
24
|
const { spawnSync } = require('node:child_process');
|
|
25
25
|
const { saveSession, loadStoredSession, listStoredSessions } = require('./cli-lib');
|
|
26
|
-
// The device-flow refusal detector + its plain-language cure (task
|
|
26
|
+
// The device-flow refusal detector + its plain-language cure (task 1003881). A leaf
|
|
27
27
|
// module, NOT setup.js: setup requires this file for runDeviceFlow, so requiring it
|
|
28
28
|
// back would close a require cycle. Both callers depend on the leaf instead.
|
|
29
29
|
const { isDeviceFlowDisabled, deviceFlowFallbackLines } = require('./device-flow-help');
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// scripts/gds/provision-byo-host.js — the runner's answer for a 'byo-host' instance:
|
|
4
|
+
// stand up NOTHING (ADR 0323, task 1004141).
|
|
5
|
+
//
|
|
6
|
+
// "Bring your own host" means the OWNER runs the instance — their machine, their VPS,
|
|
7
|
+
// their own platform account. The platform's only job is to hold the record, so the
|
|
8
|
+
// runner's job here is defined ENTIRELY BY WHAT IT MUST NOT DO: no port allocation, no
|
|
9
|
+
// database or role, no `web.env` under /etc, no systemd unit, no Caddy snippet, no DNS
|
|
10
|
+
// record, no cloud API call, no shell on anybody's box.
|
|
11
|
+
//
|
|
12
|
+
// WHY IT IS A GUARD AND NOT A CASE IN THE SWITCH. `provisionInstance` runs four
|
|
13
|
+
// preflights before it mutates anything — box capacity, ingress, unix accounts, Caddy
|
|
14
|
+
// wiring — and every one of them asks a question about a machine the platform owns. On
|
|
15
|
+
// a byo-host row the honest answer to each is not "pass" or "fail" but "not ours to
|
|
16
|
+
// ask", so the shape has to return ABOVE them, not be handled after them. That is why
|
|
17
|
+
// the caller's line sits immediately under the already-active idempotency check.
|
|
18
|
+
//
|
|
19
|
+
// WHAT IT PREVENTED. Before this existed the shape fell through to the shared
|
|
20
|
+
// co-tenant/standalone standup. `standalone` is false for it, so boxExec was the
|
|
21
|
+
// CONTROL PLANE's own shell: the runner would have allocated a port, run createdb,
|
|
22
|
+
// written env under /etc, installed a unit and a Caddy snippet and reloaded the proxy —
|
|
23
|
+
// standing up, on our box, the one shape whose entire promise is that we never do.
|
|
24
|
+
// Nothing would have errored. A silent success is the failure mode, which is why the
|
|
25
|
+
// proof (`tests/provision_byo_host.mjs`) asserts ABSENCE — no exec, no write, no port,
|
|
26
|
+
// no database — instead of asserting that the right steps ran.
|
|
27
|
+
//
|
|
28
|
+
// WHY THE ROW STILL GOES 'active'. `active` records that the platform has nothing left
|
|
29
|
+
// to do, not that the platform is running it. Liveness for this shape is the ADR 0252
|
|
30
|
+
// pull against the owner-supplied origin: the ~5-minute liveness sweep probes it by
|
|
31
|
+
// domain and only ever writes the overlay columns, never `status`, so the card earns its
|
|
32
|
+
// verdict from an observation rather than from our runner's say-so.
|
|
33
|
+
//
|
|
34
|
+
// Its own file because scripts/gds/provision.js sits at the 1500-line oversized-file
|
|
35
|
+
// ratchet — the same reason provision-net.js, provision-units.js and
|
|
36
|
+
// provision-core-upgrade.js were carved out.
|
|
37
|
+
|
|
38
|
+
// The stored value. A literal, not a regex or a prefix match: a near-miss must reach the
|
|
39
|
+
// standup's own validation rather than be quietly treated as the shape we skip.
|
|
40
|
+
const BYO_HOST_SHAPE = 'byo-host';
|
|
41
|
+
|
|
42
|
+
function isByoHost(hostingShape) {
|
|
43
|
+
return hostingShape === BYO_HOST_SHAPE;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Record a byo-host instance as provisioned without touching any box. Reaches the
|
|
47
|
+
// database ONLY through the injected `provisioning` port (the status flip + the audit
|
|
48
|
+
// event) — it takes no executor and no writeFile, so there is nothing here that could
|
|
49
|
+
// mutate a machine even by mistake.
|
|
50
|
+
async function recordByoHost(inst, { db, provisioning, log, apply }) {
|
|
51
|
+
log(' [byo-host] the owner runs this one — no port, no database, no unit, no Caddy snippet, no DNS');
|
|
52
|
+
if (!apply) {
|
|
53
|
+
log(' [byo-host] would mark it active without touching any box');
|
|
54
|
+
return { ok: true, byoHost: true, stoodUp: false };
|
|
55
|
+
}
|
|
56
|
+
// error_note: null — a stale note from an earlier failed run must not ride into the
|
|
57
|
+
// active state (the same clearing the co-tenant/standalone tail does).
|
|
58
|
+
await provisioning.setInstanceStatus(db, inst.id, 'active', { error_note: null, provisioned_at: new Date().toISOString() });
|
|
59
|
+
await provisioning.recordEvent(db, {
|
|
60
|
+
instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'active',
|
|
61
|
+
detail: 'byo-host — the owner supplies the host; the platform stood up nothing',
|
|
62
|
+
actor: 'runner',
|
|
63
|
+
});
|
|
64
|
+
log(` ✓ ${inst.slug} recorded (byo-host) — nothing was stood up`);
|
|
65
|
+
return { ok: true, byoHost: true, stoodUp: false };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { BYO_HOST_SHAPE, isByoHost, recordByoHost };
|
package/scripts/gds/provision.js
CHANGED
|
@@ -59,6 +59,7 @@ const { installedCoreVersion, runnerBanner } = require('./runner-drift.js');
|
|
|
59
59
|
const { teardownInstance } = require('./provision-teardown.js');
|
|
60
60
|
const { disconnectInstance } = require('./provision-disconnect.js');
|
|
61
61
|
const { coreUpgradeInstance, preflightCoreUpgrade, snapshotAllCoreVersions } = require('./provision-core-upgrade.js'); // the owner's deploy door, ADR 0293
|
|
62
|
+
const { isByoHost, recordByoHost } = require('./provision-byo-host.js'); // stand up NOTHING for a byo-host row, ADR 0323
|
|
62
63
|
|
|
63
64
|
// WHAT supervises an instance's service (ADR 0306). systemd on every box we run — the
|
|
64
65
|
// seam exists so a plane without systemd is not a dead end. Injectable for tests.
|
|
@@ -250,6 +251,7 @@ async function provisionInstance(inst, deps) {
|
|
|
250
251
|
// Idempotency: an already-active instance is a no-op success (re-drained intent).
|
|
251
252
|
if (inst.status === 'active') { log(' already active — nothing to do'); return { ok: true, already: true }; }
|
|
252
253
|
|
|
254
|
+
if (isByoHost(inst.hosting_shape)) return recordByoHost(inst, deps); // stand up NOTHING, ABOVE every preflight — reasoning in provision-byo-host.js's header (ADR 0323)
|
|
253
255
|
// ── preflight: DNS token scope (task 1980) ─────────────────────────────────
|
|
254
256
|
// BEFORE committing to the address (allocating a port / creating a droplet / the
|
|
255
257
|
// step-5 DNS UPSERT), verify the control-plane Cloudflare token can actually reach
|
package/scripts/gds/setup.js
CHANGED
|
@@ -274,7 +274,7 @@ async function promptOnboardingConsent() {
|
|
|
274
274
|
}
|
|
275
275
|
|
|
276
276
|
// ── W1: the manifest-born App has Device Flow OFF (task 1003051) ─────────────
|
|
277
|
-
// The detector + the guidance moved to ./device-flow-help (task
|
|
277
|
+
// The detector + the guidance moved to ./device-flow-help (task 1003881) so
|
|
278
278
|
// `bongos login` can use the same words without closing a require cycle back
|
|
279
279
|
// through this file (setup requires login for runDeviceFlow). Re-exported here
|
|
280
280
|
// because both are part of setup.js's tested public surface.
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.952'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -101,7 +101,7 @@ t('preflightAdopt: a clean repo → no blocking; every category reports', () =>
|
|
|
101
101
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
-
// ---- ignored-incoming: the check that was missing (task
|
|
104
|
+
// ---- ignored-incoming: the check that was missing (task 1003881) ----------
|
|
105
105
|
//
|
|
106
106
|
// The failure it exists to stop: adopt onto a repo whose .gitignore already carries
|
|
107
107
|
// `.claude/`. The skills/hooks/settings are written to disk, `git add` drops them, the
|
package/tests/bongos_login.mjs
CHANGED
|
@@ -45,7 +45,7 @@ test('pollDecision maps the /access-requests/status body to a loop action', () =
|
|
|
45
45
|
assert.equal(pollDecision({}), 'wait');
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
-
// ---- the Device-Flow dead end (task
|
|
48
|
+
// ---- the Device-Flow dead end (task 1003881) -------------------------------
|
|
49
49
|
//
|
|
50
50
|
// Device Flow is OFF on every GitHub App created from a manifest and GitHub offers no
|
|
51
51
|
// API to switch it on, so terminal sign-in is broken from birth on a fresh instance —
|
|
@@ -170,7 +170,7 @@ await t('a DB error returns {error}, never throws', async () => {
|
|
|
170
170
|
assert.equal(r.outcome, 'error');
|
|
171
171
|
});
|
|
172
172
|
|
|
173
|
-
// ---- the outbound half must not outlive the inbound half (task
|
|
173
|
+
// ---- the outbound half must not outlive the inbound half (task 1003881) ----
|
|
174
174
|
//
|
|
175
175
|
// The 👍 this line invites is applied ONLY by discord-approvals.js, which lives in the
|
|
176
176
|
// `discord` module. onboarding (which posts) is default-ON; discord is default-OFF; and
|
package/tests/init.mjs
CHANGED
|
@@ -239,14 +239,16 @@ test('provisioningSlug: derives from productName, clamps to 40, strips trailing
|
|
|
239
239
|
test('provisioningRequest: null unless opted in; builds the POST body when opted in', () => {
|
|
240
240
|
assert.equal(init.provisioningRequest(fullSpec()), null); // no block → not requested
|
|
241
241
|
const co = init.provisioningRequest(provSpec({})); // empty block = opted in, defaults
|
|
242
|
-
assert.deepEqual(co, { slug: 'cloud-bongos', hosting_shape: '
|
|
242
|
+
assert.deepEqual(co, { slug: 'cloud-bongos', hosting_shape: 'byo-host' });
|
|
243
243
|
const full = init.provisioningRequest(provSpec({ shape: 'dedicated', domain: 'example.com', targetRef: 'do:main', tier: 's-1vcpu-2gb' }));
|
|
244
244
|
assert.deepEqual(full, { slug: 'cloud-bongos', hosting_shape: 'dedicated', domain: 'example.com', target_ref: 'do:main', tier: 's-1vcpu-2gb' });
|
|
245
245
|
});
|
|
246
246
|
|
|
247
|
-
test('provisioningRequest: unknown shape falls back to the
|
|
248
|
-
// normalizeSpec coerces a bad shape →
|
|
249
|
-
|
|
247
|
+
test('provisioningRequest: unknown shape falls back to byo-host, the free front door', () => {
|
|
248
|
+
// normalizeSpec coerces a bad shape → byo-host (ADR 0323; was co-tenant, which that
|
|
249
|
+
// decision retired — the server now refuses it, so falling back to it would write a
|
|
250
|
+
// spec the create route rejects a step later).
|
|
251
|
+
assert.equal(init.provisioningRequest(provSpec({ shape: 'kubernetes' })).hosting_shape, 'byo-host');
|
|
250
252
|
});
|
|
251
253
|
|
|
252
254
|
test('kickoffTasks: provisioning requested → production-topology is AUTOMATED (dropped), manual steps stay', () => {
|
|
@@ -264,7 +266,7 @@ test('kickoffTasks: provisioning requested → production-topology is AUTOMATED
|
|
|
264
266
|
test('normalizeSpec: coerces the provisioning block; drops it when absent', () => {
|
|
265
267
|
assert.equal(init.normalizeSpec({}).provisioning, undefined);
|
|
266
268
|
const n = init.normalizeSpec({ provisioning: { shape: 'DEDICATED', domain: ' Example.com ', junk: 1 } });
|
|
267
|
-
assert.equal(n.provisioning.shape, '
|
|
269
|
+
assert.equal(n.provisioning.shape, 'byo-host'); // unknown shape (case) → default
|
|
268
270
|
assert.equal(n.provisioning.domain, 'Example.com'); // trimmed
|
|
269
271
|
assert.equal(n.provisioning.junk, undefined); // stray field dropped
|
|
270
272
|
});
|
|
@@ -651,14 +653,14 @@ async function runSeedTests() {
|
|
|
651
653
|
|
|
652
654
|
await at('seedViaApi: provisioning 404 (module off) → falls back to the manual production-topology task', async () => {
|
|
653
655
|
const api = fakeApi([['/provisioning/instances', { ok: false, status: 404, data: { error: 'not_found' } }]]);
|
|
654
|
-
const out = await init.seedViaApi(provSpec({ shape: '
|
|
656
|
+
const out = await init.seedViaApi(provSpec({ shape: 'byo-host' }), { log: () => {}, api });
|
|
655
657
|
assert.equal(out.provisioned, false);
|
|
656
658
|
assert.ok(findTaskPost(api, /Production topology/), 'filed the manual fallback task');
|
|
657
659
|
});
|
|
658
660
|
|
|
659
661
|
await at('seedViaApi: provisioning error → also files the fallback task', async () => {
|
|
660
662
|
const api = fakeApi([['/provisioning/instances', { ok: false, status: 500, data: {} }]]);
|
|
661
|
-
const out = await init.seedViaApi(provSpec({ shape: '
|
|
663
|
+
const out = await init.seedViaApi(provSpec({ shape: 'byo-host' }), { log: () => {}, api });
|
|
662
664
|
assert.equal(out.provisioned, false);
|
|
663
665
|
assert.ok(findTaskPost(api, /Production topology/), 'filed the manual fallback task on error');
|
|
664
666
|
});
|
package/tests/module_api.mjs
CHANGED
|
@@ -48,7 +48,7 @@ const PUBLISHED_SURFACE = [
|
|
|
48
48
|
'artistGate', // added by task 1003575 (ADR 0241): the artist gate — off | advisory | strict, plus the pure verdict helpers over it. Two modules ask it two different questions ("file this review?" and "does an open one hold the deploy?") and both must resolve the knob through this one reader, never branding().project
|
|
49
49
|
'readApplicantProfileFromHub', // added by task 1002972 (privacy spec D7): the reviewer queue's LIVE per-render read of one applicant's hub profile view. NARROW by design — the underlying call is authenticated with this instance's hub client secret, and loadIdpConfig stays OFF the doorway so no module ever holds the credential that speaks for the whole project
|
|
50
50
|
'enabledDisciplines', // BV1.R81: instance offered-disciplines (1.9.0; onboarding restock backstop)
|
|
51
|
-
'isModuleEnabled', // task
|
|
51
|
+
'isModuleEnabled', // task 1003881: is ANOTHER module present? onboarding's Discord approve/decline control is only actionable when the `discord` module's reaction handler is loaded, so the posting half has to be able to ask
|
|
52
52
|
'registerProvider', 'hasProvider', 'resolve', 'resolveOptional', 'listPorts', 'verifyPortsSatisfied',
|
|
53
53
|
'on', 'emit', 'emitAsync', 'listEvents', 'seamSnapshot',
|
|
54
54
|
'contribute', 'contributions', 'collectContributions', 'listContributionPoints',
|
|
@@ -237,7 +237,11 @@ test('no wall-clock promises, no operator vocabulary on the stranger’s path',
|
|
|
237
237
|
assert.doesNotMatch(HUB, /about five minutes/, 'the empty state');
|
|
238
238
|
assert.doesNotMatch(HUB, /a runner does the real work/, 'the mine fine print');
|
|
239
239
|
assert.doesNotMatch(SCRIPT, /pinned platform core/, 'the review Shape row');
|
|
240
|
-
|
|
240
|
+
/* The wizard creates hosting_shape 'standalone', whose owner-facing name became
|
|
241
|
+
"cloud host" at ADR 0323 — "its own server" was ambiguous between that shape and
|
|
242
|
+
a dedicated one, and said nothing about WHO runs it. The assertion still guards the
|
|
243
|
+
same property this test is about: owner vocabulary, and no wall-clock promise. */
|
|
244
|
+
assert.match(SCRIPT, /\['Shape', 'Cloud host — we stand it up and run it for you, on the platform’s current release'\]/);
|
|
241
245
|
assert.doesNotMatch(HUB, /\/new-project<\/code> assistant/, 'the done panel’s closing line');
|
|
242
246
|
assert.match(HUB, /Sign-in switches on after the GitHub steps|switch sign-in on/, 'the address step does not promise sign-in "just works"');
|
|
243
247
|
assert.doesNotMatch(HUB, /Sign-in, sharing, everything just works/);
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// tests/provision_byo_host.mjs — the runner stands up NOTHING for a 'byo-host'
|
|
2
|
+
// instance (ADR 0323, task 1004141).
|
|
3
|
+
//
|
|
4
|
+
// PROVES ABSENCE, not that the right steps ran, and that is the whole point. The
|
|
5
|
+
// shape's promise is negative — the owner supplies the host, so the platform must
|
|
6
|
+
// allocate no port, create no database, write no file, install no unit, touch no
|
|
7
|
+
// proxy and publish no DNS record. Asserting "the byo-host path ran" would pass
|
|
8
|
+
// just as happily if the co-tenant standup ran underneath it, which is exactly the
|
|
9
|
+
// bug this file exists to catch: before the branch, 'byo-host' fell through to the
|
|
10
|
+
// shared standup on the CONTROL PLANE's own shell and nothing errored.
|
|
11
|
+
//
|
|
12
|
+
// Hermetic: every executor is a spy that RECORDS instead of acting, so a single
|
|
13
|
+
// stray command shows up as a failed assertion rather than as a mutated box.
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import { test } from 'node:test';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const P = require('../scripts/gds/provision.js');
|
|
20
|
+
|
|
21
|
+
const byoHost = {
|
|
22
|
+
id: 42, slug: 'mercury', hosting_shape: 'byo-host', status: 'requested',
|
|
23
|
+
owner_builder_id: 7, port: null, db_name: null, domain: 'mercury.example.com',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Spies for every way provisionInstance can reach a machine or a record. `exec`
|
|
27
|
+
// and `writeFile` THROW rather than return, so a leaked step fails loudly with the
|
|
28
|
+
// command in the message instead of being silently collected and forgotten.
|
|
29
|
+
function harness({ apply = true } = {}) {
|
|
30
|
+
const queries = [], statuses = [], events = [], logs = [];
|
|
31
|
+
const boom = (what) => (arg) => { throw new Error(`byo-host must not ${what}: ${String(arg).slice(0, 200)}`); };
|
|
32
|
+
const deps = {
|
|
33
|
+
apply, log: (m) => logs.push(String(m)), sleep: async () => {},
|
|
34
|
+
db: { query: async (sql, params) => { queries.push({ sql, params }); return { rows: [] }; } },
|
|
35
|
+
provisioning: {
|
|
36
|
+
setInstanceStatus: async (_db, id, status, patch = {}) => { statuses.push({ id, status, patch }); },
|
|
37
|
+
recordEvent: async (_db, e) => { events.push(e); },
|
|
38
|
+
recordLivenessProbe: async () => { throw new Error('byo-host must not record a loopback liveness probe'); },
|
|
39
|
+
recordDnsNote: async () => { throw new Error('byo-host must not write a DNS note'); },
|
|
40
|
+
clearReleasedDomains: async () => { throw new Error('byo-host must not release domains'); },
|
|
41
|
+
allocatePort: async () => { throw new Error('byo-host must not allocate a port'); },
|
|
42
|
+
},
|
|
43
|
+
exec: boom('run a command'),
|
|
44
|
+
controlExec: boom('run a control-plane command'),
|
|
45
|
+
writeFile: boom('write a file'),
|
|
46
|
+
controlWriteFile: boom('write a control-plane file'),
|
|
47
|
+
doApi: () => { throw new Error('byo-host must not call a cloud API'); },
|
|
48
|
+
checkDnsTokenScope: async () => { throw new Error('byo-host must not preflight a DNS zone'); },
|
|
49
|
+
checkCaddyWiring: () => { throw new Error('byo-host must not inspect Caddy'); },
|
|
50
|
+
ingressPlaneGap: () => { throw new Error('byo-host must not probe this plane for ingress'); },
|
|
51
|
+
accountPlaneGap: () => { throw new Error('byo-host must not probe this plane for unix accounts'); },
|
|
52
|
+
getGithubToken: async () => { throw new Error('byo-host must not need a repo credential'); },
|
|
53
|
+
};
|
|
54
|
+
return { deps, queries, statuses, events, logs };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
test('a byo-host provision touches no box: no exec, no file, no port, no database, no DNS', async () => {
|
|
58
|
+
const h = harness();
|
|
59
|
+
const r = await P.provisionInstance(byoHost, h.deps);
|
|
60
|
+
assert.equal(r.ok, true);
|
|
61
|
+
assert.equal(r.stoodUp, false, 'the result says plainly that nothing was stood up');
|
|
62
|
+
assert.equal(r.byoHost, true);
|
|
63
|
+
// Absence: the only DB work is the status flip + the event, both through the
|
|
64
|
+
// provisioning port. No raw query — a port allocation, a createdb or a
|
|
65
|
+
// capacity count would each have to go through db.query to happen at all.
|
|
66
|
+
assert.deepEqual(h.queries, [], 'no direct SQL — capacity counting, port allocation and the DB steps all query');
|
|
67
|
+
assert.equal(r.port, undefined, 'no port is reported because none is allocated');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('the row reaches active with provisioned_at set, and nothing else is patched', async () => {
|
|
71
|
+
const h = harness();
|
|
72
|
+
await P.provisionInstance(byoHost, h.deps);
|
|
73
|
+
assert.equal(h.statuses.length, 1);
|
|
74
|
+
const [{ id, status, patch }] = h.statuses;
|
|
75
|
+
assert.equal(id, 42);
|
|
76
|
+
assert.equal(status, 'active', "'active' means the platform has nothing left to do, not that it runs it");
|
|
77
|
+
assert.equal(patch.error_note, null, 'a stale note from an earlier failed run must not ride into active');
|
|
78
|
+
assert.ok(patch.provisioned_at, 'the record carries when the platform finished with it');
|
|
79
|
+
assert.equal(patch.port, undefined, 'no port is stored');
|
|
80
|
+
assert.equal(patch.db_name, undefined, 'no database is named');
|
|
81
|
+
assert.equal(h.events.length, 1);
|
|
82
|
+
assert.equal(h.events[0].event, 'active');
|
|
83
|
+
assert.match(h.events[0].detail, /byo-host/, 'the audit trail says which shape, so the absence is readable later');
|
|
84
|
+
assert.equal(h.events[0].actor, 'runner');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('a dry run writes nothing at all — not even the status', async () => {
|
|
88
|
+
const h = harness({ apply: false });
|
|
89
|
+
const r = await P.provisionInstance(byoHost, h.deps);
|
|
90
|
+
assert.equal(r.ok, true);
|
|
91
|
+
assert.equal(r.stoodUp, false);
|
|
92
|
+
assert.deepEqual(h.statuses, []);
|
|
93
|
+
assert.deepEqual(h.events, []);
|
|
94
|
+
assert.ok(h.logs.some((l) => /would mark it active/.test(l)), 'a dry run still says what it would do');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('the branch is keyed on the shape, not on a missing port — a domainless row behaves the same', async () => {
|
|
98
|
+
const h = harness();
|
|
99
|
+
const r = await P.provisionInstance({ ...byoHost, domain: null }, h.deps);
|
|
100
|
+
assert.equal(r.ok, true);
|
|
101
|
+
assert.equal(r.stoodUp, false);
|
|
102
|
+
assert.equal(h.statuses[0].status, 'active');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('an already-active byo-host row stays a plain idempotent no-op', async () => {
|
|
106
|
+
const h = harness();
|
|
107
|
+
const r = await P.provisionInstance({ ...byoHost, status: 'active' }, h.deps);
|
|
108
|
+
assert.deepEqual(r, { ok: true, already: true });
|
|
109
|
+
assert.deepEqual(h.statuses, [], 're-draining an intent must not re-stamp provisioned_at');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('the shape is absent from the runner shapes that DO get stood up', () => {
|
|
113
|
+
// The guard is above every preflight, so the proof that it cannot be reordered
|
|
114
|
+
// by accident is that the co-tenant/standalone standup is unreachable for it.
|
|
115
|
+
// Any other shape still reaches the first executor — that is the control.
|
|
116
|
+
const h = harness();
|
|
117
|
+
return assert.rejects(
|
|
118
|
+
P.provisionInstance({ ...byoHost, hosting_shape: 'standalone', domain: null }, h.deps),
|
|
119
|
+
/byo-host must not/,
|
|
120
|
+
'a non-byo-host shape still reaches the box steps — the branch is shape-scoped, not a global off switch',
|
|
121
|
+
);
|
|
122
|
+
});
|
|
@@ -84,6 +84,11 @@ t('placementBox: standalone → control plane always; co-tenant → the remote b
|
|
|
84
84
|
assert.deepEqual(placementBox('co-tenant', {}), { box: 'control-plane', shapes: ['standalone', 'co-tenant'], fixedMb: 750 });
|
|
85
85
|
assert.deepEqual(placementBox('co-tenant', { remoteHost: 'cohost' }), { box: 'overflow', shapes: ['co-tenant'], fixedMb: 630 });
|
|
86
86
|
assert.equal(placementBox('dedicated', {}), null);
|
|
87
|
+
// ADR 0323: a byo-host runs on the owner's own machine, so it has no box of ours and
|
|
88
|
+
// no shared ceiling — null for a STRONGER reason than dedicated's (dedicated is our
|
|
89
|
+
// droplet, just not a shared one). remoteHost must not change that answer.
|
|
90
|
+
assert.equal(placementBox('byo-host', {}), null);
|
|
91
|
+
assert.equal(placementBox('byo-host', { remoteHost: 'cohost' }), null);
|
|
87
92
|
});
|
|
88
93
|
|
|
89
94
|
t('placementVerdict: the ceiling refuses at N of N, the floor refuses under 256 MB free, an unmeasured floor cannot refuse', () => {
|
|
@@ -122,11 +122,21 @@ await ta('a box with one slot left admits the create', async () => {
|
|
|
122
122
|
assert.equal(lastCreateArgs && lastCreateArgs.slug, 'lantern');
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
-
await ta('an absent shape
|
|
125
|
+
await ta('an absent shape takes the byo-host default, so a full box does NOT refuse it', async () => {
|
|
126
|
+
// This assertion INVERTED at task 1004141 (ADR 0323), and the inversion is the point.
|
|
127
|
+
// The default used to be 'co-tenant', which lands on the control plane, so a shapeless
|
|
128
|
+
// create was counted against the ceiling and could be refused box_full. The default is
|
|
129
|
+
// now 'byo-host', which runs on the owner's own machine and consumes nothing here — so
|
|
130
|
+
// refusing it for OUR capacity would be refusing a project that costs us no memory.
|
|
131
|
+
//
|
|
132
|
+
// The real trap this pins: THREE places resolve an absent shape — createInstance's
|
|
133
|
+
// coercion, paid-shape-gate's authority lookup, and this gate's placement lookup. If
|
|
134
|
+
// they disagree, the gate prices a request against a shape the row will not take, and
|
|
135
|
+
// nothing errors; you just get a 409 on a create that should have succeeded.
|
|
126
136
|
reset({ count: 15 });
|
|
127
137
|
const res = await post({ slug: 'lantern', no_address: true });
|
|
128
|
-
assert.equal(res.status,
|
|
129
|
-
assert.equal(
|
|
138
|
+
assert.equal(res.status, 201, res.raw);
|
|
139
|
+
assert.equal(capacityReads, 0, 'a shape with no box of ours must not even read the count');
|
|
130
140
|
});
|
|
131
141
|
|
|
132
142
|
// ── boxes the web tier cannot count are the runner's ──────────────────────────────
|
|
@@ -138,15 +148,25 @@ await ta('dedicated is its own box: never refused here, and the count is never r
|
|
|
138
148
|
assert.equal(capacityReads, 0);
|
|
139
149
|
});
|
|
140
150
|
|
|
141
|
-
await ta('
|
|
151
|
+
await ta('byo-host runs on the OWNER\'s machine: never refused here, and the count is never read', async () => {
|
|
152
|
+
// ADR 0323's new shape, and the strongest form of "not our box": there is no machine
|
|
153
|
+
// of ours to place it on at all, so a full control plane must not refuse it and the
|
|
154
|
+
// count must not even be read. A full box turning away the one shape that costs us
|
|
155
|
+
// nothing would be the worst possible refusal.
|
|
142
156
|
reset({ count: 15 });
|
|
143
|
-
|
|
144
|
-
const res = await post({ slug: 'lantern', hosting_shape: 'co-tenant', no_address: true });
|
|
157
|
+
const res = await post({ slug: 'lantern', hosting_shape: 'byo-host', no_address: true });
|
|
145
158
|
assert.equal(res.status, 201, res.raw);
|
|
146
159
|
assert.equal(capacityReads, 0);
|
|
147
|
-
delete process.env.PROVISION_REMOTE_HOST;
|
|
148
160
|
});
|
|
149
161
|
|
|
162
|
+
// The co-tenant-under-remote-exec case this file used to assert HERE is now unreachable
|
|
163
|
+
// through the route: ADR 0323 retires that shape and paid-shape-gate.js refuses it
|
|
164
|
+
// before the capacity gate runs, so a route-level assertion would only ever be proving
|
|
165
|
+
// the 403. The placement rule itself is still live — the runner reads it for the rows
|
|
166
|
+
// that already carry the shape — and stays pinned at unit level in
|
|
167
|
+
// tests/provisioning_capacity.mjs ('placementBox: … co-tenant → the remote box only
|
|
168
|
+
// under remote-exec'). Moved rather than deleted, per the live-code rule.
|
|
169
|
+
|
|
150
170
|
await ta('the live-memory floor is left to the runner: slots free + memory tight still admits', async () => {
|
|
151
171
|
reset({ count: 3, mem: MEMINFO_TIGHT });
|
|
152
172
|
const res = await post(WIZARD);
|
|
@@ -235,7 +235,7 @@ await ta('POST /instances: the picker\'s ids ride the create request through to
|
|
|
235
235
|
|
|
236
236
|
// Act — the body the wizard files
|
|
237
237
|
const res = await post('/provisioning/instances', {
|
|
238
|
-
slug: 'mercury', hosting_shape: '
|
|
238
|
+
slug: 'mercury', hosting_shape: 'byo-host', type: 'game', modules: ['agents', 'discord'],
|
|
239
239
|
});
|
|
240
240
|
|
|
241
241
|
// Assert
|
|
@@ -245,7 +245,7 @@ await ta('POST /instances: the picker\'s ids ride the create request through to
|
|
|
245
245
|
|
|
246
246
|
await ta('POST /instances: an omitted modules key is still a valid create — skipping is supported, not an error', async () => {
|
|
247
247
|
lastCreateArgs = null; existingRow = null;
|
|
248
|
-
const res = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: '
|
|
248
|
+
const res = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'byo-host', type: 'game' });
|
|
249
249
|
assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
|
|
250
250
|
assert.equal(lastCreateArgs.modules, undefined, 'nothing is invented on the owner\'s behalf');
|
|
251
251
|
// …and the receipt already carries the set they will actually get.
|
|
@@ -255,7 +255,7 @@ await ta('POST /instances: an omitted modules key is still a valid create — sk
|
|
|
255
255
|
|
|
256
256
|
await ta('POST /instances: an explicit empty selection is accepted as an answer', async () => {
|
|
257
257
|
lastCreateArgs = null; existingRow = null;
|
|
258
|
-
const res = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: '
|
|
258
|
+
const res = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'byo-host', type: 'game', modules: [] });
|
|
259
259
|
assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
|
|
260
260
|
assert.deepEqual(lastCreateArgs.modules, []);
|
|
261
261
|
});
|
|
@@ -266,7 +266,7 @@ await ta('POST /instances: an unknown module is REFUSED and named, never silentl
|
|
|
266
266
|
// prevent, and the reason badProjectDetail was written beside this.
|
|
267
267
|
existingRow = null;
|
|
268
268
|
const res = await post('/provisioning/instances', {
|
|
269
|
-
slug: 'mercury', hosting_shape: '
|
|
269
|
+
slug: 'mercury', hosting_shape: 'byo-host', modules: ['discrod'],
|
|
270
270
|
});
|
|
271
271
|
assert.equal(res.status, 400, res.raw);
|
|
272
272
|
assert.equal(res.body.error.code, 'bad_modules');
|
|
@@ -277,7 +277,7 @@ await ta('POST /instances: an unknown module is REFUSED and named, never silentl
|
|
|
277
277
|
await ta('POST /instances: naming an always-on module is refused with the reason, not the generic miss', async () => {
|
|
278
278
|
existingRow = null;
|
|
279
279
|
const res = await post('/provisioning/instances', {
|
|
280
|
-
slug: 'mercury', hosting_shape: '
|
|
280
|
+
slug: 'mercury', hosting_shape: 'byo-host', modules: ['memory'],
|
|
281
281
|
});
|
|
282
282
|
assert.equal(res.status, 400, res.raw);
|
|
283
283
|
assert.equal(res.body.error.code, 'bad_modules');
|
|
@@ -286,11 +286,11 @@ await ta('POST /instances: naming an always-on module is refused with the reason
|
|
|
286
286
|
|
|
287
287
|
await ta('POST /instances: modules must be an array of strings', async () => {
|
|
288
288
|
existingRow = null;
|
|
289
|
-
const notArray = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: '
|
|
289
|
+
const notArray = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'byo-host', modules: 'discord' });
|
|
290
290
|
assert.equal(notArray.status, 400, notArray.raw);
|
|
291
291
|
assert.equal(notArray.body.error.code, 'validation_failed');
|
|
292
292
|
|
|
293
|
-
const notStrings = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: '
|
|
293
|
+
const notStrings = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'byo-host', modules: [7] });
|
|
294
294
|
assert.equal(notStrings.status, 400, notStrings.raw);
|
|
295
295
|
assert.equal(notStrings.body.error.code, 'validation_failed');
|
|
296
296
|
});
|
|
@@ -301,7 +301,7 @@ await ta('POST /instances: a re-request never changes an existing project\'s mod
|
|
|
301
301
|
// to hunt for one.
|
|
302
302
|
existingRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: ['agents'], detail: {} };
|
|
303
303
|
const res = await post('/provisioning/instances', {
|
|
304
|
-
slug: 'mercury', hosting_shape: '
|
|
304
|
+
slug: 'mercury', hosting_shape: 'byo-host', modules: ['discord'],
|
|
305
305
|
});
|
|
306
306
|
assert.equal(res.status, 200, res.raw);
|
|
307
307
|
assert.ok(res.body.modules_ignored, 'the dropped answer is reported, not swallowed');
|
|
@@ -589,7 +589,7 @@ await ta('POST /instances: a null answer at CREATION is accepted and stores noth
|
|
|
589
589
|
lastCreateArgs = null; existingRow = null;
|
|
590
590
|
|
|
591
591
|
const res = await post('/provisioning/instances', {
|
|
592
|
-
slug: 'mercury', hosting_shape: '
|
|
592
|
+
slug: 'mercury', hosting_shape: 'byo-host', type: 'game', detail: { team_shape: null },
|
|
593
593
|
});
|
|
594
594
|
|
|
595
595
|
assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
|
|
@@ -150,7 +150,7 @@ function post(path, body) {
|
|
|
150
150
|
await ta('POST /instances: onboard_mode=adopt is threaded through to createInstance', async () => {
|
|
151
151
|
lastCreateArgs = null;
|
|
152
152
|
const res = await post('/provisioning/instances', {
|
|
153
|
-
slug: 'demo', hosting_shape: '
|
|
153
|
+
slug: 'demo', hosting_shape: 'byo-host', target_ref: 'example-owner/demo', onboard_mode: 'adopt',
|
|
154
154
|
});
|
|
155
155
|
assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
|
|
156
156
|
assert.equal(lastCreateArgs && lastCreateArgs.onboardMode, 'adopt');
|
|
@@ -158,7 +158,7 @@ await ta('POST /instances: onboard_mode=adopt is threaded through to createInsta
|
|
|
158
158
|
|
|
159
159
|
await ta('POST /instances: onboard_mode absent → greenfield (unchanged behavior for every existing caller)', async () => {
|
|
160
160
|
lastCreateArgs = null;
|
|
161
|
-
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: '
|
|
161
|
+
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'byo-host' });
|
|
162
162
|
assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
|
|
163
163
|
assert.equal(lastCreateArgs && lastCreateArgs.onboardMode, 'greenfield');
|
|
164
164
|
});
|
|
@@ -167,7 +167,7 @@ await ta('POST /instances: an unknown onboard_mode is REFUSED (never silently co
|
|
|
167
167
|
// Coercing here would be worse than refusing: the caller believes it asked for one thing
|
|
168
168
|
// and the runner does another. The route names the valid set so the client can fix itself.
|
|
169
169
|
const res = await post('/provisioning/instances', {
|
|
170
|
-
slug: 'demo', hosting_shape: '
|
|
170
|
+
slug: 'demo', hosting_shape: 'byo-host', target_ref: 'a/b', onboard_mode: 'brownfield',
|
|
171
171
|
});
|
|
172
172
|
assert.equal(res.status, 400);
|
|
173
173
|
assert.equal(res.body.error.code, 'bad_onboard_mode');
|
|
@@ -204,8 +204,8 @@ await ta('POST /instances: adopt WITHOUT target_ref is refused — there is no r
|
|
|
204
204
|
// Without target_ref the runner would fall back to greenfield behavior on a row labelled
|
|
205
205
|
// adopt. Refuse at the door rather than store a lie.
|
|
206
206
|
for (const body of [
|
|
207
|
-
{ slug: 'demo', hosting_shape: '
|
|
208
|
-
{ slug: 'demo', hosting_shape: '
|
|
207
|
+
{ slug: 'demo', hosting_shape: 'byo-host', onboard_mode: 'adopt' },
|
|
208
|
+
{ slug: 'demo', hosting_shape: 'byo-host', onboard_mode: 'adopt', target_ref: ' ' },
|
|
209
209
|
]) {
|
|
210
210
|
const res = await post('/provisioning/instances', body);
|
|
211
211
|
assert.equal(res.status, 400, `expected 400 for ${JSON.stringify(body)}`);
|