@bongos/core 1.21.117 → 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.
@@ -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 };
@@ -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,
@@ -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.117'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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');
@@ -0,0 +1,189 @@
1
+ // tests/core_auto_upgrade.mjs — the hub moves each hosted project's core on its own,
2
+ // by that project's update rule (task 1004729, ADR 0381).
3
+ //
4
+ // The runner tick used to compute `recommended` for every cloud-host and stop; a move
5
+ // only happened when someone pressed the button. These tests drive the new step with a
6
+ // fake db and a fake provisioning port and assert WHAT IT QUEUES:
7
+ // behind on `patch` → a preflight for `recommended`
8
+ // preflight passed that target → the move to it
9
+ // pinned / open intent / held target / control-plane row → nothing
10
+ // more candidates than the pace → only AUTO_MOVES_PER_TICK queued
11
+ // the platform-wide off switch → nothing at all
12
+ //
13
+ // Run: node tests/core_auto_upgrade.mjs
14
+
15
+ import { strict as assert } from 'node:assert';
16
+ import { autoUpgradeTick, AUTO_MOVES_PER_TICK, autoUpgradeEnabled } from '../scripts/gds/provision-auto-upgrade.js';
17
+
18
+ let passed = 0;
19
+ let failed = 0;
20
+ const t = async (name, fn) => {
21
+ try { await fn(); passed += 1; console.log(` PASS ${name}`); }
22
+ catch (e) { failed += 1; console.log(` FAIL ${name}\n ${e.message}`); }
23
+ };
24
+
25
+ // One hosted project, its snapshot row joined on, as the candidate query returns it.
26
+ function row(over = {}) {
27
+ return {
28
+ id: 52, slug: 'toast', owner_builder_id: 7, hosting_shape: 'cloud-host', status: 'active',
29
+ update_channel: 'patch', served_version: '1.21.20', recommended: '1.21.69', preflight: null,
30
+ ...over,
31
+ };
32
+ }
33
+
34
+ // A fake world: `rows` are candidates, `intents` the provisioning_intents history.
35
+ function makeDeps({ rows = [row()], intents = [], env = {} } = {}) {
36
+ const queued = [];
37
+ const events = [];
38
+ const db = {
39
+ async query(sql, params) {
40
+ if (/FROM provisioning_instances/.test(sql)) return { rows };
41
+ if (/FROM provisioning_intents/.test(sql)) {
42
+ const ids = params[0].map(Number);
43
+ return { rows: intents.filter((i) => ids.includes(Number(i.instance_id))) };
44
+ }
45
+ throw new Error(`unexpected query: ${sql}`);
46
+ },
47
+ };
48
+ const provisioning = {
49
+ async enqueueIntent(_db, instanceId, action, requestedBy, target) {
50
+ const intent = { id: 900 + queued.length, instance_id: instanceId, action, requested_by: requestedBy, target_version: target, state: 'pending' };
51
+ queued.push(intent);
52
+ return { intent, created: true };
53
+ },
54
+ async recordEvent(_db, e) { events.push(e); },
55
+ };
56
+ return { db, provisioning, env, log: () => {}, queued, events };
57
+ }
58
+
59
+ console.log('core auto-upgrade tick (task 1004729)');
60
+
61
+ await t('a patch tenant behind gets a preflight queued for recommended, attributed to the runner', async () => {
62
+ const d = makeDeps();
63
+ const r = await autoUpgradeTick(d);
64
+ assert.equal(d.queued.length, 1);
65
+ assert.equal(d.queued[0].action, 'core-upgrade-preflight');
66
+ assert.equal(d.queued[0].target_version, '1.21.69');
67
+ assert.equal(d.queued[0].instance_id, 52);
68
+ assert.equal(d.queued[0].requested_by, 'runner:auto-upgrade');
69
+ assert.equal(r.queued, 1);
70
+ });
71
+
72
+ await t('a preflight that PASSED for that target queues the move to it', async () => {
73
+ const d = makeDeps({ rows: [row({ preflight: { target: '1.21.69', ok: true } })],
74
+ intents: [{ instance_id: 52, action: 'core-upgrade-preflight', state: 'done', target_version: '1.21.69' }] });
75
+ await autoUpgradeTick(d);
76
+ assert.equal(d.queued.length, 1);
77
+ assert.equal(d.queued[0].action, 'core-upgrade');
78
+ assert.equal(d.queued[0].target_version, '1.21.69');
79
+ });
80
+
81
+ await t('a passed preflight for a DIFFERENT target re-asks for the current one', async () => {
82
+ const d = makeDeps({ rows: [row({ preflight: { target: '1.21.50', ok: true } })] });
83
+ await autoUpgradeTick(d);
84
+ assert.equal(d.queued[0].action, 'core-upgrade-preflight');
85
+ assert.equal(d.queued[0].target_version, '1.21.69');
86
+ });
87
+
88
+ await t('a pinned tenant gets nothing', async () => {
89
+ const d = makeDeps({ rows: [row({ update_channel: 'pinned' })] });
90
+ await autoUpgradeTick(d);
91
+ assert.equal(d.queued.length, 0);
92
+ });
93
+
94
+ await t('a tenant with an open intent gets nothing new', async () => {
95
+ for (const state of ['pending', 'running']) {
96
+ const d = makeDeps({ intents: [{ instance_id: 52, action: 'restart', state, target_version: null }] });
97
+ await autoUpgradeTick(d);
98
+ assert.equal(d.queued.length, 0, state);
99
+ }
100
+ });
101
+
102
+ await t('a target held by a failed move is not queued (retired core-upgrade)', async () => {
103
+ const d = makeDeps({ rows: [row({ preflight: { target: '1.21.69', ok: true } })],
104
+ intents: [{ instance_id: 52, action: 'core-upgrade', state: 'error', target_version: '1.21.69' }] });
105
+ await autoUpgradeTick(d);
106
+ assert.equal(d.queued.length, 0);
107
+ });
108
+
109
+ await t('a target held by a refused preflight is not queued', async () => {
110
+ const d = makeDeps({ rows: [row({ preflight: { target: '1.21.69', ok: false, error: 'migration would fail' } })] });
111
+ await autoUpgradeTick(d);
112
+ assert.equal(d.queued.length, 0);
113
+ });
114
+
115
+ await t('a target whose preflight intent itself errored is not re-asked', async () => {
116
+ const d = makeDeps({ intents: [{ instance_id: 52, action: 'core-upgrade-preflight', state: 'error', target_version: '1.21.69' }] });
117
+ await autoUpgradeTick(d);
118
+ assert.equal(d.queued.length, 0);
119
+ });
120
+
121
+ await t('a move that finished but left the box behind is not re-queued every tick', async () => {
122
+ const d = makeDeps({ rows: [row({ preflight: { target: '1.21.69', ok: true } })],
123
+ intents: [{ instance_id: 52, action: 'core-upgrade', state: 'done', target_version: '1.21.69' }] });
124
+ await autoUpgradeTick(d);
125
+ assert.equal(d.queued.length, 0);
126
+ });
127
+
128
+ await t('an older failed target does not hold a newer one', async () => {
129
+ const d = makeDeps({ intents: [{ instance_id: 52, action: 'core-upgrade', state: 'error', target_version: '1.21.60' }] });
130
+ await autoUpgradeTick(d);
131
+ assert.equal(d.queued.length, 1);
132
+ assert.equal(d.queued[0].target_version, '1.21.69');
133
+ });
134
+
135
+ await t('the control-plane row (and any non-cloud-host) gets nothing', async () => {
136
+ const d = makeDeps({ rows: [row({ id: 1, slug: 'hub', hosting_shape: 'control-plane' }), row({ id: 3, slug: 'ct', hosting_shape: 'co-tenant' })] });
137
+ await autoUpgradeTick(d);
138
+ assert.equal(d.queued.length, 0);
139
+ });
140
+
141
+ await t('nothing is queued when recommended is not newer than served', async () => {
142
+ for (const over of [{ recommended: null }, { recommended: '1.21.20' }, { recommended: '1.21.10' }, { served_version: null }]) {
143
+ const d = makeDeps({ rows: [row(over)] });
144
+ await autoUpgradeTick(d);
145
+ assert.equal(d.queued.length, 0, JSON.stringify(over));
146
+ }
147
+ });
148
+
149
+ await t('at most the pace limit is queued per tick', async () => {
150
+ assert.equal(AUTO_MOVES_PER_TICK, 1);
151
+ const d = makeDeps({ rows: [row({ id: 52 }), row({ id: 53, slug: 'b' }), row({ id: 54, slug: 'c' })] });
152
+ const r = await autoUpgradeTick(d);
153
+ assert.equal(d.queued.length, AUTO_MOVES_PER_TICK);
154
+ assert.equal(r.queued, AUTO_MOVES_PER_TICK);
155
+ });
156
+
157
+ await t('the pace counts only intents actually created (a lost race does not spend it)', async () => {
158
+ const d = makeDeps({ rows: [row({ id: 52 }), row({ id: 53, slug: 'b' })] });
159
+ const real = d.provisioning.enqueueIntent;
160
+ let first = true;
161
+ d.provisioning.enqueueIntent = async (...a) => {
162
+ if (first) { first = false; return { intent: { state: 'pending' }, created: false }; }
163
+ return real(...a);
164
+ };
165
+ await autoUpgradeTick(d);
166
+ assert.equal(d.queued.length, 1);
167
+ assert.equal(d.queued[0].instance_id, 53);
168
+ });
169
+
170
+ await t('the off switch stops every automatic move, and it defaults ON', async () => {
171
+ for (const v of ['off', '0', 'false', 'no', 'OFF']) {
172
+ const d = makeDeps({ env: { BONGOS_AUTO_CORE_UPGRADE: v } });
173
+ const r = await autoUpgradeTick(d);
174
+ assert.equal(d.queued.length, 0, v);
175
+ assert.equal(r.off, true);
176
+ }
177
+ assert.equal(autoUpgradeEnabled({}), true);
178
+ assert.equal(autoUpgradeEnabled({ BONGOS_AUTO_CORE_UPGRADE: 'on' }), true);
179
+ });
180
+
181
+ await t('a refusal from enqueueIntent (e.g. the control-plane guard) never fails the tick', async () => {
182
+ const d = makeDeps();
183
+ d.provisioning.enqueueIntent = async () => { throw new Error('refused'); };
184
+ const r = await autoUpgradeTick(d);
185
+ assert.equal(r.queued, 0);
186
+ });
187
+
188
+ console.log(`\n${passed} passed, ${failed} failed`);
189
+ if (failed) process.exit(1);