@hivemagnus/magnus-agent 1.1.2 → 1.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +3 -0
- package/README.md +47 -2
- package/deploy/magnus-agent-unprivileged.service +5 -0
- package/deploy/magnus-agent.service +5 -0
- package/dist/agent.d.ts +2 -0
- package/dist/agent.js +18 -3
- package/dist/env-catalog.js +2 -1
- package/dist/executors/historical-mutation.d.ts +5 -6
- package/dist/executors/historical-mutation.js +15 -2
- package/dist/executors/reviewed-historical-records.d.ts +22 -0
- package/dist/executors/reviewed-historical-records.js +117 -0
- package/dist/executors/scheduler-runner.js +12 -1
- package/dist/upgrade/install-service.js +15 -5
- package/dist/upgrade/routes.js +6 -1
- package/dist/upgrade/self-upgrade.d.ts +85 -10
- package/dist/upgrade/self-upgrade.js +459 -57
- package/launcher/launcher.mjs +383 -50
- package/package.json +5 -2
package/.env.example
CHANGED
|
@@ -54,6 +54,8 @@ MAGNUS_AGENT_PRIVILEGED=true
|
|
|
54
54
|
# Independent key for explicit command-receipt reconciliation. Leave unset unless
|
|
55
55
|
# your control plane uses it. Linux /usr/bin/flock is required for commands.
|
|
56
56
|
# COMMAND_RECONCILIATION_SECRET=
|
|
57
|
+
# Root-owned JSON list of operator-reviewed historical mutation records (read at startup).
|
|
58
|
+
# MAGNUS_AGENT_REVIEWED_HISTORICAL_RECORDS=/etc/magnus/reviewed-historical-records.json
|
|
57
59
|
# Rotation windows: previous values accepted alongside the current ones.
|
|
58
60
|
# NODE_MANAGER_SECRET_PREVIOUS=
|
|
59
61
|
# RESULT_SIGNING_PREVIOUS_SECRET=
|
|
@@ -126,4 +128,5 @@ LOG_LEVEL=info
|
|
|
126
128
|
# MAGNUS_AGENT_UPGRADE_REQUIRE_PROVENANCE=true
|
|
127
129
|
# Offline nodes: install from hivemagnus-magnus-agent-<X>.tgz files in this directory.
|
|
128
130
|
# MAGNUS_AGENT_UPGRADE_SOURCE_DIR=
|
|
131
|
+
# npm for self-upgrades (default: the npm that ships with the running Node.js, then npm on PATH).
|
|
129
132
|
# MAGNUS_AGENT_NPM=npm
|
package/README.md
CHANGED
|
@@ -138,11 +138,32 @@ After that, the server can upgrade the agent from the console's **Upgrade agents
|
|
|
138
138
|
|
|
139
139
|
1. The agent downloads the exact version the server names and checks its npm `sha512` integrity. It
|
|
140
140
|
also runs `npm audit signatures` (`MAGNUS_AGENT_UPGRADE_REQUIRE_PROVENANCE`).
|
|
141
|
-
2. It installs that version beside the running one
|
|
141
|
+
2. It installs that version beside the running one and checks that it runs on this Node.js
|
|
142
|
+
(`engines`), works with the installed launcher, and reports the requested version. Then it points
|
|
143
|
+
`./current` at it and exits.
|
|
142
144
|
3. The launcher starts the new version on trial.
|
|
143
145
|
4. If the new version does not report an accepted heartbeat within
|
|
144
146
|
`MAGNUS_AGENT_UPGRADE_CONFIRM_SECONDS` (default 180), or crashes twice, the launcher switches back
|
|
145
|
-
to the previous version.
|
|
147
|
+
to the previous version. A new version that runs but cannot reach the control API gets up to three
|
|
148
|
+
windows, and the revert reason then says so.
|
|
149
|
+
|
|
150
|
+
Every step has a time limit, and the whole upgrade stops after 13 minutes (`SELF_UPGRADE_TIMEOUT`).
|
|
151
|
+
npm, its lifecycle scripts and the smoke test run without the agent's credentials. The agent uses
|
|
152
|
+
`MAGNUS_AGENT_NPM` when set, otherwise the npm that ships with the Node.js it runs on, then npm on
|
|
153
|
+
`PATH`. A retried
|
|
154
|
+
request with the same `requestId` returns the current state instead of an error.
|
|
155
|
+
|
|
156
|
+
The state file (`upgrade-state.json`) and the links are written durably (fsync and rename), and the
|
|
157
|
+
agent and the launcher lock the state file while they change it. `./last-good` points at the last
|
|
158
|
+
confirmed version. The launcher switches to it when `versions/<from>` is missing, and when the state
|
|
159
|
+
file is unreadable and `./current` keeps exiting. When a write fails (full or read-only disk), the
|
|
160
|
+
launcher logs it and still starts an agent instead of crash-looping.
|
|
161
|
+
|
|
162
|
+
A confirmed version installs its own `launcher/launcher.mjs` when that declares a higher
|
|
163
|
+
`LAUNCHER_PROTOCOL` and passes a self-check; the old file is kept as `launcher.mjs.previous`. In the
|
|
164
|
+
`--unprivileged` mode, `launcher/` stays owned by root, so the launcher is updated only by
|
|
165
|
+
`install-service`. A release that needs a newer launcher than the node runs is refused with
|
|
166
|
+
`SELF_UPGRADE_LAUNCHER`.
|
|
146
167
|
|
|
147
168
|
The agent writes only inside its install directory and never uses sudo. Set
|
|
148
169
|
`MAGNUS_AGENT_SELF_UPGRADE=false` to refuse upgrade requests on a node. Offline nodes can install from
|
|
@@ -195,6 +216,30 @@ The entries can only touch what the comma-separated allowlists name:
|
|
|
195
216
|
`_TOKEN_FILES` and `_SIGNING_ENV_FILES`. Every list is empty by default, so
|
|
196
217
|
renewal does nothing until you configure it.
|
|
197
218
|
|
|
219
|
+
### Reviewed historical mutation records
|
|
220
|
+
|
|
221
|
+
A node with retained mutation records whose outcome is unknown can settle them only through a signed
|
|
222
|
+
historical quiescence receipt. The receipt counts only for records an operator has reviewed, listed
|
|
223
|
+
in a root-controlled JSON file (`MAGNUS_AGENT_REVIEWED_HISTORICAL_RECORDS`, default
|
|
224
|
+
`/etc/magnus/reviewed-historical-records.json`):
|
|
225
|
+
|
|
226
|
+
```json
|
|
227
|
+
{
|
|
228
|
+
"version": 1,
|
|
229
|
+
"records": {
|
|
230
|
+
"0b6f1c2e-6d3a-4f6e-9c1d-2a7b8e9f0a1b": { "nodeFqdn": "node-1.example.com", "recordSha256": "<64 lowercase hex>" }
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
The keys are the mutation record ids. `recordSha256` is the SHA-256 of the retained record file, as
|
|
236
|
+
in the signed observation. Entries for other nodes (`nodeFqdn` other than `HOST_FQDN`) are ignored, so
|
|
237
|
+
one file can serve a fleet. The file and its directory must be owned by root and not group- or
|
|
238
|
+
world-writable, and the file must not be a symlink or larger than 1 MiB. `0644` works for an
|
|
239
|
+
unprivileged agent too. The agent reads the file once at startup, so restart it after a change. A
|
|
240
|
+
missing file means no reviewed records. A file that fails these checks or the schema is refused as a
|
|
241
|
+
whole, and the agent logs the reason at startup.
|
|
242
|
+
|
|
198
243
|
### Hermes registry sync
|
|
199
244
|
|
|
200
245
|
`deploy/magnus-hermes-sync.service` and its timer run `dist/hermes-sync.js`
|
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
#
|
|
14
14
|
# Use a NODE_MANAGER_PORT above 1023, and make the TLS key readable by
|
|
15
15
|
# magnus-agent. Install this file as /etc/systemd/system/magnus-agent.service.
|
|
16
|
+
#
|
|
17
|
+
# These example units start the agent directly and cannot self-upgrade. For
|
|
18
|
+
# server-driven upgrades, run `sudo magnus-agent install-service` instead: it
|
|
19
|
+
# moves the install into versions/<X>, installs the upgrade launcher and writes
|
|
20
|
+
# a unit that starts it (the cron path below node_modules keeps working).
|
|
16
21
|
[Unit]
|
|
17
22
|
Description=Magnus Agent (unprivileged)
|
|
18
23
|
After=network-online.target
|
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
# policy and the agent's own allowlists. This is privileged mode
|
|
11
11
|
# (MAGNUS_AGENT_PRIVILEGED=true, the default); to run without root, use
|
|
12
12
|
# magnus-agent-unprivileged.service instead.
|
|
13
|
+
#
|
|
14
|
+
# These example units start the agent directly and cannot self-upgrade. For
|
|
15
|
+
# server-driven upgrades, run `sudo magnus-agent install-service` instead: it
|
|
16
|
+
# moves the install into versions/<X>, installs the upgrade launcher and writes
|
|
17
|
+
# a unit that starts it (the cron path below node_modules keeps working).
|
|
13
18
|
[Unit]
|
|
14
19
|
Description=Magnus Agent
|
|
15
20
|
After=network-online.target
|
package/dist/agent.d.ts
CHANGED
package/dist/agent.js
CHANGED
|
@@ -7,9 +7,12 @@ 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
|
+
import { confirmTrial, controlApiUnreachable, recordTrialAlive, selfUpgradeStatus, trialOf } from './upgrade/self-upgrade.js';
|
|
11
|
+
import { commandLedger } from './executors/command-executor.js';
|
|
11
12
|
const HEARTBEAT_INTERVAL_MS = 30_000; // 30s
|
|
12
13
|
const HEARTBEAT_BACKOFF_MAX_MS = 5 * 60_000; // 5 min max backoff
|
|
14
|
+
/** On an upgrade trial: retry fast, without backoff, so a short control API outage does not end the trial. */
|
|
15
|
+
export const TRIAL_RETRY_MS = 5_000;
|
|
13
16
|
const HERMES_DISCOVERY_INTERVAL_MS = Number(process.env['HERMES_DISCOVERY_INTERVAL_MS'] ?? 3 * 60 * 60 * 1000);
|
|
14
17
|
let heartbeatTimer = null;
|
|
15
18
|
let heartbeatRunning = false;
|
|
@@ -72,6 +75,11 @@ async function collectHealthWithDeadline() {
|
|
|
72
75
|
clearTimeout(timer);
|
|
73
76
|
}
|
|
74
77
|
}
|
|
78
|
+
/** Self-upgrade status for the heartbeat, with the sticky local enrollment marker. */
|
|
79
|
+
async function heartbeatSelfUpgrade() {
|
|
80
|
+
const enrollment = await commandLedger.managedEnrollment().catch(() => null);
|
|
81
|
+
return { ...selfUpgradeStatus(AGENT_VERSION), managedEnrollment: enrollment ? { nodeId: enrollment.nodeId, nodeFqdn: enrollment.nodeFqdn } : null };
|
|
82
|
+
}
|
|
75
83
|
/** Stop the heartbeat loop. */
|
|
76
84
|
export function stopHeartbeatLoop() {
|
|
77
85
|
heartbeatRunning = false;
|
|
@@ -89,6 +97,7 @@ export function startHeartbeatLoop() {
|
|
|
89
97
|
if (!heartbeatRunning)
|
|
90
98
|
return;
|
|
91
99
|
const span = startSpan('heartbeat.send', { kind: 'client' });
|
|
100
|
+
let posting = false;
|
|
92
101
|
try {
|
|
93
102
|
const health = await collectHealthWithDeadline();
|
|
94
103
|
// A cached Hermes snapshot is sent while a stale one refreshes in the
|
|
@@ -97,6 +106,8 @@ export function startHeartbeatLoop() {
|
|
|
97
106
|
if (!hermesSnapshot)
|
|
98
107
|
await Promise.race([refresh, delay(HERMES_HEARTBEAT_WAIT_MS)]);
|
|
99
108
|
const hermes = hermesSnapshot;
|
|
109
|
+
const selfUpgrade = await heartbeatSelfUpgrade();
|
|
110
|
+
posting = true;
|
|
100
111
|
await ky.post(`${CONTROL_API_URL}/api/v1/heartbeat`, {
|
|
101
112
|
json: {
|
|
102
113
|
agentVersion: AGENT_VERSION,
|
|
@@ -114,7 +125,7 @@ export function startHeartbeatLoop() {
|
|
|
114
125
|
},
|
|
115
126
|
...(hermes ? { hermes } : {}),
|
|
116
127
|
nodeVersion: process.version,
|
|
117
|
-
selfUpgrade
|
|
128
|
+
selfUpgrade,
|
|
118
129
|
},
|
|
119
130
|
headers: {
|
|
120
131
|
'x-node-token': NODE_TOKEN,
|
|
@@ -150,8 +161,12 @@ export function startHeartbeatLoop() {
|
|
|
150
161
|
if (consecutiveFailures === 1 || Math.log2(consecutiveFailures) % 1 === 0) {
|
|
151
162
|
logger.warn(`Heartbeat failed to send`, { attempt: consecutiveFailures, error: message });
|
|
152
163
|
}
|
|
164
|
+
// On trial: tell the launcher this version is alive, and retry at a fixed short interval.
|
|
165
|
+
const onTrial = trialOf(AGENT_VERSION) !== null;
|
|
166
|
+
if (onTrial && posting && controlApiUnreachable(err))
|
|
167
|
+
recordTrialAlive(AGENT_VERSION, message);
|
|
153
168
|
// Back off retry interval: 30s → 60s → 120s … capped at 5min
|
|
154
|
-
const backoffMs = Math.min(HEARTBEAT_INTERVAL_MS * Math.pow(2, consecutiveFailures - 1), HEARTBEAT_BACKOFF_MAX_MS);
|
|
169
|
+
const backoffMs = onTrial ? TRIAL_RETRY_MS : Math.min(HEARTBEAT_INTERVAL_MS * Math.pow(2, consecutiveFailures - 1), HEARTBEAT_BACKOFF_MAX_MS);
|
|
155
170
|
heartbeatTimer = setTimeout(() => void send(), backoffMs);
|
|
156
171
|
return;
|
|
157
172
|
}
|
package/dist/env-catalog.js
CHANGED
|
@@ -7,6 +7,7 @@ export const agentEnvCatalog = [
|
|
|
7
7
|
{ name: 'NODE_MANAGER_SECRET_PREVIOUS', required: false, secret: true, group: 'core', description: 'Previous node credential, accepted only during a rotation window.' },
|
|
8
8
|
{ name: 'RESULT_SIGNING_PREVIOUS_SECRET', required: false, secret: true, group: 'core', description: 'Previous result-signing key, accepted only during a rotation window.' },
|
|
9
9
|
{ name: 'COMMAND_RECONCILIATION_SECRET', required: false, secret: true, generate: 'hex32', group: 'core', description: 'Independent key that authorizes explicit command-receipt reconciliation. Leave unset unless your control plane uses it.' },
|
|
10
|
+
{ name: 'MAGNUS_AGENT_REVIEWED_HISTORICAL_RECORDS', required: false, secret: false, default: '/etc/magnus/reviewed-historical-records.json', group: 'core', description: 'Root-owned JSON file of operator-reviewed historical mutation records ({"version":1,"records":{"<record id>":{"nodeFqdn","recordSha256"}}}). Read at startup; a missing file means none, a file that is not root-controlled or fails the schema is refused.' },
|
|
10
11
|
{ name: 'HOST_FQDN', required: false, secret: false, default: 'hostname --fqdn', group: 'core', description: 'Host identity reported to the Control API.' },
|
|
11
12
|
{ name: 'NODE_MANAGER_PORT', required: false, secret: false, default: String(DEFAULT_PORT), group: 'core', description: 'Listener port.' },
|
|
12
13
|
{ 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.' },
|
|
@@ -16,7 +17,7 @@ export const agentEnvCatalog = [
|
|
|
16
17
|
{ 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
18
|
{ 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
19
|
{ 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,
|
|
20
|
+
{ name: 'MAGNUS_AGENT_NPM', required: false, secret: false, group: 'core', description: 'npm executable used for self-upgrades. Default: the npm that ships with the running Node.js, then npm on PATH.' },
|
|
20
21
|
{ name: 'MAGNUS_EXECUTION_SUPERVISOR_ENABLED', required: false, secret: false, default: 'false', group: 'core', description: 'Run commands under the durable execution supervisor.' },
|
|
21
22
|
{ 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.' },
|
|
22
23
|
{ 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.' },
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { type ReviewedHistoricalRecords } from './reviewed-historical-records.js';
|
|
2
3
|
export declare const historicalDrainSchema: z.ZodObject<{
|
|
3
4
|
nodeId: z.ZodString;
|
|
4
5
|
nodeFqdn: z.ZodString;
|
|
@@ -234,12 +235,11 @@ export interface HistoricalKeys {
|
|
|
234
235
|
result: string;
|
|
235
236
|
reconciliation: string;
|
|
236
237
|
}
|
|
237
|
-
export type ReviewedHistoricalRecords
|
|
238
|
-
nodeFqdn: string;
|
|
239
|
-
recordSha256: string;
|
|
240
|
-
}>>;
|
|
238
|
+
export type { ReviewedHistoricalRecords };
|
|
241
239
|
/** Records an operator has independently inspected and approved for historical
|
|
242
|
-
* settlement
|
|
240
|
+
* settlement, from the root-controlled MAGNUS_AGENT_REVIEWED_HISTORICAL_RECORDS
|
|
241
|
+
* file, read once at startup. Empty when the file is absent or refused. */
|
|
242
|
+
export declare const reviewedHistoricalRecordsLoad: import("./reviewed-historical-records.js").ReviewedRecordsLoad;
|
|
243
243
|
export declare const reviewedHistoricalRecords: ReviewedHistoricalRecords;
|
|
244
244
|
export declare const historicalHash: (value: unknown) => string;
|
|
245
245
|
export declare function validateHistoricalKeys(keys: HistoricalKeys): void;
|
|
@@ -250,5 +250,4 @@ export declare function verifyHistoricalObservation(raw: unknown, key: string):
|
|
|
250
250
|
export declare function verifyHistoricalAssertion(raw: unknown, keys: HistoricalKeys): HistoricalAssertion;
|
|
251
251
|
export declare function verifyHistoricalReceipt(raw: unknown, keys: HistoricalKeys): HistoricalReceipt;
|
|
252
252
|
export declare function readHostBoot(): Promise<HostBoot>;
|
|
253
|
-
export {};
|
|
254
253
|
//# sourceMappingURL=historical-mutation.d.ts.map
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
+
import { HOST_FQDN } from '../env.js';
|
|
6
|
+
import { logger } from '../utils/logger.js';
|
|
7
|
+
import { loadReviewedHistoricalRecords } from './reviewed-historical-records.js';
|
|
5
8
|
const sha = z.string().regex(/^[a-f0-9]{64}$/);
|
|
6
9
|
export const historicalDrainSchema = z.object({ nodeId: z.string().min(1).max(128),
|
|
7
10
|
nodeFqdn: z.string().min(1).max(253), epoch: z.number().int().positive(), barrierId: z.string().guid() }).strict();
|
|
@@ -22,8 +25,18 @@ const receiptUnsigned = z.object({ version: z.literal(1), assertion: historicalA
|
|
|
22
25
|
originalOutcome: z.literal('unknown') }).strict();
|
|
23
26
|
export const historicalReceiptSchema = receiptUnsigned.extend({ signature: sha }).strict();
|
|
24
27
|
/** Records an operator has independently inspected and approved for historical
|
|
25
|
-
* settlement
|
|
26
|
-
|
|
28
|
+
* settlement, from the root-controlled MAGNUS_AGENT_REVIEWED_HISTORICAL_RECORDS
|
|
29
|
+
* file, read once at startup. Empty when the file is absent or refused. */
|
|
30
|
+
export const reviewedHistoricalRecordsLoad = loadReviewedHistoricalRecords({
|
|
31
|
+
path: process.env['MAGNUS_AGENT_REVIEWED_HISTORICAL_RECORDS']?.trim() || undefined, hostname: HOST_FQDN
|
|
32
|
+
});
|
|
33
|
+
if (reviewedHistoricalRecordsLoad.problem) {
|
|
34
|
+
logger.warn(`Reviewed historical records refused; historical settlements are not honoured: ${reviewedHistoricalRecordsLoad.problem}`);
|
|
35
|
+
}
|
|
36
|
+
else if (reviewedHistoricalRecordsLoad.count > 0) {
|
|
37
|
+
logger.info(`Loaded ${reviewedHistoricalRecordsLoad.count} reviewed historical record(s) for ${HOST_FQDN} from ${reviewedHistoricalRecordsLoad.path}`);
|
|
38
|
+
}
|
|
39
|
+
export const reviewedHistoricalRecords = reviewedHistoricalRecordsLoad.records;
|
|
27
40
|
export const historicalHash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
28
41
|
export function validateHistoricalKeys(keys) {
|
|
29
42
|
if (keys.result.length < 32 || keys.reconciliation.length < 32 || keys.result === keys.reconciliation)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export declare const DEFAULT_REVIEWED_HISTORICAL_RECORDS_PATH = "/etc/magnus/reviewed-historical-records.json";
|
|
2
|
+
export declare const REVIEWED_RECORDS_MAX_BYTES: number;
|
|
3
|
+
export type ReviewedHistoricalRecords = Readonly<Record<string, Readonly<{
|
|
4
|
+
nodeFqdn: string;
|
|
5
|
+
recordSha256: string;
|
|
6
|
+
}>>>;
|
|
7
|
+
export interface ReviewedRecordsLoad {
|
|
8
|
+
path: string;
|
|
9
|
+
records: ReviewedHistoricalRecords;
|
|
10
|
+
/** Entries for this node that were loaded. */
|
|
11
|
+
count: number;
|
|
12
|
+
/** Why the file was refused, or null (also when it is absent). */
|
|
13
|
+
problem: string | null;
|
|
14
|
+
}
|
|
15
|
+
export interface LoadOptions {
|
|
16
|
+
path?: string;
|
|
17
|
+
hostname: string;
|
|
18
|
+
/** Owner the file and its directory must have; 0 (root) except in tests. */
|
|
19
|
+
ownerUid?: number;
|
|
20
|
+
}
|
|
21
|
+
export declare function loadReviewedHistoricalRecords(options: LoadOptions): ReviewedRecordsLoad;
|
|
22
|
+
//# sourceMappingURL=reviewed-historical-records.d.ts.map
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator-reviewed historical mutation records, from a root-controlled file
|
|
3
|
+
* (MAGNUS_AGENT_REVIEWED_HISTORICAL_RECORDS, default
|
|
4
|
+
* /etc/magnus/reviewed-historical-records.json). Only records listed there can
|
|
5
|
+
* be settled by, or count as settled through, a signed historical receipt.
|
|
6
|
+
*
|
|
7
|
+
* Read once when the agent starts, so the set cannot change under a running
|
|
8
|
+
* drain; edit the file and restart the agent to change it. A missing file is
|
|
9
|
+
* the empty set. A file that root does not control (not owned by uid 0, group-
|
|
10
|
+
* or world-writable, a symlink, in such a directory) or that fails the schema
|
|
11
|
+
* is refused as a whole: the set is empty and the reason is logged.
|
|
12
|
+
*
|
|
13
|
+
* {
|
|
14
|
+
* "version": 1,
|
|
15
|
+
* "records": {
|
|
16
|
+
* "<record id (UUID)>": { "nodeFqdn": "node-1.example.com", "recordSha256": "<64 hex>" }
|
|
17
|
+
* }
|
|
18
|
+
* }
|
|
19
|
+
*/
|
|
20
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from 'node:fs';
|
|
21
|
+
import { dirname, resolve } from 'node:path';
|
|
22
|
+
import { z } from 'zod';
|
|
23
|
+
export const DEFAULT_REVIEWED_HISTORICAL_RECORDS_PATH = '/etc/magnus/reviewed-historical-records.json';
|
|
24
|
+
export const REVIEWED_RECORDS_MAX_BYTES = 1024 * 1024;
|
|
25
|
+
const reviewedFileSchema = z.object({
|
|
26
|
+
version: z.literal(1),
|
|
27
|
+
records: z.record(z.string().guid(), z.object({
|
|
28
|
+
nodeFqdn: z.string().min(1).max(253),
|
|
29
|
+
recordSha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
30
|
+
}).strict()),
|
|
31
|
+
}).strict();
|
|
32
|
+
function controlled(info, what, ownerUid) {
|
|
33
|
+
if (info.isSymbolicLink())
|
|
34
|
+
return `${what} is a symlink`;
|
|
35
|
+
if (info.uid !== ownerUid)
|
|
36
|
+
return `${what} is owned by uid ${info.uid}, not ${ownerUid === 0 ? 'root' : ownerUid}`;
|
|
37
|
+
if (info.mode & 0o022)
|
|
38
|
+
return `${what} is group- or world-writable`;
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const empty = Object.freeze({});
|
|
42
|
+
export function loadReviewedHistoricalRecords(options) {
|
|
43
|
+
const path = resolve(options.path || DEFAULT_REVIEWED_HISTORICAL_RECORDS_PATH);
|
|
44
|
+
const ownerUid = options.ownerUid ?? 0;
|
|
45
|
+
const refuse = (problem) => ({ path, records: empty, count: 0, problem });
|
|
46
|
+
let info;
|
|
47
|
+
try {
|
|
48
|
+
info = lstatSync(path);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (error.code === 'ENOENT')
|
|
52
|
+
return { path, records: empty, count: 0, problem: null };
|
|
53
|
+
return refuse(`cannot inspect ${path}: ${error.message}`);
|
|
54
|
+
}
|
|
55
|
+
const problem = controlled(info, path, ownerUid);
|
|
56
|
+
if (problem)
|
|
57
|
+
return refuse(problem);
|
|
58
|
+
if (!info.isFile())
|
|
59
|
+
return refuse(`${path} is not a regular file`);
|
|
60
|
+
if (info.size > REVIEWED_RECORDS_MAX_BYTES)
|
|
61
|
+
return refuse(`${path} is larger than ${REVIEWED_RECORDS_MAX_BYTES} bytes`);
|
|
62
|
+
let parent;
|
|
63
|
+
try {
|
|
64
|
+
parent = lstatSync(dirname(path));
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
return refuse(`cannot inspect ${dirname(path)}: ${error.message}`);
|
|
68
|
+
}
|
|
69
|
+
const parentProblem = controlled(parent, dirname(path), ownerUid);
|
|
70
|
+
if (parentProblem)
|
|
71
|
+
return refuse(parentProblem);
|
|
72
|
+
if (!parent.isDirectory())
|
|
73
|
+
return refuse(`${dirname(path)} is not a directory`);
|
|
74
|
+
let text;
|
|
75
|
+
try {
|
|
76
|
+
// O_NOFOLLOW and the same inode as inspected: the file cannot be swapped in between.
|
|
77
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
78
|
+
try {
|
|
79
|
+
const opened = fstatSync(fd);
|
|
80
|
+
if (opened.ino !== info.ino || opened.dev !== info.dev || opened.size > REVIEWED_RECORDS_MAX_BYTES)
|
|
81
|
+
return refuse(`${path} changed while it was read`);
|
|
82
|
+
const buffer = Buffer.alloc(opened.size);
|
|
83
|
+
let offset = 0;
|
|
84
|
+
while (offset < buffer.length) {
|
|
85
|
+
const read = readSync(fd, buffer, offset, buffer.length - offset, offset);
|
|
86
|
+
if (read === 0)
|
|
87
|
+
break;
|
|
88
|
+
offset += read;
|
|
89
|
+
}
|
|
90
|
+
text = buffer.subarray(0, offset).toString('utf8');
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
closeSync(fd);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
return refuse(`cannot read ${path}: ${error.message}`);
|
|
98
|
+
}
|
|
99
|
+
let parsed;
|
|
100
|
+
try {
|
|
101
|
+
const result = reviewedFileSchema.safeParse(JSON.parse(text));
|
|
102
|
+
if (!result.success)
|
|
103
|
+
return refuse(`${path} does not match the schema: ${result.error.issues.slice(0, 3).map(issue => `${issue.path.join('.') || '(root)'}: ${issue.message}`).join('; ')}`);
|
|
104
|
+
parsed = result.data;
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
return refuse(`${path} is not valid JSON: ${error.message}`);
|
|
108
|
+
}
|
|
109
|
+
// Entries for other nodes are ignored, so one file can serve a whole fleet.
|
|
110
|
+
const records = {};
|
|
111
|
+
for (const [id, entry] of Object.entries(parsed.records)) {
|
|
112
|
+
if (entry.nodeFqdn === options.hostname)
|
|
113
|
+
records[id] = Object.freeze({ nodeFqdn: entry.nodeFqdn, recordSha256: entry.recordSha256 });
|
|
114
|
+
}
|
|
115
|
+
return { path, records: Object.freeze(records), count: Object.keys(records).length, problem: null };
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=reviewed-historical-records.js.map
|
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/** Fixed cron entrypoint. Load the installed private environment before runtime imports. */
|
|
3
|
+
import { lstatSync } from 'node:fs';
|
|
3
4
|
import { basename, dirname, resolve } from 'node:path';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
5
6
|
import { config } from 'dotenv';
|
|
6
7
|
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
7
8
|
// The deployment helper installs the tarball below the stable host directory;
|
|
8
9
|
// its private .env and .magnus ledger are outside node_modules and survive upgrades.
|
|
9
|
-
|
|
10
|
+
let install = basename(packageRoot) === 'magnus-agent' && basename(dirname(packageRoot)) === '@hivemagnus'
|
|
10
11
|
&& basename(dirname(dirname(packageRoot))) === 'node_modules'
|
|
11
12
|
? resolve(packageRoot, '../../..') : packageRoot;
|
|
13
|
+
// Versioned layout: Node resolves the cron path (<dir>/node_modules or <dir>/current) to
|
|
14
|
+
// <dir>/versions/<X>; the stable directory is the one that holds ./current.
|
|
15
|
+
if (basename(dirname(install)) === 'versions') {
|
|
16
|
+
const stable = dirname(dirname(install));
|
|
17
|
+
try {
|
|
18
|
+
if (lstatSync(resolve(stable, 'current')).isSymbolicLink())
|
|
19
|
+
install = stable;
|
|
20
|
+
}
|
|
21
|
+
catch { /* not the versioned layout */ }
|
|
22
|
+
}
|
|
12
23
|
for (const key of ['CONTROL_API_URL', 'HOST_FQDN', 'NODE_MANAGER_SECRET', 'NODE_MANAGER_SECRET_PREVIOUS',
|
|
13
24
|
'RESULT_SIGNING_SECRET', 'MAGNUS_EXECUTION_SUPERVISOR_ENABLED', 'CONTROL_API_CA_PATH', 'COMMAND_RECONCILIATION_SECRET'])
|
|
14
25
|
delete process.env[key];
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* /opt/magnus-agent/.env
|
|
7
7
|
* /opt/magnus-agent/versions/<X>/node_modules/@hivemagnus/magnus-agent
|
|
8
8
|
* /opt/magnus-agent/current -> versions/<X>
|
|
9
|
+
* /opt/magnus-agent/last-good -> versions/<X> (the last confirmed version)
|
|
9
10
|
* /opt/magnus-agent/launcher/launcher.mjs (ExecStart)
|
|
10
11
|
*
|
|
11
12
|
* After this the agent upgrades itself inside that directory; it never needs
|
|
@@ -15,7 +16,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
15
16
|
import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
16
17
|
import { dirname, join, resolve } from 'node:path';
|
|
17
18
|
import { fileURLToPath } from 'node:url';
|
|
18
|
-
import { AGENT_PACKAGE, pointCurrentAt } from './self-upgrade.js';
|
|
19
|
+
import { AGENT_PACKAGE, ALIVE_FILE, LOCK_FILE, STATE_FILE, linkVersion, pointCurrentAt, pointLink } from './self-upgrade.js';
|
|
19
20
|
export const INSTALL_SERVICE_USAGE = `Usage: sudo magnus-agent install-service [options]
|
|
20
21
|
|
|
21
22
|
Moves this install into the versioned layout used by self-upgrades, installs
|
|
@@ -188,12 +189,16 @@ export async function installService(argv) {
|
|
|
188
189
|
}
|
|
189
190
|
act(`point ${current} at versions/${version}`, () => pointCurrentAt(dir, version));
|
|
190
191
|
}
|
|
192
|
+
// The launcher falls back to the last confirmed version when the upgrade state is unreadable.
|
|
193
|
+
if (!isSymlink(join(dir, 'last-good'))) {
|
|
194
|
+
act(`link ${join(dir, 'last-good')} to the running version`, () => pointLink(dir, 'last-good', linkVersion(dir, 'current') ?? version));
|
|
195
|
+
}
|
|
191
196
|
// Paths written before the versioned layout (cron lines, other units) keep working.
|
|
192
197
|
if (!existsSync(join(dir, 'node_modules')) && !isSymlink(join(dir, 'node_modules'))) {
|
|
193
198
|
act(`link ${join(dir, 'node_modules')} to current/node_modules`, () => symlinkSync(join('current', 'node_modules'), join(dir, 'node_modules'), 'dir'));
|
|
194
199
|
}
|
|
195
|
-
// The launcher is copied from the release that runs install-service, not linked
|
|
196
|
-
// self-
|
|
200
|
+
// The launcher is copied from the release that runs install-service, not linked. A confirmed
|
|
201
|
+
// self-upgrade replaces it only with a copy that declares a higher LAUNCHER_PROTOCOL.
|
|
197
202
|
act(`install ${join(dir, 'launcher', 'launcher.mjs')}`, () => {
|
|
198
203
|
mkdirSync(join(dir, 'launcher'), { recursive: true, mode: 0o755 });
|
|
199
204
|
copyFileSync(join(isSymlink(current) ? realpathSync(join(current, 'node_modules', ...AGENT_PACKAGE.split('/'))) : here, 'launcher', 'launcher.mjs'), join(dir, 'launcher', 'launcher.mjs'));
|
|
@@ -209,8 +214,13 @@ export async function installService(argv) {
|
|
|
209
214
|
execFileSync('chown', [`${user}:${user}`, dir]);
|
|
210
215
|
execFileSync('chown', ['-R', `${user}:${user}`, join(dir, 'versions'), join(dir, '.npm')]);
|
|
211
216
|
execFileSync('chown', ['-h', `${user}:${user}`, current]);
|
|
212
|
-
|
|
213
|
-
|
|
217
|
+
for (const link of ['node_modules', 'last-good'])
|
|
218
|
+
if (isSymlink(join(dir, link)))
|
|
219
|
+
execFileSync('chown', ['-h', `${user}:${user}`, join(dir, link)]);
|
|
220
|
+
// Left behind by an earlier privileged run: the unprivileged agent must be able to replace them.
|
|
221
|
+
const leftovers = ['staging', '.magnus', STATE_FILE, LOCK_FILE, ALIVE_FILE, 'launcher-crashes.json'].map(name => join(dir, name)).filter(path => existsSync(path));
|
|
222
|
+
if (leftovers.length)
|
|
223
|
+
execFileSync('chown', ['-R', `${user}:${user}`, ...leftovers]);
|
|
214
224
|
});
|
|
215
225
|
}
|
|
216
226
|
act(`write ${options.unitPath}${existsSync(options.unitPath) ? ` (previous unit kept as ${options.unitPath}.pre-launcher)` : ''}`, () => {
|
package/dist/upgrade/routes.js
CHANGED
|
@@ -2,7 +2,7 @@ import { Hono } from 'hono';
|
|
|
2
2
|
import { agentUpgradeRequestSchema } from '@hivemagnus/magnus-contracts';
|
|
3
3
|
import { logger } from '../utils/logger.js';
|
|
4
4
|
import { AGENT_VERSION } from '../version.js';
|
|
5
|
-
import { SelfUpgradeError, selfUpgradeStatus, stageUpgrade } from './self-upgrade.js';
|
|
5
|
+
import { EXIT_BACKSTOP_MS, SelfUpgradeError, selfUpgradeStatus, stageUpgrade } from './self-upgrade.js';
|
|
6
6
|
/** Seconds between the reply and the exit that hands over to the launcher. */
|
|
7
7
|
export const RESTART_DELAY_MS = 3_000;
|
|
8
8
|
export const agentUpgradeRoutes = new Hono();
|
|
@@ -14,9 +14,14 @@ agentUpgradeRoutes.post('/', async (c) => {
|
|
|
14
14
|
}
|
|
15
15
|
try {
|
|
16
16
|
const result = await stageUpgrade({ request: parsed.data, version: AGENT_VERSION });
|
|
17
|
+
// A retried request: report the current state, change nothing.
|
|
18
|
+
if (result.repeated)
|
|
19
|
+
return c.json({ success: true, data: { accepted: true, ...result } });
|
|
17
20
|
logger.info(`Self-upgrade staged: ${result.from} -> ${result.to}; restarting through the launcher`);
|
|
18
21
|
// Reply first; the launcher starts versions/<to> on trial after this process exits.
|
|
19
22
|
setTimeout(() => process.kill(process.pid, 'SIGTERM'), RESTART_DELAY_MS).unref();
|
|
23
|
+
// Backstop: a shutdown that hangs must not keep the old version running on the new ./current.
|
|
24
|
+
setTimeout(() => process.exit(0), EXIT_BACKSTOP_MS).unref();
|
|
20
25
|
return c.json({ success: true, data: { accepted: true, ...result, restartInSeconds: RESTART_DELAY_MS / 1000 } });
|
|
21
26
|
}
|
|
22
27
|
catch (error) {
|