mcp-pacemaker 2.0.2 → 2.1.1
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/CHANGELOG.md +171 -1
- package/CONTRIBUTING.md +46 -13
- package/README.md +109 -524
- package/THIRD_PARTY_NOTICES.txt +1 -28
- package/autostart/windows/account-task.ps1 +167 -0
- package/autostart/windows/inspect-task.ps1 +42 -0
- package/autostart/windows/inspect-worker-process-sessions.ps1 +155 -0
- package/autostart/windows/legacy-access.ps1 +359 -0
- package/autostart/windows/legacy-task.ps1 +301 -0
- package/autostart/windows/register-task.ps1 +11 -2
- package/bin/account-task-adapter.mjs +47 -0
- package/bin/account-upgrade-controller.mjs +198 -0
- package/bin/account-upgrade-worker.mjs +141 -0
- package/bin/account-worker-session.mjs +215 -0
- package/bin/cli-dispatch.mjs +154 -0
- package/bin/cli-main.mjs +1063 -0
- package/bin/cli-selection.mjs +43 -0
- package/bin/cli.mjs +7 -876
- package/bin/dependency-lock.json +642 -0
- package/bin/legacy-1.3.json +32 -0
- package/bin/legacy-access.mjs +73 -0
- package/bin/legacy-installation.mjs +273 -0
- package/bin/legacy-task.mjs +76 -0
- package/bin/legacy-upgrade.mjs +527 -0
- package/bin/managed-runtime.mjs +170 -0
- package/bin/managed-state.mjs +257 -0
- package/bin/managed-upgrade.mjs +581 -0
- package/bin/mcp-bridge.mjs +150 -4
- package/bin/pooling-batches.mjs +13 -0
- package/bin/pooling-writer.mjs +4 -0
- package/bin/prewarm.mjs +59 -25
- package/bin/runtime-context.mjs +100 -0
- package/bin/service-control.mjs +32 -4
- package/bin/stable-launcher.mjs +54 -0
- package/bin/task-only-controller.mjs +226 -0
- package/bin/task-scope.mjs +65 -0
- package/bin/task-transaction-protocol.mjs +173 -0
- package/bin/top.mjs +22 -10
- package/bin/update-check.mjs +13 -2
- package/bin/upgrade-autostart.mjs +47 -0
- package/bin/upgrade-capability.json +9 -0
- package/bin/upgrade-dependencies.mjs +183 -0
- package/bin/upgrade-package.mjs +205 -0
- package/bin/windows-legacy/LegacyProcessBroker.build.json +42 -0
- package/bin/windows-legacy/LegacyProcessBroker.exe +0 -0
- package/bin/windows-legacy/src/AssemblyInfo.cs +8 -0
- package/bin/windows-legacy/src/LegacyNative.cs +832 -0
- package/bin/windows-legacy/src/LegacyProcessBroker.cs +961 -0
- package/bin/windows-legacy-process.mjs +248 -0
- package/bin/windows-lifetime/ProcessLifetimeHelper.build.json +38 -0
- package/bin/windows-lifetime/ProcessLifetimeHelper.exe +0 -0
- package/bin/windows-lifetime/src/AssemblyInfo.cs +8 -0
- package/bin/windows-lifetime/src/ProcessLifetimeHelper.cs +468 -0
- package/bin/windows-process-lifetime.mjs +218 -0
- package/bin/windows-task-channel/TaskChannelGuard.build.json +45 -0
- package/bin/windows-task-channel/TaskChannelGuard.exe +0 -0
- package/bin/windows-task-channel/src/AssemblyInfo.cs +8 -0
- package/bin/windows-task-channel/src/ChannelCliContext.cs +210 -0
- package/bin/windows-task-channel/src/ChannelFiles.cs +231 -0
- package/bin/windows-task-channel/src/ChannelNative.cs +477 -0
- package/bin/windows-task-channel/src/ChannelSession.cs +203 -0
- package/bin/windows-task-channel/src/TaskChannelGuard.cs +686 -0
- package/bin/windows-task-channel.mjs +335 -0
- package/docs/account-upgrades.md +218 -0
- package/docs/compatibility.md +39 -0
- package/docs/configuration.md +146 -0
- package/docs/dashboards.md +125 -0
- package/docs/execution-context.md +119 -0
- package/docs/http-api.md +47 -0
- package/docs/install.md +52 -0
- package/docs/operations.md +99 -0
- package/docs/upgrades.md +324 -0
- package/package.json +19 -5
- package/supervisor/supervise.mjs +226 -40
- package/tools/account-upgrade/qualify-two-account.ps1 +66 -0
- package/tools/windows-legacy/README.md +251 -0
- package/tools/windows-legacy/build.ps1 +122 -0
- package/tools/windows-legacy/inventory.mjs +92 -0
- package/tools/windows-process-lifetime/README.md +119 -0
- package/tools/windows-process-lifetime/build.ps1 +122 -0
- package/tools/windows-process-lifetime/inventory.mjs +86 -0
- package/tools/windows-task-channel/README.md +329 -0
- package/tools/windows-task-channel/build.ps1 +107 -0
- package/tools/windows-task-channel/inventory.mjs +117 -0
- package/ui/dist/THIRD_PARTY_NOTICES.txt +1 -28
- package/ui/dist/assets/{index-CgPruWYi.js → index-CcwMo2sI.js} +1 -1
- package/ui/dist/assets/index-PVcuDNrN.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/third-party-manifest.json +37 -25
- package/ui/dist/assets/index-CaLPg0c-.css +0 -1
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import semver from 'semver';
|
|
7
|
+
import {
|
|
8
|
+
acquireInstanceLock, createInstance, directoryIdentity, durableJson,
|
|
9
|
+
loadInstance, readInstanceState, readRecoveryContext, readManagedJson, verifySelection,
|
|
10
|
+
} from './managed-state.mjs';
|
|
11
|
+
import { acquireUpgrade, configuredRegistry, exactVersion, unpackUpgrade, verifyArchive } from './upgrade-package.mjs';
|
|
12
|
+
import { inspectInstance, listenerOpen, startInstance, stopInstance } from './managed-runtime.mjs';
|
|
13
|
+
import { MAX_CONFIG_BYTES, PoolingFiles, hasPoolingTransaction } from './pooling-files.mjs';
|
|
14
|
+
import { inspectAutostart } from './upgrade-autostart.mjs';
|
|
15
|
+
import { validateRegistry } from './update-check.mjs';
|
|
16
|
+
import { servicePaths } from './service-control.mjs';
|
|
17
|
+
import { stageCliDependencies } from './upgrade-dependencies.mjs';
|
|
18
|
+
import { LegacyUpgrader, legacyDormant } from './legacy-upgrade.mjs';
|
|
19
|
+
import { requireUserRuntimeContext } from './runtime-context.mjs';
|
|
20
|
+
import { LEGACY_WARNING, prepareLegacyPlan } from './legacy-installation.mjs';
|
|
21
|
+
|
|
22
|
+
const terminal = ['committed', 'rolled-back', 'aborted'];
|
|
23
|
+
const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
24
|
+
|
|
25
|
+
function sessionState(config) {
|
|
26
|
+
const path = join(dirname(config), 'sessions.json');
|
|
27
|
+
return existsSync(path) ? new PoolingFiles(config).inspect(path) : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Session records change whenever a client connects. An upgrade may end sessions, so only
|
|
31
|
+
// the config is protected here.
|
|
32
|
+
function verifyRollbackState(instance, journal) {
|
|
33
|
+
new PoolingFiles(instance.config).verify(instance.config, journal.authority);
|
|
34
|
+
if (hasPoolingTransaction(instance.config)) throw new Error('Config outcome is uncertain; automatic rollback refused.');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function instanceBinding(instance) {
|
|
38
|
+
return { config: instance.config, cwd: instance.cwd, node: instance.node, port: instance.port };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function validateJournal(instance, journal) {
|
|
42
|
+
const binding = instanceBinding(instance);
|
|
43
|
+
if (journal.sxs &&
|
|
44
|
+
journal.binding?.port === 0) binding.port = 0;
|
|
45
|
+
const matches = journal.protocol === 1 &&
|
|
46
|
+
journal.instanceId === instance.id &&
|
|
47
|
+
same(binding, journal.binding) &&
|
|
48
|
+
[journal.previous?.root, journal.target?.root].includes(instance.active.root);
|
|
49
|
+
if (!matches) throw new Error('Journal configuration or active selection does not match this managed instance.');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function assertNoPendingJournal(instance) {
|
|
53
|
+
const path = join(instance.directory, 'journal.json');
|
|
54
|
+
if (!existsSync(path)) return;
|
|
55
|
+
const journal = readManagedJson(path);
|
|
56
|
+
validateJournal(instance, journal);
|
|
57
|
+
if (!terminal.includes(journal.phase)) throw new Error('An unfinished upgrade requires --recover --instance <directory>.');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function upgradeOptions(options) {
|
|
61
|
+
if (options.self &&
|
|
62
|
+
(options.to || options.sxs || options.plan || options.recover || options.instance || options.yes)) {
|
|
63
|
+
throw new Error('--self is guidance only; do not combine it with managed upgrade options.');
|
|
64
|
+
}
|
|
65
|
+
if (options.port !== undefined &&
|
|
66
|
+
!options.sxs) throw new Error('--port is only valid with --sxs. Normal upgrades preserve the selected instance port.');
|
|
67
|
+
if (options.recover) {
|
|
68
|
+
if (!options.instance ||
|
|
69
|
+
options.to ||
|
|
70
|
+
options.sxs ||
|
|
71
|
+
options.plan ||
|
|
72
|
+
options.registry !== undefined) throw new Error('--recover requires --instance and cannot select a new target or registry.');
|
|
73
|
+
return { ...options };
|
|
74
|
+
}
|
|
75
|
+
exactVersion(options.to);
|
|
76
|
+
let port = 0;
|
|
77
|
+
if (options.port !== undefined) {
|
|
78
|
+
if (!/^[1-9][0-9]*$/.test(String(options.port))) throw new Error('--sxs --port must be an integer from 1 to 65535.');
|
|
79
|
+
port = Number(options.port);
|
|
80
|
+
if (port > 65535) throw new Error('--sxs --port must be an integer from 1 to 65535.');
|
|
81
|
+
}
|
|
82
|
+
return { ...options, ...(options.sxs ? { port } : {}), registry: validateRegistry(options.registry) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function discoverInstances(home) {
|
|
86
|
+
const parent = join(home, 'managed');
|
|
87
|
+
if (!existsSync(parent)) return [];
|
|
88
|
+
return readdirSync(parent, { withFileTypes: true })
|
|
89
|
+
.filter(entry => entry.isDirectory() && !entry.isSymbolicLink())
|
|
90
|
+
.map(entry => readInstanceState(join(parent, entry.name)))
|
|
91
|
+
.filter(instance => !legacyDormant(instance))
|
|
92
|
+
.map(instance => loadInstance(instance.directory));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function planUpgrade(options, { home, legacy, autostart: inspectStart = inspectAutostart } = {}) {
|
|
96
|
+
const selected = upgradeOptions(options);
|
|
97
|
+
const instances = selected.instance ? [loadInstance(resolve(selected.instance))] : discoverInstances(home);
|
|
98
|
+
if (!instances.length &&
|
|
99
|
+
!selected.instance) {
|
|
100
|
+
const prepared = await prepareLegacyPlan(selected, { home, ...legacy });
|
|
101
|
+
return {
|
|
102
|
+
protocol: 1, options: selected, instance: prepared.instance, legacy: prepared.legacy,
|
|
103
|
+
legacyRuntime: { task: prepared.task, processes: prepared.processes },
|
|
104
|
+
from: '1.3.0', to: selected.to, targetMajor: Number(selected.to.split('.')[0]),
|
|
105
|
+
majorChange: selected.to.split('.')[0] !== '1',
|
|
106
|
+
registry: await configuredRegistry(selected.registry), port: prepared.instance.port,
|
|
107
|
+
affectedInstances: [prepared.legacy.backendInstanceId],
|
|
108
|
+
autostart: { kind: 'legacy', name: prepared.legacy.registration.name },
|
|
109
|
+
downtime: LEGACY_WARNING,
|
|
110
|
+
rollback: 'Only before target admission, with unchanged config and a verified supervisor and bridge stop. Uncertain launch or task outcomes require explicit recovery.',
|
|
111
|
+
hostWiring: 'Unchanged. The same installed CLI entry will dispatch the selected managed version.',
|
|
112
|
+
legacyAdoption: 'One-time restart. A process the 1.3 bridge started that cannot be stopped is reported and left running.',
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (instances.length !== 1) {
|
|
116
|
+
throw new Error(instances.length
|
|
117
|
+
? 'Multiple managed instances found. Select exactly one with --instance <directory>; --port is reserved for --sxs.'
|
|
118
|
+
: 'No stable managed installation found. Legacy 1.3 cannot prove idle HTTP work or close admission; automatic legacy migration is blocked. No download, service, autostart or host-wiring change was made.');
|
|
119
|
+
}
|
|
120
|
+
const instance = instances[0];
|
|
121
|
+
if (selected.sxs) servicePaths(join(dirname(instance.config), 'sxs-00000000', 'servers.json'), selected.port || 65535);
|
|
122
|
+
assertNoPendingJournal(instance);
|
|
123
|
+
const autostart = await inspectStart(instance);
|
|
124
|
+
const running = await inspectInstance(instance);
|
|
125
|
+
if (running.unsafe) throw new Error('Unresolved configuration transaction blocks upgrade.');
|
|
126
|
+
if (selected.sxs &&
|
|
127
|
+
selected.port &&
|
|
128
|
+
await listenerOpen(selected.port)) throw new Error('Explicit --sxs port is busy. No listener was adopted or stopped.');
|
|
129
|
+
return {
|
|
130
|
+
protocol: 1, options: selected, instance,
|
|
131
|
+
from: running.version, to: selected.to,
|
|
132
|
+
targetMajor: Number(selected.to.split('.')[0]),
|
|
133
|
+
majorChange: selected.to.split('.')[0] !== running.version.split('.')[0],
|
|
134
|
+
registry: await configuredRegistry(selected.registry),
|
|
135
|
+
port: selected.sxs ? selected.port || 'OS-assigned when the new listener binds' : instance.port,
|
|
136
|
+
affectedInstances: [instance.id], autostart,
|
|
137
|
+
downtime: selected.sxs ? 'Existing service remains running.' : 'Short restart; active clients may need a reload. No zero-downtime handoff.',
|
|
138
|
+
rollback: 'Automatic only before target admission, with unchanged configuration bytes and authority and verified stop. Uncertain outcomes require manual recovery.',
|
|
139
|
+
hostWiring: 'Unchanged. SxS does not rewire any client.',
|
|
140
|
+
legacyAdoption: 'Not performed. Unknown or older supervisors/autostart are refused before interruption.',
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function describeUpgrade(plan) {
|
|
145
|
+
return [
|
|
146
|
+
`Managed ${plan.options.sxs ? 'SxS' : 'upgrade'}: ${plan.from} -> ${plan.to} (target major ${plan.targetMajor}${plan.majorChange ? '; explicit major change' : ''})`,
|
|
147
|
+
`Instance: ${plan.instance.id}\nDirectory: ${plan.instance.directory}\nConfig: ${plan.instance.config}`,
|
|
148
|
+
`Port: ${plan.port}\nRegistry: ${plan.registry}`,
|
|
149
|
+
plan.downtime, plan.hostWiring, `Rollback: ${plan.rollback}`,
|
|
150
|
+
`Legacy: ${plan.legacyAdoption}`,
|
|
151
|
+
'Target archive integrity, runtime/engine/capability and packaged startup are checked before interruption.',
|
|
152
|
+
].join('\n');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export class ManagedUpgrader {
|
|
156
|
+
constructor({ acquire = acquireUpgrade, dependencies = stageCliDependencies, checkpoint = () => {}, legacy,
|
|
157
|
+
autostart = inspectAutostart, beforeInterruption = async () => {},
|
|
158
|
+
beforeStop = async () => {}, beforeAdmission = async () => {}, afterRefusal = async () => {} } = {}) {
|
|
159
|
+
this.acquire = acquire;
|
|
160
|
+
this.dependencies = dependencies;
|
|
161
|
+
this.checkpoint = checkpoint;
|
|
162
|
+
this.legacy = legacy;
|
|
163
|
+
this.autostart = autostart;
|
|
164
|
+
this.beforeInterruption = beforeInterruption;
|
|
165
|
+
this.beforeStop = beforeStop;
|
|
166
|
+
this.beforeAdmission = beforeAdmission;
|
|
167
|
+
this.afterRefusal = afterRefusal;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
transition(directory, journal, phase, changes = {}) {
|
|
171
|
+
validateJournal(readInstanceState(directory), journal);
|
|
172
|
+
Object.assign(journal, changes, { phase });
|
|
173
|
+
durableJson(join(directory, 'journal.json'), journal);
|
|
174
|
+
this.checkpoint(phase, journal);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async stage(plan, operation) {
|
|
178
|
+
const work = join(plan.instance.directory, 'staging', operation);
|
|
179
|
+
mkdirSync(work, { recursive: true, mode: 0o700 });
|
|
180
|
+
const artifact = await this.acquire(plan.to, work, { registry: plan.registry });
|
|
181
|
+
verifyArchive(artifact.bytes, artifact.integrity);
|
|
182
|
+
const root = join(plan.instance.directory, 'versions', `${plan.to}-${operation}`);
|
|
183
|
+
mkdirSync(dirname(root), { recursive: true, mode: 0o700 });
|
|
184
|
+
mkdirSync(root, { mode: 0o700 });
|
|
185
|
+
const nodeVersion = execFileSync(plan.instance.node, ['--version'], {
|
|
186
|
+
encoding: 'utf8', windowsHide: true, timeout: 10000,
|
|
187
|
+
}).trim().replace(/^v/, '');
|
|
188
|
+
const inventory = unpackUpgrade(artifact.bytes, plan.to, root, nodeVersion);
|
|
189
|
+
const dependencyFiles = await this.dependencies(root, { registry: plan.registry, cache: join(work, 'cache') });
|
|
190
|
+
inventory.files.push(...dependencyFiles);
|
|
191
|
+
const selection = { root, version: plan.to, directoryIdentity: directoryIdentity(root), files: inventory.files };
|
|
192
|
+
durableJson(join(work, 'verified-package.json'), selection);
|
|
193
|
+
const preflight = realpathSync(mkdtempSync(join(tmpdir(), 'up-')));
|
|
194
|
+
const preflightIdentity = directoryIdentity(preflight);
|
|
195
|
+
const config = join(preflight, 'preflight.json');
|
|
196
|
+
writeFileSync(config, '{}\n', { flag: 'wx', mode: 0o600 });
|
|
197
|
+
const cliVersion = execFileSync(plan.instance.node, [join(root, 'bin', 'cli-main.mjs'), '--version'], {
|
|
198
|
+
encoding: 'utf8', windowsHide: true, timeout: 15000,
|
|
199
|
+
env: { ...process.env, HOME: preflight, USERPROFILE: preflight, MCP_PACEMAKER_CLI_INSTANCE: '' },
|
|
200
|
+
}).trim();
|
|
201
|
+
if (cliVersion !== plan.to) throw new Error('Packaged target CLI version/startup did not verify.');
|
|
202
|
+
const fixture = createInstance({
|
|
203
|
+
directory: join(preflight, 'instance'), root, config, port: 0, selection, node: plan.instance.node,
|
|
204
|
+
});
|
|
205
|
+
let started;
|
|
206
|
+
let stopped = false;
|
|
207
|
+
try {
|
|
208
|
+
started = await startInstance(fixture.directory, { pending: true });
|
|
209
|
+
await inspectInstance(started.instance);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
stopped = error.startupStopped === true;
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
if (started) {
|
|
216
|
+
await stopInstance(started.instance);
|
|
217
|
+
stopped = true;
|
|
218
|
+
}
|
|
219
|
+
if (stopped) {
|
|
220
|
+
if (directoryIdentity(preflight) !== preflightIdentity) throw new Error('Preflight directory identity changed; retained.');
|
|
221
|
+
rmSync(preflight, { recursive: true });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return selection;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async execute(plan) {
|
|
228
|
+
await requireUserRuntimeContext();
|
|
229
|
+
if (plan.legacy) {
|
|
230
|
+
const upgrader = new LegacyUpgrader({
|
|
231
|
+
...plan.legacyRuntime, ...this.legacy, checkpoint: this.checkpoint, stage: this.stage.bind(this),
|
|
232
|
+
});
|
|
233
|
+
return await upgrader.execute(plan);
|
|
234
|
+
}
|
|
235
|
+
const source = loadInstance(plan.instance.directory);
|
|
236
|
+
const release = acquireInstanceLock(source.directory);
|
|
237
|
+
const operation = randomUUID();
|
|
238
|
+
let journal;
|
|
239
|
+
let destination = source.directory;
|
|
240
|
+
try {
|
|
241
|
+
const locked = loadInstance(source.directory, { verifyFiles: false });
|
|
242
|
+
const matchesPlan = locked.id === source.id &&
|
|
243
|
+
source.id === plan.instance.id &&
|
|
244
|
+
locked.directoryIdentity === source.directoryIdentity &&
|
|
245
|
+
source.directoryIdentity === plan.instance.directoryIdentity &&
|
|
246
|
+
same(locked.active, source.active) &&
|
|
247
|
+
same(source.active, plan.instance.active) &&
|
|
248
|
+
same(instanceBinding(locked), instanceBinding(source)) &&
|
|
249
|
+
same(instanceBinding(source), instanceBinding(plan.instance));
|
|
250
|
+
if (!matchesPlan) throw new Error('Managed selection changed after the plan; create a new plan.');
|
|
251
|
+
assertNoPendingJournal(source);
|
|
252
|
+
if (!plan.options.sxs) this.requireSessionRecords(source.config, plan.to);
|
|
253
|
+
await this.autostart(source);
|
|
254
|
+
const current = await inspectInstance(source);
|
|
255
|
+
if (current.unsafe) throw new Error('Unresolved transaction blocks upgrade.');
|
|
256
|
+
const target = await this.stage(plan, operation);
|
|
257
|
+
verifySelection(target);
|
|
258
|
+
const reloaded = loadInstance(source.directory, { verifyFiles: false });
|
|
259
|
+
if (reloaded.id !== source.id ||
|
|
260
|
+
!same(reloaded.active, source.active)) throw new Error('Instance or active selection changed while staging; nothing was stopped.');
|
|
261
|
+
await this.autostart(source);
|
|
262
|
+
if (plan.options.sxs) {
|
|
263
|
+
return await this.startSxs(source, plan, target, operation);
|
|
264
|
+
}
|
|
265
|
+
// The records can grow while the target downloads, so their size is checked again.
|
|
266
|
+
this.requireSessionRecords(source.config, target.version);
|
|
267
|
+
// Session records are read only after the old bridge stops; while it runs, every session
|
|
268
|
+
// start and end rewrites them.
|
|
269
|
+
journal = {
|
|
270
|
+
protocol: 1, operation, instanceId: source.id, previous: source.active, target,
|
|
271
|
+
binding: instanceBinding(source),
|
|
272
|
+
authority: current.authority, originalInstanceId: current.instanceId,
|
|
273
|
+
sessionState: null, launchToken: randomUUID(), phase: 'prepared',
|
|
274
|
+
};
|
|
275
|
+
const backup = join(source.directory, 'staging', operation, 'servers.backup.json');
|
|
276
|
+
new PoolingFiles(source.config).stage(backup, source.config, current.authority, readFileSync(source.config));
|
|
277
|
+
journal.configBackup = backup;
|
|
278
|
+
await this.beforeInterruption(source, journal);
|
|
279
|
+
this.transition(destination, journal, 'prepared');
|
|
280
|
+
this.transition(destination, journal, 'quiescing');
|
|
281
|
+
const drained = await this.quiesce(source, destination, journal);
|
|
282
|
+
verifyRollbackState(source, journal);
|
|
283
|
+
await this.beforeStop(source, journal);
|
|
284
|
+
this.transition(destination, journal, 'stopping', { quiescedInstanceId: drained.instanceId });
|
|
285
|
+
const stoppedProof = await stopInstance(source, drained.instanceId);
|
|
286
|
+
this.transition(destination, journal, 'stopped', { stoppedProof, sessionState: this.sessionRecords(source.config) });
|
|
287
|
+
return await this.activate(destination, journal);
|
|
288
|
+
} catch (error) {
|
|
289
|
+
if (journal) {
|
|
290
|
+
if (['prepared', 'quiescing'].includes(journal.phase)) {
|
|
291
|
+
this.transition(destination, journal, journal.phase, { error: error.message });
|
|
292
|
+
} else if (['stopped', 'selecting', 'selected', 'launching'].includes(journal.phase)) {
|
|
293
|
+
if (error.cleanupUnverified) throw new Error('Target shutdown is unverified; automatic rollback refused. Preserve the journal and package.', { cause: error });
|
|
294
|
+
await this.rollback(destination, journal, error.message, { startupProof: error.startupProof });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
throw error;
|
|
298
|
+
} finally {
|
|
299
|
+
release();
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// A definite refusal releases the hold at once, as recovery would. Only an uncertain
|
|
304
|
+
// outcome, such as a lost reply, keeps admission held for explicit recovery.
|
|
305
|
+
async quiesce(source, destination, journal) {
|
|
306
|
+
try {
|
|
307
|
+
const drained = await inspectInstance(source, 'quiesce');
|
|
308
|
+
if (!same(drained.authority, journal.authority)) {
|
|
309
|
+
throw Object.assign(new Error('Configuration changed while staging; retry from a new plan.'), { refused: true });
|
|
310
|
+
}
|
|
311
|
+
return drained;
|
|
312
|
+
} catch (error) {
|
|
313
|
+
if (!error.refused) throw error;
|
|
314
|
+
await this.resumeRefused(source, error);
|
|
315
|
+
await this.releaseRefused(source, journal, error);
|
|
316
|
+
this.transition(destination, journal, 'aborted', { error: error.message });
|
|
317
|
+
throw new Error(`${error.message} Nothing was stopped; the current version is serving again.`, { cause: error });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Config work that blocked the drain can still be settling, and resume refuses until it has,
|
|
322
|
+
// so resume is retried briefly. If it still refuses, admission stays held for recovery.
|
|
323
|
+
async resumeRefused(source, refusal) {
|
|
324
|
+
const deadline = Date.now() + 10000;
|
|
325
|
+
for (;;) {
|
|
326
|
+
try { return await inspectInstance(source, 'resume'); }
|
|
327
|
+
catch (error) {
|
|
328
|
+
if (!error.refused ||
|
|
329
|
+
Date.now() >= deadline) {
|
|
330
|
+
const state = error.refused ? 'stays paused' : 'may still be paused';
|
|
331
|
+
throw new Error(`${refusal.message} The current version ${state} until \`mcp-pacemaker upgrade --recover --instance ${source.directory} --yes\` succeeds.`, { cause: error });
|
|
332
|
+
}
|
|
333
|
+
await new Promise(resolveWait => setTimeout(resolveWait, 500));
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Whatever the caller held for the upgrade, such as an account's task, is released before the
|
|
339
|
+
// journal settles. If that fails, the journal stays at `quiescing` and recovery finishes it.
|
|
340
|
+
async releaseRefused(source, journal, refusal) {
|
|
341
|
+
try { await this.afterRefusal(source, journal); }
|
|
342
|
+
catch (error) {
|
|
343
|
+
throw new Error(`${refusal.message} Nothing was stopped and the current version is serving again. ${error.message} Run recovery to finish.`,
|
|
344
|
+
{ cause: new AggregateError([refusal, error], refusal.message) });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Targets before 2.1.1 compare session records when they start, reading at most 1 MiB of them.
|
|
349
|
+
comparesSessionRecords(version) {
|
|
350
|
+
const ignoresRecords = semver.valid(version) &&
|
|
351
|
+
semver.gte(version, '2.1.1');
|
|
352
|
+
return !ignoresRecords;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Refuse such a target before anything is downloaded or stopped instead of restarting into a
|
|
356
|
+
// rollback. Only the size is checked, so a session starting or ending meanwhile cannot make
|
|
357
|
+
// this refuse.
|
|
358
|
+
requireSessionRecords(config, version) {
|
|
359
|
+
if (!this.comparesSessionRecords(version)) return;
|
|
360
|
+
const path = join(dirname(config), 'sessions.json');
|
|
361
|
+
const size = statSync(path, { throwIfNoEntry: false })?.size ?? 0;
|
|
362
|
+
if (size > MAX_CONFIG_BYTES) {
|
|
363
|
+
throw new Error(`Session records in ${path} are over 1 MiB, more than ${version} can compare when it starts. Upgrade to 2.1.1 or newer instead. Nothing was stopped.`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Records that could not be read after the stop would make such a target refuse admission,
|
|
368
|
+
// where rollback is no longer automatic. Refusing here, while the journal is still at
|
|
369
|
+
// `stopped`, rolls back to the previous version.
|
|
370
|
+
requireReadSessionRecords(config, journal) {
|
|
371
|
+
const unread = this.comparesSessionRecords(journal.target.version) &&
|
|
372
|
+
journal.sessionState === null &&
|
|
373
|
+
existsSync(join(dirname(config), 'sessions.json'));
|
|
374
|
+
if (unread) {
|
|
375
|
+
throw new Error(`Session records could not be read after the old version stopped, and ${journal.target.version} compares them when it starts.`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Read once the old bridge has stopped. A file the integrity helper refuses, such as one
|
|
380
|
+
// over 1 MiB, must not strand an upgrade whose old version is already down.
|
|
381
|
+
sessionRecords(config) {
|
|
382
|
+
try { return sessionState(config); }
|
|
383
|
+
catch { return null; }
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async startSxs(source, plan, target, operation) {
|
|
387
|
+
const current = await inspectInstance(source);
|
|
388
|
+
if (current.unsafe ||
|
|
389
|
+
current.configBusy) throw new Error('Configuration work is unresolved; SxS config was not copied.');
|
|
390
|
+
if (plan.options.port &&
|
|
391
|
+
await listenerOpen(plan.options.port)) throw new Error('Explicit --sxs port became busy; old instance was not touched.');
|
|
392
|
+
const directory = join(dirname(source.directory), `sxs-${operation}`);
|
|
393
|
+
const profile = join(dirname(source.config), `sxs-${operation.slice(0, 8)}`);
|
|
394
|
+
mkdirSync(profile, { mode: 0o700 });
|
|
395
|
+
const config = join(profile, 'servers.json');
|
|
396
|
+
const files = new PoolingFiles(source.config);
|
|
397
|
+
const authority = files.inspect(source.config);
|
|
398
|
+
files.stage(config, source.config, authority, readFileSync(source.config));
|
|
399
|
+
const sxs = createInstance({
|
|
400
|
+
directory, root: target.root, config, port: plan.options.port,
|
|
401
|
+
cwd: source.cwd, node: source.node,
|
|
402
|
+
});
|
|
403
|
+
const ordered = selection => [...selection.files].sort((left, right) => left.path.localeCompare(right.path));
|
|
404
|
+
if (!same(ordered(sxs.active), ordered(target))) throw new Error('SxS package copy does not match the verified archive.');
|
|
405
|
+
const release = acquireInstanceLock(directory);
|
|
406
|
+
const journal = {
|
|
407
|
+
protocol: 1, operation, instanceId: sxs.id, previous: null, target: sxs.active,
|
|
408
|
+
recovery: source.active,
|
|
409
|
+
binding: instanceBinding(sxs), authority: new PoolingFiles(config).inspect(config),
|
|
410
|
+
sessionState: null, launchToken: randomUUID(), phase: 'starting', sxs: true,
|
|
411
|
+
};
|
|
412
|
+
try {
|
|
413
|
+
this.transition(directory, journal, 'starting');
|
|
414
|
+
this.transition(directory, journal, 'launching');
|
|
415
|
+
const started = await startInstance(directory, { pending: true, token: journal.launchToken });
|
|
416
|
+
verifyRollbackState(started.instance, journal);
|
|
417
|
+
this.transition(directory, journal, 'admitting', {
|
|
418
|
+
targetInstanceId: started.ready.instanceId, binding: instanceBinding(started.instance),
|
|
419
|
+
});
|
|
420
|
+
await inspectInstance(started.instance, 'activate', journal);
|
|
421
|
+
this.transition(directory, journal, 'committed');
|
|
422
|
+
return { status: 'sxs-started', directory, port: started.instance.port, version: target.version };
|
|
423
|
+
} catch (error) {
|
|
424
|
+
if (journal.phase === 'launching' &&
|
|
425
|
+
error.startupStopped === true) this.transition(directory, journal, 'aborted', { error: error.message });
|
|
426
|
+
throw new Error(`SxS operation did not complete at ${directory}: ${error.message}`, { cause: error });
|
|
427
|
+
} finally { release(); }
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async activate(directory, journal) {
|
|
431
|
+
const instance = loadInstance(directory, { verifyFiles: false });
|
|
432
|
+
this.requireReadSessionRecords(instance.config, journal);
|
|
433
|
+
verifyRollbackState(instance, journal);
|
|
434
|
+
verifySelection(journal.target);
|
|
435
|
+
this.transition(directory, journal, 'selecting');
|
|
436
|
+
durableJson(join(directory, 'active.json'), journal.target);
|
|
437
|
+
this.transition(directory, journal, 'selected');
|
|
438
|
+
this.transition(directory, journal, 'launching');
|
|
439
|
+
const started = await startInstance(directory, { pending: true, token: journal.launchToken });
|
|
440
|
+
const ready = await inspectInstance(started.instance);
|
|
441
|
+
if (!ready.held ||
|
|
442
|
+
ready.traffic ||
|
|
443
|
+
ready.instanceId === journal.originalInstanceId ||
|
|
444
|
+
!same(ready.authority, journal.authority)) throw new Error('Held target readiness/configuration could not be verified.');
|
|
445
|
+
verifyRollbackState(started.instance, journal);
|
|
446
|
+
await this.beforeAdmission(started.instance, journal);
|
|
447
|
+
// Once this durable intent exists, a lost IPC acknowledgement is never automatic rollback authority.
|
|
448
|
+
this.transition(directory, journal, 'admitting', { targetInstanceId: ready.instanceId });
|
|
449
|
+
await inspectInstance(started.instance, 'activate', journal);
|
|
450
|
+
this.transition(directory, journal, 'committed');
|
|
451
|
+
return { status: 'upgraded', directory, port: started.instance.port, version: journal.target.version };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async verifyRollbackStop(instance, proof) {
|
|
455
|
+
if (!proof?.selection ||
|
|
456
|
+
!['startup', 'managed'].includes(proof.kind) ||
|
|
457
|
+
!proof.receipt ||
|
|
458
|
+
proof.receipt.root !== proof.selection.root ||
|
|
459
|
+
proof.receipt.config !== instance.config ||
|
|
460
|
+
proof.receipt.port !== instance.port) throw new Error('Rollback shutdown proof is incomplete.');
|
|
461
|
+
if (proof.kind === 'startup') {
|
|
462
|
+
const record = readManagedJson(servicePaths(instance.config, instance.port).record);
|
|
463
|
+
if (!proof.receipt.launchToken ||
|
|
464
|
+
record.protocol !== 1 ||
|
|
465
|
+
record.id !== proof.receipt.supervisorId ||
|
|
466
|
+
record.root !== proof.selection.root ||
|
|
467
|
+
record.config !== instance.config ||
|
|
468
|
+
record.port !== instance.port ||
|
|
469
|
+
record.socket !== servicePaths(instance.config, instance.port).socket ||
|
|
470
|
+
record.managedInstance !== instance.directory) throw new Error('Verified startup-stop generation changed.');
|
|
471
|
+
if (await listenerOpen(instance.port)) throw new Error('A listener exists after verified startup shutdown; no rollback launch was attempted.');
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
const previous = { ...instance, active: proof.selection };
|
|
475
|
+
const stopped = await stopInstance(previous, proof.quiescedInstanceId);
|
|
476
|
+
if (stopped.alreadyStopped !== true ||
|
|
477
|
+
stopped.supervisorId !== proof.receipt.supervisorId) throw new Error('Completed rollback shutdown proof does not match the stopped generation.');
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async rollback(directory, journal, error, { startupProof } = {}) {
|
|
481
|
+
const instance = readInstanceState(directory);
|
|
482
|
+
verifyRollbackState(instance, journal);
|
|
483
|
+
verifySelection(journal.previous);
|
|
484
|
+
await this.beforeStop(instance, journal);
|
|
485
|
+
const neverLaunched = ['stopped', 'selecting', 'selected'].includes(journal.phase);
|
|
486
|
+
const rollbackNotLaunched = ['rollback-selecting', 'rollback-selected'].includes(journal.phase);
|
|
487
|
+
let proof;
|
|
488
|
+
if (neverLaunched) {
|
|
489
|
+
proof = { kind: 'managed', selection: journal.previous, receipt: journal.stoppedProof,
|
|
490
|
+
quiescedInstanceId: journal.quiescedInstanceId };
|
|
491
|
+
await this.verifyRollbackStop(instance, proof);
|
|
492
|
+
} else if (rollbackNotLaunched) {
|
|
493
|
+
proof = journal.rollbackStopProof;
|
|
494
|
+
await this.verifyRollbackStop(instance, proof);
|
|
495
|
+
} else if (startupProof) {
|
|
496
|
+
proof = { kind: 'startup', selection: instance.active, receipt: startupProof };
|
|
497
|
+
await this.verifyRollbackStop(instance, proof);
|
|
498
|
+
} else if (await listenerOpen(instance.port)) {
|
|
499
|
+
const state = await inspectInstance(instance);
|
|
500
|
+
if (!state.held ||
|
|
501
|
+
state.traffic) throw new Error('Target may have served traffic; automatic rollback refused.');
|
|
502
|
+
proof = { kind: 'managed', selection: instance.active, receipt: await stopInstance(instance) };
|
|
503
|
+
} else {
|
|
504
|
+
const record = readManagedJson(servicePaths(instance.config, instance.port).record);
|
|
505
|
+
const priorStopId = journal.rollbackStopProof?.receipt?.supervisorId ?? journal.stoppedProof?.supervisorId;
|
|
506
|
+
if (record.id === priorStopId) throw new Error('Launch intent has no new generation proof; a closed port is not proof that launch cannot still occur.');
|
|
507
|
+
proof = { kind: 'managed', selection: instance.active, receipt: await stopInstance(instance) };
|
|
508
|
+
}
|
|
509
|
+
this.transition(directory, journal, 'rollback-selecting', { rollback: true, error, rollbackStopProof: proof });
|
|
510
|
+
durableJson(join(directory, 'active.json'), journal.previous);
|
|
511
|
+
this.checkpoint('rollback-active-written', journal);
|
|
512
|
+
this.transition(directory, journal, 'rollback-selected');
|
|
513
|
+
this.transition(directory, journal, 'rollback-launching', { launchToken: randomUUID() });
|
|
514
|
+
const started = await startInstance(directory, { pending: true, token: journal.launchToken });
|
|
515
|
+
const ready = await inspectInstance(started.instance);
|
|
516
|
+
if (!ready.held ||
|
|
517
|
+
ready.traffic ||
|
|
518
|
+
!same(ready.authority, journal.authority)) throw new Error('Rollback backend is not safely held for admission.');
|
|
519
|
+
verifyRollbackState(started.instance, journal);
|
|
520
|
+
this.checkpoint('rollback-launched', journal);
|
|
521
|
+
await this.beforeAdmission(started.instance, journal);
|
|
522
|
+
this.transition(directory, journal, 'admitting-rollback');
|
|
523
|
+
await inspectInstance(started.instance, 'activate', journal);
|
|
524
|
+
this.transition(directory, journal, 'rolled-back');
|
|
525
|
+
return { status: 'rolled-back', directory, port: started.instance.port, version: journal.previous.version };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async recover(directory) {
|
|
529
|
+
await requireUserRuntimeContext();
|
|
530
|
+
const identity = readInstanceState(resolve(directory));
|
|
531
|
+
// A lock surviving process death is not silently stolen using a reusable PID.
|
|
532
|
+
if (existsSync(join(identity.directory, 'upgrade.lock'))) {
|
|
533
|
+
throw new Error('Upgrade lock remains. Verify the original upgrader has exited and preserve the journal before removing only this instance lock.');
|
|
534
|
+
}
|
|
535
|
+
const release = acquireInstanceLock(identity.directory);
|
|
536
|
+
try {
|
|
537
|
+
const { instance, journal } = readRecoveryContext(identity.directory);
|
|
538
|
+
if (instance.id !== identity.id ||
|
|
539
|
+
instance.directoryIdentity !== identity.directoryIdentity) throw new Error('Recovery instance directory changed while acquiring its lock.');
|
|
540
|
+
validateJournal(instance, journal);
|
|
541
|
+
verifySelection(journal.previous ?? journal.recovery ?? journal.target);
|
|
542
|
+
if (journal.legacy?.protocol === 1 &&
|
|
543
|
+
journal.phase !== 'committed') {
|
|
544
|
+
return await new LegacyUpgrader({ ...this.legacy, checkpoint: this.checkpoint }).recover(instance.directory);
|
|
545
|
+
}
|
|
546
|
+
if (terminal.includes(journal.phase)) return { status: journal.phase, directory: instance.directory, port: instance.port };
|
|
547
|
+
if (journal.sxs) {
|
|
548
|
+
if (!['starting', 'launching'].includes(journal.phase)) throw new Error('SxS admission may have occurred; manual recovery required.');
|
|
549
|
+
if (journal.phase === 'launching') {
|
|
550
|
+
if (!instance.port) throw new Error('SxS port allocation or launch outcome is unknown. Wait for verified readiness or verified shutdown; no abort was recorded.');
|
|
551
|
+
if (!(await listenerOpen(instance.port))) {
|
|
552
|
+
await stopInstance(instance);
|
|
553
|
+
} else {
|
|
554
|
+
const state = await inspectInstance(instance);
|
|
555
|
+
if (!state.held ||
|
|
556
|
+
state.traffic) throw new Error('SxS traffic outcome is uncertain; no stop attempted.');
|
|
557
|
+
await stopInstance(instance);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
this.transition(instance.directory, journal, 'aborted');
|
|
561
|
+
return { status: 'aborted', directory: instance.directory, port: instance.port };
|
|
562
|
+
}
|
|
563
|
+
if (['prepared', 'quiescing'].includes(journal.phase)) {
|
|
564
|
+
await this.beforeStop(instance, journal);
|
|
565
|
+
await inspectInstance(instance, 'resume');
|
|
566
|
+
this.transition(instance.directory, journal, 'aborted');
|
|
567
|
+
return { status: 'aborted', directory: instance.directory, port: instance.port };
|
|
568
|
+
}
|
|
569
|
+
if (journal.phase === 'stopping') {
|
|
570
|
+
await this.beforeStop(instance, journal);
|
|
571
|
+
const stoppedProof = await stopInstance(instance, journal.quiescedInstanceId);
|
|
572
|
+
this.transition(instance.directory, journal, 'stopped', { stoppedProof, sessionState: this.sessionRecords(instance.config) });
|
|
573
|
+
}
|
|
574
|
+
if (['stopped', 'selecting', 'selected', 'launching',
|
|
575
|
+
'rollback-selecting', 'rollback-selected', 'rollback-launching'].includes(journal.phase)) {
|
|
576
|
+
return await this.rollback(instance.directory, journal, 'Recovered interrupted activation.');
|
|
577
|
+
}
|
|
578
|
+
throw new Error('Admission may have occurred. Automatic recovery/rollback is refused; inspect backend and configuration before a new operation.');
|
|
579
|
+
} finally { release(); }
|
|
580
|
+
}
|
|
581
|
+
}
|