@hivemagnus/magnus-server 1.1.7 → 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/.env.example +1 -0
- package/agent-version.json +1 -1
- package/dist/cli/agents.js +145 -25
- package/dist/cli/args.js +1 -1
- package/dist/cli/env-catalog.js +1 -0
- package/dist/config/features.js +2 -0
- package/dist/index.js +3 -0
- package/dist/openapi/spec.js +9 -7
- package/dist/routes/agent-upgrades.js +14 -3
- package/dist/routes/nodes.js +5 -2
- package/dist/sdk/node-api-client.js +82 -3
- package/dist/security/command-result-signature.js +22 -0
- package/dist/services/agent-upgrade-preflight.js +162 -0
- package/dist/services/agent-upgrade-readiness.js +203 -0
- package/dist/services/agent-upgrade-service.js +511 -115
- package/dist/services/command-fence-repair.js +72 -0
- package/dist/services/command-resource-leases.js +135 -8
- package/dist/services/resource-enrollment.js +10 -4
- package/package.json +3 -3
|
@@ -31,4 +31,26 @@ export function verifyCommandResultSignature(result, activeSecret, previousSecre
|
|
|
31
31
|
}
|
|
32
32
|
return matched;
|
|
33
33
|
}
|
|
34
|
+
export function commandRefusalSigningPayload(refusal) {
|
|
35
|
+
return JSON.stringify({ domain: 'magnus.command-refusal.v1', commandId: refusal.commandId, requestSha256: refusal.requestSha256,
|
|
36
|
+
code: refusal.code, highWater: refusal.highWater, nodeFqdn: refusal.nodeFqdn, leaseId: refusal.leaseId });
|
|
37
|
+
}
|
|
38
|
+
export function commandAbsenceSigningPayload(absence) {
|
|
39
|
+
return JSON.stringify({ domain: 'magnus.command-absence.v1', commandId: absence.commandId, nodeFqdn: absence.nodeFqdn, nonce: absence.nonce,
|
|
40
|
+
ledgerExists: absence.ledgerExists, receipt: absence.receipt, activeLock: absence.activeLock, resourceKey: absence.resourceKey,
|
|
41
|
+
fenceHighWater: absence.fenceHighWater, markerState: absence.markerState, markerNodeId: absence.markerNodeId, admissionFailedFence: absence.admissionFailedFence });
|
|
42
|
+
}
|
|
43
|
+
/** The fence high-water a preflight reports, bound to the node and a fresh nonce. Only this can move a server generation. */
|
|
44
|
+
export function fenceProofSigningPayload(proof) {
|
|
45
|
+
return JSON.stringify({ domain: 'magnus.fence-high-water.v1', nonce: proof.nonce, nodeFqdn: proof.nodeFqdn,
|
|
46
|
+
fenceHighWater: Object.fromEntries(Object.entries(proof.fenceHighWater).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) });
|
|
47
|
+
}
|
|
48
|
+
/** HMAC check of a signed refusal or absence payload against the active or previous result key. */
|
|
49
|
+
export function verifyCommandEvidence(payload, signature, activeSecret, previousSecret = '') {
|
|
50
|
+
if (typeof signature !== 'string' || !/^[a-f0-9]{64}$/.test(signature) || activeSecret.length < 32)
|
|
51
|
+
return false;
|
|
52
|
+
const actual = Buffer.from(signature, 'hex');
|
|
53
|
+
return [activeSecret, previousSecret].filter(secret => secret.length >= 32)
|
|
54
|
+
.some(secret => timingSafeEqual(actual, createHmac('sha256', secret).update(payload).digest()));
|
|
55
|
+
}
|
|
34
56
|
//# sourceMappingURL=command-result-signature.js.map
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { getDb } from '../db/client.js';
|
|
3
|
+
import { fenceProofSigningPayload, verifyCommandEvidence } from '../security/command-result-signature.js';
|
|
4
|
+
import { NodeApiClient } from '../sdk/node-api-client.js';
|
|
5
|
+
import { getNodeManagerSecret, RESULT_SIGNING_PREVIOUS_SECRET, RESULT_SIGNING_SECRET } from '../utils/env.js';
|
|
6
|
+
import { getNodeBaseUrl } from '../utils/node-url.js';
|
|
7
|
+
import { advanceCommandFence, checkReportedFence, fenceIsAhead, isOwnFenceKey } from './command-fence-repair.js';
|
|
8
|
+
import { recoverAbsentLeases } from './command-resource-leases.js';
|
|
9
|
+
/**
|
|
10
|
+
* Why self-upgrade is off, from what the agent says. Only an operator's choice
|
|
11
|
+
* and a layout that cannot do it are expected; anything else is a problem someone
|
|
12
|
+
* has to fix, so it must not read as expected and let a run look finished.
|
|
13
|
+
*/
|
|
14
|
+
export function selfUpgradeOffCode(report) {
|
|
15
|
+
switch (report.reasonCode) {
|
|
16
|
+
case 'SWITCHED_OFF':
|
|
17
|
+
case 'CONTAINER':
|
|
18
|
+
case 'LEGACY_LAYOUT': return 'SELF_UPGRADE_DISABLED';
|
|
19
|
+
case 'NOT_WRITABLE': return 'PREFLIGHT_NOT_WRITABLE';
|
|
20
|
+
case 'NPM': return 'PREFLIGHT_NPM';
|
|
21
|
+
case undefined:
|
|
22
|
+
case null:
|
|
23
|
+
// An agent older than 1.1.8 sends text only. Its layouts and this one exact string are all it can say safely.
|
|
24
|
+
return report.layout !== 'versioned' || report.reason === 'MAGNUS_AGENT_SELF_UPGRADE=false on this node' ? 'SELF_UPGRADE_DISABLED' : 'SELF_UPGRADE_UNAVAILABLE';
|
|
25
|
+
default: return 'SELF_UPGRADE_UNAVAILABLE';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Hold a report against what an upgrade needs. A field the agent left out proves nothing either way. */
|
|
29
|
+
export function evaluatePreflight(report, context) {
|
|
30
|
+
const problems = [];
|
|
31
|
+
const add = (code, detail, blocking = true) => problems.push({ code, detail, blocking });
|
|
32
|
+
const versioned = report.layout === 'versioned';
|
|
33
|
+
if (report.enabled === false && !versioned)
|
|
34
|
+
add(selfUpgradeOffCode({ reasonCode: report.reasonCode, reason: report.reason ?? null, layout: report.layout ?? 'legacy' }), report.reason ?? 'self-upgrade is unavailable on this node');
|
|
35
|
+
if (versioned) {
|
|
36
|
+
if (report.writable && !report.writable.ok)
|
|
37
|
+
add('PREFLIGHT_NOT_WRITABLE', `the agent cannot write ${report.writable.blocked?.join(', ') || 'its install directory'}`);
|
|
38
|
+
if (report.npm && (!report.npm.present || report.npm.problem))
|
|
39
|
+
add('PREFLIGHT_NPM', report.npm.problem ?? 'npm is missing');
|
|
40
|
+
if (report.fromVersionComplete === false)
|
|
41
|
+
add('PREFLIGHT_FROM_INCOMPLETE', `versions/${report.version} is incomplete or ./current does not run it, so a failed upgrade could not switch back`);
|
|
42
|
+
if (report.enabled === false && !report.writable?.blocked?.length && !report.npm?.problem)
|
|
43
|
+
add(selfUpgradeOffCode({ reasonCode: report.reasonCode, reason: report.reason ?? null, layout: 'versioned' }), report.reason ?? 'self-upgrade is unavailable on this node');
|
|
44
|
+
}
|
|
45
|
+
if (report.disk?.low)
|
|
46
|
+
add('PREFLIGHT_DISK_LOW', `${mib(report.disk.freeBytes)} MiB free in the install directory; an upgrade needs ${mib(report.disk.requiredBytes)} MiB`);
|
|
47
|
+
if (report.engines?.compatible === false)
|
|
48
|
+
add('PREFLIGHT_ENGINE', report.engines.problem ?? `the target does not support Node.js ${report.engines.node}`);
|
|
49
|
+
const ledger = report.ledger;
|
|
50
|
+
if (ledger && !ledger.readable)
|
|
51
|
+
add('LEDGER_UNAVAILABLE', ledger.error ?? 'the command ledger cannot be read', context.managed);
|
|
52
|
+
// A command running right now finishes by itself: it is a busy slot, which dispatch refuses and the end of the wave retries.
|
|
53
|
+
else if (ledger?.activeLock?.receipt === 'running')
|
|
54
|
+
add('RESOURCE_BUSY', `${ledger.activeLock.commandId} is running on this node right now`, false);
|
|
55
|
+
else if (ledger?.activeLock && !['none', 'completed', 'reconciled'].includes(ledger.activeLock.receipt)) {
|
|
56
|
+
add('LEDGER_UNRESOLVED', `active.lock is held by ${ledger.activeLock.commandId}, whose receipt is ${ledger.activeLock.receipt}`, context.managed);
|
|
57
|
+
}
|
|
58
|
+
else if (ledger?.unresolvedReceipts)
|
|
59
|
+
add('LEDGER_UNRESOLVED', `${ledger.unresolvedReceipts} receipt(s) have no terminal outcome`, false);
|
|
60
|
+
const marker = report.managedEnrollment;
|
|
61
|
+
if (report.managedEnrollmentState === 'unknown')
|
|
62
|
+
add('ENROLLMENT_MARKER_UNKNOWN', 'the agent could not read its managed-enrollment marker');
|
|
63
|
+
if (marker && (marker.nodeId !== context.nodeId || marker.nodeFqdn !== context.hostname))
|
|
64
|
+
add('ENROLLMENT_MARKER_MISMATCH', `the agent's managed-enrollment marker is for ${marker.nodeFqdn} (${marker.nodeId}), not this node`);
|
|
65
|
+
for (const [key, value] of Object.entries(report.fenceHighWater ?? {})) {
|
|
66
|
+
// Keys of other domains are not this node's to report, and nothing here adopts them.
|
|
67
|
+
if (!isOwnFenceKey(context.nodeId, key))
|
|
68
|
+
continue;
|
|
69
|
+
const check = checkReportedFence(context.nodeId, key, value);
|
|
70
|
+
if (!check.ok)
|
|
71
|
+
add(check.code, `the agent ${check.detail}`);
|
|
72
|
+
else if (fenceIsAhead(check.value, context.generations[key] ?? 0n))
|
|
73
|
+
add('FENCE_AHEAD', `the agent holds fence ${value} for ${key}; the server's generation is ${(context.generations[key] ?? 0n).toString()}`, false);
|
|
74
|
+
}
|
|
75
|
+
return problems;
|
|
76
|
+
}
|
|
77
|
+
const mib = (bytes) => Math.floor(bytes / (1024 * 1024));
|
|
78
|
+
export function preflightStatus(problems) {
|
|
79
|
+
return problems.some(problem => problem.blocking) ? 'blocked' : problems.length ? 'warning' : 'ok';
|
|
80
|
+
}
|
|
81
|
+
/** Bounded concurrency: one slow agent never delays the others by more than its timeout. */
|
|
82
|
+
export async function mapLimit(items, limit, work) {
|
|
83
|
+
const results = new Array(items.length);
|
|
84
|
+
let next = 0;
|
|
85
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
86
|
+
while (next < items.length) {
|
|
87
|
+
const index = next++;
|
|
88
|
+
results[index] = await work(items[index]);
|
|
89
|
+
}
|
|
90
|
+
}));
|
|
91
|
+
return results;
|
|
92
|
+
}
|
|
93
|
+
/** The first version that reports its fence, answers the trace and says what it refused. */
|
|
94
|
+
export const CERTAIN_REFUSALS_SINCE = '1.1.8';
|
|
95
|
+
export const PREFLIGHT_CONCURRENCY = 16;
|
|
96
|
+
export const PREFLIGHT_TIMEOUT_MS = 4_000;
|
|
97
|
+
function clientFor(node) {
|
|
98
|
+
return new NodeApiClient({ baseUrl: getNodeBaseUrl(node.ipAddress, node.port), resourceNodeId: node.id, nodeToken: getNodeManagerSecret(),
|
|
99
|
+
resourceActorId: 'system:agent-upgrade', timeout: PREFLIGHT_TIMEOUT_MS });
|
|
100
|
+
}
|
|
101
|
+
const generationsOf = async (nodeId) => {
|
|
102
|
+
const rows = await getDb().commandResourceFence.findMany({ where: { OR: [{ resourceKey: `node:${nodeId}` }, { resourceKey: { startsWith: `node:${nodeId}:` } }] } });
|
|
103
|
+
return Object.fromEntries(rows.map(row => [row.resourceKey, row.generation]));
|
|
104
|
+
};
|
|
105
|
+
/** Ask one agent. Never throws: a node's problem must not stop the check of the others. */
|
|
106
|
+
export async function preflightNode(target, query, options = {}) {
|
|
107
|
+
const checkedAt = new Date(options.now ?? Date.now()).toISOString();
|
|
108
|
+
try {
|
|
109
|
+
const node = await getDb().node.findUnique({ where: { id: target.nodeId }, select: { id: true, ipAddress: true, port: true } });
|
|
110
|
+
if (!node)
|
|
111
|
+
return { status: 'unavailable', checkedAt, problems: [], detail: 'node is no longer in the inventory' };
|
|
112
|
+
const nonce = randomUUID();
|
|
113
|
+
const answer = await clientFor(node).getUpgradePreflight({ ...query, nonce }, options.timeoutMs ?? PREFLIGHT_TIMEOUT_MS);
|
|
114
|
+
if (answer.status !== 'ok')
|
|
115
|
+
return { status: answer.status, checkedAt, problems: [], detail: answer.detail };
|
|
116
|
+
const problems = evaluatePreflight(answer.report, { nodeId: target.nodeId, hostname: target.hostname, managed: target.managed, generations: await generationsOf(target.nodeId) });
|
|
117
|
+
const proof = answer.report.fenceProof;
|
|
118
|
+
const fencesVerified = Boolean(proof && proof.nonce === nonce && proof.nodeFqdn.toLowerCase() === target.hostname.toLowerCase() && verifyCommandEvidence(fenceProofSigningPayload({ nonce, nodeFqdn: proof.nodeFqdn, fenceHighWater: answer.report.fenceHighWater ?? {} }), proof.signature, RESULT_SIGNING_SECRET, RESULT_SIGNING_PREVIOUS_SECRET));
|
|
119
|
+
return { status: preflightStatus(problems), checkedAt, problems, report: answer.report, fencesVerified };
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
123
|
+
console.warn(`[agent-upgrade] preflight of ${target.nodeId} failed: ${detail}`);
|
|
124
|
+
return { status: 'unreachable', checkedAt, problems: [], detail };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** Ask one node about its leases stuck `unknown`, and settle what its signed account proves. Safe to repeat. */
|
|
128
|
+
export async function recoverNodeLeases(nodeId, actorId, now = Date.now()) {
|
|
129
|
+
const node = await getDb().node.findUnique({ where: { id: nodeId }, select: { id: true, ipAddress: true, port: true, hostname: true } });
|
|
130
|
+
if (!node)
|
|
131
|
+
return [];
|
|
132
|
+
const client = clientFor(node);
|
|
133
|
+
return recoverAbsentLeases(node.id, node.hostname, (commandId, nonce, resourceKey) => client.getCommandTrace(commandId, nonce, resourceKey, PREFLIGHT_TIMEOUT_MS), actorId, now, (commandId, expected) => client.getCommandReceipt(commandId, expected));
|
|
134
|
+
}
|
|
135
|
+
/** What a check repaired, in words. Fence and lease repairs are safe to repeat; each change is audited. */
|
|
136
|
+
export async function healNode(target, preflight, actorId = 'system:agent-upgrade') {
|
|
137
|
+
const healed = [];
|
|
138
|
+
const problems = [...preflight.problems];
|
|
139
|
+
const fences = Object.entries(preflight.report?.fenceHighWater ?? {});
|
|
140
|
+
if (fences.length && !preflight.fencesVerified)
|
|
141
|
+
console.warn(`[agent-upgrade] the fences reported by ${target.nodeId} carry no valid proof; generations unchanged`);
|
|
142
|
+
for (const [resourceKey, highWater] of preflight.fencesVerified ? fences : []) {
|
|
143
|
+
if (!isOwnFenceKey(target.nodeId, resourceKey))
|
|
144
|
+
continue;
|
|
145
|
+
const advanced = await advanceCommandFence({ nodeId: target.nodeId, resourceKey, highWater, source: 'preflight', actorId });
|
|
146
|
+
if (advanced.status !== 'advanced')
|
|
147
|
+
continue;
|
|
148
|
+
healed.push(`moved the fence of ${resourceKey} from ${advanced.from} to ${advanced.to}`);
|
|
149
|
+
const index = problems.findIndex(problem => problem.code === 'FENCE_AHEAD' && problem.detail.includes(` for ${resourceKey};`));
|
|
150
|
+
if (index >= 0)
|
|
151
|
+
problems.splice(index, 1);
|
|
152
|
+
}
|
|
153
|
+
const leases = await recoverNodeLeases(target.nodeId, actorId);
|
|
154
|
+
for (const lease of leases) {
|
|
155
|
+
if (lease.outcome === 'settled')
|
|
156
|
+
healed.push(`settled lease ${lease.commandId} as not dispatched: the agent holds no receipt for it`);
|
|
157
|
+
if (lease.outcome === 'unverified' && lease.code)
|
|
158
|
+
problems.push({ code: lease.code, detail: `lease ${lease.commandId}: ${lease.detail ?? 'unverified'}`, blocking: false });
|
|
159
|
+
}
|
|
160
|
+
return { healed, leases, problems };
|
|
161
|
+
}
|
|
162
|
+
//# sourceMappingURL=agent-upgrade-preflight.js.map
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a node could take an agent upgrade, all the time and not just when
|
|
3
|
+
* one starts. Pure evaluation of what the agent's heartbeat reports against the
|
|
4
|
+
* server's own state, so an operator sees drift weeks before an upgrade, and a
|
|
5
|
+
* monitor that notifies when a node has been not ready for a day.
|
|
6
|
+
*/
|
|
7
|
+
import { compareVersions, isVersion, upgradeRemedy } from '@hivemagnus/magnus-contracts';
|
|
8
|
+
import { getDb } from '../db/index.js';
|
|
9
|
+
import { SENTINEL_NODE_IDS } from '../utils/sentinel-nodes.js';
|
|
10
|
+
import { upgradeSettings } from '../config/features.js';
|
|
11
|
+
import { CERTAIN_REFUSALS_SINCE, recoverNodeLeases, selfUpgradeOffCode } from './agent-upgrade-preflight.js';
|
|
12
|
+
import { storedSelfUpgrade } from './agent-upgrade-service.js';
|
|
13
|
+
import { checkReportedFence, fenceIsAhead, isOwnFenceKey } from './command-fence-repair.js';
|
|
14
|
+
import { ABSENT_RECEIPT_MARGIN_MS, HELD_RESOURCE_STATES } from './command-resource-leases.js';
|
|
15
|
+
import { enrollmentIsComplete } from './resource-enrollment.js';
|
|
16
|
+
import { createNotificationInTransaction } from './notification-service.js';
|
|
17
|
+
import { matchingMaintenance } from './maintenance-service.js';
|
|
18
|
+
const DAY_MS = 24 * 60 * 60_000;
|
|
19
|
+
const problem = (code, detail) => {
|
|
20
|
+
const { cause, remedy } = upgradeRemedy(code);
|
|
21
|
+
return { code, detail, cause, remedy };
|
|
22
|
+
};
|
|
23
|
+
export function computeUpgradeReadiness(input) {
|
|
24
|
+
const now = input.now ?? Date.now();
|
|
25
|
+
const report = storedSelfUpgrade(input.metadata);
|
|
26
|
+
const problems = [];
|
|
27
|
+
const enrollment = input.enrollment ?? null;
|
|
28
|
+
if (enrollment?.state === 'draining') {
|
|
29
|
+
const since = enrollment.startedAt ?? enrollment.updatedAt;
|
|
30
|
+
problems.push(problem('ENROLLMENT_DRAINING', `resource enrollment has been draining for ${Math.floor((now - since.getTime()) / DAY_MS)} day(s), since ${since.toISOString().slice(0, 10)}`));
|
|
31
|
+
}
|
|
32
|
+
if (report?.managedEnrollmentState === 'unknown')
|
|
33
|
+
problems.push(problem('ENROLLMENT_MARKER_UNKNOWN', 'the agent could not read its managed-enrollment marker'));
|
|
34
|
+
const marker = report?.managedEnrollment;
|
|
35
|
+
if (marker && (!enrollmentIsComplete(enrollment, marker.nodeFqdn) || marker.nodeId !== input.id || marker.nodeFqdn !== input.hostname))
|
|
36
|
+
problems.push(problem('ENROLLMENT_MARKER_MISMATCH', `the agent holds a managed-enrollment marker for ${marker.nodeFqdn}, but the server has ${enrollment ? `a ${enrollment.state} enrollment for ${enrollment.nodeFqdn}` : 'no enrollment row'}`));
|
|
37
|
+
for (const [key, value] of Object.entries(report?.fenceHighWater ?? {})) {
|
|
38
|
+
if (!isOwnFenceKey(input.id, key))
|
|
39
|
+
continue;
|
|
40
|
+
const check = checkReportedFence(input.id, key, value);
|
|
41
|
+
if (!check.ok)
|
|
42
|
+
problems.push(problem(check.code, `the agent ${check.detail}`));
|
|
43
|
+
else if (fenceIsAhead(check.value, input.generations?.[key] ?? 0n))
|
|
44
|
+
problems.push(problem('FENCE_AHEAD', `the agent holds fence ${value} for ${key}; the server's generation is ${(input.generations?.[key] ?? 0n).toString()}`));
|
|
45
|
+
}
|
|
46
|
+
const ledger = report?.ledger;
|
|
47
|
+
if (ledger && !ledger.readable)
|
|
48
|
+
problems.push(problem('LEDGER_UNAVAILABLE', ledger.error ?? 'the agent cannot read its command ledger'));
|
|
49
|
+
else if (ledger?.unresolvedReceipts)
|
|
50
|
+
problems.push(problem('LEDGER_UNRESOLVED', `${ledger.unresolvedReceipts} receipt(s) in the agent ledger have no terminal outcome${ledger.activeLock ? `; active.lock is held by ${ledger.activeLock.commandId}` : ''}`));
|
|
51
|
+
if (input.heldLeases)
|
|
52
|
+
problems.push(problem('LEASE_HELD', `${input.heldLeases} command lease(s) are reserved, running or unknown`));
|
|
53
|
+
// Only a node that could upgrade itself needs npm and disk for it. One that is off because of a problem says which.
|
|
54
|
+
const canSelfUpgrade = report?.layout === 'versioned' && report.enabled;
|
|
55
|
+
if (report && !report.enabled && report.layout === 'versioned') {
|
|
56
|
+
const code = selfUpgradeOffCode(report);
|
|
57
|
+
if (!upgradeRemedy(code).informational)
|
|
58
|
+
problems.push(problem(code, report.reason ?? 'self-upgrade is unavailable on this node'));
|
|
59
|
+
}
|
|
60
|
+
if (canSelfUpgrade && report?.disk?.low)
|
|
61
|
+
problems.push(problem('PREFLIGHT_DISK_LOW', `${Math.floor(report.disk.freeBytes / (1024 * 1024))} MiB free in the install directory; an upgrade needs ${Math.floor(report.disk.requiredBytes / (1024 * 1024))} MiB`));
|
|
62
|
+
if (canSelfUpgrade && report?.npm && (!report.npm.present || report.npm.problem))
|
|
63
|
+
problems.push(problem('PREFLIGHT_NPM', report.npm.problem ?? 'npm is missing on this node'));
|
|
64
|
+
const reportsDrift = Boolean(report && (report.ledger || report.disk || report.npm));
|
|
65
|
+
return { status: problems.length ? 'not-ready' : reportsDrift && report?.enabled ? 'ok' : 'unknown', problems, observedAt: report?.observedAt ?? null };
|
|
66
|
+
}
|
|
67
|
+
/** Readiness of many nodes at once, for list pages: one query per kind of server state. Never fails a page. */
|
|
68
|
+
export async function readinessOfNodes(nodes, now = Date.now()) {
|
|
69
|
+
if (!nodes.length)
|
|
70
|
+
return new Map();
|
|
71
|
+
try {
|
|
72
|
+
const db = getDb();
|
|
73
|
+
const ids = nodes.map(node => node.id);
|
|
74
|
+
const [enrollments, fences, leases] = await Promise.all([
|
|
75
|
+
db.resourceEnrollment.findMany({ where: { nodeId: { in: ids } }, select: { nodeId: true, state: true, nodeFqdn: true, startedAt: true, updatedAt: true } }),
|
|
76
|
+
db.commandResourceFence.findMany({ where: { OR: ids.flatMap(id => [{ resourceKey: `node:${id}` }, { resourceKey: `node:${id}:ardent` }]) } }),
|
|
77
|
+
db.commandResourceLease.groupBy({ by: ['nodeId'], where: { nodeId: { in: ids }, state: { in: [...HELD_RESOURCE_STATES] } }, _count: { _all: true } }),
|
|
78
|
+
]);
|
|
79
|
+
const generations = Object.fromEntries(fences.map(row => [row.resourceKey, row.generation]));
|
|
80
|
+
return new Map(nodes.map(node => [node.id, computeUpgradeReadiness({ id: node.id, hostname: node.hostname, metadata: node.metadata, now,
|
|
81
|
+
enrollment: enrollments.find(row => row.nodeId === node.id) ?? null, generations,
|
|
82
|
+
heldLeases: leases.find(row => row.nodeId === node.id)?._count._all ?? 0 })]));
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
console.warn(`[upgrade-readiness] could not be computed: ${error instanceof Error ? error.message : String(error)}`);
|
|
86
|
+
return new Map();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// ── Monitor: notify when a node has been not ready for a day ──
|
|
90
|
+
/** A node not ready for this long gets a notification, once per episode. */
|
|
91
|
+
export const NOT_READY_NOTIFY_MS = DAY_MS;
|
|
92
|
+
const CHECK_INTERVAL_MS = 10 * 60_000;
|
|
93
|
+
let timer = null;
|
|
94
|
+
/** How often one node is asked about its stuck leases. */
|
|
95
|
+
export const LEASE_RECOVERY_INTERVAL_MS = 60 * 60_000;
|
|
96
|
+
const lastLeaseRecovery = new Map();
|
|
97
|
+
export function resetLeaseRecoveryLimits() { lastLeaseRecovery.clear(); }
|
|
98
|
+
/**
|
|
99
|
+
* Settle leases nobody will come back for: no run may ever start for a node that
|
|
100
|
+
* already runs the target. Past the deadline plus a margin, on agents that answer
|
|
101
|
+
* the trace, at most once an hour per node; only signed ledger evidence settles
|
|
102
|
+
* one, and each settlement is audited by recoverAbsentLeases.
|
|
103
|
+
*/
|
|
104
|
+
async function recoverStuckLeases(now) {
|
|
105
|
+
const db = getDb();
|
|
106
|
+
const stuck = await db.commandResourceLease.findMany({ where: { state: 'unknown', deadline: { lt: new Date(now - ABSENT_RECEIPT_MARGIN_MS) } }, select: { nodeId: true }, distinct: ['nodeId'] });
|
|
107
|
+
if (!stuck.length)
|
|
108
|
+
return;
|
|
109
|
+
const nodes = await db.node.findMany({ where: { id: { in: stuck.map(row => row.nodeId) }, status: { in: ['ONLINE', 'DEGRADED'] } }, select: { id: true, name: true, agent: { select: { version: true } } } });
|
|
110
|
+
for (const node of nodes) {
|
|
111
|
+
const version = node.agent?.version ?? '';
|
|
112
|
+
if (!isVersion(version) || compareVersions(version, CERTAIN_REFUSALS_SINCE) < 0)
|
|
113
|
+
continue;
|
|
114
|
+
if (now - (lastLeaseRecovery.get(node.id) ?? -Infinity) < LEASE_RECOVERY_INTERVAL_MS)
|
|
115
|
+
continue;
|
|
116
|
+
lastLeaseRecovery.set(node.id, now);
|
|
117
|
+
const results = await recoverNodeLeases(node.id, 'system:upgrade-readiness', now).catch((error) => {
|
|
118
|
+
console.warn(`[upgrade-readiness] lease recovery on ${node.name} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
119
|
+
return [];
|
|
120
|
+
});
|
|
121
|
+
for (const lease of results)
|
|
122
|
+
if (lease.outcome === 'unverified')
|
|
123
|
+
console.warn(`[upgrade-readiness] lease ${lease.commandId} on ${node.name} stays held: ${lease.code}: ${lease.detail}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
export async function checkUpgradeReadiness(now = Date.now()) {
|
|
127
|
+
const db = getDb();
|
|
128
|
+
await recoverStuckLeases(now).catch((error) => console.warn(`[upgrade-readiness] lease recovery failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
129
|
+
const nodes = await db.node.findMany({ where: { id: { notIn: SENTINEL_NODE_IDS }, status: { in: ['ONLINE', 'DEGRADED'] } }, select: { id: true, name: true, hostname: true, metadata: true } });
|
|
130
|
+
const readiness = await readinessOfNodes(nodes, now);
|
|
131
|
+
// Lease recovery above runs regardless: leases settle whether or not this server drives upgrades.
|
|
132
|
+
const notify = upgradeSettings().agentUpgrades;
|
|
133
|
+
for (const node of nodes) {
|
|
134
|
+
const state = readiness.get(node.id);
|
|
135
|
+
if (!state)
|
|
136
|
+
continue;
|
|
137
|
+
// One node's failure is that node's: the rest of the tick still runs.
|
|
138
|
+
await checkOneNode(node, state, notify, now).catch((error) => console.warn(`[upgrade-readiness] ${node.name}: ${error instanceof Error ? error.message : String(error)}`));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async function checkOneNode(node, state, notify, now) {
|
|
142
|
+
const db = getDb();
|
|
143
|
+
const metadata = parseMetadata(node.metadata);
|
|
144
|
+
const since = typeof metadata['upgradeNotReadySince'] === 'string' ? metadata['upgradeNotReadySince'] : null;
|
|
145
|
+
// Read again right before writing: heartbeats keep their report in the same column.
|
|
146
|
+
const patch = async (change) => {
|
|
147
|
+
const row = await db.node.findUnique({ where: { id: node.id }, select: { metadata: true } });
|
|
148
|
+
await db.node.update({ where: { id: node.id }, data: { metadata: JSON.stringify(change(parseMetadata(row?.metadata))) } });
|
|
149
|
+
};
|
|
150
|
+
if (state.status !== 'not-ready') {
|
|
151
|
+
if (since)
|
|
152
|
+
await patch(withoutEpisode);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (!since)
|
|
156
|
+
return patch(current => ({ ...current, upgradeNotReadySince: new Date(now).toISOString() }));
|
|
157
|
+
if (!notify || metadata['upgradeNotReadyNotifiedAt'] || now - Date.parse(since) < NOT_READY_NOTIFY_MS)
|
|
158
|
+
return;
|
|
159
|
+
const maintenance = await matchingMaintenance(node.id);
|
|
160
|
+
await db.$transaction(async (tx) => {
|
|
161
|
+
// Several servers may run this monitor: the lock and a fresh read make one of them the one that notifies.
|
|
162
|
+
await tx.$executeRaw `SELECT pg_advisory_xact_lock(hashtextextended(${`upgrade-not-ready:${node.id}`}, 0))`;
|
|
163
|
+
const row = await tx.node.findUnique({ where: { id: node.id }, select: { metadata: true } });
|
|
164
|
+
const current = parseMetadata(row?.metadata);
|
|
165
|
+
if (current['upgradeNotReadyNotifiedAt'])
|
|
166
|
+
return;
|
|
167
|
+
await createNotificationInTransaction(tx, {
|
|
168
|
+
type: 'node.upgrade_not_ready', severity: 'warning', title: `${node.name} could not take an agent upgrade`,
|
|
169
|
+
message: `Not ready since ${since}: ${state.problems.map(item => `${item.code}: ${item.detail}. ${item.remedy}`).join(' ')}`.slice(0, 2000),
|
|
170
|
+
nodeId: node.id, nodeName: node.name, metadata: { since, codes: state.problems.map(item => item.code) },
|
|
171
|
+
}, maintenance?.id ?? null);
|
|
172
|
+
await tx.node.update({ where: { id: node.id }, data: { metadata: JSON.stringify({ ...current, upgradeNotReadyNotifiedAt: new Date(now).toISOString() }) } });
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
function parseMetadata(raw) {
|
|
176
|
+
try {
|
|
177
|
+
const parsed = JSON.parse(raw ?? '{}');
|
|
178
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
console.warn(`[upgrade-readiness] node metadata is not JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
182
|
+
return {};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function withoutEpisode(metadata) {
|
|
186
|
+
const { upgradeNotReadySince: _since, upgradeNotReadyNotifiedAt: _notified, ...rest } = metadata;
|
|
187
|
+
return rest;
|
|
188
|
+
}
|
|
189
|
+
export function startUpgradeReadinessMonitor() {
|
|
190
|
+
if (timer)
|
|
191
|
+
return;
|
|
192
|
+
const run = () => void checkUpgradeReadiness().catch((error) => console.error(`[upgrade-readiness] check failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
193
|
+
run();
|
|
194
|
+
timer = setInterval(run, CHECK_INTERVAL_MS);
|
|
195
|
+
timer.unref?.();
|
|
196
|
+
}
|
|
197
|
+
export function stopUpgradeReadinessMonitor() {
|
|
198
|
+
if (timer) {
|
|
199
|
+
clearInterval(timer);
|
|
200
|
+
timer = null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
//# sourceMappingURL=agent-upgrade-readiness.js.map
|