@bongos/core 1.21.13 → 1.21.15
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 +35 -30
- package/docs/adr/0281-an-instance-identity-is-its-own-unix-account-and-pg-role.md +3 -0
- package/docs/copy-inventory.md +49 -49
- package/docs/copy-registry.json +56 -56
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +1 -1
- package/docs/recipes/upgrading-the-core.md +1 -0
- package/modules/autonomy/fence.js +86 -1
- package/modules/hall-ui/public/deploy.js +3 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/autobongos-run.js +15 -3
- package/scripts/gds/provision-core-upgrade.js +52 -20
- package/scripts/gds/upgrade-migrate.js +101 -4
- package/scripts/gds/upgrade-outcome.js +6 -0
- package/scripts/gds/upgrade.js +39 -44
- package/scripts/gds/wedge-remedy.js +10 -4
- package/src/module-api.js +1 -1
- package/tests/autobongos_host_access.mjs +176 -0
- package/tests/core_upgrade_runner.mjs +34 -10
- package/tests/instance_db_migrated_on_code_move.mjs +20 -2
- package/tests/upgrade.mjs +147 -0
- package/tests/upgrade_outcome.mjs +3 -0
- package/tests/wedge_remedy.mjs +36 -2
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// tests/autobongos_host_access.mjs — the runner skips work it structurally
|
|
2
|
+
// cannot do (task 1004537).
|
|
3
|
+
//
|
|
4
|
+
// The runner machine holds no SSH key, no root and no service environment for a
|
|
5
|
+
// live droplet (ADR 0031 §6 / ADR 0055). Tasks 1003969 and 1003980 were still
|
|
6
|
+
// picked, three times between them, and each run burned a claim and filed an
|
|
7
|
+
// owner blocker saying the work was never doable there. These tests hold the
|
|
8
|
+
// fence that stops that: a host-access task is SKIPPED, before any claim, with
|
|
9
|
+
// the reason `needs_host_access` in nothing_claimable's skipped[], and an
|
|
10
|
+
// ordinary code task beside it is still picked.
|
|
11
|
+
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { test } from 'node:test';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const fence = require('../modules/autonomy/fence.js');
|
|
20
|
+
const runner = require('../scripts/gds/autobongos-run.js');
|
|
21
|
+
|
|
22
|
+
const { needsHostAccess, decideTask } = fence;
|
|
23
|
+
|
|
24
|
+
// The two real shapes, excerpted from their task rows (touches, tags and
|
|
25
|
+
// manual_degree exactly as they were: nothing explicit marked either one).
|
|
26
|
+
const T1003969 = {
|
|
27
|
+
id: 1003969, goal_id: 1000119, kind: 'bug', status: 'ready', requires_rank: 'xenos',
|
|
28
|
+
touches: ['docs/recipes/upgrading-the-core.md'], manual_degree: null, automation_tag: null,
|
|
29
|
+
title: 'Install the core-update-subscription timer at the declared 15min cadence on the cloudbongos.com control plane',
|
|
30
|
+
description: [
|
|
31
|
+
'WHAT REMAINS (host state, not in this repo): the systemd unit lives on the droplet.',
|
|
32
|
+
'/etc/systemd/system/cloudbongos-core-update-subscription.timer is still OnCalendar=daily with RandomizedDelaySec=1800. Change it to:',
|
|
33
|
+
' OnCalendar=*:0/15',
|
|
34
|
+
'then `sudo systemctl daemon-reload && sudo systemctl restart cloudbongos-core-update-subscription.timer`',
|
|
35
|
+
].join('\n'),
|
|
36
|
+
};
|
|
37
|
+
const T1003980 = {
|
|
38
|
+
id: 1003980, goal_id: 1000119, kind: 'bug', status: 'ready', requires_rank: 'xenos',
|
|
39
|
+
touches: [], manual_degree: null, automation_tag: null,
|
|
40
|
+
title: 'Recover the five lost bug-attachment screenshots from the co-hosting box, or accept the loss on the record',
|
|
41
|
+
description: [
|
|
42
|
+
'WHAT IS LEFT. ADR 0126 kept the old co-hosting droplet as a rollback net after the 2026-07-05 move.',
|
|
43
|
+
'If its var/bug-attachments survived, the five files are recoverable by copying them onto the control-plane droplet at BUG_ATTACHMENTS_DIR.',
|
|
44
|
+
'WHY IT IS A SEPARATE ROW. It needs an interactive session on two live boxes - look for var/bug-attachments on the old droplet,',
|
|
45
|
+
'copy across, set BUG_ATTACHMENTS_DIR in the service environment, confirm the probe goes green.',
|
|
46
|
+
'DONE WHEN: either the audit reports zero missing, or the files are confirmed unrecoverable.',
|
|
47
|
+
].join('\n'),
|
|
48
|
+
};
|
|
49
|
+
const CODE_TASK = {
|
|
50
|
+
id: 9002, goal_id: 1000119, kind: 'feature', status: 'ready', requires_rank: 'xenos',
|
|
51
|
+
touches: ['scripts/gds/sequence.js'], manual_degree: null, automation_tag: null,
|
|
52
|
+
title: 'Teach the picker a new halt reason',
|
|
53
|
+
description: 'Add a halt reason to scripts/gds/sequence.js and cover it with a tests/*.mjs case. '.repeat(4)
|
|
54
|
+
+ 'DONE WHEN: the new case passes and the old ones still do.',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// ── the predicate ───────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
test('the 1003969 shape (systemd unit edited on the droplet via sudo) needs host access', () => {
|
|
60
|
+
const d = needsHostAccess(T1003969);
|
|
61
|
+
assert.equal(d.needs, true);
|
|
62
|
+
assert.equal(d.source, 'text');
|
|
63
|
+
assert.ok(d.signals.length, 'the matched phrase is named, so a false positive can be seen and overridden');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('the 1003980 shape (copying files between two live boxes) needs host access', () => {
|
|
67
|
+
const d = needsHostAccess(T1003980);
|
|
68
|
+
assert.equal(d.needs, true);
|
|
69
|
+
assert.equal(d.source, 'text');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('an ordinary code task does not', () => {
|
|
73
|
+
assert.equal(needsHostAccess(CODE_TASK).needs, false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("the explicit tag wins both ways: 'live-only' skips with no text, 'overnight-safe' overrides the text", () => {
|
|
77
|
+
const tagged = needsHostAccess({ ...CODE_TASK, automation_tag: 'live-only' });
|
|
78
|
+
assert.equal(tagged.needs, true);
|
|
79
|
+
assert.equal(tagged.source, 'automation_tag');
|
|
80
|
+
// An owner who reads the reason and disagrees marks the task safe; the phrase
|
|
81
|
+
// match must then stay quiet rather than refuse forever.
|
|
82
|
+
assert.equal(needsHostAccess({ ...T1003969, automation_tag: 'overnight-safe' }).needs, false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('code that merely talks ABOUT droplets is not mistaken for work ON one', () => {
|
|
86
|
+
const t = { ...CODE_TASK, description: 'The DigitalOcean droplet-create call in modules/box/do-client.js leaks a droplet when the API blips. Fix the retry so no droplet is orphaned. '.repeat(2) };
|
|
87
|
+
assert.equal(needsHostAccess(t).needs, false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('one passing mention of the box does not refuse a code task (the task 1004463 shape)', () => {
|
|
91
|
+
// A real ready task on 2026-10-02: a code fix whose description quotes the
|
|
92
|
+
// owner's current workaround. One phrase is description, not the work.
|
|
93
|
+
const t = { ...CODE_TASK, description: `${CODE_TASK.description} Today the owner's only route is \`ssh cloudbongos 'systemctl restart cloudbongos'\`; the hall should do it instead.` };
|
|
94
|
+
const d = needsHostAccess(t);
|
|
95
|
+
assert.equal(d.needs, false);
|
|
96
|
+
assert.deepEqual(d.signals, ['systemctl restart'], 'the lone phrase is still reported, so the threshold is visible');
|
|
97
|
+
// The same phrase twice over, as in the two real shapes, does refuse.
|
|
98
|
+
const twice = { ...t, description: `${t.description} Then sudo systemctl daemon-reload on the production droplet.` };
|
|
99
|
+
assert.equal(needsHostAccess(twice).needs, true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('a missing task is not a host-access task (decideTask refuses it on its own)', () => {
|
|
103
|
+
assert.equal(needsHostAccess(null).needs, false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ── decideTask refuses it, like protected_path ─────────────────────────────────
|
|
107
|
+
|
|
108
|
+
test('decideTask refuses a host-access task with code needs_host_access', () => {
|
|
109
|
+
const d = decideTask({ task: T1003969, goals: [1000119], protectedHits: [] });
|
|
110
|
+
assert.equal(d.go, false);
|
|
111
|
+
assert.equal(d.code, 'needs_host_access');
|
|
112
|
+
assert.match(d.reason, /live host/);
|
|
113
|
+
assert.equal(decideTask({ task: CODE_TASK, goals: [1000119], protectedHits: [] }).go, true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ── the picker: skipped with a reason, and the code task behind it is picked ───
|
|
117
|
+
|
|
118
|
+
function fakeApi(tasks, claimable) {
|
|
119
|
+
return {
|
|
120
|
+
me: { getMe: async () => ({ ok: true, data: { builder: { rank: 'archon' } } }) },
|
|
121
|
+
tasks: {
|
|
122
|
+
getTasks: async ({ query }) => ({ ok: true, data: { tasks: query.offset ? [] : tasks } }),
|
|
123
|
+
getTasksId: async ({ id }) => {
|
|
124
|
+
const t = tasks.find((x) => Number(x.id) === Number(id));
|
|
125
|
+
return t ? { ok: true, data: { task: { dependencies: [], dependents: [], ...t } } } : { ok: false };
|
|
126
|
+
},
|
|
127
|
+
getTasksClaimable: async () => ({ ok: true, data: { claimable: claimable.map((id) => ({ id })) } }),
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
test('pickTask walks past the host-access tasks to the code task behind them', async () => {
|
|
133
|
+
const api = fakeApi([T1003969, T1003980, CODE_TASK], [1003969, 1003980, 9002]);
|
|
134
|
+
const got = await runner.pickTask([1000119], { api, rank: 'archon' });
|
|
135
|
+
assert.equal(got.task && got.task.id, 9002, 'an ordinary code task is still picked');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('when only host-access tasks are left, they appear in skipped[] as needs_host_access', async () => {
|
|
139
|
+
const api = fakeApi([T1003969, T1003980], [1003969, 1003980]);
|
|
140
|
+
const got = await runner.pickTask([1000119], { api, rank: 'archon' });
|
|
141
|
+
assert.equal(got.none, true, 'no claim is made on work the machine cannot do');
|
|
142
|
+
const byId = new Map(got.skipped.map((s) => [Number(s.id), s]));
|
|
143
|
+
for (const id of [1003969, 1003980]) {
|
|
144
|
+
const sk = byId.get(id);
|
|
145
|
+
assert.ok(sk, `task ${id} must be listed, not silently vanish`);
|
|
146
|
+
assert.ok(sk.reasons.some((r) => r.code === 'needs_host_access'), `task ${id} carries the reason`);
|
|
147
|
+
assert.equal(sk.goal_id, 1000119);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('the nothing_claimable row the iteration writes carries needs_host_access', async () => {
|
|
152
|
+
const api = fakeApi([T1003969], [1003969]);
|
|
153
|
+
let claimed = false;
|
|
154
|
+
const row = await runner.iteration({ goals: [1000119] }, {
|
|
155
|
+
readFence: async () => ({ raw: { enabled: true, paused_reason: null, goals: [{ goal_id: 1000119 }] }, graderBypassed: false }),
|
|
156
|
+
gauge: () => ({ decision: { go: true, unknown: false, sleepUntil: null, reason: 'headroom' } }),
|
|
157
|
+
state: runner.newRunnerState(),
|
|
158
|
+
pickTask: (goals, d) => runner.pickTask(goals, { ...d, api, rank: 'archon' }),
|
|
159
|
+
run: async () => { claimed = true; return { ok: true, code: 0, stdout: '', stderr: '' }; },
|
|
160
|
+
// Inert stand-ins, so a regression that DOES pick the task fails this test
|
|
161
|
+
// without spawning a real worker or writing to a real ledger.
|
|
162
|
+
spawnWorker: async () => { claimed = true; return { code: 0, envelope: null, stdout: '', stderr: '' }; },
|
|
163
|
+
worktreeName: () => 'autobongos-test-host-access',
|
|
164
|
+
gradeLedgerPath: () => path.join(os.tmpdir(), `autobongos-host-access-${process.pid}.jsonl`),
|
|
165
|
+
api,
|
|
166
|
+
verifyDeps: {
|
|
167
|
+
task: { id: 1003969, status: 'shipped', updated_at: new Date().toISOString() },
|
|
168
|
+
probeArtifact: async () => ({ checked: true, onMain: true, sha: 'a'.repeat(40), subject: 'x' }),
|
|
169
|
+
fileBlocker: async () => ({ filed: false }),
|
|
170
|
+
},
|
|
171
|
+
record: (r) => r,
|
|
172
|
+
});
|
|
173
|
+
assert.equal(row.event, 'nothing_claimable');
|
|
174
|
+
assert.equal(claimed, false, 'no worktree is made and no claim is attempted');
|
|
175
|
+
assert.deepEqual(row.skipped, [{ id: 1003969, goal_id: 1000119, reasons: ['needs_host_access'] }]);
|
|
176
|
+
});
|
|
@@ -507,12 +507,20 @@ await t('the control plane upgrades as the operator account, never as root', asy
|
|
|
507
507
|
assert.doesNotMatch(cmd, /sudo node /, 'a bare `sudo node` is the bug — it lands on root');
|
|
508
508
|
});
|
|
509
509
|
|
|
510
|
-
await t('a tenant
|
|
510
|
+
await t('a tenant MOVES as the account that owns its checkout and MIGRATES as its own (task 1004512)', async () => {
|
|
511
|
+
// The move writes: the pin, node_modules, .claude/, the pin commit — and before any of
|
|
512
|
+
// that, `git status`. Those belong to the app user, which is who scaffolded the
|
|
513
|
+
// checkout; the instance account only has group READ on it, so running the move as that
|
|
514
|
+
// account made git refuse with "detected dubious ownership" and the clean-tree pre-flight
|
|
515
|
+
// report dirt that did not exist. The DATABASE is the opposite: it is owned by the
|
|
516
|
+
// instance's own Postgres login role with CONNECT revoked from PUBLIC, so only that
|
|
517
|
+
// account can migrate it. Hence one move, two accounts.
|
|
511
518
|
const d = { ...makeDeps({ served: '1.19.762' }), privileged: true };
|
|
512
519
|
await R.coreUpgradeInstance(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), d, intent());
|
|
513
520
|
const cmd = upgradeCall(d);
|
|
514
|
-
assert.match(cmd, /sudo -u
|
|
515
|
-
assert.
|
|
521
|
+
assert.match(cmd, /sudo -u appsvc /, 'the move runs as the account that owns the checkout');
|
|
522
|
+
assert.match(cmd, /--migrate-as bongos-test4\b/, 'and hands the database steps to the instance account');
|
|
523
|
+
assert.doesNotMatch(cmd, /sudo -u bongos-test4 /, 'the instance account must not be asked to write a checkout it can only read');
|
|
516
524
|
});
|
|
517
525
|
|
|
518
526
|
await t('the database is NAMED, not discovered — an adopted repo has no core at its root to ask', async () => {
|
|
@@ -568,7 +576,9 @@ await t('ONE builder serves both legs — the preview and the move cannot drift
|
|
|
568
576
|
const box = { id: 7, slug: 'test4', port: 3006, hosting_shape: 'cloud-host', db_name: 'test4' };
|
|
569
577
|
const move = R.upgradeInvocation(box, '1.19.921', { privileged: true, requestedBy: '42' });
|
|
570
578
|
const peek = R.upgradeInvocation(box, '1.19.921', { privileged: true, dryRun: true });
|
|
571
|
-
|
|
579
|
+
// --migrate-as is SHARED, not tail: the dry run's own database pre-check connects as that
|
|
580
|
+
// account too, so a preview that omitted it would test a different identity than the move.
|
|
581
|
+
for (const shared of ['sudo -u appsvc ', 'env PGDATABASE=test4 ', '--to 1.19.921', '--registry', '--migrate-as bongos-test4']) {
|
|
572
582
|
assert.ok(move.cmd.includes(shared), `move carries ${shared}`);
|
|
573
583
|
assert.ok(peek.cmd.includes(shared), `preview carries the same ${shared}`);
|
|
574
584
|
}
|
|
@@ -746,12 +756,15 @@ function acctDeps({ accountExists = true, dryRunStdout = '', dryRunStderr = '',
|
|
|
746
756
|
return d;
|
|
747
757
|
}
|
|
748
758
|
|
|
749
|
-
await t('a tenant WITH its own account
|
|
759
|
+
await t('a tenant WITH its own account MIGRATES as that account — the security model is kept', () => {
|
|
750
760
|
// task 1003369: a distinct uid per instance is what stops a co-tenant reading a
|
|
751
|
-
// sibling's /proc/<pid>/environ. Falling back everywhere would quietly undo it.
|
|
761
|
+
// sibling's /proc/<pid>/environ. Falling back everywhere would quietly undo it. Task
|
|
762
|
+
// 1004512 moved the file half of the work to the checkout's owner and left this half
|
|
763
|
+
// exactly where it was: the database is where the per-instance identity does its job.
|
|
752
764
|
const d = acctDeps({ accountExists: true });
|
|
753
765
|
const r = R.resolveRunAs(inst({ hosting_shape: 'cloud-host', slug: 'test4' }), d);
|
|
754
|
-
assert.deepEqual({ runAs: r.runAs,
|
|
766
|
+
assert.deepEqual({ runAs: r.runAs, migrateAs: r.migrateAs, fellBack: r.fellBack },
|
|
767
|
+
{ runAs: 'appsvc', migrateAs: 'bongos-test4', fellBack: false });
|
|
755
768
|
});
|
|
756
769
|
|
|
757
770
|
await t('a tenant WITHOUT one falls back to the app user, and says so', () => {
|
|
@@ -762,6 +775,7 @@ await t('a tenant WITHOUT one falls back to the app user, and says so', () => {
|
|
|
762
775
|
const d = acctDeps({ accountExists: false });
|
|
763
776
|
const r = R.resolveRunAs(inst({ hosting_shape: 'cloud-host', slug: 'test4' }), d);
|
|
764
777
|
assert.equal(r.runAs, 'appsvc');
|
|
778
|
+
assert.equal(r.migrateAs, 'appsvc', 'both halves land on the app user — the shape that was already working');
|
|
765
779
|
assert.equal(r.fellBack, true);
|
|
766
780
|
assert.equal(r.wanted, 'bongos-test4', 'and names the account that is missing');
|
|
767
781
|
});
|
|
@@ -769,7 +783,8 @@ await t('a tenant WITHOUT one falls back to the app user, and says so', () => {
|
|
|
769
783
|
await t('the control plane is never probed — it has no instance account by design', () => {
|
|
770
784
|
const d = acctDeps({ accountExists: false });
|
|
771
785
|
const r = R.resolveRunAs(inst({ hosting_shape: 'control-plane', slug: 'cloudbongos' }), d);
|
|
772
|
-
assert.deepEqual({ runAs: r.runAs,
|
|
786
|
+
assert.deepEqual({ runAs: r.runAs, migrateAs: r.migrateAs, fellBack: r.fellBack },
|
|
787
|
+
{ runAs: 'appsvc', migrateAs: 'appsvc', fellBack: false });
|
|
773
788
|
assert.equal(d.ranWith.filter((c) => /^id -u /.test(c.cmd)).length, 0, 'no probe was spent');
|
|
774
789
|
});
|
|
775
790
|
|
|
@@ -796,7 +811,7 @@ await t('an INCONCLUSIVE probe keeps the isolated account rather than quietly wi
|
|
|
796
811
|
const d = makeDeps({ served: '1.19.762' });
|
|
797
812
|
d.exec = () => ({ ok: true, stdout: '', stderr: 'id: cannot connect to name service\n', softFailed: true });
|
|
798
813
|
const r = R.resolveRunAs(inst({ hosting_shape: 'cloud-host', slug: 'test4' }), d);
|
|
799
|
-
assert.equal(r.
|
|
814
|
+
assert.equal(r.migrateAs, 'bongos-test4', 'the intended account is kept');
|
|
800
815
|
assert.equal(r.fellBack, false);
|
|
801
816
|
assert.equal(r.probeInconclusive, true, 'and the caller can tell it was not a clean answer');
|
|
802
817
|
});
|
|
@@ -813,7 +828,7 @@ await t('the probe uses the SAME executor the upgrade will use', () => {
|
|
|
813
828
|
const r = R.resolveRunAs(inst({ hosting_shape: 'cloud-host', slug: 'test4' }), d);
|
|
814
829
|
assert.deepEqual(asked.remote, [], 'the remote executor was not asked');
|
|
815
830
|
assert.equal(asked.control.length, 1, 'the control-plane executor was');
|
|
816
|
-
assert.equal(r.
|
|
831
|
+
assert.equal(r.migrateAs, 'bongos-test4', 'and its answer is the one that counts');
|
|
817
832
|
});
|
|
818
833
|
|
|
819
834
|
await t('a planning tick probes ONCE, not twice', async () => {
|
|
@@ -860,9 +875,18 @@ await t('a fallback is recorded on the verdict the owner reads', async () => {
|
|
|
860
875
|
const d = acctDeps({ accountExists: false, dryRunStdout: ' ✓ pre-flight: ok\n' });
|
|
861
876
|
const v = await R.preflightUpgrade(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), '1.19.762', d);
|
|
862
877
|
assert.equal(v.ran_as, 'appsvc');
|
|
878
|
+
assert.equal(v.migrated_as, 'appsvc', 'the verdict names BOTH accounts — since task 1004512 there are two');
|
|
863
879
|
assert.match(v.account_note, /no bongos-test4 account yet/);
|
|
864
880
|
});
|
|
865
881
|
|
|
882
|
+
await t('a project WITH its own account has its two accounts named separately (task 1004512)', async () => {
|
|
883
|
+
const d = acctDeps({ accountExists: true, dryRunStdout: ' ✓ pre-flight: ok\n' });
|
|
884
|
+
const v = await R.preflightUpgrade(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), '1.19.762', d);
|
|
885
|
+
assert.equal(v.ran_as, 'appsvc', 'the files are written by the account that owns the checkout');
|
|
886
|
+
assert.equal(v.migrated_as, 'bongos-test4', 'the database is touched by the account that owns it');
|
|
887
|
+
assert.equal(v.account_note, null, 'and nothing is missing, so there is nothing to report');
|
|
888
|
+
});
|
|
889
|
+
|
|
866
890
|
await t('and a silent failure STILL refuses rather than inventing a pass', async () => {
|
|
867
891
|
const d = acctDeps({ softFail: true, dryRunStdout: '', dryRunStderr: '' });
|
|
868
892
|
const v = await R.preflightUpgrade(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), '1.19.762', d);
|
|
@@ -32,7 +32,7 @@ const tenant = { slug: 'charter', db_name: 'charter_db', hosting_shape: 'co-tena
|
|
|
32
32
|
|
|
33
33
|
test('bongos upgrade migrates before it restarts, and only --skip-migrate opts out', () => {
|
|
34
34
|
const src = fs.readFileSync(path.join(ROOT, 'scripts/gds/upgrade.js'), 'utf8');
|
|
35
|
-
const migrate = src.indexOf('const mig = runMigrate(instanceDir, run);');
|
|
35
|
+
const migrate = src.indexOf('const mig = runMigrate(instanceDir, run, opts.migrateAs || null);');
|
|
36
36
|
const restart = src.lastIndexOf('restartService({ service: opts.service, restartCmd: opts.restartCmd }');
|
|
37
37
|
assert.ok(migrate > 0, 'the upgrade flow runs migrate');
|
|
38
38
|
assert.ok(restart > migrate, 'the restart that makes new code live comes AFTER migrate');
|
|
@@ -42,14 +42,32 @@ test('bongos upgrade migrates before it restarts, and only --skip-migrate opts o
|
|
|
42
42
|
// no scripts is migrated rather than rolled back. tests/upgrade_migrate.mjs exercises both.
|
|
43
43
|
assert.match(src, /function runMigrate\([^)]*\) \{\n\s*const \{ cmd, args, env \} = require\('\.\/upgrade-migrate'\)\.migratePlan\(instanceDir\)/,
|
|
44
44
|
'the migrate step spawns what migratePlan decides');
|
|
45
|
+
// …and as the account that OWNS the database, when the move itself runs as the one that
|
|
46
|
+
// owns the checkout (task 1004512). Those are two different accounts on every project
|
|
47
|
+
// provisioned since task 1003369, and the wrong one cannot connect at all: the project's
|
|
48
|
+
// database is owned by its own Postgres login role with CONNECT revoked from PUBLIC.
|
|
49
|
+
// Asserting the CALL SITE, not just the helper — the helper was never the gap.
|
|
50
|
+
assert.match(src, /const mig = runMigrate\(instanceDir, run, opts\.migrateAs \|\| null\);/,
|
|
51
|
+
'the migrate step is handed --migrate-as, not left on whoever the move happens to be');
|
|
45
52
|
assert.equal(`bash ${CORE_MIGRATE_SCRIPT}`, INSTANCE_MIGRATE_SCRIPT, 'migrate is the instance scaffold script, i.e. the core migrate.sh');
|
|
46
53
|
});
|
|
47
54
|
|
|
48
55
|
test('the control-plane runner targets the instance database, not its own', () => {
|
|
49
|
-
const { cmd } = upgradeInvocation(tenant, '1.19.940', { privileged: true, runAs: 'appsvc' });
|
|
56
|
+
const { cmd } = upgradeInvocation(tenant, '1.19.940', { privileged: true, runAs: 'appsvc', migrateAs: 'appsvc' });
|
|
50
57
|
assert.match(cmd, new RegExp(`PGDATABASE=${dbName(tenant)} `));
|
|
51
58
|
assert.match(cmd, /upgrade\.js/);
|
|
52
59
|
assert.doesNotMatch(cmd, /--skip-migrate/, 'the Deploy door never skips the schema');
|
|
60
|
+
assert.doesNotMatch(cmd, /--migrate-as/, 'one account for both halves emits no flag — unchanged from before task 1004512');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('a project with its OWN account is migrated as it, whoever the move runs as (task 1004512)', () => {
|
|
64
|
+
// Naming the right database is only half of reaching it; the other half is connecting as
|
|
65
|
+
// a role that may. The runner used to solve both by running the WHOLE move as the
|
|
66
|
+
// project's account — which can only READ its checkout, so the move died at `git status`
|
|
67
|
+
// and the schema moved nowhere. Splitting the two keeps the database leg where it was.
|
|
68
|
+
const { cmd } = upgradeInvocation(tenant, '1.19.940', { privileged: true, runAs: 'appsvc', migrateAs: 'bongos-charter' });
|
|
69
|
+
assert.match(cmd, new RegExp(`PGDATABASE=${dbName(tenant)} `), 'still the instance database');
|
|
70
|
+
assert.match(cmd, /--migrate-as bongos-charter\b/, 'and now also the account that owns it');
|
|
53
71
|
});
|
|
54
72
|
|
|
55
73
|
test('provisioning migrates the new instance database at stand-up', () => {
|
package/tests/upgrade.mjs
CHANGED
|
@@ -1926,6 +1926,153 @@ t('parseArgs: --allow-downgrade is read, and is OFF unless asked for', () => {
|
|
|
1926
1926
|
assert.equal(u.parseArgs(['--to', '1.0.0', '--allow-downgrade']).allowDowngrade, true);
|
|
1927
1927
|
});
|
|
1928
1928
|
|
|
1929
|
+
// ---- 11. WHICH ACCOUNT DOES WHAT (task 1004512) -----------------------------
|
|
1930
|
+
//
|
|
1931
|
+
// Found live on cloudbongos.com: every core move on a project provisioned since task
|
|
1932
|
+
// 1003369 failed, and reported a dirty tree it had invented. The move ran wholly as the
|
|
1933
|
+
// project's own `bongos-<slug>` account, which can only READ the checkout, so `git status`
|
|
1934
|
+
// refused with `detected dubious ownership`, the clean-tree pre-flight read that as dirt,
|
|
1935
|
+
// the self-heal chain committed a tree nobody had touched, and the retry failed identically.
|
|
1936
|
+
// Both halves are pinned here: git's refusal is its own failure, and the database steps run
|
|
1937
|
+
// as the account that actually owns the database.
|
|
1938
|
+
|
|
1939
|
+
t('a `git status` that could NOT ANSWER is not reported as uncommitted work', async () => {
|
|
1940
|
+
const dir = scratchConsumer();
|
|
1941
|
+
const res = await u.runUpgrade({ to: '1.15.0', instance: dir }, {
|
|
1942
|
+
log: () => {}, err: () => {},
|
|
1943
|
+
run: (cmd, args) => (cmd === 'git' && args[0] === 'status'
|
|
1944
|
+
? { status: 128, stdout: '', stderr: `fatal: detected dubious ownership in repository at '${dir}'\n` }
|
|
1945
|
+
: { status: 0 }),
|
|
1946
|
+
});
|
|
1947
|
+
assert.equal(res.ok, false, 'it still refuses — nothing is known about the tree');
|
|
1948
|
+
assert.match(res.error, /git could not read the working tree/, 'and says which question went unanswered');
|
|
1949
|
+
assert.match(res.error, /dubious ownership/, 'carrying git\'s OWN words, not "not a git repo?"');
|
|
1950
|
+
assert.doesNotMatch(res.error, /^working tree not clean/m, 'it must never claim there is uncommitted work');
|
|
1951
|
+
});
|
|
1952
|
+
|
|
1953
|
+
t('…and that refusal is classified as unknown, so no remedy commits a guess', async () => {
|
|
1954
|
+
const o = require('../scripts/gds/upgrade-outcome.js');
|
|
1955
|
+
const dir = scratchConsumer();
|
|
1956
|
+
const res = await u.runUpgrade({ to: '1.15.0', instance: dir }, {
|
|
1957
|
+
log: () => {}, err: () => {},
|
|
1958
|
+
run: (cmd, args) => (cmd === 'git' && args[0] === 'status'
|
|
1959
|
+
? { status: 128, stdout: '', stderr: 'fatal: detected dubious ownership in repository\n' }
|
|
1960
|
+
: { status: 0 }),
|
|
1961
|
+
});
|
|
1962
|
+
// The whole point: dirty_tree is known_wedge, and wedge-remedy.js answers it by running
|
|
1963
|
+
// `git add -u && git commit`. Doing that to a tree git would not describe commits whatever
|
|
1964
|
+
// happens to be lying there, under a runner identity, in someone else's repository.
|
|
1965
|
+
const reason = o.upgradeReason(res);
|
|
1966
|
+
assert.equal(reason, 'git_unreadable', `got ${reason} — a failed "git status" is not a dirty tree`);
|
|
1967
|
+
assert.equal(o.classifyFailure(reason).class, 'unknown', 'unknown reaches a person; known_wedge would "fix" it');
|
|
1968
|
+
assert.notEqual(o.classifyFailure(reason).class, 'known_wedge');
|
|
1969
|
+
});
|
|
1970
|
+
|
|
1971
|
+
t('--migrate-as hands the DATABASE steps to another account, and nothing else', async () => {
|
|
1972
|
+
const dir = scratchConsumer();
|
|
1973
|
+
const calls = [];
|
|
1974
|
+
const res = await u.runUpgrade(
|
|
1975
|
+
{ to: '1.15.0', registry: true, instance: dir, migrateAs: 'bongos-p', healthCheck: false },
|
|
1976
|
+
{
|
|
1977
|
+
log: () => {}, err: () => {},
|
|
1978
|
+
run: (cmd, args) => {
|
|
1979
|
+
calls.push(`${cmd} ${(args || []).join(' ')}`.trim());
|
|
1980
|
+
if (cmd === 'git' && args[0] === 'status') return { status: 0, stdout: '' };
|
|
1981
|
+
if (cmd === 'npm' && args[0] === 'install') {
|
|
1982
|
+
writeFileSync(join(dir, 'node_modules', '@bongos', 'core', 'package.json'),
|
|
1983
|
+
JSON.stringify({ name: '@bongos/core', version: '1.15.0' }) + '\n');
|
|
1984
|
+
}
|
|
1985
|
+
return { status: 0, stdout: '' };
|
|
1986
|
+
},
|
|
1987
|
+
materialize: () => ({ skills: 0, writtenPaths: [] }),
|
|
1988
|
+
regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
|
|
1989
|
+
resolveInstanceDb: () => ({ db: 'p', ran: true }),
|
|
1990
|
+
databaseUrl: '', pgDatabase: '',
|
|
1991
|
+
}
|
|
1992
|
+
);
|
|
1993
|
+
assert.equal(res.ok, true, `the bump ran: ${res.error || ''}`);
|
|
1994
|
+
const migrate = calls.find((c) => /\bnpm run migrate\b/.test(c));
|
|
1995
|
+
assert.ok(migrate, `migrate ran: ${JSON.stringify(calls)}`);
|
|
1996
|
+
assert.match(migrate, /^sudo -n -u bongos-p env /, 'migrate connects as the account that owns the database');
|
|
1997
|
+
const identity = calls.find((c) => /psql -tAc/.test(c));
|
|
1998
|
+
assert.match(identity, /^sudo -n -u bongos-p env /, 'and so does the pre-check that decides whether it could');
|
|
1999
|
+
// The other half of the split: the file work stays with whoever is running, because the
|
|
2000
|
+
// project's own account cannot write this checkout.
|
|
2001
|
+
for (const bare of ['git status', 'npm install']) {
|
|
2002
|
+
assert.ok(calls.some((c) => c.startsWith(bare)), `${bare} runs as the move itself, unescalated`);
|
|
2003
|
+
assert.ok(!calls.some((c) => c.includes(`bongos-p env ${bare}`)), `${bare} must NOT be handed to the project's account`);
|
|
2004
|
+
}
|
|
2005
|
+
});
|
|
2006
|
+
|
|
2007
|
+
t('with no --migrate-as, every step is spawned exactly as it was before', async () => {
|
|
2008
|
+
// The shape half the fleet is on (a control plane, a project older than the per-instance
|
|
2009
|
+
// account model). A flag that quietly started escalating there would be a new failure.
|
|
2010
|
+
const dir = scratchConsumer();
|
|
2011
|
+
const calls = [];
|
|
2012
|
+
await u.runUpgrade(
|
|
2013
|
+
{ to: '1.15.0', registry: true, instance: dir, healthCheck: false },
|
|
2014
|
+
{
|
|
2015
|
+
log: () => {}, err: () => {},
|
|
2016
|
+
run: (cmd, args) => {
|
|
2017
|
+
calls.push(`${cmd} ${(args || []).join(' ')}`.trim());
|
|
2018
|
+
if (cmd === 'npm' && args[0] === 'install') {
|
|
2019
|
+
writeFileSync(join(dir, 'node_modules', '@bongos', 'core', 'package.json'),
|
|
2020
|
+
JSON.stringify({ name: '@bongos/core', version: '1.15.0' }) + '\n');
|
|
2021
|
+
}
|
|
2022
|
+
return { status: 0, stdout: '' };
|
|
2023
|
+
},
|
|
2024
|
+
materialize: () => ({ skills: 0, writtenPaths: [] }),
|
|
2025
|
+
regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
|
|
2026
|
+
resolveInstanceDb: () => ({ db: 'p', ran: true }),
|
|
2027
|
+
databaseUrl: '', pgDatabase: '',
|
|
2028
|
+
}
|
|
2029
|
+
);
|
|
2030
|
+
assert.ok(!calls.some((c) => c.startsWith('sudo')), `nothing escalated: ${JSON.stringify(calls.filter((c) => c.startsWith('sudo')))}`);
|
|
2031
|
+
assert.ok(calls.some((c) => /^npm run migrate$/.test(c)), 'migrate is still the bare command');
|
|
2032
|
+
});
|
|
2033
|
+
|
|
2034
|
+
t('sudo refusing the hand-over is caught BEFORE the pin moves, not at migrate', async () => {
|
|
2035
|
+
// The one failure this split can add. Soft-skipping it (as "psql failed for another
|
|
2036
|
+
// reason") would move the pin, install the new core, and then fail at migrate for a
|
|
2037
|
+
// reason that was knowable up front — a full rollback, restarting a live service twice,
|
|
2038
|
+
// to learn something one `select 1` already said.
|
|
2039
|
+
const dir = scratchConsumer();
|
|
2040
|
+
const calls = [];
|
|
2041
|
+
const res = await u.runUpgrade(
|
|
2042
|
+
{ to: '1.15.0', registry: true, instance: dir, migrateAs: 'bongos-p', healthCheck: false },
|
|
2043
|
+
{
|
|
2044
|
+
log: () => {}, err: () => {},
|
|
2045
|
+
run: (cmd, args) => {
|
|
2046
|
+
calls.push(`${cmd} ${(args || []).join(' ')}`.trim());
|
|
2047
|
+
if (cmd === 'sudo') return { status: 1, stdout: '', stderr: 'sudo: a password is required\n' };
|
|
2048
|
+
return { status: 0, stdout: '' };
|
|
2049
|
+
},
|
|
2050
|
+
resolveInstanceDb: () => ({ db: 'p', ran: true }),
|
|
2051
|
+
materialize: () => ({ skills: 0, writtenPaths: [] }),
|
|
2052
|
+
databaseUrl: '', pgDatabase: '',
|
|
2053
|
+
}
|
|
2054
|
+
);
|
|
2055
|
+
assert.equal(res.ok, false);
|
|
2056
|
+
assert.match(res.error, /cannot run as "bongos-p"/);
|
|
2057
|
+
assert.match(res.error, /a password is required/, 'carrying what sudo actually said');
|
|
2058
|
+
assert.ok(!calls.some((c) => c.startsWith('npm install')), 'and nothing was installed — the pin never moved');
|
|
2059
|
+
assert.equal(u.readPinnedCoreVersion(dir), '1.14.1', 'the pin is untouched');
|
|
2060
|
+
});
|
|
2061
|
+
|
|
2062
|
+
t('an account name that is not one is refused before anything moves', () => {
|
|
2063
|
+
// It reaches `sudo` as an argv entry, so it is refused rather than quoted into shape.
|
|
2064
|
+
return u.runUpgrade({ to: '1.15.0', instance: scratchConsumer(), migrateAs: 'root; rm -rf /' }, { log: () => {}, err: () => {} })
|
|
2065
|
+
.then((res) => {
|
|
2066
|
+
assert.equal(res.ok, false);
|
|
2067
|
+
assert.match(res.error, /not a unix account name/);
|
|
2068
|
+
});
|
|
2069
|
+
});
|
|
2070
|
+
|
|
2071
|
+
t('parseArgs: --migrate-as is read, and is absent unless passed', () => {
|
|
2072
|
+
assert.equal(u.parseArgs(['--to', '1.0.0']).migrateAs, null);
|
|
2073
|
+
assert.equal(u.parseArgs(['--to', '1.0.0', '--migrate-as', 'bongos-p']).migrateAs, 'bongos-p');
|
|
2074
|
+
});
|
|
2075
|
+
|
|
1929
2076
|
await Promise.all(pending);
|
|
1930
2077
|
console.log(`\nupgrade: ${passed} passed, ${failed} failed`);
|
|
1931
2078
|
process.exit(failed ? 1 : 0);
|
|
@@ -33,6 +33,9 @@ const TABLE = [
|
|
|
33
33
|
['downgrade refusal', { ok: false, downgrade: true, error: 'refusing to DOWNGRADE: --to 1.19.1 is older' }, 'refused', 'downgrade', 'needs_decision'],
|
|
34
34
|
['already on the target', { ok: false, error: 'already on 1.20.1 (pass --force to re-run the steps anyway)' }, 'refused', 'already_on', 'unknown'],
|
|
35
35
|
['dirty working tree', { ok: false, error: 'working tree not clean — commit/stash first, or pass --force.\n M package.json' }, 'refused', 'dirty_tree', 'known_wedge'],
|
|
36
|
+
// task 1004512: git REFUSING to read the checkout is not a report that the checkout is
|
|
37
|
+
// dirty, and must not be classified as one — see the dedicated section at the end.
|
|
38
|
+
['git would not read the tree', { ok: false, error: 'git could not read the working tree in /srv/cloudbongos/p — the clean-tree pre-flight never ran, so this is NOT a report of uncommitted work.\nfatal: detected dubious ownership in repository at \'/srv/cloudbongos/p\'' }, 'refused', 'git_unreadable', 'unknown'],
|
|
36
39
|
['module pre-check', { ok: false, error: 'module pre-check failed: game incompatible with core 1.20.1' }, 'refused', 'module_incompatible', 'needs_decision'],
|
|
37
40
|
['database unresolvable', { ok: false, error: 'cannot determine which database to migrate.\n Set PGDATABASE' }, 'refused', 'db_unresolvable', 'unknown'],
|
|
38
41
|
['database identity', { ok: false, error: 'database "cb" is not reachable as the current user: role "root" does not exist' }, 'refused', 'db_identity', 'unknown'],
|
package/tests/wedge_remedy.mjs
CHANGED
|
@@ -37,10 +37,10 @@ const intent = (over = {}) => ({ id: 99, action: 'core-upgrade', target_version:
|
|
|
37
37
|
const wedge = (reason) => ({ ok: false, error: `core upgrade to 1.20.31 was refused (${reason})`, failure: { class: 'known_wedge', reason } });
|
|
38
38
|
const selfRoot = () => '/srv/charter';
|
|
39
39
|
|
|
40
|
-
function harness({ execFails = false } = {}) {
|
|
40
|
+
function harness({ execFails = false, privileged = false } = {}) {
|
|
41
41
|
const ran = []; const events = []; const queries = [];
|
|
42
42
|
const deps = {
|
|
43
|
-
apply: true, privileged
|
|
43
|
+
apply: true, privileged, log: () => {}, selfInstanceRoot: selfRoot,
|
|
44
44
|
exec: (cmd, opts) => { ran.push({ cmd, ...opts }); return execFails ? { ok: false, softFailed: true, stderr: 'fatal: nope' } : { ok: true, stdout: '' }; },
|
|
45
45
|
db: { query: async (sql, params) => { queries.push({ sql, params }); return { rows: [] }; } },
|
|
46
46
|
provisioning: { recordEvent: async (_db, e) => { events.push(e); } },
|
|
@@ -143,6 +143,40 @@ for (const reason of W.REMEDY_REASONS) {
|
|
|
143
143
|
});
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
await t('schema_pending on a PRIVILEGED runner migrates as the account that owns the DATABASE (task 1004512)', async () => {
|
|
147
|
+
// THE ONE PATH THAT CALLS runAsFor, and until this test nothing did: every other case
|
|
148
|
+
// above runs unprivileged, where planRemedy takes its no-sudo branch and the account is
|
|
149
|
+
// never resolved at all. So the account-selection change this task is about — runAsFor
|
|
150
|
+
// reading `migrateAs` instead of `runAs` — shipped with no coverage.
|
|
151
|
+
//
|
|
152
|
+
// The two answers are DIFFERENT accounts, and only one of them can connect: the project's
|
|
153
|
+
// database is owned by its own Postgres login role with CONNECT revoked from PUBLIC, so a
|
|
154
|
+
// migration run as the app user (which owns the checkout) fails outright. Asserting the
|
|
155
|
+
// assembled command, because that is where the wrong account would actually show up.
|
|
156
|
+
const h = harness({ privileged: true });
|
|
157
|
+
const r = await W.remedyAfterFailure({ intent: intent(), inst: inst(), result: wedge('schema_pending'), deps: h.deps });
|
|
158
|
+
assert.deepEqual(r, { repended: true });
|
|
159
|
+
// Two commands now: the `id -u` probe resolveRunAs spends to learn whether the project
|
|
160
|
+
// HAS an account of its own, then the migration itself.
|
|
161
|
+
assert.ok(h.ran.some((c) => /^id -u bongos-charter$/.test(c.cmd)), `the account is probed, not assumed: ${JSON.stringify(h.ran.map((c) => c.cmd))}`);
|
|
162
|
+
const migrate = (h.ran.find((c) => /migrate/.test(c.cmd)) || {}).cmd || '';
|
|
163
|
+
assert.match(migrate, /^sudo -u bongos-charter env PGDATABASE=charter /,
|
|
164
|
+
`must migrate as the project's own account — got: ${migrate}`);
|
|
165
|
+
assert.doesNotMatch(migrate, /sudo -u lars\b/,
|
|
166
|
+
'the app user owns the checkout, not the database — it has no role to connect with');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
await t('…and the OTHER two fixes never escalate, privileged or not', async () => {
|
|
170
|
+
// The split is per-step, not per-run: dirty_tree touches the checkout and disk_full
|
|
171
|
+
// touches the backup dir, both of which belong to the runner. A sudo appearing here
|
|
172
|
+
// would mean the account change had leaked beyond the database.
|
|
173
|
+
for (const reason of ['dirty_tree', 'disk_full']) {
|
|
174
|
+
const h = harness({ privileged: true });
|
|
175
|
+
await W.remedyAfterFailure({ intent: intent(), inst: inst(), result: wedge(reason), deps: h.deps });
|
|
176
|
+
assert.doesNotMatch(h.ran[0].cmd, /sudo/, `${reason} runs as the runner, which owns what it touches`);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
146
180
|
await t('the same reason again after a fix: no second fix — retired as unknown for a person', async () => {
|
|
147
181
|
const h = harness();
|
|
148
182
|
const r = await W.remedyAfterFailure({ intent: intent({ remedied_reason: 'dirty_tree' }), inst: inst(), result: wedge('dirty_tree'), deps: h.deps });
|