@bongos/core 1.19.764 → 1.19.766
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 +39 -19
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +4 -2
- package/docs/module-api-changelog.md +4 -0
- package/modules/provisioning/capacity.js +162 -0
- package/modules/provisioning/routes/provisioning.js +4 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/provision-capacity.js +74 -0
- package/scripts/gds/provision-config.js +19 -0
- package/scripts/gds/provision-units.js +6 -1
- package/scripts/gds/provision.js +4 -2
- package/src/bongos/pool.js +18 -2
- package/src/module-api.js +1 -1
- package/tests/pool_max.mjs +79 -0
- package/tests/provision.mjs +21 -0
- package/tests/provisioning_capacity.mjs +411 -0
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.766",
|
|
6
|
+
"core_contract": "1.19.766",
|
|
7
|
+
"source_commit": "02a01e26d1d999916c8c67e1e02b06d0db40b694",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-16T13:48:32.669Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 483,
|
|
13
13
|
"agent_docs_stubbed": 25,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2122,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2630,
|
|
20
|
+
"tree_sha256": "56708a327bb43ff2237f2ef0cdd0fac079947882699533e825262e7a16bcf724",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -387,7 +387,7 @@
|
|
|
387
387
|
{
|
|
388
388
|
"path": "clients/bongos-client/index.d.ts",
|
|
389
389
|
"mode": "0000644",
|
|
390
|
-
"sha256": "
|
|
390
|
+
"sha256": "6b6255f6dbfca5789f7500180c15eb88c01311f2048e327e85ee98d8cf683d9a"
|
|
391
391
|
},
|
|
392
392
|
{
|
|
393
393
|
"path": "clients/bongos-client/index.mjs",
|
|
@@ -1957,7 +1957,7 @@
|
|
|
1957
1957
|
{
|
|
1958
1958
|
"path": "docs/api/openapi.json",
|
|
1959
1959
|
"mode": "0000644",
|
|
1960
|
-
"sha256": "
|
|
1960
|
+
"sha256": "3cdb8968261fe761545cb415ac3e1a0de27dfc747dbb6f5fbc3c86fba8f0261e"
|
|
1961
1961
|
},
|
|
1962
1962
|
{
|
|
1963
1963
|
"path": "docs/architecture.md",
|
|
@@ -2512,7 +2512,7 @@
|
|
|
2512
2512
|
{
|
|
2513
2513
|
"path": "docs/module-api-changelog.md",
|
|
2514
2514
|
"mode": "0000644",
|
|
2515
|
-
"sha256": "
|
|
2515
|
+
"sha256": "10fb4078df266a5663d8079411fdf7698adb1aa8e20a19ca63ff722e1ecb8134"
|
|
2516
2516
|
},
|
|
2517
2517
|
{
|
|
2518
2518
|
"path": "docs/modules-contract.md",
|
|
@@ -6174,6 +6174,11 @@
|
|
|
6174
6174
|
"mode": "0000644",
|
|
6175
6175
|
"sha256": "7cb9dac9cb87b679e1ae50e4112b3a7928a38ebb9902c8947a74d17bec4d9009"
|
|
6176
6176
|
},
|
|
6177
|
+
{
|
|
6178
|
+
"path": "modules/provisioning/capacity.js",
|
|
6179
|
+
"mode": "0000644",
|
|
6180
|
+
"sha256": "1a2b3bd97a2e0c0068c6200a71dabb692e90723ee1ff63a56775f2b91e1cb208"
|
|
6181
|
+
},
|
|
6177
6182
|
{
|
|
6178
6183
|
"path": "modules/provisioning/catalog-bridge.js",
|
|
6179
6184
|
"mode": "0000644",
|
|
@@ -6312,7 +6317,7 @@
|
|
|
6312
6317
|
{
|
|
6313
6318
|
"path": "modules/provisioning/routes/provisioning.js",
|
|
6314
6319
|
"mode": "0000644",
|
|
6315
|
-
"sha256": "
|
|
6320
|
+
"sha256": "3c8359b8f624fb9521a704bb7202330eae9cfbc8a722d6c9adee207dba6d9dd0"
|
|
6316
6321
|
},
|
|
6317
6322
|
{
|
|
6318
6323
|
"path": "modules/provisioning/starter-bundles.js",
|
|
@@ -7572,12 +7577,12 @@
|
|
|
7572
7577
|
{
|
|
7573
7578
|
"path": "package-lock.json",
|
|
7574
7579
|
"mode": "0000644",
|
|
7575
|
-
"sha256": "
|
|
7580
|
+
"sha256": "6e4cfa58c41e8fbe29dc94bbe4bd7a4eafedb88034b7d1ba3334d4a0764f5b56"
|
|
7576
7581
|
},
|
|
7577
7582
|
{
|
|
7578
7583
|
"path": "package.json",
|
|
7579
7584
|
"mode": "0000644",
|
|
7580
|
-
"sha256": "
|
|
7585
|
+
"sha256": "b61349c8315ac531b941ca35f84a7323b2192e0ab7507c4d76e1c592c2a9f2e7"
|
|
7581
7586
|
},
|
|
7582
7587
|
{
|
|
7583
7588
|
"path": "public-docs/index.html",
|
|
@@ -8399,10 +8404,15 @@
|
|
|
8399
8404
|
"mode": "0000644",
|
|
8400
8405
|
"sha256": "dbaec451d7e67cfce811287ebb2289582ec47d73b1fbff92125e7991b734c807"
|
|
8401
8406
|
},
|
|
8407
|
+
{
|
|
8408
|
+
"path": "scripts/gds/provision-capacity.js",
|
|
8409
|
+
"mode": "0000644",
|
|
8410
|
+
"sha256": "a8150a19c8839caf18706bdd5010fda25900e073063e876004bfc16397e96b95"
|
|
8411
|
+
},
|
|
8402
8412
|
{
|
|
8403
8413
|
"path": "scripts/gds/provision-config.js",
|
|
8404
8414
|
"mode": "0000644",
|
|
8405
|
-
"sha256": "
|
|
8415
|
+
"sha256": "c870abebd21411e1fec744281130d116f3b9e8f255ddb6823aae4072a420cc15"
|
|
8406
8416
|
},
|
|
8407
8417
|
{
|
|
8408
8418
|
"path": "scripts/gds/provision-net.js",
|
|
@@ -8422,12 +8432,12 @@
|
|
|
8422
8432
|
{
|
|
8423
8433
|
"path": "scripts/gds/provision-units.js",
|
|
8424
8434
|
"mode": "0000644",
|
|
8425
|
-
"sha256": "
|
|
8435
|
+
"sha256": "e299687a54305e45ea57ff0a439f5ff099cb5d51be64bb0a48056c93309a67e3"
|
|
8426
8436
|
},
|
|
8427
8437
|
{
|
|
8428
8438
|
"path": "scripts/gds/provision.js",
|
|
8429
8439
|
"mode": "0000644",
|
|
8430
|
-
"sha256": "
|
|
8440
|
+
"sha256": "87a9abe0f4744a0b3a3b5cc2e326ff2adb8cba3e559228cca0798e40feaee8f6"
|
|
8431
8441
|
},
|
|
8432
8442
|
{
|
|
8433
8443
|
"path": "scripts/gds/publish-credential-check.js",
|
|
@@ -9227,7 +9237,7 @@
|
|
|
9227
9237
|
{
|
|
9228
9238
|
"path": "src/bongos/pool.js",
|
|
9229
9239
|
"mode": "0000644",
|
|
9230
|
-
"sha256": "
|
|
9240
|
+
"sha256": "f3fa3e0360c1bd7fb9bdf5756edccb8c5f5cad4d9ef2b89b7f0c6382c5240294"
|
|
9231
9241
|
},
|
|
9232
9242
|
{
|
|
9233
9243
|
"path": "src/bongos/prelaunch-gate.js",
|
|
@@ -9387,7 +9397,7 @@
|
|
|
9387
9397
|
{
|
|
9388
9398
|
"path": "src/module-api.js",
|
|
9389
9399
|
"mode": "0000644",
|
|
9390
|
-
"sha256": "
|
|
9400
|
+
"sha256": "9c4c56d73c466a19a699f6c37e439d54d88211a363a869f9d87e3e840f493a2a"
|
|
9391
9401
|
},
|
|
9392
9402
|
{
|
|
9393
9403
|
"path": "src/module-loader/catalog.js",
|
|
@@ -11864,6 +11874,11 @@
|
|
|
11864
11874
|
"mode": "0000644",
|
|
11865
11875
|
"sha256": "ff9d438a33a32fecc04caa6e652eeaaded6394438bcec0d508ae7c516fa62333"
|
|
11866
11876
|
},
|
|
11877
|
+
{
|
|
11878
|
+
"path": "tests/pool_max.mjs",
|
|
11879
|
+
"mode": "0000644",
|
|
11880
|
+
"sha256": "68e5b941a1889ddc44fd548e10bb1fdb33822628b0da392592f747a32f7ccd1a"
|
|
11881
|
+
},
|
|
11867
11882
|
{
|
|
11868
11883
|
"path": "tests/post_login_landing.mjs",
|
|
11869
11884
|
"mode": "0000644",
|
|
@@ -12042,7 +12057,7 @@
|
|
|
12042
12057
|
{
|
|
12043
12058
|
"path": "tests/provision.mjs",
|
|
12044
12059
|
"mode": "0000644",
|
|
12045
|
-
"sha256": "
|
|
12060
|
+
"sha256": "245deb7b090f528180dde1457b821a8c8c2bd74a643524f441519859659643e8"
|
|
12046
12061
|
},
|
|
12047
12062
|
{
|
|
12048
12063
|
"path": "tests/provision_restart.mjs",
|
|
@@ -12059,6 +12074,11 @@
|
|
|
12059
12074
|
"mode": "0000644",
|
|
12060
12075
|
"sha256": "74d53ae7f3c93883bdc6db5187c904cc6c641267233d70bfef1a25d5b2a4ca05"
|
|
12061
12076
|
},
|
|
12077
|
+
{
|
|
12078
|
+
"path": "tests/provisioning_capacity.mjs",
|
|
12079
|
+
"mode": "0000644",
|
|
12080
|
+
"sha256": "bcb976ab17508d1af34ea0c775d25765f336f534d79de9442b2fbc6712fa2a28"
|
|
12081
|
+
},
|
|
12062
12082
|
{
|
|
12063
12083
|
"path": "tests/provisioning_credential_preflight.mjs",
|
|
12064
12084
|
"mode": "0000644",
|
|
@@ -99,7 +99,7 @@ export interface GetProfilesHandleResponse { account: unknown; cross_project: un
|
|
|
99
99
|
export interface GetProjectSettingsResponse { rows: unknown }
|
|
100
100
|
export interface GetProjectsFeaturedResponse { projects: unknown; page: PageInfo }
|
|
101
101
|
export interface GetProjectsResponse { projects: unknown }
|
|
102
|
-
export interface GetProvisioningFleetResponse { instances: unknown }
|
|
102
|
+
export interface GetProvisioningFleetResponse { instances: unknown; capacity: unknown }
|
|
103
103
|
export interface GetProvisioningGithubAppStatusResponse { ok: boolean; status: unknown; ready: unknown; terminal: unknown; domain: unknown; instance_status: unknown; app_slug: unknown }
|
|
104
104
|
export interface GetProvisioningHealthResponse { module: unknown; ok: boolean; coreVersion: unknown }
|
|
105
105
|
export interface GetProvisioningInstancesIdInviteSuggestionsResponse { suggested: unknown; seeking: unknown; reason: unknown; unavailable?: unknown }
|
package/docs/api/openapi.json
CHANGED
|
@@ -18411,10 +18411,12 @@
|
|
|
18411
18411
|
"GetProvisioningFleetResponse": {
|
|
18412
18412
|
"type": "object",
|
|
18413
18413
|
"properties": {
|
|
18414
|
-
"instances": {}
|
|
18414
|
+
"instances": {},
|
|
18415
|
+
"capacity": {}
|
|
18415
18416
|
},
|
|
18416
18417
|
"required": [
|
|
18417
|
-
"instances"
|
|
18418
|
+
"instances",
|
|
18419
|
+
"capacity"
|
|
18418
18420
|
]
|
|
18419
18421
|
},
|
|
18420
18422
|
"GetProvisioningGithubAppStatusResponse": {
|
|
@@ -2002,5 +2002,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2002
2002
|
landed since 1.19.762 with no explicit bump. run 35056690992. (task 1002620)
|
|
2003
2003
|
1.19.764 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2004
2004
|
landed since 1.19.763 with no explicit bump. run 35059447999. (task 1002620)
|
|
2005
|
+
1.19.765 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2006
|
+
landed since 1.19.764 with no explicit bump. run 35102327521. (task 1002620)
|
|
2007
|
+
1.19.766 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2008
|
+
landed since 1.19.765 with no explicit bump. run 35104246900. (task 1002620)
|
|
2005
2009
|
---------------------------------------------------------------------------
|
|
2006
2010
|
```
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// modules/provisioning/capacity.js — how many projects a shared box can hold, and
|
|
2
|
+
// whether one more fits (ADR 0285, task 1003927).
|
|
3
|
+
//
|
|
4
|
+
// ADR 0285 measured the live control plane and priced a project at its WARM size:
|
|
5
|
+
// ~80 MB (a 70 MB process plus one busy Postgres backend), on top of a fixed base
|
|
6
|
+
// the box pays whatever the project count (~750 MB with the control plane on it,
|
|
7
|
+
// ~630 MB on an overflow box). The ceiling is `floor((usable MiB − fixed) / 80)`,
|
|
8
|
+
// which reproduces the ADR's table from the box's own MemTotal — 15 on today's
|
|
9
|
+
// s-1vcpu-2gb, 39 on an s-2vcpu-4gb control plane, 41 on a 4 GB overflow box — so
|
|
10
|
+
// a resize (task 1003923) raises the ceiling by itself, with no knob to remember.
|
|
11
|
+
//
|
|
12
|
+
// Two walls, asked in order, both honest refusals rather than a silent overcommit:
|
|
13
|
+
// 1. the CEILING, counted against the rows the runner has placed or is placing
|
|
14
|
+
// on that box (`provisioning` + `active`). Not `error`: those rows hold no
|
|
15
|
+
// running unit (verified on the live fleet, 2026-09-16), and a re-provision
|
|
16
|
+
// runs this same check for itself.
|
|
17
|
+
// 2. a live MemAvailable FLOOR, because the ledger cannot see a stray unit or a
|
|
18
|
+
// project that grew — the rows say how many, the kernel says how much is left.
|
|
19
|
+
//
|
|
20
|
+
// PURE except the two I/O helpers at the bottom (`countPlaced` runs one query on
|
|
21
|
+
// the db it is handed; `readLocalMeminfo` reads this host's /proc/meminfo, for the
|
|
22
|
+
// web tier and the CLI, which run ON the control plane). The runner reads the same
|
|
23
|
+
// file through its box executor instead, so an overflow box is measured where the
|
|
24
|
+
// project will actually run (scripts/gds/provision-capacity.js). No module-api, no
|
|
25
|
+
// express, no pool of its own — this file is the shared arithmetic, not a surface.
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const fs = require('node:fs');
|
|
29
|
+
|
|
30
|
+
// ADR 0285 §1–§2. PROJECT_MB is the warm unit; FIXED_MB is the box's own overhead
|
|
31
|
+
// by role; DEFAULT_CEILING is the ADR's smallest-tier number, used ONLY when the box
|
|
32
|
+
// cannot be measured (dry-run, a fake executor, an unreadable /proc) — refusing
|
|
33
|
+
// conservatively beats guessing generously. RESIZE_AT is the ADR's "resize at 12 of
|
|
34
|
+
// 15" trigger. The floor is two warm projects plus margin, per the task's brief.
|
|
35
|
+
const PROJECT_MB = 80;
|
|
36
|
+
const FIXED_MB = Object.freeze({ 'control-plane': 750, overflow: 630 });
|
|
37
|
+
const DEFAULT_CEILING = 15;
|
|
38
|
+
const DEFAULT_MEM_FLOOR_MB = 256;
|
|
39
|
+
const RESIZE_AT = 0.8;
|
|
40
|
+
// The states that hold (or are about to hold) a slot on the box.
|
|
41
|
+
const PLACED_STATES = Object.freeze(['provisioning', 'active']);
|
|
42
|
+
|
|
43
|
+
function numEnv(name, dflt) {
|
|
44
|
+
const v = process.env[name];
|
|
45
|
+
if (v == null || v === '') return dflt;
|
|
46
|
+
const n = Number(v);
|
|
47
|
+
return Number.isFinite(n) && n > 0 ? n : dflt;
|
|
48
|
+
}
|
|
49
|
+
// The two knobs, read at call time so a test can set them per case. Both live in
|
|
50
|
+
// the runner's box.env; the web tier's copy (web.env) must match if the override
|
|
51
|
+
// is used — deriving from MemTotal is what makes the two agree with no knob at all.
|
|
52
|
+
function envOverrides() {
|
|
53
|
+
return { override: numEnv('PROVISION_BOX_CEILING', null), floorMb: numEnv('PROVISION_MEM_FLOOR_MB', DEFAULT_MEM_FLOOR_MB) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// parseMeminfo — MemTotal / MemAvailable out of /proc/meminfo text, in MiB.
|
|
57
|
+
// Null for a field that is absent, so "unmeasured" stays distinguishable from 0.
|
|
58
|
+
function parseMeminfo(text) {
|
|
59
|
+
const grab = (key) => {
|
|
60
|
+
const m = new RegExp(`^${key}:\\s+(\\d+)\\s*kB`, 'm').exec(String(text || ''));
|
|
61
|
+
return m ? Math.floor(Number(m[1]) / 1024) : null;
|
|
62
|
+
};
|
|
63
|
+
return { memTotalMb: grab('MemTotal'), memAvailableMb: grab('MemAvailable') };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// placementBox — WHICH box a shape lands on, and therefore which rows share its
|
|
67
|
+
// ceiling and which fixed base applies. A standalone always runs control-plane-
|
|
68
|
+
// local (task 2074); a co-tenant runs on the remote co-hosting box only when the
|
|
69
|
+
// runner is in remote-exec mode (ADR 0130), else here. A dedicated droplet is its
|
|
70
|
+
// own box and has no shared ceiling → null (no preflight).
|
|
71
|
+
function placementBox(hostingShape, { remoteHost = null } = {}) {
|
|
72
|
+
if (hostingShape === 'dedicated') return null;
|
|
73
|
+
if (hostingShape === 'co-tenant' && remoteHost) {
|
|
74
|
+
return { box: 'overflow', shapes: ['co-tenant'], fixedMb: FIXED_MB.overflow };
|
|
75
|
+
}
|
|
76
|
+
return { box: 'control-plane', shapes: remoteHost ? ['standalone'] : ['standalone', 'co-tenant'], fixedMb: FIXED_MB['control-plane'] };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ceilingFor — the slot count for a box: an explicit override wins, else the ADR
|
|
80
|
+
// 0285 formula over the measured MemTotal, else the conservative default. `source`
|
|
81
|
+
// says which, so every surface can show where its number came from.
|
|
82
|
+
function ceilingFor({ override = null, memTotalMb = null, fixedMb = FIXED_MB['control-plane'] } = {}) {
|
|
83
|
+
if (Number.isFinite(override) && override > 0) return { ceiling: Math.floor(override), source: 'env' };
|
|
84
|
+
if (Number.isFinite(memTotalMb) && memTotalMb > fixedMb) {
|
|
85
|
+
return { ceiling: Math.floor((memTotalMb - fixedMb) / PROJECT_MB), source: 'measured' };
|
|
86
|
+
}
|
|
87
|
+
return { ceiling: DEFAULT_CEILING, source: 'default' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// placementVerdict — may ONE more project be placed? Ceiling first (the ledger's
|
|
91
|
+
// answer), then the live floor (the kernel's). The reason is written for the
|
|
92
|
+
// owner's card, not the journal: what is full, that nothing was placed, and the
|
|
93
|
+
// two ways out. An unmeasured MemAvailable cannot fail the floor — the ceiling is
|
|
94
|
+
// the wall that always applies, and a missing reading is logged by the caller.
|
|
95
|
+
function placementVerdict({ placed, ceiling, memAvailableMb = null, floorMb = DEFAULT_MEM_FLOOR_MB }) {
|
|
96
|
+
if (placed >= ceiling) {
|
|
97
|
+
return { ok: false, wall: 'ceiling', reason: `The shared box is full: ${placed} of ${ceiling} project slots are taken (ADR 0285). Nothing was placed. Free a slot by tearing down a project, or resize the box (task 1003923), then request again.` };
|
|
98
|
+
}
|
|
99
|
+
if (Number.isFinite(memAvailableMb) && memAvailableMb < floorMb) {
|
|
100
|
+
return { ok: false, wall: 'memory', reason: `The shared box has only ${memAvailableMb} MB of memory free, under the ${floorMb} MB a new project needs to start safely (${placed} of ${ceiling} slots taken). Nothing was placed. Free memory or a slot, or resize the box (task 1003923), then request again.` };
|
|
101
|
+
}
|
|
102
|
+
return { ok: true, wall: null, reason: null };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// capacitySummary — the one shape every reader gets: the fleet API, the runner
|
|
106
|
+
// log, the CLI status. `resize_due` is the ADR's 80% trigger, so the resize is
|
|
107
|
+
// scheduled at 12 of 15 rather than discovered at 15.
|
|
108
|
+
function capacitySummary({ box, placed, ceiling, source, memAvailableMb = null, floorMb = DEFAULT_MEM_FLOOR_MB }) {
|
|
109
|
+
const verdict = placementVerdict({ placed, ceiling, memAvailableMb, floorMb });
|
|
110
|
+
return {
|
|
111
|
+
box, placed, ceiling, source,
|
|
112
|
+
remaining: Math.max(0, ceiling - placed),
|
|
113
|
+
resize_at: Math.ceil(ceiling * RESIZE_AT),
|
|
114
|
+
resize_due: placed >= Math.ceil(ceiling * RESIZE_AT),
|
|
115
|
+
mem_available_mb: memAvailableMb,
|
|
116
|
+
mem_floor_mb: floorMb,
|
|
117
|
+
verdict,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// countPlaced — rows holding a slot on the box: the given shapes in PLACED_STATES.
|
|
122
|
+
// `excludeId` keeps a row from counting itself when a reconcile re-runs its own
|
|
123
|
+
// provision (it is already 'provisioning' then). Fail-soft on a fake db that
|
|
124
|
+
// answers with no rows.
|
|
125
|
+
async function countPlaced(db, shapes, { excludeId = null } = {}) {
|
|
126
|
+
const params = [shapes, PLACED_STATES];
|
|
127
|
+
let where = 'hosting_shape = ANY($1) AND status = ANY($2)';
|
|
128
|
+
if (excludeId != null) { params.push(excludeId); where += ` AND id <> $${params.length}`; }
|
|
129
|
+
const { rows } = await db.query(`SELECT count(*)::int AS n FROM provisioning_instances WHERE ${where}`, params);
|
|
130
|
+
return Number((rows && rows[0] && rows[0].n) || 0);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// readLocalMeminfo — THIS host's /proc/meminfo, for the surfaces that run on the
|
|
134
|
+
// control plane itself. Null (not a throw) where it does not exist, e.g. a dev box
|
|
135
|
+
// that is not Linux — the summary then reads 'default' and says so.
|
|
136
|
+
function readLocalMeminfo() {
|
|
137
|
+
try { return fs.readFileSync('/proc/meminfo', 'utf8'); } catch { return null; }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// fleetCapacity — the control-plane box's summary for the fleet API and the CLI:
|
|
141
|
+
// its rows against its ceiling, measured from this host. `meminfo` is injectable
|
|
142
|
+
// for tests; `remoteHost` mirrors the runner's PROVISION_REMOTE_HOST so the two
|
|
143
|
+
// count the same shapes (set it in web.env too if co-tenants ever run remotely).
|
|
144
|
+
async function fleetCapacity(db, { meminfo = readLocalMeminfo(), remoteHost = process.env.PROVISION_REMOTE_HOST || null } = {}) {
|
|
145
|
+
const target = placementBox('standalone', { remoteHost });
|
|
146
|
+
const { override, floorMb } = envOverrides();
|
|
147
|
+
const mem = parseMeminfo(meminfo);
|
|
148
|
+
const { ceiling, source } = ceilingFor({ override, memTotalMb: mem.memTotalMb, fixedMb: target.fixedMb });
|
|
149
|
+
const placed = await countPlaced(db, target.shapes);
|
|
150
|
+
return capacitySummary({ box: target.box, placed, ceiling, source, memAvailableMb: mem.memAvailableMb, floorMb });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// The constants a caller needs to reason about a verdict, plus the helpers the
|
|
154
|
+
// runner preflight and the fleet API compose. PROJECT_MB / FIXED_MB / RESIZE_AT
|
|
155
|
+
// and the two env readers stay private: nothing outside asks them separately from
|
|
156
|
+
// the summary they produce, and an export nobody consumes is one more thing to
|
|
157
|
+
// keep true (the paid-shape-gate precedent).
|
|
158
|
+
module.exports = {
|
|
159
|
+
DEFAULT_CEILING, DEFAULT_MEM_FLOOR_MB, PLACED_STATES,
|
|
160
|
+
parseMeminfo, placementBox, ceilingFor, placementVerdict, capacitySummary,
|
|
161
|
+
countPlaced, fleetCapacity,
|
|
162
|
+
};
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
// POST /provisioning/instances/:id/teardown — request teardown of one's own
|
|
26
26
|
// GET /provisioning/instances/:id/invite-suggestions — who is worth inviting (R28, ADR 0251)
|
|
27
27
|
// Archon-scoped (requireRank('archon') — the fleet, the /boxes precedent):
|
|
28
|
-
// GET /provisioning/fleet — the full instance roster
|
|
28
|
+
// GET /provisioning/fleet — the full instance roster + the box's slot `capacity` (ADR 0285)
|
|
29
29
|
// GET /provisioning/fleet/cost-ledger — per-instance + total cost estimate
|
|
30
30
|
// POST /provisioning/instances/:id/force-teardown — tear down ANY instance
|
|
31
31
|
// public:
|
|
@@ -44,8 +44,7 @@
|
|
|
44
44
|
const crypto = require('node:crypto');
|
|
45
45
|
const express = require('express');
|
|
46
46
|
const api = require('../../../src/module-api');
|
|
47
|
-
const auth = api;
|
|
48
|
-
const { pool, validateOrRespond, parseId } = api;
|
|
47
|
+
const auth = api, { pool, validateOrRespond, parseId } = api;
|
|
49
48
|
const provisioning = require('../provisioning');
|
|
50
49
|
const { onboardPlan, stepsForShape } = require('../onboard-plan');
|
|
51
50
|
const { ALWAYS_ON_CORE, OPTIONAL_MODULES, starterBundleAnswerTable, optionalModules, effectiveModules } = require('../starter-bundles');
|
|
@@ -54,6 +53,7 @@ const { makeAskRateLimiter, clientIp } = require('../rate-limit');
|
|
|
54
53
|
const { requireAuthorityForMeteredShape } = require('../paid-shape-gate');
|
|
55
54
|
const { adoptCredentialFault } = require('../credential-preflight');
|
|
56
55
|
const catalogBridge = require('../catalog-bridge');
|
|
56
|
+
const capacity = require('../capacity'); // the box's slot ceiling (ADR 0285, task 1003927)
|
|
57
57
|
const { callbackPage } = require('./callback-page');
|
|
58
58
|
// the LOUD body validators (task 1003504) — lifted out when this file hit the size ratchet
|
|
59
59
|
const { badProjectDetail, badModuleSelection, badProjectType } = require('./body-validators');
|
|
@@ -223,7 +223,7 @@ module.exports = function provisioningRoutes() {
|
|
|
223
223
|
try {
|
|
224
224
|
const status = req.query.status || undefined;
|
|
225
225
|
const rows = await provisioning.listAllInstances(pool, { status });
|
|
226
|
-
res.json({ instances: rows.map(fleetInstance) });
|
|
226
|
+
res.json({ instances: rows.map(fleetInstance), capacity: await capacity.fleetCapacity(pool).catch(() => null) });
|
|
227
227
|
} catch (err) {
|
|
228
228
|
log.error('[provisioning] GET /provisioning/fleet', err);
|
|
229
229
|
res.fail('fleet_failed', { status: 500, message: 'internal error' });
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.766",
|
|
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.766",
|
|
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.766",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// scripts/gds/provision-capacity.js — the runner's BOX-CAPACITY preflight
|
|
2
|
+
// (ADR 0285, task 1003927): before a standalone / co-tenant project is placed on a
|
|
3
|
+
// shared box, ask whether it fits, and refuse with an owner-readable reason when it
|
|
4
|
+
// does not. Nothing enforced the ceiling before this — the runner stood up project
|
|
5
|
+
// N+1 on a full box and swap hid the overcommit until a reboot or a fleet upgrade
|
|
6
|
+
// woke every project at once.
|
|
7
|
+
//
|
|
8
|
+
// The arithmetic and the two walls (row ceiling, live MemAvailable floor) live in
|
|
9
|
+
// modules/provisioning/capacity.js, shared with the fleet API so both surfaces show
|
|
10
|
+
// one number. This file is the runner-side composition: it reads /proc/meminfo
|
|
11
|
+
// through the BOX executor — the control plane for a standalone (task 2074), the
|
|
12
|
+
// remote co-hosting box for a co-tenant under remote-exec (ADR 0130) — so an
|
|
13
|
+
// overflow box is measured where the project will actually run. A sibling of
|
|
14
|
+
// provision-config / -repo / -units / -net; provision.js requires it back.
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const { CONFIG } = require('./provision-config.js');
|
|
18
|
+
const capacity = require('../../modules/provisioning/capacity.js');
|
|
19
|
+
|
|
20
|
+
// The kernel's own two lines, kept small so the journal stays readable. allowFail:
|
|
21
|
+
// a box without procfs (never, on Linux) reads as "unmeasured", and the ceiling
|
|
22
|
+
// then falls to ADR 0285's conservative default rather than aborting the standup.
|
|
23
|
+
const MEMINFO_CMD = "grep -E '^Mem(Total|Available):' /proc/meminfo";
|
|
24
|
+
|
|
25
|
+
// capacityPreflight — { ok:true, …summary } when the project fits (or the shape has
|
|
26
|
+
// no shared box), { ok:false, error, …summary } when it does not. The refusal is a
|
|
27
|
+
// RETURN, never a throw: the drain loop retries it (a teardown may free a slot), and
|
|
28
|
+
// on retirement writes `error` onto the instance's card without flipping its status
|
|
29
|
+
// — so a project that was never placed is never labelled broken. Dry-run measures
|
|
30
|
+
// and reports but refuses nothing, since it places nothing either way.
|
|
31
|
+
async function capacityPreflight(inst, deps) {
|
|
32
|
+
const { db, provisioning, exec, log, apply } = deps;
|
|
33
|
+
const target = capacity.placementBox(inst.hosting_shape, { remoteHost: CONFIG.remoteHost });
|
|
34
|
+
if (!target) return { ok: true, skipped: 'dedicated' };
|
|
35
|
+
const boxExec = target.box === 'control-plane' ? (deps.controlExec || exec) : exec;
|
|
36
|
+
let res = null;
|
|
37
|
+
try { res = boxExec(MEMINFO_CMD, { allowFail: true }); } catch { res = null; }
|
|
38
|
+
const mem = capacity.parseMeminfo(res && res.stdout);
|
|
39
|
+
const { ceiling, source } = capacity.ceilingFor({ override: CONFIG.boxCeiling, memTotalMb: mem.memTotalMb, fixedMb: target.fixedMb });
|
|
40
|
+
const placed = await capacity.countPlaced(db, target.shapes, { excludeId: inst.id });
|
|
41
|
+
const floorMb = CONFIG.memFloorMb || capacity.DEFAULT_MEM_FLOOR_MB;
|
|
42
|
+
const summary = capacity.capacitySummary({ box: target.box, placed, ceiling, source, memAvailableMb: mem.memAvailableMb, floorMb });
|
|
43
|
+
const free = mem.memAvailableMb != null ? `${mem.memAvailableMb} MB free` : 'memory unmeasured';
|
|
44
|
+
const where = `${target.box} box at ${placed} of ${ceiling} slots (${source}, ${free})`;
|
|
45
|
+
if (!apply) {
|
|
46
|
+
log(` [preflight] ${where} — would refuse if full (dry-run measures nothing and places nothing)`);
|
|
47
|
+
return { ok: true, dryRun: true, ...summary };
|
|
48
|
+
}
|
|
49
|
+
if (source === 'default') log(` [preflight] ⚠ could not measure the box — using ADR 0285's smallest-tier ceiling (${ceiling}); set PROVISION_BOX_CEILING to override`);
|
|
50
|
+
if (!summary.verdict.ok) {
|
|
51
|
+
log(` ✖ [preflight] ${where} — refusing to place ${inst.slug} (${summary.verdict.wall} wall)`);
|
|
52
|
+
if (provisioning && provisioning.recordEvent) {
|
|
53
|
+
await provisioning.recordEvent(db, {
|
|
54
|
+
instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'placement-refused',
|
|
55
|
+
detail: summary.verdict.reason, actor: 'runner',
|
|
56
|
+
}).catch(() => {});
|
|
57
|
+
}
|
|
58
|
+
return { ok: false, error: summary.verdict.reason, ...summary };
|
|
59
|
+
}
|
|
60
|
+
log(` [preflight] ${where} ✓${summary.resize_due ? ' — at or past 80%: schedule the resize (task 1003923) or open the next box' : ''}`);
|
|
61
|
+
return { ok: true, ...summary };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// statusCapacityLine — the "box at N of M" line `provision.js status` prints, read
|
|
65
|
+
// from this host (the CLI runs on the control plane), so the resize is scheduled
|
|
66
|
+
// at 80% rather than discovered at the ceiling.
|
|
67
|
+
async function statusCapacityLine(db) {
|
|
68
|
+
const c = await capacity.fleetCapacity(db);
|
|
69
|
+
const free = c.mem_available_mb != null ? `, ${c.mem_available_mb} MB free` : '';
|
|
70
|
+
const due = c.resize_due ? ' — at or past 80%: resize or open the next box (ADR 0285, task 1003923)' : '';
|
|
71
|
+
return ` ${c.box} box: ${c.placed} of ${c.ceiling} project slots taken (${c.source}${free})${due}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { MEMINFO_CMD, capacityPreflight, statusCapacityLine };
|
|
@@ -123,6 +123,25 @@ const CONFIG = {
|
|
|
123
123
|
// fails fast (never hangs on a prompt); accept-new pins the host key on first
|
|
124
124
|
// connect (the operator can pre-seed known_hosts for strict pinning).
|
|
125
125
|
sshOpts: process.env.PROVISION_SSH_OPTS || '-o BatchMode=yes -o StrictHostKeyChecking=accept-new',
|
|
126
|
+
// ── Box capacity (ADR 0285, task 1003927) ─────────────────────────────────
|
|
127
|
+
// How many projects the box the runner places on may hold. UNSET (default)
|
|
128
|
+
// derives it from that box's own MemTotal by the ADR's formula (15 on today's
|
|
129
|
+
// s-1vcpu-2gb, 39 after the resize), so a resize raises it with no knob to
|
|
130
|
+
// remember; set it only to pin a number the measurement should not decide (a box
|
|
131
|
+
// that also hosts something the ledger cannot see). The floor is the live
|
|
132
|
+
// MemAvailable a placement must still find — two warm projects plus margin
|
|
133
|
+
// (modules/provisioning/capacity.js holds the default). Both live in box.env;
|
|
134
|
+
// mirror an override into web.env so the fleet API reports the same number.
|
|
135
|
+
boxCeiling: numEnv('PROVISION_BOX_CEILING', null),
|
|
136
|
+
memFloorMb: numEnv('PROVISION_MEM_FLOOR_MB', null),
|
|
137
|
+
// The Postgres pool cap written into a CO-LOCATED instance's web.env as PGPOOL_MAX
|
|
138
|
+
// (task 1003928, ADR 0285 §3). pg's default is 10 per process and the shared server
|
|
139
|
+
// allows 97 non-superuser backends, so nine busy projects exhaust it whatever the
|
|
140
|
+
// RAM headroom. Idle projects hold zero backends, so this bounds bursts, not the
|
|
141
|
+
// steady state: 3 × 15 slots + the control plane's own 10 stays under the 97. The
|
|
142
|
+
// control plane keeps pg's default (it is not provisioned by this runner), and a
|
|
143
|
+
// dedicated droplet has its own server, so neither is written.
|
|
144
|
+
instancePoolMax: numEnv('PROVISION_INSTANCE_PGPOOL_MAX', 3),
|
|
126
145
|
};
|
|
127
146
|
|
|
128
147
|
// task 1002757 — the provisioner's git identity resolves from the instance's own
|
|
@@ -267,13 +267,18 @@ GITHUB_APP_PRIVATE_KEY=${String(appPem).replace(/\r?\n/g, '\\n')}
|
|
|
267
267
|
const dbLines = dbPassword
|
|
268
268
|
? `PGUSER=${instanceDbRole(inst)}\nPGPASSWORD=${dbPassword}\n`
|
|
269
269
|
: '';
|
|
270
|
+
// The pool cap (task 1003928, ADR 0285 §3): a co-located instance shares the box's
|
|
271
|
+
// Postgres with every sibling and the control plane, so its pool may not take pg's
|
|
272
|
+
// default 10 of the server's 97. src/bongos/pool.js reads it bare. A dedicated
|
|
273
|
+
// droplet has its own server and keeps the default — the line is not written.
|
|
274
|
+
const poolLine = inst.hosting_shape === 'dedicated' ? '' : `PGPOOL_MAX=${CONFIG.instancePoolMax}\n`;
|
|
270
275
|
return `# /etc/${inst.slug}/web.env — per-instance secrets (chmod 600, OUTSIDE the repo).
|
|
271
276
|
${credComment}
|
|
272
277
|
${prefix}_GITHUB_CLIENT_ID=${clientId || ''}
|
|
273
278
|
${prefix}_GITHUB_CLIENT_SECRET=${clientSecret || ''}
|
|
274
279
|
${firstAdminLine}${originLines}${appLines}PORT=${inst.port}
|
|
275
280
|
PGDATABASE=${dbName(inst)}
|
|
276
|
-
${dbLines}${fedLines}${settingsEnvLines(inst, prefix)}`;
|
|
281
|
+
${poolLine}${dbLines}${fedLines}${settingsEnvLines(inst, prefix)}`;
|
|
277
282
|
}
|
|
278
283
|
function webEnvPath(inst, { etcBase = CONFIG.etcBase } = {}) {
|
|
279
284
|
return path.posix.join(etcBase, inst.slug, 'web.env');
|
package/scripts/gds/provision.js
CHANGED
|
@@ -53,6 +53,7 @@ const { CONFIG, MANIFEST_UA, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES,
|
|
|
53
53
|
const { APP_USER, alreadyScaffolded, buildCorePinRefreshCommit, dbCreateCmd, dbName, dbRoleCmd, dbRoleCmdShown, dbRoleGrantsCmd, ensureInstanceUserCmd, generateDbPassword, grantInstanceRepoReadCmd, instanceDbRole, instanceStateDir, instanceUser, deployKeyPath, deployKeyTitle, ensurePrivateRepoAccess, installedCorePackDir, installedCoreTarball, instanceInitSpec, instanceRepoRemote, migrateCmd, onboardMode, ownerLoginOf, parseTargetRef, planCorePinRefresh, refreshStandaloneCorePin, resolveOwnerGithubToken, resolveVendorableCoreTarball, safeVersionLabel, scaffoldStandaloneRepo, seedFirstVersionCmd, standaloneInstallCmd, standaloneMigrateCmd, standalonePullCmd, standaloneRegenDocsCmd, standaloneRoot } = require('./provision-repo.js');
|
|
54
54
|
const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, instanceManifestCmd, originEnvVarsFor, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
|
|
55
55
|
const { caddyBlock, caddySnippetPath, checkDnsTokenScope, classifyZoneScope, dnsReleaseEnv, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, identityCmd, identityVerdict, shouldFederate, zoneForDomain } = require('./provision-net.js');
|
|
56
|
+
const { capacityPreflight, statusCapacityLine } = require('./provision-capacity.js');
|
|
56
57
|
|
|
57
58
|
function makeExec({ apply, log = console.log, remoteHost = null, remoteCwd = null, sshOpts = '' }) {
|
|
58
59
|
// `shown` (task 1003140): what to LOG in place of the command text — for a command
|
|
@@ -258,7 +259,8 @@ async function provisionInstance(inst, deps) {
|
|
|
258
259
|
log(` [preflight] would verify the Cloudflare token can reach zone ${zoneForDomain(inst.domain)} before the address step`);
|
|
259
260
|
}
|
|
260
261
|
}
|
|
261
|
-
|
|
262
|
+
const room = await capacityPreflight(inst, deps); // box capacity (ADR 0285, task 1003927): an honest refusal BEFORE the status flip — never a silent oversubscribe
|
|
263
|
+
if (room.ok === false) return room;
|
|
262
264
|
if (apply) await provisioning.setInstanceStatus(db, inst.id, 'provisioning');
|
|
263
265
|
const patch = {};
|
|
264
266
|
|
|
@@ -1358,7 +1360,7 @@ async function cmdStatus(deps) {
|
|
|
1358
1360
|
monthly += Number(r.cost_estimate_usd || 0);
|
|
1359
1361
|
log(` ${String(r.slug).padEnd(20)} ${String(r.status).padEnd(13)} ${r.hosting_shape.padEnd(10)} port=${r.port || '-'} @${r.github_login} $${Number(r.cost_estimate_usd || 0).toFixed(2)}/mo ${r.domain || ''}`);
|
|
1360
1362
|
}
|
|
1361
|
-
log(`\n estimated run-rate: ~$${monthly.toFixed(2)}/mo`);
|
|
1363
|
+
log(`\n estimated run-rate: ~$${monthly.toFixed(2)}/mo\n${await statusCapacityLine(db)}`); // + "box at N of M" (ADR 0285, task 1003927)
|
|
1362
1364
|
return { count: rows.length };
|
|
1363
1365
|
}
|
|
1364
1366
|
|
package/src/bongos/pool.js
CHANGED
|
@@ -37,11 +37,27 @@ function instanceDbName() {
|
|
|
37
37
|
return undefined;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// The pool's connection cap (task 1003928, ADR 0285 §3). pg's default `max` is 10 and
|
|
41
|
+
// the shared server allows 97 non-superuser backends, so nine busy co-located
|
|
42
|
+
// instances exhaust it whatever the RAM headroom — connections are the second wall
|
|
43
|
+
// after memory. A provisioned instance's web.env carries PGPOOL_MAX (the runner
|
|
44
|
+
// writes a small cap, PROVISION_INSTANCE_PGPOOL_MAX); UNSET — self-host, dev, the
|
|
45
|
+
// control plane itself — returns undefined so pg keeps its own default and nothing
|
|
46
|
+
// outside a co-located box changes. Read bare like the PG* family beside it: it is
|
|
47
|
+
// a pg option, not a branding one, so it does not ride resolveEnv's prefix.
|
|
48
|
+
// Anything that is not a positive integer is ignored rather than passed to pg.
|
|
49
|
+
function poolMax(env = process.env) {
|
|
50
|
+
const n = Number(env.PGPOOL_MAX);
|
|
51
|
+
return Number.isInteger(n) && n > 0 ? n : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
40
54
|
let pool;
|
|
55
|
+
const max = poolMax();
|
|
41
56
|
if (process.env.DATABASE_URL) {
|
|
42
|
-
pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
57
|
+
pool = new Pool({ connectionString: process.env.DATABASE_URL, ...(max ? { max } : {}) });
|
|
43
58
|
} else {
|
|
44
59
|
pool = new Pool({
|
|
60
|
+
...(max ? { max } : {}),
|
|
45
61
|
// Peer auth via the local Unix socket on the droplet; PG* env overrides for other environments.
|
|
46
62
|
host: process.env.PGHOST || '/var/run/postgresql',
|
|
47
63
|
database: process.env.PGDATABASE || instanceDbName(),
|
|
@@ -66,4 +82,4 @@ pool.on('error', (err) => {
|
|
|
66
82
|
console.error('[gds/pool] unexpected pool error', err);
|
|
67
83
|
});
|
|
68
84
|
|
|
69
|
-
module.exports = { pool, instanceDbName };
|
|
85
|
+
module.exports = { pool, instanceDbName, poolMax };
|
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.766'; // 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');
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// tests/pool_max.mjs
|
|
2
|
+
//
|
|
3
|
+
// The Postgres pool cap (task 1003928, ADR 0285 §3). Every co-located instance used
|
|
4
|
+
// to open pg's default 10 connections against a shared server that allows 97, so
|
|
5
|
+
// nine busy projects exhausted it whatever the RAM headroom. src/bongos/pool.js now
|
|
6
|
+
// honours PGPOOL_MAX; the runner writes it into a co-located instance's web.env
|
|
7
|
+
// (tests/provision.mjs pins that half). These pin the pool half:
|
|
8
|
+
//
|
|
9
|
+
// • the env reaches pg's `max` — asserted on a REAL Pool built by requiring the
|
|
10
|
+
// module in a child process with the env set (pool.js builds its Pool at require
|
|
11
|
+
// time, so the assertion has to cross a process boundary; no connection is ever
|
|
12
|
+
// opened — a Pool connects lazily);
|
|
13
|
+
// • absence leaves pg's default (10), so self-host, dev and the control plane are
|
|
14
|
+
// unchanged;
|
|
15
|
+
// • garbage is ignored, never handed to pg.
|
|
16
|
+
//
|
|
17
|
+
// DB-free. Run: node tests/pool_max.mjs
|
|
18
|
+
|
|
19
|
+
import { strict as assert } from 'node:assert';
|
|
20
|
+
import { execFileSync } from 'node:child_process';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { poolMax } from '../src/bongos/pool.js';
|
|
24
|
+
|
|
25
|
+
let passed = 0, failed = 0;
|
|
26
|
+
function t(name, fn) {
|
|
27
|
+
try { fn(); passed++; console.log(` PASS ${name}`); }
|
|
28
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ROOT = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
32
|
+
const POOL = path.join(ROOT, 'src', 'bongos', 'pool.js');
|
|
33
|
+
|
|
34
|
+
// Build the pool in a child with a controlled env and report what pg was handed.
|
|
35
|
+
// The DB name is pinned so instanceDbName() never reads a pack; nothing connects.
|
|
36
|
+
function poolMaxInChild(extraEnv) {
|
|
37
|
+
const env = { ...process.env, PGDATABASE: 'pool_max_probe' };
|
|
38
|
+
delete env.PGPOOL_MAX; delete env.DATABASE_URL; delete env.NODE_TEST_CONTEXT;
|
|
39
|
+
Object.assign(env, extraEnv);
|
|
40
|
+
const out = execFileSync(process.execPath, [
|
|
41
|
+
'-e', 'const { pool } = require(process.argv[1]); console.log(JSON.stringify({ max: pool.options.max })); pool.end();',
|
|
42
|
+
POOL,
|
|
43
|
+
], { env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
44
|
+
return JSON.parse(out.trim().split('\n').pop()).max;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
console.log('poolMax (pure):');
|
|
48
|
+
|
|
49
|
+
t('a positive integer is the cap; absent/blank/garbage/zero/negative/fractional → undefined (pg keeps its default)', () => {
|
|
50
|
+
assert.equal(poolMax({ PGPOOL_MAX: '3' }), 3);
|
|
51
|
+
assert.equal(poolMax({ PGPOOL_MAX: '10' }), 10);
|
|
52
|
+
assert.equal(poolMax({}), undefined);
|
|
53
|
+
assert.equal(poolMax({ PGPOOL_MAX: '' }), undefined);
|
|
54
|
+
assert.equal(poolMax({ PGPOOL_MAX: 'three' }), undefined);
|
|
55
|
+
assert.equal(poolMax({ PGPOOL_MAX: '0' }), undefined);
|
|
56
|
+
assert.equal(poolMax({ PGPOOL_MAX: '-2' }), undefined);
|
|
57
|
+
assert.equal(poolMax({ PGPOOL_MAX: '2.5' }), undefined);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
console.log('\nthe env reaches the real Pool (child process, no connection):');
|
|
61
|
+
|
|
62
|
+
t('PGPOOL_MAX=3 → pool.options.max is 3 (the co-located instance shape)', () => {
|
|
63
|
+
assert.equal(poolMaxInChild({ PGPOOL_MAX: '3' }), 3);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
t('unset → pg\'s default 10 (self-host, dev, the control plane: unchanged)', () => {
|
|
67
|
+
assert.equal(poolMaxInChild({}), 10);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
t('garbage → pg\'s default, never a NaN handed to pg', () => {
|
|
71
|
+
assert.equal(poolMaxInChild({ PGPOOL_MAX: 'lots' }), 10);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
t('the cap applies on the DATABASE_URL branch too', () => {
|
|
75
|
+
assert.equal(poolMaxInChild({ PGPOOL_MAX: '4', DATABASE_URL: 'postgres://u:p@127.0.0.1:1/pool_max_probe' }), 4);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
console.log(`\npool_max: ${passed} passed, ${failed} failed`);
|
|
79
|
+
process.exit(failed ? 1 : 0);
|
package/tests/provision.mjs
CHANGED
|
@@ -2618,6 +2618,27 @@ t('webEnvBody: NO password ⇒ no PG credential lines — an instance provisione
|
|
|
2618
2618
|
assert.match(b, /^PGDATABASE=mercury$/m, 'the DB name is unchanged either way');
|
|
2619
2619
|
});
|
|
2620
2620
|
|
|
2621
|
+
// ── the Postgres pool cap (task 1003928, ADR 0285 §3) ─────────────────────────
|
|
2622
|
+
t('webEnvBody: a CO-LOCATED instance (co-tenant or standalone) carries PGPOOL_MAX at the runner\'s cap, beside PGDATABASE', () => {
|
|
2623
|
+
for (const shape of ['co-tenant', 'standalone']) {
|
|
2624
|
+
const b = P.webEnvBody({ ...coTenant, hosting_shape: shape });
|
|
2625
|
+
assert.match(b, new RegExp(`^PGPOOL_MAX=${P.CONFIG.instancePoolMax}$`, 'm'), `${shape} carries the cap`);
|
|
2626
|
+
const lines = b.split('\n');
|
|
2627
|
+
assert.equal(lines[lines.indexOf('PGDATABASE=mercury') + 1], `PGPOOL_MAX=${P.CONFIG.instancePoolMax}`, 'written right after the DB name, with or without a credential');
|
|
2628
|
+
}
|
|
2629
|
+
assert.equal(P.CONFIG.instancePoolMax, 3, 'the default cap: 3 × 15 slots + the control plane\'s 10 stays under the server\'s 97');
|
|
2630
|
+
// …and it rides along with the credential lines, not instead of them.
|
|
2631
|
+
const withCred = P.webEnvBody(coTenant, { dbPassword: 'pw_ABC-123' });
|
|
2632
|
+
assert.match(withCred, /^PGPOOL_MAX=3$/m);
|
|
2633
|
+
assert.match(withCred, /^PGUSER=bongos-mercury$/m);
|
|
2634
|
+
});
|
|
2635
|
+
|
|
2636
|
+
t('webEnvBody: a DEDICATED droplet has its own Postgres — no PGPOOL_MAX line, pg keeps its default', () => {
|
|
2637
|
+
const b = P.webEnvBody({ ...coTenant, hosting_shape: 'dedicated' });
|
|
2638
|
+
assert.doesNotMatch(b, /^PGPOOL_MAX=/m);
|
|
2639
|
+
assert.match(b, /^PGDATABASE=mercury$/m);
|
|
2640
|
+
});
|
|
2641
|
+
|
|
2621
2642
|
t('dedicatedUserData: the droplet runs the SAME account + role steps, in the same order around the migrate', () => {
|
|
2622
2643
|
const ud = P.dedicatedUserData(coTenant);
|
|
2623
2644
|
const at = (needle) => ud.indexOf(needle);
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
// tests/provisioning_capacity.mjs
|
|
2
|
+
//
|
|
3
|
+
// The box-capacity ceiling (ADR 0285, task 1003927): the shared arithmetic in
|
|
4
|
+
// modules/provisioning/capacity.js, the runner preflight in
|
|
5
|
+
// scripts/gds/provision-capacity.js, its composition into provisionInstance and
|
|
6
|
+
// the drain loop, and the `capacity` block on GET /provisioning/fleet.
|
|
7
|
+
//
|
|
8
|
+
// Before this, nothing enforced the ceiling the ADR measured: the runner stood up
|
|
9
|
+
// project N+1 on a full box and swap hid it. These tests pin the two walls (row
|
|
10
|
+
// ceiling, live MemAvailable floor), that a refusal happens BEFORE any status write
|
|
11
|
+
// or standup command, that it reaches the owner's card as error_note without
|
|
12
|
+
// flipping the instance's status, and that the number every surface shows comes
|
|
13
|
+
// from the same helpers. Hermetic: fake db, fake executors, a loopback listener
|
|
14
|
+
// for the route; no Postgres, no shell, no /proc.
|
|
15
|
+
//
|
|
16
|
+
// Run: node tests/provisioning_capacity.mjs
|
|
17
|
+
|
|
18
|
+
import { strict as assert } from 'node:assert';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import http from 'node:http';
|
|
21
|
+
import capacityDefault, {
|
|
22
|
+
DEFAULT_CEILING, DEFAULT_MEM_FLOOR_MB, PLACED_STATES,
|
|
23
|
+
parseMeminfo, placementBox, ceilingFor, placementVerdict, capacitySummary, countPlaced, fleetCapacity,
|
|
24
|
+
} from '../modules/provisioning/capacity.js';
|
|
25
|
+
import { MEMINFO_CMD, capacityPreflight, statusCapacityLine } from '../scripts/gds/provision-capacity.js';
|
|
26
|
+
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
const P = require('../scripts/gds/provision.js');
|
|
29
|
+
|
|
30
|
+
let passed = 0, failed = 0;
|
|
31
|
+
function t(name, fn) {
|
|
32
|
+
try { fn(); passed++; console.log(` PASS ${name}`); }
|
|
33
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
34
|
+
}
|
|
35
|
+
async function ta(name, fn) {
|
|
36
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
37
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The live control plane on 2026-09-16 (1968 MiB total) and the ADR's 4 GB tier.
|
|
41
|
+
const MEMINFO_2GB = 'MemTotal: 2015216 kB\nMemFree: 123456 kB\nMemAvailable: 1135180 kB\nSwapTotal: 4194300 kB\n';
|
|
42
|
+
const MEMINFO_4GB = 'MemTotal: 4030432 kB\nMemAvailable: 2500000 kB\n';
|
|
43
|
+
const MEMINFO_TIGHT = 'MemTotal: 2015216 kB\nMemAvailable: 120000 kB\n'; // 117 MB free — under the 256 MB floor
|
|
44
|
+
|
|
45
|
+
// A fake db: answers the ceiling COUNT with `placed`, records every query, and
|
|
46
|
+
// returns no rows for anything else (allocatePort etc.).
|
|
47
|
+
function fakeDb(placed) {
|
|
48
|
+
const queries = [];
|
|
49
|
+
return {
|
|
50
|
+
queries,
|
|
51
|
+
query: async (sql, params) => {
|
|
52
|
+
queries.push({ sql, params });
|
|
53
|
+
if (/count\(\*\)/.test(sql)) return { rows: [{ n: placed }] };
|
|
54
|
+
return { rows: [] };
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
console.log('the arithmetic (ADR 0285):');
|
|
60
|
+
|
|
61
|
+
t('parseMeminfo: MemTotal / MemAvailable in MiB; absent fields read null, never 0', () => {
|
|
62
|
+
assert.deepEqual(parseMeminfo(MEMINFO_2GB), { memTotalMb: 1967, memAvailableMb: 1108 }, 'the ADR\'s own "1967 MiB RAM" reading');
|
|
63
|
+
assert.deepEqual(parseMeminfo(''), { memTotalMb: null, memAvailableMb: null });
|
|
64
|
+
assert.deepEqual(parseMeminfo(null), { memTotalMb: null, memAvailableMb: null });
|
|
65
|
+
assert.deepEqual(parseMeminfo('MemTotal: 4030432 kB\n'), { memTotalMb: 3935, memAvailableMb: null });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
t('ceilingFor reproduces the ADR 0285 table from MemTotal: 15 / 39 / 41 — and a resize raises it with no knob', () => {
|
|
69
|
+
assert.deepEqual(ceilingFor({ memTotalMb: 1968, fixedMb: 750 }), { ceiling: 15, source: 'measured' }, 's-1vcpu-2gb control plane');
|
|
70
|
+
assert.deepEqual(ceilingFor({ memTotalMb: 3936, fixedMb: 750 }), { ceiling: 39, source: 'measured' }, 's-2vcpu-4gb control plane');
|
|
71
|
+
assert.deepEqual(ceilingFor({ memTotalMb: 3936, fixedMb: 630 }), { ceiling: 41, source: 'measured' }, 's-2vcpu-4gb overflow box');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
t('ceilingFor: an explicit override wins; an unmeasured box falls to the ADR smallest-tier default and SAYS so', () => {
|
|
75
|
+
assert.deepEqual(ceilingFor({ override: 20, memTotalMb: 1968 }), { ceiling: 20, source: 'env' });
|
|
76
|
+
assert.deepEqual(ceilingFor({}), { ceiling: DEFAULT_CEILING, source: 'default' });
|
|
77
|
+
assert.deepEqual(ceilingFor({ memTotalMb: 500, fixedMb: 750 }), { ceiling: DEFAULT_CEILING, source: 'default' }, 'a box smaller than its fixed base cannot be measured into a ceiling');
|
|
78
|
+
assert.equal(DEFAULT_CEILING, 15);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
t('placementBox: standalone → control plane always; co-tenant → the remote box only under remote-exec; dedicated → no shared box', () => {
|
|
82
|
+
assert.deepEqual(placementBox('standalone', {}), { box: 'control-plane', shapes: ['standalone', 'co-tenant'], fixedMb: 750 });
|
|
83
|
+
assert.deepEqual(placementBox('standalone', { remoteHost: 'cohost' }), { box: 'control-plane', shapes: ['standalone'], fixedMb: 750 });
|
|
84
|
+
assert.deepEqual(placementBox('co-tenant', {}), { box: 'control-plane', shapes: ['standalone', 'co-tenant'], fixedMb: 750 });
|
|
85
|
+
assert.deepEqual(placementBox('co-tenant', { remoteHost: 'cohost' }), { box: 'overflow', shapes: ['co-tenant'], fixedMb: 630 });
|
|
86
|
+
assert.equal(placementBox('dedicated', {}), null);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
t('placementVerdict: the ceiling refuses at N of N, the floor refuses under 256 MB free, an unmeasured floor cannot refuse', () => {
|
|
90
|
+
const full = placementVerdict({ placed: 15, ceiling: 15, memAvailableMb: 1108 });
|
|
91
|
+
assert.equal(full.ok, false); assert.equal(full.wall, 'ceiling');
|
|
92
|
+
assert.match(full.reason, /15 of 15 project slots/, 'names N of M for the owner');
|
|
93
|
+
assert.match(full.reason, /Nothing was placed/);
|
|
94
|
+
assert.match(full.reason, /task 1003923/, 'points at the resize');
|
|
95
|
+
const tight = placementVerdict({ placed: 10, ceiling: 15, memAvailableMb: 117 });
|
|
96
|
+
assert.equal(tight.ok, false); assert.equal(tight.wall, 'memory');
|
|
97
|
+
assert.match(tight.reason, /117 MB of memory free, under the 256 MB/);
|
|
98
|
+
assert.deepEqual(placementVerdict({ placed: 10, ceiling: 15, memAvailableMb: 1108 }), { ok: true, wall: null, reason: null });
|
|
99
|
+
assert.equal(placementVerdict({ placed: 14, ceiling: 15, memAvailableMb: null }).ok, true, 'unmeasured memory: the ceiling alone decides');
|
|
100
|
+
assert.equal(placementVerdict({ placed: 16, ceiling: 15 }).ok, false, 'past the ceiling is still full');
|
|
101
|
+
assert.equal(DEFAULT_MEM_FLOOR_MB, 256);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
t('capacitySummary: resize_due at 80% (12 of 15), remaining never negative, verdict carried', () => {
|
|
105
|
+
const eleven = capacitySummary({ box: 'control-plane', placed: 11, ceiling: 15, source: 'measured', memAvailableMb: 1108 });
|
|
106
|
+
assert.equal(eleven.resize_at, 12); assert.equal(eleven.resize_due, false); assert.equal(eleven.remaining, 4);
|
|
107
|
+
const twelve = capacitySummary({ box: 'control-plane', placed: 12, ceiling: 15, source: 'measured', memAvailableMb: 1108 });
|
|
108
|
+
assert.equal(twelve.resize_due, true, 'the ADR schedules the resize at 12');
|
|
109
|
+
assert.equal(twelve.verdict.ok, true, 'due for a resize is not yet full');
|
|
110
|
+
const over = capacitySummary({ box: 'control-plane', placed: 16, ceiling: 15, source: 'env' });
|
|
111
|
+
assert.equal(over.remaining, 0); assert.equal(over.verdict.ok, false);
|
|
112
|
+
assert.equal(over.mem_floor_mb, DEFAULT_MEM_FLOOR_MB);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await ta('countPlaced counts ONLY provisioning + active rows of the box\'s shapes, and can exclude the row being re-run', async () => {
|
|
116
|
+
const db = fakeDb(10);
|
|
117
|
+
assert.equal(await countPlaced(db, ['standalone']), 10);
|
|
118
|
+
assert.deepEqual(db.queries[0].params, [['standalone'], PLACED_STATES]);
|
|
119
|
+
assert.deepEqual(PLACED_STATES, ['provisioning', 'active'], 'error rows hold no running unit (live fleet, 2026-09-16) and must not take a slot');
|
|
120
|
+
assert.doesNotMatch(db.queries[0].sql, /id <>/, 'no exclusion unless asked');
|
|
121
|
+
await countPlaced(db, ['standalone', 'co-tenant'], { excludeId: 42 });
|
|
122
|
+
assert.match(db.queries[1].sql, /id <> \$3/);
|
|
123
|
+
assert.deepEqual(db.queries[1].params, [['standalone', 'co-tenant'], PLACED_STATES, 42]);
|
|
124
|
+
assert.equal(await countPlaced({ query: async () => ({ rows: [] }) }, ['standalone']), 0, 'a recording fake with no rows reads 0, not NaN');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await ta('fleetCapacity (the API + CLI summary) measures THIS host and counts the control plane\'s shapes', async () => {
|
|
128
|
+
delete process.env.PROVISION_BOX_CEILING;
|
|
129
|
+
const db = fakeDb(10);
|
|
130
|
+
const c = await fleetCapacity(db, { meminfo: MEMINFO_2GB, remoteHost: null });
|
|
131
|
+
assert.equal(c.box, 'control-plane'); assert.equal(c.placed, 10); assert.equal(c.ceiling, 15); assert.equal(c.source, 'measured');
|
|
132
|
+
assert.equal(c.mem_available_mb, 1108); assert.equal(c.resize_due, false); assert.equal(c.verdict.ok, true);
|
|
133
|
+
assert.deepEqual(db.queries[0].params[0], ['standalone', 'co-tenant']);
|
|
134
|
+
const remote = await fleetCapacity(fakeDb(3), { meminfo: MEMINFO_4GB, remoteHost: 'cohost' });
|
|
135
|
+
assert.equal(remote.ceiling, 39, 'the control plane\'s own 4 GB ceiling, not the overflow box\'s');
|
|
136
|
+
// The env override reaches the summary, and is read per call.
|
|
137
|
+
process.env.PROVISION_BOX_CEILING = '9';
|
|
138
|
+
const pinned = await fleetCapacity(fakeDb(10), { meminfo: MEMINFO_2GB, remoteHost: null });
|
|
139
|
+
assert.deepEqual([pinned.ceiling, pinned.source, pinned.verdict.ok], [9, 'env', false]);
|
|
140
|
+
delete process.env.PROVISION_BOX_CEILING;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
console.log('\nthe runner preflight (scripts/gds/provision-capacity.js):');
|
|
144
|
+
|
|
145
|
+
const standalone = { id: 7, slug: 'lantern', hosting_shape: 'standalone', status: 'requested', owner_builder_id: 3, port: null, db_name: 'lantern', domain: null };
|
|
146
|
+
|
|
147
|
+
// Recording deps: which executor was asked, what was logged, which events landed.
|
|
148
|
+
function preflightDeps({ apply = true, placed = 10, controlMeminfo = MEMINFO_2GB, boxMeminfo = MEMINFO_4GB } = {}) {
|
|
149
|
+
const asked = [], logs = [], events = [];
|
|
150
|
+
return {
|
|
151
|
+
asked, logs, events,
|
|
152
|
+
deps: {
|
|
153
|
+
apply, log: (l) => logs.push(l),
|
|
154
|
+
db: fakeDb(placed),
|
|
155
|
+
provisioning: { recordEvent: async (_db, e) => { events.push(e); } },
|
|
156
|
+
exec: (cmd) => { asked.push(['box', cmd]); return { ok: true, stdout: boxMeminfo }; },
|
|
157
|
+
controlExec: (cmd) => { asked.push(['control', cmd]); return { ok: true, stdout: controlMeminfo }; },
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
await ta('a standalone with room: ok, measured on the CONTROL executor, the count excludes its own row', async () => {
|
|
163
|
+
const h = preflightDeps({ placed: 10 });
|
|
164
|
+
const r = await capacityPreflight(standalone, h.deps);
|
|
165
|
+
assert.equal(r.ok, true);
|
|
166
|
+
assert.deepEqual(h.asked, [['control', MEMINFO_CMD]], 'a standalone runs control-plane-local, so its box is measured there');
|
|
167
|
+
assert.equal(r.ceiling, 15); assert.equal(r.source, 'measured'); assert.equal(r.placed, 10);
|
|
168
|
+
assert.deepEqual(h.deps.db.queries[0].params[2], 7, 'the row being provisioned never counts itself');
|
|
169
|
+
assert.equal(h.events.length, 0, 'nothing to record when it fits');
|
|
170
|
+
assert.ok(h.logs.some((l) => /control-plane box at 10 of 15 slots \(measured, 1108 MB free\)/.test(l)), `logs the box line: ${h.logs.join(' | ')}`);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await ta('a full box REFUSES with the owner-readable reason, records placement-refused, and returns (never throws)', async () => {
|
|
174
|
+
const h = preflightDeps({ placed: 15 });
|
|
175
|
+
const r = await capacityPreflight(standalone, h.deps);
|
|
176
|
+
assert.equal(r.ok, false);
|
|
177
|
+
assert.match(r.error, /15 of 15 project slots are taken/);
|
|
178
|
+
assert.equal(h.events.length, 1);
|
|
179
|
+
assert.equal(h.events[0].event, 'placement-refused');
|
|
180
|
+
assert.equal(h.events[0].instanceId, 7);
|
|
181
|
+
assert.equal(h.events[0].detail, r.error, 'the ledger carries the same sentence the card will');
|
|
182
|
+
assert.ok(h.logs.some((l) => /✖ \[preflight\].*refusing to place lantern \(ceiling wall\)/.test(l)));
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await ta('the live memory floor refuses even with slots to spare (a stray unit the ledger cannot see)', async () => {
|
|
186
|
+
const h = preflightDeps({ placed: 10, controlMeminfo: MEMINFO_TIGHT });
|
|
187
|
+
const r = await capacityPreflight(standalone, h.deps);
|
|
188
|
+
assert.equal(r.ok, false);
|
|
189
|
+
assert.equal(r.verdict.wall, 'memory');
|
|
190
|
+
assert.match(r.error, /117 MB of memory free/);
|
|
191
|
+
assert.equal(h.events[0].event, 'placement-refused');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await ta('at 80% it still places, but the log names the resize trigger', async () => {
|
|
195
|
+
const h = preflightDeps({ placed: 12 });
|
|
196
|
+
const r = await capacityPreflight(standalone, h.deps);
|
|
197
|
+
assert.equal(r.ok, true); assert.equal(r.resize_due, true);
|
|
198
|
+
assert.ok(h.logs.some((l) => /at or past 80%: schedule the resize \(task 1003923\)/.test(l)), h.logs.join(' | '));
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
await ta('a co-tenant under remote-exec is measured on the BOX executor against the overflow base (41 on 4 GB)', async () => {
|
|
202
|
+
const { CONFIG } = require('../scripts/gds/provision-config.js');
|
|
203
|
+
const prior = CONFIG.remoteHost;
|
|
204
|
+
CONFIG.remoteHost = 'cohost';
|
|
205
|
+
try {
|
|
206
|
+
const h = preflightDeps({ placed: 40 });
|
|
207
|
+
const r = await capacityPreflight({ ...standalone, hosting_shape: 'co-tenant' }, h.deps);
|
|
208
|
+
assert.deepEqual(h.asked, [['box', MEMINFO_CMD]]);
|
|
209
|
+
assert.equal(r.box, 'overflow'); assert.equal(r.ceiling, 41); assert.equal(r.ok, true);
|
|
210
|
+
assert.deepEqual(h.deps.db.queries[0].params[0], ['co-tenant'], 'only the shapes that share that box count');
|
|
211
|
+
} finally { CONFIG.remoteHost = prior; }
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
await ta('a dedicated droplet is its own box: no measurement, no count, no refusal', async () => {
|
|
215
|
+
const h = preflightDeps({ placed: 99 });
|
|
216
|
+
const r = await capacityPreflight({ ...standalone, hosting_shape: 'dedicated' }, h.deps);
|
|
217
|
+
assert.deepEqual(r, { ok: true, skipped: 'dedicated' });
|
|
218
|
+
assert.equal(h.asked.length, 0); assert.equal(h.deps.db.queries.length, 0);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
await ta('dry-run measures nothing and refuses nothing, even on a full box; an unmeasured --apply falls to the default and warns', async () => {
|
|
222
|
+
const dry = preflightDeps({ apply: false, placed: 15 });
|
|
223
|
+
const r = await capacityPreflight(standalone, dry.deps);
|
|
224
|
+
assert.equal(r.ok, true); assert.equal(r.dryRun, true);
|
|
225
|
+
assert.equal(dry.events.length, 0);
|
|
226
|
+
assert.ok(dry.logs.some((l) => /would refuse if full/.test(l)));
|
|
227
|
+
// --apply with an executor that answers no stdout (a fake, or a box without procfs)
|
|
228
|
+
const blind = preflightDeps({ placed: 3, controlMeminfo: '' });
|
|
229
|
+
const b = await capacityPreflight(standalone, blind.deps);
|
|
230
|
+
assert.equal(b.ok, true); assert.equal(b.source, 'default'); assert.equal(b.ceiling, DEFAULT_CEILING);
|
|
231
|
+
assert.ok(blind.logs.some((l) => /could not measure the box.*PROVISION_BOX_CEILING/.test(l)), blind.logs.join(' | '));
|
|
232
|
+
// …and an executor that throws is the same as one that answers nothing.
|
|
233
|
+
const throwing = preflightDeps({ placed: 3 });
|
|
234
|
+
throwing.deps.controlExec = () => { throw new Error('ssh down'); };
|
|
235
|
+
assert.equal((await capacityPreflight(standalone, throwing.deps)).source, 'default');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
await ta('statusCapacityLine prints "box at N of M" for the CLI', async () => {
|
|
239
|
+
delete process.env.PROVISION_BOX_CEILING;
|
|
240
|
+
// fleetCapacity reads THIS host's /proc/meminfo (absent on a Windows dev box → default);
|
|
241
|
+
// the line must carry the count and the source whichever it is.
|
|
242
|
+
const line = await statusCapacityLine(fakeDb(12));
|
|
243
|
+
assert.match(line, /control-plane box: 12 of \d+ project slots taken \((measured|default)/);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
console.log('\ncomposition into the runner (provisionInstance + the drain loop):');
|
|
247
|
+
|
|
248
|
+
// The recorder from tests/provision.mjs, with a db that answers the ceiling count.
|
|
249
|
+
function recorder({ apply, placed, meminfo = MEMINFO_2GB }) {
|
|
250
|
+
const cmds = [], writes = [], statuses = [], events = [];
|
|
251
|
+
const db = fakeDb(placed);
|
|
252
|
+
const deps = {
|
|
253
|
+
apply, log: () => {}, db,
|
|
254
|
+
provisioning: {
|
|
255
|
+
setInstanceStatus: async (_db, _id, s) => { statuses.push(s); },
|
|
256
|
+
recordEvent: async (_db, e) => { events.push(e); },
|
|
257
|
+
getInstanceBySlug: async () => null,
|
|
258
|
+
},
|
|
259
|
+
exec: (cmd) => { cmds.push(cmd); return { ok: true, stdout: /meminfo/.test(cmd) ? meminfo : '' }; },
|
|
260
|
+
writeFile: (p) => { writes.push(p); return { ok: true }; },
|
|
261
|
+
doApi: null,
|
|
262
|
+
checkDnsTokenScope: async () => ({ reachable: true, zone: 'example.com' }),
|
|
263
|
+
getGithubToken: async () => null,
|
|
264
|
+
};
|
|
265
|
+
return { deps, cmds, writes, statuses, events };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
await ta('provisionInstance(--apply) on a full box returns ok:false BEFORE any status write or standup command', async () => {
|
|
269
|
+
const h = recorder({ apply: true, placed: 15 });
|
|
270
|
+
const r = await P.provisionInstance(standalone, h.deps);
|
|
271
|
+
assert.equal(r.ok, false);
|
|
272
|
+
assert.match(r.error, /15 of 15 project slots/);
|
|
273
|
+
assert.deepEqual(h.statuses, [], 'never flipped to provisioning — a project that was not placed is not "provisioning"');
|
|
274
|
+
assert.deepEqual(h.cmds, [MEMINFO_CMD], 'the ONLY command run was the measurement');
|
|
275
|
+
assert.deepEqual(h.writes, [], 'no unit, no env file, no Caddy block');
|
|
276
|
+
assert.deepEqual(h.events.map((e) => e.event), ['placement-refused']);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
await ta('provisionInstance(--apply) with room proceeds into the runbook exactly as before', async () => {
|
|
280
|
+
const h = recorder({ apply: true, placed: 10 });
|
|
281
|
+
const r = await P.provisionInstance({ ...standalone, hosting_shape: 'co-tenant', port: 3003 }, h.deps);
|
|
282
|
+
assert.equal(r.ok, true);
|
|
283
|
+
assert.equal(h.statuses[0], 'provisioning');
|
|
284
|
+
assert.equal(h.cmds[0], MEMINFO_CMD, 'measured first');
|
|
285
|
+
assert.ok(h.cmds.some((c) => /createdb lantern/.test(c)), 'then the runbook');
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
await ta('provisionInstance(dry-run) on a full box still prints the whole plan — dry-run places nothing, so it refuses nothing', async () => {
|
|
289
|
+
const h = recorder({ apply: false, placed: 15 });
|
|
290
|
+
const r = await P.provisionInstance({ ...standalone, hosting_shape: 'co-tenant', port: 3003 }, h.deps);
|
|
291
|
+
assert.equal(r.ok, true);
|
|
292
|
+
assert.ok(h.cmds.some((c) => /createdb lantern/.test(c)));
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// The drain loop's retirement path: MAX_INTENT_ATTEMPTS reached → the intent is
|
|
296
|
+
// resolved 'error' and the reason lands on the instance WITHOUT changing its status.
|
|
297
|
+
await ta('cmdRunIntents: a refused placement retires onto the card as error_note, status untouched, event recorded', async () => {
|
|
298
|
+
let claims = 0; const resolved = [], statusWrites = [], events = [];
|
|
299
|
+
const deps = {
|
|
300
|
+
apply: true, log: () => {},
|
|
301
|
+
db: fakeDb(15),
|
|
302
|
+
provisioning: {
|
|
303
|
+
claimNextIntent: async () => (claims++ === 0 ? { id: 9, instance_id: 7, action: 'provision', attempts: P.MAX_INTENT_ATTEMPTS } : null),
|
|
304
|
+
getInstanceById: async () => ({ ...standalone }),
|
|
305
|
+
resolveIntent: async (_db, id, state, err) => { resolved.push({ id, state, err }); },
|
|
306
|
+
setInstanceStatus: async (_db, id, status, patch) => { statusWrites.push({ id, status, patch }); },
|
|
307
|
+
recordEvent: async (_db, e) => { events.push(e); },
|
|
308
|
+
claimNextOAuthManifest: async () => null,
|
|
309
|
+
resolveOAuthManifest: async () => {},
|
|
310
|
+
},
|
|
311
|
+
exec: (cmd) => ({ ok: true, stdout: /meminfo/.test(cmd) ? MEMINFO_2GB : '' }),
|
|
312
|
+
writeFile: () => ({ ok: true }), doApi: null,
|
|
313
|
+
};
|
|
314
|
+
const r = await P.cmdRunIntents(deps);
|
|
315
|
+
assert.equal(r.errors, 1); assert.equal(r.ok, 0);
|
|
316
|
+
assert.equal(resolved[0].state, 'error');
|
|
317
|
+
assert.match(resolved[0].err, /15 of 15 project slots/, 'the intent carries the reason');
|
|
318
|
+
assert.equal(statusWrites.length, 1);
|
|
319
|
+
assert.equal(statusWrites[0].status, 'requested', 'the CURRENT status is passed back — never flipped to error');
|
|
320
|
+
assert.match(statusWrites[0].patch.error_note, /Nothing was placed/, 'the card reads why');
|
|
321
|
+
assert.deepEqual(events.map((e) => e.event), ['placement-refused']);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
await ta('cmdRunIntents: below the attempt cap a refusal goes back to pending with last_error — a teardown may free the slot', async () => {
|
|
325
|
+
let claims = 0; const updates = [];
|
|
326
|
+
const db = fakeDb(15);
|
|
327
|
+
const rawQuery = db.query;
|
|
328
|
+
db.query = async (sql, params) => { if (/SET state='pending'/.test(sql)) { updates.push(params); return { rows: [] }; } return rawQuery(sql, params); };
|
|
329
|
+
const deps = {
|
|
330
|
+
apply: true, log: () => {}, db,
|
|
331
|
+
provisioning: {
|
|
332
|
+
claimNextIntent: async () => (claims++ === 0 ? { id: 9, instance_id: 7, action: 'provision', attempts: 1 } : null),
|
|
333
|
+
getInstanceById: async () => ({ ...standalone }),
|
|
334
|
+
resolveIntent: async () => { throw new Error('must not resolve on a retryable refusal'); },
|
|
335
|
+
setInstanceStatus: async () => { throw new Error('must not touch the instance on a retryable refusal'); },
|
|
336
|
+
recordEvent: async () => {},
|
|
337
|
+
claimNextOAuthManifest: async () => null,
|
|
338
|
+
resolveOAuthManifest: async () => {},
|
|
339
|
+
},
|
|
340
|
+
exec: (cmd) => ({ ok: true, stdout: /meminfo/.test(cmd) ? MEMINFO_2GB : '' }),
|
|
341
|
+
writeFile: () => ({ ok: true }), doApi: null,
|
|
342
|
+
};
|
|
343
|
+
const r = await P.cmdRunIntents(deps);
|
|
344
|
+
assert.equal(r.errors, 1);
|
|
345
|
+
assert.equal(updates.length, 1);
|
|
346
|
+
assert.equal(updates[0][0], 9);
|
|
347
|
+
assert.match(updates[0][1], /15 of 15 project slots/);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
console.log('\nthe fleet API carries the same summary:');
|
|
351
|
+
|
|
352
|
+
// Stub the route's data access at the same seams tests/provisioning_paid_shape_gate.mjs
|
|
353
|
+
// uses; the capacity module object is shared with the route through the require cache.
|
|
354
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
355
|
+
provisioning.listAllInstances = async () => [{ id: 1, owner_builder_id: 7, slug: 'lantern', status: 'active', hosting_shape: 'standalone', settings: {}, detail: {}, github_login: 'o', display_name: 'O', rank: 'archon' }];
|
|
356
|
+
const api = require('../src/module-api.js');
|
|
357
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
|
|
358
|
+
api.requirePermission = () => (_req, _res, next) => next();
|
|
359
|
+
const realFleetCapacity = capacityDefault.fleetCapacity;
|
|
360
|
+
|
|
361
|
+
const express = require('express');
|
|
362
|
+
const provisioningRoutes = require('../modules/provisioning/routes/provisioning.js');
|
|
363
|
+
const app = express();
|
|
364
|
+
app.use(express.json());
|
|
365
|
+
app.use((_req, res, next) => {
|
|
366
|
+
res.fail = (code, opts, details) => {
|
|
367
|
+
const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
|
|
368
|
+
res.status(status).json({ error: { code, ...(details || (typeof opts === 'object' ? opts : {})) } });
|
|
369
|
+
};
|
|
370
|
+
next();
|
|
371
|
+
});
|
|
372
|
+
app.use('/api/bongos', provisioningRoutes());
|
|
373
|
+
const server = app.listen(0);
|
|
374
|
+
await new Promise((r) => server.once('listening', r));
|
|
375
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
376
|
+
function get(path) {
|
|
377
|
+
return new Promise((resolve, reject) => {
|
|
378
|
+
http.get(`${base}${path}`, (res) => {
|
|
379
|
+
let d = '';
|
|
380
|
+
res.on('data', (c) => { d += c; });
|
|
381
|
+
res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
|
|
382
|
+
}).on('error', reject);
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
await ta('GET /provisioning/fleet returns `capacity` beside the roster — placed, ceiling, source, resize_due', async () => {
|
|
387
|
+
capacityDefault.fleetCapacity = async (db) => fleetCapacity(fakeDb(12), { meminfo: MEMINFO_2GB, remoteHost: null });
|
|
388
|
+
try {
|
|
389
|
+
const res = await get('/provisioning/fleet');
|
|
390
|
+
assert.equal(res.status, 200, res.raw);
|
|
391
|
+
assert.equal(res.body.instances.length, 1);
|
|
392
|
+
const c = res.body.capacity;
|
|
393
|
+
assert.equal(c.box, 'control-plane'); assert.equal(c.placed, 12); assert.equal(c.ceiling, 15);
|
|
394
|
+
assert.equal(c.source, 'measured'); assert.equal(c.resize_due, true); assert.equal(c.remaining, 3);
|
|
395
|
+
assert.equal(c.verdict.ok, true);
|
|
396
|
+
} finally { capacityDefault.fleetCapacity = realFleetCapacity; }
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
await ta('a capacity read that fails never fails the roster: `capacity` is null, the instances still come back', async () => {
|
|
400
|
+
capacityDefault.fleetCapacity = async () => { throw new Error('count unavailable'); };
|
|
401
|
+
try {
|
|
402
|
+
const res = await get('/provisioning/fleet');
|
|
403
|
+
assert.equal(res.status, 200, res.raw);
|
|
404
|
+
assert.equal(res.body.instances.length, 1);
|
|
405
|
+
assert.equal(res.body.capacity, null);
|
|
406
|
+
} finally { capacityDefault.fleetCapacity = realFleetCapacity; }
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
server.close();
|
|
410
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
411
|
+
process.exit(failed ? 1 : 0);
|