@hivemagnus/magnus-agent 1.1.5 → 1.1.8
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/README.md +4 -0
- package/dist/agent.js +6 -2
- package/dist/executors/command-executor.js +34 -7
- package/dist/executors/command-ledger.d.ts +81 -2
- package/dist/executors/command-ledger.js +162 -11
- package/dist/executors/command-refusal.d.ts +43 -0
- package/dist/executors/command-refusal.js +32 -0
- package/dist/executors/cron-manager.js +23 -9
- package/dist/executors/enrollment-drain.js +4 -2
- package/dist/executors/hermes-gateway-status.d.ts +14 -0
- package/dist/executors/hermes-gateway-status.js +67 -0
- package/dist/executors/hermes-manager.d.ts +6 -0
- package/dist/executors/hermes-manager.js +34 -15
- package/dist/executors/managed-http.js +58 -17
- package/dist/executors/scheduler-discovery.d.ts +17 -6
- package/dist/executors/scheduler-discovery.js +37 -13
- package/dist/executors/scheduler-inventory.d.ts +10 -0
- package/dist/executors/scheduler-inventory.js +57 -9
- package/dist/hermes-sync.js +3 -2
- package/dist/security/command-result-signature.d.ts +42 -0
- package/dist/security/command-result-signature.js +19 -0
- package/dist/security/managed-context.d.ts +2 -0
- package/dist/upgrade/preflight.d.ts +28 -0
- package/dist/upgrade/preflight.js +62 -0
- package/dist/upgrade/routes.js +18 -3
- package/dist/upgrade/self-upgrade.d.ts +41 -4
- package/dist/upgrade/self-upgrade.js +112 -18
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -18,6 +18,10 @@ integrations is optional and stays inert until you configure it.
|
|
|
18
18
|
- Command execution with timeouts, signed results and a durable command ledger.
|
|
19
19
|
- Cron management, including finite managed schedules (see
|
|
20
20
|
[MANAGED_SCHEDULER.md](https://github.com/ChefTaco/magnus/blob/main/packages/agent/MANAGED_SCHEDULER.md)).
|
|
21
|
+
Nodes upgraded from before 1.1.6 may keep one stale remembered Hermes schedule
|
|
22
|
+
source per past Hermes run; see
|
|
23
|
+
[Hermes schedule sources remembered before 1.1.6](https://github.com/ChefTaco/magnus/blob/main/packages/agent/MANAGED_SCHEDULER.md#hermes-schedule-sources-remembered-before-116)
|
|
24
|
+
for the cleanup.
|
|
21
25
|
- File read and write with symlink-resolved path checks. Agent state and
|
|
22
26
|
secrets can never be read or written through the API.
|
|
23
27
|
- Skill publication, listing and removal, for example from
|
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 { heartbeatDrift } from './upgrade/preflight.js';
|
|
10
11
|
import { confirmTrial, controlApiUnreachable, recordTrialAlive, selfUpgradeStatus, trialOf } from './upgrade/self-upgrade.js';
|
|
11
12
|
import { commandLedger } from './executors/command-executor.js';
|
|
12
13
|
const HEARTBEAT_INTERVAL_MS = 30_000; // 30s
|
|
@@ -77,8 +78,11 @@ async function collectHealthWithDeadline() {
|
|
|
77
78
|
}
|
|
78
79
|
/** Self-upgrade status for the heartbeat, with the sticky local enrollment marker. */
|
|
79
80
|
async function heartbeatSelfUpgrade() {
|
|
80
|
-
const
|
|
81
|
-
|
|
81
|
+
const marker = await commandLedger.enrollmentMarker();
|
|
82
|
+
if (marker.error)
|
|
83
|
+
logger.warn(`Local enrollment marker could not be read: ${marker.error}`);
|
|
84
|
+
return { ...selfUpgradeStatus(AGENT_VERSION), ...heartbeatDrift(commandLedger),
|
|
85
|
+
managedEnrollment: marker.marker ? { nodeId: marker.marker.nodeId, nodeFqdn: marker.marker.nodeFqdn } : null, managedEnrollmentState: marker.state };
|
|
82
86
|
}
|
|
83
87
|
/** Stop the heartbeat loop. */
|
|
84
88
|
export function stopHeartbeatLoop() {
|
|
@@ -2,14 +2,16 @@ import { managedExecution } from '../security/managed-context.js';
|
|
|
2
2
|
import { Hono } from 'hono';
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
-
import { RESULT_SIGNING_SECRET, MAGNUS_EXECUTION_SUPERVISOR_ENABLED } from '../env.js';
|
|
5
|
+
import { HOST_FQDN, RESULT_SIGNING_SECRET, MAGNUS_EXECUTION_SUPERVISOR_ENABLED } from '../env.js';
|
|
6
6
|
import { startSpan } from '../collectors/otel-collector.js';
|
|
7
|
-
import { CommandLedger, CommandAdmissionError, commandFenceSchema, commandReconciliationSchema } from './command-ledger.js';
|
|
8
|
-
import { signCommandResult } from '../security/command-result-signature.js';
|
|
7
|
+
import { admissionCause, CommandLedger, CommandAdmissionError, commandFenceSchema, commandReconciliationSchema, commandRequestDigest } from './command-ledger.js';
|
|
8
|
+
import { commandAbsenceSigningPayload, signCommandEvidence, signCommandResult } from '../security/command-result-signature.js';
|
|
9
|
+
import { refusalError, refusalStatus } from './command-refusal.js';
|
|
9
10
|
import { ExecutionSupervisor } from './execution-supervisor.js';
|
|
10
11
|
import { mutationBarrier } from './mutation-barrier.js';
|
|
11
12
|
import { COMMAND_COMPLETION_REGISTRY, COMMAND_COMPLETION_REGISTRY_SHA256, commandCompletionDescriptor } from '../security/command-completion-registry.js';
|
|
12
13
|
import { childEnvironment } from '../utils/child-env.js';
|
|
14
|
+
import { logger } from '../utils/logger.js';
|
|
13
15
|
export const commandRoutes = new Hono();
|
|
14
16
|
export const commandLedger = new CommandLedger();
|
|
15
17
|
export const executionSupervisor = new ExecutionSupervisor();
|
|
@@ -119,7 +121,8 @@ commandRoutes.post('/execute', async (c) => {
|
|
|
119
121
|
catch (error) {
|
|
120
122
|
if (!(error instanceof CommandAdmissionError))
|
|
121
123
|
throw error;
|
|
122
|
-
|
|
124
|
+
logger.warn(`Command ${parsed.data.commandId} refused: ${error.code}: ${error.message}${admissionCause(error)}`);
|
|
125
|
+
return c.json({ success: false, error: await refusalError({ commandId: parsed.data.commandId, requestSha256: commandRequestDigest(parsed.data), leaseId: managedExecution.getStore()?.claim?.leaseId }, error.code, error.message, { notStarted: error.notStarted, ...(error.highWater ? { highWater: error.highWater } : {}) }) }, refusalStatus(error.code));
|
|
123
126
|
}
|
|
124
127
|
});
|
|
125
128
|
/**
|
|
@@ -138,11 +141,11 @@ export async function executeCommand(input) {
|
|
|
138
141
|
// Parse/snapshot even internal callers before awaiting or executing effects.
|
|
139
142
|
const opts = executeBodySchema.parse(input);
|
|
140
143
|
if (draining)
|
|
141
|
-
throw new CommandAdmissionError('LEDGER_UNAVAILABLE', 'Node is draining; no command executed');
|
|
144
|
+
throw new CommandAdmissionError('LEDGER_UNAVAILABLE', 'Node is draining; no command executed', { notStarted: true });
|
|
142
145
|
if (RESULT_SIGNING_SECRET.length < 32)
|
|
143
146
|
throw new CommandAdmissionError('LEDGER_UNAVAILABLE', 'Command signing is unavailable; no command executed');
|
|
144
147
|
if (opts.coordination && !MAGNUS_EXECUTION_SUPERVISOR_ENABLED)
|
|
145
|
-
throw new CommandAdmissionError('LEDGER_UNAVAILABLE', 'Managed commands require the node execution supervisor; no command executed');
|
|
148
|
+
throw new CommandAdmissionError('LEDGER_UNAVAILABLE', 'Managed commands require the node execution supervisor; no command executed', { notStarted: true });
|
|
146
149
|
const pending = commandLedger.execute(opts, (digest) => MAGNUS_EXECUTION_SUPERVISOR_ENABLED
|
|
147
150
|
? executeSupervised(opts, digest) : executeProcess(opts, digest), managedExecution.getStore()?.claim);
|
|
148
151
|
pendingExecutions.add(pending);
|
|
@@ -293,10 +296,33 @@ commandRoutes.get('/receipts/:commandId', async (c) => {
|
|
|
293
296
|
return c.json({ success: false, error: { code: 'NOT_FOUND', message: 'Command receipt not found' } }, 404);
|
|
294
297
|
return c.json({ success: true, data: row });
|
|
295
298
|
}
|
|
296
|
-
catch {
|
|
299
|
+
catch (error) {
|
|
300
|
+
logger.warn(`Command receipt ${id} could not be read: ${error instanceof Error ? error.message : String(error)}`);
|
|
297
301
|
return c.json({ success: false, error: { code: 'LEDGER_UNAVAILABLE', message: 'Command receipt unavailable; do not replay' } }, 503);
|
|
298
302
|
}
|
|
299
303
|
});
|
|
304
|
+
// Whether anything local refers to a command, for leases the server holds as
|
|
305
|
+
// `unknown`. Read only and signed with a nonce: it says what this ledger holds
|
|
306
|
+
// now, and the server decides what that proves.
|
|
307
|
+
commandRoutes.get('/receipts/:commandId/trace', async (c) => {
|
|
308
|
+
const id = c.req.param('commandId');
|
|
309
|
+
const nonce = c.req.query('nonce') ?? '';
|
|
310
|
+
const resourceKey = c.req.query('resourceKey') ?? '';
|
|
311
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(id) || !/^[a-zA-Z0-9-]{16,64}$/.test(nonce) || !/^[a-zA-Z0-9][a-zA-Z0-9:._-]{0,199}$/.test(resourceKey))
|
|
312
|
+
return c.json({ success: false, error: { code: 'VALIDATION_ERROR', message: 'Invalid command identifier, nonce or resource key' } }, 400);
|
|
313
|
+
try {
|
|
314
|
+
const trace = await commandLedger.commandTrace(id, resourceKey);
|
|
315
|
+
if (trace.marker.error)
|
|
316
|
+
logger.warn(`Enrollment marker could not be read: ${trace.marker.error}`);
|
|
317
|
+
const unsigned = { commandId: id, nodeFqdn: HOST_FQDN, nonce, ledgerExists: trace.ledgerExists, receipt: trace.receipt, activeLock: trace.activeLock, resourceKey,
|
|
318
|
+
fenceHighWater: trace.fenceHighWater, markerState: trace.marker.state, markerNodeId: trace.marker.marker?.nodeId ?? null, admissionFailedFence: trace.admissionFailedFence };
|
|
319
|
+
return c.json({ success: true, data: { ...unsigned, signature: signCommandEvidence(commandAbsenceSigningPayload(unsigned), RESULT_SIGNING_SECRET) } });
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
logger.warn(`Command trace ${id} could not be read: ${error instanceof Error ? error.message : String(error)}`);
|
|
323
|
+
return c.json({ success: false, error: { code: 'LEDGER_UNAVAILABLE', message: 'Command ledger unavailable' } }, 503);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
300
326
|
// Existing node authentication is necessary but insufficient: only the separate
|
|
301
327
|
// service reconciliation key can attest that an operator reviewed the evidence.
|
|
302
328
|
// This releases a receipt claim, never executes or authorizes remediation.
|
|
@@ -311,6 +337,7 @@ commandRoutes.post('/receipts/:commandId/reconcile', async (c) => {
|
|
|
311
337
|
catch (error) {
|
|
312
338
|
if (!(error instanceof CommandAdmissionError))
|
|
313
339
|
throw error;
|
|
340
|
+
logger.warn(`Reconciliation of ${parsed.data.commandId} refused: ${error.code}: ${error.message}${admissionCause(error)}`);
|
|
314
341
|
return c.json({ success: false, error: { code: error.code, message: error.message } }, error.code === 'LEDGER_UNAVAILABLE' ? 503 : error.code === 'INVALID_RECONCILIATION' ? 403 : 409);
|
|
315
342
|
}
|
|
316
343
|
});
|
|
@@ -55,9 +55,59 @@ export declare const commandReconciliationSchema: z.ZodObject<{
|
|
|
55
55
|
expiresAt: z.ZodNumber;
|
|
56
56
|
signature: z.ZodString;
|
|
57
57
|
}, z.core.$strict>;
|
|
58
|
+
export type CommandAdmissionCode = 'RESOURCE_BUSY' | 'OUTCOME_UNKNOWN' | 'IDEMPOTENCY_CONFLICT' | 'STALE_FENCE' | 'LEASE_EXPIRED' | 'LEDGER_UNAVAILABLE' | 'INVALID_RECONCILIATION' | 'UNMANAGED_MUTATION' | 'RESOURCE_CLAIM_INVALID';
|
|
59
|
+
/** What went wrong underneath an admission error, for a WARN line. */
|
|
60
|
+
export declare const admissionCause: (error: CommandAdmissionError) => string;
|
|
58
61
|
export declare class CommandAdmissionError extends Error {
|
|
59
|
-
readonly code:
|
|
60
|
-
|
|
62
|
+
readonly code: CommandAdmissionCode;
|
|
63
|
+
/** The callback demonstrably never ran and this command id has no receipt: a certain refusal. */
|
|
64
|
+
notStarted: boolean;
|
|
65
|
+
/** STALE_FENCE: the fence this ledger already accepted for the key, so the server can move past it. */
|
|
66
|
+
readonly highWater?: {
|
|
67
|
+
resourceKey: string;
|
|
68
|
+
fence: string;
|
|
69
|
+
};
|
|
70
|
+
constructor(code: CommandAdmissionCode, message: string, details?: {
|
|
71
|
+
notStarted?: boolean;
|
|
72
|
+
highWater?: {
|
|
73
|
+
resourceKey: string;
|
|
74
|
+
fence: string;
|
|
75
|
+
};
|
|
76
|
+
cause?: unknown;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
declare const enrollmentMarkerSchema: z.ZodObject<{
|
|
80
|
+
version: z.ZodLiteral<1>;
|
|
81
|
+
nodeId: z.ZodString;
|
|
82
|
+
nodeFqdn: z.ZodString;
|
|
83
|
+
}, z.core.$strict>;
|
|
84
|
+
export type EnrollmentMarker = {
|
|
85
|
+
state: 'absent' | 'present' | 'unknown';
|
|
86
|
+
marker: z.infer<typeof enrollmentMarkerSchema> | null;
|
|
87
|
+
error?: string;
|
|
88
|
+
};
|
|
89
|
+
export interface CommandTrace {
|
|
90
|
+
ledgerExists: boolean;
|
|
91
|
+
receipt: boolean;
|
|
92
|
+
activeLock: boolean;
|
|
93
|
+
fenceHighWater: string | null;
|
|
94
|
+
marker: EnrollmentMarker;
|
|
95
|
+
/** This request passed the fence, then a later write failed, and the callback did not start. */
|
|
96
|
+
admissionFailed: boolean;
|
|
97
|
+
admissionFailedFence: string | null;
|
|
98
|
+
}
|
|
99
|
+
/** Read-only view of the ledger for preflight checks and heartbeats. */
|
|
100
|
+
export interface LedgerHealth {
|
|
101
|
+
readable: boolean;
|
|
102
|
+
activeLock: {
|
|
103
|
+
commandId: string;
|
|
104
|
+
receipt: string;
|
|
105
|
+
} | null;
|
|
106
|
+
nonTerminalReceipts: number;
|
|
107
|
+
unresolvedReceipts: number;
|
|
108
|
+
truncated: boolean;
|
|
109
|
+
fenceHighWater: Record<string, string>;
|
|
110
|
+
error: string | null;
|
|
61
111
|
}
|
|
62
112
|
export declare function commandRequestDigest(input: CommandIdentity): string;
|
|
63
113
|
export declare class CommandLedger {
|
|
@@ -82,6 +132,15 @@ export declare class CommandLedger {
|
|
|
82
132
|
private read;
|
|
83
133
|
private write;
|
|
84
134
|
private receiptName;
|
|
135
|
+
private failedAdmissionName;
|
|
136
|
+
/**
|
|
137
|
+
* A write after the fence failed, so the fence is ahead with no receipt, and the
|
|
138
|
+
* callback did not start. Say so durably, or that state looks like a receipt that
|
|
139
|
+
* went missing. If not even this can be written, keep the claim instead: it is
|
|
140
|
+
* already on disk, needs no new space, and says the same (recoverClaim clears it).
|
|
141
|
+
* A receipt that did land must keep its claim too, or it can never be reconciled.
|
|
142
|
+
*/
|
|
143
|
+
private keepEvidenceOfFailedAdmission;
|
|
85
144
|
receipt(id: string): Promise<{
|
|
86
145
|
state: string;
|
|
87
146
|
version: 1;
|
|
@@ -309,8 +368,28 @@ export declare class CommandLedger {
|
|
|
309
368
|
} | undefined;
|
|
310
369
|
} | undefined;
|
|
311
370
|
}>;
|
|
371
|
+
/**
|
|
372
|
+
* The sticky marker as it is, without creating anything. An unreadable marker
|
|
373
|
+
* is `unknown`, not absent: reporting nothing could hide a mismatch.
|
|
374
|
+
*/
|
|
375
|
+
enrollmentMarker(): Promise<EnrollmentMarker>;
|
|
376
|
+
/**
|
|
377
|
+
* What this ledger holds about a command, read only and without the mutex. On its
|
|
378
|
+
* own "no receipt" proves nothing: a ledger that is gone holds nothing either. So
|
|
379
|
+
* this also says whether there is a ledger, what fence it last accepted for the
|
|
380
|
+
* key and what its marker says. Ordering in execute(): active.lock, fence, durable
|
|
381
|
+
* running receipt, and only then the callback, so a claim without a receipt
|
|
382
|
+
* never started.
|
|
383
|
+
*/
|
|
384
|
+
commandTrace(id: string, resourceKey?: string): Promise<CommandTrace>;
|
|
385
|
+
/**
|
|
386
|
+
* Read-only health for preflight checks and heartbeats. Takes no mutex and
|
|
387
|
+
* creates nothing, so asking never competes with a running command.
|
|
388
|
+
*/
|
|
389
|
+
health(): Promise<LedgerHealth>;
|
|
312
390
|
/** Persist an authoritative observation; never clears an active/unknown claim. */
|
|
313
391
|
observeManagedEnrollment(identity: Pick<ManagedClaim, 'nodeId' | 'nodeFqdn'>): Promise<void>;
|
|
314
392
|
execute(input: CommandIdentity, run: (digest: string) => Promise<CommandReceiptResult>, enrollment?: ManagedClaim): Promise<CommandReceiptResult>;
|
|
315
393
|
}
|
|
394
|
+
export {};
|
|
316
395
|
//# sourceMappingURL=command-ledger.d.ts.map
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { createHash, randomUUID } from 'node:crypto';
|
|
3
3
|
import { constants } from 'node:fs';
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
|
-
import { lstat, mkdir, open, rename, unlink } from 'node:fs/promises';
|
|
5
|
+
import { lstat, mkdir, open, readdir, rename, unlink } from 'node:fs/promises';
|
|
6
6
|
import { join, resolve } from 'node:path';
|
|
7
7
|
import { z } from 'zod';
|
|
8
8
|
import { HOST_FQDN, NODE_MANAGER_SECRET, RESULT_SIGNING_SECRET } from '../env.js';
|
|
@@ -48,13 +48,29 @@ const receiptSchema = z.object({
|
|
|
48
48
|
finishedAt: z.number().int().nonnegative().optional(), coordination: commandFenceSchema.optional(),
|
|
49
49
|
result: resultSchema.optional(), reconciliation: commandReconciliationSchema.optional(),
|
|
50
50
|
}).strict();
|
|
51
|
+
/** What went wrong underneath an admission error, for a WARN line. */
|
|
52
|
+
export const admissionCause = (error) => error.cause === undefined ? '' : ` (cause: ${error.cause instanceof Error ? error.cause.message : String(error.cause)})`;
|
|
51
53
|
export class CommandAdmissionError extends Error {
|
|
52
54
|
code;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
+
/** The callback demonstrably never ran and this command id has no receipt: a certain refusal. */
|
|
56
|
+
notStarted;
|
|
57
|
+
/** STALE_FENCE: the fence this ledger already accepted for the key, so the server can move past it. */
|
|
58
|
+
highWater;
|
|
59
|
+
constructor(code, message, details = {}) {
|
|
60
|
+
// The reply keeps a fixed code and message; the cause stays here for the caller's log.
|
|
61
|
+
super(message, details.cause === undefined ? undefined : { cause: details.cause });
|
|
55
62
|
this.code = code;
|
|
63
|
+
this.notStarted = details.notStarted ?? false;
|
|
64
|
+
if (details.highWater)
|
|
65
|
+
this.highWater = details.highWater;
|
|
56
66
|
}
|
|
57
67
|
}
|
|
68
|
+
const enrollmentMarkerSchema = z.object({ version: z.literal(1), nodeId: z.string().min(1).max(128), nodeFqdn: z.string().min(1).max(253) }).strict();
|
|
69
|
+
/** Bounds one scan: receipts are never pruned, and a heartbeat must stay cheap. */
|
|
70
|
+
const HEALTH_SCAN_LIMIT = 5000;
|
|
71
|
+
const HEALTH_FENCE_LIMIT = 64;
|
|
72
|
+
/** `state` precedes the potentially large result in every receipt this ledger writes. */
|
|
73
|
+
const RECEIPT_STATE = /"state":"([a-z]+)"/;
|
|
58
74
|
const hash = (value) => createHash('sha256').update(value).digest('hex');
|
|
59
75
|
export function commandRequestDigest(input) {
|
|
60
76
|
return hash(JSON.stringify({ commandId: input.commandId, command: input.command, args: input.args,
|
|
@@ -175,6 +191,25 @@ export class CommandLedger {
|
|
|
175
191
|
}
|
|
176
192
|
}
|
|
177
193
|
receiptName(id) { return 'receipt-' + hash(id) + '.json'; }
|
|
194
|
+
failedAdmissionName(id) { return 'admission-failed-' + hash(id) + '.json'; }
|
|
195
|
+
/**
|
|
196
|
+
* A write after the fence failed, so the fence is ahead with no receipt, and the
|
|
197
|
+
* callback did not start. Say so durably, or that state looks like a receipt that
|
|
198
|
+
* went missing. If not even this can be written, keep the claim instead: it is
|
|
199
|
+
* already on disk, needs no new space, and says the same (recoverClaim clears it).
|
|
200
|
+
* A receipt that did land must keep its claim too, or it can never be reconciled.
|
|
201
|
+
*/
|
|
202
|
+
async keepEvidenceOfFailedAdmission(input) {
|
|
203
|
+
try {
|
|
204
|
+
if (await this.read(this.receiptName(input.commandId)) !== undefined)
|
|
205
|
+
return true;
|
|
206
|
+
await this.write(this.failedAdmissionName(input.commandId), { commandId: input.commandId, fence: input.coordination.fence });
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
catch { // no-reason-needed: the original failure is what is reported; keeping the claim is the fallback
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
178
213
|
async receipt(id) {
|
|
179
214
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(id))
|
|
180
215
|
throw new Error('Invalid command identifier');
|
|
@@ -250,7 +285,7 @@ export class CommandLedger {
|
|
|
250
285
|
catch (error) {
|
|
251
286
|
if (error instanceof CommandAdmissionError)
|
|
252
287
|
throw error;
|
|
253
|
-
throw new CommandAdmissionError('LEDGER_UNAVAILABLE', 'Reconciliation could not be persisted; inspect the receipt before retrying the same decision');
|
|
288
|
+
throw new CommandAdmissionError('LEDGER_UNAVAILABLE', 'Reconciliation could not be persisted; inspect the receipt before retrying the same decision', { cause: error });
|
|
254
289
|
}
|
|
255
290
|
finally {
|
|
256
291
|
await mutex?.close();
|
|
@@ -261,7 +296,7 @@ export class CommandLedger {
|
|
|
261
296
|
const value = await this.read('managed-enrollment.json');
|
|
262
297
|
if (value === undefined)
|
|
263
298
|
return null;
|
|
264
|
-
return
|
|
299
|
+
return enrollmentMarkerSchema.parse(value);
|
|
265
300
|
}
|
|
266
301
|
/** A drain cannot equate an idle process or expired fence with settled effects. */
|
|
267
302
|
async drainSnapshot() {
|
|
@@ -351,7 +386,7 @@ export class CommandLedger {
|
|
|
351
386
|
if (!prior || BigInt(fence.fence) > BigInt(prior.fence))
|
|
352
387
|
await this.write(fenceName, { resourceKey: fence.resourceKey, fence: fence.fence });
|
|
353
388
|
else if (prior && BigInt(fence.fence) < BigInt(prior.fence))
|
|
354
|
-
throw new CommandAdmissionError('STALE_FENCE', 'A successor fence already owns this resource');
|
|
389
|
+
throw new CommandAdmissionError('STALE_FENCE', 'A successor fence already owns this resource', { highWater: { resourceKey: fence.resourceKey, fence: prior.fence } });
|
|
355
390
|
if (!enrolled)
|
|
356
391
|
await this.write('managed-enrollment.json', { version: 1, nodeId: enrollment.nodeId, nodeFqdn: enrollment.nodeFqdn });
|
|
357
392
|
this.checkResult(result, input.commandId, digest, fence);
|
|
@@ -364,6 +399,113 @@ export class CommandLedger {
|
|
|
364
399
|
await mutex.close();
|
|
365
400
|
}
|
|
366
401
|
}
|
|
402
|
+
/**
|
|
403
|
+
* The sticky marker as it is, without creating anything. An unreadable marker
|
|
404
|
+
* is `unknown`, not absent: reporting nothing could hide a mismatch.
|
|
405
|
+
*/
|
|
406
|
+
async enrollmentMarker() {
|
|
407
|
+
try {
|
|
408
|
+
const raw = await this.read('managed-enrollment.json');
|
|
409
|
+
if (raw === undefined)
|
|
410
|
+
return { state: 'absent', marker: null };
|
|
411
|
+
return { state: 'present', marker: enrollmentMarkerSchema.parse(raw) };
|
|
412
|
+
}
|
|
413
|
+
catch (error) {
|
|
414
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR')
|
|
415
|
+
return { state: 'absent', marker: null };
|
|
416
|
+
return { state: 'unknown', marker: null, error: error instanceof Error ? error.message : String(error) };
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* What this ledger holds about a command, read only and without the mutex. On its
|
|
421
|
+
* own "no receipt" proves nothing: a ledger that is gone holds nothing either. So
|
|
422
|
+
* this also says whether there is a ledger, what fence it last accepted for the
|
|
423
|
+
* key and what its marker says. Ordering in execute(): active.lock, fence, durable
|
|
424
|
+
* running receipt, and only then the callback, so a claim without a receipt
|
|
425
|
+
* never started.
|
|
426
|
+
*/
|
|
427
|
+
async commandTrace(id, resourceKey) {
|
|
428
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(id))
|
|
429
|
+
throw new Error('Invalid command identifier');
|
|
430
|
+
let ledgerExists = true;
|
|
431
|
+
try {
|
|
432
|
+
ledgerExists = (await lstat(this.root)).isDirectory();
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR')
|
|
436
|
+
throw error;
|
|
437
|
+
ledgerExists = false;
|
|
438
|
+
}
|
|
439
|
+
const marker = await this.enrollmentMarker();
|
|
440
|
+
if (!ledgerExists)
|
|
441
|
+
return { ledgerExists, receipt: false, activeLock: false, fenceHighWater: null, marker, admissionFailed: false, admissionFailedFence: null };
|
|
442
|
+
const raw = await this.read('active.lock');
|
|
443
|
+
const receipt = await this.read(this.receiptName(id));
|
|
444
|
+
const fence = resourceKey ? await this.read('fence-' + hash(resourceKey) + '.json') : undefined;
|
|
445
|
+
const failed = await this.read(this.failedAdmissionName(id));
|
|
446
|
+
const admissionFailedFence = failed?.commandId === id && typeof failed.fence === 'string' && /^[0-9]{1,20}$/.test(failed.fence) ? failed.fence : null;
|
|
447
|
+
return { ledgerExists, receipt: receipt !== undefined, activeLock: raw !== undefined && lockSchema.parse(raw).commandId === id,
|
|
448
|
+
admissionFailed: admissionFailedFence !== null, admissionFailedFence,
|
|
449
|
+
fenceHighWater: fence?.resourceKey === resourceKey && typeof fence?.fence === 'string' && /^[0-9]{1,20}$/.test(fence.fence) ? fence.fence : null, marker };
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Read-only health for preflight checks and heartbeats. Takes no mutex and
|
|
453
|
+
* creates nothing, so asking never competes with a running command.
|
|
454
|
+
*/
|
|
455
|
+
async health() {
|
|
456
|
+
const health = { readable: true, activeLock: null, nonTerminalReceipts: 0, unresolvedReceipts: 0, truncated: false, fenceHighWater: {}, error: null };
|
|
457
|
+
let names;
|
|
458
|
+
try {
|
|
459
|
+
names = await readdir(this.root);
|
|
460
|
+
}
|
|
461
|
+
catch (error) {
|
|
462
|
+
if (error.code === 'ENOENT')
|
|
463
|
+
return health;
|
|
464
|
+
return { ...health, readable: false, error: 'the ledger directory cannot be read' };
|
|
465
|
+
}
|
|
466
|
+
try {
|
|
467
|
+
const receiptState = async (name) => {
|
|
468
|
+
const file = await open(join(this.root, name), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
469
|
+
try {
|
|
470
|
+
const { buffer, bytesRead } = await file.read({ buffer: Buffer.alloc(1024), position: 0 });
|
|
471
|
+
return RECEIPT_STATE.exec(buffer.toString('utf8', 0, bytesRead))?.[1] ?? null;
|
|
472
|
+
}
|
|
473
|
+
finally {
|
|
474
|
+
await file.close();
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
const running = new Set([...this.active].map(id => this.receiptName(id)));
|
|
478
|
+
const receipts = names.filter(name => name.startsWith('receipt-'));
|
|
479
|
+
health.truncated = receipts.length > HEALTH_SCAN_LIMIT;
|
|
480
|
+
for (const name of receipts.slice(0, HEALTH_SCAN_LIMIT)) {
|
|
481
|
+
const state = await receiptState(name);
|
|
482
|
+
if (state === 'completed' || state === 'reconciled')
|
|
483
|
+
continue;
|
|
484
|
+
health.nonTerminalReceipts += 1;
|
|
485
|
+
if (!running.has(name))
|
|
486
|
+
health.unresolvedReceipts += 1;
|
|
487
|
+
}
|
|
488
|
+
for (const name of names.filter(entry => entry.startsWith('fence-')).slice(0, HEALTH_FENCE_LIMIT)) {
|
|
489
|
+
const record = await this.read(name);
|
|
490
|
+
if (typeof record?.resourceKey === 'string' && typeof record.fence === 'string' && /^[0-9]{1,20}$/.test(record.fence))
|
|
491
|
+
health.fenceHighWater[record.resourceKey] = record.fence;
|
|
492
|
+
}
|
|
493
|
+
const raw = await this.read('active.lock');
|
|
494
|
+
if (raw !== undefined) {
|
|
495
|
+
const claim = lockSchema.parse(raw);
|
|
496
|
+
const state = await receiptState(this.receiptName(claim.commandId)).catch((error) => {
|
|
497
|
+
if (error.code === 'ENOENT')
|
|
498
|
+
return 'none';
|
|
499
|
+
throw error;
|
|
500
|
+
});
|
|
501
|
+
health.activeLock = { commandId: claim.commandId, receipt: state === 'running' ? (this.active.has(claim.commandId) ? 'running' : 'unknown') : state ?? 'invalid' };
|
|
502
|
+
}
|
|
503
|
+
return health;
|
|
504
|
+
}
|
|
505
|
+
catch (error) {
|
|
506
|
+
return { ...health, readable: false, error: `the ledger holds an unreadable record (${error instanceof Error ? error.name : 'error'})` };
|
|
507
|
+
}
|
|
508
|
+
}
|
|
367
509
|
/** Persist an authoritative observation; never clears an active/unknown claim. */
|
|
368
510
|
async observeManagedEnrollment(identity) {
|
|
369
511
|
const value = z.object({ nodeId: z.string().min(1).max(128), nodeFqdn: z.string().min(1).max(253) }).strict().parse(identity);
|
|
@@ -385,7 +527,7 @@ export class CommandLedger {
|
|
|
385
527
|
// Snapshot before awaiting; callers cannot mutate accepted scope or identity.
|
|
386
528
|
input = structuredClone(input);
|
|
387
529
|
const digest = commandRequestDigest(input);
|
|
388
|
-
let held = false, started = false, admitted = false;
|
|
530
|
+
let held = false, started = false, admitted = false, noReceipt = false, fenceWritten = false, keepClaim = false;
|
|
389
531
|
let mutex;
|
|
390
532
|
const previous = async () => {
|
|
391
533
|
const row = await this.receipt(input.commandId);
|
|
@@ -403,6 +545,7 @@ export class CommandLedger {
|
|
|
403
545
|
const replay = await previous();
|
|
404
546
|
if (replay)
|
|
405
547
|
return replay;
|
|
548
|
+
noReceipt = true;
|
|
406
549
|
mutex = await this.acquire();
|
|
407
550
|
await this.recoverClaim();
|
|
408
551
|
const enrolled = await this.managedEnrollment();
|
|
@@ -425,8 +568,9 @@ export class CommandLedger {
|
|
|
425
568
|
if (prior && (prior.resourceKey !== fence.resourceKey || !/^[1-9][0-9]{0,18}$/.test(prior.fence)))
|
|
426
569
|
throw new Error('Invalid fencing record');
|
|
427
570
|
if (prior && BigInt(fence.fence) <= BigInt(prior.fence))
|
|
428
|
-
throw new CommandAdmissionError('STALE_FENCE', 'Stale resource fence cannot execute');
|
|
571
|
+
throw new CommandAdmissionError('STALE_FENCE', 'Stale resource fence cannot execute', { highWater: { resourceKey: fence.resourceKey, fence: prior.fence } });
|
|
429
572
|
await this.write(name, { resourceKey: fence.resourceKey, fence: fence.fence });
|
|
573
|
+
fenceWritten = true;
|
|
430
574
|
}
|
|
431
575
|
if (enrollment && !enrolled)
|
|
432
576
|
await this.write('managed-enrollment.json', { version: 1, nodeId: enrollment.nodeId, nodeFqdn: enrollment.nodeFqdn });
|
|
@@ -443,15 +587,22 @@ export class CommandLedger {
|
|
|
443
587
|
return result;
|
|
444
588
|
}
|
|
445
589
|
catch (error) {
|
|
446
|
-
|
|
590
|
+
// `started` stays true from just before the callback until its terminal receipt is durable.
|
|
591
|
+
const certain = !started && noReceipt;
|
|
592
|
+
if (error instanceof CommandAdmissionError) {
|
|
593
|
+
if (certain && !['OUTCOME_UNKNOWN', 'IDEMPOTENCY_CONFLICT'].includes(error.code))
|
|
594
|
+
error.notStarted = true;
|
|
447
595
|
throw error;
|
|
448
|
-
|
|
596
|
+
}
|
|
597
|
+
if (certain && fenceWritten)
|
|
598
|
+
keepClaim = await this.keepEvidenceOfFailedAdmission(input);
|
|
599
|
+
throw new CommandAdmissionError(started ? 'OUTCOME_UNKNOWN' : 'LEDGER_UNAVAILABLE', started ? 'Execution receipt is uncertain; retained for reconciliation and never replayed' : 'Command ledger unavailable; inspect its receipt before retrying', { notStarted: certain, cause: error });
|
|
449
600
|
}
|
|
450
601
|
finally {
|
|
451
602
|
if (admitted)
|
|
452
603
|
this.active.delete(input.commandId);
|
|
453
604
|
try {
|
|
454
|
-
if (held && !started)
|
|
605
|
+
if (held && !started && !keepClaim)
|
|
455
606
|
await this.releaseClaim();
|
|
456
607
|
}
|
|
457
608
|
catch {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The error body of a refusal. Only with a local proof that the request did
|
|
3
|
+
* not start, nothing local for its id and a lease to name, does it carry
|
|
4
|
+
* `executed: false` and a signature bound to the request, the lease and this node;
|
|
5
|
+
* the server settles the lease on that alone. Anything less stays uncertain, the
|
|
6
|
+
* safe answer for an older server too. A claim this command still holds counts as
|
|
7
|
+
* something local: the request may be between its claim and its receipt.
|
|
8
|
+
*/
|
|
9
|
+
export declare function refusalError(request: {
|
|
10
|
+
commandId: string;
|
|
11
|
+
requestSha256: string;
|
|
12
|
+
leaseId?: string | undefined;
|
|
13
|
+
}, code: string, message: string, details: {
|
|
14
|
+
notStarted: boolean;
|
|
15
|
+
highWater?: {
|
|
16
|
+
resourceKey: string;
|
|
17
|
+
fence: string;
|
|
18
|
+
};
|
|
19
|
+
}): Promise<{
|
|
20
|
+
highWater?: string | undefined;
|
|
21
|
+
resourceKey?: string | undefined;
|
|
22
|
+
code: string;
|
|
23
|
+
message: string;
|
|
24
|
+
} | {
|
|
25
|
+
dispatched: boolean;
|
|
26
|
+
executed: boolean;
|
|
27
|
+
refusal: {
|
|
28
|
+
signature: string;
|
|
29
|
+
commandId: string;
|
|
30
|
+
requestSha256: string;
|
|
31
|
+
code: string;
|
|
32
|
+
highWater: string | null;
|
|
33
|
+
nodeFqdn: string;
|
|
34
|
+
leaseId: string;
|
|
35
|
+
};
|
|
36
|
+
highWater?: string | undefined;
|
|
37
|
+
resourceKey?: string | undefined;
|
|
38
|
+
code: string;
|
|
39
|
+
message: string;
|
|
40
|
+
}>;
|
|
41
|
+
/** Same statuses as /commands/execute. */
|
|
42
|
+
export declare const refusalStatus: (code: string) => 409 | 503;
|
|
43
|
+
//# sourceMappingURL=command-refusal.d.ts.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { HOST_FQDN, RESULT_SIGNING_SECRET } from '../env.js';
|
|
2
|
+
import { commandRefusalSigningPayload, signCommandEvidence } from '../security/command-result-signature.js';
|
|
3
|
+
import { logger } from '../utils/logger.js';
|
|
4
|
+
import { commandLedger } from './command-executor.js';
|
|
5
|
+
/**
|
|
6
|
+
* The error body of a refusal. Only with a local proof that the request did
|
|
7
|
+
* not start, nothing local for its id and a lease to name, does it carry
|
|
8
|
+
* `executed: false` and a signature bound to the request, the lease and this node;
|
|
9
|
+
* the server settles the lease on that alone. Anything less stays uncertain, the
|
|
10
|
+
* safe answer for an older server too. A claim this command still holds counts as
|
|
11
|
+
* something local: the request may be between its claim and its receipt.
|
|
12
|
+
*/
|
|
13
|
+
export async function refusalError(request, code, message, details) {
|
|
14
|
+
const base = { code, message, ...(details.highWater ? { highWater: details.highWater.fence, resourceKey: details.highWater.resourceKey } : {}) };
|
|
15
|
+
if (!details.notStarted || !request.leaseId)
|
|
16
|
+
return base;
|
|
17
|
+
try {
|
|
18
|
+
const trace = await commandLedger.commandTrace(request.commandId);
|
|
19
|
+
if (trace.receipt || trace.activeLock)
|
|
20
|
+
return base;
|
|
21
|
+
const unsigned = { commandId: request.commandId, requestSha256: request.requestSha256, code, highWater: details.highWater?.fence ?? null, nodeFqdn: HOST_FQDN, leaseId: request.leaseId };
|
|
22
|
+
return { ...base, dispatched: false, executed: false,
|
|
23
|
+
refusal: { ...unsigned, signature: signCommandEvidence(commandRefusalSigningPayload(unsigned), RESULT_SIGNING_SECRET) } };
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
logger.warn(`Refusal of ${request.commandId} (${code}) stays uncertain: ${error instanceof Error ? error.message : String(error)}`);
|
|
27
|
+
return base;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Same statuses as /commands/execute. */
|
|
31
|
+
export const refusalStatus = (code) => code === 'LEDGER_UNAVAILABLE' ? 503 : 409;
|
|
32
|
+
//# sourceMappingURL=command-refusal.js.map
|