@hivemagnus/magnus-server 1.0.3 → 1.1.0
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/.env.example +20 -0
- package/README.md +7 -3
- package/agent-version.json +3 -1
- package/dist/cli/agents.js +111 -0
- package/dist/cli/args.js +19 -4
- package/dist/cli/auto-migrate.js +85 -0
- package/dist/cli/env-catalog.js +14 -0
- package/dist/cli/migrations.js +117 -0
- package/dist/cli/preflight.js +5 -14
- package/dist/cli/upgrade.js +55 -0
- package/dist/cli.js +121 -7
- package/dist/config/features.js +22 -0
- package/dist/index.js +9 -3
- package/dist/openapi/spec.js +72 -31
- package/dist/routes/agent-upgrades.js +42 -0
- package/dist/routes/heartbeat.js +8 -1
- package/dist/routes/self-update.js +19 -0
- package/dist/routes/system-updates.js +27 -0
- package/dist/sdk/node-api-client.js +4 -3
- package/dist/security/native-completion-registry.js +2 -0
- package/dist/server.js +5 -0
- package/dist/services/agent-upgrade-service.js +293 -0
- package/dist/services/agent-version-warnings.js +31 -0
- package/dist/services/self-update-service.js +11 -8
- package/dist/services/update-check-service.js +178 -0
- package/dist/utils/runtime-file.js +23 -0
- package/package.json +3 -3
package/dist/cli.js
CHANGED
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
// named on the command line have been applied.
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
6
|
import { existsSync } from 'node:fs';
|
|
7
|
-
import { createRequire } from 'node:module';
|
|
8
7
|
import { dirname, join, resolve } from 'node:path';
|
|
9
8
|
import { fileURLToPath } from 'node:url';
|
|
10
9
|
import { parseArgs, USAGE, UsageError } from './cli/args.js';
|
|
11
10
|
import { buildEnvSchemaDocument, renderEnvTable } from './cli/env-catalog.js';
|
|
12
11
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
|
+
/** Node reads NODE_EXTRA_CA_CERTS only at startup; an env file that sets it needs a fresh process. */
|
|
13
|
+
const STARTUP_EXTRA_CA = process.env['NODE_EXTRA_CA_CERTS'];
|
|
13
14
|
const MIGRATIONS_DIR = join(PACKAGE_ROOT, 'prisma', 'migrations');
|
|
14
15
|
const PRISMA_CONFIG = join(PACKAGE_ROOT, 'prisma.config.ts');
|
|
15
16
|
class FatalError extends Error {
|
|
@@ -47,10 +48,10 @@ async function version() {
|
|
|
47
48
|
async function migrate() {
|
|
48
49
|
if (!process.env['DATABASE_URL'] && process.env['NODE_ENV'] === 'production')
|
|
49
50
|
fatal('Configuration error: DATABASE_URL is required to run migrations in production.');
|
|
51
|
+
const { prismaCliPath } = await import('./cli/migrations.js');
|
|
50
52
|
let prismaCli;
|
|
51
53
|
try {
|
|
52
|
-
|
|
53
|
-
prismaCli = join(dirname(require.resolve('prisma/package.json')), 'build', 'index.js');
|
|
54
|
+
prismaCli = prismaCliPath();
|
|
54
55
|
}
|
|
55
56
|
catch {
|
|
56
57
|
return fatal('Migration error: the prisma CLI package is not installed next to magnus-server.');
|
|
@@ -75,7 +76,7 @@ async function migrate() {
|
|
|
75
76
|
await disconnectDb();
|
|
76
77
|
}
|
|
77
78
|
}
|
|
78
|
-
async function commandCheck() {
|
|
79
|
+
async function commandCheck(format) {
|
|
79
80
|
const { listMissingSecrets } = await import('./utils/env.js');
|
|
80
81
|
const { loadEnv } = await import('./config/env.js');
|
|
81
82
|
const { featureFlags } = await import('./config/features.js');
|
|
@@ -104,11 +105,19 @@ async function commandCheck() {
|
|
|
104
105
|
if (databaseUrl)
|
|
105
106
|
results.push(...await checkDatabase(databaseUrl, MIGRATIONS_DIR));
|
|
106
107
|
results.push(await checkRedis(process.env['REDIS_URL'] ?? 'redis://127.0.0.1:6379'));
|
|
108
|
+
const { checkForUpdate, updateResult } = await import('./services/update-check-service.js');
|
|
109
|
+
const update = await checkForUpdate();
|
|
110
|
+
results.push(updateResult(update));
|
|
111
|
+
const features = featureFlags();
|
|
112
|
+
const failures = results.filter(result => result.status === 'fail').length;
|
|
113
|
+
if (format === 'json') {
|
|
114
|
+
console.log(JSON.stringify({ version: await version(), ok: failures === 0, results, integrations: features, update }, null, 2));
|
|
115
|
+
return failures === 0 ? 0 : 1;
|
|
116
|
+
}
|
|
107
117
|
const symbol = { pass: '✓', warn: '!', fail: '✗' };
|
|
108
118
|
console.log(`magnus-server ${await version()} configuration check`);
|
|
109
119
|
for (const result of results)
|
|
110
120
|
console.log(` ${symbol[result.status]} ${result.name.padEnd(12)} ${result.message}`);
|
|
111
|
-
const features = featureFlags();
|
|
112
121
|
const onOff = (value) => (value ? 'on' : 'off');
|
|
113
122
|
console.log('Integrations:');
|
|
114
123
|
console.log(` Hive IAM ${onOff(features.iam)}`);
|
|
@@ -119,13 +128,94 @@ async function commandCheck() {
|
|
|
119
128
|
console.log(` Hermes registry ${onOff(features.hermesRegistry)}`);
|
|
120
129
|
console.log(` Resource leases ${onOff(features.resourceLeases)}`);
|
|
121
130
|
console.log(` Agent discovery ${onOff(features.agentDiscovery)}`);
|
|
122
|
-
const failures = results.filter(result => result.status === 'fail').length;
|
|
123
131
|
console.log(failures === 0 ? 'Result: OK' : `Result: ${failures} problem(s) found`);
|
|
124
132
|
return failures === 0 ? 0 : 1;
|
|
125
133
|
}
|
|
134
|
+
/** Applied and pending migrations, with impact notes (`--format json` for tooling). */
|
|
135
|
+
async function commandMigrations(format) {
|
|
136
|
+
const { resolveDatabaseUrl } = await import('./db/client.js');
|
|
137
|
+
const { readMigrationState } = await import('./cli/migrations.js');
|
|
138
|
+
const pg = (await import('pg')).default;
|
|
139
|
+
const client = new pg.Client({ connectionString: resolveDatabaseUrl(), connectionTimeoutMillis: 10_000 });
|
|
140
|
+
await client.connect();
|
|
141
|
+
try {
|
|
142
|
+
const state = await readMigrationState(client, MIGRATIONS_DIR);
|
|
143
|
+
if (format === 'json') {
|
|
144
|
+
console.log(JSON.stringify({ version: await version(), packaged: state.packaged, applied: [...state.applied].sort(), pending: state.pending, fresh: state.fresh }, null, 2));
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
for (const name of state.packaged) {
|
|
148
|
+
const pending = state.pending.find(migration => migration.name === name);
|
|
149
|
+
console.log(` ${pending ? '…' : '✓'} ${name}${pending ? ` (pending, ${pending.impact}${pending.note ? `: ${pending.note}` : ''})` : ''}`);
|
|
150
|
+
}
|
|
151
|
+
console.log(state.pending.length === 0 ? 'All migrations applied.' : `${state.pending.length} pending; run \`magnus-server migrate\`.`);
|
|
152
|
+
}
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
await client.end().catch(() => undefined);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** `pg_dump -Fc` into MAGNUS_BACKUP_DIR; prints JSON with the file and restore command. */
|
|
160
|
+
async function commandBackup(label) {
|
|
161
|
+
const { resolveDatabaseUrl } = await import('./db/client.js');
|
|
162
|
+
const { backupDatabase } = await import('./cli/migrations.js');
|
|
163
|
+
const { defaultBackupDir } = await import('./cli/auto-migrate.js');
|
|
164
|
+
const { magnusHome } = await import('./utils/magnus-home.js');
|
|
165
|
+
const pg = (await import('pg')).default;
|
|
166
|
+
const databaseUrl = resolveDatabaseUrl();
|
|
167
|
+
const client = new pg.Client({ connectionString: databaseUrl, connectionTimeoutMillis: 10_000 });
|
|
168
|
+
await client.connect();
|
|
169
|
+
try {
|
|
170
|
+
const result = await backupDatabase({
|
|
171
|
+
databaseUrl, client, backupDir: defaultBackupDir(process.env, magnusHome()),
|
|
172
|
+
label: label ?? `manual-${await version()}`, pgDump: process.env['MAGNUS_PG_DUMP'],
|
|
173
|
+
});
|
|
174
|
+
console.log(JSON.stringify(result));
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
await client.end().catch(() => undefined);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** `MAGNUS_AUTO_MIGRATE=true`: back up and migrate under an advisory lock before listening. */
|
|
182
|
+
async function startupAutoMigrate() {
|
|
183
|
+
const { upgradeSettings } = await import('./config/features.js');
|
|
184
|
+
const settings = upgradeSettings();
|
|
185
|
+
if (!settings.autoMigrate)
|
|
186
|
+
return;
|
|
187
|
+
const { resolveDatabaseUrl } = await import('./db/client.js');
|
|
188
|
+
const { autoMigrate, defaultBackupDir } = await import('./cli/auto-migrate.js');
|
|
189
|
+
const { magnusHome } = await import('./utils/magnus-home.js');
|
|
190
|
+
try {
|
|
191
|
+
await autoMigrate({
|
|
192
|
+
databaseUrl: resolveDatabaseUrl(),
|
|
193
|
+
migrationsDir: MIGRATIONS_DIR,
|
|
194
|
+
backupDir: defaultBackupDir(process.env, magnusHome()),
|
|
195
|
+
version: await version(),
|
|
196
|
+
settings,
|
|
197
|
+
pgDump: process.env['MAGNUS_PG_DUMP'],
|
|
198
|
+
migrate,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
if (error instanceof FatalError)
|
|
203
|
+
throw error;
|
|
204
|
+
fatal(error instanceof Error ? error.message : String(error));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
126
207
|
async function commandStart(options) {
|
|
208
|
+
// Without --env-file the server reads its package-level .env; apply it here
|
|
209
|
+
// too so startup switches such as MAGNUS_AUTO_MIGRATE see the same values.
|
|
210
|
+
const packageEnv = join(PACKAGE_ROOT, '.env');
|
|
211
|
+
if (!process.env['MAGNUS_ENV_FILE'] && existsSync(packageEnv)) {
|
|
212
|
+
process.loadEnvFile(packageEnv);
|
|
213
|
+
dropEmptyValues();
|
|
214
|
+
}
|
|
127
215
|
if (options.migrate)
|
|
128
216
|
await migrate();
|
|
217
|
+
else
|
|
218
|
+
await startupAutoMigrate();
|
|
129
219
|
await import('./index.js');
|
|
130
220
|
}
|
|
131
221
|
async function run(argv) {
|
|
@@ -146,7 +236,31 @@ async function run(argv) {
|
|
|
146
236
|
return 0;
|
|
147
237
|
case 'check':
|
|
148
238
|
loadEnvFiles(options.envFiles);
|
|
149
|
-
return commandCheck();
|
|
239
|
+
return commandCheck(options.formatGiven ? options.format : 'table');
|
|
240
|
+
case 'migrations':
|
|
241
|
+
loadEnvFiles(options.envFiles);
|
|
242
|
+
return commandMigrations(options.formatGiven ? options.format : 'table');
|
|
243
|
+
case 'backup':
|
|
244
|
+
loadEnvFiles(options.envFiles);
|
|
245
|
+
return commandBackup(options.label);
|
|
246
|
+
case 'upgrade':
|
|
247
|
+
case 'rollback': {
|
|
248
|
+
const { delegateToProject } = await import('./cli/upgrade.js');
|
|
249
|
+
return delegateToProject(options.command, options.passthrough, PACKAGE_ROOT);
|
|
250
|
+
}
|
|
251
|
+
case 'agents': {
|
|
252
|
+
loadEnvFiles(options.envFiles);
|
|
253
|
+
// The command calls agents over TLS: trust the same extra CA as the service.
|
|
254
|
+
if (process.env['NODE_EXTRA_CA_CERTS'] && process.env['NODE_EXTRA_CA_CERTS'] !== STARTUP_EXTRA_CA) {
|
|
255
|
+
return new Promise((resolvePromise, reject) => {
|
|
256
|
+
const child = spawn(process.execPath, process.argv.slice(1), { stdio: 'inherit', env: process.env });
|
|
257
|
+
child.on('error', reject);
|
|
258
|
+
child.on('close', code => resolvePromise(code ?? 1));
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
const { commandAgents } = await import('./cli/agents.js');
|
|
262
|
+
return commandAgents(options.passthrough);
|
|
263
|
+
}
|
|
150
264
|
case 'migrate':
|
|
151
265
|
loadEnvFiles(options.envFiles);
|
|
152
266
|
await migrate();
|
package/dist/config/features.js
CHANGED
|
@@ -13,4 +13,26 @@ export function featureFlags(env = process.env) {
|
|
|
13
13
|
agentDiscovery: truthy(env['ENABLE_AGENT_DISCOVERY']),
|
|
14
14
|
};
|
|
15
15
|
}
|
|
16
|
+
/** Boolean switch with an explicit default: only `true`/`false` (any case) override it. */
|
|
17
|
+
export function envSwitch(value, fallback) {
|
|
18
|
+
const normalized = (value ?? '').trim().toLowerCase();
|
|
19
|
+
if (normalized === 'true')
|
|
20
|
+
return true;
|
|
21
|
+
if (normalized === 'false')
|
|
22
|
+
return false;
|
|
23
|
+
return fallback;
|
|
24
|
+
}
|
|
25
|
+
export function upgradeSettings(env = process.env) {
|
|
26
|
+
const concurrency = Number.parseInt(env['MAGNUS_AGENT_UPGRADE_CONCURRENCY'] ?? '', 10);
|
|
27
|
+
return {
|
|
28
|
+
autoMigrate: envSwitch(env['MAGNUS_AUTO_MIGRATE'], false),
|
|
29
|
+
autoMigrateBackup: envSwitch(env['MAGNUS_AUTO_MIGRATE_BACKUP'], true),
|
|
30
|
+
autoMigrateAllowSlow: envSwitch(env['MAGNUS_AUTO_MIGRATE_ALLOW_SLOW'], false),
|
|
31
|
+
updateCheck: envSwitch(env['MAGNUS_UPDATE_CHECK'], true),
|
|
32
|
+
updateRegistry: (env['MAGNUS_UPDATE_REGISTRY']?.trim() || 'https://registry.npmjs.org').replace(/\/+$/, ''),
|
|
33
|
+
agentUpgrades: envSwitch(env['MAGNUS_AGENT_UPGRADES'], true),
|
|
34
|
+
agentUpgradeConcurrency: Number.isInteger(concurrency) && concurrency > 0 ? Math.min(concurrency, 50) : 5,
|
|
35
|
+
legacyGitSelfUpdate: envSwitch(env['MAGNUS_LEGACY_GIT_SELF_UPDATE'], false),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
16
38
|
//# sourceMappingURL=features.js.map
|
package/dist/index.js
CHANGED
|
@@ -33,6 +33,7 @@ const { startSecurityFusionRetention, stopSecurityFusionRetention } = await impo
|
|
|
33
33
|
const { reconcileSecurityFusionEvidence } = await import('./services/security-fusion-evidence-integrity.js');
|
|
34
34
|
const { startSecurityFusionOutboxDispatcher, stopSecurityFusionOutboxDispatcher, } = await import('./services/security-fusion-outbox.js');
|
|
35
35
|
const { startCommandWorker, closeCommandQueue } = await import('./queue/workers.js');
|
|
36
|
+
const { startUpdateCheck, stopUpdateCheck } = await import('./services/update-check-service.js');
|
|
36
37
|
const { startWebhookWorker, closeWebhookQueue } = await import('./services/webhook-service.js');
|
|
37
38
|
const { setupTerminalWebSocket } = await import('./routes/terminal.js');
|
|
38
39
|
const { disconnectRedis } = await import('./queue/redis.js');
|
|
@@ -51,8 +52,11 @@ if (securityFusionLocal()) {
|
|
|
51
52
|
+ `updated=${evidenceReconciliation.updated} quarantined=${evidenceReconciliation.quarantined} `
|
|
52
53
|
+ `mismatched_cases=${evidenceReconciliation.mismatchedCases}`);
|
|
53
54
|
}
|
|
55
|
+
const { writeRuntimeFile } = await import('./utils/runtime-file.js');
|
|
56
|
+
const { CONTROL_API_VERSION } = await import('./config/version.js');
|
|
54
57
|
/** Background services shared by the TLS and development listeners. */
|
|
55
|
-
function startBackgroundServices() {
|
|
58
|
+
function startBackgroundServices(listenPort) {
|
|
59
|
+
writeRuntimeFile({ version: CONTROL_API_VERSION, port: listenPort });
|
|
56
60
|
startNodeIssueMonitor();
|
|
57
61
|
startApplicationProbes();
|
|
58
62
|
startLogRetention();
|
|
@@ -67,6 +71,7 @@ function startBackgroundServices() {
|
|
|
67
71
|
startRolloutReconciler();
|
|
68
72
|
startWebhookWorker();
|
|
69
73
|
startCodexFleetMonitor();
|
|
74
|
+
startUpdateCheck();
|
|
70
75
|
if (features.hermesRegistry) {
|
|
71
76
|
startExtensionSourceMonitor();
|
|
72
77
|
void importPackageSkills().then(result => console.log(`[hermes-registry] catalog import: ${JSON.stringify(result)}`))
|
|
@@ -101,11 +106,11 @@ const server = listener.useTls
|
|
|
101
106
|
},
|
|
102
107
|
}, (info) => {
|
|
103
108
|
console.log(`🚀 Magnus server listening on https://${listener.hostname}:${info.port}`);
|
|
104
|
-
startBackgroundServices();
|
|
109
|
+
startBackgroundServices(info.port);
|
|
105
110
|
})
|
|
106
111
|
: serve({ fetch: handler, port, hostname: listener.hostname }, (info) => {
|
|
107
112
|
console.log(`🚀 Magnus server listening on http://${listener.hostname}:${info.port} (development opt-in)`);
|
|
108
|
-
startBackgroundServices();
|
|
113
|
+
startBackgroundServices(info.port);
|
|
109
114
|
});
|
|
110
115
|
const terminalWss = setupTerminalWebSocket(server);
|
|
111
116
|
// ── Graceful shutdown ──
|
|
@@ -133,6 +138,7 @@ async function shutdown(signal) {
|
|
|
133
138
|
stopSecurityExporter();
|
|
134
139
|
stopSecurityFusionOutboxDispatcher();
|
|
135
140
|
stopCodexFleetMonitor();
|
|
141
|
+
stopUpdateCheck();
|
|
136
142
|
stopExtensionSourceMonitor();
|
|
137
143
|
for (const client of terminalWss.clients)
|
|
138
144
|
client.terminate();
|
package/dist/openapi/spec.js
CHANGED
|
@@ -2011,36 +2011,6 @@ const paths = {
|
|
|
2011
2011
|
},
|
|
2012
2012
|
},
|
|
2013
2013
|
},
|
|
2014
|
-
'/deploy/upgrade/{nodeId}': {
|
|
2015
|
-
post: {
|
|
2016
|
-
tags: ['Deploy'],
|
|
2017
|
-
summary: 'Upgrade agent',
|
|
2018
|
-
description: 'Upgrade the agent on a node to the current expected version. Runs asynchronously.',
|
|
2019
|
-
security: apiSecretAuth,
|
|
2020
|
-
parameters: [pp('nodeId', 'Node ID')],
|
|
2021
|
-
responses: {
|
|
2022
|
-
202: success('Upgrade started'),
|
|
2023
|
-
401: err401,
|
|
2024
|
-
403: err403,
|
|
2025
|
-
404: err404,
|
|
2026
|
-
},
|
|
2027
|
-
},
|
|
2028
|
-
},
|
|
2029
|
-
'/deploy/version': {
|
|
2030
|
-
get: {
|
|
2031
|
-
tags: ['Deploy'],
|
|
2032
|
-
summary: 'Expected agent version',
|
|
2033
|
-
description: 'Returns the expected agent version and deploy username.',
|
|
2034
|
-
security: apiSecretAuth,
|
|
2035
|
-
responses: {
|
|
2036
|
-
200: success('Version info', {
|
|
2037
|
-
type: 'object',
|
|
2038
|
-
properties: { agentVersion: { type: 'string' }, deployUsername: { type: 'string' } },
|
|
2039
|
-
}),
|
|
2040
|
-
401: err401,
|
|
2041
|
-
},
|
|
2042
|
-
},
|
|
2043
|
-
},
|
|
2044
2014
|
'/deploy/control-node': {
|
|
2045
2015
|
post: {
|
|
2046
2016
|
tags: ['Deploy'],
|
|
@@ -2157,6 +2127,77 @@ const paths = {
|
|
|
2157
2127
|
},
|
|
2158
2128
|
},
|
|
2159
2129
|
// ──────────────── Self-Update ────────────────
|
|
2130
|
+
'/system/updates': {
|
|
2131
|
+
get: {
|
|
2132
|
+
tags: ['Updates'],
|
|
2133
|
+
summary: 'Available releases and agent version skew',
|
|
2134
|
+
description: 'Result of the daily npm registry check (MAGNUS_UPDATE_CHECK): the newest release of the running major, a newer major if any, release notes, new required settings and slow or one-way migrations. Also lists agents that run a different version than this server ships with, and the command that fixes it.',
|
|
2135
|
+
security: apiSecretAuth,
|
|
2136
|
+
responses: { 200: success('Update status', { type: 'object', properties: {
|
|
2137
|
+
version: { type: 'string' },
|
|
2138
|
+
server: { type: 'object', properties: { enabled: { type: 'boolean' }, current: { type: 'string' }, latest: { type: ['string', 'null'] },
|
|
2139
|
+
latestMajor: { type: ['string', 'null'] }, updateAvailable: { type: 'boolean' }, command: { type: 'string' } } },
|
|
2140
|
+
agents: { type: 'object', properties: { expected: { type: 'string' }, current: { type: 'integer' },
|
|
2141
|
+
behind: { type: 'array', items: { type: 'object' } }, ahead: { type: 'array', items: { type: 'object' } },
|
|
2142
|
+
upgradesEnabled: { type: 'boolean' }, fixCommand: { type: ['string', 'null'] } } },
|
|
2143
|
+
} }), 401: err401 },
|
|
2144
|
+
},
|
|
2145
|
+
},
|
|
2146
|
+
'/system/updates/check': {
|
|
2147
|
+
post: {
|
|
2148
|
+
tags: ['Updates'],
|
|
2149
|
+
summary: 'Check the registry now',
|
|
2150
|
+
description: 'Refreshes the update check immediately (ADMIN or OPERATOR).',
|
|
2151
|
+
security: apiSecretAuth,
|
|
2152
|
+
responses: { 200: success('Update status'), 401: err401, 403: err403 },
|
|
2153
|
+
},
|
|
2154
|
+
},
|
|
2155
|
+
'/agents/upgrade/eligibility': {
|
|
2156
|
+
get: {
|
|
2157
|
+
tags: ['Agent upgrades'],
|
|
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 ships with) now, or why not (offline, not set up with `magnus-agent install-service`, MAGNUS_AGENT_SELF_UPGRADE=false, another major version, already current).',
|
|
2160
|
+
security: apiSecretAuth,
|
|
2161
|
+
parameters: [qp('version', 'Target agent version (X.Y.Z)')],
|
|
2162
|
+
responses: { 200: success('Eligibility', { type: 'object', properties: {
|
|
2163
|
+
version: { type: 'string' }, upgradesEnabled: { type: 'boolean' }, disabledReason: { type: ['string', 'null'] },
|
|
2164
|
+
nodes: { type: 'array', items: { type: 'object', properties: { nodeId: { type: 'string' }, name: { type: 'string' },
|
|
2165
|
+
version: { type: 'string' }, eligible: { type: 'boolean' }, reason: { type: ['string', 'null'] } } } },
|
|
2166
|
+
} }), 400: err400, 401: err401 },
|
|
2167
|
+
},
|
|
2168
|
+
},
|
|
2169
|
+
'/agents/upgrade': {
|
|
2170
|
+
post: {
|
|
2171
|
+
tags: ['Agent upgrades'],
|
|
2172
|
+
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, stopping at the first agent that does not confirm. 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.',
|
|
2174
|
+
security: apiSecretAuth,
|
|
2175
|
+
requestBody: { required: false, content: { 'application/json': { schema: { type: 'object', properties: {
|
|
2176
|
+
version: { type: 'string', description: 'Agent version (default: the one this server ships with)' },
|
|
2177
|
+
integrity: { type: 'string', description: 'npm sha512 integrity (default: read from the registry)' },
|
|
2178
|
+
nodeIds: { type: 'array', items: { type: 'string' }, description: 'Only these nodes' },
|
|
2179
|
+
} } } } },
|
|
2180
|
+
responses: { 202: success('Run started'), 400: err400, 401: err401, 403: err403, 409: err409 },
|
|
2181
|
+
},
|
|
2182
|
+
},
|
|
2183
|
+
'/agents/upgrade/runs/latest': {
|
|
2184
|
+
get: {
|
|
2185
|
+
tags: ['Agent upgrades'],
|
|
2186
|
+
summary: 'Latest upgrade run',
|
|
2187
|
+
security: apiSecretAuth,
|
|
2188
|
+
responses: { 200: success('Run (or null)'), 401: err401 },
|
|
2189
|
+
},
|
|
2190
|
+
},
|
|
2191
|
+
'/agents/upgrade/runs/{id}': {
|
|
2192
|
+
get: {
|
|
2193
|
+
tags: ['Agent upgrades'],
|
|
2194
|
+
summary: 'Upgrade run status',
|
|
2195
|
+
description: 'Per-node state of a run: pending, requested, confirmed, reverted, failed or skipped.',
|
|
2196
|
+
security: apiSecretAuth,
|
|
2197
|
+
parameters: [pp('id', 'Run ID')],
|
|
2198
|
+
responses: { 200: success('Run'), 401: err401, 404: err404 },
|
|
2199
|
+
},
|
|
2200
|
+
},
|
|
2160
2201
|
'/self-update/status': {
|
|
2161
2202
|
get: {
|
|
2162
2203
|
tags: ['Self-Update'],
|
|
@@ -2214,7 +2255,7 @@ const paths = {
|
|
|
2214
2255
|
post: {
|
|
2215
2256
|
tags: ['Self-Update'],
|
|
2216
2257
|
summary: 'Apply update',
|
|
2217
|
-
description: '
|
|
2258
|
+
description: 'Source checkouts only, and only with MAGNUS_LEGACY_GIT_SELF_UPDATE=true (otherwise 410 SELF_UPDATE_RETIRED): pull latest code, run `npm ci`, build, and optionally restart the process. npm installs upgrade with `magnus upgrade`. Requires ADMIN role.',
|
|
2218
2259
|
security: apiSecretAuth,
|
|
2219
2260
|
requestBody: {
|
|
2220
2261
|
required: false,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { authMiddleware, getUser, requireRole } from '../middleware/auth.js';
|
|
4
|
+
import { HttpError } from '../middleware/error-handler.js';
|
|
5
|
+
import { upgradeSettings } from '../config/features.js';
|
|
6
|
+
import { AGENT_VERSION } from '../config/version.js';
|
|
7
|
+
import { getUpgradeRun, latestUpgradeRun, listUpgradeCandidates, startUpgradeRun } from '../services/agent-upgrade-service.js';
|
|
8
|
+
/** /api/v1/agents/upgrade: which agents can take a version, and fleet upgrade runs. */
|
|
9
|
+
export const agentUpgradesRouter = new Hono();
|
|
10
|
+
agentUpgradesRouter.use('*', authMiddleware);
|
|
11
|
+
const version = z.string().regex(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/);
|
|
12
|
+
agentUpgradesRouter.get('/eligibility', async (c) => {
|
|
13
|
+
const target = version.safeParse(c.req.query('version') ?? AGENT_VERSION);
|
|
14
|
+
if (!target.success)
|
|
15
|
+
throw new HttpError(400, 'INVALID_VERSION', 'version must be X.Y.Z');
|
|
16
|
+
const nodes = await listUpgradeCandidates(target.data);
|
|
17
|
+
return c.json({ success: true, data: {
|
|
18
|
+
version: target.data, serverVersion: AGENT_VERSION, upgradesEnabled: upgradeSettings().agentUpgrades,
|
|
19
|
+
disabledReason: upgradeSettings().agentUpgrades ? null : 'MAGNUS_AGENT_UPGRADES=false on the server',
|
|
20
|
+
nodes, latestRun: await latestUpgradeRun(),
|
|
21
|
+
} });
|
|
22
|
+
});
|
|
23
|
+
const startSchema = z.object({
|
|
24
|
+
version: version.optional(),
|
|
25
|
+
integrity: z.string().regex(/^sha512-[A-Za-z0-9+/]+={0,2}$/).optional(),
|
|
26
|
+
nodeIds: z.array(z.string().min(1).max(128)).max(1000).optional(),
|
|
27
|
+
}).strict();
|
|
28
|
+
agentUpgradesRouter.post('/', requireRole('ADMIN'), async (c) => {
|
|
29
|
+
const parsed = startSchema.safeParse(await c.req.json().catch(() => ({})));
|
|
30
|
+
if (!parsed.success)
|
|
31
|
+
throw new HttpError(400, 'VALIDATION_ERROR', parsed.error.issues.map(issue => issue.message).join('; '));
|
|
32
|
+
const { run } = await startUpgradeRun({ ...parsed.data, actorId: getUser(c).userId });
|
|
33
|
+
return c.json({ success: true, data: run }, 202);
|
|
34
|
+
});
|
|
35
|
+
agentUpgradesRouter.get('/runs/latest', async (c) => c.json({ success: true, data: await latestUpgradeRun() }));
|
|
36
|
+
agentUpgradesRouter.get('/runs/:id', async (c) => {
|
|
37
|
+
const run = await getUpgradeRun(c.req.param('id'));
|
|
38
|
+
if (!run)
|
|
39
|
+
throw new HttpError(404, 'NOT_FOUND', 'Upgrade run not found');
|
|
40
|
+
return c.json({ success: true, data: run });
|
|
41
|
+
});
|
|
42
|
+
//# sourceMappingURL=agent-upgrades.js.map
|
package/dist/routes/heartbeat.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { enqueueSecurityObservations, securityFusionLocal } from '../services/security-export.js';
|
|
2
|
+
import { warnOnAgentVersionMismatch } from '../services/agent-version-warnings.js';
|
|
3
|
+
import { recordSelfUpgradeReport } from '../services/agent-upgrade-service.js';
|
|
2
4
|
import { Hono } from 'hono';
|
|
3
5
|
import { z } from 'zod';
|
|
4
6
|
import { nanoid } from 'nanoid';
|
|
@@ -330,6 +332,11 @@ heartbeatRouter.post('/', async (c) => {
|
|
|
330
332
|
}) },
|
|
331
333
|
});
|
|
332
334
|
}
|
|
335
|
+
if (existing && body.selfUpgrade !== undefined) {
|
|
336
|
+
await recordSelfUpgradeReport({ id: nodeId, name: existing.name }, body).catch((err) => {
|
|
337
|
+
console.error(`[agent-upgrade] could not record the self-upgrade report of ${existing.name}:`, err instanceof Error ? err.message : err);
|
|
338
|
+
});
|
|
339
|
+
}
|
|
333
340
|
if (!fqdn && existing) {
|
|
334
341
|
// If the node's hostname already contains a dot it's likely an FQDN — skip lookup
|
|
335
342
|
if (!existing.hostname.includes('.')) {
|
|
@@ -521,7 +528,7 @@ heartbeatRouter.post('/', async (c) => {
|
|
|
521
528
|
}
|
|
522
529
|
}
|
|
523
530
|
const expectedVersion = AGENT_VERSION;
|
|
524
|
-
const updateAvailable =
|
|
531
|
+
const { updateAvailable } = warnOnAgentVersionMismatch({ id: nodeId, name: existing?.name ?? fqdn ?? nodeId }, body.agentVersion, expectedVersion);
|
|
525
532
|
return c.json({ success: true, data: { accepted: true, expectedVersion, updateAvailable } });
|
|
526
533
|
});
|
|
527
534
|
// ── Retired Redis Streams heartbeat ingress ──
|
|
@@ -6,6 +6,7 @@ import { authMiddleware, getUser, requireRole } from '../middleware/auth.js';
|
|
|
6
6
|
import { logAuditEvent } from '../services/audit-service.js';
|
|
7
7
|
import { checkForUpdates, performSelfUpdate, scheduleRestart, getUpdateStatus, } from '../services/self-update-service.js';
|
|
8
8
|
import { HttpError } from '../middleware/error-handler.js';
|
|
9
|
+
import { upgradeSettings } from '../config/features.js';
|
|
9
10
|
export const selfUpdateRouter = new Hono();
|
|
10
11
|
selfUpdateRouter.use('*', authMiddleware);
|
|
11
12
|
selfUpdateRouter.use('*', async (c, next) => {
|
|
@@ -16,6 +17,24 @@ selfUpdateRouter.use('*', async (c, next) => {
|
|
|
16
17
|
c.header(c.res.headers.has('X-Magnus-Work-Id') ? 'X-Magnus-Restart-Work-Id' : 'X-Magnus-Work-Id', c.error.workId);
|
|
17
18
|
}
|
|
18
19
|
});
|
|
20
|
+
/**
|
|
21
|
+
* The git-checkout update path (`git pull`, rebuild, restart) only works for
|
|
22
|
+
* source installs. npm installs upgrade with `magnus upgrade`, which backs up
|
|
23
|
+
* the database and reverts on failure; the old endpoints stay behind
|
|
24
|
+
* MAGNUS_LEGACY_GIT_SELF_UPDATE=true.
|
|
25
|
+
*/
|
|
26
|
+
const legacyGitSelfUpdate = async (c, next) => {
|
|
27
|
+
if (!upgradeSettings().legacyGitSelfUpdate) {
|
|
28
|
+
return c.json({ success: false, error: { code: 'SELF_UPDATE_RETIRED',
|
|
29
|
+
message: 'In-place git self-update is off. Upgrade with `magnus upgrade` (or `magnus-server upgrade`) on the server host; '
|
|
30
|
+
+ 'set MAGNUS_LEGACY_GIT_SELF_UPDATE=true to re-enable this endpoint for a source checkout.',
|
|
31
|
+
upgradeCommand: 'magnus upgrade' } }, 410);
|
|
32
|
+
}
|
|
33
|
+
await next();
|
|
34
|
+
};
|
|
35
|
+
selfUpdateRouter.use('/check', legacyGitSelfUpdate);
|
|
36
|
+
selfUpdateRouter.use('/apply', legacyGitSelfUpdate);
|
|
37
|
+
selfUpdateRouter.use('/restart', legacyGitSelfUpdate);
|
|
19
38
|
selfUpdateRouter.get('/work/:workId', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
20
39
|
const id = z.string().guid().safeParse(c.req.param('workId'));
|
|
21
40
|
if (!id.success)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { authMiddleware, requireRole } from '../middleware/auth.js';
|
|
3
|
+
import { upgradeSettings } from '../config/features.js';
|
|
4
|
+
import { CONTROL_API_VERSION } from '../config/version.js';
|
|
5
|
+
import { agentFixCommand, agentVersionSkew, getUpdateCheck } from '../services/update-check-service.js';
|
|
6
|
+
/**
|
|
7
|
+
* GET /api/v1/system/updates: available releases (MAGNUS_UPDATE_CHECK) and
|
|
8
|
+
* agents running a different version than the server ships with.
|
|
9
|
+
*/
|
|
10
|
+
export const systemUpdatesRouter = new Hono();
|
|
11
|
+
systemUpdatesRouter.use('*', authMiddleware);
|
|
12
|
+
async function report(refresh) {
|
|
13
|
+
const [server, agents] = await Promise.all([getUpdateCheck({ refresh }), agentVersionSkew()]);
|
|
14
|
+
const settings = upgradeSettings();
|
|
15
|
+
return {
|
|
16
|
+
version: CONTROL_API_VERSION,
|
|
17
|
+
server,
|
|
18
|
+
agents: {
|
|
19
|
+
...agents,
|
|
20
|
+
upgradesEnabled: settings.agentUpgrades,
|
|
21
|
+
fixCommand: agents.behind.length > 0 ? agentFixCommand(agents.behind.map(node => node.name)) : null,
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
systemUpdatesRouter.get('/', async (c) => c.json({ success: true, data: await report(false) }));
|
|
26
|
+
systemUpdatesRouter.post('/check', requireRole('ADMIN', 'OPERATOR'), async (c) => c.json({ success: true, data: await report(true) }));
|
|
27
|
+
//# sourceMappingURL=system-updates.js.map
|
|
@@ -390,9 +390,10 @@ export class NodeApiClient {
|
|
|
390
390
|
async removeSkill(skillName) {
|
|
391
391
|
return this.mutate('DELETE', `skills/${encodeURIComponent(skillName)}`);
|
|
392
392
|
}
|
|
393
|
-
// ──
|
|
394
|
-
|
|
395
|
-
|
|
393
|
+
// ── Agent self-upgrade ──
|
|
394
|
+
/** Stage an exact agent version; the agent restarts into it through its launcher. */
|
|
395
|
+
async upgradeAgent(request) {
|
|
396
|
+
return this.mutate('POST', 'agent-upgrade', request, 15 * 60_000);
|
|
396
397
|
}
|
|
397
398
|
// ── Ping ──
|
|
398
399
|
async ping() {
|
|
@@ -20,6 +20,8 @@ export const NATIVE_COMPLETION_REGISTRY = {
|
|
|
20
20
|
completionMode: 'buffered-synchronous-http', timeoutCeilingSeconds: 3600, resourceClassifier: 'node-scheduler' },
|
|
21
21
|
{ id: 'ardent-action', method: 'POST', pathMatcher: { kind: 'exact', path: '/ardent/action' },
|
|
22
22
|
completionMode: 'buffered-synchronous-http', timeoutCeilingSeconds: 900, resourceClassifier: 'node-ardent' },
|
|
23
|
+
{ id: 'agent-self-upgrade', method: 'POST', pathMatcher: { kind: 'exact', path: '/agent-upgrade' },
|
|
24
|
+
completionMode: 'buffered-synchronous-http', timeoutCeilingSeconds: 900, resourceClassifier: 'node-filesystem' },
|
|
23
25
|
],
|
|
24
26
|
};
|
|
25
27
|
export const NATIVE_COMPLETION_REGISTRY_SHA256 = createHash('sha256')
|
package/dist/server.js
CHANGED
|
@@ -30,6 +30,8 @@ import { libraryRouter } from './routes/library.js';
|
|
|
30
30
|
import { rolesRouter } from './routes/roles.js';
|
|
31
31
|
import { notificationsRouter } from './routes/notifications.js';
|
|
32
32
|
import { selfUpdateRouter } from './routes/self-update.js';
|
|
33
|
+
import { systemUpdatesRouter } from './routes/system-updates.js';
|
|
34
|
+
import { agentUpgradesRouter } from './routes/agent-upgrades.js';
|
|
33
35
|
import { scanRouter } from './routes/scan.js';
|
|
34
36
|
import { terminalTicketRouter } from './routes/terminal-ticket.js';
|
|
35
37
|
import { usersRouter, authRouter } from './routes/users.js';
|
|
@@ -93,6 +95,7 @@ const v1 = new Hono();
|
|
|
93
95
|
// ── Rate limiting on sensitive endpoints (permissive — Beta) ──
|
|
94
96
|
v1.use('/auth/*', rateLimitMiddleware({ windowMs: 60_000, max: 20, keyPrefix: 'rl:auth', failClosed: true }));
|
|
95
97
|
v1.use('/deploy/*', rateLimitMiddleware({ windowMs: 60_000, max: 10, keyPrefix: 'rl:deploy', failClosed: true }));
|
|
98
|
+
v1.use('/agents/upgrade', rateLimitMiddleware({ windowMs: 60_000, max: 10, keyPrefix: 'rl:agent-upgrade', failClosed: true }));
|
|
96
99
|
v1.use('/self-update/*', rateLimitMiddleware({ windowMs: 60_000, max: 10, keyPrefix: 'rl:selfupdate', failClosed: true }));
|
|
97
100
|
v1.use('/commands/*', rateLimitMiddleware({ windowMs: 60_000, max: 60, keyPrefix: 'rl:commands' }));
|
|
98
101
|
v1.use('/quick-command/*', rateLimitMiddleware({ windowMs: 60_000, max: 30, keyPrefix: 'rl:quickcmd' }));
|
|
@@ -125,6 +128,8 @@ v1.route('/library', libraryRouter);
|
|
|
125
128
|
v1.route('/roles', rolesRouter);
|
|
126
129
|
v1.route('/notifications', notificationsRouter);
|
|
127
130
|
v1.route('/self-update', selfUpdateRouter);
|
|
131
|
+
v1.route('/system/updates', systemUpdatesRouter);
|
|
132
|
+
v1.route('/agents/upgrade', agentUpgradesRouter);
|
|
128
133
|
v1.route('/scan', scanRouter);
|
|
129
134
|
v1.route('/terminal', terminalTicketRouter);
|
|
130
135
|
v1.route('/users', usersRouter);
|