@bongos/core 1.19.949 → 1.19.951
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 +43 -28
- 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/init.js +20 -7
- package/scripts/gds/provision-byo-host.js +68 -0
- package/scripts/gds/provision.js +2 -0
- package/src/bongos/module-scope-map.js +20 -11
- package/src/module-api.js +1 -1
- package/tests/init.mjs +9 -7
- 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
|
@@ -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
|
|
@@ -261,6 +261,13 @@ const MODULE_GLOBS = {
|
|
|
261
261
|
// the front door is this module's first customer, not its owner. ---
|
|
262
262
|
'ui-design': [
|
|
263
263
|
'modules/ui-design/',
|
|
264
|
+
// The `impeccable` design method's working tree — mocks/, review/, surfaces/.
|
|
265
|
+
// DESIGN.md:226 names .impeccable/mocks/world-assets/ as the SOURCE of cutouts
|
|
266
|
+
// that shipped to modules/public-landing/public/assets/world/, so this is
|
|
267
|
+
// ui-design's own output (task 1004146). Deliberately here and not in a
|
|
268
|
+
// protected module: ui-design is unprotected, so a ui builder keeps reach over
|
|
269
|
+
// their own working directory.
|
|
270
|
+
'.impeccable/',
|
|
264
271
|
'scripts/gds/design-sync.js', 'scripts/gds/figma-design-sync.js', 'scripts/gds/validate-design.js',
|
|
265
272
|
'scripts/gds/ui-kit-transform.js', // the kit's stub reaches the real serving transforms through this command (task 1003320)
|
|
266
273
|
'.claude/skills/design/', '.claude/skills/design-sync/', '.claude/skills/figma-design-sync/',
|
|
@@ -377,6 +384,19 @@ const MODULE_GLOBS = {
|
|
|
377
384
|
'.claude/settings.json', '.claude/hooks/',
|
|
378
385
|
'.husky/',
|
|
379
386
|
'.npmrc', '.gitleaks.toml',
|
|
387
|
+
// The SELF-HOST IMAGE (V4.R63, task 1202, ADR 0062): `docker compose up` brings
|
|
388
|
+
// up Postgres + a vanilla, neutral-branded instance with every feature module
|
|
389
|
+
// off. Assigned here by task 1004146 — same class as the .github/ and .husky/
|
|
390
|
+
// entries above and the .devcontainer/ one below: repo-root build config that
|
|
391
|
+
// defines WHAT RUNS. Not
|
|
392
|
+
// `provisioning`, which owns the control plane that stands up instances, not an
|
|
393
|
+
// image a stranger runs. They are not dev-box artifacts either, despite most
|
|
394
|
+
// inbound references coming from .devcontainer/ and the devcontainer workflow.
|
|
395
|
+
'Dockerfile', 'docker-compose.yml', 'docker-entrypoint.sh', '.dockerignore',
|
|
396
|
+
// Repo-level dotfiles, the class .gitattributes and .npmrc already sit in.
|
|
397
|
+
// .env.local.example is the local-secrets template, next to the .gitleaks.toml
|
|
398
|
+
// scanner config above (task 1004146).
|
|
399
|
+
'.gitignore', '.env.local.example',
|
|
380
400
|
// .devcontainer/ is the same class as the entries above it — repo-level
|
|
381
401
|
// developer-environment config, not a feature's territory (task 1004029).
|
|
382
402
|
// It belonged to NO module and was not in SHARED_GLOBS, so isCovered() said
|
|
@@ -557,17 +577,6 @@ const UNOWNED_ROOTS = Object.freeze({
|
|
|
557
577
|
// Generated packaging manifests — written by package-core.js, never hand-edited.
|
|
558
578
|
'.bongos-core.json': 'generated package manifest (scripts/gds/package-core.js)',
|
|
559
579
|
'.cloudbongos-core.json': 'generated package manifest (scripts/gds/package-core.js)',
|
|
560
|
-
// Container build + repo-level dotfiles. review: devsecops already owns the
|
|
561
|
-
// neighbouring .npmrc / .gitattributes / package.json, so these plausibly belong
|
|
562
|
-
// to it — but that is a scope-wall change and wants its own decision.
|
|
563
|
-
'.dockerignore': 'review: container build, plausibly devsecops',
|
|
564
|
-
'Dockerfile': 'review: container build, plausibly devsecops',
|
|
565
|
-
'docker-compose.yml': 'review: container build, plausibly devsecops',
|
|
566
|
-
'docker-entrypoint.sh': 'review: container build, plausibly devsecops',
|
|
567
|
-
'.gitignore': 'review: repo-level dotfile, plausibly devsecops',
|
|
568
|
-
'.env.local.example': 'review: repo-level dotfile, plausibly devsecops',
|
|
569
|
-
// Design tooling checked into the tree. review: plausibly ui-design.
|
|
570
|
-
'.impeccable/': 'review: design tooling, plausibly ui-design',
|
|
571
580
|
// Governance + legal. Deliberately not methodology: methodology owns docs/, and
|
|
572
581
|
// these are the project's constitutional documents rather than its handbook.
|
|
573
582
|
'LICENSE': 'project legal text',
|
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.951'; // 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');
|
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
|
});
|
|
@@ -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)}`);
|