@hivemagnus/magnus-server 1.1.2 → 1.1.3
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/agent-version.json +1 -1
- package/dist/cli/agents.js +103 -30
- package/dist/cli/args.js +10 -2
- package/dist/cli/auto-migrate.js +75 -17
- package/dist/cli/env-catalog.js +4 -1
- package/dist/cli/migrations.js +153 -15
- package/dist/cli.js +83 -8
- package/dist/index.js +1 -1
- package/dist/openapi/spec.js +36 -7
- package/dist/routes/agent-upgrades.js +28 -7
- package/dist/routes/heartbeat.js +2 -1
- package/dist/routes/resource-leases.js +5 -7
- package/dist/sdk/node-api-client.js +96 -4
- package/dist/server.js +1 -0
- package/dist/services/agent-upgrade-service.js +610 -150
- package/dist/services/command-resource-leases.js +13 -2
- package/dist/services/managed-cron.js +4 -7
- package/dist/services/node-issue-monitor.js +2 -0
- package/dist/services/resource-enrollment.js +73 -18
- package/dist/services/resource-ownership.js +20 -0
- package/dist/utils/runtime-file.js +25 -1
- package/package.json +3 -3
package/agent-version.json
CHANGED
package/dist/cli/agents.js
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
60
|
-
|
|
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:
|
|
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 =
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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(
|
|
127
|
+
console.log(nodeLine(node));
|
|
97
128
|
}
|
|
98
129
|
await save(run);
|
|
99
130
|
};
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
package/dist/cli/auto-migrate.js
CHANGED
|
@@ -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
|
|
7
|
-
*
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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');
|
package/dist/cli/env-catalog.js
CHANGED
|
@@ -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).' },
|
package/dist/cli/migrations.js
CHANGED
|
@@ -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
|
|
34
|
-
?
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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,64 @@ 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
|
|
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
|
|
101
|
-
|
|
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:
|
|
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
|
+
const aside = restoreAsideSchema(options.now ?? new Date());
|
|
243
|
+
await options.client.query(restoreSwapSql(aside));
|
|
244
|
+
log(` moved the current schema aside as ${aside}`);
|
|
245
|
+
const { url, env } = splitPassword(options.databaseUrl);
|
|
246
|
+
const result = await run(pgRestore, ['--single-transaction', '--exit-on-error', '--no-owner', '--dbname', url, options.file], { capture: true, env: { ...process.env, ...env } });
|
|
247
|
+
if (result.code !== 0) {
|
|
248
|
+
await options.client.query(restoreRevertSql(aside)).catch((error) => {
|
|
249
|
+
throw new Error(`pg_restore failed and putting the original schema back failed too; it is kept as ${aside}: ${error instanceof Error ? error.message : String(error)}`);
|
|
250
|
+
});
|
|
251
|
+
throw new Error(`pg_restore failed (exit ${result.code}); the database is unchanged: ${result.stderr.trim().split('\n').slice(-3).join(' ')}`);
|
|
252
|
+
}
|
|
253
|
+
await options.client.query(restoreFinishSql(aside));
|
|
254
|
+
}
|
|
117
255
|
//# sourceMappingURL=migrations.js.map
|