@hivemagnus/magnus-agent 1.0.2 → 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 +14 -0
- package/README.md +40 -1
- package/dist/agent.js +7 -1
- package/dist/env-catalog.js +6 -0
- package/dist/env.js +7 -0
- package/dist/executors/ardent-manager.d.ts +3 -2
- package/dist/executors/ardent-manager.js +49 -20
- package/dist/executors/ardent-packages.d.ts +8 -0
- package/dist/executors/ardent-packages.js +16 -0
- package/dist/executors/scheduler-status.d.ts +2 -2
- package/dist/index.js +5 -0
- package/dist/security/native-completion-registry.d.ts +10 -0
- package/dist/security/native-completion-registry.js +2 -0
- package/dist/server.js +3 -1
- package/dist/upgrade/install-service.d.ts +14 -0
- package/dist/upgrade/install-service.js +227 -0
- package/dist/upgrade/routes.d.ts +5 -0
- package/dist/upgrade/routes.js +31 -0
- package/dist/upgrade/self-upgrade.d.ts +65 -0
- package/dist/upgrade/self-upgrade.js +242 -0
- package/launcher/launcher.mjs +140 -0
- package/package.json +4 -2
package/.env.example
CHANGED
|
@@ -95,6 +95,9 @@ LOG_LEVEL=info
|
|
|
95
95
|
# ── Ardent (optional) ────────────────────────────────────────────────
|
|
96
96
|
# ARDENT_OWNER_USER=operator
|
|
97
97
|
# ARDENT_STAGED_PACKAGE=/opt/magnus-agent/ardent-package.tgz
|
|
98
|
+
# Scoped npm names accepted as the Ardent runtime, in preference order. Add an
|
|
99
|
+
# older build's package name to keep managing it (interoperating with older components).
|
|
100
|
+
# MAGNUS_AGENT_ARDENT_PACKAGES=@hivemagnus/ardent
|
|
98
101
|
|
|
99
102
|
# ── HiveMem credential renewal (optional, all lists empty by default) ─
|
|
100
103
|
# Entries live in a root-owned 0600 JSON file; the lists below bound what the
|
|
@@ -113,3 +116,14 @@ LOG_LEVEL=info
|
|
|
113
116
|
# MAGNUS_AGENT_WORKSPACE_ROOT=/var/lib/magnus-agent/workspaces
|
|
114
117
|
# Account the docker-group heartbeat check verifies (default: the agent's own user).
|
|
115
118
|
# MAGNUS_AGENT_DOCKER_USER=operator
|
|
119
|
+
|
|
120
|
+
# ── Self-upgrade (after `sudo magnus-agent install-service`) ─────────
|
|
121
|
+
# Accept server-driven upgrades to an exact version and integrity.
|
|
122
|
+
# MAGNUS_AGENT_SELF_UPGRADE=true
|
|
123
|
+
# Seconds a new version has to report a heartbeat before the launcher reverts it.
|
|
124
|
+
# MAGNUS_AGENT_UPGRADE_CONFIRM_SECONDS=180
|
|
125
|
+
# Refuse upgrades whose packages fail `npm audit signatures`.
|
|
126
|
+
# MAGNUS_AGENT_UPGRADE_REQUIRE_PROVENANCE=true
|
|
127
|
+
# Offline nodes: install from hivemagnus-magnus-agent-<X>.tgz files in this directory.
|
|
128
|
+
# MAGNUS_AGENT_UPGRADE_SOURCE_DIR=
|
|
129
|
+
# MAGNUS_AGENT_NPM=npm
|
package/README.md
CHANGED
|
@@ -121,6 +121,34 @@ verification is never disabled.
|
|
|
121
121
|
| HiveMem credential and certificate renewal | **yes** | not started |
|
|
122
122
|
| Execution supervisor (`MAGNUS_EXECUTION_SUPERVISOR_ENABLED`) | **yes** | configuration error if enabled |
|
|
123
123
|
|
|
124
|
+
## Upgrades
|
|
125
|
+
|
|
126
|
+
Run `sudo magnus-agent install-service` (add `--unprivileged` for the ordinary-account mode) once
|
|
127
|
+
per node. It does four things:
|
|
128
|
+
|
|
129
|
+
- moves the install into `/opt/magnus-agent/versions/<X>`
|
|
130
|
+
- links `./current`
|
|
131
|
+
- installs the upgrade launcher (`launcher/launcher.mjs`)
|
|
132
|
+
- writes the systemd unit
|
|
133
|
+
|
|
134
|
+
`--dry-run` prints the steps first.
|
|
135
|
+
|
|
136
|
+
After that, the server can upgrade the agent from the console's **Upgrade agents** action or with
|
|
137
|
+
`magnus-server agents upgrade`, in four steps:
|
|
138
|
+
|
|
139
|
+
1. The agent downloads the exact version the server names and checks its npm `sha512` integrity. It
|
|
140
|
+
also runs `npm audit signatures` (`MAGNUS_AGENT_UPGRADE_REQUIRE_PROVENANCE`).
|
|
141
|
+
2. It installs that version beside the running one, points `./current` at it and exits.
|
|
142
|
+
3. The launcher starts the new version on trial.
|
|
143
|
+
4. If the new version does not report an accepted heartbeat within
|
|
144
|
+
`MAGNUS_AGENT_UPGRADE_CONFIRM_SECONDS` (default 180), or crashes twice, the launcher switches back
|
|
145
|
+
to the previous version.
|
|
146
|
+
|
|
147
|
+
The agent writes only inside its install directory and never uses sudo. Set
|
|
148
|
+
`MAGNUS_AGENT_SELF_UPGRADE=false` to refuse upgrade requests on a node. Offline nodes can install from
|
|
149
|
+
local tarballs with `MAGNUS_AGENT_UPGRADE_SOURCE_DIR`. See
|
|
150
|
+
[UPGRADING.md](../../UPGRADING.md#agents).
|
|
151
|
+
|
|
124
152
|
## CLI
|
|
125
153
|
|
|
126
154
|
```text
|
|
@@ -150,7 +178,7 @@ and `magnus-agent env-schema` prints them as JSON.
|
|
|
150
178
|
| `SKILLS_DIR` / `SKILLS_EXTRA_DIRS` | Skill directories |
|
|
151
179
|
| `HERMES_USER`, `HERMES_REGISTRY_TOKEN`, `MAGNUS_HERMES_SYNC_PULL` | Hermes Agent management and registry sync |
|
|
152
180
|
| `CODEX_USER`, `CODEX_BIN`, `CODEX_HOME` | Codex CLI management |
|
|
153
|
-
| `ARDENT_OWNER_USER`, `ARDENT_STAGED_PACKAGE` | Ardent management |
|
|
181
|
+
| `ARDENT_OWNER_USER`, `ARDENT_STAGED_PACKAGE`, `MAGNUS_AGENT_ARDENT_PACKAGES` | Ardent management |
|
|
154
182
|
| `MAGNUS_AGENT_RENEWAL_*` | HiveMem credential renewal allowlists (empty by default) |
|
|
155
183
|
| `MAGNUS_AGENT_TLS_RENEWAL=tailscale` | Renew a Tailscale listener certificate |
|
|
156
184
|
| `MAGNUS_AGENT_PRIVILEGED` | Privileged (root, default `true`) or unprivileged mode |
|
|
@@ -175,6 +203,17 @@ With `--submit-only` it only reports the local Hermes inventory. Applying the
|
|
|
175
203
|
server's desired skills, plugins and MCP releases also needs
|
|
176
204
|
`MAGNUS_HERMES_SYNC_PULL=true`.
|
|
177
205
|
|
|
206
|
+
## Interoperating with older components
|
|
207
|
+
|
|
208
|
+
`MAGNUS_AGENT_ARDENT_PACKAGES` (default `@hivemagnus/ardent`) lists the scoped
|
|
209
|
+
npm package names the agent accepts as the Ardent runtime, in preference order.
|
|
210
|
+
It applies to the staged `ardent-package.json` sidecar, the install path
|
|
211
|
+
`node_modules/<name>`, the install receipt and the managed `ardent@<profile>`
|
|
212
|
+
user unit. To keep managing an Ardent build published under another scope, add
|
|
213
|
+
its name, for example `MAGNUS_AGENT_ARDENT_PACKAGES=@hivemagnus/ardent,@legacy-scope/ardent`.
|
|
214
|
+
Older sidecars with the `{name, version, sha256}` shape are accepted unchanged.
|
|
215
|
+
Profile channel maps accept any channel key that matches `[a-z][a-z0-9_-]{0,31}`.
|
|
216
|
+
|
|
178
217
|
## Development
|
|
179
218
|
|
|
180
219
|
```bash
|
package/dist/agent.js
CHANGED
|
@@ -7,6 +7,7 @@ import { controlApiFetch } from './tls.js';
|
|
|
7
7
|
import { startSpan, formatTraceparent } from './collectors/otel-collector.js';
|
|
8
8
|
import { collectHermesState } from './executors/hermes-manager.js';
|
|
9
9
|
import { existsSync } from 'node:fs';
|
|
10
|
+
import { confirmTrial, selfUpgradeStatus } from './upgrade/self-upgrade.js';
|
|
10
11
|
const HEARTBEAT_INTERVAL_MS = 30_000; // 30s
|
|
11
12
|
const HEARTBEAT_BACKOFF_MAX_MS = 5 * 60_000; // 5 min max backoff
|
|
12
13
|
const HERMES_DISCOVERY_INTERVAL_MS = Number(process.env['HERMES_DISCOVERY_INTERVAL_MS'] ?? 3 * 60 * 60 * 1000);
|
|
@@ -112,6 +113,8 @@ export function startHeartbeatLoop() {
|
|
|
112
113
|
serviceRestart: existsSync('/etc/magnus/security-restart-services'),
|
|
113
114
|
},
|
|
114
115
|
...(hermes ? { hermes } : {}),
|
|
116
|
+
nodeVersion: process.version,
|
|
117
|
+
selfUpgrade: selfUpgradeStatus(AGENT_VERSION),
|
|
115
118
|
},
|
|
116
119
|
headers: {
|
|
117
120
|
'x-node-token': NODE_TOKEN,
|
|
@@ -120,8 +123,11 @@ export function startHeartbeatLoop() {
|
|
|
120
123
|
timeout: 10_000,
|
|
121
124
|
fetch: controlApiFetch,
|
|
122
125
|
}).json().then((resp) => {
|
|
126
|
+
// The first accepted heartbeat from a version on trial confirms the upgrade.
|
|
127
|
+
if (resp.data?.accepted && confirmTrial(AGENT_VERSION))
|
|
128
|
+
logger.info(`Self-upgrade to ${AGENT_VERSION} confirmed`);
|
|
123
129
|
if (resp.data?.updateAvailable && resp.data.expectedVersion) {
|
|
124
|
-
logger.info(`Agent update available: ${AGENT_VERSION} → ${resp.data.expectedVersion}`);
|
|
130
|
+
logger.info(`Agent update available: ${AGENT_VERSION} → ${resp.data.expectedVersion} (upgrade from the console's Nodes page or with \`magnus-server agents upgrade\`)`);
|
|
125
131
|
}
|
|
126
132
|
});
|
|
127
133
|
if (consecutiveFailures > 0) {
|
package/dist/env-catalog.js
CHANGED
|
@@ -12,6 +12,11 @@ export const agentEnvCatalog = [
|
|
|
12
12
|
{ name: 'NODE_MANAGER_BIND_HOST', required: false, secret: false, default: '0.0.0.0 (TLS) / 127.0.0.1 (HTTP)', group: 'core', description: 'Listener address.' },
|
|
13
13
|
{ name: 'ENABLE_NODE_MANAGEMENT', required: false, secret: false, default: 'true', group: 'core', description: 'Node management routes and heartbeat. Must stay true.' },
|
|
14
14
|
{ name: 'MAGNUS_AGENT_PRIVILEGED', required: false, secret: false, default: 'true', group: 'core', description: 'true: run as root with every executor (startup fails if not root). false: run as an ordinary account; cron, users, system updates, docker-group remediation, Ardent management, credential renewal and the execution supervisor are disabled and their routes return 403 AGENT_NOT_PRIVILEGED.' },
|
|
15
|
+
{ name: 'MAGNUS_AGENT_SELF_UPGRADE', required: false, secret: false, default: 'true', group: 'core', description: 'true: accept server-driven upgrades to an exact version and npm integrity, installed into versions/<X> and confirmed by the launcher (needs `magnus-agent install-service`). false: refuse them; upgrade by hand or by image.' },
|
|
16
|
+
{ name: 'MAGNUS_AGENT_UPGRADE_CONFIRM_SECONDS', required: false, secret: false, default: '180', group: 'core', description: 'Seconds a newly installed version has to report an accepted heartbeat before the launcher switches back to the previous one (10-3600).' },
|
|
17
|
+
{ name: 'MAGNUS_AGENT_UPGRADE_REQUIRE_PROVENANCE', required: false, secret: false, default: 'true', group: 'core', description: 'Run `npm audit signatures` on a staged upgrade and refuse it unless every package has valid registry signatures. Set false only for locally built tarballs.' },
|
|
18
|
+
{ name: 'MAGNUS_AGENT_UPGRADE_SOURCE_DIR', required: false, secret: false, group: 'core', description: 'Install self-upgrades from tarballs in this directory (hivemagnus-magnus-agent-<X>.tgz) instead of the npm registry, for offline nodes. The integrity check still applies.' },
|
|
19
|
+
{ name: 'MAGNUS_AGENT_NPM', required: false, secret: false, default: 'npm', group: 'core', description: 'npm executable used for self-upgrades.' },
|
|
15
20
|
{ name: 'MAGNUS_EXECUTION_SUPERVISOR_ENABLED', required: false, secret: false, default: 'false', group: 'core', description: 'Run commands under the durable execution supervisor.' },
|
|
16
21
|
{ name: 'CONTROL_API_CA_PATH', required: false, secret: false, group: 'tls', description: 'PEM CA bundle trusted for a private-CA Control API. Verification is never disabled.' },
|
|
17
22
|
{ name: 'TLS_CERT_PATH', required: false, secret: false, group: 'tls', description: 'Listener certificate (absolute or relative to AGENT_INSTALL_DIR). Required with TLS_KEY_PATH when NODE_ENV=production.' },
|
|
@@ -36,6 +41,7 @@ export const agentEnvCatalog = [
|
|
|
36
41
|
{ name: 'CODEX_HOME', required: false, secret: false, default: '~/.codex', group: 'codex', description: 'Codex state directory override.' },
|
|
37
42
|
{ name: 'ARDENT_OWNER_USER', required: false, secret: false, group: 'ardent', description: 'Local account that owns the Ardent runtime and profiles.' },
|
|
38
43
|
{ name: 'ARDENT_STAGED_PACKAGE', required: false, secret: false, default: '/opt/magnus-agent/ardent-package.tgz', group: 'ardent', description: 'Verified Ardent package tarball staged for installation.' },
|
|
44
|
+
{ name: 'MAGNUS_AGENT_ARDENT_PACKAGES', required: false, secret: false, default: '@hivemagnus/ardent', group: 'ardent', description: 'Comma-separated scoped npm package names accepted as the Ardent runtime (staged sidecar, install path, receipt), in preference order. Add a legacy name to keep managing an older Ardent build.' },
|
|
39
45
|
{ name: 'MAGNUS_AGENT_RENEWAL_CONFIG', required: false, secret: false, default: '$AGENT_INSTALL_DIR/credential-renewal.json', group: 'renewal', description: 'HiveMem credential renewal entries (root-owned, mode 0600). Absent means renewal is off.' },
|
|
40
46
|
{ name: 'MAGNUS_AGENT_RENEWAL_ENDPOINTS', required: false, secret: false, group: 'renewal', description: 'Comma-separated HTTPS HiveMem base URLs renewal may call.' },
|
|
41
47
|
{ name: 'MAGNUS_AGENT_RENEWAL_UNITS', required: false, secret: false, group: 'renewal', description: 'Comma-separated systemd units renewal may try-restart after replacing a credential.' },
|
package/dist/env.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { getFqdn } from './fqdn.js';
|
|
11
11
|
import { isIPv4 } from 'node:net';
|
|
12
12
|
import { agentPrivileged, isRoot } from './privileged.js';
|
|
13
|
+
import { ardentPackageNames } from './executors/ardent-packages.js';
|
|
13
14
|
function envFlag(name, fallback = false) {
|
|
14
15
|
return (process.env[name] ?? String(fallback)).toLowerCase() === 'true';
|
|
15
16
|
}
|
|
@@ -113,6 +114,12 @@ export function configurationErrors() {
|
|
|
113
114
|
if (!agentPrivileged() && MAGNUS_EXECUTION_SUPERVISOR_ENABLED) {
|
|
114
115
|
errors.push('MAGNUS_EXECUTION_SUPERVISOR_ENABLED=true requires MAGNUS_AGENT_PRIVILEGED=true');
|
|
115
116
|
}
|
|
117
|
+
try {
|
|
118
|
+
ardentPackageNames();
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
errors.push(error instanceof Error ? error.message : 'MAGNUS_AGENT_ARDENT_PACKAGES is invalid');
|
|
122
|
+
}
|
|
116
123
|
return errors;
|
|
117
124
|
}
|
|
118
125
|
//# sourceMappingURL=env.js.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
|
+
export { ardentPackageNames, DEFAULT_ARDENT_PACKAGE } from './ardent-packages.js';
|
|
2
3
|
type ArdentRoot = {
|
|
3
4
|
owner: string;
|
|
4
5
|
home: string;
|
|
@@ -6,13 +7,13 @@ type ArdentRoot = {
|
|
|
6
7
|
install: string;
|
|
7
8
|
};
|
|
8
9
|
export declare function verifiedInstalledArdentPackage(install: string, staged: {
|
|
10
|
+
name?: string;
|
|
9
11
|
version: string;
|
|
10
12
|
sha256: string;
|
|
11
13
|
}): Promise<boolean>;
|
|
12
14
|
export declare function readArdentState(target?: ArdentRoot): Promise<object>;
|
|
13
15
|
export declare function hermesTrafficConsumersDisabled(home: string): Promise<boolean>;
|
|
14
|
-
export declare function ardentUserUnit(root: ArdentRoot, profile: string): string;
|
|
16
|
+
export declare function ardentUserUnit(root: ArdentRoot, profile: string, packageName?: string): string;
|
|
15
17
|
export declare function createArdentRoutes(resolveRoot?: () => Promise<ArdentRoot>): Hono;
|
|
16
18
|
export declare const ardentRoutes: Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
|
|
17
|
-
export {};
|
|
18
19
|
//# sourceMappingURL=ardent-manager.d.ts.map
|
|
@@ -7,6 +7,8 @@ import { Hono } from 'hono';
|
|
|
7
7
|
import { parse as parseEnv } from 'dotenv';
|
|
8
8
|
import YAML from 'yaml';
|
|
9
9
|
import { z } from 'zod';
|
|
10
|
+
import { ardentPackageNames } from './ardent-packages.js';
|
|
11
|
+
export { ardentPackageNames, DEFAULT_ARDENT_PACKAGE } from './ardent-packages.js';
|
|
10
12
|
const profileName = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/);
|
|
11
13
|
const configSummary = z.object({
|
|
12
14
|
schema_version: z.literal(1), profile: profileName, name: z.string().min(1),
|
|
@@ -15,12 +17,34 @@ const configSummary = z.object({
|
|
|
15
17
|
a2a: z.object({ enabled: z.boolean() }).passthrough(),
|
|
16
18
|
channels: z.record(z.string().regex(/^[a-z][a-z0-9_-]{0,31}$/), z.object({ enabled: z.boolean() }).passthrough()).default({}),
|
|
17
19
|
}).passthrough();
|
|
18
|
-
const
|
|
20
|
+
const acceptedPackageName = z.string().refine(name => ardentPackageNames().includes(name), 'Ardent package name is not accepted by MAGNUS_AGENT_ARDENT_PACKAGES');
|
|
21
|
+
const packageVersion = z.object({ name: acceptedPackageName, version: z.string() });
|
|
19
22
|
const installedPackageReceipt = z.object({
|
|
20
|
-
schemaVersion: z.literal(1), name:
|
|
23
|
+
schemaVersion: z.literal(1), name: acceptedPackageName,
|
|
21
24
|
version: z.string(), sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
22
25
|
packageTreeSha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
23
26
|
}).strict();
|
|
27
|
+
/** Package directory of an accepted Ardent package name inside an install prefix. */
|
|
28
|
+
function packageDir(install, name) {
|
|
29
|
+
if (!ardentPackageNames().includes(name))
|
|
30
|
+
throw new Error('Ardent package name is not accepted by MAGNUS_AGENT_ARDENT_PACKAGES');
|
|
31
|
+
return join(install, 'node_modules', ...name.split('/'));
|
|
32
|
+
}
|
|
33
|
+
/** The accepted Ardent package installed under `install`, in preference order. */
|
|
34
|
+
async function installedPackageName(install) {
|
|
35
|
+
for (const name of ardentPackageNames()) {
|
|
36
|
+
try {
|
|
37
|
+
const installed = packageVersion.parse(JSON.parse(await readFile(join(packageDir(install, name), 'package.json'), 'utf8')));
|
|
38
|
+
if (installed.name === name)
|
|
39
|
+
return name;
|
|
40
|
+
}
|
|
41
|
+
catch { /* Try the next accepted name. */ }
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
async function installedCli(install) {
|
|
46
|
+
return join(packageDir(install, await installedPackageName(install) ?? ardentPackageNames()[0]), 'dist/cli.js');
|
|
47
|
+
}
|
|
24
48
|
/** Channel name -> enabled, for every channel the profile config declares. */
|
|
25
49
|
function channelStates(config) {
|
|
26
50
|
return Object.fromEntries(Object.entries(config.channels).map(([name, channel]) => [name, channel.enabled]));
|
|
@@ -112,11 +136,11 @@ async function stagedPackage() {
|
|
|
112
136
|
const sidecarBytes = await readFile(sidecar);
|
|
113
137
|
if (createHash('sha256').update(sidecarBytes).digest('hex') !== entry('ardent-package.json'))
|
|
114
138
|
throw new Error('Staged Ardent package sidecar differs from release manifest');
|
|
115
|
-
const details = z.object({ name:
|
|
139
|
+
const details = z.object({ name: acceptedPackageName, version: z.string(), sha256: z.string().regex(/^[a-f0-9]{64}$/) }).parse(JSON.parse(sidecarBytes.toString('utf8')));
|
|
116
140
|
const digest = createHash('sha256').update(await readFile(archive)).digest('hex');
|
|
117
141
|
if (digest !== details.sha256 || digest !== entry('ardent-package.tgz'))
|
|
118
142
|
throw new Error('Staged Ardent package differs from release manifest');
|
|
119
|
-
return { version: details.version, sha256: digest };
|
|
143
|
+
return { name: details.name, version: details.version, sha256: digest };
|
|
120
144
|
}
|
|
121
145
|
async function packageTreeDigest(packageRoot) {
|
|
122
146
|
const entries = [];
|
|
@@ -160,15 +184,20 @@ export async function verifiedInstalledArdentPackage(install, staged) {
|
|
|
160
184
|
const receipt = installedPackageReceipt.safeParse(JSON.parse(await readFile(receiptPath, 'utf8')));
|
|
161
185
|
if (!receipt.success || receipt.data.version !== staged.version || receipt.data.sha256 !== staged.sha256)
|
|
162
186
|
return false;
|
|
163
|
-
|
|
187
|
+
if (staged.name !== undefined && receipt.data.name !== staged.name)
|
|
188
|
+
return false;
|
|
189
|
+
return await packageTreeDigest(packageDir(install, receipt.data.name)) === receipt.data.packageTreeSha256;
|
|
164
190
|
}
|
|
165
191
|
export async function readArdentState(target) {
|
|
166
192
|
const root = target ?? await ardentRoot();
|
|
167
193
|
let version = null;
|
|
194
|
+
let packageName = null;
|
|
168
195
|
try {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
196
|
+
packageName = await installedPackageName(root.install);
|
|
197
|
+
if (packageName) {
|
|
198
|
+
const installed = JSON.parse(await readFile(join(packageDir(root.install, packageName), 'package.json'), 'utf8'));
|
|
199
|
+
version = packageVersion.parse(installed).version;
|
|
200
|
+
}
|
|
172
201
|
}
|
|
173
202
|
catch { /* An uninstalled package is a normal inventory state. */ }
|
|
174
203
|
const profiles = await Promise.all((await safeProfiles(root.root)).map(async (name) => {
|
|
@@ -191,7 +220,7 @@ export async function readArdentState(target) {
|
|
|
191
220
|
catch (error) {
|
|
192
221
|
stagedPackageError = error instanceof Error ? error.message.slice(0, 200) : 'Staged package unavailable';
|
|
193
222
|
}
|
|
194
|
-
return { installed: version !== null, version, owner: root.owner, profiles, stagedPackage: staged, stagedPackageError, checkedAt: new Date().toISOString() };
|
|
223
|
+
return { installed: version !== null, version, packageName, owner: root.owner, profiles, stagedPackage: staged, stagedPackageError, checkedAt: new Date().toISOString() };
|
|
195
224
|
}
|
|
196
225
|
async function managementToken(dir, envName) {
|
|
197
226
|
const processToken = process.env[envName];
|
|
@@ -264,12 +293,12 @@ export async function hermesTrafficConsumersDisabled(home) {
|
|
|
264
293
|
return false;
|
|
265
294
|
}
|
|
266
295
|
}
|
|
267
|
-
export function ardentUserUnit(root, profile) {
|
|
296
|
+
export function ardentUserUnit(root, profile, packageName = ardentPackageNames()[0]) {
|
|
268
297
|
profileName.parse(profile);
|
|
269
298
|
const safe = /^[A-Za-z0-9_./-]+$/;
|
|
270
299
|
if (![root.home, root.root, root.install].every(path => safe.test(path)))
|
|
271
300
|
throw new Error('Ardent service paths contain unsupported characters');
|
|
272
|
-
return `[Unit]\nDescription=Magnus Ardent ${profile}\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nWorkingDirectory=${join(root.root, 'profiles', profile)}\nEnvironment=ARDENT_HOME=${root.root}\nExecStart=/usr/bin/node ${join(root.install, '
|
|
301
|
+
return `[Unit]\nDescription=Magnus Ardent ${profile}\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nWorkingDirectory=${join(root.root, 'profiles', profile)}\nEnvironment=ARDENT_HOME=${root.root}\nExecStart=/usr/bin/node ${join(packageDir(root.install, packageName), 'dist/cli.js')} --profile ${profile} gateway run\nRestart=on-failure\nRestartSec=5\nTimeoutStopSec=75\nNoNewPrivileges=true\n\n[Install]\nWantedBy=default.target\n`;
|
|
273
302
|
}
|
|
274
303
|
async function serviceAction(root, profile, action) {
|
|
275
304
|
if (process.getuid?.() !== 0)
|
|
@@ -289,7 +318,7 @@ async function serviceAction(root, profile, action) {
|
|
|
289
318
|
}
|
|
290
319
|
const unit = `ardent@${profile}.service`;
|
|
291
320
|
const path = join(root.home, '.config/systemd/user', unit);
|
|
292
|
-
const expected = ardentUserUnit(root, profile);
|
|
321
|
+
const expected = ardentUserUnit(root, profile, await installedPackageName(root.install) ?? undefined);
|
|
293
322
|
const metadata = await lstat(path).catch((error) => {
|
|
294
323
|
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')
|
|
295
324
|
return undefined;
|
|
@@ -307,7 +336,7 @@ async function serviceAction(root, profile, action) {
|
|
|
307
336
|
if (action !== 'service-start' && existing === undefined)
|
|
308
337
|
throw new Error('Managed Ardent user service is not installed');
|
|
309
338
|
if (action === 'service-start' && existing === undefined) {
|
|
310
|
-
const script =
|
|
339
|
+
const script = await installedCli(root.install);
|
|
311
340
|
const scriptInfo = await lstat(script);
|
|
312
341
|
if (!scriptInfo.isFile() || scriptInfo.isSymbolicLink())
|
|
313
342
|
throw new Error('Ardent package executable is unavailable');
|
|
@@ -377,14 +406,14 @@ async function installPackage(root) {
|
|
|
377
406
|
let installed = false;
|
|
378
407
|
try {
|
|
379
408
|
await runAsOwner(root, ['/usr/bin/npm', 'install', '--prefix', stage, '--omit=dev', '--no-audit', '--no-fund', '--no-save', archive], 9 * 60_000);
|
|
380
|
-
const packageFile = join(stage, '
|
|
409
|
+
const packageFile = join(packageDir(stage, staged.name), 'package.json');
|
|
381
410
|
const candidate = packageVersion.parse(JSON.parse(await readFile(packageFile, 'utf8')));
|
|
382
|
-
if (candidate.version !== staged.version)
|
|
383
|
-
throw new Error('Staged Ardent package version differs');
|
|
411
|
+
if (candidate.name !== staged.name || candidate.version !== staged.version)
|
|
412
|
+
throw new Error('Staged Ardent package name or version differs');
|
|
384
413
|
await runAsOwner(root, ['/usr/bin/node', '-e', "const Database=require(process.argv[1]);new Database(':memory:').close()", join(stage, 'node_modules/better-sqlite3')], 10_000);
|
|
385
|
-
const packageTreeSha256 = await packageTreeDigest(
|
|
414
|
+
const packageTreeSha256 = await packageTreeDigest(packageDir(stage, staged.name));
|
|
386
415
|
await writeFile(join(stage, 'package-receipt.json'), `${JSON.stringify({
|
|
387
|
-
schemaVersion: 1, name:
|
|
416
|
+
schemaVersion: 1, name: staged.name, version: staged.version, sha256: staged.sha256, packageTreeSha256,
|
|
388
417
|
})}\n`, { mode: 0o644 });
|
|
389
418
|
if (priorInfo) {
|
|
390
419
|
await rename(root.install, previous);
|
|
@@ -426,7 +455,7 @@ async function createProfile(root, profile, body) {
|
|
|
426
455
|
if (current.config.api.port === input.api_port)
|
|
427
456
|
throw new Error(`API port is already assigned to profile ${name}`);
|
|
428
457
|
}
|
|
429
|
-
const script =
|
|
458
|
+
const script = await installedCli(root.install);
|
|
430
459
|
const metadata = await lstat(script);
|
|
431
460
|
if (!metadata.isFile() || metadata.isSymbolicLink())
|
|
432
461
|
throw new Error('Installed Ardent CLI is unsafe');
|
|
@@ -453,7 +482,7 @@ async function importHermesProfile(root, profile, body) {
|
|
|
453
482
|
const sourceInfo = await lstat(source);
|
|
454
483
|
if (!sourceInfo.isDirectory() || sourceInfo.isSymbolicLink())
|
|
455
484
|
throw new Error('Hermes source home is unsafe');
|
|
456
|
-
const script =
|
|
485
|
+
const script = await installedCli(root.install);
|
|
457
486
|
const scriptInfo = await lstat(script);
|
|
458
487
|
if (!scriptInfo.isFile() || scriptInfo.isSymbolicLink())
|
|
459
488
|
throw new Error('Installed Ardent CLI is unsafe');
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const DEFAULT_ARDENT_PACKAGE = "@hivemagnus/ardent";
|
|
2
|
+
/**
|
|
3
|
+
* npm package names the agent accepts as the Ardent runtime, in preference
|
|
4
|
+
* order. `MAGNUS_AGENT_ARDENT_PACKAGES` (comma-separated scoped names) lets a
|
|
5
|
+
* node keep managing an Ardent build published under another scope.
|
|
6
|
+
*/
|
|
7
|
+
export declare function ardentPackageNames(value?: string | undefined): string[];
|
|
8
|
+
//# sourceMappingURL=ardent-packages.d.ts.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const DEFAULT_ARDENT_PACKAGE = '@hivemagnus/ardent';
|
|
2
|
+
const scopedPackageName = /^@[a-z0-9][a-z0-9._-]{0,99}\/[a-z0-9][a-z0-9._-]{0,99}$/;
|
|
3
|
+
/**
|
|
4
|
+
* npm package names the agent accepts as the Ardent runtime, in preference
|
|
5
|
+
* order. `MAGNUS_AGENT_ARDENT_PACKAGES` (comma-separated scoped names) lets a
|
|
6
|
+
* node keep managing an Ardent build published under another scope.
|
|
7
|
+
*/
|
|
8
|
+
export function ardentPackageNames(value = process.env['MAGNUS_AGENT_ARDENT_PACKAGES']) {
|
|
9
|
+
if (value === undefined || value.trim() === '')
|
|
10
|
+
return [DEFAULT_ARDENT_PACKAGE];
|
|
11
|
+
const names = value.split(',').map(item => item.trim()).filter(Boolean);
|
|
12
|
+
if (!names.length || names.some(name => !scopedPackageName.test(name)))
|
|
13
|
+
throw new Error('MAGNUS_AGENT_ARDENT_PACKAGES must list scoped npm package names such as @scope/ardent');
|
|
14
|
+
return [...new Set(names)];
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=ardent-packages.js.map
|
|
@@ -6,8 +6,8 @@ export declare const schedulerStatusSchema: z.ZodObject<{
|
|
|
6
6
|
nodeFqdn: z.ZodString;
|
|
7
7
|
nonce: z.ZodString;
|
|
8
8
|
state: z.ZodEnum<{
|
|
9
|
-
enrolled: "enrolled";
|
|
10
9
|
legacy: "legacy";
|
|
10
|
+
enrolled: "enrolled";
|
|
11
11
|
draining: "draining";
|
|
12
12
|
}>;
|
|
13
13
|
epoch: z.ZodNumber;
|
|
@@ -25,7 +25,7 @@ export declare function freshSchedulerStatus(input: {
|
|
|
25
25
|
nodeId: string;
|
|
26
26
|
nodeFqdn: string;
|
|
27
27
|
nonce: string;
|
|
28
|
-
state: "
|
|
28
|
+
state: "legacy" | "enrolled" | "draining";
|
|
29
29
|
epoch: number;
|
|
30
30
|
barrierId: string | null;
|
|
31
31
|
schedulerChallenge: {
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,7 @@ Usage:
|
|
|
12
12
|
magnus-agent [start] [--env-file <path>] Start the agent (default)
|
|
13
13
|
magnus-agent check [--json] [--env-file <path>] Validate configuration without starting
|
|
14
14
|
magnus-agent env-schema Print the configuration schema as JSON
|
|
15
|
+
magnus-agent install-service [--unprivileged] (root, once) Versioned layout, upgrade launcher and systemd unit
|
|
15
16
|
magnus-agent --help | --version
|
|
16
17
|
|
|
17
18
|
Options:
|
|
@@ -92,6 +93,10 @@ export async function main(argv) {
|
|
|
92
93
|
}
|
|
93
94
|
return report.ok ? 0 : 1;
|
|
94
95
|
}
|
|
96
|
+
case 'install-service': {
|
|
97
|
+
const { installService } = await import('./upgrade/install-service.js');
|
|
98
|
+
return installService(cli.rest);
|
|
99
|
+
}
|
|
95
100
|
default:
|
|
96
101
|
return fail(`unknown command: ${cli.command}. Run "magnus-agent --help".`);
|
|
97
102
|
}
|
|
@@ -82,6 +82,16 @@ export declare const NATIVE_COMPLETION_REGISTRY: {
|
|
|
82
82
|
readonly completionMode: "buffered-synchronous-http";
|
|
83
83
|
readonly timeoutCeilingSeconds: 900;
|
|
84
84
|
readonly resourceClassifier: "node-ardent";
|
|
85
|
+
}, {
|
|
86
|
+
readonly id: "agent-self-upgrade";
|
|
87
|
+
readonly method: "POST";
|
|
88
|
+
readonly pathMatcher: {
|
|
89
|
+
readonly kind: "exact";
|
|
90
|
+
readonly path: "/agent-upgrade";
|
|
91
|
+
};
|
|
92
|
+
readonly completionMode: "buffered-synchronous-http";
|
|
93
|
+
readonly timeoutCeilingSeconds: 900;
|
|
94
|
+
readonly resourceClassifier: "node-filesystem";
|
|
85
95
|
}];
|
|
86
96
|
};
|
|
87
97
|
export type NativeCompletionDescriptor = (typeof NATIVE_COMPLETION_REGISTRY.descriptors)[number];
|
|
@@ -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
|
@@ -162,7 +162,7 @@ export async function createApp() {
|
|
|
162
162
|
});
|
|
163
163
|
// ── Node-management routes (fleet agent) ──
|
|
164
164
|
if (ENABLE_NODE_MANAGEMENT) {
|
|
165
|
-
const [{ healthRoutes }, { commandRoutes }, { managedMutationGuard, registerManagedHttp }, { cronRoutes }, { fileRoutes }, { skillRoutes }, { updateRoutes }, { driftRoutes }, { networkInfoRoutes }, { profilingRoutes }, { otelRoutes }, { workspaceRoutes }, { hermesRoutes }, { codexRoutes }, { ardentRoutes },] = await Promise.all([
|
|
165
|
+
const [{ healthRoutes }, { commandRoutes }, { managedMutationGuard, registerManagedHttp }, { cronRoutes }, { fileRoutes }, { skillRoutes }, { updateRoutes }, { driftRoutes }, { networkInfoRoutes }, { profilingRoutes }, { otelRoutes }, { workspaceRoutes }, { hermesRoutes }, { codexRoutes }, { ardentRoutes }, { agentUpgradeRoutes },] = await Promise.all([
|
|
166
166
|
import('./collectors/health-routes.js'),
|
|
167
167
|
import('./executors/command-executor.js'),
|
|
168
168
|
import('./executors/managed-http.js'),
|
|
@@ -178,6 +178,7 @@ export async function createApp() {
|
|
|
178
178
|
import('./executors/hermes-manager.js'),
|
|
179
179
|
import('./executors/codex-manager.js'),
|
|
180
180
|
import('./executors/ardent-manager.js'),
|
|
181
|
+
import('./upgrade/routes.js'),
|
|
181
182
|
]);
|
|
182
183
|
const v1 = new Hono();
|
|
183
184
|
v1.use('*', nodeAuthMiddleware);
|
|
@@ -205,6 +206,7 @@ export async function createApp() {
|
|
|
205
206
|
v1.route('/hermes', hermesRoutes);
|
|
206
207
|
v1.route('/codex', codexRoutes);
|
|
207
208
|
v1.route('/ardent', ardentRoutes);
|
|
209
|
+
v1.route('/agent-upgrade', agentUpgradeRoutes);
|
|
208
210
|
// User management (provision, password, listing)
|
|
209
211
|
const { userRoutes } = await import('./routes/user-manager.js');
|
|
210
212
|
v1.route('/users', userRoutes);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface InstallServiceOptions {
|
|
2
|
+
dir: string;
|
|
3
|
+
unprivileged: boolean;
|
|
4
|
+
user: string;
|
|
5
|
+
dryRun: boolean;
|
|
6
|
+
start: boolean;
|
|
7
|
+
unitPath: string;
|
|
8
|
+
nodePath: string;
|
|
9
|
+
}
|
|
10
|
+
export declare const INSTALL_SERVICE_USAGE = "Usage: sudo magnus-agent install-service [options]\n\nMoves this install into the versioned layout used by self-upgrades, installs\nthe upgrade launcher and writes the systemd unit. Run it once per node.\n\nOptions:\n --dir DIR Install directory (default: AGENT_INSTALL_DIR or /opt/magnus-agent)\n --unprivileged Run as an ordinary account (MAGNUS_AGENT_PRIVILEGED=false)\n --user NAME Account for --unprivileged (default magnus-agent; must exist)\n --unit PATH Unit file (default /etc/systemd/system/magnus-agent.service)\n --no-start Write everything, but do not restart the service\n --dry-run Print the steps without changing anything";
|
|
11
|
+
export declare function parseInstallServiceArgs(argv: readonly string[], env?: NodeJS.ProcessEnv): InstallServiceOptions | 'help';
|
|
12
|
+
export declare function renderUnit(options: Pick<InstallServiceOptions, 'dir' | 'unprivileged' | 'user' | 'nodePath'>): string;
|
|
13
|
+
export declare function installService(argv: readonly string[]): Promise<number>;
|
|
14
|
+
//# sourceMappingURL=install-service.d.ts.map
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `magnus-agent install-service`: one-time, operator-run (root) setup that
|
|
3
|
+
* moves an npm-prefix install into the versioned layout the self-upgrade
|
|
4
|
+
* needs, installs the launcher and writes the systemd unit:
|
|
5
|
+
*
|
|
6
|
+
* /opt/magnus-agent/.env
|
|
7
|
+
* /opt/magnus-agent/versions/<X>/node_modules/@hivemagnus/magnus-agent
|
|
8
|
+
* /opt/magnus-agent/current -> versions/<X>
|
|
9
|
+
* /opt/magnus-agent/launcher/launcher.mjs (ExecStart)
|
|
10
|
+
*
|
|
11
|
+
* After this the agent upgrades itself inside that directory; it never needs
|
|
12
|
+
* root for it and never calls sudo.
|
|
13
|
+
*/
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { dirname, join, resolve } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { AGENT_PACKAGE, pointCurrentAt } from './self-upgrade.js';
|
|
19
|
+
export const INSTALL_SERVICE_USAGE = `Usage: sudo magnus-agent install-service [options]
|
|
20
|
+
|
|
21
|
+
Moves this install into the versioned layout used by self-upgrades, installs
|
|
22
|
+
the upgrade launcher and writes the systemd unit. Run it once per node.
|
|
23
|
+
|
|
24
|
+
Options:
|
|
25
|
+
--dir DIR Install directory (default: AGENT_INSTALL_DIR or /opt/magnus-agent)
|
|
26
|
+
--unprivileged Run as an ordinary account (MAGNUS_AGENT_PRIVILEGED=false)
|
|
27
|
+
--user NAME Account for --unprivileged (default magnus-agent; must exist)
|
|
28
|
+
--unit PATH Unit file (default /etc/systemd/system/magnus-agent.service)
|
|
29
|
+
--no-start Write everything, but do not restart the service
|
|
30
|
+
--dry-run Print the steps without changing anything`;
|
|
31
|
+
export function parseInstallServiceArgs(argv, env = process.env) {
|
|
32
|
+
const options = {
|
|
33
|
+
dir: env['AGENT_INSTALL_DIR'] ?? '/opt/magnus-agent', unprivileged: false, user: 'magnus-agent', dryRun: false, start: true,
|
|
34
|
+
unitPath: '/etc/systemd/system/magnus-agent.service', nodePath: process.execPath,
|
|
35
|
+
};
|
|
36
|
+
for (let index = 0; index < argv.length; index++) {
|
|
37
|
+
const arg = argv[index];
|
|
38
|
+
const value = () => {
|
|
39
|
+
const next = argv[++index];
|
|
40
|
+
if (!next || next.startsWith('--'))
|
|
41
|
+
throw new Error(`${arg} needs a value`);
|
|
42
|
+
return next;
|
|
43
|
+
};
|
|
44
|
+
if (arg === '--help' || arg === '-h')
|
|
45
|
+
return 'help';
|
|
46
|
+
else if (arg === '--dir')
|
|
47
|
+
options.dir = value();
|
|
48
|
+
else if (arg === '--unprivileged')
|
|
49
|
+
options.unprivileged = true;
|
|
50
|
+
else if (arg === '--user')
|
|
51
|
+
options.user = value();
|
|
52
|
+
else if (arg === '--unit')
|
|
53
|
+
options.unitPath = value();
|
|
54
|
+
else if (arg === '--no-start')
|
|
55
|
+
options.start = false;
|
|
56
|
+
else if (arg === '--dry-run')
|
|
57
|
+
options.dryRun = true;
|
|
58
|
+
else
|
|
59
|
+
throw new Error(`unknown option ${arg}`);
|
|
60
|
+
}
|
|
61
|
+
options.dir = resolve(options.dir);
|
|
62
|
+
return options;
|
|
63
|
+
}
|
|
64
|
+
export function renderUnit(options) {
|
|
65
|
+
const { dir } = options;
|
|
66
|
+
const common = [
|
|
67
|
+
'Type=simple',
|
|
68
|
+
`WorkingDirectory=${dir}`,
|
|
69
|
+
`Environment=AGENT_INSTALL_DIR=${dir}`,
|
|
70
|
+
`Environment=npm_config_cache=${join(dir, '.npm')}`,
|
|
71
|
+
`EnvironmentFile=${join(dir, '.env')}`,
|
|
72
|
+
`ExecStart=${options.nodePath} ${join(dir, 'launcher', 'launcher.mjs')}`,
|
|
73
|
+
'# Any exit restarts: the launcher exits to hand over to a new or reverted version.',
|
|
74
|
+
'Restart=always',
|
|
75
|
+
'RestartSec=5',
|
|
76
|
+
'StandardOutput=journal',
|
|
77
|
+
'StandardError=journal',
|
|
78
|
+
'UMask=0077',
|
|
79
|
+
'NoNewPrivileges=true',
|
|
80
|
+
'PrivateTmp=true',
|
|
81
|
+
];
|
|
82
|
+
const hardening = options.unprivileged ? [
|
|
83
|
+
`User=${options.user}`,
|
|
84
|
+
`Group=${options.user}`,
|
|
85
|
+
'Environment=MAGNUS_AGENT_PRIVILEGED=false',
|
|
86
|
+
'CapabilityBoundingSet=',
|
|
87
|
+
'AmbientCapabilities=',
|
|
88
|
+
'PrivateDevices=true',
|
|
89
|
+
'ProtectSystem=strict',
|
|
90
|
+
`ReadWritePaths=${dir}`,
|
|
91
|
+
'StateDirectory=magnus-agent',
|
|
92
|
+
'ProtectHome=read-only',
|
|
93
|
+
'ProtectKernelTunables=true',
|
|
94
|
+
'ProtectKernelModules=true',
|
|
95
|
+
'ProtectKernelLogs=true',
|
|
96
|
+
'ProtectControlGroups=true',
|
|
97
|
+
'ProtectClock=true',
|
|
98
|
+
'ProtectHostname=true',
|
|
99
|
+
'RestrictSUIDSGID=true',
|
|
100
|
+
'RestrictRealtime=true',
|
|
101
|
+
'RestrictNamespaces=true',
|
|
102
|
+
'LockPersonality=true',
|
|
103
|
+
'RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK',
|
|
104
|
+
'SystemCallArchitectures=native',
|
|
105
|
+
] : [];
|
|
106
|
+
return [
|
|
107
|
+
'# Generated by `magnus-agent install-service`. The launcher runs ./current and',
|
|
108
|
+
'# reverts a self-upgrade that does not report a heartbeat in time.',
|
|
109
|
+
'[Unit]',
|
|
110
|
+
`Description=Magnus Agent${options.unprivileged ? ' (unprivileged)' : ''}`,
|
|
111
|
+
'After=network-online.target',
|
|
112
|
+
'Wants=network-online.target',
|
|
113
|
+
'StartLimitIntervalSec=0',
|
|
114
|
+
'',
|
|
115
|
+
'[Service]',
|
|
116
|
+
...common,
|
|
117
|
+
...hardening,
|
|
118
|
+
'',
|
|
119
|
+
'[Install]',
|
|
120
|
+
'WantedBy=multi-user.target',
|
|
121
|
+
'',
|
|
122
|
+
].join('\n');
|
|
123
|
+
}
|
|
124
|
+
function packageRoot() {
|
|
125
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
126
|
+
}
|
|
127
|
+
function ownVersion() {
|
|
128
|
+
return JSON.parse(readFileSync(join(packageRoot(), 'package.json'), 'utf8')).version;
|
|
129
|
+
}
|
|
130
|
+
function isSymlink(path) {
|
|
131
|
+
try {
|
|
132
|
+
return lstatSync(path).isSymbolicLink();
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
export async function installService(argv) {
|
|
139
|
+
let options;
|
|
140
|
+
try {
|
|
141
|
+
options = parseInstallServiceArgs(argv);
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
console.error(`magnus-agent install-service: ${error.message}`);
|
|
145
|
+
return 2;
|
|
146
|
+
}
|
|
147
|
+
if (options === 'help') {
|
|
148
|
+
console.log(INSTALL_SERVICE_USAGE);
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
const { dir, dryRun } = options;
|
|
152
|
+
if (!dryRun && (typeof process.geteuid !== 'function' || process.geteuid() !== 0)) {
|
|
153
|
+
console.error('magnus-agent install-service writes a system unit; run it as root (sudo). Use --dry-run to preview.');
|
|
154
|
+
return 1;
|
|
155
|
+
}
|
|
156
|
+
const version = ownVersion();
|
|
157
|
+
const steps = [];
|
|
158
|
+
const act = (description, action) => {
|
|
159
|
+
steps.push(description);
|
|
160
|
+
if (!dryRun)
|
|
161
|
+
action();
|
|
162
|
+
};
|
|
163
|
+
const target = join(dir, 'versions', version);
|
|
164
|
+
const current = join(dir, 'current');
|
|
165
|
+
const here = realpathSync(packageRoot());
|
|
166
|
+
if (!existsSync(join(dir, '.env')))
|
|
167
|
+
console.warn(`warning: ${join(dir, '.env')} does not exist; create it before the service starts`);
|
|
168
|
+
if (isSymlink(current)) {
|
|
169
|
+
steps.push(`keep the versioned layout (./current -> ${realpathSync(current)})`);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
const prefixModules = join(dir, 'node_modules');
|
|
173
|
+
const fromPrefix = existsSync(prefixModules) && here === realpathSync(join(prefixModules, ...AGENT_PACKAGE.split('/')));
|
|
174
|
+
act(`create ${target}`, () => mkdirSync(target, { recursive: true }));
|
|
175
|
+
if (fromPrefix) {
|
|
176
|
+
act(`move ${prefixModules} to ${join(target, 'node_modules')}`, () => renameSync(prefixModules, join(target, 'node_modules')));
|
|
177
|
+
for (const file of ['package.json', 'package-lock.json']) {
|
|
178
|
+
if (existsSync(join(dir, file)))
|
|
179
|
+
act(`move ${file} into ${target}`, () => renameSync(join(dir, file), join(target, file)));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
act(`npm install ${AGENT_PACKAGE}@${version} into ${target}`, () => {
|
|
184
|
+
writeFileSync(join(target, 'package.json'), `${JSON.stringify({ name: `magnus-agent-${version}`, version, private: true,
|
|
185
|
+
dependencies: { [AGENT_PACKAGE]: version }, allowScripts: { 'cpu-features': true, ssh2: true } }, null, 2)}\n`);
|
|
186
|
+
execFileSync('npm', ['install', '--omit=dev', '--no-audit', '--no-fund', '--loglevel=error'], { cwd: target, stdio: 'inherit' });
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
act(`point ${current} at versions/${version}`, () => pointCurrentAt(dir, version));
|
|
190
|
+
}
|
|
191
|
+
// The launcher is copied from the release that runs install-service, not linked:
|
|
192
|
+
// self-upgrades never replace it.
|
|
193
|
+
act(`install ${join(dir, 'launcher', 'launcher.mjs')}`, () => {
|
|
194
|
+
mkdirSync(join(dir, 'launcher'), { recursive: true, mode: 0o755 });
|
|
195
|
+
copyFileSync(join(isSymlink(current) ? realpathSync(join(current, 'node_modules', ...AGENT_PACKAGE.split('/'))) : here, 'launcher', 'launcher.mjs'), join(dir, 'launcher', 'launcher.mjs'));
|
|
196
|
+
});
|
|
197
|
+
act(`create ${join(dir, '.npm')} and ${join(dir, 'versions')}`, () => {
|
|
198
|
+
mkdirSync(join(dir, '.npm'), { recursive: true, mode: 0o700 });
|
|
199
|
+
mkdirSync(join(dir, 'versions'), { recursive: true });
|
|
200
|
+
});
|
|
201
|
+
if (options.unprivileged) {
|
|
202
|
+
const user = options.user;
|
|
203
|
+
act(`give ${user} the install directory (the agent writes versions/, current and its upgrade state)`, () => {
|
|
204
|
+
execFileSync('id', ['-u', user], { stdio: 'ignore' });
|
|
205
|
+
execFileSync('chown', [`${user}:${user}`, dir]);
|
|
206
|
+
execFileSync('chown', ['-R', `${user}:${user}`, join(dir, 'versions'), join(dir, '.npm')]);
|
|
207
|
+
execFileSync('chown', ['-h', `${user}:${user}`, current]);
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
act(`write ${options.unitPath}${existsSync(options.unitPath) ? ` (previous unit kept as ${options.unitPath}.pre-launcher)` : ''}`, () => {
|
|
211
|
+
if (existsSync(options.unitPath) && !existsSync(`${options.unitPath}.pre-launcher`))
|
|
212
|
+
copyFileSync(options.unitPath, `${options.unitPath}.pre-launcher`);
|
|
213
|
+
writeFileSync(options.unitPath, renderUnit(options), { mode: 0o644 });
|
|
214
|
+
});
|
|
215
|
+
const unit = options.unitPath.split('/').pop();
|
|
216
|
+
act(`systemctl daemon-reload && systemctl enable ${unit}`, () => {
|
|
217
|
+
execFileSync('systemctl', ['daemon-reload']);
|
|
218
|
+
execFileSync('systemctl', ['enable', unit], { stdio: 'ignore' });
|
|
219
|
+
});
|
|
220
|
+
if (options.start)
|
|
221
|
+
act(`systemctl restart ${unit}`, () => execFileSync('systemctl', ['restart', unit]));
|
|
222
|
+
console.log(`${dryRun ? 'Would' : 'Done'}:\n${steps.map(step => ` - ${step}`).join('\n')}`);
|
|
223
|
+
if (!dryRun)
|
|
224
|
+
console.log(`The agent now runs ${version} through the launcher; the server can upgrade it from the Nodes page or with \`magnus-server agents upgrade\`.`);
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
//# sourceMappingURL=install-service.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
/** Seconds between the reply and the exit that hands over to the launcher. */
|
|
3
|
+
export declare const RESTART_DELAY_MS = 3000;
|
|
4
|
+
export declare const agentUpgradeRoutes: Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
|
|
5
|
+
//# sourceMappingURL=routes.d.ts.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { agentUpgradeRequestSchema } from '@hivemagnus/magnus-contracts';
|
|
3
|
+
import { logger } from '../utils/logger.js';
|
|
4
|
+
import { AGENT_VERSION } from '../version.js';
|
|
5
|
+
import { SelfUpgradeError, selfUpgradeStatus, stageUpgrade } from './self-upgrade.js';
|
|
6
|
+
/** Seconds between the reply and the exit that hands over to the launcher. */
|
|
7
|
+
export const RESTART_DELAY_MS = 3_000;
|
|
8
|
+
export const agentUpgradeRoutes = new Hono();
|
|
9
|
+
agentUpgradeRoutes.get('/status', c => c.json({ success: true, data: selfUpgradeStatus(AGENT_VERSION) }));
|
|
10
|
+
agentUpgradeRoutes.post('/', async (c) => {
|
|
11
|
+
const parsed = agentUpgradeRequestSchema.safeParse(await c.req.json().catch(() => null));
|
|
12
|
+
if (!parsed.success) {
|
|
13
|
+
return c.json({ success: false, error: { code: 'VALIDATION_ERROR', message: 'version, integrity (sha512-…) and requestId are required' } }, 400);
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
const result = await stageUpgrade({ request: parsed.data, version: AGENT_VERSION });
|
|
17
|
+
logger.info(`Self-upgrade staged: ${result.from} -> ${result.to}; restarting through the launcher`);
|
|
18
|
+
// Reply first; the launcher starts versions/<to> on trial after this process exits.
|
|
19
|
+
setTimeout(() => process.kill(process.pid, 'SIGTERM'), RESTART_DELAY_MS).unref();
|
|
20
|
+
return c.json({ success: true, data: { accepted: true, ...result, restartInSeconds: RESTART_DELAY_MS / 1000 } });
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error instanceof SelfUpgradeError) {
|
|
24
|
+
logger.warn(`Self-upgrade to ${parsed.data.version} refused: ${error.message}`);
|
|
25
|
+
return c.json({ success: false, error: { code: error.code, message: error.message } }, error.status);
|
|
26
|
+
}
|
|
27
|
+
logger.error('Self-upgrade failed', { error: error instanceof Error ? error.message : String(error) });
|
|
28
|
+
return c.json({ success: false, error: { code: 'SELF_UPGRADE_FAILED', message: error instanceof Error ? error.message : String(error) } }, 500);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
//# sourceMappingURL=routes.js.map
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type AgentSelfUpgradeStatus, type AgentUpgradeRequest } from '@hivemagnus/magnus-contracts';
|
|
2
|
+
export declare const STATE_FILE = "upgrade-state.json";
|
|
3
|
+
export declare const AGENT_PACKAGE = "@hivemagnus/magnus-agent";
|
|
4
|
+
export interface UpgradeState {
|
|
5
|
+
state: 'idle' | 'staged' | 'trial' | 'confirmed' | 'reverted' | 'failed';
|
|
6
|
+
from: string | null;
|
|
7
|
+
to: string | null;
|
|
8
|
+
requestId: string | null;
|
|
9
|
+
attempt?: number;
|
|
10
|
+
maxAttempts?: number;
|
|
11
|
+
trialDeadline?: string;
|
|
12
|
+
reason?: string | null;
|
|
13
|
+
updatedAt?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function installDir(env?: NodeJS.ProcessEnv): string;
|
|
16
|
+
export declare function readUpgradeState(dir: string): UpgradeState | null;
|
|
17
|
+
export declare function writeUpgradeState(dir: string, state: UpgradeState): void;
|
|
18
|
+
export declare function pointCurrentAt(dir: string, version: string): void;
|
|
19
|
+
export type Layout = AgentSelfUpgradeStatus['layout'];
|
|
20
|
+
export declare function detectLayout(dir: string, env?: NodeJS.ProcessEnv): Layout;
|
|
21
|
+
/** Whether this agent can upgrade itself, and why not. Reported with every heartbeat. */
|
|
22
|
+
export declare function selfUpgradeStatus(version: string, env?: NodeJS.ProcessEnv): AgentSelfUpgradeStatus;
|
|
23
|
+
/**
|
|
24
|
+
* Called after every accepted heartbeat: the first one from a version on trial
|
|
25
|
+
* confirms it, and versions other than the previous and the current one are pruned.
|
|
26
|
+
*/
|
|
27
|
+
export declare function confirmTrial(version: string, env?: NodeJS.ProcessEnv, now?: Date): boolean;
|
|
28
|
+
export declare function pruneVersions(dir: string, keep: ReadonlyArray<string | null>): void;
|
|
29
|
+
export declare class SelfUpgradeError extends Error {
|
|
30
|
+
readonly code: string;
|
|
31
|
+
readonly status: 400 | 409 | 422 | 500;
|
|
32
|
+
constructor(code: string, message: string, status?: 400 | 409 | 422 | 500);
|
|
33
|
+
}
|
|
34
|
+
export interface Runner {
|
|
35
|
+
(command: string, args: readonly string[], options: {
|
|
36
|
+
cwd: string;
|
|
37
|
+
env: NodeJS.ProcessEnv;
|
|
38
|
+
}): Promise<{
|
|
39
|
+
code: number;
|
|
40
|
+
stdout: string;
|
|
41
|
+
stderr: string;
|
|
42
|
+
}>;
|
|
43
|
+
}
|
|
44
|
+
export declare const runProcess: Runner;
|
|
45
|
+
/** npm pack names files `<scope>-<name>-<version>.tgz`. */
|
|
46
|
+
export declare function tarballName(version: string): string;
|
|
47
|
+
export declare function sha512Integrity(file: string): string;
|
|
48
|
+
/** Other @hivemagnus tarballs in the source directory, used instead of the registry (offline nodes). */
|
|
49
|
+
export declare function sourceOverrides(sourceDir: string, agentTarball: string): Record<string, string>;
|
|
50
|
+
export interface StageOptions {
|
|
51
|
+
request: AgentUpgradeRequest;
|
|
52
|
+
version: string;
|
|
53
|
+
env?: NodeJS.ProcessEnv;
|
|
54
|
+
run?: Runner;
|
|
55
|
+
now?: Date;
|
|
56
|
+
}
|
|
57
|
+
export interface StageResult {
|
|
58
|
+
from: string;
|
|
59
|
+
to: string;
|
|
60
|
+
integrity: string;
|
|
61
|
+
requestId: string;
|
|
62
|
+
}
|
|
63
|
+
/** Download, verify, install and switch. The caller exits afterwards so the launcher starts the new version. */
|
|
64
|
+
export declare function stageUpgrade(options: StageOptions): Promise<StageResult>;
|
|
65
|
+
//# sourceMappingURL=self-upgrade.d.ts.map
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent self-upgrade (MAGNUS_AGENT_SELF_UPGRADE). The server asks for an exact
|
|
3
|
+
* version and its npm integrity; the agent downloads that tarball, checks it,
|
|
4
|
+
* installs it into versions/<X> beside the running one, points ./current at
|
|
5
|
+
* it and exits. The launcher (launcher/launcher.mjs, installed by
|
|
6
|
+
* `magnus-agent install-service`) starts the new version on trial and switches
|
|
7
|
+
* back if it does not report an accepted heartbeat in time. Nothing here uses
|
|
8
|
+
* sudo: the agent only writes inside its own install directory.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { accessSync, constants, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { join, resolve } from 'node:path';
|
|
14
|
+
import { AGENT_SELF_UPGRADE_PROTOCOL, checkUpgradeAllowed, compareVersions, } from '@hivemagnus/magnus-contracts';
|
|
15
|
+
export const STATE_FILE = 'upgrade-state.json';
|
|
16
|
+
export const AGENT_PACKAGE = '@hivemagnus/magnus-agent';
|
|
17
|
+
export function installDir(env = process.env) {
|
|
18
|
+
return resolve(env['AGENT_INSTALL_DIR'] ?? process.cwd());
|
|
19
|
+
}
|
|
20
|
+
export function readUpgradeState(dir) {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(readFileSync(join(dir, STATE_FILE), 'utf8'));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function writeUpgradeState(dir, state) {
|
|
29
|
+
const path = join(dir, STATE_FILE);
|
|
30
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
31
|
+
writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
32
|
+
renameSync(temporary, path);
|
|
33
|
+
}
|
|
34
|
+
export function pointCurrentAt(dir, version) {
|
|
35
|
+
const temporary = join(dir, `current.${process.pid}.tmp`);
|
|
36
|
+
rmSync(temporary, { force: true });
|
|
37
|
+
symlinkSync(join('versions', version), temporary, 'dir');
|
|
38
|
+
renameSync(temporary, join(dir, 'current'));
|
|
39
|
+
}
|
|
40
|
+
export function detectLayout(dir, env = process.env) {
|
|
41
|
+
if (env['MAGNUS_AGENT_CONTAINER'] === '1')
|
|
42
|
+
return 'container';
|
|
43
|
+
try {
|
|
44
|
+
if (env['MAGNUS_AGENT_LAUNCHER'] === '1' && lstatSync(join(dir, 'current')).isSymbolicLink())
|
|
45
|
+
return 'versioned';
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// no ./current
|
|
49
|
+
}
|
|
50
|
+
return 'legacy';
|
|
51
|
+
}
|
|
52
|
+
function selfUpgradeSwitch(env) {
|
|
53
|
+
return (env['MAGNUS_AGENT_SELF_UPGRADE'] ?? 'true').trim().toLowerCase() !== 'false';
|
|
54
|
+
}
|
|
55
|
+
/** Whether this agent can upgrade itself, and why not. Reported with every heartbeat. */
|
|
56
|
+
export function selfUpgradeStatus(version, env = process.env) {
|
|
57
|
+
const dir = installDir(env);
|
|
58
|
+
const layout = detectLayout(dir, env);
|
|
59
|
+
const launcherProtocol = Number.parseInt(env['MAGNUS_AGENT_LAUNCHER_PROTOCOL'] ?? '', 10);
|
|
60
|
+
const state = readUpgradeState(dir);
|
|
61
|
+
let reason = null;
|
|
62
|
+
if (!selfUpgradeSwitch(env))
|
|
63
|
+
reason = 'MAGNUS_AGENT_SELF_UPGRADE=false on this node';
|
|
64
|
+
else if (layout === 'container')
|
|
65
|
+
reason = 'container agent: upgrade by pulling a newer image';
|
|
66
|
+
else if (layout === 'legacy')
|
|
67
|
+
reason = 'not running under the upgrade launcher; run `sudo magnus-agent install-service` once on this node';
|
|
68
|
+
else {
|
|
69
|
+
try {
|
|
70
|
+
accessSync(dir, constants.W_OK);
|
|
71
|
+
accessSync(join(dir, 'versions'), constants.W_OK);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
reason = `the agent cannot write ${dir}`;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
protocol: AGENT_SELF_UPGRADE_PROTOCOL,
|
|
79
|
+
launcherProtocol: Number.isInteger(launcherProtocol) ? launcherProtocol : null,
|
|
80
|
+
layout,
|
|
81
|
+
enabled: reason === null,
|
|
82
|
+
reason,
|
|
83
|
+
upgrade: state ? reportOf(state) : { state: 'idle', from: null, to: version },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function reportOf(state) {
|
|
87
|
+
return {
|
|
88
|
+
state: state.state, from: state.from, to: state.to, requestId: state.requestId, reason: state.reason ?? null,
|
|
89
|
+
...(state.attempt !== undefined ? { attempt: state.attempt } : {}), ...(state.updatedAt ? { updatedAt: state.updatedAt } : {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Called after every accepted heartbeat: the first one from a version on trial
|
|
94
|
+
* confirms it, and versions other than the previous and the current one are pruned.
|
|
95
|
+
*/
|
|
96
|
+
export function confirmTrial(version, env = process.env, now = new Date()) {
|
|
97
|
+
const dir = installDir(env);
|
|
98
|
+
const state = readUpgradeState(dir);
|
|
99
|
+
if (!state || state.state !== 'trial' || state.to !== version)
|
|
100
|
+
return false;
|
|
101
|
+
writeUpgradeState(dir, { ...state, state: 'confirmed', reason: null, updatedAt: now.toISOString() });
|
|
102
|
+
pruneVersions(dir, [state.from, state.to]);
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
export function pruneVersions(dir, keep) {
|
|
106
|
+
const versions = join(dir, 'versions');
|
|
107
|
+
if (!existsSync(versions))
|
|
108
|
+
return;
|
|
109
|
+
for (const name of readdirSync(versions)) {
|
|
110
|
+
if (keep.includes(name))
|
|
111
|
+
continue;
|
|
112
|
+
rmSync(join(versions, name), { recursive: true, force: true });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
export class SelfUpgradeError extends Error {
|
|
116
|
+
code;
|
|
117
|
+
status;
|
|
118
|
+
constructor(code, message, status = 409) {
|
|
119
|
+
super(message);
|
|
120
|
+
this.code = code;
|
|
121
|
+
this.status = status;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
export const runProcess = (command, args, options) => new Promise((resolvePromise, reject) => {
|
|
125
|
+
const child = spawn(command, args, { cwd: options.cwd, env: options.env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
126
|
+
let stdout = '';
|
|
127
|
+
let stderr = '';
|
|
128
|
+
child.stdout.on('data', chunk => { stdout += String(chunk); });
|
|
129
|
+
child.stderr.on('data', chunk => { stderr += String(chunk); });
|
|
130
|
+
child.on('error', reject);
|
|
131
|
+
child.on('close', code => resolvePromise({ code: code ?? 1, stdout, stderr }));
|
|
132
|
+
});
|
|
133
|
+
/** npm pack names files `<scope>-<name>-<version>.tgz`. */
|
|
134
|
+
export function tarballName(version) {
|
|
135
|
+
return `hivemagnus-magnus-agent-${version}.tgz`;
|
|
136
|
+
}
|
|
137
|
+
export function sha512Integrity(file) {
|
|
138
|
+
return `sha512-${createHash('sha512').update(readFileSync(file)).digest('base64')}`;
|
|
139
|
+
}
|
|
140
|
+
const ALLOW_SCRIPTS = { 'cpu-features': true, ssh2: true };
|
|
141
|
+
const SOURCE_TARBALL = /^hivemagnus-(.+?)-(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\.tgz$/;
|
|
142
|
+
/** Other @hivemagnus tarballs in the source directory, used instead of the registry (offline nodes). */
|
|
143
|
+
export function sourceOverrides(sourceDir, agentTarball) {
|
|
144
|
+
const overrides = {};
|
|
145
|
+
for (const file of readdirSync(sourceDir)) {
|
|
146
|
+
const match = SOURCE_TARBALL.exec(file);
|
|
147
|
+
const path = join(sourceDir, file);
|
|
148
|
+
if (!match || path === agentTarball || match[1] === 'magnus-agent')
|
|
149
|
+
continue;
|
|
150
|
+
overrides[`@hivemagnus/${match[1]}`] = `file:${path}`;
|
|
151
|
+
}
|
|
152
|
+
return overrides;
|
|
153
|
+
}
|
|
154
|
+
let staging = false;
|
|
155
|
+
/** Download, verify, install and switch. The caller exits afterwards so the launcher starts the new version. */
|
|
156
|
+
export async function stageUpgrade(options) {
|
|
157
|
+
const env = options.env ?? process.env;
|
|
158
|
+
const run = options.run ?? runProcess;
|
|
159
|
+
const { request, version: from } = options;
|
|
160
|
+
const status = selfUpgradeStatus(from, env);
|
|
161
|
+
if (!status.enabled)
|
|
162
|
+
throw new SelfUpgradeError('SELF_UPGRADE_UNAVAILABLE', status.reason ?? 'self-upgrade is unavailable');
|
|
163
|
+
if (request.packageName !== AGENT_PACKAGE)
|
|
164
|
+
throw new SelfUpgradeError('SELF_UPGRADE_PACKAGE', `only ${AGENT_PACKAGE} can be installed`, 422);
|
|
165
|
+
if (compareVersions(request.version, from) === 0)
|
|
166
|
+
throw new SelfUpgradeError('SELF_UPGRADE_CURRENT', `already running ${from}`);
|
|
167
|
+
const decision = checkUpgradeAllowed(from, request.version);
|
|
168
|
+
if (!decision.allowed)
|
|
169
|
+
throw new SelfUpgradeError('SELF_UPGRADE_REFUSED', decision.reason ?? 'upgrade refused', 422);
|
|
170
|
+
const previous = status.upgrade?.state;
|
|
171
|
+
if (previous === 'staged' || previous === 'trial')
|
|
172
|
+
throw new SelfUpgradeError('SELF_UPGRADE_BUSY', `an upgrade to ${status.upgrade?.to} is still on trial`);
|
|
173
|
+
if (staging)
|
|
174
|
+
throw new SelfUpgradeError('SELF_UPGRADE_BUSY', 'an upgrade is already being staged');
|
|
175
|
+
staging = true;
|
|
176
|
+
const dir = installDir(env);
|
|
177
|
+
const work = join(dir, 'staging', request.version);
|
|
178
|
+
const target = join(dir, 'versions', request.version);
|
|
179
|
+
try {
|
|
180
|
+
rmSync(work, { recursive: true, force: true });
|
|
181
|
+
mkdirSync(work, { recursive: true, mode: 0o700 });
|
|
182
|
+
const npm = env['MAGNUS_AGENT_NPM'] || 'npm';
|
|
183
|
+
const npmEnv = { ...env, npm_config_cache: env['npm_config_cache'] ?? join(dir, '.npm'), npm_config_update_notifier: 'false' };
|
|
184
|
+
const fail = (step, result) => {
|
|
185
|
+
throw new SelfUpgradeError('SELF_UPGRADE_FAILED', `${step} failed (exit ${result.code}): ${(result.stderr || result.stdout).trim().split('\n').slice(-3).join(' ')}`, 500);
|
|
186
|
+
};
|
|
187
|
+
// 1. The exact tarball: from an operator-provided directory, or the registry.
|
|
188
|
+
const sourceDir = env['MAGNUS_AGENT_UPGRADE_SOURCE_DIR']?.trim();
|
|
189
|
+
let tarball;
|
|
190
|
+
if (sourceDir) {
|
|
191
|
+
tarball = join(resolve(sourceDir), tarballName(request.version));
|
|
192
|
+
if (!existsSync(tarball))
|
|
193
|
+
throw new SelfUpgradeError('SELF_UPGRADE_SOURCE', `${tarball} does not exist`, 422);
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
const packed = await run(npm, ['pack', `${AGENT_PACKAGE}@${request.version}`, '--pack-destination', work, '--json', '--loglevel=error'], { cwd: work, env: npmEnv });
|
|
197
|
+
if (packed.code !== 0)
|
|
198
|
+
fail('npm pack', packed);
|
|
199
|
+
tarball = join(work, tarballName(request.version));
|
|
200
|
+
}
|
|
201
|
+
// 2. The bytes must match what the server pinned (npm also checks the registry's own digest on download).
|
|
202
|
+
const integrity = sha512Integrity(tarball);
|
|
203
|
+
if (integrity !== request.integrity) {
|
|
204
|
+
throw new SelfUpgradeError('SELF_UPGRADE_INTEGRITY', `tarball integrity ${integrity.slice(0, 20)}… differs from the requested ${request.integrity.slice(0, 20)}…`, 422);
|
|
205
|
+
}
|
|
206
|
+
// 3. Install beside the running version.
|
|
207
|
+
const install = join(work, 'install');
|
|
208
|
+
mkdirSync(install, { recursive: true });
|
|
209
|
+
const overrides = sourceDir ? sourceOverrides(resolve(sourceDir), tarball) : {};
|
|
210
|
+
writeFileSync(join(install, 'package.json'), `${JSON.stringify({
|
|
211
|
+
name: `magnus-agent-${request.version}`, version: request.version, private: true, type: 'module',
|
|
212
|
+
dependencies: { [AGENT_PACKAGE]: `file:${tarball}` }, ...(Object.keys(overrides).length ? { overrides } : {}), allowScripts: ALLOW_SCRIPTS,
|
|
213
|
+
}, null, 2)}\n`);
|
|
214
|
+
const installed = await run(npm, ['install', '--omit=dev', '--no-audit', '--no-fund', '--loglevel=error'], { cwd: install, env: npmEnv });
|
|
215
|
+
if (installed.code !== 0)
|
|
216
|
+
fail('npm install', installed);
|
|
217
|
+
// 4. Registry signatures and provenance of everything installed.
|
|
218
|
+
if ((env['MAGNUS_AGENT_UPGRADE_REQUIRE_PROVENANCE'] ?? 'true').trim().toLowerCase() !== 'false') {
|
|
219
|
+
const audit = await run(npm, ['audit', 'signatures', '--omit=dev', '--loglevel=error'], { cwd: install, env: npmEnv });
|
|
220
|
+
if (audit.code !== 0)
|
|
221
|
+
fail('npm audit signatures', audit);
|
|
222
|
+
}
|
|
223
|
+
// 5. The new code must at least start and report the requested version.
|
|
224
|
+
const entry = join(install, 'node_modules', '@hivemagnus', 'magnus-agent', 'dist', 'index.js');
|
|
225
|
+
const smoke = await run(process.execPath, [entry, '--version'], { cwd: install, env });
|
|
226
|
+
if (smoke.code !== 0 || smoke.stdout.trim() !== request.version) {
|
|
227
|
+
throw new SelfUpgradeError('SELF_UPGRADE_FAILED', `the installed agent reports "${smoke.stdout.trim()}", not ${request.version}`, 500);
|
|
228
|
+
}
|
|
229
|
+
rmSync(target, { recursive: true, force: true });
|
|
230
|
+
mkdirSync(join(dir, 'versions'), { recursive: true });
|
|
231
|
+
renameSync(install, target);
|
|
232
|
+
const now = (options.now ?? new Date()).toISOString();
|
|
233
|
+
writeUpgradeState(dir, { state: 'staged', from, to: request.version, requestId: request.requestId, attempt: 0, maxAttempts: 2, reason: null, updatedAt: now });
|
|
234
|
+
pointCurrentAt(dir, request.version);
|
|
235
|
+
return { from, to: request.version, integrity, requestId: request.requestId };
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
rmSync(work, { recursive: true, force: true });
|
|
239
|
+
staging = false;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
//# sourceMappingURL=self-upgrade.js.map
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* magnus-agent launcher: the stable process systemd starts. It runs the agent
|
|
4
|
+
* from ./current and guards self-upgrades:
|
|
5
|
+
*
|
|
6
|
+
* staged the agent installed versions/<to>, pointed ./current at it and exited
|
|
7
|
+
* trial the new version runs; it must report one accepted heartbeat
|
|
8
|
+
* (state "confirmed") before the deadline
|
|
9
|
+
* confirmed done
|
|
10
|
+
* reverted the trial failed (crash loop or no heartbeat in time); ./current
|
|
11
|
+
* points back at versions/<from>
|
|
12
|
+
*
|
|
13
|
+
* The launcher has no dependencies and never changes during an upgrade; only
|
|
14
|
+
* `magnus-agent install-service` replaces it. It never escalates privileges.
|
|
15
|
+
*/
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
import { existsSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync, openSync, fsyncSync, closeSync } from 'node:fs';
|
|
18
|
+
import { dirname, join, resolve } from 'node:path';
|
|
19
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
20
|
+
|
|
21
|
+
export const LAUNCHER_PROTOCOL = 1;
|
|
22
|
+
export const STATE_FILE = 'upgrade-state.json';
|
|
23
|
+
const POLL_MS = 2_000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Pure decision for one launch.
|
|
27
|
+
* @param {null | Record<string, any>} state
|
|
28
|
+
* @param {number} now epoch ms
|
|
29
|
+
* @param {number} confirmMs trial window
|
|
30
|
+
* @returns {{ action: 'run' | 'revert', next: Record<string, any> | null, trial: boolean, reason?: string }}
|
|
31
|
+
*/
|
|
32
|
+
export function decideLaunch(state, now, confirmMs) {
|
|
33
|
+
if (!state || typeof state !== 'object') return { action: 'run', next: null, trial: false };
|
|
34
|
+
const maxAttempts = Number.isInteger(state.maxAttempts) && state.maxAttempts > 0 ? state.maxAttempts : 2;
|
|
35
|
+
const stamp = new Date(now).toISOString();
|
|
36
|
+
if (state.state === 'staged') {
|
|
37
|
+
return { action: 'run', trial: true,
|
|
38
|
+
next: { ...state, state: 'trial', attempt: 1, maxAttempts, trialDeadline: new Date(now + confirmMs).toISOString(), updatedAt: stamp } };
|
|
39
|
+
}
|
|
40
|
+
if (state.state === 'trial') {
|
|
41
|
+
const deadline = Date.parse(state.trialDeadline ?? '');
|
|
42
|
+
const attempt = Number.isInteger(state.attempt) ? state.attempt : maxAttempts;
|
|
43
|
+
if (!Number.isFinite(deadline) || now >= deadline) {
|
|
44
|
+
return { action: 'revert', trial: false, reason: `no confirmed heartbeat from ${state.to} within ${Math.round(confirmMs / 1000)} s`,
|
|
45
|
+
next: { ...state, state: 'reverted', updatedAt: stamp } };
|
|
46
|
+
}
|
|
47
|
+
if (attempt >= maxAttempts) {
|
|
48
|
+
return { action: 'revert', trial: false, reason: `${state.to} exited ${attempt} time(s) before reporting a heartbeat`,
|
|
49
|
+
next: { ...state, state: 'reverted', updatedAt: stamp } };
|
|
50
|
+
}
|
|
51
|
+
return { action: 'run', trial: true, next: { ...state, attempt: attempt + 1, updatedAt: stamp } };
|
|
52
|
+
}
|
|
53
|
+
return { action: 'run', next: null, trial: false };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function readState(dir) {
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(readFileSync(join(dir, STATE_FILE), 'utf8'));
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function writeState(dir, state) {
|
|
65
|
+
const path = join(dir, STATE_FILE);
|
|
66
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
67
|
+
writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
68
|
+
const fd = openSync(temporary, 'r');
|
|
69
|
+
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
70
|
+
renameSync(temporary, path);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function pointCurrentAt(dir, version) {
|
|
74
|
+
const temporary = join(dir, `current.${process.pid}.tmp`);
|
|
75
|
+
rmSync(temporary, { force: true });
|
|
76
|
+
symlinkSync(join('versions', version), temporary, 'dir');
|
|
77
|
+
renameSync(temporary, join(dir, 'current'));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function revert(dir, decision) {
|
|
81
|
+
const state = decision.next;
|
|
82
|
+
if (state.from && existsSync(join(dir, 'versions', state.from))) pointCurrentAt(dir, state.from);
|
|
83
|
+
else state.reason = `${decision.reason}; versions/${state.from} is missing, so ./current was left as it is`;
|
|
84
|
+
writeState(dir, { ...state, reason: state.reason ?? decision.reason });
|
|
85
|
+
console.error(`[launcher] reverted ${state.to} -> ${state.from}: ${decision.reason}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function confirmSeconds(env) {
|
|
89
|
+
const value = Number.parseInt(env.MAGNUS_AGENT_UPGRADE_CONFIRM_SECONDS ?? '', 10);
|
|
90
|
+
return Number.isInteger(value) && value >= 10 ? Math.min(value, 3600) : 180;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function main() {
|
|
94
|
+
const dir = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
95
|
+
const confirmMs = confirmSeconds(process.env) * 1000;
|
|
96
|
+
const decision = decideLaunch(readState(dir), Date.now(), confirmMs);
|
|
97
|
+
if (decision.action === 'revert') revert(dir, decision);
|
|
98
|
+
else if (decision.next) writeState(dir, decision.next);
|
|
99
|
+
|
|
100
|
+
const entry = join(dir, 'current', 'node_modules', '@hivemagnus', 'magnus-agent', 'dist', 'index.js');
|
|
101
|
+
if (!existsSync(entry)) {
|
|
102
|
+
console.error(`[launcher] ${entry} is missing; reinstall with magnus-agent install-service`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
const child = spawn(process.execPath, [entry, 'start'], {
|
|
106
|
+
cwd: dir,
|
|
107
|
+
stdio: 'inherit',
|
|
108
|
+
env: {
|
|
109
|
+
...process.env,
|
|
110
|
+
AGENT_INSTALL_DIR: process.env.AGENT_INSTALL_DIR ?? dir,
|
|
111
|
+
MAGNUS_AGENT_LAUNCHER: '1',
|
|
112
|
+
MAGNUS_AGENT_LAUNCHER_PROTOCOL: String(LAUNCHER_PROTOCOL),
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, () => child.kill(signal));
|
|
116
|
+
|
|
117
|
+
let failedTrial = false;
|
|
118
|
+
let watcher;
|
|
119
|
+
if (decision.trial) {
|
|
120
|
+
watcher = setInterval(() => {
|
|
121
|
+
const state = readState(dir);
|
|
122
|
+
if (!state || state.state !== 'trial') { clearInterval(watcher); return; }
|
|
123
|
+
const verdict = decideLaunch({ ...state, attempt: 0 }, Date.now(), confirmMs);
|
|
124
|
+
if (verdict.action === 'revert') {
|
|
125
|
+
clearInterval(watcher);
|
|
126
|
+
failedTrial = true;
|
|
127
|
+
revert(dir, verdict);
|
|
128
|
+
child.kill('SIGTERM');
|
|
129
|
+
setTimeout(() => child.kill('SIGKILL'), 10_000).unref();
|
|
130
|
+
}
|
|
131
|
+
}, POLL_MS);
|
|
132
|
+
}
|
|
133
|
+
child.on('exit', (code, signal) => {
|
|
134
|
+
if (watcher) clearInterval(watcher);
|
|
135
|
+
// Any exit restarts through systemd (Restart=always); a failed trial exits non-zero.
|
|
136
|
+
process.exit(failedTrial ? 1 : code ?? (signal ? 1 : 0));
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) main();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hivemagnus/magnus-agent",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Magnus node agent: a small daemon that enrolls a Linux host with a magnus-server and runs authenticated health, command, cron, file and skill management, with optional Hermes Agent, Codex and Ardent management.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"magnus",
|
|
@@ -36,7 +36,8 @@
|
|
|
36
36
|
"LICENSE",
|
|
37
37
|
".env.example",
|
|
38
38
|
"deploy/*.service",
|
|
39
|
-
"deploy/*.timer"
|
|
39
|
+
"deploy/*.timer",
|
|
40
|
+
"launcher/launcher.mjs"
|
|
40
41
|
],
|
|
41
42
|
"engines": {
|
|
42
43
|
"node": "^24.22.1 || ^26.13.0"
|
|
@@ -54,6 +55,7 @@
|
|
|
54
55
|
"clean": "node -e \"const fs=require('node:fs');fs.rmSync('dist',{recursive:true,force:true});fs.rmSync('tsconfig.tsbuildinfo',{force:true})\""
|
|
55
56
|
},
|
|
56
57
|
"dependencies": {
|
|
58
|
+
"@hivemagnus/magnus-contracts": "1.1.0",
|
|
57
59
|
"@hono/node-server": "^2.1.5",
|
|
58
60
|
"dotenv": "^18.0.9",
|
|
59
61
|
"hono": "^4.13.14",
|