@bongos/core 1.21.116 → 1.21.118
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 +68 -33
- package/docs/adr/0293-the-owners-deploy-door-is-an-intent-the-control-plane-drains.md +2 -1
- package/docs/adr/0350-the-hub-holds-a-per-project-write-deploy-key-so-a-hosted-upgrade-reaches-github.md +34 -0
- package/docs/adr/0381-the-hub-moves-each-hosted-project-by-its-update-rule.md +34 -0
- package/docs/adr/README.md +1 -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 +3 -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 +20 -0
- package/scripts/gds/fitness-lib.js +35 -0
- package/scripts/gds/fitness.js +2 -2
- package/scripts/gds/provision-auto-upgrade.js +139 -0
- package/scripts/gds/provision-core-upgrade.js +26 -7
- package/scripts/gds/provision-pin-replay.js +126 -0
- package/scripts/gds/provision-repo.js +60 -0
- package/scripts/gds/provision.js +7 -1
- package/scripts/gds/run-unit-tests.js +4 -0
- package/scripts/gds/upgrade-outcome.js +4 -0
- package/scripts/gds/wedge-remedy.js +13 -2
- package/src/module-api.js +1 -1
- package/tests/core_auto_upgrade.mjs +189 -0
- package/tests/db_ownership_handover.mjs +156 -0
- package/tests/fitness_installed_core.mjs +127 -0
- package/tests/pin_replay.mjs +147 -0
- package/tests/pin_write_key.mjs +26 -0
- package/tests/provision.mjs +34 -0
- package/tests/upgrade_migrate.mjs +10 -0
- package/tests/wedge_remedy.mjs +23 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// scripts/gds/provision-auto-upgrade.js — the hub moves each hosted project's core on
|
|
4
|
+
// its own, by that project's update rule (task 1004729, ADR 0381).
|
|
5
|
+
//
|
|
6
|
+
// WHY THIS EXISTS. Every runner tick, snapshotAllCoreVersions works out `recommended`
|
|
7
|
+
// for every active cloud-host under its own rule (pinned / patch / minor, follow
|
|
8
|
+
// released) — and then stopped. A move only happened when someone pressed the button on
|
|
9
|
+
// /deploy, so a project on `patch` sat on 1.21.20 for 11 days while 1.21.69 was out
|
|
10
|
+
// (toast, 2026-10). The rule already IS the owner's decision about taking updates; this
|
|
11
|
+
// step acts on it, with the same two steps the button uses:
|
|
12
|
+
//
|
|
13
|
+
// 1. behind, not pinned, no open intent, target not held → queue a
|
|
14
|
+
// `core-upgrade-preflight` for `recommended` (the dry run; changes nothing);
|
|
15
|
+
// 2. that preflight PASSED for that same target → queue the `core-upgrade` to it.
|
|
16
|
+
//
|
|
17
|
+
// Every intent goes through provisioning.enqueueIntent, exactly as the runner's own
|
|
18
|
+
// reconcile does, so the control-plane guard and the single-open-intent slot apply, and
|
|
19
|
+
// /deploy shows these moves like any other (requested_by 'runner:auto-upgrade').
|
|
20
|
+
//
|
|
21
|
+
// MINIMAL FAILURE HOLD. A passing preflight does not run migrations (toast's 1.21.115
|
|
22
|
+
// passed its preflight and failed at migrate, intent 267), so without a hold this would
|
|
23
|
+
// re-queue a target that already failed, every tick, forever. A target is held for an
|
|
24
|
+
// instance when it already has, FOR THAT TARGET: a core-upgrade that ended (error, or
|
|
25
|
+
// done but the box is still behind), a preflight intent that errored, or a stored
|
|
26
|
+
// preflight verdict that refused. A newer release is a new target and is not held. The
|
|
27
|
+
// richer hold — telling the project owner, releasing it — is task 1004730.
|
|
28
|
+
//
|
|
29
|
+
// Carved into its own file because provision.js and provision-core-upgrade.js sit at
|
|
30
|
+
// the size ratchet. Best-effort: nothing here may fail the tick.
|
|
31
|
+
|
|
32
|
+
const { resolveEnv } = require('../../src/instance-config');
|
|
33
|
+
|
|
34
|
+
// At most this many automatic intents per tick, across ALL projects, so a release never
|
|
35
|
+
// restarts every hosted project at once. The tick runs every few minutes; one is plenty.
|
|
36
|
+
const AUTO_MOVES_PER_TICK = 1;
|
|
37
|
+
|
|
38
|
+
const REQUESTED_BY = 'runner:auto-upgrade';
|
|
39
|
+
const OPEN_STATES = new Set(['pending', 'running']);
|
|
40
|
+
const OFF_VALUES = new Set(['off', '0', 'false', 'no', 'disabled']);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The platform-wide off switch: `<PREFIX>_AUTO_CORE_UPGRADE` (BONGOS_AUTO_CORE_UPGRADE on
|
|
44
|
+
* a vanilla hub) in the runner's environment. Unset means ON — the owner asked for this
|
|
45
|
+
* behaviour (2026-10-14). off / 0 / false / no / disabled stops every automatic move; the
|
|
46
|
+
* per-project `pinned` rule stays the project-level off switch. PURE over `env`.
|
|
47
|
+
*/
|
|
48
|
+
function autoUpgradeEnabled(env = process.env) {
|
|
49
|
+
const v = resolveEnv('AUTO_CORE_UPGRADE', { env, warn: () => {}, warnPack: () => {} });
|
|
50
|
+
if (v === undefined || v === null || String(v).trim() === '') return true;
|
|
51
|
+
return !OFF_VALUES.has(String(v).trim().toLowerCase());
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseVerdict(p) {
|
|
55
|
+
if (!p) return null;
|
|
56
|
+
if (typeof p === 'string') { try { return JSON.parse(p); } catch { return null; } }
|
|
57
|
+
return p;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* What, if anything, to queue for one project. PURE.
|
|
62
|
+
* @param {object} inst provisioning_instances row joined with its core-version snapshot
|
|
63
|
+
* @param {object[]} intents that instance's intents (open ones + core-upgrade history)
|
|
64
|
+
* @param {object} channelMod update-channel.js
|
|
65
|
+
* @returns {{ action: string, to: string } | { skip: string }}
|
|
66
|
+
*/
|
|
67
|
+
function decideAutoMove(inst, intents, channelMod) {
|
|
68
|
+
if (inst.hosting_shape !== 'cloud-host') return { skip: 'not a hosted project' }; // never the control-plane row
|
|
69
|
+
if (inst.status !== 'active') return { skip: 'not active' };
|
|
70
|
+
if (channelMod.normalizeChannel(inst.update_channel) === 'pinned') return { skip: 'pinned' };
|
|
71
|
+
const served = inst.served_version;
|
|
72
|
+
const to = inst.recommended;
|
|
73
|
+
if (!served || !to || channelMod.compareSemver(to, served) <= 0) return { skip: 'current' };
|
|
74
|
+
if (intents.some((i) => OPEN_STATES.has(i.state))) return { skip: 'open intent' };
|
|
75
|
+
const same = (i) => String(i.target_version || '') === String(to);
|
|
76
|
+
const held = intents.some((i) => same(i) && (
|
|
77
|
+
(i.action === 'core-upgrade' && (i.state === 'error' || i.state === 'done'))
|
|
78
|
+
|| (i.action === 'core-upgrade-preflight' && i.state === 'error')));
|
|
79
|
+
const verdict = parseVerdict(inst.preflight);
|
|
80
|
+
const verdictForTarget = verdict && String(verdict.target || '') === String(to) ? verdict : null;
|
|
81
|
+
if (held || (verdictForTarget && verdictForTarget.ok !== true)) return { skip: `held: ${to} already failed here` };
|
|
82
|
+
if (verdictForTarget) return { action: 'core-upgrade', to };
|
|
83
|
+
return { action: 'core-upgrade-preflight', to };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Queue at most AUTO_MOVES_PER_TICK automatic core intents. Runs after the snapshot on an
|
|
88
|
+
* --apply tick. Never throws.
|
|
89
|
+
*/
|
|
90
|
+
async function autoUpgradeTick(deps) {
|
|
91
|
+
const { db, provisioning, log = () => {} } = deps;
|
|
92
|
+
if (!autoUpgradeEnabled(deps.env || process.env)) {
|
|
93
|
+
log('auto core-upgrade: off (AUTO_CORE_UPGRADE) — no automatic moves');
|
|
94
|
+
return { queued: 0, off: true };
|
|
95
|
+
}
|
|
96
|
+
const channelMod = deps.updateChannel || require('./update-channel.js');
|
|
97
|
+
let queued = 0;
|
|
98
|
+
try {
|
|
99
|
+
const { rows } = await db.query(
|
|
100
|
+
`SELECT i.id, i.slug, i.owner_builder_id, i.hosting_shape, i.status, i.update_channel,
|
|
101
|
+
s.served_version, s.recommended, s.preflight
|
|
102
|
+
FROM provisioning_instances i
|
|
103
|
+
JOIN provisioning_core_version_snapshot s ON s.instance_id = i.id
|
|
104
|
+
WHERE i.status = 'active' AND i.hosting_shape = 'cloud-host' AND s.recommended IS NOT NULL
|
|
105
|
+
ORDER BY i.id`);
|
|
106
|
+
if (!rows.length) return { queued };
|
|
107
|
+
const { rows: intents } = await db.query(
|
|
108
|
+
`SELECT instance_id, action, state, target_version FROM provisioning_intents
|
|
109
|
+
WHERE instance_id = ANY($1)
|
|
110
|
+
AND (state IN ('pending', 'running')
|
|
111
|
+
OR (action IN ('core-upgrade', 'core-upgrade-preflight') AND target_version IS NOT NULL))`,
|
|
112
|
+
[rows.map((r) => r.id)]);
|
|
113
|
+
for (const inst of rows) {
|
|
114
|
+
if (queued >= AUTO_MOVES_PER_TICK) break;
|
|
115
|
+
const mine = intents.filter((i) => String(i.instance_id) === String(inst.id));
|
|
116
|
+
const d = decideAutoMove(inst, mine, channelMod);
|
|
117
|
+
if (d.skip) { if (d.skip.startsWith('held')) log(` auto core-upgrade: ${inst.slug} ${d.skip}`); continue; }
|
|
118
|
+
try {
|
|
119
|
+
const r = await provisioning.enqueueIntent(db, inst.id, d.action, REQUESTED_BY, d.to);
|
|
120
|
+
if (!r || !r.created) continue; // lost a race for the open slot — not spent
|
|
121
|
+
queued++;
|
|
122
|
+
log(` auto core-upgrade: ${inst.slug} ${inst.served_version} → ${d.to} (${d.action} queued)`);
|
|
123
|
+
if (provisioning.recordEvent) {
|
|
124
|
+
await provisioning.recordEvent(db, {
|
|
125
|
+
instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: d.action, actor: REQUESTED_BY,
|
|
126
|
+
detail: d.action === 'core-upgrade' ? `automatic core move to ${d.to} (update rule)` : `automatic pre-flight check for ${d.to} (update rule)`,
|
|
127
|
+
}).catch(() => {});
|
|
128
|
+
}
|
|
129
|
+
} catch (e) {
|
|
130
|
+
log(` auto core-upgrade: ${inst.slug} not queued (${(e && e.message) || e})`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} catch (e) {
|
|
134
|
+
log(` auto core-upgrade skipped (${(e && e.message) || e})`);
|
|
135
|
+
}
|
|
136
|
+
return { queued };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = { AUTO_MOVES_PER_TICK, autoUpgradeEnabled, autoUpgradeTick };
|
|
@@ -36,6 +36,7 @@ const { spawnSync } = require('child_process');
|
|
|
36
36
|
const { CONFIG, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, REPO_ROOT } = require('./provision-config.js');
|
|
37
37
|
const { appUser, dbName, instanceUser, parseTargetRef, resolveOwnerGithubToken, selfInstanceRoot, standaloneRoot } = require('./provision-repo.js');
|
|
38
38
|
const { ensurePinWriteKey, hasOwnAccount, pinKeyPath, pinSshCommand } = require('./provision-pin-key.js');
|
|
39
|
+
const { replayPinCommit } = require('./provision-pin-replay.js'); // GitHub ahead → pin on top (task 1004733)
|
|
39
40
|
const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
|
|
40
41
|
const { RELEASE_NOTES_FILE, RELEASE_NOTES_MAX_BYTES, parseChangeRecords, notesBetween } = require('./release-notes.js');
|
|
41
42
|
const outcome = require('./upgrade-outcome.js'); // how far a failed move got, as a class (task 1004446)
|
|
@@ -411,9 +412,13 @@ async function coreUpgradeInstance(inst, deps, intent) {
|
|
|
411
412
|
// parseRepoNote in modules/provisioning/core-upgrade.js (re-stated there because a
|
|
412
413
|
// module may not require a script; tests/core_upgrade_runner.mjs round-trips the two).
|
|
413
414
|
//
|
|
414
|
-
// NOTHING IS FORCED
|
|
415
|
-
//
|
|
416
|
-
//
|
|
415
|
+
// NOTHING IS FORCED, AND THE LIVE BOX IS NEVER REBASED. When GitHub has commits the box
|
|
416
|
+
// does not, moving the box onto them would change what it runs with no restart and no
|
|
417
|
+
// health check. Since task 1004733 the pin commit ALONE is replayed onto GitHub's history in
|
|
418
|
+
// a scratch worktree and pushed as an ordinary fast-forward (provision-pin-replay.js), so
|
|
419
|
+
// GitHub names the new core and the box stays exactly what the move proved. A conflict in
|
|
420
|
+
// the pin files is reported ("conflict"), never resolved here. The box still does not pick
|
|
421
|
+
// up the owner's other commits — that would be a deploy, not a pin push (ADR 0350 amendment).
|
|
417
422
|
//
|
|
418
423
|
// WHAT MAKES IT DURABLE (task 1004291, ADR 0350). The owner's token is kept for an hour
|
|
419
424
|
// after they sign in with repository access (GITHUB_TOKEN_TTL_MS in src/bongos/db.js — one
|
|
@@ -431,6 +436,7 @@ const REPO_NOTES = Object.freeze({
|
|
|
431
436
|
needs_account: 'The hub could not set up lasting write access to the project\'s GitHub repository: this project needs its own account on the server first. Once it has one, reconnecting GitHub with repository access sets up lasting access.',
|
|
432
437
|
refused: 'GitHub turned down the owner\'s GitHub access — it may have expired, or lack permission to write to the repository.',
|
|
433
438
|
behind: 'GitHub has changes the server does not have, so the new version could not be added on top of them. An operator needs to bring the two back in line; the site itself is running normally.',
|
|
439
|
+
conflict: 'GitHub has its own changes to the same version files (package.json or package-lock.json), so the new version was not added on top of them and nothing there was overwritten. An operator needs to choose between the two; the site itself is running normally.',
|
|
434
440
|
failed: 'The hub could not save the new version to GitHub.',
|
|
435
441
|
});
|
|
436
442
|
// A branch name that is safe to interpolate. Narrower than git's own ref grammar on
|
|
@@ -498,6 +504,14 @@ async function pushUpgradePin(inst, deps, { instanceDir }) {
|
|
|
498
504
|
const at = `${repo.owner}/${repo.name}@${branch}`;
|
|
499
505
|
const hardened = `git -C ${instanceDir} -c core.hooksPath=/dev/null`;
|
|
500
506
|
const env = { GIT_TERMINAL_PROMPT: '0' }; // a refused credential fails; it never waits at a prompt
|
|
507
|
+
// GitHub is ahead: replay the pin commit alone on top, over the credential that was just
|
|
508
|
+
// refused as behind, and push again — never forced (task 1004733, provision-pin-replay.js).
|
|
509
|
+
const replayed = (via, git, remote) => {
|
|
510
|
+
const rr = replayPinCommit({ exec, instanceDir, branch, git, remote, env, slug: inst.slug, tmpDir: deps.tmpDir, pushFault, gitLine, log });
|
|
511
|
+
if (!rr.ok) return miss(rr.code, rr.extra);
|
|
512
|
+
if (rr.already) log(` ✓ ${at} already names this version — nothing to add`);
|
|
513
|
+
return { pushed: true, via, branch, ...(rr.replayed ? { replayed: true } : { already: true }) };
|
|
514
|
+
};
|
|
501
515
|
|
|
502
516
|
const token = await resolveOwnerGithubToken(inst, deps, { tag: '[core-upgrade]' });
|
|
503
517
|
|
|
@@ -515,8 +529,9 @@ async function pushUpgradePin(inst, deps, { instanceDir }) {
|
|
|
515
529
|
return { pushed: true, via: 'write-key', branch };
|
|
516
530
|
}
|
|
517
531
|
const fault = pushFault(r);
|
|
518
|
-
// The key WORKED and the history stopped it: no other credential changes that
|
|
519
|
-
|
|
532
|
+
// The key WORKED and the history stopped it: no other credential changes that, so the
|
|
533
|
+
// pin is replayed on top of GitHub's history over this same key (task 1004733).
|
|
534
|
+
if (fault === 'behind') return replayed('write-key', `${hardened} -c ${pinSshCommand(keyPath)}`, `git@github.com:${repo.owner}/${repo.name}.git`);
|
|
520
535
|
log(` • ${inst.slug}: the write key did not push (${fault}) — trying the next credential`);
|
|
521
536
|
}
|
|
522
537
|
|
|
@@ -525,8 +540,11 @@ async function pushUpgradePin(inst, deps, { instanceDir }) {
|
|
|
525
540
|
let r = null;
|
|
526
541
|
try {
|
|
527
542
|
writeFile(credFile, `https://x-access-token:${token}@github.com\n`, { mode: 0o600 });
|
|
528
|
-
|
|
529
|
-
|
|
543
|
+
const viaToken = `${hardened} -c credential.helper= -c credential.helper='store --file=${credFile}'`;
|
|
544
|
+
const tokenRemote = `https://x-access-token@github.com/${repo.owner}/${repo.name}.git`;
|
|
545
|
+
r = exec(`${viaToken} push ${tokenRemote} HEAD:${branch}`, { allowFail: true, env });
|
|
546
|
+
// Behind: replayed while the credential file still exists (the finally removes it).
|
|
547
|
+
if (failedRun(r) && pushFault(r) === 'behind') return replayed('owner-token', viaToken, tokenRemote);
|
|
530
548
|
} catch (e) {
|
|
531
549
|
return miss('failed', `(could not stage the owner's credential: ${String((e && e.message) || e).slice(0, 160)})`);
|
|
532
550
|
} finally {
|
|
@@ -547,6 +565,7 @@ async function pushUpgradePin(inst, deps, { instanceDir }) {
|
|
|
547
565
|
return { pushed: true, via: 'origin', branch };
|
|
548
566
|
}
|
|
549
567
|
const fault = pushFault(r);
|
|
568
|
+
if (fault === 'behind') return replayed('origin', hardened, 'origin');
|
|
550
569
|
// A refusal here is not the owner's access being turned down — there was none to turn
|
|
551
570
|
// down. It is the read-only key, which is the no-access case.
|
|
552
571
|
// ...unless the project has no account of its own, in which case no key CAN be minted
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// provision-pin-replay.js — when GitHub is AHEAD of a hosted project's box, put the core move's
|
|
3
|
+
// pin commit on top of GitHub's history instead of leaving GitHub on the old core (task 1004733,
|
|
4
|
+
// ADR 0350 amendment).
|
|
5
|
+
//
|
|
6
|
+
// WHY. pushUpgradePin pushes the pin commit the move made on the box. For a project the owner
|
|
7
|
+
// works from a laptop, GitHub almost always has commits the box does not, so that push is
|
|
8
|
+
// refused as non-fast-forward ("behind"). Before this file the move then finished `done` with a
|
|
9
|
+
// repo_not_updated note: the box ran the new core, GitHub still named the old one, and the next
|
|
10
|
+
// deploy from GitHub could quietly roll the core back.
|
|
11
|
+
//
|
|
12
|
+
// WHAT IT DOES. Fetch the branch over the SAME credential that was refused, replay ONLY the pin
|
|
13
|
+
// commit (package.json + package-lock.json, and any generated file upgrade.js committed with it)
|
|
14
|
+
// on top of what was fetched, and push that — an ordinary fast-forward, never forced.
|
|
15
|
+
//
|
|
16
|
+
// THE LIVE BOX IS NOT TOUCHED. The replay happens in a scratch worktree (mode 0700, outside the
|
|
17
|
+
// checkout, removed whatever happened). The box's HEAD, files and running site stay exactly what
|
|
18
|
+
// the move's health check proved, which is why the old "nothing is rebased" rule (changing what
|
|
19
|
+
// the box runs with no restart and no health check) still holds.
|
|
20
|
+
//
|
|
21
|
+
// A REAL CONFLICT STOPS. If the owner changed the same lines of those files, choosing between the
|
|
22
|
+
// two is a person's call: the cherry-pick is aborted, nothing is pushed, and the note names the
|
|
23
|
+
// files ("conflict"). Anything else that goes wrong reports, never retries in a loop.
|
|
24
|
+
//
|
|
25
|
+
// OUT OF SCOPE, ON PURPOSE: the box picking up the owner's OTHER commits. Nothing pulls them onto
|
|
26
|
+
// a hosted box after provisioning (standalonePullCmd runs at provision time only, provision.js);
|
|
27
|
+
// merging them into a live checkout here would deploy the owner's code with no install, migrate,
|
|
28
|
+
// restart or health check — a deploy, not a pin push. ADR 0350's amendment records that ruling.
|
|
29
|
+
//
|
|
30
|
+
// NEVER THROWS: the caller turns a throw into a broken instance, and this instance is running.
|
|
31
|
+
|
|
32
|
+
const crypto = require('node:crypto');
|
|
33
|
+
const os = require('node:os');
|
|
34
|
+
|
|
35
|
+
// The subject upgrade.js persistPin gives the pin commit. Anything else at HEAD is not ours to
|
|
36
|
+
// replay onto someone's history.
|
|
37
|
+
const PIN_SUBJECT_RE = /^pin @bongos\/core /;
|
|
38
|
+
const PIN_FILES = ['package.json', 'package-lock.json'];
|
|
39
|
+
const SHA_RE = /^[0-9a-f]{40,64}$/;
|
|
40
|
+
// A scratch path the shell may see unquoted (the tmp dir itself is the runner's, but checked).
|
|
41
|
+
const SAFE_PATH_RE = /^[A-Za-z0-9_./:~-]+$/;
|
|
42
|
+
|
|
43
|
+
const failedRun = (r) => !r || r.softFailed === true || r.ok === false;
|
|
44
|
+
const out = (r) => String((r && r.stdout) || '').trim();
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Replay the box's pin commit onto the branch GitHub has, and push it.
|
|
48
|
+
*
|
|
49
|
+
* @param {object} o
|
|
50
|
+
* exec the CONTROL PLANE executor (the one pushUpgradePin pushed with)
|
|
51
|
+
* instanceDir the project's checkout on the hub
|
|
52
|
+
* branch the branch pushUpgradePin pushed (already BRANCH_RE-checked)
|
|
53
|
+
* git the credentialed, hardened git prefix that was refused as behind
|
|
54
|
+
* remote the remote that same push named
|
|
55
|
+
* env that push's env (GIT_TERMINAL_PROMPT=0)
|
|
56
|
+
* slug the project's (validated) slug, for the scratch path's name
|
|
57
|
+
* tmpDir where the scratch worktree goes (default os.tmpdir())
|
|
58
|
+
* pushFault, gitLine pushUpgradePin's own readers of a git refusal
|
|
59
|
+
* @returns {{ ok:true, replayed?:true, already?:true } | { ok:false, code:string, extra:string }}
|
|
60
|
+
*/
|
|
61
|
+
function replayPinCommit(o) {
|
|
62
|
+
try { return replay(o); } catch (e) {
|
|
63
|
+
return { ok: false, code: 'failed', extra: `(the pin could not be replayed: ${String((e && e.message) || e).slice(0, 160)})` };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function replay({ exec, instanceDir, branch, git, remote, env, slug, tmpDir, pushFault, gitLine, log = () => {} }) {
|
|
68
|
+
const plain = `git -C ${instanceDir} -c core.hooksPath=/dev/null`;
|
|
69
|
+
const run = (cmd, extraEnv) => exec(cmd, { allowFail: true, env: { ...env, ...(extraEnv || {}) } });
|
|
70
|
+
|
|
71
|
+
// 1. HEAD must be the move's own pin commit, with one parent, or there is nothing of ours to replay.
|
|
72
|
+
const head = run(`${plain} log -1 --format=%H%n%P%n%cn%n%ce%n%s HEAD`);
|
|
73
|
+
const [sha, parents, cn, ce, subject] = out(head).split('\n');
|
|
74
|
+
if (failedRun(head) || !SHA_RE.test(sha || '') || String(parents || '').split(' ').length !== 1 || !PIN_SUBJECT_RE.test(subject || '')) {
|
|
75
|
+
return { ok: false, code: 'behind', extra: '(the newest change on the server is not the version change, so it was not added on top)' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 2. What GitHub has now, over the same credential. Only FETCH_HEAD is written for it.
|
|
79
|
+
const fetched = run(`${git} fetch --no-tags ${remote} ${branch}`);
|
|
80
|
+
if (failedRun(fetched)) return { ok: false, code: 'behind', extra: gitLine(fetched) };
|
|
81
|
+
const upR = run(`${plain} rev-parse FETCH_HEAD`);
|
|
82
|
+
const up = out(upR);
|
|
83
|
+
if (failedRun(upR) || !SHA_RE.test(up)) return { ok: false, code: 'behind', extra: '(could not read what GitHub has)' };
|
|
84
|
+
|
|
85
|
+
// Already pinned the same way upstream (the owner bumped it by hand): nothing to add.
|
|
86
|
+
const same = run(`${plain} diff --quiet ${up} ${sha} -- ${PIN_FILES.join(' ')}`);
|
|
87
|
+
if (!failedRun(same)) return { ok: true, already: true };
|
|
88
|
+
|
|
89
|
+
// 3. Replay in a private scratch worktree, so the live checkout never moves.
|
|
90
|
+
const base = String(tmpDir || os.tmpdir()).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
91
|
+
const wt = `${base}/bongos-pin-replay-${slug}-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
|
|
92
|
+
if (!SAFE_PATH_RE.test(wt)) return { ok: false, code: 'failed', extra: '(no safe scratch folder to replay the version change in)' };
|
|
93
|
+
try {
|
|
94
|
+
const made = run(`umask 077 && mkdir ${wt}`);
|
|
95
|
+
const added = failedRun(made) ? made : run(`${plain} worktree add --detach ${wt} ${up}`);
|
|
96
|
+
if (failedRun(added)) return { ok: false, code: 'failed', extra: gitLine(added) };
|
|
97
|
+
const at = `git -C ${wt} -c core.hooksPath=/dev/null`;
|
|
98
|
+
// The pin commit keeps its author and message; its committer stays who committed it on the
|
|
99
|
+
// box (env, not argv — a name is never shell-interpolated).
|
|
100
|
+
const who = cn && ce ? { GIT_COMMITTER_NAME: cn, GIT_COMMITTER_EMAIL: ce } : {};
|
|
101
|
+
const picked = run(`${at} cherry-pick ${sha}`, who);
|
|
102
|
+
if (failedRun(picked)) {
|
|
103
|
+
const both = out(run(`${at} diff --name-only --diff-filter=U`)).split('\n').filter(Boolean);
|
|
104
|
+
run(`${at} cherry-pick --abort`);
|
|
105
|
+
if (both.length) return { ok: false, code: 'conflict', extra: `(changed on both sides: ${both.join(', ').slice(0, 200)})` };
|
|
106
|
+
return { ok: false, code: 'failed', extra: gitLine(picked) };
|
|
107
|
+
}
|
|
108
|
+
const nextR = run(`${at} rev-parse HEAD`);
|
|
109
|
+
const next = out(nextR);
|
|
110
|
+
if (failedRun(nextR) || !SHA_RE.test(next)) return { ok: false, code: 'failed', extra: '(the replayed version change could not be read back)' };
|
|
111
|
+
|
|
112
|
+
// 4. A plain fast-forward of GitHub's branch to the replayed commit. A second refusal
|
|
113
|
+
// (someone pushed in between) is reported, not chased.
|
|
114
|
+
const pushed = run(`${git} push ${remote} ${next}:refs/heads/${branch}`);
|
|
115
|
+
if (failedRun(pushed)) return { ok: false, code: pushFault(pushed), extra: gitLine(pushed) };
|
|
116
|
+
log(` ✓ GitHub had newer commits: the pin was added on top of them (${next.slice(0, 12)})`);
|
|
117
|
+
return { ok: true, replayed: true };
|
|
118
|
+
} finally {
|
|
119
|
+
// Not --force: the abort above leaves it clean, and the rm + prune catch anything left.
|
|
120
|
+
run(`${plain} worktree remove ${wt}`);
|
|
121
|
+
run(`rm -rf ${wt}`);
|
|
122
|
+
run(`${plain} worktree prune`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = { replayPinCommit };
|
|
@@ -383,6 +383,64 @@ function dbRoleGrantsCmd(inst, { privileged = false } = {}) {
|
|
|
383
383
|
return asPostgres(`psql -v ON_ERROR_STOP=1 -d ${db} -c '${sql}'`, privileged);
|
|
384
384
|
}
|
|
385
385
|
|
|
386
|
+
// Hand OWNERSHIP of everything in the schema to the instance role (task 1004735). The grants
|
|
387
|
+
// above are not enough: GRANT ALL carries no right to ALTER a table or CREATE OR REPLACE a
|
|
388
|
+
// function, which only the OWNER has. Later core moves migrate AS the instance role
|
|
389
|
+
// (--migrate-as, task 1004512), so the first migration that changed an existing table failed
|
|
390
|
+
// with "must be owner of table tasks" and the move rolled back — toast, 2026-10-14.
|
|
391
|
+
//
|
|
392
|
+
// Object by object, never REASSIGN OWNED BY postgres: that also reassigns every SHARED object
|
|
393
|
+
// postgres owns — sibling projects' whole databases included. Skipped on purpose:
|
|
394
|
+
// • objects an EXTENSION owns (pg_depend deptype e) — not the project's, and ALTER errors;
|
|
395
|
+
// • sequences linked to a table column (deptype a/i) — they follow their table, and an
|
|
396
|
+
// ALTER SEQUENCE on one errors;
|
|
397
|
+
// • row types of tables and auto-made array types — they follow their table / element.
|
|
398
|
+
// Idempotent: only objects not already owned by the role are touched, so a re-run is a no-op.
|
|
399
|
+
//
|
|
400
|
+
// Same quoting rule as the commands above: dollar-quoted literals, format(%I)/regclass for
|
|
401
|
+
// identifiers — no single or double quote, so it rides psql -c '…' and bash -s unchanged. And
|
|
402
|
+
// never the text $p$: that marks a password for the runner's log redaction (provision.mjs), which
|
|
403
|
+
// is why kind lists are array literals and a procedure is the ELSE of f/w/a.
|
|
404
|
+
// Run as postgres, in the instance's own database: provision.js step 2b (right after the
|
|
405
|
+
// grants), and the migrate_not_owner remedy in wedge-remedy.js for projects provisioned before.
|
|
406
|
+
function dbOwnershipSql(role, schema = 'public') {
|
|
407
|
+
if (!/^[A-Za-z0-9_-]{1,63}$/.test(String(role || ''))) throw new Error(`dbOwnershipSql: role ${JSON.stringify(role)} is not a plain role name`);
|
|
408
|
+
if (!/^[a-z_][a-z0-9_]{0,62}$/.test(String(schema))) throw new Error(`dbOwnershipSql: schema ${JSON.stringify(schema)} is not a plain schema name`);
|
|
409
|
+
const r = `$r$${role}$r$`;
|
|
410
|
+
const ns = `$s$${schema}$s$::regnamespace`;
|
|
411
|
+
const notExt = (cat, col, types = '$e$e$e$') =>
|
|
412
|
+
`NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.classid = $c$${cat}$c$::regclass AND d.objid = ${col} AND d.deptype IN (${types}))`;
|
|
413
|
+
const k = (x) => `$k$${x}$k$`;
|
|
414
|
+
const t = (x) => `$t$${x}$t$`;
|
|
415
|
+
const anyOf = (list) => '$k${' + list + '}$k$::char[]'; // an array literal, not $k$p$k$ — see the $p$ note above
|
|
416
|
+
return `DO $own$ DECLARE o record; BEGIN ` +
|
|
417
|
+
// tables, partitions, views, materialized views, foreign tables — their indexes, row types
|
|
418
|
+
// and column-owned sequences follow them
|
|
419
|
+
`FOR o IN SELECT c.oid::regclass AS obj, c.relkind FROM pg_class c WHERE c.relnamespace = ${ns} ` +
|
|
420
|
+
`AND c.relkind = ANY (${anyOf('r,p,v,m,f')}) AND c.relowner <> ${r}::regrole AND ${notExt('pg_class', 'c.oid')} LOOP ` +
|
|
421
|
+
`EXECUTE format($f$ALTER %s %s OWNER TO %I$f$, CASE o.relkind WHEN ${k('v')} THEN ${t('VIEW')} WHEN ${k('m')} THEN ${t('MATERIALIZED VIEW')} WHEN ${k('f')} THEN ${t('FOREIGN TABLE')} ELSE ${t('TABLE')} END, o.obj, ${r}); END LOOP; ` +
|
|
422
|
+
// free-standing sequences
|
|
423
|
+
`FOR o IN SELECT c.oid::regclass AS obj FROM pg_class c WHERE c.relnamespace = ${ns} AND c.relkind = ${k('S')} ` +
|
|
424
|
+
`AND c.relowner <> ${r}::regrole AND ${notExt('pg_class', 'c.oid', '$e$e$e$, $e$a$e$, $e$i$e$')} LOOP ` +
|
|
425
|
+
`EXECUTE format($f$ALTER SEQUENCE %s OWNER TO %I$f$, o.obj, ${r}); END LOOP; ` +
|
|
426
|
+
// functions, procedures, aggregates — what CREATE OR REPLACE needs to own
|
|
427
|
+
`FOR o IN SELECT p.oid::regprocedure AS obj, p.prokind FROM pg_proc p WHERE p.pronamespace = ${ns} ` +
|
|
428
|
+
`AND p.proowner <> ${r}::regrole AND ${notExt('pg_proc', 'p.oid')} LOOP ` +
|
|
429
|
+
`EXECUTE format($f$ALTER %s %s OWNER TO %I$f$, CASE o.prokind WHEN ${k('f')} THEN ${t('FUNCTION')} WHEN ${k('w')} THEN ${t('FUNCTION')} WHEN ${k('a')} THEN ${t('AGGREGATE')} ELSE ${t('PROCEDURE')} END, o.obj, ${r}); END LOOP; ` +
|
|
430
|
+
// free-standing types: enums, domains, ranges, and composite types made by CREATE TYPE
|
|
431
|
+
`FOR o IN SELECT y.oid::regtype AS obj, y.typtype FROM pg_type y WHERE y.typnamespace = ${ns} AND y.typowner <> ${r}::regrole ` +
|
|
432
|
+
`AND (y.typtype = ANY (${anyOf('e,d,r')}) OR (y.typtype = ${k('c')} AND EXISTS (SELECT 1 FROM pg_class c WHERE c.oid = y.typrelid AND c.relkind = ${k('c')}))) ` +
|
|
433
|
+
`AND ${notExt('pg_type', 'y.oid')} LOOP ` +
|
|
434
|
+
`EXECUTE format($f$ALTER %s %s OWNER TO %I$f$, CASE o.typtype WHEN ${k('d')} THEN ${t('DOMAIN')} ELSE ${t('TYPE')} END, o.obj, ${r}); END LOOP; ` +
|
|
435
|
+
`END $own$;`;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// The ownership handover as a command (task 1004735): provisioning runs it after the grants,
|
|
439
|
+
// and the migrate_not_owner remedy runs it for a project provisioned before that. PURE.
|
|
440
|
+
function dbOwnershipCmd(inst, { privileged = false } = {}) {
|
|
441
|
+
return asPostgres(`psql -v ON_ERROR_STOP=1 -d ${dbName(inst)} -c '${dbOwnershipSql(instanceDbRole(inst))}'`, privileged);
|
|
442
|
+
}
|
|
443
|
+
|
|
386
444
|
// ── STANDALONE scaffold (ADR 0108 / 0125, task 2055) ──────────────────────────
|
|
387
445
|
// The MISSING first leg of the greenfield "one guided flow": provision.js used to
|
|
388
446
|
// THROW if the instance repo wasn't already cloned. These generate + run the scaffold
|
|
@@ -1242,6 +1300,8 @@ module.exports = {
|
|
|
1242
1300
|
dbRoleCmd,
|
|
1243
1301
|
dbRoleCmdShown,
|
|
1244
1302
|
dbRoleGrantsCmd,
|
|
1303
|
+
dbOwnershipCmd,
|
|
1304
|
+
dbOwnershipSql,
|
|
1245
1305
|
deployKeyPath,
|
|
1246
1306
|
deployKeyTitle,
|
|
1247
1307
|
ensureInstanceUserCmd,
|
package/scripts/gds/provision.js
CHANGED
|
@@ -49,7 +49,7 @@ const crypto = require('node:crypto');
|
|
|
49
49
|
|
|
50
50
|
const retry = require('./intent-retry.js'); // when a failed intent is tried again (task 1004447)
|
|
51
51
|
const { CONFIG, MANIFEST_UA, softFailResult, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, MAX_INTENT_ATTEMPTS, REPO_ROOT, coreVersionSafe, hasFlag, loadDeps, oauthSecret, provisionerBotEmail } = require('./provision-config.js');
|
|
52
|
-
const { accountPreflight, appUser, alreadyScaffolded, buildCorePinRefreshCommit, coreCheckoutHasGit, dbCreateCmd, dbName, dbRoleCmd, dbRoleCmdShown, dbRoleGrantsCmd, ensureInstanceUserCmd, generateDbPassword, grantInstanceRepoReadCmd, instanceDbRole, instanceStateDir, instanceUser, deployKeyPath, deployKeyTitle, ensurePrivateRepoAccess, installedCorePackDir, installedCoreTarball, instanceInitSpec, instanceRepoRemote, migrateCmd, onboardMode, ownerLoginOf, parseTargetRef, planCorePinRefresh, refreshStandaloneCorePin, resolveOwnerGithubToken, resolveVendorableCoreTarball, safeVersionLabel, scaffoldStandaloneRepo, seedFirstVersionCmd, standaloneInstallCmd, standaloneMigrateCmd, standalonePullCmd, standaloneRegenDocsCmd, standaloneRoot } = require('./provision-repo.js');
|
|
52
|
+
const { accountPreflight, appUser, alreadyScaffolded, buildCorePinRefreshCommit, coreCheckoutHasGit, dbCreateCmd, dbName, dbRoleCmd, dbRoleCmdShown, dbRoleGrantsCmd, dbOwnershipCmd, ensureInstanceUserCmd, generateDbPassword, grantInstanceRepoReadCmd, instanceDbRole, instanceStateDir, instanceUser, deployKeyPath, deployKeyTitle, ensurePrivateRepoAccess, installedCorePackDir, installedCoreTarball, instanceInitSpec, instanceRepoRemote, migrateCmd, onboardMode, ownerLoginOf, parseTargetRef, planCorePinRefresh, refreshStandaloneCorePin, resolveOwnerGithubToken, resolveVendorableCoreTarball, safeVersionLabel, scaffoldStandaloneRepo, seedFirstVersionCmd, standaloneInstallCmd, standaloneMigrateCmd, standalonePullCmd, standaloneRegenDocsCmd, standaloneRoot } = require('./provision-repo.js');
|
|
53
53
|
const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, envPrefixMismatch, instanceManifestCmd, originEnvVarsFor, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, renamedEnvCarry, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
|
|
54
54
|
const { caddyBlock, caddySnippetPath, checkCaddyWiring, checkDnsTokenScope, classifyZoneScope, dnsReleaseEnv, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, identityCmd, identityVerdict, ingressPlaneGap, ingressPreflight, shouldFederate, zoneForDomain } = require('./provision-net.js');
|
|
55
55
|
const { resolveProcessManager } = require('./provision-process.js');
|
|
@@ -463,6 +463,11 @@ async function provisionInstance(inst, deps) {
|
|
|
463
463
|
// call site also suppresses stderr). The GRANTS carry no secret and log in full.
|
|
464
464
|
boxExec(dbRoleCmd(inst, { password: dbPassword, privileged }), { shown: dbRoleCmdShown(inst) });
|
|
465
465
|
boxExec(dbRoleGrantsCmd(inst, { privileged }));
|
|
466
|
+
// Grants are not ownership: later moves migrate AS this role and must ALTER what the migrate
|
|
467
|
+
// above created as postgres (task 1004735). Non-fatal on purpose — if it ever fails, the
|
|
468
|
+
// project still serves, and the first move that needs it applies it (migrate_not_owner).
|
|
469
|
+
const own = boxExec(dbOwnershipCmd(inst, { privileged }), { allowFail: true });
|
|
470
|
+
if (own && own.softFailed) log(' ⚠ [db] could not hand the schema to the instance role — its first core move that ALTERs a table will retry it (migrate_not_owner)');
|
|
466
471
|
|
|
467
472
|
// ── step 3: config + per-instance secrets ─────────────────────────────────
|
|
468
473
|
log(' [config] per-instance web.env');
|
|
@@ -1280,6 +1285,7 @@ async function cmdRunIntents(deps) {
|
|
|
1280
1285
|
}
|
|
1281
1286
|
}
|
|
1282
1287
|
await snapshotAllCoreVersions(deps); // refresh what the deploy control reads, AFTER the drain (ADR 0293 D3/D6)
|
|
1288
|
+
await require('./provision-auto-upgrade.js').autoUpgradeTick(deps); // then act on each hosted project's update rule: preflight, then move, one per tick (task 1004729, ADR 0381)
|
|
1283
1289
|
const m = await drainOAuthManifests(deps); // same tick: convert + place any GitHub-App-Manifest creds awaiting the control plane
|
|
1284
1290
|
await require('./provision-github-setup.js').verifyGithubAppSetups(deps).catch((e) => log(` github-setup check failed (non-blocking): ${e.message}`)); // install + device flow, asked of GitHub with the app's key (task 1004649)
|
|
1285
1291
|
log(`done — ran=${ran} ok=${ok} errors=${errors}; oauth-manifest converted=${m.converted} errors=${m.errors}`);
|
|
@@ -184,6 +184,10 @@ const INTEGRATION = new Set([
|
|
|
184
184
|
// the --integration lane never saw them either, because it runs only this set. A
|
|
185
185
|
// test that cannot fail in either lane is a file that looks like a gate.
|
|
186
186
|
'handle_machinery', 'hub_profile_leak', 'profile_fields',
|
|
187
|
+
// task 1004735: the schema ownership handover (provision-repo.js dbOwnershipSql) against a
|
|
188
|
+
// real engine — one DO block of catalog queries, so only Postgres can say it runs, what it
|
|
189
|
+
// reaches and that it leaves extension objects alone. Throwaway schema + role, dropped after.
|
|
190
|
+
'db_ownership_handover',
|
|
187
191
|
// task 1002300 (R24): guilds against real SQL. The DB-free sibling
|
|
188
192
|
// (guilds_api) pins the route layer and statement shapes; only a real engine
|
|
189
193
|
// proves the one-owner partial index, the expiry CTE drained before its insert,
|
|
@@ -66,6 +66,9 @@ const REASON_CLASS = Object.freeze({
|
|
|
66
66
|
// a migration's CHECK constraint is broken by rows the project already has (task 1004734): a
|
|
67
67
|
// retry fails identically and no remedy may rewrite the owner's data, so a person decides.
|
|
68
68
|
migrate_check_violation: 'unknown',
|
|
69
|
+
// a migration ALTERs an object the project's own account does not OWN — the tables were made
|
|
70
|
+
// by postgres at provisioning (task 1004735). Known fix: hand them over (wedge-remedy.js).
|
|
71
|
+
migrate_not_owner: 'known_wedge',
|
|
69
72
|
restart: 'unknown',
|
|
70
73
|
health: 'unknown',
|
|
71
74
|
// the runner's one automatic fix did not clear it, or itself failed (task 1004448)
|
|
@@ -90,6 +93,7 @@ const UPGRADE_REASONS = [
|
|
|
90
93
|
[/^npm install failed/, 'npm_install'],
|
|
91
94
|
[/^integrity pin/, 'integrity_pin'],
|
|
92
95
|
[/^migrate failed .*\bERROR: .*check constraint/, 'migrate_check_violation'], // upgrade-migrate.js migrateError (task 1004734)
|
|
96
|
+
[/^migrate failed .*\bmust be owner of /, 'migrate_not_owner'], // Postgres' own wording (task 1004735)
|
|
93
97
|
[/^migrate failed/, 'migrate'],
|
|
94
98
|
[/^restart failed/, 'restart'],
|
|
95
99
|
[/^(health check failed after restart|served core version did not change)/, 'health'],
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
// knows is safe to delete — they are copies, and the newest are kept).
|
|
15
15
|
// schema_pending — the project's database is behind the code it is running NOW. Fix:
|
|
16
16
|
// migrate that database, with its own PGDATABASE, before the code moves.
|
|
17
|
+
// migrate_not_owner — a migration changed a table or function the project's own account
|
|
18
|
+
// does not OWN: provisioning made them as postgres (task 1004735). Fix:
|
|
19
|
+
// hand ownership of the project's schema to its account, as postgres.
|
|
20
|
+
// Heals every project provisioned before provision-repo.js did this itself.
|
|
17
21
|
// intent-retry.js retires every known_wedge at once, so on its own the failure only waited
|
|
18
22
|
// for a person. This file runs the fix, records it as an event on the project, and re-pends
|
|
19
23
|
// the move for ONE more try.
|
|
@@ -33,13 +37,13 @@
|
|
|
33
37
|
// runner's own exec and db, so every fix is testable with an injected exec.
|
|
34
38
|
|
|
35
39
|
const { CONFIG } = require('./provision-config.js');
|
|
36
|
-
const { dbName, selfInstanceRoot, standaloneRoot } = require('./provision-repo.js');
|
|
40
|
+
const { dbName, dbOwnershipCmd, instanceDbRole, selfInstanceRoot, standaloneRoot } = require('./provision-repo.js');
|
|
37
41
|
const { migratePlan, CORE_MIGRATE_SCRIPT, ACCOUNT_RE } = require('./upgrade-migrate.js');
|
|
38
42
|
const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
|
|
39
43
|
const outcome = require('./upgrade-outcome.js');
|
|
40
44
|
const { DEFAULT_RETRY_DELAY_MS } = require('./intent-retry.js');
|
|
41
45
|
|
|
42
|
-
const REMEDY_REASONS = Object.freeze(['dirty_tree', 'disk_full', 'schema_pending']);
|
|
46
|
+
const REMEDY_REASONS = Object.freeze(['dirty_tree', 'disk_full', 'schema_pending', 'migrate_not_owner']);
|
|
43
47
|
// The newest dumps a disk-full prune always leaves behind: a week of nightlies. The nightly
|
|
44
48
|
// job's own retention is 30 days; this only ever cuts below that when the disk is full.
|
|
45
49
|
const DUMP_KEEP_FLOOR = 7;
|
|
@@ -95,6 +99,13 @@ function planRemedy(inst, reason, o = {}) {
|
|
|
95
99
|
describe: `pruned ${db}'s old database backups in ${backupDir}, keeping the newest ${DUMP_KEEP_FLOOR}`,
|
|
96
100
|
};
|
|
97
101
|
}
|
|
102
|
+
if (reason === 'migrate_not_owner') {
|
|
103
|
+
// A control plane migrates as its own app user, which created its tables — there is no
|
|
104
|
+
// separate project account to hand them to, so the snag cannot be this one there.
|
|
105
|
+
if (inst.hosting_shape === 'control-plane') return { refused: 'the control plane has no separate project account to hand its tables to' };
|
|
106
|
+
const role = instanceDbRole(inst);
|
|
107
|
+
return { reason, cwd: null, env: null, cmd: dbOwnershipCmd(inst, { privileged: o.privileged }), describe: `handed ownership of ${db}'s tables and functions to ${role}, the account its migrations run as` };
|
|
108
|
+
}
|
|
98
109
|
// schema_pending — the project's OWN migrate command (a declared script wins, else the
|
|
99
110
|
// core's migrate.sh: upgrade-migrate.js), aimed at its OWN database. migrate.sh defaults to
|
|
100
111
|
// production when PGDATABASE is unset, so it is always set, never inherited.
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.21.
|
|
78
|
+
const CORE_VERSION = '1.21.118'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|