@hivemagnus/magnus-server 1.1.2 → 1.1.4

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.
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.1.2"
2
+ "version": "1.1.4"
3
3
  }
@@ -1,24 +1,43 @@
1
1
  /** `magnus-server agents upgrade`: the console's "Upgrade agents" action from a shell. */
2
2
  import { UsageError } from './args.js';
3
3
  export const AGENTS_USAGE = `Usage: magnus-server agents upgrade [options] [--env-file FILE]
4
+ magnus-server agents stop RUN_ID [--env-file FILE]
4
5
 
5
- Upgrades agents to the version this server ships with (or --to), one canary
6
+ Upgrades agents to the version bundled with this server (or --to), one canary
6
7
  first, then MAGNUS_AGENT_UPGRADE_CONCURRENCY at a time. Each agent installs the
7
8
  exact version beside the running one and switches back by itself if the new
8
- version does not report a heartbeat in time. Stops at the first failure.
9
+ version does not report a heartbeat in time. A revert, a release problem or an
10
+ agent that goes silent stops the run; an agent that refuses before changing
11
+ anything (busy, enrollment, unreachable) is reported as refused and the run
12
+ goes on.
9
13
 
10
14
  Options:
11
15
  --to X.Y.Z Agent version (default: the one bundled with this server)
12
16
  --integrity sha512-… npm integrity of that version (default: read from the registry)
13
17
  --nodes a,b Only these nodes (names, host names or ids)
18
+ --canary NODE Upgrade this node first (default: the first eligible one)
19
+ --allow-prerelease Required for a prerelease version such as 1.2.0-rc.1
20
+ --retry-reverted Also offer nodes that already reverted this version
21
+ --retry RUN_ID Retry the failed, refused, skipped and interrupted nodes of a run
14
22
  --dry-run Show which agents would be upgraded, and why others would not
15
23
 
24
+ Ctrl-C asks the run to stop after the nodes in progress; a second Ctrl-C leaves
25
+ at once, and the run is then shown as interrupted.
26
+
16
27
  Agents are called over TLS. NODE_EXTRA_CA_CERTS from --env-file is honoured, so
17
28
  the command trusts the same CA as the service.`;
18
29
  export function parseAgentsArgs(args) {
19
30
  const [sub, ...rest] = args;
20
31
  if (!sub || sub === '--help' || sub === '-h' || sub === 'help')
21
32
  return 'help';
33
+ if (sub === 'stop') {
34
+ const [id, ...extra] = rest;
35
+ if (id === '--help' || id === '-h')
36
+ return 'help';
37
+ if (!id || id.startsWith('--') || extra.length)
38
+ throw new UsageError('agents stop takes one RUN_ID');
39
+ return { stop: id, dryRun: false };
40
+ }
22
41
  if (sub !== 'upgrade')
23
42
  throw new UsageError(`unknown agents subcommand "${sub}"`);
24
43
  const options = { dryRun: false };
@@ -37,6 +56,14 @@ export function parseAgentsArgs(args) {
37
56
  options.integrity = value();
38
57
  else if (name === '--nodes')
39
58
  options.nodes = value().split(',').map(node => node.trim()).filter(Boolean);
59
+ else if (name === '--canary')
60
+ options.canary = value();
61
+ else if (name === '--retry')
62
+ options.retry = value();
63
+ else if (name === '--allow-prerelease')
64
+ options.allowPrerelease = true;
65
+ else if (name === '--retry-reverted')
66
+ options.retryReverted = true;
40
67
  else if (name === '--dry-run')
41
68
  options.dryRun = true;
42
69
  else if (name === '--help' || name === '-h')
@@ -44,8 +71,13 @@ export function parseAgentsArgs(args) {
44
71
  else
45
72
  throw new UsageError(`unknown option ${name}`);
46
73
  }
74
+ if (options.nodes?.length === 0)
75
+ throw new UsageError('--nodes needs at least one node');
76
+ if (options.retry && (options.to || options.integrity || options.nodes || options.dryRun))
77
+ throw new UsageError('--retry reuses the version, integrity and nodes of that run; drop --to, --integrity, --nodes and --dry-run');
47
78
  return options;
48
79
  }
80
+ const nodeLine = (node) => ` ${node.name}: ${node.state}${node.detail ? ` (${node.code ? `${node.code}: ` : ''}${node.detail})` : node.code ? ` (${node.code})` : ''}`;
49
81
  export async function commandAgents(args) {
50
82
  const options = parseAgentsArgs(args);
51
83
  if (options === 'help') {
@@ -56,33 +88,32 @@ export async function commandAgents(args) {
56
88
  const { disconnectRedis } = await import('../queue/redis.js');
57
89
  const service = await import('../services/agent-upgrade-service.js');
58
90
  try {
59
- let nodeIds;
60
- if (options.nodes) {
91
+ if (options.stop) {
92
+ const run = await service.stopUpgradeRun(options.stop, 'system:magnus-server-cli');
93
+ console.log(`Asked run ${run.id} to stop; it stops before its next batch.`);
94
+ return 0;
95
+ }
96
+ const resolveNodes = async (names) => {
61
97
  const rows = await getDb().node.findMany({
62
- where: { OR: [{ id: { in: options.nodes } }, { name: { in: options.nodes } }, { hostname: { in: options.nodes } }] },
98
+ where: { OR: [{ id: { in: names } }, { name: { in: names } }, { hostname: { in: names } }] },
63
99
  select: { id: true, name: true, hostname: true },
64
100
  });
65
- const unknown = options.nodes.filter(node => !rows.some(row => row.id === node || row.name === node || row.hostname === node));
66
- if (unknown.length) {
67
- console.error(`Unknown node(s): ${unknown.join(', ')}`);
101
+ const unknown = names.filter(node => !rows.some(row => row.id === node || row.name === node || row.hostname === node));
102
+ return { ids: rows.map(row => row.id), unknown, byName: (name) => rows.find(row => row.id === name || row.name === name || row.hostname === name)?.id };
103
+ };
104
+ let nodeIds;
105
+ let canaryNodeId;
106
+ const wanted = [...options.nodes ?? [], ...options.canary ? [options.canary] : []];
107
+ if (wanted.length) {
108
+ const resolved = await resolveNodes(wanted);
109
+ if (resolved.unknown.length) {
110
+ console.error(`Unknown node(s): ${resolved.unknown.join(', ')}`);
68
111
  return 1;
69
112
  }
70
- nodeIds = rows.map(row => row.id);
71
- }
72
- const release = await service.resolveAgentRelease({ version: options.to, integrity: options.integrity });
73
- const candidates = await service.listUpgradeCandidates(release.version, nodeIds);
74
- console.log(`magnus-agent ${release.version} (${release.integrity.slice(0, 19)}…)`);
75
- for (const candidate of candidates) {
76
- console.log(` ${candidate.eligible ? '→' : '·'} ${candidate.name.padEnd(28)} ${candidate.version.padEnd(12)} ${candidate.eligible ? 'will upgrade' : candidate.reason}`);
77
- }
78
- const eligible = candidates.filter(candidate => candidate.eligible);
79
- if (options.dryRun) {
80
- console.log(`Dry run: ${eligible.length} of ${candidates.length} agent(s) would be upgraded.`);
81
- return 0;
82
- }
83
- if (eligible.length === 0) {
84
- console.log('Nothing to upgrade.');
85
- return candidates.some(candidate => candidate.reason !== 'already on this version') ? 1 : 0;
113
+ if (options.nodes)
114
+ nodeIds = options.nodes.map(name => resolved.byName(name));
115
+ if (options.canary)
116
+ canaryNodeId = resolved.byName(options.canary);
86
117
  }
87
118
  const deps = service.defaultDeps();
88
119
  const seen = new Map();
@@ -93,15 +124,57 @@ export async function commandAgents(args) {
93
124
  continue;
94
125
  seen.set(node.nodeId, node.state);
95
126
  if (node.state !== 'pending')
96
- console.log(` ${node.name}: ${node.state}${node.detail ? ` (${node.detail})` : ''}`);
127
+ console.log(nodeLine(node));
97
128
  }
98
129
  await save(run);
99
130
  };
100
- const { done } = await service.startUpgradeRun({ version: release.version, integrity: release.integrity, nodeIds: eligible.map(node => node.nodeId),
101
- actorId: 'system:magnus-server-cli', deps });
102
- const run = await done;
103
- console.log(`${run.status === 'succeeded' ? 'Done' : 'Stopped'}: ${run.message ?? run.status}`);
104
- return run.status === 'succeeded' ? 0 : 1;
131
+ let started;
132
+ if (options.retry) {
133
+ started = await service.retryUpgradeRun(options.retry, { actorId: 'system:magnus-server-cli', deps, canaryNodeId,
134
+ allowRetryReverted: options.retryReverted, allowPrerelease: options.allowPrerelease });
135
+ console.log(`Retrying run ${options.retry} as ${started.run.id}: ${started.run.nodes.length} agent(s) to ${started.run.version}`);
136
+ }
137
+ else {
138
+ const release = await service.resolveAgentRelease({ version: options.to, integrity: options.integrity });
139
+ const candidates = await service.listUpgradeCandidates(release.version, nodeIds, { allowRetryReverted: options.retryReverted });
140
+ console.log(`magnus-agent ${release.version} (${release.integrity.slice(0, 19)}…)`);
141
+ for (const candidate of candidates) {
142
+ console.log(` ${candidate.eligible ? '→' : '·'} ${candidate.name.padEnd(28)} ${candidate.version.padEnd(12)} ${candidate.eligible ? 'will upgrade' : candidate.reason}`);
143
+ }
144
+ const eligible = candidates.filter(candidate => candidate.eligible);
145
+ if (options.dryRun) {
146
+ console.log(`Dry run: ${eligible.length} of ${candidates.length} agent(s) would be upgraded.`);
147
+ return 0;
148
+ }
149
+ if (eligible.length === 0) {
150
+ console.log('Nothing to upgrade.');
151
+ return candidates.some(candidate => candidate.reason !== 'already on this version') ? 1 : 0;
152
+ }
153
+ started = await service.startUpgradeRun({ version: release.version, integrity: release.integrity, nodeIds: eligible.map(node => node.nodeId),
154
+ canaryNodeId, allowPrerelease: options.allowPrerelease, allowRetryReverted: options.retryReverted, actorId: 'system:magnus-server-cli', deps });
155
+ }
156
+ let interrupts = 0;
157
+ const onSigint = () => {
158
+ if (++interrupts > 1) {
159
+ console.error('Leaving now; the run shows as interrupted once its lease runs out.');
160
+ process.exit(130);
161
+ }
162
+ console.error('Stopping after the nodes in progress (Ctrl-C again to leave now)…');
163
+ void service.stopUpgradeRun(started.run.id, 'system:magnus-server-cli').catch(() => undefined);
164
+ };
165
+ process.on('SIGINT', onSigint);
166
+ try {
167
+ const run = await started.done;
168
+ for (const node of run.excluded ?? [])
169
+ if (options.nodes || options.retry)
170
+ console.log(` ${node.name}: not upgraded (${node.reason})`);
171
+ console.log(`${run.status === 'succeeded' ? 'Done' : run.status === 'stopped' ? 'Stopped' : 'Failed'}: ${run.message ?? run.status}`);
172
+ console.log(`Run ${run.id}${run.nodes.every(node => node.state === 'confirmed') ? '' : `; retry the rest with: magnus-server agents upgrade --retry ${run.id}`}`);
173
+ return run.status === 'succeeded' ? 0 : 1;
174
+ }
175
+ finally {
176
+ process.off('SIGINT', onSigint);
177
+ }
105
178
  }
106
179
  finally {
107
180
  await disconnectDb().catch(() => undefined);
package/dist/cli/args.js CHANGED
@@ -8,6 +8,7 @@ Commands:
8
8
  migrate Apply pending migrations (prisma migrate deploy) and base data
9
9
  migrations Show applied and pending migrations with their impact notes
10
10
  backup Dump PostgreSQL (pg_dump -Fc) into MAGNUS_BACKUP_DIR and print the restore command
11
+ restore FILE Replace the database with a dump (stop the server first; asks unless --yes)
11
12
  upgrade Upgrade a \`magnus init\` project (runs \`magnus upgrade\`; see --help there)
12
13
  rollback Switch a \`magnus init\` project back to the previous version
13
14
  agents Agent fleet commands: \`agents upgrade [--to X.Y.Z] [--nodes a,b] [--dry-run]\`
@@ -20,14 +21,15 @@ Options:
20
21
  --migrate start: apply pending migrations before listening (MAGNUS_AUTO_MIGRATE adds a backup and lock)
21
22
  --format <fmt> env-schema (default json), check and migrations (default table): json or table
22
23
  --label <text> backup: label in the dump file name
24
+ --yes restore: do not ask for confirmation
23
25
  -h, --help Show this help
24
26
  -v, --version Print the version
25
27
  `;
26
- const COMMANDS = new Set(['start', 'check', 'migrate', 'migrations', 'backup', 'upgrade', 'rollback', 'agents', 'env-schema', 'help', 'version']);
28
+ const COMMANDS = new Set(['start', 'check', 'migrate', 'migrations', 'backup', 'restore', 'upgrade', 'rollback', 'agents', 'env-schema', 'help', 'version']);
27
29
  /** Commands whose remaining arguments belong to another tool. */
28
30
  const PASSTHROUGH = new Set(['upgrade', 'rollback', 'agents']);
29
31
  export function parseArgs(argv) {
30
- const options = { command: 'start', envFiles: [], migrate: false, format: 'json', formatGiven: false, passthrough: [] };
32
+ const options = { command: 'start', envFiles: [], migrate: false, format: 'json', formatGiven: false, passthrough: [], yes: false };
31
33
  let commandSeen = false;
32
34
  for (let index = 0; index < argv.length; index++) {
33
35
  const arg = argv[index];
@@ -62,8 +64,12 @@ export function parseArgs(argv) {
62
64
  }
63
65
  else if (arg === '--label' || arg.startsWith('--label='))
64
66
  options.label = value('--label');
67
+ else if (arg === '--yes')
68
+ options.yes = true;
65
69
  else if (arg.startsWith('-'))
66
70
  throw new UsageError(`unknown option ${arg}`);
71
+ else if (commandSeen && options.command === 'restore' && options.file === undefined)
72
+ options.file = arg;
67
73
  else if (!commandSeen && COMMANDS.has(arg)) {
68
74
  if (options.command !== 'help' && options.command !== 'version')
69
75
  options.command = arg;
@@ -72,6 +78,8 @@ export function parseArgs(argv) {
72
78
  else
73
79
  throw new UsageError(`unknown command "${arg}"`);
74
80
  }
81
+ if (options.command === 'restore' && !options.file)
82
+ throw new UsageError('restore needs the dump file');
75
83
  return options;
76
84
  }
77
85
  //# sourceMappingURL=args.js.map
@@ -3,16 +3,21 @@
3
3
  *
4
4
  * Runs before the server listens. A session advisory lock serializes every
5
5
  * instance sharing the database, so only one dumps and migrates while the
6
- * others wait and then find nothing pending. A failed backup stops startup:
7
- * migrations never run without a dump unless the operator turned the dump off.
6
+ * others wait (up to MAGNUS_AUTO_MIGRATE_LOCK_TIMEOUT) and then find nothing
7
+ * pending. A failed backup stops startup: migrations never run without a dump
8
+ * unless the operator turned the dump off. A database with a failed migration
9
+ * is refused before any dump, and a restart loop reuses its recent dump, so a
10
+ * crash-looping service cannot fill the disk.
8
11
  */
9
12
  import { join } from 'node:path';
10
13
  import pg from 'pg';
11
- import { backupDatabase, describeMigration, migrationsNeedingAttention, readMigrationState, } from './migrations.js';
14
+ import { backupDatabase, backupKeep, describeMigration, failedMigrationAdvice, findReusableBackup, restoreCommand, migrationsNeedingAttention, readMigrationState, } from './migrations.js';
12
15
  /** Key for `pg_advisory_lock(hashtextextended(key, 0))`; distinct from Prisma's own migrate lock. */
13
16
  export const MIGRATE_LOCK_KEY = 'magnus:migrate';
14
17
  /** Pure decision, separated for tests. */
15
18
  export function decideAutoMigrate(state, settings) {
19
+ if (state.failed.length > 0)
20
+ return { action: 'refuse', reason: failedMigrationAdvice(state.failed) };
16
21
  if (state.pending.length === 0)
17
22
  return { action: 'skip', reason: `${state.packaged.length} migration(s) already applied` };
18
23
  const attention = migrationsNeedingAttention(state.pending);
@@ -26,19 +31,61 @@ export function decideAutoMigrate(state, settings) {
26
31
  // A fresh database has nothing to back up.
27
32
  return { action: 'migrate', backup: settings.autoMigrateBackup && !state.fresh };
28
33
  }
34
+ /** A dump this recent of the same database state is reused by the next attempt. */
35
+ export const BACKUP_REUSE_MS = 60 * 60_000;
36
+ export function autoMigrateLimits(env = process.env) {
37
+ const seconds = Number(env['MAGNUS_AUTO_MIGRATE_LOCK_TIMEOUT'] ?? '');
38
+ return { lockTimeoutMs: (Number.isFinite(seconds) && seconds > 0 ? seconds : 600) * 1000, keepBackups: backupKeep(env) };
39
+ }
40
+ /**
41
+ * The connection migrations and dumps use: MAGNUS_MIGRATE_DATABASE_URL when
42
+ * set (a direct connection past a pooler), else DATABASE_URL.
43
+ */
44
+ export function migrateDatabaseUrl(env, fallback) {
45
+ return env['MAGNUS_MIGRATE_DATABASE_URL']?.trim() || fallback;
46
+ }
47
+ /** Advisory locks do not survive PgBouncer transaction pooling. */
48
+ export function poolerWarning(url) {
49
+ try {
50
+ return new URL(url).searchParams.get('pgbouncer') === 'true'
51
+ ? 'DATABASE_URL goes through PgBouncer (pgbouncer=true): the migrate lock and prisma migrate need a direct connection; set MAGNUS_MIGRATE_DATABASE_URL.'
52
+ : null;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
29
58
  export async function autoMigrate(options) {
30
59
  const log = options.log ?? ((line) => console.log(line));
60
+ const now = options.now ?? (() => new Date());
61
+ const sleep = options.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
62
+ const limits = { ...autoMigrateLimits(), ...options.limits };
63
+ const pooler = poolerWarning(options.databaseUrl);
64
+ if (pooler)
65
+ log(`[auto-migrate] warning: ${pooler}`);
31
66
  const client = new pg.Client({ connectionString: options.databaseUrl, connectionTimeoutMillis: 10_000 });
32
67
  await client.connect();
33
68
  let locked = false;
34
69
  try {
35
- const tried = await client.query('SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS locked', [MIGRATE_LOCK_KEY]);
36
- if (!tried.rows[0]?.locked) {
37
- log('[auto-migrate] another instance is migrating; waiting for it to finish');
38
- await client.query('SELECT pg_advisory_lock(hashtextextended($1, 0))', [MIGRATE_LOCK_KEY]);
70
+ const deadline = now().getTime() + limits.lockTimeoutMs;
71
+ for (let attempt = 0;; attempt++) {
72
+ const tried = await client.query('SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS locked', [MIGRATE_LOCK_KEY]);
73
+ if (tried.rows[0]?.locked)
74
+ break;
75
+ if (attempt === 0)
76
+ log('[auto-migrate] another instance is migrating; waiting for it to finish');
77
+ if (now().getTime() >= deadline) {
78
+ throw new Error(`Auto-migrate gave up after ${Math.round(limits.lockTimeoutMs / 1000)} s waiting for another instance's migrate lock (MAGNUS_AUTO_MIGRATE_LOCK_TIMEOUT). `
79
+ + 'If no other server is migrating, a stale session holds it: check pg_locks for advisory locks, or connect without a pooler (MAGNUS_MIGRATE_DATABASE_URL).');
80
+ }
81
+ await sleep(2_000);
39
82
  }
40
83
  locked = true;
41
84
  const state = await readMigrationState(client, options.migrationsDir);
85
+ if (state.unknown.length > 0) {
86
+ log(`[auto-migrate] warning: the database has ${state.unknown.length} migration(s) this version does not know (a newer release applied them): ${state.unknown.map(m => m.name).join(', ')}. `
87
+ + 'If this version misbehaves, upgrade again or restore the dump taken before that upgrade.');
88
+ }
42
89
  const decision = decideAutoMigrate(state, options.settings);
43
90
  if (decision.action === 'skip') {
44
91
  log(`[auto-migrate] nothing to do: ${decision.reason}`);
@@ -49,16 +96,27 @@ export async function autoMigrate(options) {
49
96
  log(`[auto-migrate] ${state.pending.length} pending: ${state.pending.map(describeMigration).join(', ')}`);
50
97
  let backup;
51
98
  if (decision.backup) {
52
- backup = await backupDatabase({
53
- databaseUrl: options.databaseUrl,
54
- backupDir: options.backupDir,
55
- label: `pre-migrate-${options.version}`,
56
- client,
57
- pgDump: options.pgDump,
58
- }).catch((error) => {
59
- throw new Error(`Auto-migrate stopped before migrating: the backup failed. ${error instanceof Error ? error.message : String(error)}`);
60
- });
61
- log(`[auto-migrate] backup written to ${backup.file}`);
99
+ const label = `pre-migrate-${options.version}`;
100
+ const recent = findReusableBackup(options.backupDir, label, state.lastApplied, BACKUP_REUSE_MS, now());
101
+ if (recent) {
102
+ backup = { file: recent, metadataFile: `${recent}.json`, restoreCommand: restoreCommand(recent), reused: true };
103
+ log(`[auto-migrate] reusing the dump of the same database state from the last attempt: ${recent}`);
104
+ }
105
+ else {
106
+ backup = await backupDatabase({
107
+ databaseUrl: options.databaseUrl,
108
+ backupDir: options.backupDir,
109
+ label,
110
+ client,
111
+ pgDump: options.pgDump,
112
+ lastApplied: state.lastApplied,
113
+ keep: limits.keepBackups,
114
+ now: now(),
115
+ }).catch((error) => {
116
+ throw new Error(`Auto-migrate stopped before migrating: the backup failed. ${error instanceof Error ? error.message : String(error)}`);
117
+ });
118
+ log(`[auto-migrate] backup written to ${backup.file}`);
119
+ }
62
120
  }
63
121
  else if (!state.fresh) {
64
122
  log('[auto-migrate] MAGNUS_AUTO_MIGRATE_BACKUP=false: migrating without a dump');
@@ -102,8 +102,11 @@ export const ENV_CATALOG = [
102
102
  { name: 'MAGNUS_AUTO_MIGRATE_BACKUP', group: 'upgrades', default: 'true', description: 'Dump the database (pg_dump -Fc into MAGNUS_BACKUP_DIR) before an automatic migration. A failed dump stops startup. Set false only if you back up another way.' },
103
103
  { name: 'MAGNUS_AUTO_MIGRATE_ALLOW_SLOW', group: 'upgrades', default: 'false', description: 'Let auto-migrate apply migrations marked slow or one-way; otherwise startup stops and asks for `magnus upgrade`.' },
104
104
  { name: 'MAGNUS_BACKUP_DIR', group: 'upgrades', default: '$MAGNUS_HOME/backups', description: 'Directory for pre-migration database dumps (0700; dumps are 0600).' },
105
+ { name: 'MAGNUS_BACKUP_KEEP', group: 'upgrades', default: '5', description: 'Dumps kept in MAGNUS_BACKUP_DIR after each new one; older ones are deleted, except the newest dump of each label (each upgrade step keeps the dump its rollback needs).' },
106
+ { name: 'MAGNUS_AUTO_MIGRATE_LOCK_TIMEOUT', group: 'upgrades', default: '600', description: 'Seconds startup auto-migrate waits for another instance\'s migrate lock before it gives up and exits.' },
107
+ { name: 'MAGNUS_MIGRATE_DATABASE_URL', group: 'upgrades', secret: true, description: 'Direct PostgreSQL connection for migrations, dumps and restores when DATABASE_URL goes through a pooler such as PgBouncer (advisory locks need a session). Defaults to DATABASE_URL.' },
105
108
  { name: 'MAGNUS_PG_DUMP', group: 'upgrades', default: 'pg_dump', description: 'pg_dump binary used for pre-migration backups. Its major version must be at least the server\'s.' },
106
- { name: 'MAGNUS_RUNTIME_FILE', group: 'upgrades', default: '$MAGNUS_HOME/run/server-runtime.json', description: 'File the server writes after it starts listening ({version, pid, port}); upgrade tooling reads it to confirm the new version is serving.' },
109
+ { name: 'MAGNUS_RUNTIME_FILE', group: 'upgrades', default: '$MAGNUS_HOME/run/server-runtime.json', description: 'File the server writes after it starts listening ({version, pid, port, host}); upgrade tooling reads it to confirm the new version is serving.' },
107
110
  { name: 'MAGNUS_UPDATE_CHECK', group: 'upgrades', default: 'true', description: 'Daily check of the npm registry for newer Magnus releases, shown in the console and `magnus-server check`. Sends no data beyond a registry GET. Set false to opt out.' },
108
111
  { name: 'MAGNUS_UPDATE_REGISTRY', group: 'upgrades', default: 'https://registry.npmjs.org', description: 'npm registry used by the update check.' },
109
112
  { name: 'MAGNUS_AGENT_UPGRADES', group: 'upgrades', default: 'true', description: 'Allow server-driven agent upgrades (console action, API and `magnus-server agents upgrade`). Agents must also allow it (MAGNUS_AGENT_SELF_UPGRADE).' },
@@ -3,9 +3,10 @@
3
3
  * `magnus-server migrate`, `check` and startup auto-migrate.
4
4
  */
5
5
  import { spawn } from 'node:child_process';
6
- import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
6
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
7
7
  import { createRequire } from 'node:module';
8
- import { dirname, join } from 'node:path';
8
+ import { basename, dirname, join } from 'node:path';
9
+ import { restoreAsideSchema, restoreFinishSql, restoreRevertSql, restoreSwapSql } from '@hivemagnus/magnus-contracts';
9
10
  /** Packaged migration directory names, in apply order. */
10
11
  export function packagedMigrations(migrationsDir) {
11
12
  if (!existsSync(migrationsDir))
@@ -30,13 +31,27 @@ export function readMigrationImpact(migrationsDir, name) {
30
31
  export async function readMigrationState(client, migrationsDir) {
31
32
  const packaged = packagedMigrations(migrationsDir);
32
33
  const table = await client.query(`SELECT to_regclass('public._prisma_migrations') IS NOT NULL AS exists`);
33
- const applied = table.rows[0]?.exists
34
- ? new Set((await client.query('SELECT migration_name FROM _prisma_migrations WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL')).rows
35
- .map(row => row.migration_name))
36
- : new Set();
34
+ const rows = table.rows[0]?.exists
35
+ ? (await client.query('SELECT migration_name, finished_at, rolled_back_at FROM _prisma_migrations ORDER BY migration_name')).rows
36
+ : [];
37
+ const done = rows.filter(row => row.finished_at && !row.rolled_back_at);
38
+ const applied = new Set(done.map(row => row.migration_name));
39
+ const failed = [...new Set(rows.filter(row => !row.finished_at && !row.rolled_back_at && !applied.has(row.migration_name)).map(row => row.migration_name))];
40
+ const known = new Set(packaged);
37
41
  const pending = packaged.filter(name => !applied.has(name))
38
42
  .map(name => ({ name, ...readMigrationImpact(migrationsDir, name) }));
39
- return { packaged, applied, pending, fresh: applied.size === 0 };
43
+ const unknown = done.filter(row => !known.has(row.migration_name))
44
+ .map(row => ({ name: row.migration_name, appliedAt: row.finished_at ? new Date(row.finished_at).toISOString() : null }));
45
+ const lastApplied = [...applied].sort().at(-1) ?? null;
46
+ return { packaged, applied, pending, fresh: applied.size === 0, failed, unknown, lastApplied };
47
+ }
48
+ /** Why Prisma will not migrate a database with failed rows, and the ways out. */
49
+ export function failedMigrationAdvice(failed) {
50
+ return [
51
+ `migration(s) ${failed.join(', ')} failed earlier and are still marked as failed in _prisma_migrations; the database may be half-migrated.`,
52
+ 'Restore the dump taken before that attempt (`magnus restore <dump>`, or `magnus-server restore <dump> --env-file <file>` with the server stopped),',
53
+ 'or repair the schema by hand and then mark the migration with `npx prisma migrate resolve --rolled-back <name>` (or `--applied <name>`).',
54
+ ].join(' ');
40
55
  }
41
56
  /** Pending migrations an operator must confirm: slow ones, or ones only a restore can undo. */
42
57
  export function migrationsNeedingAttention(pending) {
@@ -67,14 +82,96 @@ export function majorOf(versionText) {
67
82
  const match = /(\d+)(?:\.\d+)?/.exec(versionText);
68
83
  return match ? Number(match[1]) : null;
69
84
  }
70
- /** A restore command that never contains the connection password. */
71
- export function restoreCommand(file) {
72
- return `pg_restore --clean --if-exists --no-owner -d "$DATABASE_URL" '${file.replace(/'/g, `'\\''`)}'`;
85
+ function shellQuote(value) {
86
+ return `'${value.replace(/'/g, `'\\''`)}'`;
87
+ }
88
+ /** The command that restores a dump; it never contains the connection string. */
89
+ export function restoreCommand(file, envFile = process.env['MAGNUS_ENV_FILE']) {
90
+ return `magnus-server restore ${shellQuote(file)}${envFile ? ` --env-file ${shellQuote(envFile)}` : ''}`;
91
+ }
92
+ /** Connection URL without its password, and the password for PGPASSWORD (never in argv). */
93
+ export function splitPassword(databaseUrl) {
94
+ try {
95
+ const url = new URL(databaseUrl);
96
+ const password = decodeURIComponent(url.password);
97
+ url.password = '';
98
+ return { url: url.toString(), env: password ? { PGPASSWORD: password } : {} };
99
+ }
100
+ catch {
101
+ return { url: databaseUrl, env: {} };
102
+ }
103
+ }
104
+ /** pg_restore next to the configured pg_dump (same client version), else from PATH. */
105
+ export function pgRestoreFor(pgDump) {
106
+ return pgDump && pgDump.includes('/') ? join(dirname(pgDump), 'pg_restore') : 'pg_restore';
107
+ }
108
+ function readMetadata(file) {
109
+ try {
110
+ return JSON.parse(readFileSync(`${file}.json`, 'utf8'));
111
+ }
112
+ catch {
113
+ return null;
114
+ }
115
+ }
116
+ function dumpsIn(dir) {
117
+ if (!existsSync(dir))
118
+ return [];
119
+ return readdirSync(dir).filter(name => name.endsWith('.dump')).map(name => join(dir, name));
120
+ }
121
+ /**
122
+ * A dump taken within `maxAgeMs` with the same label and the same newest
123
+ * applied migration: the database is in the state it captured (a restart loop
124
+ * after a failed migrate), so dumping again would only fill the disk.
125
+ */
126
+ export function findReusableBackup(dir, label, lastApplied, maxAgeMs, now = new Date()) {
127
+ let best = null;
128
+ for (const file of dumpsIn(dir)) {
129
+ const meta = readMetadata(file);
130
+ if (!meta?.createdAt || meta.label !== label || (meta.lastApplied ?? null) !== lastApplied || !('lastApplied' in meta))
131
+ continue;
132
+ const at = Date.parse(meta.createdAt);
133
+ if (!Number.isFinite(at) || now.getTime() - at > maxAgeMs || at > now.getTime())
134
+ continue;
135
+ if (statSync(file).size === 0)
136
+ continue;
137
+ if (!best || at > best.at)
138
+ best = { file, at };
139
+ }
140
+ return best?.file ?? null;
141
+ }
142
+ /**
143
+ * Keep the newest `keep` dumps; older ones go, except the newest dump of each
144
+ * label (the one a rollback of that step would restore). Returns removed files.
145
+ */
146
+ export function pruneBackups(dir, keep) {
147
+ if (!Number.isInteger(keep) || keep < 1)
148
+ return [];
149
+ const dumps = dumpsIn(dir).map(file => {
150
+ const meta = readMetadata(file);
151
+ return { file, label: meta?.label ?? basename(file), at: Date.parse(meta?.createdAt ?? '') || statSync(file).mtimeMs };
152
+ }).sort((a, b) => b.at - a.at);
153
+ const newestPerLabel = new Set();
154
+ const removed = [];
155
+ dumps.forEach((dump, index) => {
156
+ const newest = !newestPerLabel.has(dump.label);
157
+ newestPerLabel.add(dump.label);
158
+ if (index < keep || newest)
159
+ return;
160
+ rmSync(dump.file, { force: true });
161
+ rmSync(`${dump.file}.json`, { force: true });
162
+ removed.push(dump.file);
163
+ });
164
+ return removed;
165
+ }
166
+ export function backupKeep(env = process.env) {
167
+ const value = Number.parseInt(env['MAGNUS_BACKUP_KEEP'] ?? '', 10);
168
+ return Number.isInteger(value) && value > 0 ? value : 5;
73
169
  }
74
170
  /**
75
171
  * `pg_dump -Fc` into the backup directory (0700 directory, 0600 dump).
76
172
  * Refuses when pg_dump is older than the server, which would produce an
77
- * unusable or partial dump.
173
+ * unusable or partial dump. The password goes through PGPASSWORD, never argv,
174
+ * and the dump is read back with `pg_restore --list` before it counts.
78
175
  */
79
176
  export async function backupDatabase(options) {
80
177
  const run = options.run ?? runCommand;
@@ -95,23 +192,87 @@ export async function backupDatabase(options) {
95
192
  throw new Error(`${pgDump} is PostgreSQL ${clientMajor} but the server is PostgreSQL ${serverMajor}; install the PostgreSQL ${serverMajor} client and set MAGNUS_PG_DUMP to it (on Debian and Ubuntu: /usr/lib/postgresql/${serverMajor}/bin/pg_dump).`);
96
193
  }
97
194
  mkdirSync(options.backupDir, { recursive: true, mode: 0o700 });
98
- const stamp = (options.now ?? new Date()).toISOString().replace(/[:.]/g, '-');
195
+ const now = options.now ?? new Date();
196
+ const stamp = now.toISOString().replace(/[:.]/g, '-');
99
197
  const file = join(options.backupDir, `${stamp}-${options.label.replace(/[^A-Za-z0-9._-]+/g, '_')}.dump`);
100
- const result = await run(pgDump, ['--format=custom', '--no-owner', '--file', file, '--dbname', options.databaseUrl], { capture: true });
101
- if (result.code !== 0)
198
+ const { url, env } = splitPassword(options.databaseUrl);
199
+ const result = await run(pgDump, ['--format=custom', '--no-owner', '--file', file, '--dbname', url], { capture: true, env: { ...process.env, ...env } });
200
+ if (result.code !== 0) {
201
+ rmSync(file, { force: true });
102
202
  throw new Error(`pg_dump failed (exit ${result.code}): ${result.stderr.trim().split('\n').slice(-3).join(' ')}`);
203
+ }
103
204
  if (!existsSync(file) || statSync(file).size === 0)
104
205
  throw new Error(`pg_dump wrote no data to ${file}`);
105
206
  chmodSync(file, 0o600);
207
+ const listed = await run(pgRestoreFor(options.pgDump), ['--list', file], { capture: true }).catch(() => null);
208
+ if (listed && listed.code !== 0) {
209
+ rmSync(file, { force: true });
210
+ throw new Error(`the dump ${file} is not readable by pg_restore: ${(listed.stderr || listed.stdout).trim().split('\n').slice(-2).join(' ')}`);
211
+ }
106
212
  const metadataFile = `${file}.json`;
107
213
  const restore = restoreCommand(file);
108
214
  writeFileSync(metadataFile, `${JSON.stringify({
109
- createdAt: (options.now ?? new Date()).toISOString(),
215
+ createdAt: now.toISOString(),
110
216
  label: options.label,
111
217
  postgresMajor: serverMajor,
112
218
  pgDumpMajor: clientMajor,
219
+ lastApplied: options.lastApplied ?? null,
220
+ verified: listed !== null,
113
221
  restoreCommand: restore,
114
222
  }, null, 2)}\n`, { mode: 0o600 });
223
+ if (options.keep)
224
+ pruneBackups(options.backupDir, options.keep);
115
225
  return { file, metadataFile, restoreCommand: restore };
116
226
  }
227
+ /**
228
+ * Restore a dump into an empty `public` schema (see restoreSwapSql). The
229
+ * services must be stopped. On failure the original schema is put back.
230
+ */
231
+ export async function restoreDatabase(options) {
232
+ const run = options.run ?? runCommand;
233
+ const log = options.log ?? (() => undefined);
234
+ const pgRestore = options.pgRestore ?? 'pg_restore';
235
+ if (!existsSync(options.file))
236
+ throw new Error(`${options.file} does not exist`);
237
+ const listed = await run(pgRestore, ['--list', options.file], { capture: true }).catch((error) => {
238
+ throw new Error(`${pgRestore} could not be started (install the PostgreSQL client tools or set MAGNUS_PG_DUMP): ${error instanceof Error ? error.message : String(error)}`);
239
+ });
240
+ if (listed.code !== 0)
241
+ throw new Error(`${options.file} is not a readable dump: ${(listed.stderr || listed.stdout).trim().split('\n').slice(-2).join(' ')}`);
242
+ // A schema left aside by an earlier restore means that restore was interrupted (or could not drop
243
+ // it): the original data may only exist there, so never swap again on top of it.
244
+ const leftover = await options.client.query("SELECT nspname FROM pg_namespace WHERE nspname LIKE 'magnus\\_pre\\_restore\\_%' ORDER BY nspname");
245
+ if (leftover.rows.length) {
246
+ const names = leftover.rows.map(row => row.nspname).join(', ');
247
+ throw new Error(`an earlier restore left the schema ${names} behind, which may hold the original data. Check what \`public\` holds; `
248
+ + `to put the set-aside schema back run \`${restoreRevertSql(leftover.rows.at(-1).nspname)}\`, or drop it once you are sure it is not needed, then restore again.`);
249
+ }
250
+ const aside = restoreAsideSchema(options.now ?? new Date());
251
+ await options.client.query(restoreSwapSql(aside));
252
+ log(` moved the current schema aside as ${aside}`);
253
+ // From here on every failure must put the original schema back before it is reported.
254
+ const putBack = async (reason) => {
255
+ try {
256
+ await options.client.query(restoreRevertSql(aside));
257
+ }
258
+ catch (error) {
259
+ throw new Error(`${reason}, and putting the original schema back failed too; it is kept as ${aside}: ${error instanceof Error ? error.message : String(error)}`);
260
+ }
261
+ throw new Error(`${reason}; the database is unchanged`);
262
+ };
263
+ const { url, env } = splitPassword(options.databaseUrl);
264
+ let result;
265
+ try {
266
+ result = await run(pgRestore, ['--single-transaction', '--exit-on-error', '--no-owner', '--dbname', url, options.file], { capture: true, env: { ...process.env, ...env } });
267
+ }
268
+ catch (error) {
269
+ return putBack(`${pgRestore} could not be run (${error instanceof Error ? error.message : String(error)})`);
270
+ }
271
+ if (result.code !== 0)
272
+ return putBack(`pg_restore failed (exit ${result.code}): ${result.stderr.trim().split('\n').slice(-3).join(' ')}`);
273
+ // The restore committed; failing to drop the old copy must not report the restore as failed.
274
+ await options.client.query(restoreFinishSql(aside)).catch((error) => {
275
+ log(` warning: the restore succeeded but the previous schema ${aside} could not be dropped (${error instanceof Error ? error.message : String(error)}); drop it with \`${restoreFinishSql(aside)}\` once you have checked the restored data`);
276
+ });
277
+ }
117
278
  //# sourceMappingURL=migrations.js.map