@bongos/core 1.19.998 → 1.19.1000
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 +105 -90
- package/.claude/skills/collab-review/SKILL.md +24 -13
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0330-an-ask-is-answered-where-it-was-asked.md +155 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +154 -4
- package/docs/api-reference.md +5 -3
- package/docs/copy-inventory.md +18 -13
- package/docs/copy-registry.json +70 -15
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/collab-lib.js +79 -0
- package/modules/hall-ui/public/collab.css +60 -0
- package/modules/hall-ui/public/collab.js +134 -5
- package/modules/lifecycle/help-requests.js +118 -8
- package/modules/lifecycle/migrations/lifecycle_011_help_request_replies.sql +113 -0
- package/modules/lifecycle/routes/help-requests.js +93 -13
- package/modules/provisioning/capacity.js +3 -3
- package/modules/provisioning/control-plane-guard.js +1 -1
- package/modules/provisioning/core-upgrade.js +7 -7
- package/modules/provisioning/migrations/provisioning_027_cloud_host_shape_rename.sql +58 -0
- package/modules/provisioning/onboard-plan.js +1 -1
- package/modules/provisioning/paid-shape-gate.js +28 -15
- package/modules/provisioning/provisioning.js +18 -14
- package/modules/provisioning/routes/repo-private.js +1 -1
- package/modules/provisioning/tests/liveness.mjs +16 -8
- package/modules/provisioning/tests/provisioning.mjs +1 -1
- package/modules/public-landing/public/projects.html +26 -27
- package/modules/ui-design/kit/fixtures/provisioning-instances.json +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/init.js +12 -4
- package/scripts/gds/onboard.js +1 -1
- package/scripts/gds/provision-capacity.js +6 -1
- package/scripts/gds/provision-config.js +6 -3
- package/scripts/gds/provision-core-upgrade.js +8 -8
- package/scripts/gds/provision-disconnect.js +2 -2
- package/scripts/gds/provision-process.js +2 -2
- package/scripts/gds/provision-repo-private.js +1 -1
- package/scripts/gds/provision-repo.js +13 -3
- package/scripts/gds/provision-teardown.js +2 -2
- package/scripts/gds/provision-units.js +2 -2
- package/scripts/gds/provision.js +6 -6
- package/src/module-api.js +1 -1
- package/tests/collab_page.mjs +140 -7
- package/tests/core_upgrade_door.mjs +1 -1
- package/tests/core_upgrade_runner.mjs +20 -20
- package/tests/deploy_page_projects.mjs +5 -5
- package/tests/hall_collab_world.mjs +6 -2
- package/tests/help_requests.mjs +201 -6
- package/tests/hub_disconnect_ui.mjs +4 -1
- package/tests/init.mjs +6 -6
- package/tests/onboard.mjs +5 -4
- package/tests/project_catalog_backfill.mjs +1 -1
- package/tests/project_catalog_bridge.mjs +2 -2
- package/tests/project_catalog_routes.mjs +4 -4
- package/tests/projects_hub.mjs +1 -1
- package/tests/projects_hub_pre_uat.mjs +22 -5
- package/tests/projects_hub_repo_private.mjs +1 -1
- package/tests/provision.mjs +32 -32
- package/tests/provision_byo_host.mjs +1 -1
- package/tests/provision_caddy_wiring.mjs +1 -1
- package/tests/provision_disconnect.mjs +3 -3
- package/tests/provision_repo_private.mjs +1 -1
- package/tests/provision_restart.mjs +5 -5
- package/tests/provision_settings_apply.mjs +4 -4
- package/tests/provision_teardown_address.mjs +2 -2
- package/tests/provisioning_capacity.mjs +40 -34
- package/tests/provisioning_capacity_gate.mjs +5 -12
- package/tests/provisioning_default_domain.mjs +1 -1
- package/tests/provisioning_domain_attach.mjs +4 -4
- package/tests/provisioning_domain_unique.mjs +2 -2
- package/tests/provisioning_error_note_scope.mjs +1 -1
- package/tests/provisioning_module_selection.mjs +5 -5
- package/tests/provisioning_onboard_mode.mjs +2 -2
- package/tests/provisioning_paid_shape_gate.mjs +57 -44
- package/tests/provisioning_repo_private_route.mjs +1 -1
- package/tests/provisioning_restart.mjs +11 -4
- package/tests/provisioning_settings_apply.mjs +12 -5
- package/tests/provisioning_settings_env.mjs +1 -1
- package/tests/provisioning_teardown_intent.mjs +3 -3
- package/tests/public_landing_projects.mjs +13 -6
- package/tests/upgrade.mjs +2 -2
|
@@ -39,7 +39,7 @@ const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
|
|
|
39
39
|
// for one would be a request that is accepted and never lands. The route refuses the
|
|
40
40
|
// same shape; this is the second gate, and it is the one that holds if a row's shape
|
|
41
41
|
// changes between the request and the drain.
|
|
42
|
-
const UPGRADE_SHAPES = Object.freeze(new Set(['co-tenant', '
|
|
42
|
+
const UPGRADE_SHAPES = Object.freeze(new Set(['co-tenant', 'cloud-host', 'control-plane']));
|
|
43
43
|
|
|
44
44
|
// The strict three-number form a core version takes. Re-stated here rather than imported
|
|
45
45
|
// from the web tier's core-upgrade.js: a module may not be required from scripts/ (ADR
|
|
@@ -152,7 +152,7 @@ async function coreUpgradeInstance(inst, deps, intent) {
|
|
|
152
152
|
const { db, provisioning, exec, log, apply, privileged } = deps;
|
|
153
153
|
// A standalone's box IS the control plane's box, the same routing restartInstance and
|
|
154
154
|
// settingsApplyInstance use; a co-tenant shares this checkout.
|
|
155
|
-
const boxExec = inst.hosting_shape === '
|
|
155
|
+
const boxExec = inst.hosting_shape === 'cloud-host' ? (deps.controlExec || exec) : exec;
|
|
156
156
|
const to = intent && intent.target_version;
|
|
157
157
|
log(`core-upgrade ${inst.slug} → ${to || '(no target)'} (shape=${inst.hosting_shape}, status=${inst.status})`);
|
|
158
158
|
|
|
@@ -190,7 +190,7 @@ async function coreUpgradeInstance(inst, deps, intent) {
|
|
|
190
190
|
// ---- 2. is the target real, and permitted -----------------------------------------
|
|
191
191
|
const channelMod = deps.updateChannel || require('./update-channel.js');
|
|
192
192
|
const selfRoot = deps.selfInstanceRoot || selfInstanceRoot;
|
|
193
|
-
const instanceDir = inst.hosting_shape === '
|
|
193
|
+
const instanceDir = inst.hosting_shape === 'cloud-host' ? standaloneRoot(inst) : selfRoot();
|
|
194
194
|
const readInstalled = deps.readInstalledCoreVersion || require('./upgrade.js').readInstalledCoreVersion;
|
|
195
195
|
const installed = readInstalled(instanceDir);
|
|
196
196
|
// THE PLATFORM DEFAULT, NOT A PER-INSTANCE CHANNEL — and saying so beats implying
|
|
@@ -325,7 +325,7 @@ function resolveRunAs(inst, deps, { probe: allowProbe = true } = {}) {
|
|
|
325
325
|
// probe on `exec` would then ask the co-hosting box whether an account exists while
|
|
326
326
|
// the command it is deciding for runs on the control plane — a wrong answer that
|
|
327
327
|
// looks like a right one.
|
|
328
|
-
const boxExec = inst.hosting_shape === '
|
|
328
|
+
const boxExec = inst.hosting_shape === 'cloud-host' ? (deps.controlExec || deps.exec) : deps.exec;
|
|
329
329
|
const r = boxExec(`id -u ${wanted}`, { allowFail: true });
|
|
330
330
|
const failed = !r || r.softFailed === true || r.ok === false;
|
|
331
331
|
if (!failed) return { runAs: wanted, fellBack: false };
|
|
@@ -371,7 +371,7 @@ function upgradeInvocation(inst, to, { privileged = false, dryRun = false, reque
|
|
|
371
371
|
// just to node_modules/@bongos/core — and the door aimed at the package instead of
|
|
372
372
|
// the project. So ask, rather than assume; in the legacy shape the answer is
|
|
373
373
|
// REPO_ROOT and the command below is byte-identical to what it always was.
|
|
374
|
-
const instanceDir = inst.hosting_shape === '
|
|
374
|
+
const instanceDir = inst.hosting_shape === 'cloud-host' ? standaloneRoot(inst) : selfRoot();
|
|
375
375
|
// WHICH ACCOUNT, AND WHY IT IS RESOLVED RATHER THAN DERIVED (task 1004128).
|
|
376
376
|
//
|
|
377
377
|
// Task 1004119 wrote this as `control-plane ? APP_USER : instanceUser(inst)` and was
|
|
@@ -409,7 +409,7 @@ function upgradeInvocation(inst, to, { privileged = false, dryRun = false, reque
|
|
|
409
409
|
// is the interesting answer, not an exception: pre-flight refusing is what this is for.
|
|
410
410
|
function preflightUpgrade(inst, to, deps) {
|
|
411
411
|
const { exec, privileged } = deps;
|
|
412
|
-
const boxExec = inst.hosting_shape === '
|
|
412
|
+
const boxExec = inst.hosting_shape === 'cloud-host' ? (deps.controlExec || exec) : exec;
|
|
413
413
|
// A distinct local name: `deps.selfInstanceRoot || selfInstanceRoot` with a const of
|
|
414
414
|
// the same name would TDZ-shadow the import and crash every uninjected call.
|
|
415
415
|
const selfRoot = deps.selfInstanceRoot || selfInstanceRoot;
|
|
@@ -718,10 +718,10 @@ async function preflightCoreUpgrade(inst, deps, intent) {
|
|
|
718
718
|
async function snapshotCoreVersions(inst, deps) {
|
|
719
719
|
const { db, exec, log } = deps;
|
|
720
720
|
if (!UPGRADE_SHAPES.has(inst.hosting_shape) || inst.status !== 'active') return { ok: true, skipped: true };
|
|
721
|
-
const boxExec = inst.hosting_shape === '
|
|
721
|
+
const boxExec = inst.hosting_shape === 'cloud-host' ? (deps.controlExec || exec) : exec;
|
|
722
722
|
const channelMod = deps.updateChannel || require('./update-channel.js');
|
|
723
723
|
const selfRoot = deps.selfInstanceRoot || selfInstanceRoot;
|
|
724
|
-
const instanceDir = inst.hosting_shape === '
|
|
724
|
+
const instanceDir = inst.hosting_shape === 'cloud-host' ? standaloneRoot(inst) : selfRoot();
|
|
725
725
|
try {
|
|
726
726
|
const probe = boxExec(versionCmd(inst), { allowFail: true });
|
|
727
727
|
const body = probe && !probe.softFailed ? (probe.stdout || '') : null;
|
|
@@ -49,7 +49,7 @@ function eraseCommands(inst, { privileged = false } = {}) {
|
|
|
49
49
|
`${sudo}find ${CONFIG.backupDir} -maxdepth 1 -type f -name '${dumpGlob(inst)}' -delete`,
|
|
50
50
|
`${sudo}rm -rf ${path.posix.dirname(webEnvPath(inst))} ${instanceStateDir(inst)}`,
|
|
51
51
|
];
|
|
52
|
-
if (inst.hosting_shape === '
|
|
52
|
+
if (inst.hosting_shape === 'cloud-host') cmds.push(`${sudo}rm -rf ${standaloneRoot(inst)}`);
|
|
53
53
|
cmds.push(`if id -u ${user} >/dev/null 2>&1; then ${sudo}userdel ${user}; fi`);
|
|
54
54
|
return cmds;
|
|
55
55
|
}
|
|
@@ -63,7 +63,7 @@ async function disconnectInstance(inst, deps) {
|
|
|
63
63
|
await teardownInstance(inst, deps);
|
|
64
64
|
|
|
65
65
|
if (inst.hosting_shape !== 'dedicated') {
|
|
66
|
-
const boxExec = inst.hosting_shape === '
|
|
66
|
+
const boxExec = inst.hosting_shape === 'cloud-host' ? (deps.controlExec || exec) : exec;
|
|
67
67
|
for (const cmd of eraseCommands(inst, { privileged })) {
|
|
68
68
|
try { boxExec(cmd); }
|
|
69
69
|
catch (e) { return { ok: false, error: `erase step failed (${cmd.split(' ').slice(0, 3).join(' ')}): ${(e && e.message) || e}` }; }
|
|
@@ -75,13 +75,13 @@ function supervisorConfPath(inst) {
|
|
|
75
75
|
// cannot drift: a standalone runs out of its own pinned core, anything else out of this
|
|
76
76
|
// checkout.
|
|
77
77
|
function programCommand(inst) {
|
|
78
|
-
return inst.hosting_shape === '
|
|
78
|
+
return inst.hosting_shape === 'cloud-host'
|
|
79
79
|
? `/usr/bin/node ${CORE_PKG_DIR}/src/platform-server.js`
|
|
80
80
|
: '/usr/bin/node src/platform-server.js';
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
function supervisorProgram(inst, { etcBase = CONFIG.etcBase } = {}) {
|
|
84
|
-
const workDir = inst.hosting_shape === '
|
|
84
|
+
const workDir = inst.hosting_shape === 'cloud-host' ? standaloneRoot(inst) : REPO_ROOT;
|
|
85
85
|
return `[program:${inst.slug}]
|
|
86
86
|
; Cloud Bongos instance — ${inst.slug} (provisioned by provision.js, ADR 0306)
|
|
87
87
|
command=${programCommand(inst)}
|
|
@@ -40,7 +40,7 @@ async function repoPrivateInstance(inst, deps) {
|
|
|
40
40
|
|
|
41
41
|
// Only a standalone project has its own checkout to wire; any other shape reads from a
|
|
42
42
|
// checkout this project does not own, so a key minted here would change nothing.
|
|
43
|
-
if (inst.hosting_shape !== '
|
|
43
|
+
if (inst.hosting_shape !== 'cloud-host') {
|
|
44
44
|
return { ok: false, error: 'This project shares its code checkout with others, so its repository cannot be made private from here.' };
|
|
45
45
|
}
|
|
46
46
|
if (inst.status !== 'active') {
|
|
@@ -79,8 +79,18 @@ function seedFirstVersionCmd(inst, { privileged = false } = {}) {
|
|
|
79
79
|
return asPostgres(`psql -v ON_ERROR_STOP=1 -d ${dbName(inst)} -c '${sql}'`, privileged);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
// ──
|
|
83
|
-
//
|
|
82
|
+
// ── The CLOUD-HOST shape (hosting_shape='cloud-host', ADR 0108, task 1978) ────
|
|
83
|
+
//
|
|
84
|
+
// TWO SENSES OF "STANDALONE", AND THIS FILE USES BOTH. The ADR 0108 sense is a repo
|
|
85
|
+
// TOPOLOGY: an instance running from its own checkout with the core as an npm dependency,
|
|
86
|
+
// as opposed to co-tenant's shared control-plane checkout. That is what `standaloneRoot`,
|
|
87
|
+
// `standaloneBase` and `PROVISION_STANDALONE_BASE` name, and those keep their spelling —
|
|
88
|
+
// `PROVISION_STANDALONE_BASE` in particular is a live environment variable on the control
|
|
89
|
+
// plane and is documented in the /new-project skill. The other sense was the HOSTING SHAPE
|
|
90
|
+
// once stored as 'standalone'; task 1004182 renamed that value to 'cloud-host'. Only the
|
|
91
|
+
// stored value moved. Do not "finish the rename" by touching the topology names.
|
|
92
|
+
//
|
|
93
|
+
// A cloud-host instance runs from its OWN repo checkout (host content: config/,
|
|
84
94
|
// migrations/instance/, the core PIN in package.json) with @bongos/core installed
|
|
85
95
|
// as an npm DEPENDENCY — NOT the shared control-plane checkout the co-tenant shape uses.
|
|
86
96
|
// It is the ADR 0108 DESTINATION model; these generators make provision.js drive the
|
|
@@ -883,7 +893,7 @@ function deployKeyPath(inst) { return path.posix.join(standaloneRoot(inst), '.gi
|
|
|
883
893
|
async function ensurePrivateRepoAccess(inst, deps) {
|
|
884
894
|
const { log, apply } = deps;
|
|
885
895
|
const exec = deps.controlExec || deps.exec;
|
|
886
|
-
if (inst.hosting_shape !== '
|
|
896
|
+
if (inst.hosting_shape !== 'cloud-host') return { skipped: 'shape' };
|
|
887
897
|
const repo = parseTargetRef(inst.target_ref);
|
|
888
898
|
if (!repo) return { skipped: 'no_repo' };
|
|
889
899
|
// Dry-run stays OFFLINE like every other plan path here (the checkDnsTokenScope
|
|
@@ -24,7 +24,7 @@ async function teardownInstance(inst, deps) {
|
|
|
24
24
|
// local (task 2074), so disable/remove its unit HERE — not on the remote co-hosting
|
|
25
25
|
// box, where it was never installed (that would allowFail-noop and leave the real
|
|
26
26
|
// control-plane unit running). Co-tenant + dedicated are unchanged.
|
|
27
|
-
const boxExec = inst.hosting_shape === '
|
|
27
|
+
const boxExec = inst.hosting_shape === 'cloud-host' ? controlExec : exec;
|
|
28
28
|
const sudoP = privileged ? 'sudo ' : ''; // systemctl / rm-under-/etc escalation (task 2066)
|
|
29
29
|
log(`teardown ${inst.slug} (status=${inst.status})`);
|
|
30
30
|
if (apply) await provisioning.setInstanceStatus(db, inst.id, 'tearing_down');
|
|
@@ -47,7 +47,7 @@ async function teardownInstance(inst, deps) {
|
|
|
47
47
|
// files must NOT ride along — and a revoke skip must be LOUD: a torn-down project
|
|
48
48
|
// silently keeping durable read access to the owner's private repo is the exact
|
|
49
49
|
// shape a teardown exists to end. A missing token / API hiccup never blocks it.
|
|
50
|
-
if (inst.hosting_shape === '
|
|
50
|
+
if (inst.hosting_shape === 'cloud-host' && inst.target_ref) {
|
|
51
51
|
const repo = parseTargetRef(inst.target_ref);
|
|
52
52
|
if (repo) {
|
|
53
53
|
try {
|
|
@@ -16,7 +16,7 @@ const { CORE_PKG_DIR, appUser, dbName, instanceDbRole, instanceStateDir, instanc
|
|
|
16
16
|
function serviceUnit(inst, { etcBase = CONFIG.etcBase } = {}) {
|
|
17
17
|
const envFile = path.posix.join(etcBase, inst.slug, 'web.env');
|
|
18
18
|
const user = instanceUser(inst), stateDir = instanceStateDir(inst);
|
|
19
|
-
const standalone = inst.hosting_shape === '
|
|
19
|
+
const standalone = inst.hosting_shape === 'cloud-host';
|
|
20
20
|
const workDir = standalone ? standaloneRoot(inst) : REPO_ROOT;
|
|
21
21
|
const execStart = standalone
|
|
22
22
|
? `/usr/bin/node ${CORE_PKG_DIR}/src/platform-server.js`
|
|
@@ -91,7 +91,7 @@ function backupTimerPath(inst) { return `/etc/systemd/system/${backupUnitName(in
|
|
|
91
91
|
// scripts/gds/ — mirrors serviceUnit's execStart resolution so they can't diverge.
|
|
92
92
|
function backupScriptPath(inst) {
|
|
93
93
|
const rel = 'scripts/gds/db-backup-nightly.sh';
|
|
94
|
-
return inst.hosting_shape === '
|
|
94
|
+
return inst.hosting_shape === 'cloud-host'
|
|
95
95
|
? path.posix.join(standaloneRoot(inst), CORE_PKG_DIR, rel)
|
|
96
96
|
: path.posix.join(REPO_ROOT, rel);
|
|
97
97
|
}
|
package/scripts/gds/provision.js
CHANGED
|
@@ -238,7 +238,7 @@ async function provisionInstance(inst, deps) {
|
|
|
238
238
|
// So a standalone uses the control-plane executors; co-tenant + dedicated are
|
|
239
239
|
// unchanged (boxExec === exec, boxWriteFile === writeFile). When no remoteHost is
|
|
240
240
|
// set, controlExec === exec anyway, so this is a no-op for the all-local default.
|
|
241
|
-
const standalone = inst.hosting_shape === '
|
|
241
|
+
const standalone = inst.hosting_shape === 'cloud-host';
|
|
242
242
|
const boxExec = standalone ? controlExec : exec;
|
|
243
243
|
const boxWriteFile = standalone ? (deps.controlWriteFile || writeFile) : writeFile;
|
|
244
244
|
const sudoP = privileged ? 'sudo ' : ''; // systemctl escalation prefix (task 2066)
|
|
@@ -372,7 +372,7 @@ async function provisionInstance(inst, deps) {
|
|
|
372
372
|
// materialize config/ + .claude/ + file:-pinned core + lockfile → commit + push
|
|
373
373
|
// (ADR 0108 / 0125, task 2055). Replaces the old "repo must already be cloned"
|
|
374
374
|
// throw: the greenfield flow now has no manual clone step. Idempotent + dry-run-safe.
|
|
375
|
-
if (inst.hosting_shape === '
|
|
375
|
+
if (inst.hosting_shape === 'cloud-host') {
|
|
376
376
|
await scaffoldStandaloneRepo(inst, deps);
|
|
377
377
|
// A PRIVATE target_ref gets its own read-only deploy key BEFORE step 2's
|
|
378
378
|
// `git pull --ff-only origin main` — the pull that failed unauthenticated
|
|
@@ -740,7 +740,7 @@ async function domainAttachInstance(inst, deps) {
|
|
|
740
740
|
const controlExec = deps.controlExec || exec;
|
|
741
741
|
// Same box routing as provisionInstance: a standalone runs control-plane-local
|
|
742
742
|
// (task 2074), so its env/caddy/service live HERE, not on the co-hosting box.
|
|
743
|
-
const standalone = inst.hosting_shape === '
|
|
743
|
+
const standalone = inst.hosting_shape === 'cloud-host';
|
|
744
744
|
const boxExec = standalone ? controlExec : exec;
|
|
745
745
|
const boxWriteFile = standalone ? (deps.controlWriteFile || writeFile) : writeFile;
|
|
746
746
|
const sudoP = privileged ? 'sudo ' : '';
|
|
@@ -876,7 +876,7 @@ async function domainAttachInstance(inst, deps) {
|
|
|
876
876
|
async function settingsApplyInstance(inst, deps) {
|
|
877
877
|
const { db, provisioning, exec, writeFile, log, apply, privileged } = deps;
|
|
878
878
|
// Same box routing as domainAttachInstance: a standalone runs control-plane-local.
|
|
879
|
-
const standalone = inst.hosting_shape === '
|
|
879
|
+
const standalone = inst.hosting_shape === 'cloud-host';
|
|
880
880
|
const boxExec = standalone ? (deps.controlExec || exec) : exec;
|
|
881
881
|
const boxWriteFile = standalone ? (deps.controlWriteFile || writeFile) : writeFile;
|
|
882
882
|
const sudoP = privileged ? 'sudo ' : '';
|
|
@@ -992,7 +992,7 @@ async function settingsApplyInstance(inst, deps) {
|
|
|
992
992
|
async function restartInstance(inst, deps) {
|
|
993
993
|
const { db, provisioning, exec, log, apply, privileged } = deps;
|
|
994
994
|
// Same box routing as settingsApplyInstance: a standalone runs control-plane-local.
|
|
995
|
-
const boxExec = inst.hosting_shape === '
|
|
995
|
+
const boxExec = inst.hosting_shape === 'cloud-host' ? (deps.controlExec || exec) : exec;
|
|
996
996
|
const sudoP = privileged ? 'sudo ' : '';
|
|
997
997
|
log(`restart ${inst.slug} (shape=${inst.hosting_shape}, status=${inst.status})`);
|
|
998
998
|
|
|
@@ -1124,7 +1124,7 @@ async function placeManifestCreds(inst, mrow, deps) {
|
|
|
1124
1124
|
return { ok: false, error: 'GitHub App Manifest cred placement is not supported for a dedicated droplet yet — run oauth-secret.js on the droplet instead' };
|
|
1125
1125
|
}
|
|
1126
1126
|
if (!mrow.github_code) return { ok: false, error: 'manifest row carries no github_code to convert' };
|
|
1127
|
-
const standalone = inst.hosting_shape === '
|
|
1127
|
+
const standalone = inst.hosting_shape === 'cloud-host';
|
|
1128
1128
|
const boxExec = standalone ? (deps.controlExec || deps.exec) : deps.exec;
|
|
1129
1129
|
const boxWriteFile = standalone ? (deps.controlWriteFile || deps.writeFile) : deps.writeFile;
|
|
1130
1130
|
const sudoP = deps.privileged ? 'sudo ' : '';
|
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.1000'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/collab_page.mjs
CHANGED
|
@@ -565,12 +565,138 @@ test('the asker can settle in place, with both outcomes', () => {
|
|
|
565
565
|
assert.match(js, /patchHelpRequestsId/, 'through the real API, not a local edit');
|
|
566
566
|
});
|
|
567
567
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
568
|
+
// REPLACED, not deleted (task 1004216). This test used to read "there is
|
|
569
|
+
// deliberately NO reply thread — that would reimplement the Agora", deferring
|
|
570
|
+
// replies to task 1002946 under goal 1000064. That task is ABANDONED and that
|
|
571
|
+
// goal is ARCHIVED, so the deferral had no addressee left: the surface that was
|
|
572
|
+
// to own replies will never be built, and an ask stayed unanswerable to keep a
|
|
573
|
+
// promise to a thing that does not exist. ADR 0330 records the reversal.
|
|
574
|
+
//
|
|
575
|
+
// What the old test was PROTECTING is still real, so it is what these assert:
|
|
576
|
+
// a thread here must not grow into the chat surface ADR 0187 §4a declined.
|
|
577
|
+
test('an ask can be answered — and the answer surface stops well short of a chat', () => {
|
|
578
|
+
const src = read('modules', 'hall-ui', 'public', 'collab.js');
|
|
579
|
+
// Comments stripped before the bans are checked: this file ARGUES at length
|
|
580
|
+
// about the four things it is not building, and a scan that read the argument
|
|
581
|
+
// as the thing would fail on the very prose explaining the restraint.
|
|
582
|
+
const js = src.replace(/^\s*\/\/.*$/gm, '');
|
|
583
|
+
assert.match(js, /data-reply-form/, 'there is somewhere to write an answer');
|
|
584
|
+
assert.match(js, /POST', `\/help-requests\/\$\{id\}\/replies`/, 'and it posts to the replies route');
|
|
585
|
+
|
|
586
|
+
// The four things a chat has that this must not grow.
|
|
587
|
+
assert.ok(!/data-edit|patchReply|editReply/i.test(js), 'no edit — a reply is what was said at the time');
|
|
588
|
+
assert.ok(!/deleteReply|data-reply-delete/i.test(js), 'no delete');
|
|
589
|
+
assert.ok(!/data-react|reaction|emoji/i.test(js), 'no reactions');
|
|
590
|
+
assert.ok(!/parent_reply|data-reply-to|@mention|mentions/i.test(js), 'no nesting and no mentions');
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
// RUN, not grepped — this file's own convention for everything in collab-lib.js,
|
|
594
|
+
// and the grader was right that the first pass broke it. canClose in particular
|
|
595
|
+
// decides whether a close control is drawn at all, so each branch of it needs a
|
|
596
|
+
// test that calls it rather than a regex that agrees with a wrong answer.
|
|
597
|
+
test('canClose: the asker and the addressee, and nobody else', () => {
|
|
598
|
+
const L = lib();
|
|
599
|
+
const craftAsk = { requested_by: '90', needs_craft: 'artist', needs_builder_id: null };
|
|
600
|
+
const namedAsk = { requested_by: '90', needs_craft: null, needs_builder_id: '7' };
|
|
601
|
+
|
|
602
|
+
assert.equal(L.canClose(namedAsk, 90, []), true, 'the asker always may');
|
|
603
|
+
assert.equal(L.canClose(namedAsk, 7, []), true, 'so may the person it names');
|
|
604
|
+
assert.equal(L.canClose(craftAsk, 7, ['artist']), true, 'or a craft they belong to');
|
|
605
|
+
|
|
606
|
+
assert.equal(L.canClose(craftAsk, 7, ['builder']), false, 'a craft they do not belong to is not theirs to close');
|
|
607
|
+
assert.equal(L.canClose(namedAsk, 8, ['artist']), false, 'a stranger to a by-name ask');
|
|
608
|
+
assert.equal(L.canClose(craftAsk, 7, []), false, 'no declared craft reaches no craft ask');
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test('canClose fails CLOSED on every missing fact', () => {
|
|
612
|
+
// The page reads meId from /me. A failed read leaves it null, and the correct
|
|
613
|
+
// answer then is "draw nothing" — never "draw it and let the server refuse".
|
|
614
|
+
const L = lib();
|
|
615
|
+
const ask = { requested_by: '90', needs_builder_id: '7' };
|
|
616
|
+
for (const bad of [null, undefined]) {
|
|
617
|
+
assert.equal(L.canClose(ask, bad, ['artist']), false, `meId ${bad} draws no control`);
|
|
618
|
+
}
|
|
619
|
+
assert.equal(L.canClose(null, 7, ['artist']), false, 'no request, no control');
|
|
620
|
+
assert.equal(L.canClose({ requested_by: '90', needs_craft: null }, 7, null), false,
|
|
621
|
+
'a non-array craft list is not iterated into a yes');
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test('canClose compares ids ACROSS types — the API sends strings, /me sends what it sends', () => {
|
|
625
|
+
// The live bug this forecloses: needs_builder_id arrives as "7" and meId as 7,
|
|
626
|
+
// and === between them is false, so the person a request names would silently
|
|
627
|
+
// lose their own close control.
|
|
628
|
+
const L = lib();
|
|
629
|
+
assert.equal(L.canClose({ requested_by: '90', needs_builder_id: '7' }, 7, []), true);
|
|
630
|
+
assert.equal(L.canClose({ requested_by: 90, needs_builder_id: 7 }, '7', []), true);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test('threadOf: oldest first, ties broken on id, and never explodes on a missing thread', () => {
|
|
634
|
+
const L = lib();
|
|
635
|
+
const out = L.threadOf({ replies: [
|
|
636
|
+
{ id: 3, created_at: '2026-09-22T10:00:00Z' },
|
|
637
|
+
{ id: 1, created_at: '2026-09-20T10:00:00Z' },
|
|
638
|
+
{ id: 2, created_at: '2026-09-22T10:00:00Z' },
|
|
639
|
+
] });
|
|
640
|
+
assert.deepEqual(out.map((r) => r.id), [1, 2, 3], 'two replies in the same second keep the order written');
|
|
641
|
+
assert.deepEqual(L.threadOf({}), [], 'a row with no replies field');
|
|
642
|
+
assert.deepEqual(L.threadOf(null), []);
|
|
643
|
+
assert.deepEqual(L.threadOf({ replies: 'nonsense' }), [], 'and a field that is not a list');
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
test('saidLabel is the PAST tense — an answer is not a debt', () => {
|
|
647
|
+
// ageLabel says "waiting 3 days", which under an answer reads as the answer
|
|
648
|
+
// still being owed: the exact opposite of what a reply means.
|
|
649
|
+
const L = lib();
|
|
650
|
+
const NOW2 = Date.parse('2026-09-23T12:00:00Z');
|
|
651
|
+
const ago = (d) => new Date(NOW2 - d * 86400000).toISOString();
|
|
652
|
+
assert.equal(L.saidLabel(ago(0), NOW2), 'today');
|
|
653
|
+
assert.equal(L.saidLabel(ago(1), NOW2), 'yesterday');
|
|
654
|
+
assert.equal(L.saidLabel(ago(3), NOW2), '3 days ago');
|
|
655
|
+
assert.equal(L.saidLabel(ago(21), NOW2), '3 weeks ago');
|
|
656
|
+
assert.equal(L.saidLabel('nonsense', NOW2), 'just now', 'an unknown time is not an accusation');
|
|
657
|
+
for (const label of [L.saidLabel(ago(3), NOW2), L.saidLabel(ago(21), NOW2)]) {
|
|
658
|
+
assert.ok(!/waiting/.test(label), `"${label}" must not borrow the ask's tense`);
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
test('replyAuthor names somebody, whatever the server sent', () => {
|
|
663
|
+
const L = lib();
|
|
664
|
+
// Neutral names on purpose: this is the portable core, and a founder's login
|
|
665
|
+
// baked into a fixture is what audit-authorship.sh exists to catch.
|
|
666
|
+
assert.equal(L.replyAuthor({ builder_name: 'Preview Alpha', builder_login: 'preview-alpha' }), 'Preview Alpha');
|
|
667
|
+
assert.equal(L.replyAuthor({ builder_login: 'preview-alpha' }), 'preview-alpha', 'the login when there is no display name');
|
|
668
|
+
assert.equal(L.replyAuthor({ builder_id: 7 }), 'builder 7', 'the bare id an older server sends');
|
|
669
|
+
assert.equal(L.replyAuthor({}), 'someone', 'never an empty byline');
|
|
670
|
+
assert.equal(L.replyAuthor(null), '');
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
test('validateReplyBody refuses before the round trip, and trims what it accepts', () => {
|
|
674
|
+
const L = lib();
|
|
675
|
+
assert.deepEqual(L.validateReplyBody(' moot '), { ok: true, body: 'moot' });
|
|
676
|
+
assert.equal(L.validateReplyBody(' ').ok, false);
|
|
677
|
+
assert.equal(L.validateReplyBody(' ').reason, 'empty');
|
|
678
|
+
assert.equal(L.validateReplyBody(null).reason, 'empty');
|
|
679
|
+
assert.equal(L.validateReplyBody('x'.repeat(L.MAX_REPLY + 1)).reason, 'too_long');
|
|
680
|
+
assert.equal(L.validateReplyBody('x'.repeat(L.MAX_REPLY)).ok, true, 'the cap itself is allowed');
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
test('append-only is enforced by the DATABASE, not by the routes that exist today', () => {
|
|
684
|
+
// A route layer that merely omits an update is one careless PR from allowing
|
|
685
|
+
// one. The table refuses an UPDATE or a DELETE outright, so the guarantee
|
|
686
|
+
// holds against a future route, a migration, or a hand-run statement.
|
|
687
|
+
const raw = read('modules', 'lifecycle', 'migrations', 'lifecycle_011_help_request_replies.sql');
|
|
688
|
+
assert.match(raw, /BEFORE UPDATE OR DELETE ON lifecycle_help_request_replies/);
|
|
689
|
+
assert.match(raw, /RAISE EXCEPTION/, 'and it raises rather than silently ignoring the write');
|
|
690
|
+
// Same reason as above — the migration's header names the columns it refuses
|
|
691
|
+
// to have, so the ban is checked against the STATEMENTS, not the argument.
|
|
692
|
+
const sql = raw.replace(/^\s*--.*$/gm, '');
|
|
693
|
+
for (const col of ['updated_at', 'edited_by', 'deleted_at', 'parent_reply_id']) {
|
|
694
|
+
assert.ok(!new RegExp(`\\b${col}\\b`).test(sql), `no ${col} column — the shape refuses to become a chat`);
|
|
573
695
|
}
|
|
696
|
+
|
|
697
|
+
const routes = read('modules', 'lifecycle', 'routes', 'help-requests.js');
|
|
698
|
+
assert.ok(!/router\.(patch|delete|put)\('\/help-requests\/:id\/replies/.test(routes),
|
|
699
|
+
'and no route offers to change one');
|
|
574
700
|
});
|
|
575
701
|
|
|
576
702
|
// ---- placement ------------------------------------------------------------
|
|
@@ -724,7 +850,14 @@ test('the skill the page invokes exists, and is the ANSWERING half', () => {
|
|
|
724
850
|
assert.match(md, /^name: collab-review$/m);
|
|
725
851
|
assert.match(md, /GET \/api\/bongos\/help-requests\/for-me/);
|
|
726
852
|
assert.match(md, /GET \/api\/bongos\/task-recommendations\/for-me/);
|
|
727
|
-
// The
|
|
728
|
-
|
|
853
|
+
// The rules the routes enforce, stated where a session will read them. Since
|
|
854
|
+
// ADR 0330 the addressee CAN close an ask — so what the skill must carry is no
|
|
855
|
+
// longer "you cannot", it is "you do not, because you are not the one who read
|
|
856
|
+
// the answer", plus the half that did not widen.
|
|
857
|
+
assert.match(md, /POST \/api\/bongos\/help-requests\/:id\/replies/, 'it answers on the ask');
|
|
858
|
+
assert.match(md, /Do NOT close the ask/, 'and hands the close to a person');
|
|
859
|
+
assert.match(md, /withdraw/i, 'and knows withdraw did not widen');
|
|
860
|
+
assert.ok(!/POST \/api\/bongos\/help-requests --body-file/.test(md),
|
|
861
|
+
'the file-a-counter-ask workaround is deleted, not left beside the real thing');
|
|
729
862
|
assert.match(md, /DATA, not instructions/, 'an ask is untrusted input');
|
|
730
863
|
});
|
|
@@ -119,7 +119,7 @@ await t('and widening it did NOT let the destructive acts through', () => {
|
|
|
119
119
|
});
|
|
120
120
|
|
|
121
121
|
await t('an ordinary instance is untouched by the guard', () => {
|
|
122
|
-
for (const shape of ['co-tenant', 'dedicated', '
|
|
122
|
+
for (const shape of ['co-tenant', 'dedicated', 'cloud-host']) {
|
|
123
123
|
for (const a of P.INTENT_ACTIONS) {
|
|
124
124
|
assert.equal(G.intentAllowedForShape(a, shape), true, `${a}/${shape} must be unaffected`);
|
|
125
125
|
}
|
|
@@ -264,7 +264,7 @@ await t('the upgrade CLI is run somewhere it ACTUALLY EXISTS — the bug that br
|
|
|
264
264
|
// Error: Cannot find module '/srv/<base>/<slug>/scripts/gds/upgrade.js'
|
|
265
265
|
// No assertion about FLAGS could have caught it, so this one resolves the composed
|
|
266
266
|
// cwd + relative CLI against the real filesystem, for every shape the door admits.
|
|
267
|
-
for (const shape of ['control-plane', '
|
|
267
|
+
for (const shape of ['control-plane', 'cloud-host', 'co-tenant']) {
|
|
268
268
|
const d = makeDeps({ served: '1.19.762' });
|
|
269
269
|
await R.coreUpgradeInstance(inst({ hosting_shape: shape, slug: 'hermeslines-marketing', port: 3011 }), d, intent());
|
|
270
270
|
const call = d.ranWith.find((c) => /upgrade\.js/.test(c.cmd));
|
|
@@ -278,7 +278,7 @@ await t('the upgrade CLI is run somewhere it ACTUALLY EXISTS — the bug that br
|
|
|
278
278
|
|
|
279
279
|
await t('a standalone tenant is NAMED with --instance, because the CLI no longer runs inside it', async () => {
|
|
280
280
|
const d = makeDeps({ served: '1.19.762' });
|
|
281
|
-
await R.coreUpgradeInstance(inst({ hosting_shape: '
|
|
281
|
+
await R.coreUpgradeInstance(inst({ hosting_shape: 'cloud-host', slug: 'hermeslines-marketing', port: 3011 }), d, intent());
|
|
282
282
|
const cmd = d.ran.find((c) => /upgrade\.js/.test(c));
|
|
283
283
|
assert.match(cmd, /--instance \S*hermeslines-marketing/, 'the target is named explicitly');
|
|
284
284
|
assert.ok(cmd.includes('--service hermeslines-marketing'), 'and its own unit is still the one restarted');
|
|
@@ -475,7 +475,7 @@ await t('the control plane upgrades as the operator account, never as root', asy
|
|
|
475
475
|
|
|
476
476
|
await t('a tenant upgrades as its OWN account, which is also its Postgres login role', async () => {
|
|
477
477
|
const d = { ...makeDeps({ served: '1.19.762' }), privileged: true };
|
|
478
|
-
await R.coreUpgradeInstance(inst({ hosting_shape: '
|
|
478
|
+
await R.coreUpgradeInstance(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), d, intent());
|
|
479
479
|
const cmd = upgradeCall(d);
|
|
480
480
|
assert.match(cmd, /sudo -u bongos-test4 /, 'the instance account, not the operator and not root');
|
|
481
481
|
assert.doesNotMatch(cmd, /sudo -u appsvc /, 'one tenant must never be upgraded under another account');
|
|
@@ -487,7 +487,7 @@ await t('the database is NAMED, not discovered — an adopted repo has no core a
|
|
|
487
487
|
// upgrade died with "cannot determine which database to migrate".
|
|
488
488
|
const d = { ...makeDeps({ served: '1.19.762' }), privileged: true };
|
|
489
489
|
await R.coreUpgradeInstance(
|
|
490
|
-
inst({ hosting_shape: '
|
|
490
|
+
inst({ hosting_shape: 'cloud-host', slug: 'hermeslines-marketing', db_name: 'hermeslines-marketing', port: 3011 }), d, intent());
|
|
491
491
|
assert.match(upgradeCall(d), /env PGDATABASE=hermeslines-marketing /, 'the runner passes the name the control plane already holds');
|
|
492
492
|
});
|
|
493
493
|
|
|
@@ -502,7 +502,7 @@ await t('an UNPRIVILEGED runner still adds no sudo and no env at all', async ()
|
|
|
502
502
|
// it so the fix above cannot quietly start requiring sudo where there is none.
|
|
503
503
|
const d = makeDeps({ served: '1.19.762' });
|
|
504
504
|
d.privileged = false;
|
|
505
|
-
await R.coreUpgradeInstance(inst({ hosting_shape: '
|
|
505
|
+
await R.coreUpgradeInstance(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), d, intent());
|
|
506
506
|
const cmd = upgradeCall(d);
|
|
507
507
|
assert.doesNotMatch(cmd, /sudo/, 'no escalation when the runner is not privileged');
|
|
508
508
|
assert.match(cmd, /^node \S*scripts\/gds\/upgrade\.js /, 'it still invokes the CLI directly');
|
|
@@ -531,7 +531,7 @@ await t('ONE builder serves both legs — the preview and the move cannot drift
|
|
|
531
531
|
// Everything deciding whether the upgrade is ALLOWED must be identical; only the tail
|
|
532
532
|
// that describes what happens AFTER the pin moves may differ, because a dry run stops
|
|
533
533
|
// before it.
|
|
534
|
-
const box = { id: 7, slug: 'test4', port: 3006, hosting_shape: '
|
|
534
|
+
const box = { id: 7, slug: 'test4', port: 3006, hosting_shape: 'cloud-host', db_name: 'test4' };
|
|
535
535
|
const move = R.upgradeInvocation(box, '1.19.921', { privileged: true, requestedBy: '42' });
|
|
536
536
|
const peek = R.upgradeInvocation(box, '1.19.921', { privileged: true, dryRun: true });
|
|
537
537
|
for (const shared of ['sudo -u bongos-test4 ', 'env PGDATABASE=test4 ', '--to 1.19.921', '--registry']) {
|
|
@@ -552,13 +552,13 @@ await t('ONE builder serves both legs — the preview and the move cannot drift
|
|
|
552
552
|
await t('an invalid slug is refused at the BUILDER, not left to a helper to throw', () => {
|
|
553
553
|
// The slug reaches a shell through instanceUser()/dbName(). Depending on another
|
|
554
554
|
// function's internals for that is one refactor away from an injection path.
|
|
555
|
-
assert.throws(() => R.upgradeInvocation({ slug: 'a; rm -rf /', port: 1, hosting_shape: '
|
|
555
|
+
assert.throws(() => R.upgradeInvocation({ slug: 'a; rm -rf /', port: 1, hosting_shape: 'cloud-host' }, '1.19.921', {}),
|
|
556
556
|
/invalid slug/);
|
|
557
557
|
});
|
|
558
558
|
|
|
559
559
|
await t('a dry run that fails WITHOUT a recognisable marker is still a refusal, never a pass', () => {
|
|
560
560
|
const deps = { privileged: false, exec: () => ({ ok: false, stdout: 'something went sideways' }) };
|
|
561
|
-
const r = R.preflightUpgrade({ id: 7, slug: 'test4', port: 3006, hosting_shape: '
|
|
561
|
+
const r = R.preflightUpgrade({ id: 7, slug: 'test4', port: 3006, hosting_shape: 'cloud-host' }, '1.19.921', deps);
|
|
562
562
|
assert.equal(r.ok, false, 'a non-zero exit is a refusal whatever it printed');
|
|
563
563
|
assert.ok(r.error);
|
|
564
564
|
});
|
|
@@ -716,7 +716,7 @@ await t('a tenant WITH its own account runs as that account — the security mod
|
|
|
716
716
|
// task 1003369: a distinct uid per instance is what stops a co-tenant reading a
|
|
717
717
|
// sibling's /proc/<pid>/environ. Falling back everywhere would quietly undo it.
|
|
718
718
|
const d = acctDeps({ accountExists: true });
|
|
719
|
-
const r = R.resolveRunAs(inst({ hosting_shape: '
|
|
719
|
+
const r = R.resolveRunAs(inst({ hosting_shape: 'cloud-host', slug: 'test4' }), d);
|
|
720
720
|
assert.deepEqual({ runAs: r.runAs, fellBack: r.fellBack }, { runAs: 'bongos-test4', fellBack: false });
|
|
721
721
|
});
|
|
722
722
|
|
|
@@ -726,7 +726,7 @@ await t('a tenant WITHOUT one falls back to the app user, and says so', () => {
|
|
|
726
726
|
// instance's own unit already says User=lars — but it IS a provisioning gap, so it
|
|
727
727
|
// is reported rather than silently papered over.
|
|
728
728
|
const d = acctDeps({ accountExists: false });
|
|
729
|
-
const r = R.resolveRunAs(inst({ hosting_shape: '
|
|
729
|
+
const r = R.resolveRunAs(inst({ hosting_shape: 'cloud-host', slug: 'test4' }), d);
|
|
730
730
|
assert.equal(r.runAs, 'appsvc');
|
|
731
731
|
assert.equal(r.fellBack, true);
|
|
732
732
|
assert.equal(r.wanted, 'bongos-test4', 'and names the account that is missing');
|
|
@@ -741,7 +741,7 @@ await t('the control plane is never probed — it has no instance account by des
|
|
|
741
741
|
|
|
742
742
|
await t('the dry run is invoked as the account that actually exists', async () => {
|
|
743
743
|
const d = acctDeps({ accountExists: false, dryRunStdout: ' ✓ pre-flight: ok\n' });
|
|
744
|
-
await R.preflightUpgrade(inst({ hosting_shape: '
|
|
744
|
+
await R.preflightUpgrade(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), '1.19.762', d);
|
|
745
745
|
const cmd = (d.ranWith.find((c) => /upgrade\.js/.test(c.cmd)) || {}).cmd || '';
|
|
746
746
|
assert.match(cmd, /sudo -u appsvc /, `must not sudo to an account that does not exist — got: ${cmd}`);
|
|
747
747
|
assert.doesNotMatch(cmd, /bongos-test4/);
|
|
@@ -749,7 +749,7 @@ await t('the dry run is invoked as the account that actually exists', async () =
|
|
|
749
749
|
|
|
750
750
|
await t('the MOVE probes identically, so preview and move cannot diverge on the account', async () => {
|
|
751
751
|
const d = acctDeps({ accountExists: false });
|
|
752
|
-
await R.coreUpgradeInstance(inst({ hosting_shape: '
|
|
752
|
+
await R.coreUpgradeInstance(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), d, intent());
|
|
753
753
|
const cmd = (d.ranWith.find((c) => /upgrade\.js/.test(c.cmd)) || {}).cmd || '';
|
|
754
754
|
assert.match(cmd, /sudo -u appsvc /, 'the move follows the same probe as the preview');
|
|
755
755
|
});
|
|
@@ -761,7 +761,7 @@ await t('an INCONCLUSIVE probe keeps the isolated account rather than quietly wi
|
|
|
761
761
|
// well exist. Only `no such user` is evidence.
|
|
762
762
|
const d = makeDeps({ served: '1.19.762' });
|
|
763
763
|
d.exec = () => ({ ok: true, stdout: '', stderr: 'id: cannot connect to name service\n', softFailed: true });
|
|
764
|
-
const r = R.resolveRunAs(inst({ hosting_shape: '
|
|
764
|
+
const r = R.resolveRunAs(inst({ hosting_shape: 'cloud-host', slug: 'test4' }), d);
|
|
765
765
|
assert.equal(r.runAs, 'bongos-test4', 'the intended account is kept');
|
|
766
766
|
assert.equal(r.fellBack, false);
|
|
767
767
|
assert.equal(r.probeInconclusive, true, 'and the caller can tell it was not a clean answer');
|
|
@@ -776,7 +776,7 @@ await t('the probe uses the SAME executor the upgrade will use', () => {
|
|
|
776
776
|
const d = makeDeps({ served: '1.19.762' });
|
|
777
777
|
d.controlExec = (cmd) => { asked.control.push(cmd); return { ok: true, stdout: '1001\n' }; };
|
|
778
778
|
d.exec = (cmd) => { asked.remote.push(cmd); return { ok: true, stdout: '', stderr: "id: 'x': no such user\n", softFailed: true }; };
|
|
779
|
-
const r = R.resolveRunAs(inst({ hosting_shape: '
|
|
779
|
+
const r = R.resolveRunAs(inst({ hosting_shape: 'cloud-host', slug: 'test4' }), d);
|
|
780
780
|
assert.deepEqual(asked.remote, [], 'the remote executor was not asked');
|
|
781
781
|
assert.equal(asked.control.length, 1, 'the control-plane executor was');
|
|
782
782
|
assert.equal(r.runAs, 'bongos-test4', 'and its answer is the one that counts');
|
|
@@ -788,7 +788,7 @@ await t('a planning tick probes ONCE, not twice', async () => {
|
|
|
788
788
|
// a subprocess per tick for nothing.
|
|
789
789
|
const d = acctDeps({ accountExists: false });
|
|
790
790
|
d.apply = false;
|
|
791
|
-
await R.coreUpgradeInstance(inst({ hosting_shape: '
|
|
791
|
+
await R.coreUpgradeInstance(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), d, intent());
|
|
792
792
|
assert.equal(d.ranWith.filter((c) => /^id -u /.test(c.cmd)).length, 1,
|
|
793
793
|
'exactly one probe: the dry run that actually executes');
|
|
794
794
|
});
|
|
@@ -800,7 +800,7 @@ await t('an invalid slug is refused by THIS call site, not merely by the helper
|
|
|
800
800
|
// helper's internals, so the assertion names the thrower.
|
|
801
801
|
const d = acctDeps({});
|
|
802
802
|
assert.throws(
|
|
803
|
-
() => R.resolveRunAs({ hosting_shape: '
|
|
803
|
+
() => R.resolveRunAs({ hosting_shape: 'cloud-host', slug: 'bad slug;rm -rf /' }, d),
|
|
804
804
|
/resolveRunAs: refusing to probe/,
|
|
805
805
|
'the refusal must come from this function, not from instanceUser further down',
|
|
806
806
|
);
|
|
@@ -809,7 +809,7 @@ await t('an invalid slug is refused by THIS call site, not merely by the helper
|
|
|
809
809
|
|
|
810
810
|
await t('a refusal reports what the command SAID, not "did not complete"', async () => {
|
|
811
811
|
const d = acctDeps({ softFail: true, dryRunStderr: 'sudo: unknown user bongos-test4\n' });
|
|
812
|
-
const v = await R.preflightUpgrade(inst({ hosting_shape: '
|
|
812
|
+
const v = await R.preflightUpgrade(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), '1.19.762', d);
|
|
813
813
|
assert.equal(v.ok, false);
|
|
814
814
|
assert.match(v.error, /unknown user bongos-test4/,
|
|
815
815
|
`the reason was in hand and must travel — got: ${JSON.stringify(v.error)}`);
|
|
@@ -817,21 +817,21 @@ await t('a refusal reports what the command SAID, not "did not complete"', async
|
|
|
817
817
|
|
|
818
818
|
await t('a ✖ line still wins over the raw tail', async () => {
|
|
819
819
|
const d = acctDeps({ softFail: true, dryRunStdout: 'noise\n ✖ already on 1.19.927 (pass --force to re-run)\n' });
|
|
820
|
-
const v = await R.preflightUpgrade(inst({ hosting_shape: '
|
|
820
|
+
const v = await R.preflightUpgrade(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), '1.19.762', d);
|
|
821
821
|
assert.match(v.error, /already on 1\.19\.927/);
|
|
822
822
|
assert.doesNotMatch(v.error, /^noise/, 'the parsed refusal is preferred to the raw tail');
|
|
823
823
|
});
|
|
824
824
|
|
|
825
825
|
await t('a fallback is recorded on the verdict the owner reads', async () => {
|
|
826
826
|
const d = acctDeps({ accountExists: false, dryRunStdout: ' ✓ pre-flight: ok\n' });
|
|
827
|
-
const v = await R.preflightUpgrade(inst({ hosting_shape: '
|
|
827
|
+
const v = await R.preflightUpgrade(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), '1.19.762', d);
|
|
828
828
|
assert.equal(v.ran_as, 'appsvc');
|
|
829
829
|
assert.match(v.account_note, /no bongos-test4 account yet/);
|
|
830
830
|
});
|
|
831
831
|
|
|
832
832
|
await t('and a silent failure STILL refuses rather than inventing a pass', async () => {
|
|
833
833
|
const d = acctDeps({ softFail: true, dryRunStdout: '', dryRunStderr: '' });
|
|
834
|
-
const v = await R.preflightUpgrade(inst({ hosting_shape: '
|
|
834
|
+
const v = await R.preflightUpgrade(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), '1.19.762', d);
|
|
835
835
|
assert.equal(v.ok, false, 'a command that said nothing is not a pass');
|
|
836
836
|
assert.match(v.error, /said nothing/);
|
|
837
837
|
});
|
|
@@ -22,7 +22,7 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
22
22
|
const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/deploy.js'), 'utf8');
|
|
23
23
|
|
|
24
24
|
const preview = (id, slug, over = {}) => ({
|
|
25
|
-
instance_id: id, slug, hosting_shape: '
|
|
25
|
+
instance_id: id, slug, hosting_shape: 'cloud-host',
|
|
26
26
|
served_version: '1.19.800', recommended_version: '1.19.871',
|
|
27
27
|
behind: true, reading: 'ok', observed_at: new Date().toISOString(), open_intent: null,
|
|
28
28
|
...over,
|
|
@@ -30,11 +30,11 @@ const preview = (id, slug, over = {}) => ({
|
|
|
30
30
|
|
|
31
31
|
// The fleet as the control plane really reports it: mostly torn down, a couple live.
|
|
32
32
|
const FLEET = [
|
|
33
|
-
{ id: '31', slug: 'mercury', status: 'active', hosting_shape: '
|
|
33
|
+
{ id: '31', slug: 'mercury', status: 'active', hosting_shape: 'cloud-host' },
|
|
34
34
|
{ id: '32', slug: 'hoodify-finance', status: 'active', hosting_shape: 'co-tenant' },
|
|
35
|
-
{ id: '33', slug: 'walk-lantern', status: 'torn_down', hosting_shape: '
|
|
36
|
-
{ id: '34', slug: 'testccc', status: 'torn_down', hosting_shape: '
|
|
37
|
-
{ id: '35', slug: 'unreadable-one', status: 'active', hosting_shape: '
|
|
35
|
+
{ id: '33', slug: 'walk-lantern', status: 'torn_down', hosting_shape: 'cloud-host' },
|
|
36
|
+
{ id: '34', slug: 'testccc', status: 'torn_down', hosting_shape: 'cloud-host' },
|
|
37
|
+
{ id: '35', slug: 'unreadable-one', status: 'active', hosting_shape: 'cloud-host' },
|
|
38
38
|
];
|
|
39
39
|
|
|
40
40
|
function boot({ controlPlane = preview('1', 'cloudbongos'), controlPlaneStatus = 200, fleet = FLEET, fleetStatus = 200, instanceOver = null, moveTries = 4, postStatus = 200, scope = null } = {}) {
|