@bongos/core 1.19.1053 → 1.19.1054
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 +17 -17
- package/docs/module-api-changelog.md +2 -0
- package/modules/provisioning/control-plane-guard.js +14 -1
- package/modules/provisioning/provisioning.js +5 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/scripts/gds/provision-units.js +38 -2
- package/scripts/gds/provision.js +6 -6
- package/src/module-api.js +1 -1
- package/tests/core_upgrade_door.mjs +16 -2
- package/tests/provision_settings_apply.mjs +84 -0
- package/tests/provisioning_settings_apply.mjs +50 -5
package/.bongos-core.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
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.1054",
|
|
6
|
+
"core_contract": "1.19.1054",
|
|
7
|
+
"source_commit": "16cad90499c802cd308487cec2c824589b064b95",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-28T04:
|
|
9
|
+
"built_at": "2026-09-28T04:35:28.546Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 542,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
19
|
"file_count": 3078,
|
|
20
|
-
"tree_sha256": "
|
|
20
|
+
"tree_sha256": "b4fe4ba91ff03f9db59c7306f72e4f1898f4f5d0a168d05a228e0689c42765db",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2737,7 +2737,7 @@
|
|
|
2737
2737
|
{
|
|
2738
2738
|
"path": "docs/module-api-changelog.md",
|
|
2739
2739
|
"mode": "0000644",
|
|
2740
|
-
"sha256": "
|
|
2740
|
+
"sha256": "cd63efa0d5860b1d1e150d0472ea95cc8285e58526aae6696f05dc803ca7cfea"
|
|
2741
2741
|
},
|
|
2742
2742
|
{
|
|
2743
2743
|
"path": "docs/modules-contract.md",
|
|
@@ -6897,7 +6897,7 @@
|
|
|
6897
6897
|
{
|
|
6898
6898
|
"path": "modules/provisioning/control-plane-guard.js",
|
|
6899
6899
|
"mode": "0000644",
|
|
6900
|
-
"sha256": "
|
|
6900
|
+
"sha256": "beb559945e2b00edfab521a65e2c8b91671a35e8a67f6d3be30d39a7a7d8118c"
|
|
6901
6901
|
},
|
|
6902
6902
|
{
|
|
6903
6903
|
"path": "modules/provisioning/core-upgrade-authz.js",
|
|
@@ -7107,7 +7107,7 @@
|
|
|
7107
7107
|
{
|
|
7108
7108
|
"path": "modules/provisioning/provisioning.js",
|
|
7109
7109
|
"mode": "0000644",
|
|
7110
|
-
"sha256": "
|
|
7110
|
+
"sha256": "1a4823ad0fde877d954faf5650942cb39ab063541433ac101cec04b7cb03d980"
|
|
7111
7111
|
},
|
|
7112
7112
|
{
|
|
7113
7113
|
"path": "modules/provisioning/public-refusal.js",
|
|
@@ -8462,12 +8462,12 @@
|
|
|
8462
8462
|
{
|
|
8463
8463
|
"path": "package-lock.json",
|
|
8464
8464
|
"mode": "0000644",
|
|
8465
|
-
"sha256": "
|
|
8465
|
+
"sha256": "7a222030251e4f1975d78ef3b4bf78d212aa17b80d001b6858f0dcf92bbf0d90"
|
|
8466
8466
|
},
|
|
8467
8467
|
{
|
|
8468
8468
|
"path": "package.json",
|
|
8469
8469
|
"mode": "0000644",
|
|
8470
|
-
"sha256": "
|
|
8470
|
+
"sha256": "b4cf0e08cfe3dc1e5980144ff698d22ac1800712e3939a663d67845232d0b95c"
|
|
8471
8471
|
},
|
|
8472
8472
|
{
|
|
8473
8473
|
"path": "public-docs/index.html",
|
|
@@ -8487,7 +8487,7 @@
|
|
|
8487
8487
|
{
|
|
8488
8488
|
"path": "release-notes.json",
|
|
8489
8489
|
"mode": "0000644",
|
|
8490
|
-
"sha256": "
|
|
8490
|
+
"sha256": "b4125b6b8f4b390e216f8686281a0a98da09c2a405f662beb8368ef6dee81ce3"
|
|
8491
8491
|
},
|
|
8492
8492
|
{
|
|
8493
8493
|
"path": "scripts/bongos-mcp.js",
|
|
@@ -9477,12 +9477,12 @@
|
|
|
9477
9477
|
{
|
|
9478
9478
|
"path": "scripts/gds/provision-units.js",
|
|
9479
9479
|
"mode": "0000644",
|
|
9480
|
-
"sha256": "
|
|
9480
|
+
"sha256": "b35e21dabea1b2b45242c4744a743926259e593e01fe337d464dc4545588309d"
|
|
9481
9481
|
},
|
|
9482
9482
|
{
|
|
9483
9483
|
"path": "scripts/gds/provision.js",
|
|
9484
9484
|
"mode": "0000644",
|
|
9485
|
-
"sha256": "
|
|
9485
|
+
"sha256": "bb767eea3debd87ad13ea859d85267b5b6be88c2eadf3c3ac236408c58f84c23"
|
|
9486
9486
|
},
|
|
9487
9487
|
{
|
|
9488
9488
|
"path": "scripts/gds/publish-credential-check.js",
|
|
@@ -10562,7 +10562,7 @@
|
|
|
10562
10562
|
{
|
|
10563
10563
|
"path": "src/module-api.js",
|
|
10564
10564
|
"mode": "0000644",
|
|
10565
|
-
"sha256": "
|
|
10565
|
+
"sha256": "b31a0374ebfe190599d53bfd40004328cc7bb1aa3906487c005a53718b990a21"
|
|
10566
10566
|
},
|
|
10567
10567
|
{
|
|
10568
10568
|
"path": "src/module-loader/catalog.js",
|
|
@@ -11527,7 +11527,7 @@
|
|
|
11527
11527
|
{
|
|
11528
11528
|
"path": "tests/core_upgrade_door.mjs",
|
|
11529
11529
|
"mode": "0000644",
|
|
11530
|
-
"sha256": "
|
|
11530
|
+
"sha256": "52b55a04a99870a1501f2a422096337e3426a7624ec6e1728f291e426eb9567b"
|
|
11531
11531
|
},
|
|
11532
11532
|
{
|
|
11533
11533
|
"path": "tests/core_upgrade_owner_door.mjs",
|
|
@@ -13952,7 +13952,7 @@
|
|
|
13952
13952
|
{
|
|
13953
13953
|
"path": "tests/provision_settings_apply.mjs",
|
|
13954
13954
|
"mode": "0000644",
|
|
13955
|
-
"sha256": "
|
|
13955
|
+
"sha256": "edcaec20fe9fe6ae0c6fd542516d10ffb3412ab009f356af3ee52abca939342b"
|
|
13956
13956
|
},
|
|
13957
13957
|
{
|
|
13958
13958
|
"path": "tests/provision_teardown_address.mjs",
|
|
@@ -14072,7 +14072,7 @@
|
|
|
14072
14072
|
{
|
|
14073
14073
|
"path": "tests/provisioning_settings_apply.mjs",
|
|
14074
14074
|
"mode": "0000644",
|
|
14075
|
-
"sha256": "
|
|
14075
|
+
"sha256": "550f67b56dd92d413a1bafc70d76375df9c02d84f9d3e4dfd5789697ae0164f0"
|
|
14076
14076
|
},
|
|
14077
14077
|
{
|
|
14078
14078
|
"path": "tests/provisioning_settings_env.mjs",
|
|
@@ -2593,5 +2593,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2593
2593
|
landed since 1.19.1051 with no explicit bump. run 36372902384. (task 1002620)
|
|
2594
2594
|
1.19.1053 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2595
2595
|
landed since 1.19.1052 with no explicit bump. run 36375859084. (task 1002620)
|
|
2596
|
+
1.19.1054 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2597
|
+
landed since 1.19.1053 with no explicit bump. run 36378326588. (task 1002620)
|
|
2596
2598
|
---------------------------------------------------------------------------
|
|
2597
2599
|
```
|
|
@@ -40,10 +40,23 @@ const { PublicRefusal } = require('./public-refusal');
|
|
|
40
40
|
// the provisioning module — so its hall is the only one with an
|
|
41
41
|
// Upgrade page at all. A preview that cannot run where the button
|
|
42
42
|
// lives is not a preview (task 1004125).
|
|
43
|
+
// settings-apply
|
|
44
|
+
// — push a SAVED setting into the box's own web.env, then restart
|
|
45
|
+
// (task 1004334). It is `restart` plus an in-place edit of the
|
|
46
|
+
// settings lines, and nothing else: the runner leg re-reads the
|
|
47
|
+
// file, replaces only the vocabulary keys (SETTINGS_VOCAB plus its
|
|
48
|
+
// declared companions) and refuses outright if the file cannot be
|
|
49
|
+
// read, so it cannot blank a credential, a port or the database
|
|
50
|
+
// name. It deletes nothing, provisions nothing and moves no pin.
|
|
51
|
+
// Refusing it here protected the box from nothing and left the
|
|
52
|
+
// owner's saved settings on the platform's own row unable to reach
|
|
53
|
+
// the live site — PATCH …/settings on this row stored 'off' while
|
|
54
|
+
// cloudbongos.com kept reading 'strict', and the only remedy was a
|
|
55
|
+
// hand edit on a machine the owner cannot reach.
|
|
43
56
|
//
|
|
44
57
|
// Adding to this list is a deliberate edit someone has to argue for. It is pinned
|
|
45
58
|
// by name in tests/core_upgrade_door.mjs so it cannot grow as a side effect.
|
|
46
|
-
const CONTROL_PLANE_ACTIONS = Object.freeze(['core-upgrade', 'core-upgrade-preflight', 'restart']);
|
|
59
|
+
const CONTROL_PLANE_ACTIONS = Object.freeze(['core-upgrade', 'core-upgrade-preflight', 'restart', 'settings-apply']);
|
|
47
60
|
|
|
48
61
|
/**
|
|
49
62
|
* May this intent action be aimed at an instance of this hosting shape? PURE.
|
|
@@ -104,8 +104,9 @@ const MAX_RELEASED_DOMAINS = 8;
|
|
|
104
104
|
// excludes 'active' (task 1002691).
|
|
105
105
|
const SETTINGS_APPLY_FROM = new Set(['active']);
|
|
106
106
|
// The hosting shapes the runner has a write path to. 'byo-host' is absent (the owner runs
|
|
107
|
-
// it, we reach into nothing)
|
|
108
|
-
|
|
107
|
+
// it, we reach into nothing). 'control-plane' is the runner's OWN box, admitted by name in
|
|
108
|
+
// control-plane-guard.js (task 1004334) so a setting saved on the platform's row reaches it.
|
|
109
|
+
const SETTINGS_APPLY_SHAPES = new Set(['co-tenant', 'cloud-host', 'control-plane']);
|
|
109
110
|
// The ONE state a restart is meaningful from (task 1003238). Liveness is an
|
|
110
111
|
// OVERLAY, not a seventh state — an instance the heartbeat cannot reach is still
|
|
111
112
|
// 'active', and 'active' is exactly where a restart applies. Every earlier state
|
|
@@ -115,8 +116,8 @@ const SETTINGS_APPLY_SHAPES = new Set(['co-tenant', 'cloud-host']);
|
|
|
115
116
|
// SETTINGS_APPLY_FROM: each action declaring its own gate is this module's
|
|
116
117
|
// convention, and the two answer different questions.
|
|
117
118
|
const RESTART_FROM = new Set(['active']);
|
|
118
|
-
// Same reachability rule as the settings push
|
|
119
|
-
// the
|
|
119
|
+
// Same reachability rule as the settings push (the runner can only restart what it runs),
|
|
120
|
+
// minus 'control-plane': task 1004334 gave the self row a settings push, not a restart door.
|
|
120
121
|
const RESTART_SHAPES = new Set(['co-tenant', 'cloud-host']);
|
|
121
122
|
|
|
122
123
|
// Per-project settings (task 1003040, goal 1000058): the known keys + their
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1054",
|
|
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.1054",
|
|
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.1054",
|
|
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/release-notes.json
CHANGED
|
@@ -7619,5 +7619,11 @@
|
|
|
7619
7619
|
"id": "1004328",
|
|
7620
7620
|
"text": "Your thinking and the project map are now two separate pages that share only their drawing. Your thinking keeps just your own views, simple and your thinking, with your own tools: the list, recent, claude layer, my influence,"
|
|
7621
7621
|
}
|
|
7622
|
+
],
|
|
7623
|
+
"1.19.1054": [
|
|
7624
|
+
{
|
|
7625
|
+
"id": "1004334",
|
|
7626
|
+
"text": "The platform can now apply a saved setting to its own site, not just to the projects it hosts. Turning the artist gate off on Cloud Bongos will actually reach cloudbongos.com: the platform updates its own settings file and res"
|
|
7627
|
+
}
|
|
7622
7628
|
]
|
|
7623
7629
|
}
|
|
@@ -304,9 +304,44 @@ function originEnvVarsFor(inst) {
|
|
|
304
304
|
};
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
-
|
|
307
|
+
// `envBody` is the web.env being patched; only the control-plane row reads it (see
|
|
308
|
+
// selfEnvPrefix). Every other shape keeps the provisioned-instance prefix, unchanged.
|
|
309
|
+
function settingsEnvVarsFor(inst, envBody) {
|
|
308
310
|
const { settingsEnvVars } = require(path.join(REPO_ROOT, 'modules/provisioning/provisioning')); // eslint-disable-line global-require
|
|
309
|
-
|
|
311
|
+
const prefix = inst && inst.hosting_shape === 'control-plane' ? selfEnvPrefix({ envBody }) : provisionedEnvPrefix();
|
|
312
|
+
return settingsEnvVars(inst, prefix);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// The env-var prefix the CONTROL PLANE's own web process resolves (task 1004334). The
|
|
316
|
+
// self row is not a provisioned instance: it loads the pack src/branding.js loads, from
|
|
317
|
+
// ITS instance root, so provisionedEnvPrefix() (the neutral pack's floor) is the wrong
|
|
318
|
+
// question — a value written under a prefix the site does not resolve is read by
|
|
319
|
+
// nothing, and the push would report success over a site that never changed.
|
|
320
|
+
//
|
|
321
|
+
// Precedence mirrors loadBranding: the instance pack is a <PREFIX>_BRANDING_FILE line in
|
|
322
|
+
// the web.env being patched (the file the web service actually boots with) when there is
|
|
323
|
+
// one, else <instanceRoot>/config/branding.json; under it sits the core's neutral pack,
|
|
324
|
+
// then the fallback. The first that names an envPrefix wins. A wrong answer is not silent: the
|
|
325
|
+
// leg's read-back (settingsConsumed) compares the live manifest after the restart and
|
|
326
|
+
// records "still reports the OLD values".
|
|
327
|
+
function selfEnvPrefix({ envBody = '', instanceRoot, readFile = (p) => fs.readFileSync(p, 'utf8') } = {}) {
|
|
328
|
+
const { FALLBACK_ENV_PREFIX, LEGACY_ENV_PREFIXES } = require('../../src/instance-config');
|
|
329
|
+
const root = instanceRoot || require('./provision-repo.js').selfInstanceRoot(); // eslint-disable-line global-require
|
|
330
|
+
const names = [FALLBACK_ENV_PREFIX, ...LEGACY_ENV_PREFIXES].map((p) => `${p}_BRANDING_FILE`);
|
|
331
|
+
const override = String(envBody || '').split(/\r?\n/)
|
|
332
|
+
.map((l) => /^([A-Z]+_BRANDING_FILE)=(.*)$/.exec(l.trim()))
|
|
333
|
+
.find((m) => m && names.includes(m[1]) && m[2].trim());
|
|
334
|
+
const candidates = [
|
|
335
|
+
override ? path.resolve(root, override[2].trim()) : path.join(root, 'config', 'branding.json'),
|
|
336
|
+
path.join(REPO_ROOT, ENV_PREFIX_PACK),
|
|
337
|
+
];
|
|
338
|
+
for (const p of candidates) {
|
|
339
|
+
try {
|
|
340
|
+
const pack = JSON.parse(readFile(p));
|
|
341
|
+
if (pack && typeof pack.envPrefix === 'string' && pack.envPrefix) return pack.envPrefix;
|
|
342
|
+
} catch { /* absent or unreadable — the next candidate answers */ }
|
|
343
|
+
}
|
|
344
|
+
return FALLBACK_ENV_PREFIX;
|
|
310
345
|
}
|
|
311
346
|
|
|
312
347
|
// The instance's own public manifest — the settings read-back ADR 0190 names
|
|
@@ -375,6 +410,7 @@ module.exports = {
|
|
|
375
410
|
instanceManifestCmd,
|
|
376
411
|
originEnvVarsFor,
|
|
377
412
|
provisionedEnvPrefix,
|
|
413
|
+
selfEnvPrefix,
|
|
378
414
|
serviceUnit,
|
|
379
415
|
serviceUnitPath,
|
|
380
416
|
settingsConsumed,
|
package/scripts/gds/provision.js
CHANGED
|
@@ -875,12 +875,12 @@ async function domainAttachInstance(inst, deps) {
|
|
|
875
875
|
// ---------------------------------------------------------------------------
|
|
876
876
|
async function settingsApplyInstance(inst, deps) {
|
|
877
877
|
const { db, provisioning, exec, writeFile, log, apply, privileged } = deps;
|
|
878
|
-
//
|
|
879
|
-
const
|
|
880
|
-
const boxExec =
|
|
881
|
-
const boxWriteFile =
|
|
878
|
+
// A standalone runs control-plane-local; the control plane's own row IS this box (task 1004334).
|
|
879
|
+
const onControlBox = inst.hosting_shape === 'cloud-host' || inst.hosting_shape === 'control-plane';
|
|
880
|
+
const boxExec = onControlBox ? (deps.controlExec || exec) : exec;
|
|
881
|
+
const boxWriteFile = onControlBox ? (deps.controlWriteFile || writeFile) : writeFile;
|
|
882
882
|
const sudoP = privileged ? 'sudo ' : '';
|
|
883
|
-
const vars = settingsEnvVarsFor(inst);
|
|
883
|
+
const vars = settingsEnvVarsFor(inst); // the self row's prefix is re-read from the live file at the write
|
|
884
884
|
const shown = Object.entries(vars).map(([k, v]) => `${k}=${v}`).join(' ');
|
|
885
885
|
log(`settings-apply ${inst.slug} (shape=${inst.hosting_shape}, status=${inst.status}): ${shown}`);
|
|
886
886
|
|
|
@@ -917,7 +917,7 @@ async function settingsApplyInstance(inst, deps) {
|
|
|
917
917
|
}
|
|
918
918
|
try {
|
|
919
919
|
log(' [config] patch the settings lines of web.env in place (creds + origins untouched)');
|
|
920
|
-
boxWriteFile(webEnvPath(inst), upsertEnvVars(current,
|
|
920
|
+
boxWriteFile(webEnvPath(inst), upsertEnvVars(current, settingsEnvVarsFor(inst, current)), { mode: 0o600, sudo: true });
|
|
921
921
|
log(' [service] restart so the memoized branding pack re-reads them');
|
|
922
922
|
pmFor(deps).restartService(inst, { exec: boxExec, sudoP });
|
|
923
923
|
} catch (e) {
|
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.1054'; // 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');
|
|
@@ -102,18 +102,32 @@ await t('the allow-list is exactly the acts that cannot destroy the box', () =>
|
|
|
102
102
|
// dry run returns at pre-flight having moved no pin, run no npm and restarted
|
|
103
103
|
// nothing, and the control plane is the only instance whose hall HAS an Upgrade
|
|
104
104
|
// page — so refusing the preview there protected nothing and removed everything.
|
|
105
|
+
// Widened a second time, by 'settings-apply' (task 1004334), for the reason argued in
|
|
106
|
+
// the guard's own comment: it is restart plus an in-place edit of the settings lines,
|
|
107
|
+
// and without it a setting saved on the platform's row could never reach the live site.
|
|
105
108
|
assert.deepEqual([...G.CONTROL_PLANE_ACTIONS].sort(),
|
|
106
|
-
['core-upgrade', 'core-upgrade-preflight', 'restart']);
|
|
109
|
+
['core-upgrade', 'core-upgrade-preflight', 'restart', 'settings-apply']);
|
|
107
110
|
});
|
|
108
111
|
|
|
109
112
|
await t('the preview is permitted on the control plane — the one row that can use it', () => {
|
|
110
113
|
assert.equal(G.intentAllowedForShape('core-upgrade-preflight', 'control-plane'), true);
|
|
111
114
|
});
|
|
112
115
|
|
|
116
|
+
await t('the settings push is permitted on the control plane, and the guard says WHY by name (task 1004334)', () => {
|
|
117
|
+
assert.equal(G.intentAllowedForShape('settings-apply', 'control-plane'), true);
|
|
118
|
+
// The argument is part of the edit: an entry added without its reason is exactly the
|
|
119
|
+
// "grew as a side effect" this pin exists to stop.
|
|
120
|
+
const src = read('modules', 'provisioning', 'control-plane-guard.js');
|
|
121
|
+
const reason = src.slice(src.indexOf('// settings-apply'), src.indexOf('const CONTROL_PLANE_ACTIONS'));
|
|
122
|
+
assert.ok(reason.length > 0 && src.includes('// settings-apply'), 'the guard comment carries a settings-apply entry');
|
|
123
|
+
assert.match(reason, /task 1004334/, 'the reason names the task that argued it');
|
|
124
|
+
assert.match(reason, /deletes nothing/, 'the reason states the property that makes it safe');
|
|
125
|
+
});
|
|
126
|
+
|
|
113
127
|
await t('and widening it did NOT let the destructive acts through', () => {
|
|
114
128
|
// The whole risk of arguing one action onto an allow-list is arguing four.
|
|
115
129
|
for (const a of P.INTENT_ACTIONS) {
|
|
116
|
-
if (['core-upgrade', 'core-upgrade-preflight', 'restart'].includes(a)) continue;
|
|
130
|
+
if (['core-upgrade', 'core-upgrade-preflight', 'restart', 'settings-apply'].includes(a)) continue;
|
|
117
131
|
assert.equal(G.intentAllowedForShape(a, 'control-plane'), false,
|
|
118
132
|
`'${a}' must still be refused against the box the platform runs on`);
|
|
119
133
|
}
|
|
@@ -315,6 +315,90 @@ await ta('the shape refusal: dedicated → ok:false with guidance naming the val
|
|
|
315
315
|
assert.equal(writes.length, 0);
|
|
316
316
|
});
|
|
317
317
|
|
|
318
|
+
console.log('\nthe control plane\'s own row — the platform pushes a setting to itself (task 1004334):');
|
|
319
|
+
|
|
320
|
+
const U = require('../scripts/gds/provision-units.js');
|
|
321
|
+
const { FALLBACK_ENV_PREFIX } = require('../src/instance-config.js');
|
|
322
|
+
|
|
323
|
+
t('selfEnvPrefix: the web.env BRANDING_FILE line wins, then the instance pack, then the neutral pack, then the fallback', () => {
|
|
324
|
+
const packs = (map) => (p) => {
|
|
325
|
+
const hit = Object.keys(map).find((k) => p.replace(/\\/g, '/').endsWith(k));
|
|
326
|
+
if (!hit) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; }
|
|
327
|
+
return JSON.stringify(map[hit]);
|
|
328
|
+
};
|
|
329
|
+
const neutral = { 'config/branding.neutral.json': { envPrefix: 'NEUTRALX' } };
|
|
330
|
+
const withOwn = { ...neutral, 'config/branding.json': { envPrefix: 'OWNPACK' }, 'packs/site.json': { envPrefix: 'OVERRIDE' } };
|
|
331
|
+
const root = '/srv/instance';
|
|
332
|
+
assert.equal(U.selfEnvPrefix({ envBody: 'PORT=1\nBONGOS_BRANDING_FILE=packs/site.json\n', instanceRoot: root, readFile: packs(withOwn) }), 'OVERRIDE');
|
|
333
|
+
assert.equal(U.selfEnvPrefix({ envBody: 'GDS_BRANDING_FILE=packs/site.json\r\n', instanceRoot: root, readFile: packs(withOwn) }), 'OVERRIDE', 'a legacy spelling still names the pack, as loadBranding reads it');
|
|
334
|
+
assert.equal(U.selfEnvPrefix({ envBody: 'PORT=1\n', instanceRoot: root, readFile: packs(withOwn) }), 'OWNPACK', 'no override → the instance root\'s config/branding.json');
|
|
335
|
+
assert.equal(U.selfEnvPrefix({ envBody: 'FOO_BRANDING_FILE=packs/site.json\n', instanceRoot: root, readFile: packs(withOwn) }), 'OWNPACK', 'an unknown prefix is not the branding knob');
|
|
336
|
+
assert.equal(U.selfEnvPrefix({ envBody: '# BONGOS_BRANDING_FILE=packs/site.json\n', instanceRoot: root, readFile: packs(withOwn) }), 'OWNPACK', 'a commented line is not set');
|
|
337
|
+
// loadBranding's own rule: an override that names a missing file loads NO instance pack,
|
|
338
|
+
// so the neutral one answers — config/branding.json is not consulted as a second chance.
|
|
339
|
+
assert.equal(U.selfEnvPrefix({ envBody: 'BONGOS_BRANDING_FILE=packs/gone.json\n', instanceRoot: root, readFile: packs(withOwn) }), 'NEUTRALX');
|
|
340
|
+
assert.equal(U.selfEnvPrefix({ envBody: '', instanceRoot: root, readFile: packs({ ...neutral, 'config/branding.json': { name: 'no prefix here' } }) }), 'NEUTRALX', 'a pack that names no envPrefix inherits the neutral one');
|
|
341
|
+
assert.equal(U.selfEnvPrefix({ envBody: '', instanceRoot: root, readFile: packs({}) }), FALLBACK_ENV_PREFIX, 'nothing readable → the fallback');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
t('settingsEnvVarsFor: only the control-plane row reads the self prefix — every other shape is byte-identical to before', () => {
|
|
345
|
+
const body = 'BONGOS_BRANDING_FILE=/nowhere/at/all.json\n';
|
|
346
|
+
for (const shape of ['cloud-host', 'co-tenant']) {
|
|
347
|
+
assert.deepEqual(P.settingsEnvVarsFor({ ...activeStandalone, hosting_shape: shape }, body), P.settingsEnvVarsFor({ ...activeStandalone, hosting_shape: shape }), shape);
|
|
348
|
+
assert.ok(Object.keys(P.settingsEnvVarsFor({ ...activeStandalone, hosting_shape: shape }, body)).every((k) => k.startsWith(`${NEUTRAL_PREFIX}_`)), shape);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
const selfRow = {
|
|
353
|
+
id: 41, slug: 'cloudbongos', hosting_shape: 'control-plane', status: 'active', owner_builder_id: 3,
|
|
354
|
+
port: 3002, domain: 'cloudbongos.com', settings: { artist_gate: 'off' },
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
await ta('--apply, the control-plane row: its OWN /etc/<slug>/web.env on the control-plane pair, under ITS pack\'s prefix, then restart <slug>; nothing else moves', async () => {
|
|
358
|
+
const fs = await import('node:fs');
|
|
359
|
+
const os = await import('node:os');
|
|
360
|
+
const path = await import('node:path');
|
|
361
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cp-pack-'));
|
|
362
|
+
const pack = path.join(dir, 'site.json');
|
|
363
|
+
fs.writeFileSync(pack, JSON.stringify({ envPrefix: 'CPSELF' }));
|
|
364
|
+
const selfEnv = `CPSELF_GITHUB_CLIENT_SECRET=keepme0123456789\nBONGOS_BRANDING_FILE=${pack}\nPORT=3002\nCPSELF_ARTIST_GATE=strict\nGITHUB_PUSH_TOKEN=keepme-too\n`;
|
|
365
|
+
const seen = { box: [], control: [] };
|
|
366
|
+
const mk = (bucket) => (cmd) => {
|
|
367
|
+
seen[bucket].push(cmd);
|
|
368
|
+
if (/\bcat /.test(cmd)) return { ok: true, stdout: selfEnv };
|
|
369
|
+
if (/api\/bongos\/instance/.test(cmd)) return { ok: true, stdout: manifestWith({ platformVisibility: 'public', joinability: 'apply', visibility: 'public', joinGrant: 'full', artistGate: 'off', spendPayer: 'none' }) };
|
|
370
|
+
return { ok: true, stdout: '{"ok":true}' };
|
|
371
|
+
};
|
|
372
|
+
try {
|
|
373
|
+
const { deps, writes, events } = recorder({ apply: true });
|
|
374
|
+
deps.exec = mk('box'); deps.controlExec = mk('control');
|
|
375
|
+
const r = await P.settingsApplyInstance(selfRow, deps);
|
|
376
|
+
assert.equal(r.ok, true, JSON.stringify(r));
|
|
377
|
+
assert.equal(r.consumed, true, 'the read-back saw the live site report the new value');
|
|
378
|
+
assert.equal(seen.box.length, 0, 'nothing runs on the co-hosting box executor — the self row IS the control plane');
|
|
379
|
+
assert.ok(seen.control.some((c) => /^cat \/etc\/cloudbongos\/web\.env$/.test(c)), seen.control.join(' | '));
|
|
380
|
+
assert.ok(seen.control.some((c) => /^systemctl restart cloudbongos$/.test(c)), 'the service the core-upgrade door already restarts');
|
|
381
|
+
assert.deepEqual(writes.map((w) => [w.via, w.path]), [['control', '/etc/cloudbongos/web.env']]);
|
|
382
|
+
const out = writes[0].body;
|
|
383
|
+
assert.match(out, /^CPSELF_ARTIST_GATE=off$/m, 'replaced IN PLACE under the prefix the site resolves');
|
|
384
|
+
assert.doesNotMatch(out, /^BONGOS_ARTIST_GATE=/m, 'not written under the provisioned floor, which this site would never read');
|
|
385
|
+
assert.match(out, /^CPSELF_GITHUB_CLIENT_SECRET=keepme0123456789$/m, 'creds untouched');
|
|
386
|
+
assert.match(out, /^GITHUB_PUSH_TOKEN=keepme-too$/m);
|
|
387
|
+
assert.match(out, /^PORT=3002$/m);
|
|
388
|
+
assert.ok(![...seen.control].some((c) => /systemctl (stop|disable)|caddy|box-dns|rm -|upgrade\.js|npm/.test(c)), 'settings only — no teardown, DNS, Caddy or pin legs');
|
|
389
|
+
assert.match(events[0].detail, /reads the new values/);
|
|
390
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
await ta('the control-plane row keeps every refusal of the leg: an unreadable web.env writes nothing and restarts nothing', async () => {
|
|
394
|
+
const { deps, cmds, writes } = recorder({ apply: true, readFails: true });
|
|
395
|
+
const r = await P.settingsApplyInstance(selfRow, deps);
|
|
396
|
+
assert.equal(r.ok, false);
|
|
397
|
+
assert.match(r.error, /cannot read \/etc\/cloudbongos\/web\.env/);
|
|
398
|
+
assert.equal(writes.length, 0);
|
|
399
|
+
assert.ok(!cmds.some((c) => /restart/.test(c)), 'the platform is never restarted over a file it could not read');
|
|
400
|
+
});
|
|
401
|
+
|
|
318
402
|
console.log('\nthe verdict — recorded only when earned:');
|
|
319
403
|
|
|
320
404
|
await ta('--apply success, manifest matches: consumed=true, event says it reads the new values, F19 latch released, domain-bearing PASS records no liveness verdict', async () => {
|
|
@@ -72,19 +72,30 @@ t('SETTINGS_APPLY_FROM is exactly {active} — the provision intent cannot stand
|
|
|
72
72
|
assert.ok(!provisioning.PROVISION_ENQUEUE_FROM.has('active'), 'the premise the gate rests on');
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
-
t('SETTINGS_APPLY_SHAPES is the reachable
|
|
76
|
-
|
|
75
|
+
t('SETTINGS_APPLY_SHAPES is the reachable three, and the set is a subset of HOSTING_SHAPES', () => {
|
|
76
|
+
// 'control-plane' joined in task 1004334: the runner's OWN box, admitted by name in
|
|
77
|
+
// control-plane-guard.js, so a setting saved on the platform's row reaches the live site.
|
|
78
|
+
assert.deepEqual([...provisioning.SETTINGS_APPLY_SHAPES].sort(), ['cloud-host', 'co-tenant', 'control-plane']);
|
|
77
79
|
for (const s of provisioning.SETTINGS_APPLY_SHAPES) assert.ok(provisioning.HOSTING_SHAPES.includes(s), s);
|
|
78
80
|
// The exclusion this used to assert was 'dedicated', which task 1004182 removed from
|
|
79
81
|
// HOSTING_SHAPES entirely — so that assertion became vacuously true and stopped testing
|
|
80
|
-
// anything. Name the
|
|
81
|
-
// of ours runs a byo-host
|
|
82
|
-
for (const unreachable of ['byo-host'
|
|
82
|
+
// anything. Name the shape that DOES exist and is still deliberately unreachable:
|
|
83
|
+
// nothing of ours runs a byo-host.
|
|
84
|
+
for (const unreachable of ['byo-host']) {
|
|
83
85
|
assert.ok(provisioning.HOSTING_SHAPES.includes(unreachable), `${unreachable} must be a real shape or this assertion is vacuous`);
|
|
84
86
|
assert.ok(!provisioning.SETTINGS_APPLY_SHAPES.has(unreachable), unreachable);
|
|
85
87
|
}
|
|
86
88
|
});
|
|
87
89
|
|
|
90
|
+
t('the two gates agree: every settings-apply shape is one the self-row guard lets a settings-apply through', () => {
|
|
91
|
+
// A shape on SETTINGS_APPLY_SHAPES that the guard refuses would make the route say
|
|
92
|
+
// "a restart is coming" and then answer the owner with control_plane_protected.
|
|
93
|
+
const G = require('../modules/provisioning/control-plane-guard.js');
|
|
94
|
+
for (const s of provisioning.SETTINGS_APPLY_SHAPES) assert.equal(G.intentAllowedForShape('settings-apply', s), true, s);
|
|
95
|
+
// and the restart door was NOT opened on the self row as a side effect
|
|
96
|
+
assert.ok(!provisioning.RESTART_SHAPES.has('control-plane'), 'task 1004334 widened the settings push only');
|
|
97
|
+
});
|
|
98
|
+
|
|
88
99
|
await ta('enqueueSettingsApply: a fresh intent → created:true; the INSERT carries the settings-apply action', async () => {
|
|
89
100
|
const calls = [];
|
|
90
101
|
const db = { query: async (sql, params) => { calls.push({ sql, params }); return { rows: [{ id: 1, action: params[1], state: 'pending' }] }; } };
|
|
@@ -104,6 +115,26 @@ await ta('enqueueSettingsApply: a fresh intent → created:true; the INSERT carr
|
|
|
104
115
|
assert.deepEqual(insert.params, [18, 'settings-apply', 'api:self', null]);
|
|
105
116
|
});
|
|
106
117
|
|
|
118
|
+
await ta('enqueueSettingsApply on the CONTROL-PLANE row passes the self-row guard and INSERTs; a teardown on the same row is still refused (task 1004334)', async () => {
|
|
119
|
+
const selfRow = (calls) => ({ query: async (sql, params) => {
|
|
120
|
+
calls.push({ sql, params });
|
|
121
|
+
if (/SELECT hosting_shape FROM provisioning_instances/.test(sql)) return { rows: [{ hosting_shape: 'control-plane' }] };
|
|
122
|
+
return { rows: [{ id: 3, action: params[1], state: 'pending' }] };
|
|
123
|
+
} });
|
|
124
|
+
const calls = [];
|
|
125
|
+
const r = await provisioning.enqueueSettingsApply(selfRow(calls), 41, 'api:self');
|
|
126
|
+
assert.equal(r.created, true, 'the real guard (not a stub) admits the push onto the self row');
|
|
127
|
+
const insert = calls.find((c) => /INSERT INTO provisioning_intents/.test(c.sql));
|
|
128
|
+
assert.deepEqual(insert.params, [41, 'settings-apply', 'api:self', null]);
|
|
129
|
+
// Everything the guard existed to refuse is still refused through the same door.
|
|
130
|
+
for (const destructive of ['teardown', 'disconnect', 'provision', 'domain-attach', 'repo-private']) {
|
|
131
|
+
const seen = [];
|
|
132
|
+
await assert.rejects(provisioning.enqueueIntent(selfRow(seen), 41, destructive, 'api:self'),
|
|
133
|
+
(e) => e.code === 'control_plane_protected' && e.status === 409, destructive);
|
|
134
|
+
assert.ok(!seen.some((c) => /INSERT/.test(c.sql)), `${destructive}: nothing written`);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
107
138
|
await ta('enqueueSettingsApply: an identical PENDING push already open → created:false, no conflict (it carries this change)', async () => {
|
|
108
139
|
const db = { query: async (sql) => {
|
|
109
140
|
if (/INSERT/.test(sql)) { const e = new Error('dup'); e.code = '23505'; throw e; }
|
|
@@ -260,6 +291,20 @@ await ta('ACTIVE co-tenant queues too; the Archon override names WHICH archon on
|
|
|
260
291
|
assert.equal(events[0].actor, 'api:archon:999');
|
|
261
292
|
});
|
|
262
293
|
|
|
294
|
+
await ta('ACTIVE control-plane row (the platform itself): artist_gate off QUEUES a push and says a restart is coming (task 1004334)', async () => {
|
|
295
|
+
// The case TW01 hit: this save used to answer "runs on a machine the platform doesn't
|
|
296
|
+
// manage", stored 'off', and left the live site reading 'strict' with no way to apply it.
|
|
297
|
+
reset({ inst: { owner_builder_id: 7, slug: 'cloudbongos', status: 'active', hosting_shape: 'control-plane', domain: 'cloudbongos.com', port: 3002, settings: {} } });
|
|
298
|
+
const r = await patch('/provisioning/instances/41/settings', { artist_gate: 'off' });
|
|
299
|
+
assert.equal(r.status, 200, r.raw);
|
|
300
|
+
assert.equal(r.body.saved.artist_gate, 'off');
|
|
301
|
+
assert.deepEqual(updateCalls.map((c) => c.patch), [{ artist_gate: 'off' }]);
|
|
302
|
+
assert.equal(updateCalls[0].enqueuedSoFar, 0, 'the row is written before the intent the runner reads it through');
|
|
303
|
+
assert.deepEqual(enqueueCalls, [{ id: 41, requestedBy: 'api:self' }]);
|
|
304
|
+
receiptShape(r.body.push, { queued: true, restart: true, reachable: true });
|
|
305
|
+
assert.doesNotMatch(r.body.push.message, /doesn.t manage/, 'no longer the unreachable-machine sentence');
|
|
306
|
+
});
|
|
307
|
+
|
|
263
308
|
await ta('an identical PENDING push already open → 200, queued:false, "already queued … will carry this change"', async () => {
|
|
264
309
|
reset();
|
|
265
310
|
enqueueResult = { intent: { id: 5, action: 'settings-apply', state: 'pending' }, created: false };
|