@bongos/core 1.19.950 → 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 +42 -27
- package/docs/module-api-changelog.md +2 -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/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
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.951",
|
|
6
|
+
"core_contract": "1.19.951",
|
|
7
|
+
"source_commit": "52686c08170a959adbd2fff6681162b3b3e248ae",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-22T04:02:59.605Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 513,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2350,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2889,
|
|
20
|
+
"tree_sha256": "4c573e93b9c8147a0c7657ef1f9c43b7e3a0e37d6254e65502e205f91a77fdb0",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2687,7 +2687,7 @@
|
|
|
2687
2687
|
{
|
|
2688
2688
|
"path": "docs/module-api-changelog.md",
|
|
2689
2689
|
"mode": "0000644",
|
|
2690
|
-
"sha256": "
|
|
2690
|
+
"sha256": "707238093af0e4b56e36139173b6d46d7ffd33a86c348181a40dc74083821738"
|
|
2691
2691
|
},
|
|
2692
2692
|
{
|
|
2693
2693
|
"path": "docs/modules-contract.md",
|
|
@@ -6517,12 +6517,12 @@
|
|
|
6517
6517
|
{
|
|
6518
6518
|
"path": "modules/provisioning/capacity-gate.js",
|
|
6519
6519
|
"mode": "0000644",
|
|
6520
|
-
"sha256": "
|
|
6520
|
+
"sha256": "5d0d3eb34c68620b785803a40df416872e6a606cf6e144a9d2793f88bc231cb8"
|
|
6521
6521
|
},
|
|
6522
6522
|
{
|
|
6523
6523
|
"path": "modules/provisioning/capacity.js",
|
|
6524
6524
|
"mode": "0000644",
|
|
6525
|
-
"sha256": "
|
|
6525
|
+
"sha256": "56a2be9188fa0d8785174e6e91547c10266c3b6859fcad00aebd5739de4f366f"
|
|
6526
6526
|
},
|
|
6527
6527
|
{
|
|
6528
6528
|
"path": "modules/provisioning/catalog-bridge.js",
|
|
@@ -6649,6 +6649,11 @@
|
|
|
6649
6649
|
"mode": "0000644",
|
|
6650
6650
|
"sha256": "cdc3e8992d1e4273a472b826f208736235c8a3b16a700de33a7705cdae99ef9e"
|
|
6651
6651
|
},
|
|
6652
|
+
{
|
|
6653
|
+
"path": "modules/provisioning/migrations/provisioning_021_byo_host_shape.sql",
|
|
6654
|
+
"mode": "0000644",
|
|
6655
|
+
"sha256": "cde8a3a4c66c17d17c4e18866f11600a06073377de41d5ed70cd93185cd78d6d"
|
|
6656
|
+
},
|
|
6652
6657
|
{
|
|
6653
6658
|
"path": "modules/provisioning/module.json",
|
|
6654
6659
|
"mode": "0000644",
|
|
@@ -6657,12 +6662,12 @@
|
|
|
6657
6662
|
{
|
|
6658
6663
|
"path": "modules/provisioning/onboard-plan.js",
|
|
6659
6664
|
"mode": "0000644",
|
|
6660
|
-
"sha256": "
|
|
6665
|
+
"sha256": "c67f592415bb9c81e3eef658a7352c2bc47dfb4056b2cdef52f772e5021d28cf"
|
|
6661
6666
|
},
|
|
6662
6667
|
{
|
|
6663
6668
|
"path": "modules/provisioning/paid-shape-gate.js",
|
|
6664
6669
|
"mode": "0000644",
|
|
6665
|
-
"sha256": "
|
|
6670
|
+
"sha256": "cceda07080d1175a3629ebb2105e36220a2c2728768f2c6eb691f0269479a002"
|
|
6666
6671
|
},
|
|
6667
6672
|
{
|
|
6668
6673
|
"path": "modules/provisioning/pollers/catalog-backfill.js",
|
|
@@ -6682,7 +6687,7 @@
|
|
|
6682
6687
|
{
|
|
6683
6688
|
"path": "modules/provisioning/provisioning.js",
|
|
6684
6689
|
"mode": "0000644",
|
|
6685
|
-
"sha256": "
|
|
6690
|
+
"sha256": "82bb265d63bb882468bbb609faa60a783ccf7dfeec5cd4595550586c37131bda"
|
|
6686
6691
|
},
|
|
6687
6692
|
{
|
|
6688
6693
|
"path": "modules/provisioning/rate-limit.js",
|
|
@@ -6717,7 +6722,7 @@
|
|
|
6717
6722
|
{
|
|
6718
6723
|
"path": "modules/provisioning/routes/provisioning.js",
|
|
6719
6724
|
"mode": "0000644",
|
|
6720
|
-
"sha256": "
|
|
6725
|
+
"sha256": "a10d9ac50f6be584c33a6bb559ad9d0431c2ac7f4655c23fcdf47baedf540cd2"
|
|
6721
6726
|
},
|
|
6722
6727
|
{
|
|
6723
6728
|
"path": "modules/provisioning/seams.js",
|
|
@@ -7242,7 +7247,7 @@
|
|
|
7242
7247
|
{
|
|
7243
7248
|
"path": "modules/public-landing/public/projects.html",
|
|
7244
7249
|
"mode": "0000644",
|
|
7245
|
-
"sha256": "
|
|
7250
|
+
"sha256": "dab97906f2db723c0ec00aaf1a7930b7e0051098e6102704f65a6d0c705543ff"
|
|
7246
7251
|
},
|
|
7247
7252
|
{
|
|
7248
7253
|
"path": "modules/public-landing/public/projects.probes.json",
|
|
@@ -8042,12 +8047,12 @@
|
|
|
8042
8047
|
{
|
|
8043
8048
|
"path": "package-lock.json",
|
|
8044
8049
|
"mode": "0000644",
|
|
8045
|
-
"sha256": "
|
|
8050
|
+
"sha256": "14465332b60fa42b702654bd0dd552e8b332b3c17cc969bfe08c3cd8a720320a"
|
|
8046
8051
|
},
|
|
8047
8052
|
{
|
|
8048
8053
|
"path": "package.json",
|
|
8049
8054
|
"mode": "0000644",
|
|
8050
|
-
"sha256": "
|
|
8055
|
+
"sha256": "f7f40ce54ba7eddac00919f6407ee95888c15a87e3fc526450b64a00365cd913"
|
|
8051
8056
|
},
|
|
8052
8057
|
{
|
|
8053
8058
|
"path": "public-docs/index.html",
|
|
@@ -8767,7 +8772,7 @@
|
|
|
8767
8772
|
{
|
|
8768
8773
|
"path": "scripts/gds/init.js",
|
|
8769
8774
|
"mode": "0000644",
|
|
8770
|
-
"sha256": "
|
|
8775
|
+
"sha256": "d18b93dc0ac9136cfae79d98cd3506cb12d94fce30829a39dfb15f880770b93e"
|
|
8771
8776
|
},
|
|
8772
8777
|
{
|
|
8773
8778
|
"path": "scripts/gds/install-git-hooks.js",
|
|
@@ -8974,6 +8979,11 @@
|
|
|
8974
8979
|
"mode": "0000644",
|
|
8975
8980
|
"sha256": "b0e2c14720e8258eba4d4ef7f69cc2bcbb26c3be312deb891383dce7a1dbf71e"
|
|
8976
8981
|
},
|
|
8982
|
+
{
|
|
8983
|
+
"path": "scripts/gds/provision-byo-host.js",
|
|
8984
|
+
"mode": "0000644",
|
|
8985
|
+
"sha256": "92ef1fa760b90ece9781abe6d3c75abf84ff39cbb8b42194cca367bab7821269"
|
|
8986
|
+
},
|
|
8977
8987
|
{
|
|
8978
8988
|
"path": "scripts/gds/provision-capacity.js",
|
|
8979
8989
|
"mode": "0000644",
|
|
@@ -9027,7 +9037,7 @@
|
|
|
9027
9037
|
{
|
|
9028
9038
|
"path": "scripts/gds/provision.js",
|
|
9029
9039
|
"mode": "0000644",
|
|
9030
|
-
"sha256": "
|
|
9040
|
+
"sha256": "1d92b6cbbfb544328a8bacd29cb492445508617298c251872d846bb7ab5a180a"
|
|
9031
9041
|
},
|
|
9032
9042
|
{
|
|
9033
9043
|
"path": "scripts/gds/publish-credential-check.js",
|
|
@@ -10047,7 +10057,7 @@
|
|
|
10047
10057
|
{
|
|
10048
10058
|
"path": "src/module-api.js",
|
|
10049
10059
|
"mode": "0000644",
|
|
10050
|
-
"sha256": "
|
|
10060
|
+
"sha256": "156e1f0be121fb3fa396c32374f1ab8d0540f1d6c42a8aee28d05b5429a14115"
|
|
10051
10061
|
},
|
|
10052
10062
|
{
|
|
10053
10063
|
"path": "src/module-loader/catalog.js",
|
|
@@ -12277,7 +12287,7 @@
|
|
|
12277
12287
|
{
|
|
12278
12288
|
"path": "tests/init.mjs",
|
|
12279
12289
|
"mode": "0000644",
|
|
12280
|
-
"sha256": "
|
|
12290
|
+
"sha256": "9feeee7dbc2f1ae59a0af7eadbb521b74f114ce83e3f45ffdef59c1f8fdf25ec"
|
|
12281
12291
|
},
|
|
12282
12292
|
{
|
|
12283
12293
|
"path": "tests/instance_config.mjs",
|
|
@@ -13062,7 +13072,7 @@
|
|
|
13062
13072
|
{
|
|
13063
13073
|
"path": "tests/projects_hub_pre_uat.mjs",
|
|
13064
13074
|
"mode": "0000644",
|
|
13065
|
-
"sha256": "
|
|
13075
|
+
"sha256": "19e1b68463ddccb1a1155e1adfb3d4c130711745030183fb1d27650f1bfe5444"
|
|
13066
13076
|
},
|
|
13067
13077
|
{
|
|
13068
13078
|
"path": "tests/promote_deps_gate.mjs",
|
|
@@ -13104,6 +13114,11 @@
|
|
|
13104
13114
|
"mode": "0000644",
|
|
13105
13115
|
"sha256": "9f62eea1b43a478334a83df52d0e81a3b3b67f4c4eea54680d2d8db3b25ee513"
|
|
13106
13116
|
},
|
|
13117
|
+
{
|
|
13118
|
+
"path": "tests/provision_byo_host.mjs",
|
|
13119
|
+
"mode": "0000644",
|
|
13120
|
+
"sha256": "79c33863862966ef0d7a0f4d9dc2290596a4f98da2796083b9319c24a6bfc26e"
|
|
13121
|
+
},
|
|
13107
13122
|
{
|
|
13108
13123
|
"path": "tests/provision_caddy_wiring.mjs",
|
|
13109
13124
|
"mode": "0000644",
|
|
@@ -13137,12 +13152,12 @@
|
|
|
13137
13152
|
{
|
|
13138
13153
|
"path": "tests/provisioning_capacity.mjs",
|
|
13139
13154
|
"mode": "0000644",
|
|
13140
|
-
"sha256": "
|
|
13155
|
+
"sha256": "7a48fb54dce89efa207d7dd254a8a5ef70c6b1958b6ed2be198a73e76bb74327"
|
|
13141
13156
|
},
|
|
13142
13157
|
{
|
|
13143
13158
|
"path": "tests/provisioning_capacity_gate.mjs",
|
|
13144
13159
|
"mode": "0000644",
|
|
13145
|
-
"sha256": "
|
|
13160
|
+
"sha256": "363405d0f78714e9e3c29ee6ce64a1735a96fdaa95c631930743019daf8e26ff"
|
|
13146
13161
|
},
|
|
13147
13162
|
{
|
|
13148
13163
|
"path": "tests/provisioning_credential_preflight.mjs",
|
|
@@ -13182,17 +13197,17 @@
|
|
|
13182
13197
|
{
|
|
13183
13198
|
"path": "tests/provisioning_module_selection.mjs",
|
|
13184
13199
|
"mode": "0000644",
|
|
13185
|
-
"sha256": "
|
|
13200
|
+
"sha256": "8eaa3b40b38cbc294836ac3c4905f6a0a7c4a04e9c68d443e503f479a735c839"
|
|
13186
13201
|
},
|
|
13187
13202
|
{
|
|
13188
13203
|
"path": "tests/provisioning_onboard_mode.mjs",
|
|
13189
13204
|
"mode": "0000644",
|
|
13190
|
-
"sha256": "
|
|
13205
|
+
"sha256": "96c7ea0d646151393767061f042082f006007390ff652c7c649fd9151ab2ed61"
|
|
13191
13206
|
},
|
|
13192
13207
|
{
|
|
13193
13208
|
"path": "tests/provisioning_paid_shape_gate.mjs",
|
|
13194
13209
|
"mode": "0000644",
|
|
13195
|
-
"sha256": "
|
|
13210
|
+
"sha256": "859a44a139cfc7a52bc534dac8dc52f1203d60d5bdaac34e64834b6838e128fe"
|
|
13196
13211
|
},
|
|
13197
13212
|
{
|
|
13198
13213
|
"path": "tests/provisioning_recommendations.mjs",
|
|
@@ -2374,5 +2374,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2374
2374
|
landed since 1.19.948 with no explicit bump. run 35675336642. (task 1002620)
|
|
2375
2375
|
1.19.950 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2376
2376
|
landed since 1.19.949 with no explicit bump. run 35683452297. (task 1002620)
|
|
2377
|
+
1.19.951 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2378
|
+
landed since 1.19.950 with no explicit bump. run 35685346231. (task 1002620)
|
|
2377
2379
|
---------------------------------------------------------------------------
|
|
2378
2380
|
```
|
|
@@ -26,7 +26,13 @@ function requireRoomOnBox(db, log) {
|
|
|
26
26
|
return async function capacityGate(req, res, next) {
|
|
27
27
|
const body = req.body || {};
|
|
28
28
|
// The handler's own default and validation: a malformed request gets its 400 there.
|
|
29
|
-
|
|
29
|
+
// The absent-shape default MUST match createInstance's coercion and the paid-shape
|
|
30
|
+
// gate's, or this gate prices a request against a shape the row will not take. It
|
|
31
|
+
// was 'co-tenant' until ADR 0323; that shape lands on the control plane, so after the
|
|
32
|
+
// default moved to 'byo-host' — which runs on the OWNER's machine and takes no slot
|
|
33
|
+
// here — a shapeless create on a full box would have been refused box_full for
|
|
34
|
+
// capacity it never consumes. Three places default this; they agree deliberately.
|
|
35
|
+
const shape = body.hosting_shape || 'byo-host';
|
|
30
36
|
const slug = String(body.slug || '').trim().toLowerCase();
|
|
31
37
|
if (!provisioning.HOSTING_SHAPES.includes(shape) || !provisioning.isValidSlug(slug)) return next();
|
|
32
38
|
const target = capacity.placementBox(shape, { remoteHost: process.env.PROVISION_REMOTE_HOST || null });
|
|
@@ -68,8 +68,14 @@ function parseMeminfo(text) {
|
|
|
68
68
|
// local (task 2074); a co-tenant runs on the remote co-hosting box only when the
|
|
69
69
|
// runner is in remote-exec mode (ADR 0130), else here. A dedicated droplet is its
|
|
70
70
|
// own box and has no shared ceiling → null (no preflight).
|
|
71
|
+
//
|
|
72
|
+
// A 'byo-host' (ADR 0323) also answers null, for a stronger reason than dedicated's:
|
|
73
|
+
// the owner supplies the machine, so the platform has no box to place it on and no
|
|
74
|
+
// memory of its own to spend. It can never be refused for capacity, and it must never
|
|
75
|
+
// consume a slot that a cloud-host would otherwise get — which is what returning null
|
|
76
|
+
// here buys, since PLACED_STATES counting keys off the shapes a box declares.
|
|
71
77
|
function placementBox(hostingShape, { remoteHost = null } = {}) {
|
|
72
|
-
if (hostingShape === 'dedicated') return null;
|
|
78
|
+
if (hostingShape === 'dedicated' || hostingShape === 'byo-host') return null;
|
|
73
79
|
if (hostingShape === 'co-tenant' && remoteHost) {
|
|
74
80
|
return { box: 'overflow', shapes: ['co-tenant'], fixedMb: FIXED_MB.overflow };
|
|
75
81
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
-- provisioning_021 — admit the 'byo-host' hosting shape (ADR 0323, task 1004141).
|
|
2
|
+
--
|
|
3
|
+
-- "Bring your own host": the OWNER runs the instance on their own machine, VPS or
|
|
4
|
+
-- platform account. The platform stands up nothing and reaches into nothing, so the
|
|
5
|
+
-- row is a record of an instance we do not operate — liveness is the ADR 0252 pull
|
|
6
|
+
-- against an owner-supplied origin.
|
|
7
|
+
--
|
|
8
|
+
-- A CHECK-ENUM WIDENING, and nothing else. `DROP CONSTRAINT IF EXISTS` paired with a
|
|
9
|
+
-- re-`ADD CONSTRAINT` of the SAME name in the SAME migration only ever PERMITS more
|
|
10
|
+
-- values: no existing row changes and no data is lost, which is why fitness.js
|
|
11
|
+
-- (isConstraintWidening) treats this exact shape as additive rather than destructive.
|
|
12
|
+
-- The precedent is provisioning_002, which admitted 'standalone' the same way.
|
|
13
|
+
--
|
|
14
|
+
-- 'co-tenant' STAYS IN THE SET even though ADR 0323 retires it. Retirement closes the
|
|
15
|
+
-- DOOR, not the record: one torn-down row still carries that value, and dropping it
|
|
16
|
+
-- from the constraint would make a historical row violate its own table. The door is
|
|
17
|
+
-- closed in code instead — paid-shape-gate.js lists it UNREQUESTABLE, so no new row can
|
|
18
|
+
-- ever take it, while the old one stays readable. A shape that is storable but never
|
|
19
|
+
-- grantable again is exactly what that set is for.
|
|
20
|
+
--
|
|
21
|
+
-- The two managed shapes keep their OLD stored spellings ('standalone', 'dedicated')
|
|
22
|
+
-- on purpose. ADR 0323 renames them owner-facing to "cloud host" and "dedicated cloud
|
|
23
|
+
-- host", but about half the code reading those values is landlord machinery that task
|
|
24
|
+
-- 1004142 deletes when it moves them to a managed platform, and renaming a call site a
|
|
25
|
+
-- task before deleting it is work done twice. That task carries the data rename with
|
|
26
|
+
-- those files already open.
|
|
27
|
+
|
|
28
|
+
ALTER TABLE provisioning_instances DROP CONSTRAINT IF EXISTS provisioning_instances_hosting_shape_check;
|
|
29
|
+
ALTER TABLE provisioning_instances ADD CONSTRAINT provisioning_instances_hosting_shape_check
|
|
30
|
+
CHECK (hosting_shape IN ('byo-host', 'co-tenant', 'dedicated', 'standalone', 'control-plane'));
|
|
31
|
+
|
|
32
|
+
-- The COLUMN default was 'co-tenant' (provisioning_001), which is now a retired shape.
|
|
33
|
+
-- Every insert today goes through createInstance, which always passes an explicit shape,
|
|
34
|
+
-- so nothing currently relies on this default — which is exactly why it is worth moving
|
|
35
|
+
-- now rather than leaving a loaded gun for the first insert that ever omits the column.
|
|
36
|
+
-- Changing a DEFAULT rewrites no existing row.
|
|
37
|
+
ALTER TABLE provisioning_instances ALTER COLUMN hosting_shape SET DEFAULT 'byo-host';
|
|
@@ -34,11 +34,18 @@
|
|
|
34
34
|
// `shapes` (task 1002696; direction §7 — a surface renders only steps somebody real
|
|
35
35
|
// will perform on THIS instance's shape). The control-plane shapes (standalone /
|
|
36
36
|
// co-tenant / dedicated) have the platform's runner performing every 'auto' leg;
|
|
37
|
-
// '
|
|
38
|
-
// a
|
|
39
|
-
// with no `shapes` apply everywhere. Filter with stepsForShape().
|
|
37
|
+
// 'byo-host' has no runner, so the runner-performed legs are absent from its plan —
|
|
38
|
+
// a bring-your-own-host form must never promise them (QA walk finding, task 1002717).
|
|
39
|
+
// Steps with no `shapes` apply everywhere. Filter with stepsForShape().
|
|
40
|
+
//
|
|
41
|
+
// 'byo-host' REPLACED the plan-only spelling 'self-host' here (ADR 0323, task 1004141).
|
|
42
|
+
// That value was this file's own invention and had no callers anywhere else, so it was
|
|
43
|
+
// a second vocabulary for a concept the hosting shapes now name directly — and two words
|
|
44
|
+
// for one thing is how a filter starts silently matching nothing. The shape is absent
|
|
45
|
+
// from CONTROL_PLANE_SHAPES, which is what drops the runner-performed legs from its plan
|
|
46
|
+
// BY CONSTRUCTION rather than by a list someone has to remember to update.
|
|
40
47
|
const CONTROL_PLANE_SHAPES = ['standalone', 'co-tenant', 'dedicated'];
|
|
41
|
-
const ALL_SHAPES = [...CONTROL_PLANE_SHAPES, '
|
|
48
|
+
const ALL_SHAPES = [...CONTROL_PLANE_SHAPES, 'byo-host'];
|
|
42
49
|
|
|
43
50
|
function onboardPlan(mode = 'greenfield', opts = {}) {
|
|
44
51
|
const adopt = mode === 'adopt';
|
|
@@ -13,8 +13,15 @@
|
|
|
13
13
|
// the shapes it accepts is actually that front door. The others each hand the caller
|
|
14
14
|
// something the platform pays for or trusts:
|
|
15
15
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
16
|
+
// byo-host — the front door since ADR 0323. The OWNER supplies the host, so the
|
|
17
|
+
// platform pays nothing and runs none of the caller's code. No
|
|
18
|
+
// authority required, ever. This is the shape that replaced 'co-tenant'
|
|
19
|
+
// as the free option, and it is a STRONGER front door than co-tenant
|
|
20
|
+
// was: co-tenant was free of charge but still ran on our box, whereas
|
|
21
|
+
// this one never touches it.
|
|
22
|
+
// co-tenant — RETIRED (ADR 0323). Was the free front door; ran on the platform's
|
|
23
|
+
// own box, which is why the name could not be carried over to
|
|
24
|
+
// bring-your-own-host. Now UNREQUESTABLE — see below.
|
|
18
25
|
// dedicated — allocates a real droplet (`scripts/gds/provision.js`), billed
|
|
19
26
|
// monthly, defaulting to s-2vcpu-4gb when no tier is named.
|
|
20
27
|
// standalone — runs the caller's OWN repo on the platform's box. The unit's
|
|
@@ -61,11 +68,11 @@
|
|
|
61
68
|
const provisioning = require('./provisioning.js');
|
|
62
69
|
|
|
63
70
|
// The shapes any authenticated builder may request with no permission at all. This is
|
|
64
|
-
// the whole allow-list, and it is deliberately one entry long:
|
|
71
|
+
// the whole allow-list, and it is deliberately one entry long: byo-host is the front
|
|
65
72
|
// door, and the front door is the only thing that should be free. `tests/
|
|
66
73
|
// provisioning_paid_shape_gate.mjs` pins HOSTING_SHAPES against this set so a new shape
|
|
67
74
|
// cannot be added without someone deciding, in writing, which side of the line it is on.
|
|
68
|
-
const OPEN_SHAPES = Object.freeze(new Set(['
|
|
75
|
+
const OPEN_SHAPES = Object.freeze(new Set(['byo-host']));
|
|
69
76
|
|
|
70
77
|
// Shapes that are not requestable through this route AT ALL — no permission admits them,
|
|
71
78
|
// because there is no rank at which "ask the API for a second control plane" is a
|
|
@@ -75,7 +82,13 @@ const OPEN_SHAPES = Object.freeze(new Set(['co-tenant']));
|
|
|
75
82
|
// `WHERE hosting_shape = 'control-plane' LIMIT 1`, so a second row makes "which machine
|
|
76
83
|
// is the control plane" a question with a caller-influenced answer. Refused by name, at
|
|
77
84
|
// the door, for the same reason control-plane-guard.js refuses by name.
|
|
78
|
-
|
|
85
|
+
// 'co-tenant' joins it for a different reason (ADR 0323): it is RETIRED, not secret.
|
|
86
|
+
// It remains a valid stored value because a torn-down row still carries it, so it cannot
|
|
87
|
+
// simply leave HOSTING_SHAPES without making that row's history unreadable — and a shape
|
|
88
|
+
// that is storable but must never be granted again is exactly what 'unrequestable' means.
|
|
89
|
+
// Refusing it here rather than deleting the value is what makes the retirement complete
|
|
90
|
+
// without rewriting the past.
|
|
91
|
+
const UNREQUESTABLE_SHAPES = Object.freeze(new Set(['control-plane', 'co-tenant']));
|
|
79
92
|
|
|
80
93
|
// shapeAuthority — what does this wire value demand? PURE. Returns one of:
|
|
81
94
|
// 'open' → admit with no permission
|
|
@@ -88,7 +101,7 @@ const UNREQUESTABLE_SHAPES = Object.freeze(new Set(['control-plane']));
|
|
|
88
101
|
//
|
|
89
102
|
// 'defer' is safe despite looking like a hole: the validator runs immediately after this
|
|
90
103
|
// middleware and refuses anything outside HOSTING_SHAPES, and `createInstance` coerces an
|
|
91
|
-
// unrecognised shape to '
|
|
104
|
+
// unrecognised shape to 'byo-host' rather than storing it. There is no path by which a
|
|
92
105
|
// deferred value becomes a privileged row.
|
|
93
106
|
//
|
|
94
107
|
// Matches the exact wire value — a case or whitespace variant of a privileged shape is
|
|
@@ -96,7 +109,7 @@ const UNREQUESTABLE_SHAPES = Object.freeze(new Set(['control-plane']));
|
|
|
96
109
|
// admitted as a cheap one.
|
|
97
110
|
function shapeAuthority(rawShape) {
|
|
98
111
|
const shape = rawShape === undefined || rawShape === null || rawShape === ''
|
|
99
|
-
? '
|
|
112
|
+
? 'byo-host' // an absent shape is the documented default, and it is the open one
|
|
100
113
|
: rawShape;
|
|
101
114
|
if (typeof shape !== 'string') return 'defer';
|
|
102
115
|
if (!provisioning.HOSTING_SHAPES.includes(shape)) return 'defer';
|
|
@@ -115,9 +128,25 @@ function requireAuthorityForShape(requirePermission) {
|
|
|
115
128
|
const verdict = shapeAuthority(req.body && req.body.hosting_shape);
|
|
116
129
|
if (verdict === 'open' || verdict === 'defer') return next();
|
|
117
130
|
if (verdict === 'unrequestable') {
|
|
131
|
+
// Two shapes land here for different reasons, so the message names which.
|
|
132
|
+
//
|
|
133
|
+
// It names STORED VALUES and never an owner-facing label, deliberately. The
|
|
134
|
+
// stored value → owner-facing name translation lives in exactly one place —
|
|
135
|
+
// SHAPE_LABEL in modules/public-landing/public/projects.html (ADR 0323, and this
|
|
136
|
+
// module's CLAUDE.md says so) — and an API error string is the worst possible
|
|
137
|
+
// second copy of it: the next person to rename the product renames the label map
|
|
138
|
+
// and a wizard heading, and this sentence keeps selling a name that no longer
|
|
139
|
+
// exists. The right register for a 403 is the wire vocabulary the caller sent,
|
|
140
|
+
// which is also what `details.requestable` already hands them; a UI that wants to
|
|
141
|
+
// say it in product words has SHAPE_LABEL for exactly that. The guard against it
|
|
142
|
+
// creeping back is an assertion, not this comment — see
|
|
143
|
+
// ../../tests/provisioning_paid_shape_gate.mjs.
|
|
144
|
+
const retired = req.body.hosting_shape === 'co-tenant';
|
|
118
145
|
return res.fail('shape_not_requestable', {
|
|
119
146
|
status: 403,
|
|
120
|
-
message:
|
|
147
|
+
message: retired
|
|
148
|
+
? "That hosting shape has been retired and can no longer be requested. Ask for one of the shapes in `requestable` instead: `byo-host` runs on a host you supply, and the rest are stood up for you."
|
|
149
|
+
: 'The control plane enrolls its own row; it cannot be requested through this route.',
|
|
121
150
|
details: { hosting_shape: req.body.hosting_shape, requestable: [...provisioning.HOSTING_SHAPES].filter((s) => !UNREQUESTABLE_SHAPES.has(s)) },
|
|
122
151
|
});
|
|
123
152
|
}
|
|
@@ -24,11 +24,8 @@ const { normalizeModuleSelection, effectiveModules } = require('./starter-bundle
|
|
|
24
24
|
// Constants (mirror the CHECK constraints in provisioning_001_tables.sql)
|
|
25
25
|
// ---------------------------------------------------------------------------
|
|
26
26
|
const INSTANCE_STATES = ['requested', 'provisioning', 'active', 'tearing_down', 'torn_down', 'error'];
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
// standalone — its OWN repo checkout + pinned @cloudbongos/core npm dep, co-located on
|
|
30
|
-
// the box (ADR 0108 destination model; owner-hosted, $0). See provision.js.
|
|
31
|
-
const HOSTING_SHAPES = ['co-tenant', 'dedicated', 'standalone', 'control-plane'];
|
|
27
|
+
// Shapes, owner-facing names + why the spellings differ: ADR 0323 + this module's CLAUDE.md.
|
|
28
|
+
const HOSTING_SHAPES = ['byo-host', 'co-tenant', 'dedicated', 'standalone', 'control-plane'];
|
|
32
29
|
// How the instance's repo was onboarded (migration provisioning_004, ADR 0121):
|
|
33
30
|
// greenfield — a fresh EMPTY repo; the runner scaffolds it and pushes the first commit
|
|
34
31
|
// adopt — a repo the owner ALREADY has; the runner must LAYER onto it, preserving
|
|
@@ -1061,7 +1058,10 @@ async function listAllInstances(db, { status } = {}) {
|
|
|
1061
1058
|
// decide (same owner → idempotent re-request; different owner → 409). Never
|
|
1062
1059
|
// silently reassigns ownership.
|
|
1063
1060
|
async function createInstance(db, { ownerBuilderId, slug, targetRef, hostingShape, domain, dbName, port, tier, onboardMode, type, description, detail, modules }) {
|
|
1064
|
-
|
|
1061
|
+
// Unknown/absent coerces to the shape that costs nothing and trusts nothing. Was
|
|
1062
|
+
// 'co-tenant' (then the free front door); that shape is now retired + unrequestable,
|
|
1063
|
+
// so coercing to it would mint a row no caller is allowed to ask for.
|
|
1064
|
+
const shape = HOSTING_SHAPES.includes(hostingShape) ? hostingShape : 'byo-host';
|
|
1065
1065
|
// Unknown/absent → 'greenfield', matching the column default. Falling back to the
|
|
1066
1066
|
// SAFE mode matters: greenfield refuses to push over existing history (no --force
|
|
1067
1067
|
// anywhere), whereas defaulting an unknown value to 'adopt' would put the layering
|
|
@@ -12,10 +12,12 @@
|
|
|
12
12
|
//
|
|
13
13
|
// own-scoped (requireBuilder — the caller's OWN instances):
|
|
14
14
|
// POST /provisioning/instances — request a new instance
|
|
15
|
-
// (only `
|
|
16
|
-
// shape ALSO
|
|
17
|
-
//
|
|
18
|
-
//
|
|
15
|
+
// (only `byo-host` is free — ADR 0323 made it
|
|
16
|
+
// the front door; every other shape ALSO
|
|
17
|
+
// needs provisioning.fleet.manage, and
|
|
18
|
+
// `control-plane` and the RETIRED `co-tenant`
|
|
19
|
+
// are not requestable at all;
|
|
20
|
+
// ../paid-shape-gate.js.
|
|
19
21
|
// An `adopt` ALSO passes the credential
|
|
20
22
|
// preflight — ../credential-preflight.js,
|
|
21
23
|
// task 1002909; greenfield is exempt; a full box → 409, ../capacity-gate.js)
|
|
@@ -331,7 +333,15 @@ module.exports = function provisioningRoutes() {
|
|
|
331
333
|
if (!provisioning.isValidSlug(slug)) {
|
|
332
334
|
return res.fail('bad_slug', 400, { reason: 'lowercase, 2–40 chars of [a-z0-9-], no leading/trailing/double dash' });
|
|
333
335
|
}
|
|
334
|
-
|
|
336
|
+
// FOURTH and last place that resolves an absent shape, and they must all agree:
|
|
337
|
+
// this route, paid-shape-gate.js's authority lookup, capacity-gate.js's placement
|
|
338
|
+
// lookup, and createInstance's coercion. Until ADR 0323 they agreed on 'co-tenant'.
|
|
339
|
+
// When that shape was retired this line was the hole: the gate admits an absent
|
|
340
|
+
// shape as 'byo-host' (open), and then a stale default here would hand createInstance
|
|
341
|
+
// a literal 'co-tenant' — which IS in HOSTING_SHAPES, so the coercion never fires and
|
|
342
|
+
// the validator never complains, and a shapeless create quietly stores the one shape
|
|
343
|
+
// no caller is allowed to request. Nothing would have errored.
|
|
344
|
+
const shape = body.hosting_shape || 'byo-host';
|
|
335
345
|
if (!provisioning.HOSTING_SHAPES.includes(shape)) {
|
|
336
346
|
return res.fail('bad_hosting_shape', 400, { valid: provisioning.HOSTING_SHAPES });
|
|
337
347
|
}
|
|
@@ -3934,7 +3934,9 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
3934
3934
|
['Address', resolvedDomain()
|
|
3935
3935
|
? '<code>' + esc(resolvedDomain()) + '</code>'
|
|
3936
3936
|
: '<em style="font-weight:600;color:var(--warn)">none — sign-in stays locked until it has one</em>'],
|
|
3937
|
-
|
|
3937
|
+
/* The wizard creates hosting_shape 'standalone', whose owner-facing name is
|
|
3938
|
+
"cloud host" (ADR 0323) — we stand it up and run it for them. */
|
|
3939
|
+
['Shape', 'Cloud host — we stand it up and run it for you, on the platform’s current release']
|
|
3938
3940
|
];
|
|
3939
3941
|
$('reviewList').innerHTML = rows.map(function (r) {
|
|
3940
3942
|
return '<li><span class="k">' + r[0] + '</span><span class="v">' + r[1] + '</span></li>';
|
|
@@ -5205,7 +5207,20 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
5205
5207
|
|
|
5206
5208
|
/* ── rendering ───────────────────────────────────────────────────────── */
|
|
5207
5209
|
|
|
5208
|
-
|
|
5210
|
+
/* The owner-facing name of each hosting shape (ADR 0323). This map IS the translation
|
|
5211
|
+
between the stored value and the word the owner reads: 'standalone' and 'dedicated'
|
|
5212
|
+
keep their old spellings in the database on purpose (task 1004142 renames the data),
|
|
5213
|
+
so this is the ONLY place that difference should ever be visible. Read as a noun
|
|
5214
|
+
phrase — it lands in a meta list and in the "server" ledger row.
|
|
5215
|
+
'co-tenant' is retired and unrequestable, but one torn-down row still carries it,
|
|
5216
|
+
so it keeps a label rather than falling through to the raw value. */
|
|
5217
|
+
var SHAPE_LABEL = {
|
|
5218
|
+
'byo-host': 'your own host',
|
|
5219
|
+
standalone: 'cloud host',
|
|
5220
|
+
dedicated: 'dedicated cloud host',
|
|
5221
|
+
'co-tenant': 'shared server (retired)',
|
|
5222
|
+
'control-plane': 'the control plane'
|
|
5223
|
+
};
|
|
5209
5224
|
/* what a teardown ACTUALLY does, per hosting shape (F17 — the DECIDED policy:
|
|
5210
5225
|
retention is deliberate on shared boxes; a dedicated machine takes its
|
|
5211
5226
|
database with it). Copy follows the runner, never the other way around. */
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.951",
|
|
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.951",
|
|
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.951",
|
|
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",
|
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,
|
|
@@ -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/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)}`);
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
// provisioning.fleet.manage.
|
|
12
12
|
//
|
|
13
13
|
// The fix gates the SHAPE, not the route: ordinary project creation must not require a rank,
|
|
14
|
-
// so `co-tenant`
|
|
14
|
+
// so the front door — `byo-host` since ADR 0323, `co-tenant` before it — stays open to any
|
|
15
|
+
// authenticated builder. Everything else
|
|
15
16
|
// demands provisioning.fleet.manage, deliberately the same atom that already guards the fleet
|
|
16
17
|
// cost-ledger and force-teardown, since spending on the fleet and managing the fleet are one
|
|
17
18
|
// authority.
|
|
@@ -110,17 +111,79 @@ function post(path, body) {
|
|
|
110
111
|
|
|
111
112
|
// ── the front door stays open — this is the half a careless fix would break ───────
|
|
112
113
|
|
|
113
|
-
await ta('
|
|
114
|
+
await ta('byo-host (and an absent shape, which defaults to it) stays open with no permission', async () => {
|
|
115
|
+
// ADR 0323 moved the front door from 'co-tenant' to 'byo-host'. It is a STRONGER
|
|
116
|
+
// front door: co-tenant cost nothing to invoice but still ran the caller's process on
|
|
117
|
+
// our box, whereas byo-host runs on the owner's own machine and never touches ours.
|
|
118
|
+
// The absent-shape case is the one a careless fix breaks — createInstance coerces an
|
|
119
|
+
// unknown or missing shape to byo-host, and if the gate's own default disagreed with
|
|
120
|
+
// that coercion the wizard's shapeless create would 403 instead of 201.
|
|
114
121
|
for (const body of [
|
|
115
|
-
{ slug: 'demo', hosting_shape: '
|
|
122
|
+
{ slug: 'demo', hosting_shape: 'byo-host', no_address: true },
|
|
116
123
|
{ slug: 'demo', no_address: true },
|
|
117
124
|
]) {
|
|
118
125
|
held = [];
|
|
126
|
+
lastCreateArgs = null;
|
|
119
127
|
const res = await post('/provisioning/instances', body);
|
|
120
128
|
assert.equal(res.status, 201, `expected 201 for ${JSON.stringify(body)}, got ${res.status} ${res.raw}`);
|
|
129
|
+
// Assert the STORED shape, not just the status. A 201 alone does not prove the row is
|
|
130
|
+
// right: the create route resolves an absent shape independently of this gate, and at
|
|
131
|
+
// task 1004141 that default was briefly still 'co-tenant' — the gate admitted the
|
|
132
|
+
// request as byo-host and the route stored the retired shape, with no error anywhere.
|
|
133
|
+
// Checking the status only is what let that through.
|
|
134
|
+
assert.equal(lastCreateArgs && lastCreateArgs.hostingShape, 'byo-host',
|
|
135
|
+
`${JSON.stringify(body)} must STORE byo-host, got ${lastCreateArgs && lastCreateArgs.hostingShape}`);
|
|
121
136
|
}
|
|
122
137
|
});
|
|
123
138
|
|
|
139
|
+
await ta('co-tenant is RETIRED: refused for every caller, and not as a permission problem', async () => {
|
|
140
|
+
// ADR 0323 retires the shape whose name meant "a process on OUR box" — the opposite
|
|
141
|
+
// of bring-your-own-host, so the name could not be carried over. It stays a valid
|
|
142
|
+
// STORED value (one torn-down row carries it, and HOSTING_SHAPES still lists it) but
|
|
143
|
+
// becomes UNREQUESTABLE, which is the difference between closing a door and rewriting
|
|
144
|
+
// the record. The refusal must NOT be a 403-for-want-of-permission: no rank admits a
|
|
145
|
+
// retired shape, so granting fleet.manage must not open it either.
|
|
146
|
+
held = [];
|
|
147
|
+
lastCreateArgs = null;
|
|
148
|
+
let res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'co-tenant', no_address: true });
|
|
149
|
+
assert.equal(res.status, 403, `expected 403, got ${res.status} ${res.raw}`);
|
|
150
|
+
assert.equal(res.body.error.code, 'shape_not_requestable');
|
|
151
|
+
assert.equal(lastCreateArgs, null, 'a retired shape must not create a row');
|
|
152
|
+
|
|
153
|
+
held = ['provisioning.fleet.manage'];
|
|
154
|
+
res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'co-tenant', no_address: true });
|
|
155
|
+
assert.equal(res.status, 403, `every permission still must not admit it, got ${res.status} ${res.raw}`);
|
|
156
|
+
assert.equal(res.body.error.code, 'shape_not_requestable');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await ta('the refusal speaks WIRE vocabulary — no owner-facing label is translated here', async () => {
|
|
160
|
+
// The single-translation invariant, ENFORCED rather than stated. ADR 0323 puts the
|
|
161
|
+
// stored value → owner-facing name map in exactly one place (SHAPE_LABEL in
|
|
162
|
+
// modules/public-landing/public/projects.html), and an API error string is the worst
|
|
163
|
+
// place for a second copy: whoever renames the product next will change the label map
|
|
164
|
+
// and a wizard heading, and a sentence buried in a 403 will go on selling a name that
|
|
165
|
+
// no longer exists, with nothing failing. This assertion is what fails instead.
|
|
166
|
+
//
|
|
167
|
+
// It checks BOTH unrequestable shapes, because the retired one is the tempting case —
|
|
168
|
+
// it is the refusal that wants to suggest an alternative, which is exactly where a
|
|
169
|
+
// product name creeps in. Suggesting one is fine; `details.requestable` already does
|
|
170
|
+
// it in stored values, and the message points at that field.
|
|
171
|
+
held = [];
|
|
172
|
+
for (const shape of ['co-tenant', 'control-plane']) {
|
|
173
|
+
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: shape, no_address: true });
|
|
174
|
+
assert.equal(res.status, 403, `expected 403 for ${shape}, got ${res.status} ${res.raw}`);
|
|
175
|
+
const message = res.body.error.message || '';
|
|
176
|
+
for (const label of ['bring your own host', 'cloud host', 'dedicated cloud host']) {
|
|
177
|
+
assert.ok(!message.toLowerCase().includes(label),
|
|
178
|
+
`the ${shape} refusal translates a stored value into the owner-facing label "${label}" — that map lives only in projects.html's SHAPE_LABEL (ADR 0323): ${message}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// And the caller is still told what they CAN ask for, in the vocabulary they sent.
|
|
182
|
+
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'co-tenant', no_address: true });
|
|
183
|
+
assert.deepEqual([...res.body.error.details.requestable].sort(), ['byo-host', 'dedicated', 'standalone'],
|
|
184
|
+
'a refusal that names no alternative is a dead end — the stored values are the alternative');
|
|
185
|
+
});
|
|
186
|
+
|
|
124
187
|
// ── standalone runs the caller's OWN code on the platform's box (task 1003370) ────
|
|
125
188
|
|
|
126
189
|
await ta('standalone: REFUSED for a builder without provisioning.fleet.manage', async () => {
|
|
@@ -232,20 +295,27 @@ await ta('HOSTING_SHAPES is pinned, so a new shape forces a written decision abo
|
|
|
232
295
|
// POST for a second control plane until task 1003370.
|
|
233
296
|
//
|
|
234
297
|
// So the question this list now forces is the one that generalises. The gate is an
|
|
235
|
-
// allow-list: `
|
|
298
|
+
// allow-list: `byo-host` needs nothing, `control-plane` and `co-tenant` are refused
|
|
299
|
+
// outright (for different reasons — never-yours vs retired), and
|
|
236
300
|
// EVERY other shape — including one added after this file was last read — demands
|
|
237
301
|
// provisioning.fleet.manage by omission. A fifth shape therefore arrives already
|
|
238
302
|
// gated, and this assertion is the prompt to confirm that is what was wanted
|
|
239
303
|
// rather than the thing that has to catch the hole.
|
|
304
|
+
//
|
|
305
|
+
// It fired a SECOND time at task 1004141 (ADR 0323), which added 'byo-host' and
|
|
306
|
+
// retired 'co-tenant'. That retirement is why the list below still carries a shape
|
|
307
|
+
// no caller may request: the value stays storable so one torn-down row remains
|
|
308
|
+
// readable, and the door is shut in UNREQUESTABLE_SHAPES instead. Dropping it from
|
|
309
|
+
// HOSTING_SHAPES would make that historical row violate its own CHECK constraint.
|
|
240
310
|
assert.deepEqual([...provisioning.HOSTING_SHAPES].sort(),
|
|
241
|
-
['co-tenant', 'control-plane', 'dedicated', 'standalone']);
|
|
311
|
+
['byo-host', 'co-tenant', 'control-plane', 'dedicated', 'standalone']);
|
|
242
312
|
});
|
|
243
313
|
|
|
244
314
|
await ta('an unknown shape is answered by the validator, not by the gate', () => {
|
|
245
315
|
// The gate DEFERS anything outside HOSTING_SHAPES so a typo gets `bad_hosting_shape`
|
|
246
316
|
// naming the valid set, rather than a 403 about a permission the caller would not
|
|
247
317
|
// have needed. Deferring is safe: the validator refuses it microseconds later, and
|
|
248
|
-
// createInstance coerces an unrecognised shape to
|
|
318
|
+
// createInstance coerces an unrecognised shape to byo-host rather than storing it,
|
|
249
319
|
// so no deferred value can become a privileged row.
|
|
250
320
|
return post('/provisioning/instances', { slug: 'demo', hosting_shape: 'co-tenamt', no_address: true })
|
|
251
321
|
.then((res) => {
|