mcp-pacemaker 2.1.0 → 2.1.2
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 +142 -2
- package/CONTRIBUTING.md +11 -4
- package/README.md +116 -764
- package/SECURITY.md +7 -0
- package/THIRD_PARTY_NOTICES.txt +1 -1
- package/autostart/windows/legacy-access.ps1 +359 -0
- package/bin/account-upgrade-controller.mjs +1 -1
- package/bin/account-upgrade-worker.mjs +7 -0
- package/bin/cli-main.mjs +61 -5
- package/bin/dependency-lock.json +642 -0
- package/bin/legacy-access.mjs +73 -0
- package/bin/legacy-installation.mjs +75 -19
- package/bin/legacy-task.mjs +24 -0
- package/bin/legacy-upgrade.mjs +66 -10
- package/bin/managed-upgrade.mjs +102 -12
- package/bin/mcp-bridge.mjs +115 -20
- package/bin/prewarm.mjs +28 -6
- package/bin/service-control.mjs +10 -5
- package/bin/upgrade-capability.json +2 -1
- package/bin/upgrade-dependencies.mjs +67 -6
- package/bin/upgrade-package.mjs +2 -0
- package/bin/windows-legacy/LegacyProcessBroker.build.json +3 -3
- package/bin/windows-legacy/LegacyProcessBroker.exe +0 -0
- package/bin/windows-legacy/src/LegacyNative.cs +60 -11
- package/bin/windows-legacy/src/LegacyProcessBroker.cs +317 -197
- package/bin/windows-legacy-process.mjs +12 -5
- package/docs/account-upgrades.md +17 -4
- package/docs/comparison.md +167 -0
- package/docs/compatibility.md +39 -0
- package/docs/configuration.md +146 -0
- package/docs/dashboards.md +125 -0
- package/docs/execution-context.md +11 -5
- 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 +11 -3
- package/tools/windows-legacy/README.md +118 -92
- package/tools/windows-legacy/inventory.mjs +8 -6
- package/ui/dist/THIRD_PARTY_NOTICES.txt +1 -1
- package/ui/dist/third-party-manifest.json +6 -6
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { durableJson } from './managed-state.mjs';
|
|
4
|
+
import { WindowsLegacyTaskAdapter } from './legacy-task.mjs';
|
|
5
|
+
import {
|
|
6
|
+
ACCESS_FIXES, legacyAccessProblems, legacyAccessReport, legacyStatePort, parseLegacyRegistration, verifyLegacyPackage,
|
|
7
|
+
} from './legacy-installation.mjs';
|
|
8
|
+
|
|
9
|
+
const NEXT = 'Run mcp-pacemaker upgrade --to <version> from a normal (non-administrator) terminal.';
|
|
10
|
+
|
|
11
|
+
export async function inspectLegacyAccess(home, { task = new WindowsLegacyTaskAdapter() } = {}) {
|
|
12
|
+
if (process.platform !== 'win32') throw new Error('--fix-legacy-access applies only to the known Windows 1.3 registration. Nothing was changed.');
|
|
13
|
+
const { port } = legacyStatePort(home);
|
|
14
|
+
const { root, registration } = parseLegacyRegistration(await task.inspect(port), port);
|
|
15
|
+
verifyLegacyPackage(root);
|
|
16
|
+
const cliPath = join(root, 'bin', 'cli.mjs');
|
|
17
|
+
const report = legacyAccessReport(await task.access(port, cliPath), registration);
|
|
18
|
+
return { port, cliPath, registration, report };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function describeLegacyAccessFix({ port, report }) {
|
|
22
|
+
return [
|
|
23
|
+
`Legacy 1.3 access fix for port ${port}. It will only:`,
|
|
24
|
+
...report.fixable.map(id => ` - ${ACCESS_FIXES[id](report)}`),
|
|
25
|
+
'Unchanged: task XML, triggers and run-as account (it keeps running as you, not elevated), other permissions and file contents.',
|
|
26
|
+
].join('\n');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Elevated, explicit and bound to the inspected descriptors: makes an install done from an
|
|
30
|
+
// administrator terminal look like one done from a normal terminal, so the ordinary upgrade can run.
|
|
31
|
+
export async function fixLegacyAccess({
|
|
32
|
+
home, plan = false, yes = false, interactive = false, confirm,
|
|
33
|
+
task = new WindowsLegacyTaskAdapter(), log = console.log, now = () => new Date(),
|
|
34
|
+
} = {}) {
|
|
35
|
+
const selected = await inspectLegacyAccess(home, { task });
|
|
36
|
+
const { port, cliPath, registration, report } = selected;
|
|
37
|
+
const summary = { port, task: registration.path, cli: cliPath };
|
|
38
|
+
if (!report.missing.length) return { status: 'unchanged', ...summary, next: NEXT };
|
|
39
|
+
if (!report.elevated) {
|
|
40
|
+
throw new Error('Run --fix-legacy-access from an administrator terminal (Run as administrator). Nothing was changed.');
|
|
41
|
+
}
|
|
42
|
+
const manual = report.missing.filter(id => !report.fixable.includes(id));
|
|
43
|
+
if (manual.length) {
|
|
44
|
+
throw new Error([
|
|
45
|
+
'These permissions need an administrator to change them by hand; --fix-legacy-access does not. Nothing was changed.',
|
|
46
|
+
...legacyAccessProblems(report).map(problem => ` - ${problem}`),
|
|
47
|
+
].join('\n'));
|
|
48
|
+
}
|
|
49
|
+
log(describeLegacyAccessFix(selected));
|
|
50
|
+
if (plan) return { status: 'planned', ...summary, changes: report.fixable };
|
|
51
|
+
if (!yes) {
|
|
52
|
+
if (!interactive) throw new Error('--fix-legacy-access needs an interactive confirmation or --yes. Nothing was changed.');
|
|
53
|
+
if (await confirm('Apply this access fix?') !== true) return { status: 'cancelled', ...summary };
|
|
54
|
+
}
|
|
55
|
+
const directory = join(home, 'legacy-upgrade');
|
|
56
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
57
|
+
const journal = join(directory, `access-${now().toISOString().replaceAll(':', '-')}.json`);
|
|
58
|
+
const record = {
|
|
59
|
+
protocol: 1, kind: 'legacy-access-fix', createdAt: now().toISOString(), ...summary,
|
|
60
|
+
changes: report.fixable, before: report.expected,
|
|
61
|
+
};
|
|
62
|
+
durableJson(journal, record);
|
|
63
|
+
let result;
|
|
64
|
+
try { result = await task.grantAccess(port, cliPath, report.expected); }
|
|
65
|
+
catch (error) {
|
|
66
|
+
durableJson(journal, { ...record, failed: error.message, changed: error.changed ?? [] });
|
|
67
|
+
throw new Error(`${error.message}\nRecorded before-state: ${journal}`, { cause: error });
|
|
68
|
+
}
|
|
69
|
+
durableJson(journal, { ...record, changed: result.changed, after: result.after });
|
|
70
|
+
const after = legacyAccessReport(await task.access(port, cliPath), registration);
|
|
71
|
+
if (after.missing.length) throw new Error(`Access is still missing after the fix: ${after.missing.join(', ')}. Recorded before-state: ${journal}`);
|
|
72
|
+
return { status: 'fixed', ...summary, changed: result.changed, journal, next: NEXT };
|
|
73
|
+
}
|
|
@@ -13,12 +13,21 @@ import { assertDurableRoot } from './service-control.mjs';
|
|
|
13
13
|
const approved = new WeakMap();
|
|
14
14
|
const inventory = readManagedJson(new URL('./legacy-1.3.json', import.meta.url));
|
|
15
15
|
const hash = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
16
|
+
const ACCESS_PROBLEMS = {
|
|
17
|
+
'task-write': report => `Task ${report.task.path} (owner ${report.task.ownerName}): your account can only read it.`,
|
|
18
|
+
'cli-owner': report => `${report.cli.path} (owner ${report.cli.ownerName}): your account cannot copy it with its permissions.`,
|
|
19
|
+
'cli-write': report => `${report.cli.path}: your account cannot write it.`,
|
|
20
|
+
'cli-delete': report => `${report.cli.path}: your account cannot rename it.`,
|
|
21
|
+
'bin-add-file': report => `${report.cli.directory}: your account cannot create files in it.`,
|
|
22
|
+
};
|
|
23
|
+
export const ACCESS_FIXES = {
|
|
24
|
+
'task-write': report => `give your account full control of task ${report.task.path}`,
|
|
25
|
+
'cli-owner': report => `make your account the owner of ${report.cli.path}`,
|
|
26
|
+
};
|
|
16
27
|
export const LEGACY_WARNING = [
|
|
17
|
-
'
|
|
18
|
-
'
|
|
19
|
-
'
|
|
20
|
-
'Historical orphan coverage remains unproven; uncaptured or unattributed processes are left untouched and are not enumerated as a complete set.',
|
|
21
|
-
'No unknown call is automatically replayed. The new managed runtime contains future children from birth.',
|
|
28
|
+
'Upgrade this 1.3 installation? Its supervisor, its bridge and the processes the bridge started will be stopped.',
|
|
29
|
+
'Open sessions and calls in progress end, and clients reconnect to the new version. No call is retried.',
|
|
30
|
+
'servers.json, every server setting in it and your client setup are kept as they are.',
|
|
22
31
|
].join('\n');
|
|
23
32
|
|
|
24
33
|
export function legacyPlanDigest(plan) {
|
|
@@ -30,18 +39,19 @@ export function legacyPlanDigest(plan) {
|
|
|
30
39
|
|
|
31
40
|
export async function confirmLegacyUpgrade(plan, { yes = false, interactive = false, confirm } = {}) {
|
|
32
41
|
if (!plan.legacy) throw new Error('Expected a legacy restart plan.');
|
|
33
|
-
if (yes ||
|
|
34
|
-
!interactive) throw new Error('Legacy migration requires interactive acknowledgement of this exact restart/uncertain-HTTP/partial-tree plan. Run upgrade --to <version> without --yes in an interactive terminal; pause callers before confirming.');
|
|
35
42
|
const digest = legacyPlanDigest(plan);
|
|
36
|
-
|
|
37
|
-
|
|
43
|
+
if (!yes) {
|
|
44
|
+
if (!interactive) throw new Error('Legacy migration needs confirmation. Run upgrade --to <version> in an interactive terminal, or add --yes to confirm the plan shown.');
|
|
45
|
+
const answer = await confirm(`${LEGACY_WARNING}\nPlan: ${digest}`);
|
|
46
|
+
if (answer !== true) return false;
|
|
47
|
+
}
|
|
38
48
|
if (legacyPlanDigest(plan) !== digest) throw new Error('Legacy plan changed during confirmation.');
|
|
39
49
|
approved.set(plan, digest);
|
|
40
50
|
return true;
|
|
41
51
|
}
|
|
42
52
|
|
|
43
53
|
export function requireLegacyApproval(plan) {
|
|
44
|
-
if (approved.get(plan) !== legacyPlanDigest(plan)) throw new Error('This legacy plan has no matching
|
|
54
|
+
if (approved.get(plan) !== legacyPlanDigest(plan)) throw new Error('This legacy plan has no matching confirmation.');
|
|
45
55
|
approved.delete(plan);
|
|
46
56
|
}
|
|
47
57
|
|
|
@@ -132,10 +142,58 @@ export function legacySessionState(config) {
|
|
|
132
142
|
return existsSync(path) ? new PoolingFiles(config).inspect(path) : null;
|
|
133
143
|
}
|
|
134
144
|
|
|
145
|
+
export function legacyStatePort(home) {
|
|
146
|
+
const statePath = join(home, 'state.json');
|
|
147
|
+
if (!existsSync(statePath)) throw new Error('No stable managed installation or exact legacy install state was found. Nothing was changed.');
|
|
148
|
+
const state = readManagedJson(statePath);
|
|
149
|
+
const ports = [...new Set((state.hosts ?? []).map(host => canonicalPort(host.port)))];
|
|
150
|
+
if (ports.length !== 1) throw new Error('Legacy migration requires exactly one selected port in existing install state.');
|
|
151
|
+
return { statePath, port: ports[0] };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function legacyAccessReport(report, registration) {
|
|
155
|
+
const bound = report?.protocol === 1 &&
|
|
156
|
+
report.task?.path === registration.path &&
|
|
157
|
+
report.expected?.xmlSha256 === registration.xmlSha256 &&
|
|
158
|
+
Array.isArray(report.missing) &&
|
|
159
|
+
Array.isArray(report.fixable) &&
|
|
160
|
+
report.missing.every(id => Object.hasOwn(ACCESS_PROBLEMS, id)) &&
|
|
161
|
+
report.fixable.every(id => Object.hasOwn(ACCESS_FIXES, id) && report.missing.includes(id));
|
|
162
|
+
if (!bound) throw new Error('Legacy access check did not match the selected registration. Nothing was changed.');
|
|
163
|
+
return report;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function legacyAccessProblems(report) {
|
|
167
|
+
return report.missing.map(id => ACCESS_PROBLEMS[id](report));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// A certain gap here would otherwise fail after the journal is published (task hold) or after staging (CLI copy).
|
|
171
|
+
export function assertLegacyAccess(report, registration) {
|
|
172
|
+
legacyAccessReport(report, registration);
|
|
173
|
+
if (!report.missing.length) return;
|
|
174
|
+
const lines = [
|
|
175
|
+
'Your account cannot make the changes this upgrade needs. Nothing was changed.',
|
|
176
|
+
...legacyAccessProblems(report).map(problem => ` - ${problem}`),
|
|
177
|
+
];
|
|
178
|
+
if (report.fixable.length) {
|
|
179
|
+
lines.push(
|
|
180
|
+
'This usually means 1.3 was set up from an administrator terminal.',
|
|
181
|
+
`Run this once from ${report.elevated ? 'this' : 'an'} administrator terminal. It will only ${report.fixable.map(id => ACCESS_FIXES[id](report)).join(' and ')}:`,
|
|
182
|
+
' mcp-pacemaker upgrade --fix-legacy-access',
|
|
183
|
+
'Then run this upgrade again from a normal (non-administrator) terminal.',
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
if (report.missing.some(id => !report.fixable.includes(id))) {
|
|
187
|
+
lines.push('--fix-legacy-access does not change the other permissions listed; an administrator must grant them.');
|
|
188
|
+
}
|
|
189
|
+
throw Object.assign(new Error(lines.join('\n')), { code: 'LEGACY_ACCESS_MISSING' });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 1.3 rewrites sessions.json whenever a session starts or ends, and an upgrade may end
|
|
193
|
+
// sessions, so only the config is compared.
|
|
135
194
|
export function verifyLegacyConfig(config, legacy) {
|
|
136
195
|
new PoolingFiles(config).verify(config, legacy.authority);
|
|
137
|
-
if (
|
|
138
|
-
hasPoolingTransaction(config)) throw new Error('Legacy configuration/session state changed or has an unresolved transaction.');
|
|
196
|
+
if (hasPoolingTransaction(config)) throw new Error('Legacy configuration has an unresolved transaction.');
|
|
139
197
|
}
|
|
140
198
|
|
|
141
199
|
async function nonceChallenge(port, nonce) {
|
|
@@ -183,21 +241,19 @@ export async function verifyLegacyListener(instance, legacy, { sameGeneration =
|
|
|
183
241
|
export async function prepareLegacyPlan(options, { home, task = new WindowsLegacyTaskAdapter(), processes } = {}) {
|
|
184
242
|
if (process.platform !== 'win32') throw new Error('Legacy restart migration has no supported adapter on this OS. Nothing was changed.');
|
|
185
243
|
if (options.sxs) throw new Error('Legacy-source SxS is not supported; no existing service was changed.');
|
|
186
|
-
const statePath =
|
|
187
|
-
if (!existsSync(statePath)) throw new Error('No stable managed installation or exact legacy install state was found. Nothing was changed.');
|
|
188
|
-
const state = readManagedJson(statePath);
|
|
189
|
-
const ports = [...new Set((state.hosts ?? []).map(host => canonicalPort(host.port)))];
|
|
190
|
-
if (ports.length !== 1) throw new Error('Legacy migration requires exactly one selected port in existing install state.');
|
|
191
|
-
const port = ports[0];
|
|
244
|
+
const { statePath, port } = legacyStatePort(home);
|
|
192
245
|
const records = task.inspectLegacySource ? await task.inspectLegacySource(port) : await task.inspect(port);
|
|
193
246
|
const { root, registration } = parseLegacyRegistration(records, port);
|
|
194
247
|
const rootIdentity = verifyLegacyPackage(root);
|
|
248
|
+
if (typeof task.access === 'function') assertLegacyAccess(await task.access(port, join(root, 'bin', 'cli.mjs')), registration);
|
|
195
249
|
const config = realpathSync.native(join(home, 'servers.json'));
|
|
196
250
|
const files = new PoolingFiles(config);
|
|
251
|
+
// 1.3 rewrites sessions.json on every session start and end, so it is read only after the
|
|
252
|
+
// old bridge has stopped.
|
|
197
253
|
const legacy = {
|
|
198
254
|
protocol: 1, root, rootIdentity, registration,
|
|
199
255
|
stateAuthority: files.inspect(statePath),
|
|
200
|
-
authority: files.inspect(config), sessions:
|
|
256
|
+
authority: files.inspect(config), sessions: null,
|
|
201
257
|
};
|
|
202
258
|
const instance = {
|
|
203
259
|
id: randomUUID(), directory: join(home, 'managed', `legacy-${port}-${randomUUID()}`),
|
package/bin/legacy-task.mjs
CHANGED
|
@@ -33,6 +33,30 @@ export class WindowsLegacyTaskAdapter {
|
|
|
33
33
|
repoint(port, expected, launcher) { return this.call('repoint', port, { expected, launcher }); }
|
|
34
34
|
enable(port, expected) { return this.call('enable', port, { expected }); }
|
|
35
35
|
restore(port, expected, original) { return this.call('restore', port, { expected, original }); }
|
|
36
|
+
|
|
37
|
+
callAccess(operation, port, cliPath, input) {
|
|
38
|
+
if (process.platform !== 'win32') throw new Error('Legacy access checks are supported only for the known Windows 1.3 registration.');
|
|
39
|
+
const script = fileURLToPath(new URL('../autostart/windows/legacy-access.ps1', import.meta.url));
|
|
40
|
+
let output;
|
|
41
|
+
try {
|
|
42
|
+
output = execFileSync('pwsh', [
|
|
43
|
+
'-NoProfile', '-NonInteractive', '-File', script, '-Operation', operation, '-Port', String(port), '-CliPath', cliPath,
|
|
44
|
+
], {
|
|
45
|
+
input: input ? JSON.stringify(input) : '', encoding: 'utf8',
|
|
46
|
+
windowsHide: true, timeout: 30000, maxBuffer: 1024 * 1024,
|
|
47
|
+
});
|
|
48
|
+
} catch (error) {
|
|
49
|
+
let reply;
|
|
50
|
+
try { reply = JSON.parse(String(error.stdout ?? '')); } catch { throw error; }
|
|
51
|
+
throw Object.assign(new Error(String(reply.error)), { changed: Array.isArray(reply.changed) ? reply.changed : [] });
|
|
52
|
+
}
|
|
53
|
+
return JSON.parse(output);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Read-only: what the ordinary runtime user cannot do to the task and bin/cli.mjs.
|
|
57
|
+
access(port, cliPath) { return this.callAccess('inspect', port, cliPath); }
|
|
58
|
+
// Elevated only: fixes the gaps reported by access(), bound to its returned descriptors.
|
|
59
|
+
grantAccess(port, cliPath, expected) { return this.callAccess('grant', port, cliPath, { expected }); }
|
|
36
60
|
assertRestored(port, actual, original) {
|
|
37
61
|
const { xml: actualXml, xmlSha256: actualHash, ...actualFields } = actual;
|
|
38
62
|
const { xml: originalXml, xmlSha256: originalHash, ...originalFields } = original;
|
package/bin/legacy-upgrade.mjs
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
5
|
import { isDeepStrictEqual } from 'node:util';
|
|
6
|
+
import semver from 'semver';
|
|
6
7
|
import {
|
|
7
8
|
acquireInstanceLock, createInstance, directoryIdentity, durableJson,
|
|
8
9
|
readInstanceState, readManagedJson, readRecoveryContext, snapshotPackage, verifySelection,
|
|
9
10
|
legacyMigrationPhases,
|
|
10
11
|
} from './managed-state.mjs';
|
|
11
12
|
import { inspectInstance, listenerOpen, startInstance, stopInstance } from './managed-runtime.mjs';
|
|
12
|
-
import { PoolingFiles } from './pooling-files.mjs';
|
|
13
|
+
import { MAX_CONFIG_BYTES, PoolingFiles } from './pooling-files.mjs';
|
|
13
14
|
import { registerDefaultCli } from './cli-dispatch.mjs';
|
|
14
15
|
import { servicePaths } from './service-control.mjs';
|
|
15
16
|
import { WindowsLegacyTaskAdapter } from './legacy-task.mjs';
|
|
16
17
|
import { requireUserRuntimeContext } from './runtime-context.mjs';
|
|
17
18
|
import {
|
|
18
|
-
legacyPlanDigest, parseLegacyRegistration, requireLegacyApproval, verifyLegacyConfig, verifyLegacyListener, verifyLegacyPackage,
|
|
19
|
+
legacyPlanDigest, legacySessionState, parseLegacyRegistration, requireLegacyApproval, verifyLegacyConfig, verifyLegacyListener, verifyLegacyPackage,
|
|
19
20
|
} from './legacy-installation.mjs';
|
|
20
21
|
|
|
21
22
|
export const legacyPhases = legacyMigrationPhases;
|
|
@@ -148,6 +149,7 @@ export class LegacyUpgrader {
|
|
|
148
149
|
await requireUserRuntimeContext();
|
|
149
150
|
const approvalDigest = legacyPlanDigest(plan);
|
|
150
151
|
requireLegacyApproval(plan);
|
|
152
|
+
this.requireSessionRecords(plan.instance.config, plan.to);
|
|
151
153
|
this.verifySource(plan.instance, plan.legacy);
|
|
152
154
|
await this.taskMatches(plan.instance, plan.legacy.registration);
|
|
153
155
|
const operation = randomUUID();
|
|
@@ -174,6 +176,8 @@ export class LegacyUpgrader {
|
|
|
174
176
|
const target = await this.stage({ ...plan, instance: { ...plan.instance, directory: work } }, operation);
|
|
175
177
|
const capability = readManagedJson(join(target.root, 'bin', 'upgrade-capability.json'));
|
|
176
178
|
if (capability.legacyRestartProtocol !== 1) throw new Error('Target does not support legacy restart recovery; old service was not touched.');
|
|
179
|
+
// The records can grow while the target downloads, so their size is checked again.
|
|
180
|
+
this.requireSessionRecords(plan.instance.config, target.version);
|
|
177
181
|
const recovery = snapshotPackage(target.root, join(work, 'recovery'));
|
|
178
182
|
verifySelection(recovery);
|
|
179
183
|
this.verifySource(plan.instance, plan.legacy);
|
|
@@ -247,7 +251,12 @@ export class LegacyUpgrader {
|
|
|
247
251
|
const legacyStop = await session.stop();
|
|
248
252
|
this.transition(instance, journal, 'legacy-stopping', { legacyStop });
|
|
249
253
|
this.assertPartialStop(legacyStop);
|
|
250
|
-
|
|
254
|
+
// The 1.3 bridge has stopped, so its session records no longer change. An older target
|
|
255
|
+
// still compares them at admission.
|
|
256
|
+
const sessions = this.sessionRecords(instance.config);
|
|
257
|
+
this.transition(instance, journal, 'legacy-stopped', {
|
|
258
|
+
legacyStop, sessionState: sessions, legacy: { ...journal.legacy, sessions },
|
|
259
|
+
});
|
|
251
260
|
return await this.activate(instance, journal);
|
|
252
261
|
} catch (error) {
|
|
253
262
|
if (journal &&
|
|
@@ -268,14 +277,53 @@ export class LegacyUpgrader {
|
|
|
268
277
|
}
|
|
269
278
|
}
|
|
270
279
|
|
|
280
|
+
// Targets before 2.1.1 compare session records when they start, reading at most 1 MiB of them.
|
|
281
|
+
comparesSessionRecords(version) {
|
|
282
|
+
const ignoresRecords = semver.valid(version) &&
|
|
283
|
+
semver.gte(version, '2.1.1');
|
|
284
|
+
return !ignoresRecords;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Refuse such a target before anything is downloaded or stopped instead of restarting into a
|
|
288
|
+
// rollback. Only the size is checked, so a session starting or ending meanwhile cannot make
|
|
289
|
+
// this refuse.
|
|
290
|
+
requireSessionRecords(config, version) {
|
|
291
|
+
if (!this.comparesSessionRecords(version)) return;
|
|
292
|
+
const path = join(dirname(config), 'sessions.json');
|
|
293
|
+
const size = statSync(path, { throwIfNoEntry: false })?.size ?? 0;
|
|
294
|
+
if (size > MAX_CONFIG_BYTES) {
|
|
295
|
+
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.`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Records that could not be read after the stop would make such a target refuse admission,
|
|
300
|
+
// where rollback is no longer automatic. Refusing here, while the journal is still at
|
|
301
|
+
// `legacy-stopped`, restarts 1.3.
|
|
302
|
+
requireReadSessionRecords(config, journal) {
|
|
303
|
+
const unread = this.comparesSessionRecords(journal.target.version) &&
|
|
304
|
+
journal.sessionState === null &&
|
|
305
|
+
existsSync(join(dirname(config), 'sessions.json'));
|
|
306
|
+
if (unread) {
|
|
307
|
+
throw new Error(`Session records could not be read after the old version stopped, and ${journal.target.version} compares them when it starts.`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Read once the 1.3 bridge has stopped. A file the integrity helper refuses, such as one
|
|
312
|
+
// over 1 MiB, must not strand an upgrade whose old version is already down.
|
|
313
|
+
sessionRecords(config) {
|
|
314
|
+
try { return legacySessionState(config); }
|
|
315
|
+
catch { return null; }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// An upgrade may end the old bridge's sessions; only the supervisor and bridge must be proven
|
|
319
|
+
// stopped. A process the bridge started that could not be stopped is reported, not a refusal.
|
|
271
320
|
assertPartialStop(receipt) {
|
|
272
321
|
if (receipt?.legacyRootStopVerified !== true ||
|
|
273
|
-
receipt.observedDescendantsStopped !==
|
|
322
|
+
typeof receipt.observedDescendantsStopped !== 'boolean' ||
|
|
274
323
|
receipt.treeCompleteness !== 'unproven' ||
|
|
275
324
|
!Array.isArray(receipt.errors) ||
|
|
276
|
-
receipt.errors.length ||
|
|
277
325
|
'stopped' in receipt ||
|
|
278
|
-
'activeProcesses' in receipt) throw new Error('Legacy
|
|
326
|
+
'activeProcesses' in receipt) throw new Error('Legacy supervisor and bridge shutdown is not verified. Registration remains held; no activation was authorized.');
|
|
279
327
|
}
|
|
280
328
|
|
|
281
329
|
async verifyOldStopped(instance, journal) {
|
|
@@ -286,6 +334,7 @@ export class LegacyUpgrader {
|
|
|
286
334
|
}
|
|
287
335
|
|
|
288
336
|
async activate(instance, journal) {
|
|
337
|
+
this.requireReadSessionRecords(instance.config, journal);
|
|
289
338
|
await this.verifyOldStopped(instance, journal);
|
|
290
339
|
this.verifySource(instance, journal.legacy);
|
|
291
340
|
await this.taskMatches(instance, journal.legacy.taskCurrent);
|
|
@@ -317,10 +366,16 @@ export class LegacyUpgrader {
|
|
|
317
366
|
return {
|
|
318
367
|
status: 'upgraded', directory: instance.directory, port: instance.port, version: journal.target.version,
|
|
319
368
|
legacyStop: journal.legacyStop,
|
|
320
|
-
warning:
|
|
369
|
+
warning: this.stopWarning(journal.legacyStop),
|
|
321
370
|
};
|
|
322
371
|
}
|
|
323
372
|
|
|
373
|
+
stopWarning(receipt) {
|
|
374
|
+
const ended = 'Open 1.3 sessions and calls in progress were ended; clients reconnect. No call was retried.';
|
|
375
|
+
if (receipt.observedDescendantsStopped) return ended;
|
|
376
|
+
return `${ended} Not every process the 1.3 bridge started could be confirmed stopped; see legacyStop.errors.`;
|
|
377
|
+
}
|
|
378
|
+
|
|
324
379
|
selectCli(instance, journal) {
|
|
325
380
|
const cli = journal.legacy.cli;
|
|
326
381
|
const files = new PoolingFiles(cli.path);
|
|
@@ -407,7 +462,8 @@ export class LegacyUpgrader {
|
|
|
407
462
|
this.assertRestoredTask(instance.port, journal.legacy.taskCurrent, journal.legacy.registration);
|
|
408
463
|
this.transition(instance, journal, 'legacy-rollback-launching');
|
|
409
464
|
await this.task.start(instance.port, journal.legacy.taskCurrent);
|
|
410
|
-
const
|
|
465
|
+
const started = Date.now();
|
|
466
|
+
const deadline = started + RESTART_OBSERVATION_MS;
|
|
411
467
|
let lastError;
|
|
412
468
|
while (Date.now() < deadline) {
|
|
413
469
|
try {
|
|
@@ -424,7 +480,7 @@ export class LegacyUpgrader {
|
|
|
424
480
|
await new Promise(resolveWait => setTimeout(resolveWait, 100));
|
|
425
481
|
}
|
|
426
482
|
throw new Error('Legacy restart outcome is uncertain; retained code, task and journal require inspection.' +
|
|
427
|
-
(lastError ? ` Last observation after ${
|
|
483
|
+
(lastError ? ` Last observation after ${Date.now() - started} ms: ${lastError.message}` : ''),
|
|
428
484
|
{ cause: lastError });
|
|
429
485
|
}
|
|
430
486
|
|
package/bin/managed-upgrade.mjs
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
|
-
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
|
+
import semver from 'semver';
|
|
6
7
|
import {
|
|
7
8
|
acquireInstanceLock, createInstance, directoryIdentity, durableJson,
|
|
8
9
|
loadInstance, readInstanceState, readRecoveryContext, readManagedJson, verifySelection,
|
|
9
10
|
} from './managed-state.mjs';
|
|
10
11
|
import { acquireUpgrade, configuredRegistry, exactVersion, unpackUpgrade, verifyArchive } from './upgrade-package.mjs';
|
|
11
12
|
import { inspectInstance, listenerOpen, startInstance, stopInstance } from './managed-runtime.mjs';
|
|
12
|
-
import { PoolingFiles, hasPoolingTransaction } from './pooling-files.mjs';
|
|
13
|
+
import { MAX_CONFIG_BYTES, PoolingFiles, hasPoolingTransaction } from './pooling-files.mjs';
|
|
13
14
|
import { inspectAutostart } from './upgrade-autostart.mjs';
|
|
14
15
|
import { validateRegistry } from './update-check.mjs';
|
|
15
16
|
import { servicePaths } from './service-control.mjs';
|
|
@@ -26,9 +27,10 @@ function sessionState(config) {
|
|
|
26
27
|
return existsSync(path) ? new PoolingFiles(config).inspect(path) : null;
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
// Session records change whenever a client connects. An upgrade may end sessions, so only
|
|
31
|
+
// the config is protected here.
|
|
29
32
|
function verifyRollbackState(instance, journal) {
|
|
30
33
|
new PoolingFiles(instance.config).verify(instance.config, journal.authority);
|
|
31
|
-
if (!same(sessionState(instance.config), journal.sessionState)) throw new Error('Persisted session state changed; rollback/activation refused.');
|
|
32
34
|
if (hasPoolingTransaction(instance.config)) throw new Error('Config outcome is uncertain; automatic rollback refused.');
|
|
33
35
|
}
|
|
34
36
|
|
|
@@ -105,9 +107,9 @@ export async function planUpgrade(options, { home, legacy, autostart: inspectSta
|
|
|
105
107
|
affectedInstances: [prepared.legacy.backendInstanceId],
|
|
106
108
|
autostart: { kind: 'legacy', name: prepared.legacy.registration.name },
|
|
107
109
|
downtime: LEGACY_WARNING,
|
|
108
|
-
rollback: 'Only before target admission, with unchanged config
|
|
110
|
+
rollback: 'Only before target admission, with unchanged config and a verified supervisor and bridge stop. Uncertain launch or task outcomes require explicit recovery.',
|
|
109
111
|
hostWiring: 'Unchanged. The same installed CLI entry will dispatch the selected managed version.',
|
|
110
|
-
legacyAdoption: 'One-time restart
|
|
112
|
+
legacyAdoption: 'One-time restart. A process the 1.3 bridge started that cannot be stopped is reported and left running.',
|
|
111
113
|
};
|
|
112
114
|
}
|
|
113
115
|
if (instances.length !== 1) {
|
|
@@ -153,7 +155,7 @@ export function describeUpgrade(plan) {
|
|
|
153
155
|
export class ManagedUpgrader {
|
|
154
156
|
constructor({ acquire = acquireUpgrade, dependencies = stageCliDependencies, checkpoint = () => {}, legacy,
|
|
155
157
|
autostart = inspectAutostart, beforeInterruption = async () => {},
|
|
156
|
-
beforeStop = async () => {}, beforeAdmission = async () => {} } = {}) {
|
|
158
|
+
beforeStop = async () => {}, beforeAdmission = async () => {}, afterRefusal = async () => {} } = {}) {
|
|
157
159
|
this.acquire = acquire;
|
|
158
160
|
this.dependencies = dependencies;
|
|
159
161
|
this.checkpoint = checkpoint;
|
|
@@ -162,6 +164,7 @@ export class ManagedUpgrader {
|
|
|
162
164
|
this.beforeInterruption = beforeInterruption;
|
|
163
165
|
this.beforeStop = beforeStop;
|
|
164
166
|
this.beforeAdmission = beforeAdmission;
|
|
167
|
+
this.afterRefusal = afterRefusal;
|
|
165
168
|
}
|
|
166
169
|
|
|
167
170
|
transition(directory, journal, phase, changes = {}) {
|
|
@@ -246,10 +249,10 @@ export class ManagedUpgrader {
|
|
|
246
249
|
same(instanceBinding(source), instanceBinding(plan.instance));
|
|
247
250
|
if (!matchesPlan) throw new Error('Managed selection changed after the plan; create a new plan.');
|
|
248
251
|
assertNoPendingJournal(source);
|
|
252
|
+
if (!plan.options.sxs) this.requireSessionRecords(source.config, plan.to);
|
|
249
253
|
await this.autostart(source);
|
|
250
254
|
const current = await inspectInstance(source);
|
|
251
255
|
if (current.unsafe) throw new Error('Unresolved transaction blocks upgrade.');
|
|
252
|
-
const sessions = sessionState(source.config);
|
|
253
256
|
const target = await this.stage(plan, operation);
|
|
254
257
|
verifySelection(target);
|
|
255
258
|
const reloaded = loadInstance(source.directory, { verifyFiles: false });
|
|
@@ -259,11 +262,15 @@ export class ManagedUpgrader {
|
|
|
259
262
|
if (plan.options.sxs) {
|
|
260
263
|
return await this.startSxs(source, plan, target, operation);
|
|
261
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.
|
|
262
269
|
journal = {
|
|
263
270
|
protocol: 1, operation, instanceId: source.id, previous: source.active, target,
|
|
264
271
|
binding: instanceBinding(source),
|
|
265
272
|
authority: current.authority, originalInstanceId: current.instanceId,
|
|
266
|
-
sessionState:
|
|
273
|
+
sessionState: null, launchToken: randomUUID(), phase: 'prepared',
|
|
267
274
|
};
|
|
268
275
|
const backup = join(source.directory, 'staging', operation, 'servers.backup.json');
|
|
269
276
|
new PoolingFiles(source.config).stage(backup, source.config, current.authority, readFileSync(source.config));
|
|
@@ -271,13 +278,12 @@ export class ManagedUpgrader {
|
|
|
271
278
|
await this.beforeInterruption(source, journal);
|
|
272
279
|
this.transition(destination, journal, 'prepared');
|
|
273
280
|
this.transition(destination, journal, 'quiescing');
|
|
274
|
-
const drained = await
|
|
275
|
-
if (!same(drained.authority, journal.authority)) throw new Error('Configuration changed while staging; retry from a new plan.');
|
|
281
|
+
const drained = await this.quiesce(source, destination, journal);
|
|
276
282
|
verifyRollbackState(source, journal);
|
|
277
283
|
await this.beforeStop(source, journal);
|
|
278
284
|
this.transition(destination, journal, 'stopping', { quiescedInstanceId: drained.instanceId });
|
|
279
285
|
const stoppedProof = await stopInstance(source, drained.instanceId);
|
|
280
|
-
this.transition(destination, journal, 'stopped', { stoppedProof });
|
|
286
|
+
this.transition(destination, journal, 'stopped', { stoppedProof, sessionState: this.sessionRecords(source.config) });
|
|
281
287
|
return await this.activate(destination, journal);
|
|
282
288
|
} catch (error) {
|
|
283
289
|
if (journal) {
|
|
@@ -294,6 +300,89 @@ export class ManagedUpgrader {
|
|
|
294
300
|
}
|
|
295
301
|
}
|
|
296
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
|
+
|
|
297
386
|
async startSxs(source, plan, target, operation) {
|
|
298
387
|
const current = await inspectInstance(source);
|
|
299
388
|
if (current.unsafe ||
|
|
@@ -340,6 +429,7 @@ export class ManagedUpgrader {
|
|
|
340
429
|
|
|
341
430
|
async activate(directory, journal) {
|
|
342
431
|
const instance = loadInstance(directory, { verifyFiles: false });
|
|
432
|
+
this.requireReadSessionRecords(instance.config, journal);
|
|
343
433
|
verifyRollbackState(instance, journal);
|
|
344
434
|
verifySelection(journal.target);
|
|
345
435
|
this.transition(directory, journal, 'selecting');
|
|
@@ -479,7 +569,7 @@ export class ManagedUpgrader {
|
|
|
479
569
|
if (journal.phase === 'stopping') {
|
|
480
570
|
await this.beforeStop(instance, journal);
|
|
481
571
|
const stoppedProof = await stopInstance(instance, journal.quiescedInstanceId);
|
|
482
|
-
this.transition(instance.directory, journal, 'stopped', { stoppedProof });
|
|
572
|
+
this.transition(instance.directory, journal, 'stopped', { stoppedProof, sessionState: this.sessionRecords(instance.config) });
|
|
483
573
|
}
|
|
484
574
|
if (['stopped', 'selecting', 'selected', 'launching',
|
|
485
575
|
'rollback-selecting', 'rollback-selected', 'rollback-launching'].includes(journal.phase)) {
|