@hivemagnus/magnus-server 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/agent-version.json +1 -1
- package/dist/cli/agents.js +103 -30
- package/dist/cli/args.js +10 -2
- package/dist/cli/auto-migrate.js +75 -17
- package/dist/cli/env-catalog.js +4 -1
- package/dist/cli/migrations.js +176 -15
- package/dist/cli.js +83 -8
- package/dist/index.js +1 -1
- package/dist/openapi/spec.js +36 -7
- package/dist/routes/agent-upgrades.js +28 -7
- package/dist/routes/heartbeat.js +2 -1
- package/dist/routes/resource-leases.js +5 -7
- package/dist/sdk/node-api-client.js +96 -4
- package/dist/server.js +1 -0
- package/dist/services/agent-upgrade-service.js +610 -150
- package/dist/services/command-resource-leases.js +13 -2
- package/dist/services/managed-cron.js +4 -7
- package/dist/services/node-issue-monitor.js +2 -0
- package/dist/services/resource-enrollment.js +73 -18
- package/dist/services/resource-ownership.js +20 -0
- package/dist/utils/runtime-file.js +25 -1
- package/package.json +3 -3
|
@@ -2,29 +2,54 @@
|
|
|
2
2
|
* Server-driven agent upgrades (MAGNUS_AGENT_UPGRADES). The server pins one
|
|
3
3
|
* exact version and its npm integrity, asks one canary agent to stage it,
|
|
4
4
|
* waits until that agent reports the version on trial as confirmed, then
|
|
5
|
-
* upgrades the rest a few at a time.
|
|
6
|
-
*
|
|
7
|
-
* (
|
|
8
|
-
*
|
|
5
|
+
* upgrades the rest a few at a time. A revert, a release problem or a node
|
|
6
|
+
* that goes silent after the request stops the run; a node that refuses
|
|
7
|
+
* before anything changed (busy, enrollment, unreachable) is recorded as
|
|
8
|
+
* `refused` and the wave goes on. Agents decide for themselves whether they
|
|
9
|
+
* can upgrade (MAGNUS_AGENT_SELF_UPGRADE, launcher layout) and report it with
|
|
10
|
+
* every heartbeat, so the console can grey out the ones that cannot.
|
|
11
|
+
*
|
|
12
|
+
* Eligibility uses the same read-only predicates as resource admission
|
|
13
|
+
* (admission fence, enrollment, held work and leases, fresh health), so a node
|
|
14
|
+
* the console offers is one that dispatch will not refuse for those reasons.
|
|
9
15
|
*/
|
|
10
16
|
import { randomUUID } from 'node:crypto';
|
|
11
|
-
import { AGENT_SELF_UPGRADE_PROTOCOL, agentSelfUpgradeStatusSchema, checkUpgradeAllowed, compareVersions, isVersion, } from '@hivemagnus/magnus-contracts';
|
|
17
|
+
import { AGENT_SELF_UPGRADE_PROTOCOL, agentSelfUpgradeStatusSchema, checkUpgradeAllowed, compareVersions, isVersion, parseVersion, } from '@hivemagnus/magnus-contracts';
|
|
12
18
|
import { upgradeSettings } from '../config/features.js';
|
|
13
19
|
import { AGENT_VERSION } from '../config/version.js';
|
|
14
20
|
import { getDb } from '../db/client.js';
|
|
15
21
|
import { HttpError } from '../middleware/error-handler.js';
|
|
16
|
-
import { NodeApiClient } from '../sdk/node-api-client.js';
|
|
22
|
+
import { AgentResponseError, DispatchOutcomeUnknownError, NodeApiClient, NotDispatchedError } from '../sdk/node-api-client.js';
|
|
17
23
|
import { getNodeManagerSecret } from '../utils/env.js';
|
|
18
24
|
import { getNodeBaseUrl } from '../utils/node-url.js';
|
|
19
25
|
import { SENTINEL_NODE_IDS } from '../utils/sentinel-nodes.js';
|
|
20
26
|
import { logAuditEvent } from './audit-service.js';
|
|
27
|
+
import { HELD_RESOURCE_STATES, hasFreshOnlineHealth, legacyAdmissionPossible, resourceLeasesRequested } from './command-resource-leases.js';
|
|
28
|
+
import { enrollmentIsComplete } from './resource-enrollment.js';
|
|
29
|
+
import { stickyEnrollmentCause } from './resource-ownership.js';
|
|
21
30
|
export const AGENT_PACKAGE = '@hivemagnus/magnus-agent';
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
31
|
+
const HEARTBEAT_MS = 30_000;
|
|
32
|
+
/** MAGNUS_AGENT_UPGRADE_CONFIRM_SECONDS default, for agents that do not report theirs. */
|
|
33
|
+
const DEFAULT_CONFIRM_SECONDS = 180;
|
|
34
|
+
/** Restart and first heartbeat of the new version, on top of the trial window. */
|
|
35
|
+
const CONFIRM_MARGIN_MS = 2 * 60_000;
|
|
36
|
+
/** A request whose reply was lost may still be installing. */
|
|
37
|
+
const UNKNOWN_DISPATCH_MARGIN_MS = 10 * 60_000;
|
|
26
38
|
const POLL_MS = 5_000;
|
|
27
39
|
const RUN_TTL_SECONDS = 7 * 24 * 60 * 60;
|
|
40
|
+
/** The owner renews its lease every LEASE_MS / 3; a lease this old means the owner is gone. */
|
|
41
|
+
const LEASE_MS = 90_000;
|
|
42
|
+
/** Runs written by servers without leases count as abandoned after this long. */
|
|
43
|
+
const UNLEASED_RUN_MS = 2 * 60 * 60_000;
|
|
44
|
+
const MAX_LOCAL_RUNS = 20;
|
|
45
|
+
/** This process, as the owner of the runs it executes. */
|
|
46
|
+
export const INSTANCE_ID = randomUUID();
|
|
47
|
+
/** Agent error codes that mean the node was not touched. */
|
|
48
|
+
export const UNTOUCHED_CODES = new Set(['SELF_UPGRADE_BUSY', 'SELF_UPGRADE_CURRENT', 'SELF_UPGRADE_UNAVAILABLE', 'SELF_UPGRADE_PACKAGE',
|
|
49
|
+
'SELF_UPGRADE_REFUSED', 'SELF_UPGRADE_LAUNCHER', 'SELF_UPGRADE_ENGINE', 'SELF_UPGRADE_TIMEOUT',
|
|
50
|
+
'RESOURCE_BUSY', 'UNMANAGED_MUTATION', 'ENROLLMENT_AUTHORITY_UNAVAILABLE', 'RESOURCE_CLAIM_INVALID']);
|
|
51
|
+
/** Agent error codes that point at the release itself: the wave stops. */
|
|
52
|
+
export const RELEASE_PROBLEM_CODES = new Set(['SELF_UPGRADE_INTEGRITY', 'SELF_UPGRADE_FAILED', 'SELF_UPGRADE_SOURCE']);
|
|
28
53
|
function parseMetadata(raw) {
|
|
29
54
|
try {
|
|
30
55
|
const parsed = JSON.parse(raw ?? '{}');
|
|
@@ -34,50 +59,121 @@ function parseMetadata(raw) {
|
|
|
34
59
|
return {};
|
|
35
60
|
}
|
|
36
61
|
}
|
|
62
|
+
const parseWarnings = new Map();
|
|
63
|
+
/**
|
|
64
|
+
* Parse a self-upgrade report leniently: a malformed `upgrade` entry is
|
|
65
|
+
* dropped instead of the whole report. Failures are logged (once an hour per source).
|
|
66
|
+
*/
|
|
67
|
+
export function parseSelfUpgradeStatus(value, source) {
|
|
68
|
+
if (value === undefined || value === null)
|
|
69
|
+
return null;
|
|
70
|
+
const parsed = agentSelfUpgradeStatusSchema.safeParse(value);
|
|
71
|
+
if (parsed.success)
|
|
72
|
+
return parsed.data;
|
|
73
|
+
const partial = typeof value === 'object' && !Array.isArray(value)
|
|
74
|
+
? agentSelfUpgradeStatusSchema.safeParse({ ...value, upgrade: null }) : null;
|
|
75
|
+
if (source && Date.now() - (parseWarnings.get(source) ?? 0) > 60 * 60_000) {
|
|
76
|
+
parseWarnings.set(source, Date.now());
|
|
77
|
+
console.warn(`[agent-upgrade] ${source} sent a self-upgrade report that does not parse (${parsed.error.issues.slice(0, 3)
|
|
78
|
+
.map(issue => `${issue.path.join('.') || '<root>'}: ${issue.message}`).join('; ')})${partial?.success ? '; kept it without its upgrade entry' : '; ignored it'}`);
|
|
79
|
+
}
|
|
80
|
+
return partial?.success ? partial.data : null;
|
|
81
|
+
}
|
|
37
82
|
export function storedSelfUpgrade(metadataRaw) {
|
|
38
83
|
const value = parseMetadata(metadataRaw)['agentSelfUpgrade'];
|
|
39
|
-
const parsed =
|
|
40
|
-
if (!parsed
|
|
84
|
+
const parsed = parseSelfUpgradeStatus(value);
|
|
85
|
+
if (!parsed)
|
|
41
86
|
return null;
|
|
42
87
|
const extra = value;
|
|
43
|
-
return { ...parsed
|
|
88
|
+
return { ...parsed, nodeVersion: typeof extra.nodeVersion === 'string' ? extra.nodeVersion : null,
|
|
44
89
|
observedAt: typeof extra.observedAt === 'string' ? extra.observedAt : new Date(0).toISOString() };
|
|
45
90
|
}
|
|
91
|
+
const AGENT_STATUSES = new Set(['RUNNING', 'STOPPED', 'ERROR']);
|
|
92
|
+
const priorStatus = (metadata) => {
|
|
93
|
+
const value = metadata['agentStatusBeforeUpgrade'];
|
|
94
|
+
return typeof value === 'string' && AGENT_STATUSES.has(value) ? value : 'RUNNING';
|
|
95
|
+
};
|
|
96
|
+
/** End UPDATING (node went offline, or the agent stopped reporting an upgrade) and restore the status it had before. */
|
|
97
|
+
export async function clearAgentUpdating(nodeId) {
|
|
98
|
+
const db = getDb();
|
|
99
|
+
const agent = await db.agent.findUnique({ where: { nodeId }, select: { status: true } });
|
|
100
|
+
if (agent?.status !== 'UPDATING')
|
|
101
|
+
return;
|
|
102
|
+
const row = await db.node.findUnique({ where: { id: nodeId }, select: { metadata: true } });
|
|
103
|
+
const metadata = parseMetadata(row?.metadata);
|
|
104
|
+
await db.agent.updateMany({ where: { nodeId, status: 'UPDATING' }, data: { status: priorStatus(metadata) } });
|
|
105
|
+
if ('agentStatusBeforeUpgrade' in metadata) {
|
|
106
|
+
delete metadata['agentStatusBeforeUpgrade'];
|
|
107
|
+
await db.node.update({ where: { id: nodeId }, data: { metadata: JSON.stringify(metadata) } });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
46
110
|
/**
|
|
47
111
|
* Store the self-upgrade report from a heartbeat, keep Agent.status in step
|
|
48
|
-
* (UPDATING while a version is staged or on trial
|
|
112
|
+
* (UPDATING while a version is staged or on trial, then the status it had
|
|
113
|
+
* before), audit each confirmation and revert once, and record outcomes that
|
|
114
|
+
* arrive after their run stopped watching.
|
|
49
115
|
*/
|
|
50
|
-
export async function recordSelfUpgradeReport(node, body) {
|
|
51
|
-
const parsed =
|
|
52
|
-
if (!parsed
|
|
116
|
+
export async function recordSelfUpgradeReport(node, body, store = defaultRunStore()) {
|
|
117
|
+
const parsed = parseSelfUpgradeStatus(body.selfUpgrade, node.name);
|
|
118
|
+
if (!parsed) {
|
|
119
|
+
await clearAgentUpdating(node.id);
|
|
53
120
|
return;
|
|
121
|
+
}
|
|
54
122
|
const db = getDb();
|
|
55
123
|
const row = await db.node.findUnique({ where: { id: node.id }, select: { metadata: true } });
|
|
56
124
|
const metadata = parseMetadata(row?.metadata);
|
|
57
125
|
const previous = storedSelfUpgrade(row?.metadata);
|
|
58
126
|
const nodeVersion = typeof body.nodeVersion === 'string' ? body.nodeVersion.slice(0, 32) : null;
|
|
59
|
-
const next = { ...parsed
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
const state = parsed.data.upgrade?.state;
|
|
127
|
+
const next = { ...parsed, nodeVersion, observedAt: new Date().toISOString() };
|
|
128
|
+
const nextMetadata = { ...metadata, agentSelfUpgrade: next, nodeVersion };
|
|
129
|
+
let changed = !(previous && JSON.stringify({ ...previous, observedAt: '' }) === JSON.stringify({ ...next, observedAt: '' })
|
|
130
|
+
&& Date.now() - Date.parse(previous.observedAt) < 10 * 60_000);
|
|
131
|
+
const state = parsed.upgrade?.state;
|
|
66
132
|
const updating = state === 'staged' || state === 'trial';
|
|
67
|
-
await db.agent.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
details: { node: node.name, from: upgrade.from, to: upgrade.to, requestId: upgrade.requestId, result: upgrade.state, reason: upgrade.reason ?? null },
|
|
78
|
-
}).catch(() => undefined);
|
|
133
|
+
const agent = await db.agent.findUnique({ where: { nodeId: node.id }, select: { status: true } });
|
|
134
|
+
if (agent && updating && agent.status !== 'UPDATING') {
|
|
135
|
+
nextMetadata['agentStatusBeforeUpgrade'] = agent.status;
|
|
136
|
+
changed = true;
|
|
137
|
+
await db.agent.updateMany({ where: { nodeId: node.id, status: { not: 'UPDATING' } }, data: { status: 'UPDATING' } });
|
|
138
|
+
}
|
|
139
|
+
else if (agent && !updating && agent.status === 'UPDATING') {
|
|
140
|
+
delete nextMetadata['agentStatusBeforeUpgrade'];
|
|
141
|
+
changed = true;
|
|
142
|
+
await db.agent.updateMany({ where: { nodeId: node.id, status: 'UPDATING' }, data: { status: priorStatus(metadata) } });
|
|
79
143
|
}
|
|
144
|
+
if (changed)
|
|
145
|
+
await db.node.update({ where: { id: node.id }, data: { metadata: JSON.stringify(nextMetadata) } });
|
|
146
|
+
const upgrade = parsed.upgrade;
|
|
147
|
+
if (upgrade && (upgrade.state === 'reverted' || upgrade.state === 'confirmed' || upgrade.state === 'failed') && upgrade.requestId
|
|
148
|
+
&& !(previous?.upgrade?.requestId === upgrade.requestId && previous.upgrade.state === upgrade.state)) {
|
|
149
|
+
const runId = await store.runForRequest(upgrade.requestId).catch(() => null);
|
|
150
|
+
if (upgrade.state !== 'failed') {
|
|
151
|
+
await logAuditEvent({
|
|
152
|
+
action: upgrade.state === 'reverted' ? 'agent.upgrade.node_reverted' : 'agent.upgrade.node_confirmed',
|
|
153
|
+
actorId: 'system:agent-upgrade', actorType: 'system', targetType: 'node', targetId: node.id,
|
|
154
|
+
details: { node: node.name, from: upgrade.from, to: upgrade.to, requestId: upgrade.requestId, runId, result: upgrade.state, reason: upgrade.reason ?? null },
|
|
155
|
+
}).catch(() => undefined);
|
|
156
|
+
}
|
|
157
|
+
if (runId)
|
|
158
|
+
await recordLateOutcome(store, runId, upgrade.requestId, upgrade.state, upgrade.reason ?? null).catch(() => undefined);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/** A report for a node whose run already ended without knowing its outcome. */
|
|
162
|
+
async function recordLateOutcome(store, runId, requestId, state, reason) {
|
|
163
|
+
const run = await store.get(runId);
|
|
164
|
+
if (!run || run.status === 'running')
|
|
165
|
+
return; // the owner is watching and records it itself
|
|
166
|
+
const entry = run.nodes.find(node => node.requestId === requestId);
|
|
167
|
+
if (!entry || !(entry.awaitingReport || entry.state === 'requested'))
|
|
168
|
+
return;
|
|
169
|
+
entry.state = state;
|
|
170
|
+
entry.code = state === 'confirmed' ? null : entry.code;
|
|
171
|
+
entry.detail = `${state === 'confirmed' ? 'confirmed' : state === 'reverted' ? `reverted: ${reason ?? 'the agent switched back'}` : `failed: ${reason ?? 'the agent reported a failure'}`} (reported after the run ended)`;
|
|
172
|
+
entry.awaitingReport = false;
|
|
173
|
+
run.summary = summarizeRun(run);
|
|
174
|
+
await store.save(run);
|
|
80
175
|
}
|
|
176
|
+
export const isPrerelease = (version) => (parseVersion(version)?.prerelease.length ?? 0) > 0;
|
|
81
177
|
/** The exact version (default: the one this server ships) and its npm integrity. */
|
|
82
178
|
export async function resolveAgentRelease(options = {}) {
|
|
83
179
|
const version = options.version ?? AGENT_VERSION;
|
|
@@ -92,7 +188,7 @@ export async function resolveAgentRelease(options = {}) {
|
|
|
92
188
|
const url = `${registry}/${AGENT_PACKAGE.replace('/', '%2f')}/${version}`;
|
|
93
189
|
let response;
|
|
94
190
|
try {
|
|
95
|
-
response = await (options.fetchImpl ?? fetch)(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(15_000) });
|
|
191
|
+
response = await (options.fetchImpl ?? fetch)(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(options.timeoutMs ?? 15_000) });
|
|
96
192
|
}
|
|
97
193
|
catch (error) {
|
|
98
194
|
throw new HttpError(502, 'REGISTRY_UNREACHABLE', `Cannot read ${AGENT_PACKAGE}@${version} from ${registry}: ${error instanceof Error ? error.message : String(error)}. Pass the integrity explicitly for offline installs.`);
|
|
@@ -107,14 +203,34 @@ export async function resolveAgentRelease(options = {}) {
|
|
|
107
203
|
throw new HttpError(502, 'REGISTRY_ERROR', `${AGENT_PACKAGE}@${version} has no sha512 integrity`);
|
|
108
204
|
return { version, integrity };
|
|
109
205
|
}
|
|
110
|
-
|
|
111
|
-
|
|
206
|
+
const verifications = new Map();
|
|
207
|
+
/** Whether the registry has `version` (cached ten minutes); eligibility shows unpublished targets as unverified. */
|
|
208
|
+
export async function verifyAgentRelease(version, fetchImpl) {
|
|
209
|
+
const cached = verifications.get(version);
|
|
210
|
+
if (cached && Date.now() - cached.at < 10 * 60_000)
|
|
211
|
+
return cached.value;
|
|
212
|
+
let value;
|
|
213
|
+
try {
|
|
214
|
+
const release = await resolveAgentRelease({ version, fetchImpl, timeoutMs: 5_000 });
|
|
215
|
+
value = { verified: true, integrity: release.integrity, reason: null };
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
value = { verified: false, integrity: null, reason: error instanceof Error ? error.message : String(error) };
|
|
219
|
+
}
|
|
220
|
+
verifications.set(version, { at: Date.now(), value });
|
|
221
|
+
return value;
|
|
222
|
+
}
|
|
223
|
+
const day = (value) => value ? new Date(value).toISOString().slice(0, 10) : 'an unknown date';
|
|
224
|
+
const ENROLLMENT_DOC = 'docs/COMMAND_RESOURCE_LEASES.md';
|
|
225
|
+
/** Pure, for tests: why one node can or cannot take `target` now. Mirrors the dispatch preconditions. */
|
|
226
|
+
export function classifyCandidate(row, target, options = {}) {
|
|
227
|
+
const now = options.now ?? Date.now();
|
|
112
228
|
const version = row.agent?.version ?? 'unknown';
|
|
113
229
|
const report = storedSelfUpgrade(row.metadata);
|
|
114
230
|
const base = { nodeId: row.id, name: row.name, version, upgrade: report?.upgrade ?? null, layout: report?.layout ?? null };
|
|
115
231
|
const no = (reason) => ({ ...base, eligible: false, reason });
|
|
116
|
-
if (
|
|
117
|
-
return no(
|
|
232
|
+
if (!hasFreshOnlineHealth(row, now))
|
|
233
|
+
return no(`offline or no recent heartbeat${row.lastHeartbeatAt ? ` (last ${row.lastHeartbeatAt.toISOString()})` : ''}`);
|
|
118
234
|
if (!isVersion(version))
|
|
119
235
|
return no('agent version unknown');
|
|
120
236
|
if (compareVersions(version, target) === 0)
|
|
@@ -128,166 +244,510 @@ export function classifyCandidate(row, target, now = Date.now()) {
|
|
|
128
244
|
return no('agent self-upgrade protocol too old');
|
|
129
245
|
if (!report.enabled)
|
|
130
246
|
return no(report.reason ?? 'self-upgrade disabled on this node');
|
|
131
|
-
|
|
132
|
-
|
|
247
|
+
const upgrade = report.upgrade;
|
|
248
|
+
if (upgrade && (upgrade.state === 'staged' || upgrade.state === 'trial'))
|
|
249
|
+
return no(`an upgrade to ${upgrade.to} is in progress`);
|
|
250
|
+
if (upgrade?.state === 'reverted' && upgrade.to === target && !options.allowRetryReverted)
|
|
251
|
+
return no(`reverted ${target} at ${upgrade.updatedAt ?? report.observedAt}: ${upgrade.reason ?? 'no reason given'}; try again only with allowRetryReverted`);
|
|
252
|
+
if (row.admissionFence)
|
|
253
|
+
return no(`release ${row.admissionFence.releaseId} closed admission to this node at ${row.admissionFence.closedAt.toISOString()}; release the fence first`);
|
|
254
|
+
const enrollment = row.resourceEnrollment ?? null;
|
|
255
|
+
const hostname = row.hostname ?? '';
|
|
256
|
+
const marker = report.managedEnrollment;
|
|
257
|
+
if (marker && (!enrollmentIsComplete(enrollment, marker.nodeFqdn) || marker.nodeId !== row.id || marker.nodeFqdn !== hostname)) {
|
|
258
|
+
const server = enrollment ? `a ${enrollment.state} enrollment for ${enrollment.nodeFqdn}` : 'no enrollment row';
|
|
259
|
+
const cause = stickyEnrollmentCause(enrollment, { lease: Boolean(row.leaseHistory), managedCron: Boolean(row.managedCronHistory),
|
|
260
|
+
participantWork: Boolean(row.participantHistory) });
|
|
261
|
+
return no(`agent and server disagree about resource enrollment: the agent holds a managed-enrollment marker for ${marker.nodeFqdn} but the server has ${server}`
|
|
262
|
+
+ `${cause ? ` (the server answers this agent as enrolled because of ${cause})` : ''}; `
|
|
263
|
+
+ `complete the node's resource enrollment on the server (${ENROLLMENT_DOC}), or upgrade this agent by hand on the node`);
|
|
264
|
+
}
|
|
265
|
+
// Same rule as admission: off the legacy path once leases are on, enrollment started, or any lease exists.
|
|
266
|
+
const managed = !legacyAdmissionPossible(enrollment, options.leasesRequested ?? resourceLeasesRequested()) || Boolean(row.leaseHistory);
|
|
267
|
+
if (managed) {
|
|
268
|
+
if (!enrollmentIsComplete(enrollment, hostname)) {
|
|
269
|
+
if (!enrollment)
|
|
270
|
+
return no(`resource leases apply to this node (${row.leaseHistory ? 'it has lease history' : 'MAGNUS_RESOURCE_LEASES_ENABLED=true'}) but it is not enrolled; enroll it first (${ENROLLMENT_DOC})`);
|
|
271
|
+
if (enrollment.state === 'draining')
|
|
272
|
+
return no(`resource enrollment stuck in draining since ${day(enrollment.startedAt ?? enrollment.updatedAt)}; finish the drain (POST /api/v1/resource-leases/enrollment/${row.id}/complete) first`);
|
|
273
|
+
if (enrollment.state === 'enrolled')
|
|
274
|
+
return no(`resource enrollment is for ${enrollment.nodeFqdn} but the inventory host name is ${hostname || 'unknown'}; fix the inventory host name first`);
|
|
275
|
+
return no(`resource enrollment is ${enrollment.state} while resource leases apply to this node; enroll it first (${ENROLLMENT_DOC})`);
|
|
276
|
+
}
|
|
277
|
+
if (row.heldWork)
|
|
278
|
+
return no(`${row.heldWork} participant work item(s) still held on this node; reconcile them first`);
|
|
279
|
+
if (row.heldLeases)
|
|
280
|
+
return no(`${row.heldLeases} command lease(s) unsettled on this node (reserved, running or unknown); reconcile them first`);
|
|
281
|
+
}
|
|
133
282
|
return { ...base, eligible: true, reason: null };
|
|
134
283
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
284
|
+
/** Candidates for `target`; `nodeIds: []` selects nothing (never the whole fleet). */
|
|
285
|
+
export async function listUpgradeCandidates(target, nodeIds, options = {}) {
|
|
286
|
+
if (nodeIds && nodeIds.length === 0)
|
|
287
|
+
return [];
|
|
288
|
+
const db = getDb();
|
|
289
|
+
const rows = await db.node.findMany({
|
|
290
|
+
where: { id: { notIn: SENTINEL_NODE_IDS, ...(nodeIds ? { in: [...nodeIds] } : {}) }, status: { not: 'DECOMMISSIONED' } },
|
|
291
|
+
select: { id: true, name: true, hostname: true, status: true, lastHeartbeatAt: true, metadata: true, agent: { select: { version: true } },
|
|
292
|
+
resourceEnrollment: { select: { state: true, nodeFqdn: true, startedAt: true, updatedAt: true } },
|
|
293
|
+
admissionFence: { select: { releaseId: true, closedAt: true } } },
|
|
139
294
|
orderBy: { name: 'asc' },
|
|
140
295
|
});
|
|
141
|
-
|
|
296
|
+
const ids = rows.map(row => row.id);
|
|
297
|
+
const [leases, work, crons] = ids.length ? await Promise.all([
|
|
298
|
+
db.commandResourceLease.groupBy({ by: ['nodeId', 'state'], where: { nodeId: { in: ids } }, _count: { _all: true } }),
|
|
299
|
+
db.resourceParticipantWork.groupBy({ by: ['nodeId', 'state'], where: { nodeId: { in: ids } }, _count: { _all: true } }),
|
|
300
|
+
db.managedCronDefinition.groupBy({ by: ['nodeId'], where: { nodeId: { in: ids } }, _count: { _all: true } }),
|
|
301
|
+
]) : [[], [], []];
|
|
302
|
+
const held = (items, nodeId) => items.filter(item => item.nodeId === nodeId && HELD_RESOURCE_STATES.includes(item.state)).reduce((sum, item) => sum + item._count._all, 0);
|
|
303
|
+
return rows.map(row => classifyCandidate({ ...row,
|
|
304
|
+
leaseHistory: leases.some(lease => lease.nodeId === row.id),
|
|
305
|
+
heldLeases: held(leases, row.id),
|
|
306
|
+
heldWork: held(work, row.id),
|
|
307
|
+
participantHistory: work.some(item => item.nodeId === row.id),
|
|
308
|
+
managedCronHistory: crons.some(item => item.nodeId === row.id),
|
|
309
|
+
}, target, options));
|
|
142
310
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
runs
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
311
|
+
/** In-process store: tests, and the fallback without Redis. Keeps the newest MAX_LOCAL_RUNS runs. */
|
|
312
|
+
export function memoryRunStore() {
|
|
313
|
+
const runs = new Map();
|
|
314
|
+
const stops = new Map();
|
|
315
|
+
const requests = new Map();
|
|
316
|
+
let held = null;
|
|
317
|
+
let latestId = null;
|
|
318
|
+
return {
|
|
319
|
+
async save(run) {
|
|
320
|
+
runs.delete(run.id);
|
|
321
|
+
runs.set(run.id, run);
|
|
322
|
+
latestId = run.id;
|
|
323
|
+
for (const [id, stored] of runs) {
|
|
324
|
+
if (runs.size <= MAX_LOCAL_RUNS)
|
|
325
|
+
break;
|
|
326
|
+
if (stored.status !== 'running') {
|
|
327
|
+
runs.delete(id);
|
|
328
|
+
stops.delete(id);
|
|
329
|
+
for (const node of stored.nodes)
|
|
330
|
+
requests.delete(node.requestId);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
async get(id) { return runs.get(id) ?? null; },
|
|
335
|
+
async latest() { return latestId ? runs.get(latestId) ?? null : null; },
|
|
336
|
+
async lock(token) {
|
|
337
|
+
if (held && held.until > Date.now() && held.token !== token)
|
|
338
|
+
return false;
|
|
339
|
+
held = { token, until: Date.now() + LEASE_MS };
|
|
340
|
+
return true;
|
|
341
|
+
},
|
|
342
|
+
async renew(token) {
|
|
343
|
+
if (held?.token !== token)
|
|
344
|
+
return false;
|
|
345
|
+
held.until = Date.now() + LEASE_MS;
|
|
346
|
+
return true;
|
|
347
|
+
},
|
|
348
|
+
async unlock(token) { if (held?.token === token)
|
|
349
|
+
held = null; },
|
|
350
|
+
async requestStop(id, actorId) { stops.set(id, actorId); },
|
|
351
|
+
async stopRequested(id) { return stops.get(id) ?? null; },
|
|
352
|
+
async index(run) { for (const node of run.nodes)
|
|
353
|
+
requests.set(node.requestId, run.id); },
|
|
354
|
+
async runForRequest(requestId) { return requests.get(requestId) ?? null; },
|
|
355
|
+
};
|
|
167
356
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
return
|
|
176
|
-
|
|
357
|
+
const LOCK_KEY = 'agent-upgrade-run:lock';
|
|
358
|
+
const RELEASE_IF_OWNED = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
|
|
359
|
+
const RENEW_IF_OWNED = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('pexpire', KEYS[1], ARGV[2]) else return 0 end";
|
|
360
|
+
let sharedStore = null;
|
|
361
|
+
/** Redis-backed store; every call falls back to this process's memory when Redis is unavailable. */
|
|
362
|
+
export function defaultRunStore() {
|
|
363
|
+
if (sharedStore)
|
|
364
|
+
return sharedStore;
|
|
365
|
+
const memory = memoryRunStore();
|
|
366
|
+
const redis = async () => (await import('../queue/redis.js')).getRedis();
|
|
367
|
+
const either = async (remote, local) => {
|
|
368
|
+
try {
|
|
369
|
+
return await remote();
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
return local();
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
sharedStore = {
|
|
376
|
+
async save(run) {
|
|
377
|
+
await memory.save(run);
|
|
378
|
+
await either(async () => {
|
|
379
|
+
const client = await redis();
|
|
380
|
+
await client.set(`agent-upgrade-run:${run.id}`, JSON.stringify(run), 'EX', RUN_TTL_SECONDS);
|
|
381
|
+
await client.set('agent-upgrade-run:latest', run.id, 'EX', RUN_TTL_SECONDS);
|
|
382
|
+
}, async () => undefined);
|
|
383
|
+
},
|
|
384
|
+
get: id => either(async () => {
|
|
385
|
+
const raw = await (await redis()).get(`agent-upgrade-run:${id}`);
|
|
386
|
+
return raw ? JSON.parse(raw) : memory.get(id);
|
|
387
|
+
}, () => memory.get(id)),
|
|
388
|
+
latest: () => either(async () => {
|
|
389
|
+
const id = await (await redis()).get('agent-upgrade-run:latest');
|
|
390
|
+
return id ? sharedStore.get(id) : memory.latest();
|
|
391
|
+
}, () => memory.latest()),
|
|
392
|
+
lock: token => either(async () => await (await redis()).set(LOCK_KEY, token, 'PX', LEASE_MS, 'NX') === 'OK', () => memory.lock(token)),
|
|
393
|
+
renew: token => either(async () => Number(await (await redis()).eval(RENEW_IF_OWNED, 1, LOCK_KEY, token, String(LEASE_MS))) === 1, () => memory.renew(token)),
|
|
394
|
+
unlock: token => either(async () => { await (await redis()).eval(RELEASE_IF_OWNED, 1, LOCK_KEY, token); }, () => memory.unlock(token)),
|
|
395
|
+
async requestStop(id, actorId) {
|
|
396
|
+
await memory.requestStop(id, actorId);
|
|
397
|
+
await either(async () => { await (await redis()).set(`agent-upgrade-run:${id}:stop`, actorId, 'EX', RUN_TTL_SECONDS); }, async () => undefined);
|
|
398
|
+
},
|
|
399
|
+
stopRequested: id => either(async () => await (await redis()).get(`agent-upgrade-run:${id}:stop`) ?? memory.stopRequested(id), () => memory.stopRequested(id)),
|
|
400
|
+
async index(run) {
|
|
401
|
+
await memory.index(run);
|
|
402
|
+
await either(async () => {
|
|
403
|
+
const pipeline = (await redis()).pipeline();
|
|
404
|
+
for (const node of run.nodes)
|
|
405
|
+
pipeline.set(`agent-upgrade-request:${node.requestId}`, run.id, 'EX', RUN_TTL_SECONDS);
|
|
406
|
+
await pipeline.exec();
|
|
407
|
+
}, async () => undefined);
|
|
408
|
+
},
|
|
409
|
+
runForRequest: requestId => either(async () => await (await redis()).get(`agent-upgrade-request:${requestId}`) ?? memory.runForRequest(requestId), () => memory.runForRequest(requestId)),
|
|
410
|
+
};
|
|
411
|
+
return sharedStore;
|
|
177
412
|
}
|
|
413
|
+
/** Runs this process is executing right now. */
|
|
414
|
+
const executing = new Map();
|
|
178
415
|
function clientFor(node) {
|
|
179
416
|
return new NodeApiClient({ baseUrl: getNodeBaseUrl(node.ipAddress, node.port), resourceNodeId: node.id, nodeToken: getNodeManagerSecret(),
|
|
180
|
-
timeout: 15 * 60_000 });
|
|
417
|
+
resourceActorId: 'system:agent-upgrade', timeout: 15 * 60_000 });
|
|
418
|
+
}
|
|
419
|
+
/** Trial window the agent reported (or the default), two heartbeats, and the restart margin. */
|
|
420
|
+
export function confirmWindowMs(report, fallbackMs = DEFAULT_CONFIRM_SECONDS * 1000 + 2 * HEARTBEAT_MS + CONFIRM_MARGIN_MS) {
|
|
421
|
+
return report?.confirmSeconds ? report.confirmSeconds * 1000 + 2 * HEARTBEAT_MS + CONFIRM_MARGIN_MS : fallbackMs;
|
|
181
422
|
}
|
|
182
|
-
export function defaultDeps() {
|
|
423
|
+
export function defaultDeps(store = defaultRunStore()) {
|
|
183
424
|
return {
|
|
184
425
|
request: async (nodeId, body) => {
|
|
185
|
-
const node = await getDb().node.
|
|
426
|
+
const node = await getDb().node.findUnique({ where: { id: nodeId }, select: { id: true, ipAddress: true, port: true } });
|
|
427
|
+
if (!node)
|
|
428
|
+
throw new NotDispatchedError('NODE_NOT_FOUND', 'node is no longer in the inventory');
|
|
186
429
|
await clientFor(node).upgradeAgent(body);
|
|
187
430
|
},
|
|
188
431
|
observe: async (nodeId) => {
|
|
189
|
-
const row = await getDb().node.findUnique({ where: { id: nodeId }, select: { metadata: true, agent: { select: { version: true } } } });
|
|
190
|
-
return { version: row?.agent?.version ?? null, report: storedSelfUpgrade(row?.metadata) };
|
|
432
|
+
const row = await getDb().node.findUnique({ where: { id: nodeId }, select: { metadata: true, lastHeartbeatAt: true, agent: { select: { version: true } } } });
|
|
433
|
+
return { version: row?.agent?.version ?? null, report: storedSelfUpgrade(row?.metadata), lastHeartbeatAt: row?.lastHeartbeatAt ?? null };
|
|
434
|
+
},
|
|
435
|
+
check: async (nodeId, run) => {
|
|
436
|
+
const [candidate] = await listUpgradeCandidates(run.version, [nodeId], { allowRetryReverted: run.allowRetryReverted });
|
|
437
|
+
return candidate ? candidate.eligible ? null : candidate.reason : 'node is no longer in the inventory';
|
|
191
438
|
},
|
|
192
439
|
sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
|
|
193
440
|
now: () => Date.now(),
|
|
194
|
-
save:
|
|
195
|
-
|
|
441
|
+
save: run => store.save(run),
|
|
442
|
+
stopRequested: async (runId) => await store.stopRequested(runId).catch(() => null),
|
|
443
|
+
confirmTimeoutMs: confirmWindowMs(null),
|
|
196
444
|
pollMs: POLL_MS,
|
|
197
445
|
};
|
|
198
446
|
}
|
|
447
|
+
/** Whether a failed request left the node untouched (refused), hit a release problem (failed), or may have run (unknown). */
|
|
448
|
+
export function classifyDispatchFailure(error) {
|
|
449
|
+
const message = (error instanceof AgentResponseError ? error.agentMessage ?? error.message : error instanceof Error ? error.message : String(error)).slice(0, 1000);
|
|
450
|
+
const code = error?.code;
|
|
451
|
+
if (error instanceof NotDispatchedError || error?.dispatched === false)
|
|
452
|
+
return { kind: 'refused', code: typeof code === 'string' ? code : 'NOT_DISPATCHED', message };
|
|
453
|
+
if (error instanceof AgentResponseError) {
|
|
454
|
+
const agentCode = error.code ?? `HTTP_${error.agentStatus}`;
|
|
455
|
+
if (agentCode === 'MUTATION_OUTCOME_UNKNOWN')
|
|
456
|
+
return { kind: 'unknown', code: agentCode, message };
|
|
457
|
+
if (UNTOUCHED_CODES.has(agentCode))
|
|
458
|
+
return { kind: 'refused', code: agentCode, message };
|
|
459
|
+
return { kind: 'failed', code: agentCode, message: RELEASE_PROBLEM_CODES.has(agentCode) ? message : `agent answered HTTP ${error.agentStatus}: ${message}` };
|
|
460
|
+
}
|
|
461
|
+
if (error instanceof DispatchOutcomeUnknownError)
|
|
462
|
+
return { kind: 'unknown', code: error.code, message };
|
|
463
|
+
return { kind: 'unknown', code: typeof code === 'string' ? code : 'MUTATION_OUTCOME_UNKNOWN', message };
|
|
464
|
+
}
|
|
465
|
+
const audit = (action, run, details, targetType = 'fleet', targetId = run.id) => logAuditEvent({ action, actorId: run.actorId, targetType, targetId, details: { runId: run.id, version: run.version, ...details } }).catch(() => undefined);
|
|
199
466
|
/** Ask one node to upgrade and wait for its confirmed (or reverted) report. */
|
|
200
467
|
async function upgradeOne(run, entry, deps) {
|
|
468
|
+
const iso = () => new Date(deps.now()).toISOString();
|
|
469
|
+
const end = (state, detail, code = null, awaitingReport = false) => {
|
|
470
|
+
Object.assign(entry, { state, detail: detail?.slice(0, 1000) ?? null, code, finishedAt: iso(), awaitingReport });
|
|
471
|
+
if (state === 'refused' || state === 'failed' || (state === 'confirmed' && detail?.startsWith('confirmed by version')))
|
|
472
|
+
void audit(`agent.upgrade.node_${state}`, run, { node: entry.name, requestId: entry.requestId, code, detail }, 'node', entry.nodeId);
|
|
473
|
+
};
|
|
474
|
+
let reason;
|
|
475
|
+
try {
|
|
476
|
+
reason = await deps.check(entry.nodeId, run);
|
|
477
|
+
}
|
|
478
|
+
catch (error) {
|
|
479
|
+
reason = `eligibility check failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
480
|
+
}
|
|
481
|
+
if (reason)
|
|
482
|
+
return void Object.assign(entry, { state: 'skipped', detail: reason, finishedAt: iso() });
|
|
201
483
|
entry.state = 'requested';
|
|
202
484
|
await deps.save(run);
|
|
485
|
+
const startedAt = deps.now();
|
|
486
|
+
let unknown = null;
|
|
203
487
|
try {
|
|
204
488
|
await deps.request(entry.nodeId, { version: run.version, integrity: run.integrity, requestId: entry.requestId, packageName: AGENT_PACKAGE });
|
|
205
489
|
}
|
|
206
490
|
catch (error) {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
491
|
+
const failure = classifyDispatchFailure(error);
|
|
492
|
+
if (failure.kind === 'refused')
|
|
493
|
+
return end('refused', failure.message, failure.code);
|
|
494
|
+
if (failure.kind === 'failed')
|
|
495
|
+
return end('failed', failure.message, failure.code);
|
|
496
|
+
unknown = failure;
|
|
210
497
|
}
|
|
211
|
-
|
|
212
|
-
|
|
498
|
+
entry.dispatchedAt = iso();
|
|
499
|
+
let windowMs = deps.confirmTimeoutMs;
|
|
500
|
+
let versionSince = null;
|
|
501
|
+
let lastHeartbeat = null;
|
|
502
|
+
const deadline = () => Math.max(startedAt + windowMs + (unknown ? UNKNOWN_DISPATCH_MARGIN_MS : 0), versionSince === null ? 0 : versionSince + windowMs + deps.pollMs);
|
|
503
|
+
while (deps.now() < deadline()) {
|
|
213
504
|
await deps.sleep(deps.pollMs);
|
|
214
|
-
|
|
505
|
+
let observation;
|
|
506
|
+
try {
|
|
507
|
+
observation = await deps.observe(entry.nodeId);
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
console.warn(`[agent-upgrade] could not read the state of ${entry.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
const { version, report } = observation;
|
|
514
|
+
lastHeartbeat = observation.lastHeartbeatAt ?? lastHeartbeat;
|
|
515
|
+
if (report?.confirmSeconds)
|
|
516
|
+
windowMs = confirmWindowMs(report);
|
|
215
517
|
const upgrade = report?.upgrade;
|
|
216
|
-
if (upgrade?.requestId === entry.requestId
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
518
|
+
if (upgrade?.requestId === entry.requestId) {
|
|
519
|
+
if (upgrade.state === 'confirmed')
|
|
520
|
+
return end('confirmed', unknown ? 'confirmed (the reply to the request was lost)' : null);
|
|
521
|
+
if (upgrade.state === 'reverted')
|
|
522
|
+
return end('reverted', upgrade.reason ?? 'the agent switched back to its previous version', 'SELF_UPGRADE_REVERTED');
|
|
523
|
+
if (upgrade.state === 'failed')
|
|
524
|
+
return end('failed', upgrade.reason ?? 'the agent reported the upgrade as failed', 'SELF_UPGRADE_FAILED');
|
|
525
|
+
}
|
|
526
|
+
else if (!upgrade?.requestId && version === run.version) {
|
|
527
|
+
// Agents that do not report request ids: the version has to hold for one full trial window.
|
|
528
|
+
versionSince ??= deps.now();
|
|
529
|
+
if (deps.now() - versionSince >= windowMs)
|
|
530
|
+
return end('confirmed', 'confirmed by version (the agent does not report upgrade requests)');
|
|
220
531
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
entry.detail = null;
|
|
224
|
-
return;
|
|
532
|
+
else {
|
|
533
|
+
versionSince = null;
|
|
225
534
|
}
|
|
535
|
+
// Silent for longer than the agent's own trial window: it will not confirm, even if the extended deadline is not up.
|
|
536
|
+
if (lastHeartbeat && deps.now() - lastHeartbeat.getTime() > windowMs)
|
|
537
|
+
return end('failed', `no heartbeat since ${lastHeartbeat.toISOString()}`, 'AGENT_SILENT', true);
|
|
226
538
|
}
|
|
227
|
-
|
|
228
|
-
|
|
539
|
+
const minutes = Math.round((deadline() - startedAt) / 60_000);
|
|
540
|
+
if (unknown)
|
|
541
|
+
return end('failed', `outcome unknown (${unknown.message}); no report for this request within ${minutes} min`, unknown.code, true);
|
|
542
|
+
end('failed', `no confirmation within ${minutes} min`, 'CONFIRM_TIMEOUT', true);
|
|
543
|
+
}
|
|
544
|
+
/** "N confirmed, N refused, N failed, N skipped" and each refusal reason. */
|
|
545
|
+
export function summarizeRun(run) {
|
|
546
|
+
const count = (state) => run.nodes.filter(node => node.state === state).length;
|
|
547
|
+
const reverted = count('reverted');
|
|
548
|
+
const refused = run.nodes.filter(node => node.state === 'refused');
|
|
549
|
+
return `${count('confirmed')} confirmed, ${refused.length} refused, ${count('failed')} failed, ${reverted ? `${reverted} reverted, ` : ''}${count('skipped')} skipped`
|
|
550
|
+
+ (refused.length ? `; refused: ${refused.map(node => `${node.name} (${node.code ? `${node.code}: ` : ''}${node.detail ?? 'no reason given'})`).join('; ')}` : '');
|
|
229
551
|
}
|
|
230
|
-
|
|
552
|
+
const halts = (node) => node.state === 'failed' || node.state === 'reverted';
|
|
553
|
+
/**
|
|
554
|
+
* Canary first, then batches of `concurrency`. A refused canary hands the
|
|
555
|
+
* role to the next node. Stops at the first revert or failure, or between
|
|
556
|
+
* batches when someone asked to stop.
|
|
557
|
+
*/
|
|
231
558
|
export async function executeUpgradeRun(run, deps) {
|
|
232
|
-
const queue = run.nodes.filter(node => node.state === 'pending');
|
|
233
|
-
const [canary, ...rest] = queue;
|
|
234
559
|
const finish = async (status, message) => {
|
|
235
560
|
for (const node of run.nodes)
|
|
236
561
|
if (node.state === 'pending')
|
|
237
|
-
node
|
|
562
|
+
Object.assign(node, { state: 'skipped', detail: status === 'stopped' ? 'run stopped' : 'not reached' });
|
|
238
563
|
run.status = status;
|
|
239
|
-
run.
|
|
564
|
+
run.summary = summarizeRun(run);
|
|
565
|
+
run.message = `${message} (${run.summary})`;
|
|
240
566
|
run.finishedAt = new Date(deps.now()).toISOString();
|
|
241
567
|
await deps.save(run);
|
|
568
|
+
await audit(status === 'stopped' ? 'agent.upgrade_run.stopped' : 'agent.upgrade_run.finished', run, { status, summary: run.summary,
|
|
569
|
+
stoppedBy: run.stopRequestedBy ?? null });
|
|
242
570
|
return run;
|
|
243
571
|
};
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
return
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
572
|
+
const pending = () => run.nodes.filter(node => node.state === 'pending');
|
|
573
|
+
const stopped = async () => {
|
|
574
|
+
const actor = await deps.stopRequested(run.id).catch(() => null);
|
|
575
|
+
if (actor)
|
|
576
|
+
run.stopRequestedBy = actor;
|
|
577
|
+
return actor;
|
|
578
|
+
};
|
|
579
|
+
try {
|
|
580
|
+
let canary;
|
|
581
|
+
while ((canary = pending()[0])) {
|
|
582
|
+
if (await stopped())
|
|
583
|
+
return finish('stopped', `stopped by ${run.stopRequestedBy}`);
|
|
584
|
+
run.canaryNodeId = canary.nodeId;
|
|
585
|
+
await upgradeOne(run, canary, deps);
|
|
586
|
+
await deps.save(run);
|
|
587
|
+
if (canary.state === 'confirmed')
|
|
588
|
+
break;
|
|
589
|
+
if (halts(canary))
|
|
590
|
+
return finish('failed', `canary ${canary.name} did not confirm (${canary.state}: ${canary.detail ?? ''}); the other nodes were not touched`);
|
|
591
|
+
}
|
|
592
|
+
for (let batch = pending().slice(0, run.concurrency); batch.length; batch = pending().slice(0, run.concurrency)) {
|
|
593
|
+
if (await stopped())
|
|
594
|
+
return finish('stopped', `stopped by ${run.stopRequestedBy}`);
|
|
595
|
+
await Promise.all(batch.map(node => upgradeOne(run, node, deps)));
|
|
596
|
+
await deps.save(run);
|
|
597
|
+
const bad = batch.find(halts);
|
|
598
|
+
if (bad)
|
|
599
|
+
return finish('failed', `${bad.name} did not confirm (${bad.state}: ${bad.detail ?? ''}); remaining nodes were skipped`);
|
|
600
|
+
}
|
|
601
|
+
const confirmed = run.nodes.filter(node => node.state === 'confirmed').length;
|
|
602
|
+
if (!run.nodes.length)
|
|
603
|
+
return finish('succeeded', 'no eligible nodes');
|
|
604
|
+
return confirmed ? finish('succeeded', `${confirmed} agent(s) upgraded to ${run.version}`) : finish('failed', 'no agent was upgraded');
|
|
605
|
+
}
|
|
606
|
+
catch (error) {
|
|
607
|
+
return finish('failed', `run aborted: ${error instanceof Error ? error.message : String(error)}`);
|
|
257
608
|
}
|
|
258
|
-
return finish('succeeded', `${run.nodes.filter(node => node.state === 'confirmed').length} agent(s) upgraded to ${run.version}`);
|
|
259
609
|
}
|
|
260
|
-
|
|
610
|
+
// ── Interrupted runs ──
|
|
611
|
+
const leaseExpired = (run, now = Date.now()) => run.status === 'running' && !executing.has(run.id)
|
|
612
|
+
&& (run.leaseExpiresAt ? Date.parse(run.leaseExpiresAt) : Date.parse(run.startedAt) + UNLEASED_RUN_MS) < now;
|
|
613
|
+
/**
|
|
614
|
+
* The owner stopped renewing (server restart, CLI interrupted): settle the
|
|
615
|
+
* requested nodes from their stored heartbeat reports, skip the pending ones
|
|
616
|
+
* and mark the run `interrupted`. Unresolved nodes keep `awaitingReport`.
|
|
617
|
+
*/
|
|
618
|
+
export async function reconcileInterruptedRun(run, observe, save, now = Date.now()) {
|
|
619
|
+
if (!leaseExpired(run, now))
|
|
620
|
+
return run;
|
|
621
|
+
const at = new Date(now).toISOString();
|
|
622
|
+
for (const node of run.nodes) {
|
|
623
|
+
if (node.state === 'pending')
|
|
624
|
+
Object.assign(node, { state: 'skipped', detail: 'run interrupted before this node', finishedAt: at });
|
|
625
|
+
if (node.state !== 'requested')
|
|
626
|
+
continue;
|
|
627
|
+
const upgrade = await observe(node.nodeId).then(observation => observation.report?.upgrade, () => null);
|
|
628
|
+
const mine = upgrade?.requestId === node.requestId ? upgrade : null;
|
|
629
|
+
if (mine?.state === 'confirmed')
|
|
630
|
+
Object.assign(node, { state: 'confirmed', detail: 'confirmed (recorded after the run was interrupted)', code: null });
|
|
631
|
+
else if (mine?.state === 'reverted')
|
|
632
|
+
Object.assign(node, { state: 'reverted', detail: mine.reason ?? 'the agent switched back', code: 'SELF_UPGRADE_REVERTED' });
|
|
633
|
+
else if (mine?.state === 'failed')
|
|
634
|
+
Object.assign(node, { state: 'failed', detail: mine.reason ?? 'the agent reported a failure', code: 'SELF_UPGRADE_FAILED' });
|
|
635
|
+
else
|
|
636
|
+
Object.assign(node, { state: 'failed', code: 'RUN_INTERRUPTED', awaitingReport: true,
|
|
637
|
+
detail: `the server stopped watching${mine ? ` while the agent reported ${mine.state}` : ''}; a later report for this request is recorded here` });
|
|
638
|
+
node.finishedAt = at;
|
|
639
|
+
}
|
|
640
|
+
run.status = 'interrupted';
|
|
641
|
+
run.summary = summarizeRun(run);
|
|
642
|
+
run.message = `interrupted: the server that ran it stopped renewing its lease (restart or interrupted CLI) (${run.summary})`;
|
|
643
|
+
run.finishedAt = at;
|
|
644
|
+
await save(run);
|
|
645
|
+
await audit('agent.upgrade_run.finished', run, { status: 'interrupted', summary: run.summary });
|
|
646
|
+
return run;
|
|
647
|
+
}
|
|
648
|
+
export async function getUpgradeRun(id, store = defaultRunStore()) {
|
|
649
|
+
const run = executing.get(id)?.run ?? await store.get(id).catch(() => null);
|
|
650
|
+
return run ? reconcileInterruptedRun(run, defaultDeps(store).observe, saved => store.save(saved)) : null;
|
|
651
|
+
}
|
|
652
|
+
export async function latestUpgradeRun(store = defaultRunStore()) {
|
|
653
|
+
const run = await store.latest().catch(() => null);
|
|
654
|
+
return run ? getUpgradeRun(run.id, store) : null;
|
|
655
|
+
}
|
|
656
|
+
/** Validate, take the fleet-wide run lock, create the run and start it in the background. */
|
|
261
657
|
export async function startUpgradeRun(options) {
|
|
262
658
|
const settings = upgradeSettings();
|
|
263
659
|
if (!settings.agentUpgrades)
|
|
264
660
|
throw new HttpError(403, 'AGENT_UPGRADES_DISABLED', 'Server-driven agent upgrades are off (MAGNUS_AGENT_UPGRADES=false)');
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
if (
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const
|
|
273
|
-
if (
|
|
274
|
-
throw new HttpError(409, 'AGENT_UPGRADE_RUNNING',
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
661
|
+
const version = options.version ?? AGENT_VERSION;
|
|
662
|
+
if (isVersion(version) && isPrerelease(version) && !options.allowPrerelease)
|
|
663
|
+
throw new HttpError(400, 'PRERELEASE_NOT_ALLOWED', `${version} is a prerelease; pass allowPrerelease to install it`);
|
|
664
|
+
if (options.nodeIds && options.nodeIds.length === 0)
|
|
665
|
+
throw new HttpError(400, 'NO_NODES_SELECTED', 'nodeIds is empty; leave it out to upgrade every eligible agent');
|
|
666
|
+
const store = options.store ?? defaultRunStore();
|
|
667
|
+
const id = randomUUID();
|
|
668
|
+
const token = `${INSTANCE_ID}:${id}`;
|
|
669
|
+
if (!await store.lock(token))
|
|
670
|
+
throw new HttpError(409, 'AGENT_UPGRADE_RUNNING', 'Another agent upgrade run is starting or in progress');
|
|
671
|
+
try {
|
|
672
|
+
const running = await latestUpgradeRun(store);
|
|
673
|
+
if (running?.status === 'running')
|
|
674
|
+
throw new HttpError(409, 'AGENT_UPGRADE_RUNNING', `Upgrade run ${running.id} is still in progress`);
|
|
675
|
+
const release = await resolveAgentRelease({ version, integrity: options.integrity });
|
|
676
|
+
const candidates = await listUpgradeCandidates(release.version, options.nodeIds, { allowRetryReverted: options.allowRetryReverted });
|
|
677
|
+
const unknown = (options.nodeIds ?? []).filter(nodeId => !candidates.some(candidate => candidate.nodeId === nodeId));
|
|
678
|
+
if (unknown.length)
|
|
679
|
+
throw new HttpError(400, 'UNKNOWN_NODES', `Unknown or decommissioned node(s): ${unknown.slice(0, 20).join(', ')}`);
|
|
680
|
+
const eligible = candidates.filter(candidate => candidate.eligible);
|
|
681
|
+
if (options.canaryNodeId && !eligible.some(candidate => candidate.nodeId === options.canaryNodeId)) {
|
|
682
|
+
const canary = candidates.find(candidate => candidate.nodeId === options.canaryNodeId);
|
|
683
|
+
throw new HttpError(400, 'INVALID_CANARY', canary ? `Canary ${canary.name} cannot be upgraded now: ${canary.reason}` : 'canaryNodeId is not among the selected nodes');
|
|
684
|
+
}
|
|
685
|
+
if (eligible.length === 0) {
|
|
686
|
+
const reasons = candidates.slice(0, 5).map(candidate => `${candidate.name}: ${candidate.reason}`).join('; ');
|
|
687
|
+
throw new HttpError(409, 'NO_ELIGIBLE_AGENTS', `No selected agent can be upgraded now${reasons ? ` (${reasons})` : ''}`);
|
|
688
|
+
}
|
|
689
|
+
if (options.canaryNodeId)
|
|
690
|
+
eligible.unshift(...eligible.splice(eligible.findIndex(candidate => candidate.nodeId === options.canaryNodeId), 1));
|
|
691
|
+
const deps = options.deps ?? defaultDeps(store);
|
|
692
|
+
const run = {
|
|
693
|
+
id, version: release.version, integrity: release.integrity, actorId: options.actorId, status: 'running',
|
|
694
|
+
startedAt: new Date(deps.now()).toISOString(), finishedAt: null, concurrency: settings.agentUpgradeConcurrency, message: null, summary: null,
|
|
695
|
+
canaryNodeId: eligible[0].nodeId, owner: INSTANCE_ID, leaseExpiresAt: new Date(deps.now() + LEASE_MS).toISOString(), stopRequestedBy: null,
|
|
696
|
+
retryOf: options.retryOf ?? null, allowRetryReverted: options.allowRetryReverted ?? false,
|
|
697
|
+
excluded: candidates.filter(candidate => !candidate.eligible).map(candidate => ({ nodeId: candidate.nodeId, name: candidate.name, reason: candidate.reason ?? 'not eligible' })),
|
|
698
|
+
nodes: eligible.map(candidate => ({ nodeId: candidate.nodeId, name: candidate.name, from: candidate.version, state: 'pending', requestId: randomUUID(), detail: null })),
|
|
699
|
+
};
|
|
700
|
+
await store.index(run);
|
|
288
701
|
await deps.save(run);
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
702
|
+
await audit('agent.upgrade_run.started', run, { nodes: run.nodes.map(node => node.name), canary: run.nodes[0].name, retryOf: run.retryOf,
|
|
703
|
+
excluded: run.excluded.map(node => `${node.name}: ${node.reason}`).slice(0, 50) });
|
|
704
|
+
// Renew the lease while this process runs it; a reader that finds it expired marks the run interrupted.
|
|
705
|
+
const renew = async () => {
|
|
706
|
+
run.leaseExpiresAt = new Date(Date.now() + LEASE_MS).toISOString();
|
|
707
|
+
if (!await store.renew(token).catch(() => false))
|
|
708
|
+
console.warn(`[agent-upgrade] run ${run.id}: could not renew the run lock`);
|
|
709
|
+
await deps.save(run).catch(() => undefined);
|
|
710
|
+
};
|
|
711
|
+
const timer = setInterval(() => void renew(), LEASE_MS / 3);
|
|
712
|
+
timer.unref?.();
|
|
713
|
+
executing.set(run.id, { run, timer });
|
|
714
|
+
const done = executeUpgradeRun(run, deps).finally(async () => {
|
|
715
|
+
clearInterval(timer);
|
|
716
|
+
executing.delete(run.id);
|
|
717
|
+
await store.unlock(token).catch(() => undefined);
|
|
718
|
+
});
|
|
719
|
+
return { run, candidates, done };
|
|
720
|
+
}
|
|
721
|
+
catch (error) {
|
|
722
|
+
await store.unlock(token).catch(() => undefined);
|
|
723
|
+
throw error;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
/** Ask a run to stop; the owner honours it before the next batch. A run nobody owns any more is closed as interrupted. */
|
|
727
|
+
export async function stopUpgradeRun(id, actorId, store = defaultRunStore()) {
|
|
728
|
+
const run = await getUpgradeRun(id, store);
|
|
729
|
+
if (!run)
|
|
730
|
+
throw new HttpError(404, 'NOT_FOUND', 'Upgrade run not found');
|
|
731
|
+
if (run.status !== 'running')
|
|
732
|
+
throw new HttpError(409, 'RUN_NOT_RUNNING', `Upgrade run ${id} already ended (${run.status})`);
|
|
733
|
+
await store.requestStop(id, actorId);
|
|
734
|
+
run.stopRequestedBy = actorId;
|
|
735
|
+
await logAuditEvent({ action: 'agent.upgrade_run.stop_requested', actorId, targetType: 'fleet', targetId: id, details: { runId: id } }).catch(() => undefined);
|
|
736
|
+
return run;
|
|
737
|
+
}
|
|
738
|
+
/** Retry the nodes a finished run did not upgrade, with the same version and integrity. */
|
|
739
|
+
export async function retryUpgradeRun(id, options) {
|
|
740
|
+
const store = options.store ?? defaultRunStore();
|
|
741
|
+
const previous = await getUpgradeRun(id, store);
|
|
742
|
+
if (!previous)
|
|
743
|
+
throw new HttpError(404, 'NOT_FOUND', 'Upgrade run not found');
|
|
744
|
+
if (previous.status === 'running')
|
|
745
|
+
throw new HttpError(409, 'AGENT_UPGRADE_RUNNING', `Upgrade run ${id} is still in progress`);
|
|
746
|
+
const retry = new Set(['failed', 'refused', 'skipped', 'requested', ...options.allowRetryReverted ? ['reverted'] : []]);
|
|
747
|
+
const nodeIds = previous.nodes.filter(node => retry.has(node.state)).map(node => node.nodeId);
|
|
748
|
+
if (!nodeIds.length)
|
|
749
|
+
throw new HttpError(409, 'NOTHING_TO_RETRY', `Run ${id} has no failed, refused, skipped or interrupted nodes${options.allowRetryReverted ? '' : ' (reverted nodes need allowRetryReverted)'}`);
|
|
750
|
+
return startUpgradeRun({ ...options, store, version: previous.version, integrity: previous.integrity, nodeIds, retryOf: previous.id,
|
|
751
|
+
allowPrerelease: options.allowPrerelease ?? isPrerelease(previous.version) });
|
|
292
752
|
}
|
|
293
753
|
//# sourceMappingURL=agent-upgrade-service.js.map
|