@hivemagnus/magnus-server 1.1.1 → 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 +10 -10
package/dist/cli.js
CHANGED
|
@@ -56,9 +56,11 @@ async function migrate() {
|
|
|
56
56
|
catch {
|
|
57
57
|
return fatal('Migration error: the prisma CLI package is not installed next to magnus-server.');
|
|
58
58
|
}
|
|
59
|
+
// MAGNUS_MIGRATE_DATABASE_URL: a direct connection for migrations when DATABASE_URL goes through a pooler.
|
|
60
|
+
const migrateUrl = process.env['MAGNUS_MIGRATE_DATABASE_URL']?.trim();
|
|
59
61
|
const code = await new Promise((resolvePromise, reject) => {
|
|
60
62
|
const child = spawn(process.execPath, [prismaCli, 'migrate', 'deploy', '--config', PRISMA_CONFIG], {
|
|
61
|
-
cwd: PACKAGE_ROOT, env: process.env, stdio: 'inherit',
|
|
63
|
+
cwd: PACKAGE_ROOT, env: migrateUrl ? { ...process.env, DATABASE_URL: migrateUrl } : process.env, stdio: 'inherit',
|
|
62
64
|
});
|
|
63
65
|
child.on('error', reject);
|
|
64
66
|
child.on('close', exitCode => resolvePromise(exitCode ?? 1));
|
|
@@ -141,13 +143,23 @@ async function commandMigrations(format) {
|
|
|
141
143
|
try {
|
|
142
144
|
const state = await readMigrationState(client, MIGRATIONS_DIR);
|
|
143
145
|
if (format === 'json') {
|
|
144
|
-
|
|
146
|
+
const size = await client.query('SELECT pg_database_size(current_database())::text AS bytes, current_setting(\'server_version_num\') AS version')
|
|
147
|
+
.then(result => result.rows[0]).catch(() => undefined);
|
|
148
|
+
console.log(JSON.stringify({
|
|
149
|
+
version: await version(), packaged: state.packaged, applied: [...state.applied].sort(), pending: state.pending, fresh: state.fresh,
|
|
150
|
+
failed: state.failed, unknown: state.unknown,
|
|
151
|
+
databaseBytes: size ? Number(size.bytes) : null, postgresMajor: size ? Math.floor(Number(size.version) / 10_000) : null,
|
|
152
|
+
}, null, 2));
|
|
145
153
|
}
|
|
146
154
|
else {
|
|
147
155
|
for (const name of state.packaged) {
|
|
148
156
|
const pending = state.pending.find(migration => migration.name === name);
|
|
149
157
|
console.log(` ${pending ? '…' : '✓'} ${name}${pending ? ` (pending, ${pending.impact}${pending.note ? `: ${pending.note}` : ''})` : ''}`);
|
|
150
158
|
}
|
|
159
|
+
for (const migration of state.unknown)
|
|
160
|
+
console.log(` ? ${migration.name} (applied by a newer release; unknown to this version)`);
|
|
161
|
+
for (const name of state.failed)
|
|
162
|
+
console.log(` ✗ ${name} (failed; see \`prisma migrate resolve\` or restore a dump)`);
|
|
151
163
|
console.log(state.pending.length === 0 ? 'All migrations applied.' : `${state.pending.length} pending; run \`magnus-server migrate\`.`);
|
|
152
164
|
}
|
|
153
165
|
return 0;
|
|
@@ -159,17 +171,19 @@ async function commandMigrations(format) {
|
|
|
159
171
|
/** `pg_dump -Fc` into MAGNUS_BACKUP_DIR; prints JSON with the file and restore command. */
|
|
160
172
|
async function commandBackup(label) {
|
|
161
173
|
const { resolveDatabaseUrl } = await import('./db/client.js');
|
|
162
|
-
const { backupDatabase } = await import('./cli/migrations.js');
|
|
163
|
-
const { defaultBackupDir } = await import('./cli/auto-migrate.js');
|
|
174
|
+
const { backupDatabase, backupKeep, readMigrationState } = await import('./cli/migrations.js');
|
|
175
|
+
const { defaultBackupDir, migrateDatabaseUrl } = await import('./cli/auto-migrate.js');
|
|
164
176
|
const { magnusHome } = await import('./utils/magnus-home.js');
|
|
165
177
|
const pg = (await import('pg')).default;
|
|
166
|
-
const databaseUrl = resolveDatabaseUrl();
|
|
178
|
+
const databaseUrl = migrateDatabaseUrl(process.env, resolveDatabaseUrl());
|
|
167
179
|
const client = new pg.Client({ connectionString: databaseUrl, connectionTimeoutMillis: 10_000 });
|
|
168
180
|
await client.connect();
|
|
169
181
|
try {
|
|
182
|
+
const state = await readMigrationState(client, MIGRATIONS_DIR).catch(() => null);
|
|
170
183
|
const result = await backupDatabase({
|
|
171
184
|
databaseUrl, client, backupDir: defaultBackupDir(process.env, magnusHome()),
|
|
172
185
|
label: label ?? `manual-${await version()}`, pgDump: process.env['MAGNUS_PG_DUMP'],
|
|
186
|
+
lastApplied: state?.lastApplied ?? null, keep: backupKeep(),
|
|
173
187
|
});
|
|
174
188
|
console.log(JSON.stringify(result));
|
|
175
189
|
return 0;
|
|
@@ -178,18 +192,76 @@ async function commandBackup(label) {
|
|
|
178
192
|
await client.end().catch(() => undefined);
|
|
179
193
|
}
|
|
180
194
|
}
|
|
195
|
+
/** Replace the database with a dump; refuses while this host's server is running. */
|
|
196
|
+
async function commandRestore(file, yes) {
|
|
197
|
+
const { resolveDatabaseUrl } = await import('./db/client.js');
|
|
198
|
+
const { pgRestoreFor, restoreDatabase } = await import('./cli/migrations.js');
|
|
199
|
+
const { migrateDatabaseUrl } = await import('./cli/auto-migrate.js');
|
|
200
|
+
const { runningServer } = await import('./utils/runtime-file.js');
|
|
201
|
+
const running = runningServer();
|
|
202
|
+
if (running)
|
|
203
|
+
fatal(`The server (pid ${running.pid}, ${running.version}) is still running; stop it before restoring (for example: systemctl stop magnus-server, or docker compose stop magnus-server).`);
|
|
204
|
+
const path = resolve(file);
|
|
205
|
+
if (!yes) {
|
|
206
|
+
if (!process.stdin.isTTY)
|
|
207
|
+
fatal('Restoring replaces the whole database; pass --yes to confirm non-interactively.');
|
|
208
|
+
const { createInterface } = await import('node:readline/promises');
|
|
209
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
210
|
+
const answer = await rl.question(`Replace the database with ${path}? Changes made since the dump are lost [y/N] `).finally(() => rl.close());
|
|
211
|
+
if (!/^y(es)?$/i.test(answer.trim()))
|
|
212
|
+
return 1;
|
|
213
|
+
}
|
|
214
|
+
const pg = (await import('pg')).default;
|
|
215
|
+
const databaseUrl = migrateDatabaseUrl(process.env, resolveDatabaseUrl());
|
|
216
|
+
const client = new pg.Client({ connectionString: databaseUrl, connectionTimeoutMillis: 10_000 });
|
|
217
|
+
await client.connect();
|
|
218
|
+
try {
|
|
219
|
+
await restoreDatabase({ databaseUrl, file: path, client, pgRestore: pgRestoreFor(process.env['MAGNUS_PG_DUMP']), log: line => console.log(line) });
|
|
220
|
+
console.log(`Restored ${path}. Start the server version that matches the dump.`);
|
|
221
|
+
return 0;
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
return fatal(`Restore failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
await client.end().catch(() => undefined);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/** Warn at startup when a newer release migrated the database (best effort). */
|
|
231
|
+
async function warnSchemaAhead() {
|
|
232
|
+
try {
|
|
233
|
+
const { resolveDatabaseUrl } = await import('./db/client.js');
|
|
234
|
+
const { readMigrationState } = await import('./cli/migrations.js');
|
|
235
|
+
const pg = (await import('pg')).default;
|
|
236
|
+
const client = new pg.Client({ connectionString: resolveDatabaseUrl(), connectionTimeoutMillis: 5_000 });
|
|
237
|
+
await client.connect();
|
|
238
|
+
try {
|
|
239
|
+
const state = await readMigrationState(client, MIGRATIONS_DIR);
|
|
240
|
+
if (state.unknown.length > 0) {
|
|
241
|
+
console.warn(`[startup] warning: the database has ${state.unknown.length} migration(s) this version does not know (a newer release applied them): `
|
|
242
|
+
+ `${state.unknown.map(m => m.name).join(', ')}. If this version misbehaves, upgrade again or restore the dump taken before that upgrade.`);
|
|
243
|
+
}
|
|
244
|
+
if (state.failed.length > 0)
|
|
245
|
+
console.warn(`[startup] warning: failed migration(s) ${state.failed.join(', ')}; run \`magnus-server migrations\`.`);
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
await client.end().catch(() => undefined);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
catch { /* the server reports database problems itself */ }
|
|
252
|
+
}
|
|
181
253
|
/** `MAGNUS_AUTO_MIGRATE=true`: back up and migrate under an advisory lock before listening. */
|
|
182
254
|
async function startupAutoMigrate() {
|
|
183
255
|
const { upgradeSettings } = await import('./config/features.js');
|
|
184
256
|
const settings = upgradeSettings();
|
|
185
257
|
if (!settings.autoMigrate)
|
|
186
|
-
return;
|
|
258
|
+
return warnSchemaAhead();
|
|
187
259
|
const { resolveDatabaseUrl } = await import('./db/client.js');
|
|
188
|
-
const { autoMigrate, defaultBackupDir } = await import('./cli/auto-migrate.js');
|
|
260
|
+
const { autoMigrate, defaultBackupDir, migrateDatabaseUrl } = await import('./cli/auto-migrate.js');
|
|
189
261
|
const { magnusHome } = await import('./utils/magnus-home.js');
|
|
190
262
|
try {
|
|
191
263
|
await autoMigrate({
|
|
192
|
-
databaseUrl: resolveDatabaseUrl(),
|
|
264
|
+
databaseUrl: migrateDatabaseUrl(process.env, resolveDatabaseUrl()),
|
|
193
265
|
migrationsDir: MIGRATIONS_DIR,
|
|
194
266
|
backupDir: defaultBackupDir(process.env, magnusHome()),
|
|
195
267
|
version: await version(),
|
|
@@ -243,6 +315,9 @@ async function run(argv) {
|
|
|
243
315
|
case 'backup':
|
|
244
316
|
loadEnvFiles(options.envFiles);
|
|
245
317
|
return commandBackup(options.label);
|
|
318
|
+
case 'restore':
|
|
319
|
+
loadEnvFiles(options.envFiles);
|
|
320
|
+
return commandRestore(options.file, options.yes);
|
|
246
321
|
case 'upgrade':
|
|
247
322
|
case 'rollback': {
|
|
248
323
|
const { delegateToProject } = await import('./cli/upgrade.js');
|
package/dist/index.js
CHANGED
|
@@ -56,7 +56,7 @@ const { writeRuntimeFile } = await import('./utils/runtime-file.js');
|
|
|
56
56
|
const { CONTROL_API_VERSION } = await import('./config/version.js');
|
|
57
57
|
/** Background services shared by the TLS and development listeners. */
|
|
58
58
|
function startBackgroundServices(listenPort) {
|
|
59
|
-
writeRuntimeFile({ version: CONTROL_API_VERSION, port: listenPort });
|
|
59
|
+
writeRuntimeFile({ version: CONTROL_API_VERSION, port: listenPort, host: listener.hostname });
|
|
60
60
|
startNodeIssueMonitor();
|
|
61
61
|
startApplicationProbes();
|
|
62
62
|
startLogRetention();
|
package/dist/openapi/spec.js
CHANGED
|
@@ -2156,13 +2156,16 @@ const paths = {
|
|
|
2156
2156
|
get: {
|
|
2157
2157
|
tags: ['Agent upgrades'],
|
|
2158
2158
|
summary: 'Which agents can take a version',
|
|
2159
|
-
description: 'For every node: its agent version and whether it can upgrade itself to `version` (default: the agent version this server
|
|
2159
|
+
description: 'For every node: its agent version and whether it can upgrade itself to `version` (default: the agent version bundled with this server) now, or why not: offline, not set up with `magnus-agent install-service`, MAGNUS_AGENT_SELF_UPGRADE=false, another major version, already current, already reverted this version, a closed release admission fence, a resource enrollment that is not complete (for example "stuck in draining since <date>"), held participant work or command leases, or an agent whose sticky managed-enrollment marker the server does not share. `release.verified` is false when the registry does not have the version.',
|
|
2160
2160
|
security: apiSecretAuth,
|
|
2161
|
-
parameters: [qp('version', 'Target agent version (X.Y.Z)')],
|
|
2161
|
+
parameters: [qp('version', 'Target agent version (X.Y.Z)'), qp('allowRetryReverted', '`true` to offer nodes that already reverted this version')],
|
|
2162
2162
|
responses: { 200: success('Eligibility', { type: 'object', properties: {
|
|
2163
|
-
version: { type: 'string' }, upgradesEnabled: { type: 'boolean' }, disabledReason: { type: ['string', 'null'] },
|
|
2163
|
+
version: { type: 'string' }, serverVersion: { type: 'string' }, upgradesEnabled: { type: 'boolean' }, disabledReason: { type: ['string', 'null'] },
|
|
2164
|
+
prerelease: { type: 'boolean' },
|
|
2165
|
+
release: { type: 'object', properties: { verified: { type: 'boolean' }, integrity: { type: ['string', 'null'] }, reason: { type: ['string', 'null'] } } },
|
|
2164
2166
|
nodes: { type: 'array', items: { type: 'object', properties: { nodeId: { type: 'string' }, name: { type: 'string' },
|
|
2165
2167
|
version: { type: 'string' }, eligible: { type: 'boolean' }, reason: { type: ['string', 'null'] } } } },
|
|
2168
|
+
latestRun: { type: ['object', 'null'] },
|
|
2166
2169
|
} }), 400: err400, 401: err401 },
|
|
2167
2170
|
},
|
|
2168
2171
|
},
|
|
@@ -2170,12 +2173,15 @@ const paths = {
|
|
|
2170
2173
|
post: {
|
|
2171
2174
|
tags: ['Agent upgrades'],
|
|
2172
2175
|
summary: 'Upgrade agents',
|
|
2173
|
-
description: 'Starts an upgrade run (ADMIN, MAGNUS_AGENT_UPGRADES=true): one canary, then MAGNUS_AGENT_UPGRADE_CONCURRENCY agents at a time
|
|
2176
|
+
description: 'Starts an upgrade run (ADMIN, MAGNUS_AGENT_UPGRADES=true): one canary, then MAGNUS_AGENT_UPGRADE_CONCURRENCY agents at a time. Each agent installs the exact version and npm integrity and switches back by itself if the new version does not report a heartbeat in time. A revert, a release problem (integrity, install failure) or an agent that goes silent after the request stops the run. An agent that refuses before changing anything is recorded as `refused` and the run goes on; a refused canary hands the role to the next node. Only one run at a time. Selected nodes that are not eligible are listed in `excluded`; unknown node ids are a 400.',
|
|
2174
2177
|
security: apiSecretAuth,
|
|
2175
2178
|
requestBody: { required: false, content: { 'application/json': { schema: { type: 'object', properties: {
|
|
2176
|
-
version: { type: 'string', description: 'Agent version (default: the one this server
|
|
2179
|
+
version: { type: 'string', description: 'Agent version (default: the one bundled with this server)' },
|
|
2177
2180
|
integrity: { type: 'string', description: 'npm sha512 integrity (default: read from the registry)' },
|
|
2178
|
-
nodeIds: { type: 'array', items: { type: 'string' }, description: 'Only these nodes' },
|
|
2181
|
+
nodeIds: { type: 'array', minItems: 1, items: { type: 'string' }, description: 'Only these nodes (leave out for every eligible agent)' },
|
|
2182
|
+
canaryNodeId: { type: 'string', description: 'Upgrade this node first' },
|
|
2183
|
+
allowPrerelease: { type: 'boolean', description: 'Required for a prerelease version' },
|
|
2184
|
+
allowRetryReverted: { type: 'boolean', description: 'Also upgrade nodes that already reverted this version' },
|
|
2179
2185
|
} } } } },
|
|
2180
2186
|
responses: { 202: success('Run started'), 400: err400, 401: err401, 403: err403, 409: err409 },
|
|
2181
2187
|
},
|
|
@@ -2192,12 +2198,35 @@ const paths = {
|
|
|
2192
2198
|
get: {
|
|
2193
2199
|
tags: ['Agent upgrades'],
|
|
2194
2200
|
summary: 'Upgrade run status',
|
|
2195
|
-
description: '
|
|
2201
|
+
description: 'Run status (running, succeeded, failed, stopped or interrupted) and per-node state: pending, requested, confirmed, reverted, failed, refused or skipped, with the agent error code and message. A run whose server stopped renewing its lease (restart, interrupted CLI) is shown as interrupted; its requested nodes are settled from their heartbeat reports.',
|
|
2196
2202
|
security: apiSecretAuth,
|
|
2197
2203
|
parameters: [pp('id', 'Run ID')],
|
|
2198
2204
|
responses: { 200: success('Run'), 401: err401, 404: err404 },
|
|
2199
2205
|
},
|
|
2200
2206
|
},
|
|
2207
|
+
'/agents/upgrade/runs/{id}/stop': {
|
|
2208
|
+
post: {
|
|
2209
|
+
tags: ['Agent upgrades'],
|
|
2210
|
+
summary: 'Stop an upgrade run',
|
|
2211
|
+
description: 'ADMIN. The run stops before its next batch; nodes already asked to upgrade finish their trial.',
|
|
2212
|
+
security: apiSecretAuth,
|
|
2213
|
+
parameters: [pp('id', 'Run ID')],
|
|
2214
|
+
responses: { 202: success('Stop requested'), 401: err401, 403: err403, 404: err404, 409: err409 },
|
|
2215
|
+
},
|
|
2216
|
+
},
|
|
2217
|
+
'/agents/upgrade/runs/{id}/retry': {
|
|
2218
|
+
post: {
|
|
2219
|
+
tags: ['Agent upgrades'],
|
|
2220
|
+
summary: 'Retry an upgrade run',
|
|
2221
|
+
description: 'ADMIN. Starts a new run with the same version and integrity for the failed, refused, skipped and interrupted nodes of a finished run (reverted nodes only with allowRetryReverted).',
|
|
2222
|
+
security: apiSecretAuth,
|
|
2223
|
+
parameters: [pp('id', 'Run ID')],
|
|
2224
|
+
requestBody: { required: false, content: { 'application/json': { schema: { type: 'object', properties: {
|
|
2225
|
+
canaryNodeId: { type: 'string' }, allowRetryReverted: { type: 'boolean' },
|
|
2226
|
+
} } } } },
|
|
2227
|
+
responses: { 202: success('Run started'), 400: err400, 401: err401, 403: err403, 404: err404, 409: err409 },
|
|
2228
|
+
},
|
|
2229
|
+
},
|
|
2201
2230
|
'/self-update/status': {
|
|
2202
2231
|
get: {
|
|
2203
2232
|
tags: ['Self-Update'],
|
|
@@ -4,32 +4,43 @@ import { authMiddleware, getUser, requireRole } from '../middleware/auth.js';
|
|
|
4
4
|
import { HttpError } from '../middleware/error-handler.js';
|
|
5
5
|
import { upgradeSettings } from '../config/features.js';
|
|
6
6
|
import { AGENT_VERSION } from '../config/version.js';
|
|
7
|
-
import { getUpgradeRun, latestUpgradeRun, listUpgradeCandidates, startUpgradeRun } from '../services/agent-upgrade-service.js';
|
|
7
|
+
import { getUpgradeRun, isPrerelease, latestUpgradeRun, listUpgradeCandidates, retryUpgradeRun, startUpgradeRun, stopUpgradeRun, verifyAgentRelease } from '../services/agent-upgrade-service.js';
|
|
8
8
|
/** /api/v1/agents/upgrade: which agents can take a version, and fleet upgrade runs. */
|
|
9
9
|
export const agentUpgradesRouter = new Hono();
|
|
10
10
|
agentUpgradesRouter.use('*', authMiddleware);
|
|
11
11
|
const version = z.string().regex(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/);
|
|
12
|
+
const nodeId = z.string().min(1).max(128);
|
|
12
13
|
agentUpgradesRouter.get('/eligibility', async (c) => {
|
|
13
14
|
const target = version.safeParse(c.req.query('version') ?? AGENT_VERSION);
|
|
14
15
|
if (!target.success)
|
|
15
16
|
throw new HttpError(400, 'INVALID_VERSION', 'version must be X.Y.Z');
|
|
16
|
-
const nodes = await
|
|
17
|
+
const [nodes, release, latestRun] = await Promise.all([
|
|
18
|
+
listUpgradeCandidates(target.data, undefined, { allowRetryReverted: c.req.query('allowRetryReverted') === 'true' }),
|
|
19
|
+
verifyAgentRelease(target.data), latestUpgradeRun()
|
|
20
|
+
]);
|
|
17
21
|
return c.json({ success: true, data: {
|
|
18
22
|
version: target.data, serverVersion: AGENT_VERSION, upgradesEnabled: upgradeSettings().agentUpgrades,
|
|
19
23
|
disabledReason: upgradeSettings().agentUpgrades ? null : 'MAGNUS_AGENT_UPGRADES=false on the server',
|
|
20
|
-
|
|
24
|
+
prerelease: isPrerelease(target.data), release, nodes, latestRun,
|
|
21
25
|
} });
|
|
22
26
|
});
|
|
23
27
|
const startSchema = z.object({
|
|
24
28
|
version: version.optional(),
|
|
25
29
|
integrity: z.string().regex(/^sha512-[A-Za-z0-9+/]+={0,2}$/).optional(),
|
|
26
|
-
nodeIds: z.array(
|
|
30
|
+
nodeIds: z.array(nodeId).min(1, 'nodeIds must name at least one node; leave it out for every eligible agent').max(1000).optional(),
|
|
31
|
+
canaryNodeId: nodeId.optional(),
|
|
32
|
+
allowPrerelease: z.boolean().optional(),
|
|
33
|
+
allowRetryReverted: z.boolean().optional(),
|
|
27
34
|
}).strict();
|
|
28
|
-
|
|
29
|
-
const parsed =
|
|
35
|
+
const parseBody = async (schema, raw) => {
|
|
36
|
+
const parsed = schema.safeParse(raw);
|
|
30
37
|
if (!parsed.success)
|
|
31
38
|
throw new HttpError(400, 'VALIDATION_ERROR', parsed.error.issues.map(issue => issue.message).join('; '));
|
|
32
|
-
|
|
39
|
+
return parsed.data;
|
|
40
|
+
};
|
|
41
|
+
agentUpgradesRouter.post('/', requireRole('ADMIN'), async (c) => {
|
|
42
|
+
const body = await parseBody(startSchema, await c.req.json().catch(() => ({})));
|
|
43
|
+
const { run } = await startUpgradeRun({ ...body, actorId: getUser(c).userId });
|
|
33
44
|
return c.json({ success: true, data: run }, 202);
|
|
34
45
|
});
|
|
35
46
|
agentUpgradesRouter.get('/runs/latest', async (c) => c.json({ success: true, data: await latestUpgradeRun() }));
|
|
@@ -39,4 +50,14 @@ agentUpgradesRouter.get('/runs/:id', async (c) => {
|
|
|
39
50
|
throw new HttpError(404, 'NOT_FOUND', 'Upgrade run not found');
|
|
40
51
|
return c.json({ success: true, data: run });
|
|
41
52
|
});
|
|
53
|
+
agentUpgradesRouter.post('/runs/:id/stop', requireRole('ADMIN'), async (c) => c.json({ success: true, data: await stopUpgradeRun(c.req.param('id'), getUser(c).userId) }, 202));
|
|
54
|
+
const retrySchema = z.object({
|
|
55
|
+
canaryNodeId: nodeId.optional(),
|
|
56
|
+
allowRetryReverted: z.boolean().optional(),
|
|
57
|
+
}).strict();
|
|
58
|
+
agentUpgradesRouter.post('/runs/:id/retry', requireRole('ADMIN'), async (c) => {
|
|
59
|
+
const body = await parseBody(retrySchema, await c.req.json().catch(() => ({})));
|
|
60
|
+
const { run } = await retryUpgradeRun(c.req.param('id'), { ...body, actorId: getUser(c).userId });
|
|
61
|
+
return c.json({ success: true, data: run }, 202);
|
|
62
|
+
});
|
|
42
63
|
//# sourceMappingURL=agent-upgrades.js.map
|
package/dist/routes/heartbeat.js
CHANGED
|
@@ -332,7 +332,8 @@ heartbeatRouter.post('/', async (c) => {
|
|
|
332
332
|
}) },
|
|
333
333
|
});
|
|
334
334
|
}
|
|
335
|
-
|
|
335
|
+
// Also without a (valid) report, so an agent that stopped reporting one does not stay UPDATING.
|
|
336
|
+
if (existing) {
|
|
336
337
|
await recordSelfUpgradeReport({ id: nodeId, name: existing.name }, body).catch((err) => {
|
|
337
338
|
console.error(`[agent-upgrade] could not record the self-upgrade report of ${existing.name}:`, err instanceof Error ? err.message : err);
|
|
338
339
|
});
|
|
@@ -11,6 +11,7 @@ import { HttpError } from '../middleware/error-handler.js';
|
|
|
11
11
|
import { NODE_MANAGER_SECRET } from '../utils/env.js';
|
|
12
12
|
import { verifyWithRotation } from '../services/credential-rotation-service.js';
|
|
13
13
|
import { resourceLeasesRequested } from '../services/command-resource-leases.js';
|
|
14
|
+
import { readOwnershipHistory, stickyEnrollmentObserved } from '../services/resource-ownership.js';
|
|
14
15
|
import { activateArdentScope, ardentScopeActivationSchema, assertArdentScope, inspectArdentScope } from '../services/ardent-scope-enrollment.js';
|
|
15
16
|
import { legacyAdmissionRequestSchema, resourceClaimSchema } from '../security/managed-http.js';
|
|
16
17
|
import { participantWorkReconciliationSchema, historicalParticipantWorkReconciliationSchema } from '../schemas/resource-participant-reconciliation.js';
|
|
@@ -144,14 +145,11 @@ resourceLeasesRouter.post('/legacy-admission', async (c) => {
|
|
|
144
145
|
if (current?.hostname !== body.nodeFqdn)
|
|
145
146
|
throw new HttpError(409, 'RESOURCE_TARGET_INVALID', 'Inventory target changed');
|
|
146
147
|
await assertNodeAdmissionOpen(tx, node.id);
|
|
147
|
-
const [enrollment,
|
|
148
|
-
tx.resourceEnrollment.findUnique({ where: { nodeId: node.id } }),
|
|
149
|
-
tx.commandResourceLease.findFirst({ where: { nodeId: node.id }, select: { id: true } }),
|
|
150
|
-
tx.managedCronDefinition.findFirst({ where: { nodeId: node.id }, select: { cronJobId: true } }),
|
|
151
|
-
tx.resourceParticipantWork.findFirst({ where: { nodeId: node.id }, select: { workId: true } }),
|
|
148
|
+
const [enrollment, history] = await Promise.all([
|
|
149
|
+
tx.resourceEnrollment.findUnique({ where: { nodeId: node.id } }), readOwnershipHistory(tx, node.id)
|
|
152
150
|
]);
|
|
153
|
-
|
|
154
|
-
|
|
151
|
+
// Sticky rule (see resource-ownership.ts): the agent stores `enrolled` for good.
|
|
152
|
+
const enrolled = stickyEnrollmentObserved(enrollment, history);
|
|
155
153
|
const gateEnabled = resourceLeasesRequested();
|
|
156
154
|
return { ...body, nodeId: node.id, enrolled, gateEnabled, allowedLegacy: !enrolled && !gateEnabled };
|
|
157
155
|
});
|
|
@@ -6,7 +6,7 @@ import { commandReconciliationSchema } from '../schemas/command-reconciliation.j
|
|
|
6
6
|
import { admitResourceCommand, markResourceDispatched, failResourceCommand, settleResourceResult, settleResourceReconciliation } from '../services/command-resource-leases.js';
|
|
7
7
|
import { commandFenceSchema, commandResultSchema } from '../schemas/node-responses.js';
|
|
8
8
|
import { commandRequestDigest } from '../security/command-request.js';
|
|
9
|
-
import ky from 'ky';
|
|
9
|
+
import ky, { HTTPError } from 'ky';
|
|
10
10
|
import { HttpError } from '../middleware/error-handler.js';
|
|
11
11
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
12
12
|
import { z } from 'zod';
|
|
@@ -63,6 +63,82 @@ async function rejectUnprivilegedAgent({ response }) {
|
|
|
63
63
|
export function isAgentNotPrivileged(error) {
|
|
64
64
|
return error instanceof HttpError && error.code === AGENT_NOT_PRIVILEGED;
|
|
65
65
|
}
|
|
66
|
+
/** `{ success: false, error: { code, message } }` from an agent reply (object or JSON text). */
|
|
67
|
+
export function agentErrorBody(raw) {
|
|
68
|
+
let value = raw;
|
|
69
|
+
if (typeof raw === 'string') {
|
|
70
|
+
try {
|
|
71
|
+
value = JSON.parse(raw);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const error = value?.error;
|
|
78
|
+
if (!error || typeof error.code !== 'string')
|
|
79
|
+
return null;
|
|
80
|
+
return { code: error.code.slice(0, 64), message: typeof error.message === 'string' ? error.message.slice(0, 1000) : error.code };
|
|
81
|
+
}
|
|
82
|
+
/** The agent answered a mutation with an error status. `agentStatus` is not an HTTP status for our callers. */
|
|
83
|
+
export class AgentResponseError extends Error {
|
|
84
|
+
agentStatus;
|
|
85
|
+
code;
|
|
86
|
+
agentMessage;
|
|
87
|
+
constructor(agentStatus, body) {
|
|
88
|
+
super(`Native mutation completed with HTTP ${agentStatus}${body ? `: ${body.code}: ${body.message}` : ''}`);
|
|
89
|
+
this.name = 'AgentResponseError';
|
|
90
|
+
this.agentStatus = agentStatus;
|
|
91
|
+
this.code = body?.code ?? null;
|
|
92
|
+
this.agentMessage = body?.message ?? null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Thrown by `upgradeAgent` for every failure before the request left: the node is untouched. */
|
|
96
|
+
export class NotDispatchedError extends HttpError {
|
|
97
|
+
dispatched = false;
|
|
98
|
+
constructor(code, message, cause) {
|
|
99
|
+
super(409, code, message);
|
|
100
|
+
this.name = 'NotDispatchedError';
|
|
101
|
+
if (cause !== undefined)
|
|
102
|
+
this.cause = cause;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** The request may have reached the agent but no answer came back. */
|
|
106
|
+
export class DispatchOutcomeUnknownError extends Error {
|
|
107
|
+
code = 'MUTATION_OUTCOME_UNKNOWN';
|
|
108
|
+
constructor(message, cause) {
|
|
109
|
+
super(message);
|
|
110
|
+
this.name = 'DispatchOutcomeUnknownError';
|
|
111
|
+
if (cause !== undefined)
|
|
112
|
+
this.cause = cause;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const CONNECT_ERRORS = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EHOSTUNREACH', 'ENETUNREACH', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT']);
|
|
116
|
+
/** A connection that never opened cannot have carried the request. */
|
|
117
|
+
export function isConnectError(error) {
|
|
118
|
+
for (let current = error, depth = 0; current && typeof current === 'object' && depth < 5; current = current.cause, depth++) {
|
|
119
|
+
const code = current.code;
|
|
120
|
+
if (typeof code === 'string' && CONNECT_ERRORS.has(code))
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
function errorText(error) {
|
|
126
|
+
return (error instanceof Error ? error.message : String(error)).slice(0, 1000);
|
|
127
|
+
}
|
|
128
|
+
/** Sort an upgradeAgent failure by whether the request can have reached the agent. */
|
|
129
|
+
export function classifyAgentRequestError(error, dispatched) {
|
|
130
|
+
if (!dispatched)
|
|
131
|
+
return error instanceof NotDispatchedError ? error : new NotDispatchedError(error instanceof HttpError ? error.code : error instanceof AgentResponseError && error.code ? error.code : 'NOT_DISPATCHED', errorText(error), error);
|
|
132
|
+
if (error instanceof AgentResponseError)
|
|
133
|
+
return error;
|
|
134
|
+
if (error instanceof HTTPError)
|
|
135
|
+
return new AgentResponseError(error.response.status, agentErrorBody(error.data));
|
|
136
|
+
if (error instanceof HttpError && error.code === AGENT_NOT_PRIVILEGED)
|
|
137
|
+
return new AgentResponseError(403, { code: error.code, message: error.message });
|
|
138
|
+
if (isConnectError(error))
|
|
139
|
+
return new NotDispatchedError('AGENT_UNREACHABLE', `agent unreachable: ${errorText(error)}`, error);
|
|
140
|
+
return new DispatchOutcomeUnknownError(`no answer from the agent after the request was sent: ${errorText(error)}`, error);
|
|
141
|
+
}
|
|
66
142
|
export class NodeApiClient {
|
|
67
143
|
api;
|
|
68
144
|
nodeToken;
|
|
@@ -85,7 +161,7 @@ export class NodeApiClient {
|
|
|
85
161
|
});
|
|
86
162
|
}
|
|
87
163
|
/** All finite native mutations use the same central owner and node execution slot. */
|
|
88
|
-
async mutateWithEvidence(method, path, body, timeout = this.defaultTimeout, commandId, skillPublication, requireManaged = false) {
|
|
164
|
+
async mutateWithEvidence(method, path, body, timeout = this.defaultTimeout, commandId, skillPublication, requireManaged = false, dispatch) {
|
|
89
165
|
if (!this.resourceContext)
|
|
90
166
|
throw new Error('Managed mutation requires canonical resource node identity');
|
|
91
167
|
const serialized = body === undefined ? '' : JSON.stringify(body);
|
|
@@ -97,6 +173,11 @@ export class NodeApiClient {
|
|
|
97
173
|
const admission = await admitResourceCommand(this.resourceContext, identity);
|
|
98
174
|
if (!admission && requireManaged)
|
|
99
175
|
throw new Error('This operation requires enrolled managed admission; direct node mutation is forbidden');
|
|
176
|
+
if (!admission) {
|
|
177
|
+
if (dispatch?.pingFirst)
|
|
178
|
+
await this.api.get('ping', { timeout: 10_000, retry: 0 });
|
|
179
|
+
dispatch?.onDispatch();
|
|
180
|
+
}
|
|
100
181
|
if (!admission)
|
|
101
182
|
return { response: await this.api(path, { method,
|
|
102
183
|
...(body === undefined ? {} : { body: serialized, headers: { 'content-type': 'application/json' } }), timeout, retry: 0 }).json() };
|
|
@@ -110,6 +191,7 @@ export class NodeApiClient {
|
|
|
110
191
|
if (!capability.success || capability.data?.features?.managedMutations !== 1)
|
|
111
192
|
throw new Error('Node does not support managed mutations; operation not dispatched');
|
|
112
193
|
await markResourceDispatched(admission);
|
|
194
|
+
dispatch?.onDispatch();
|
|
113
195
|
const managedRequest = managedHttpRequestSchema.parse({
|
|
114
196
|
claim: { leaseId: admission.leaseId, nodeId: admission.nodeId, nodeFqdn: admission.nodeFqdn },
|
|
115
197
|
commandId: admission.commandId, method, path: '/' + path, body: serialized,
|
|
@@ -132,7 +214,7 @@ export class NodeApiClient {
|
|
|
132
214
|
bundleSha256: skillPublication.bundleSha256, responseBody: response.body,
|
|
133
215
|
});
|
|
134
216
|
if (response.status >= 400)
|
|
135
|
-
throw new
|
|
217
|
+
throw new AgentResponseError(response.status, agentErrorBody(response.body));
|
|
136
218
|
const commandSha256 = receipt.coordination?.requestSha256;
|
|
137
219
|
if (!commandSha256 || commandSha256 !== commandRequestDigest(admission.payload))
|
|
138
220
|
throw new Error('Managed mutation command evidence differs');
|
|
@@ -393,7 +475,17 @@ export class NodeApiClient {
|
|
|
393
475
|
// ── Agent self-upgrade ──
|
|
394
476
|
/** Stage an exact agent version; the agent restarts into it through its launcher. */
|
|
395
477
|
async upgradeAgent(request) {
|
|
396
|
-
|
|
478
|
+
// Every failure is typed: NotDispatchedError (node untouched), AgentResponseError
|
|
479
|
+
// (the agent answered with an error) or DispatchOutcomeUnknownError (sent, no answer).
|
|
480
|
+
let dispatched = false;
|
|
481
|
+
try {
|
|
482
|
+
return (await this.mutateWithEvidence('POST', 'agent-upgrade', request, 15 * 60_000,
|
|
483
|
+
// A stable command id lets an operator find (and reconcile) the lease of a request whose reply was lost.
|
|
484
|
+
/^[a-zA-Z0-9._-]{1,100}$/.test(request.requestId) ? `agent-upgrade-${request.requestId}` : undefined, undefined, false, { pingFirst: true, onDispatch: () => { dispatched = true; } })).response;
|
|
485
|
+
}
|
|
486
|
+
catch (error) {
|
|
487
|
+
throw classifyAgentRequestError(error, dispatched);
|
|
488
|
+
}
|
|
397
489
|
}
|
|
398
490
|
// ── Ping ──
|
|
399
491
|
async ping() {
|
package/dist/server.js
CHANGED
|
@@ -96,6 +96,7 @@ const v1 = new Hono();
|
|
|
96
96
|
v1.use('/auth/*', rateLimitMiddleware({ windowMs: 60_000, max: 20, keyPrefix: 'rl:auth', failClosed: true }));
|
|
97
97
|
v1.use('/deploy/*', rateLimitMiddleware({ windowMs: 60_000, max: 10, keyPrefix: 'rl:deploy', failClosed: true }));
|
|
98
98
|
v1.use('/agents/upgrade', rateLimitMiddleware({ windowMs: 60_000, max: 10, keyPrefix: 'rl:agent-upgrade', failClosed: true }));
|
|
99
|
+
v1.use('/agents/upgrade/runs/:id/retry', rateLimitMiddleware({ windowMs: 60_000, max: 10, keyPrefix: 'rl:agent-upgrade', failClosed: true }));
|
|
99
100
|
v1.use('/self-update/*', rateLimitMiddleware({ windowMs: 60_000, max: 10, keyPrefix: 'rl:selfupdate', failClosed: true }));
|
|
100
101
|
v1.use('/commands/*', rateLimitMiddleware({ windowMs: 60_000, max: 60, keyPrefix: 'rl:commands' }));
|
|
101
102
|
v1.use('/quick-command/*', rateLimitMiddleware({ windowMs: 60_000, max: 30, keyPrefix: 'rl:quickcmd' }));
|