diff --git a/packages/cli/README.md b/packages/cli/README.md
index 565019460e..0e2008c1c8 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -133,9 +133,11 @@ maka runtime-host service check-update --target next --json
```
The result pins the selected channel to an exact version and package integrity. It also reports
-whether the package carries enough compatibility evidence for a future unattended update; this
-command never installs or switches a package. An updater must still verify the downloaded archive
-against that integrity and confirm the compatibility value from its extracted package manifest.
+whether the package carries enough compatibility evidence for unattended use; this command never
+installs or switches a package. Installation-management callers can pass the same selector to
+`service update --target`. That path verifies the archive and extracted manifest before delegating
+to the existing exact-package update transaction, and does not mutate a candidate that requires
+manual review.
## Uninstall
diff --git a/packages/cli/README.zh-CN.md b/packages/cli/README.zh-CN.md
index 4e66195a38..244830149a 100644
--- a/packages/cli/README.zh-CN.md
+++ b/packages/cli/README.zh-CN.md
@@ -125,8 +125,9 @@ maka runtime-host service check-update --target next --json
```
结果会把频道固定为精确版本和 package integrity,并说明 package 是否提供足够的兼容性证据,
-可供未来的无人值守更新使用;该命令不会安装或切换 package。更新程序仍须以该 integrity
-校验下载的 archive,并从解压后的 package manifest 中再次确认兼容性值。
+可供无人值守流程使用;该命令不会安装或切换 package。安装管理方可以把同一
+selector 传给 `service update --target`。该路径会先校验 archive 与解包后的 manifest,再委托给
+现有的精确 package 更新事务;需要人工审查的候选不会改变当前 Host。
## 卸载
diff --git a/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts b/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts
index 0d7a3e9343..4bb50c7708 100644
--- a/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts
@@ -117,27 +117,29 @@ test('maps install, stop, start, restart, and uninstall onto one LaunchAgent ser
});
});
-test('restores the previous loaded LaunchAgent when replacement bootstrap fails', async () => {
- await withFixture(async ({ homeDir, cliPath, launchctl }) => {
- const plistPath = resolveLaunchAgentPath(SERVICE_ID, homeDir);
- const previousPlist = 'previous\n';
- await writeFile(plistPath, previousPlist, { mode: 0o600 });
- launchctl.loaded = true;
- launchctl.failNextBootstrap = true;
- const backend = createLaunchAgentRuntimeHostService(SERVICE_ID, {
- homeDir,
- uid: UID,
- runLaunchctl: launchctl.run,
- isProcessAlive: () => false,
- });
+test('restores the previous loaded LaunchAgent when deployment bootstrap fails', async () => {
+ for (const action of ['install', 'replace'] as const) {
+ await withFixture(async ({ homeDir, cliPath, launchctl }) => {
+ const plistPath = resolveLaunchAgentPath(SERVICE_ID, homeDir);
+ const previousPlist = 'previous\n';
+ await writeFile(plistPath, previousPlist, { mode: 0o600 });
+ launchctl.loaded = true;
+ launchctl.failNextBootstrap = true;
+ const backend = createLaunchAgentRuntimeHostService(SERVICE_ID, {
+ homeDir,
+ uid: UID,
+ runLaunchctl: launchctl.run,
+ isProcessAlive: () => false,
+ });
- await assert.rejects(
- backend.install(fixtureConfig(process.execPath, cliPath, join(homeDir, 'state'))),
- /Starting the Runtime Host LaunchAgent failed/u,
- );
- assert.equal(await readFile(plistPath, 'utf8'), previousPlist);
- assert.equal(launchctl.loaded, true);
- });
+ await assert.rejects(
+ backend[action](fixtureConfig(process.execPath, cliPath, join(homeDir, 'state'))),
+ /Starting the Runtime Host LaunchAgent failed/u,
+ );
+ assert.equal(await readFile(plistPath, 'utf8'), previousPlist);
+ assert.equal(launchctl.loaded, true);
+ });
+ }
});
interface FakeLaunchctl {
diff --git a/packages/cli/src/__tests__/runtime-host-selected-update.test.ts b/packages/cli/src/__tests__/runtime-host-selected-update.test.ts
new file mode 100644
index 0000000000..8ce5ac0194
--- /dev/null
+++ b/packages/cli/src/__tests__/runtime-host-selected-update.test.ts
@@ -0,0 +1,176 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import { decodeRuntimeHostServiceManagementFrame } from '@maka/runtime-host/operator';
+import {
+ runManagedRuntimeHostSelectedUpdateCli,
+ type RuntimeHostSelectedUpdateCliOptions,
+ type RuntimeHostUpdateCliOptions,
+} from '../runtime-host-update-command.js';
+import { parseRuntimeHostCommand } from '../runtime-host-cli.js';
+import type { RuntimeHostUpdateSelection } from '../runtime-host-update-discovery.js';
+
+const INTEGRITY =
+ 'sha512-jUKdo/5dbM94KXq+kOZ1d+obhDLAENfI/QWr1PnXWcdu2PqDyLklJBtiVO6HRwoL1l40z1NE9Rq+hLAxCN0Fyg==';
+const TARGET = {
+ serviceId: 'b'.repeat(64),
+ rootPath: '/srv/maka',
+ rootId: 'a'.repeat(64),
+};
+const OPTIONS: RuntimeHostSelectedUpdateCliOptions = {
+ json: false,
+ framed: true,
+ clientDataRoot: '/client',
+ defaultRootPath: '/workspace',
+ selector: { kind: 'channel', channel: 'next' },
+ expectedTarget: TARGET,
+};
+
+describe('managed Runtime Host selected update', () => {
+ it('parses an optional target without changing the exact-package command', () => {
+ assert.deepEqual(
+ parseRuntimeHostCommand([
+ 'service',
+ 'update',
+ '--target',
+ 'next',
+ '--expected-service-id',
+ TARGET.serviceId,
+ '--expected-root-path',
+ TARGET.rootPath,
+ '--expected-root-id',
+ TARGET.rootId,
+ ]),
+ {
+ kind: 'runtime-host-service-update',
+ json: false,
+ selector: { kind: 'channel', channel: 'next' },
+ expectedTarget: TARGET,
+ },
+ );
+ assert.equal(
+ 'selector' in
+ parseRuntimeHostCommand([
+ 'service',
+ 'update',
+ '--expected-service-id',
+ TARGET.serviceId,
+ '--expected-root-path',
+ TARGET.rootPath,
+ '--expected-root-id',
+ TARGET.rootId,
+ ]),
+ false,
+ );
+ });
+
+ it('hands one verified admitted package to the existing update transaction', async () => {
+ const selection = updateSelection({
+ kind: 'unattended_update',
+ compatibility: 7,
+ });
+ let updateInput: RuntimeHostUpdateCliOptions | undefined;
+ const exitCode = await runManagedRuntimeHostSelectedUpdateCli(OPTIONS, {
+ resolveSelection: async () => selection,
+ withPackage: async (candidate, use) => {
+ assert.deepEqual(candidate, selection.candidate);
+ return use('/verified/package');
+ },
+ update: async (input) => {
+ updateInput = input;
+ return 0;
+ },
+ });
+ assert.equal(exitCode, 0);
+ assert.equal(updateInput?.sourcePackageRoot, '/verified/package');
+ assert.equal(updateInput?.version, '2.0.0');
+ assert.deepEqual(updateInput?.registrySelection, {
+ integrity: INTEGRITY,
+ current: {
+ version: '1.0.0',
+ cliPath: '/managed/versions/1.0.0/dist/cli.js',
+ },
+ });
+ });
+
+ it('lets the exact transaction decide whether a current candidate needs repair', async () => {
+ const selection = updateSelection({ kind: 'current' });
+ let updateInput: RuntimeHostUpdateCliOptions | undefined;
+ assert.equal(
+ await runManagedRuntimeHostSelectedUpdateCli(OPTIONS, {
+ resolveSelection: async () => selection,
+ withPackage: async () => assert.fail('the current deployment must not be downloaded'),
+ update: async (input) => {
+ updateInput = input;
+ return 0;
+ },
+ }),
+ 0,
+ );
+ assert.equal(updateInput?.sourcePackageRoot, '/managed/versions/2.0.0');
+ });
+
+ it('keeps non-admitted candidates outside package acquisition and mutation', async () => {
+ let output = '';
+ const exitCode = await runManagedRuntimeHostSelectedUpdateCli(OPTIONS, {
+ resolveSelection: async () =>
+ updateSelection({
+ kind: 'manual_action',
+ reason: 'compatibility_mismatch',
+ }),
+ withPackage: async () => assert.fail('package acquisition is not expected'),
+ update: async () => assert.fail('the update transaction is not expected'),
+ writeOutput: (value) => {
+ output += value;
+ },
+ });
+ const frame = decodeRuntimeHostServiceManagementFrame(output.trim());
+ assert.equal(exitCode, 1);
+ assert.equal(frame?.kind === 'error' ? frame.error.code : undefined, 'update_not_admitted');
+ });
+});
+
+function updateSelection(
+ outcome: RuntimeHostUpdateSelection['outcome'],
+): RuntimeHostUpdateSelection {
+ const candidate = {
+ version: '2.0.0',
+ integrity: INTEGRITY,
+ compatibility: 7,
+ };
+ return {
+ selector: OPTIONS.selector,
+ candidate,
+ outcome,
+ currentCliPath: `/managed/versions/${outcome.kind === 'current' ? candidate.version : '1.0.0'}/dist/cli.js`,
+ service: {
+ platform: 'linux',
+ arch: 'x64',
+ osRelease: 'test',
+ state: 'running',
+ pid: 42,
+ lastExitCode: null,
+ installedVersion: outcome.kind === 'current' ? candidate.version : '1.0.0',
+ stateRoot: TARGET.rootPath,
+ projectDirectoryRoots: [],
+ },
+ };
+}
diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
index 56abf415c0..244bdb7c93 100644
--- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
@@ -43,8 +43,11 @@ import {
import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority';
import { parseRuntimeHostCommand } from '../runtime-host-cli.js';
import {
+ openRuntimeHostManagedPackageDeployment,
+ prepareRuntimeHostManagedPackageDeployment,
removeRuntimeHostManagedDeployment,
resolveRuntimeHostManagedDeploymentRoot,
+ resolveRuntimeHostManagedPackageCliPath,
} from '../runtime-host-managed-deployment.js';
import { runManagedRuntimeHostServiceCli } from '../runtime-host-service-management-command.js';
import { runManagedRuntimeHostUpdateCli } from '../runtime-host-update-command.js';
@@ -1228,9 +1231,24 @@ describe('managed Runtime Host service', () => {
/Starting the Runtime Host service failed/u,
);
assert.match(await readFile(unitPath, 'utf8'), /--websocket-port" "41001"/u);
+
+ const replacementBackend = backend();
+ await replacementBackend.stop();
+ systemd.failNext('restart');
+ assert.ok(first.service.config);
+ const replacementConfig = {
+ ...first.service.config,
+ websocket: { ...first.service.config.websocket, port: 41_004 },
+ };
+ await assert.rejects(
+ replacementBackend.replace(replacementConfig),
+ /Starting the Runtime Host service failed/u,
+ );
+ assert.match(await readFile(unitPath, 'utf8'), /--websocket-port" "41001"/u);
+ assert.equal((await replacementBackend.status()).state, 'stopped');
});
- it('keeps the selected package configured when replacement readiness is unknown', async (t) => {
+ it('distinguishes backend replacement failure from unknown target readiness', async (t) => {
const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-update-failure-'));
t.after(() => rm(base, { recursive: true, force: true }));
const clientDataRoot = join(base, 'config');
@@ -1243,6 +1261,8 @@ describe('managed Runtime Host service', () => {
await writeFile(targetCli, '#!/usr/bin/env node\n', 'utf8');
let state: 'running' | 'stopped' = 'running';
let replaceCalls = 0;
+ let replaceFails = true;
+ let stopFails = false;
const backend: RuntimeHostServiceBackend = {
...createReadyBackend(),
status: async () => ({
@@ -1256,9 +1276,11 @@ describe('managed Runtime Host service', () => {
}),
replace: async () => {
replaceCalls += 1;
- throw new Error('replacement failed after launch');
+ if (replaceFails) throw new Error('replacement was not committed');
+ state = 'running';
},
stop: async () => {
+ if (stopFails) throw new Error('replacement could not be stopped');
state = 'stopped';
},
};
@@ -1284,10 +1306,37 @@ describe('managed Runtime Host service', () => {
error instanceof RuntimeHostServiceManagerError && error.code === 'update_incomplete',
);
assert.equal(replaceCalls, 1);
- const config = JSON.parse(
+ const restored = JSON.parse(
await readFile(resolveRuntimeHostManagedServiceConfigPath(clientDataRoot), 'utf8'),
) as RuntimeHostManagedServiceConfig;
- assert.equal(config.launch.cliPath, await realpath(targetCli));
+ assert.equal(restored.launch.cliPath, await realpath(previousCli));
+
+ replaceFails = false;
+ await assert.rejects(
+ replaceRuntimeHostManagedService({ ...common, cliPath: targetCli, expectedTarget }, backend, {
+ waitForReady: async () => Promise.reject(new Error('target readiness is unknown')),
+ }),
+ (error: unknown) =>
+ error instanceof RuntimeHostServiceManagerError && error.code === 'update_incomplete',
+ );
+ assert.equal(replaceCalls, 2);
+ const retained = JSON.parse(
+ await readFile(resolveRuntimeHostManagedServiceConfigPath(clientDataRoot), 'utf8'),
+ ) as RuntimeHostManagedServiceConfig;
+ assert.equal(retained.launch.cliPath, await realpath(targetCli));
+
+ stopFails = true;
+ await assert.rejects(
+ replaceRuntimeHostManagedService({ ...common, cliPath: targetCli, expectedTarget }, backend, {
+ waitForReady: async () => Promise.reject(new Error('target readiness is unknown')),
+ }),
+ (error: unknown) =>
+ error instanceof RuntimeHostServiceManagerError &&
+ error.code === 'update_incomplete' &&
+ error.cause instanceof AggregateError &&
+ /could not be stopped/u.test(error.message),
+ );
+ assert.equal(state, 'running');
});
it('updates through the current operator and preserves exact update outcomes', async () => {
@@ -1300,7 +1349,11 @@ describe('managed Runtime Host service', () => {
rootId: 'a'.repeat(64),
};
const order: string[] = [];
- const service = (version: string, state: 'running' | 'stopped') =>
+ const service = (
+ version: string,
+ state: 'running' | 'stopped',
+ cliPath = join(deploymentRoot, 'versions', version, 'dist', 'cli.js'),
+ ) =>
({
schemaVersion: 1,
action: 'status',
@@ -1321,7 +1374,7 @@ describe('managed Runtime Host service', () => {
websocket: { host: '127.0.0.1', port: 7400, path: '/runtime-host' },
launch: {
nodePath: process.execPath,
- cliPath: join(deploymentRoot, 'versions', version, 'dist', 'cli.js'),
+ cliPath,
},
},
},
@@ -1329,11 +1382,15 @@ describe('managed Runtime Host service', () => {
let statusReads = 0;
let observedVersion = '1.0.0';
let observedState: 'running' | 'stopped' = 'running';
- let readyChecks = 0;
+ let observedCliPath: string | undefined;
let readyFailure = false;
let operatorSupportsProcessLifetimeLock = false;
let legacyLeaseCalls = 0;
+ let operatorStatusFailure = false;
let operatorFailure: Extract | undefined;
+ let replaceFailure = false;
+ let cleanupFailure = false;
+ let expectAllowInterruptActiveTasks = true;
let insideLifecycle = false;
let output = '';
const options = {
@@ -1346,6 +1403,24 @@ describe('managed Runtime Host service', () => {
expectedTarget,
allowInterruptActiveTasks: true,
} as const;
+ const deployment = (version: string, cliPath: string) => ({
+ version,
+ root: deploymentRoot,
+ cliPath,
+ operatorPath: join(deploymentRoot, 'operator'),
+ activate: async () => {
+ assert.equal(insideLifecycle, true);
+ order.push('activate');
+ operatorSupportsProcessLifetimeLock = true;
+ },
+ cleanup: async () => {
+ order.push('cleanup');
+ if (cleanupFailure) throw new Error('Injected package cleanup failure');
+ },
+ rollback: async () => {
+ order.push('rollback');
+ },
+ });
const overrides = {
createBackend: createUnusedBackend,
withLifecycleLock: async (_root: string, operation: () => Promise) => {
@@ -1365,23 +1440,20 @@ describe('managed Runtime Host service', () => {
legacyLeaseCalls += 1;
return operation([]);
},
- prepareDeployment: async () => ({
- version: '2.0.0',
- root: deploymentRoot,
- cliPath: join(deploymentRoot, 'versions', '2.0.0', 'dist', 'cli.js'),
- operatorPath: join(deploymentRoot, 'operator'),
- activate: async () => {
- assert.equal(insideLifecycle, true);
- order.push('activate');
- operatorSupportsProcessLifetimeLock = true;
- },
- cleanup: async () => {
- order.push('cleanup');
- },
- rollback: async () => {
- order.push('rollback');
- },
- }),
+ openDeployment: async (
+ input: Parameters[0],
+ ) => deployment(input.version, input.cliPath),
+ prepareDeployment: async (
+ input: Parameters[0],
+ ) =>
+ deployment(
+ input.version,
+ resolveRuntimeHostManagedPackageCliPath(
+ deploymentRoot,
+ input.version,
+ input.packageIntegrity,
+ ),
+ ),
runOperator: async (
_operatorPath: string,
args: readonly string[],
@@ -1391,8 +1463,9 @@ describe('managed Runtime Host service', () => {
},
) => {
const action = args[0];
- assert.ok(action === 'status' || action === 'retire' || action === 'stop');
+ assert.ok(action === 'status' || action === 'retire');
if (action === 'status') {
+ if (operatorStatusFailure) throw new Error('The active operator is unavailable');
assert.equal(
invocation?.capabilityRequest,
RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY,
@@ -1422,24 +1495,11 @@ describe('managed Runtime Host service', () => {
};
}
order.push(action);
- if (action === 'retire') assert.ok(args.includes('--allow-interrupt-active-tasks'));
- if (action === 'stop') {
- return {
- schemaVersion: 1 as const,
- kind: 'result' as const,
- action: 'stop' as const,
- service: {
- platform: 'linux',
- arch: 'x64',
- osRelease: 'test',
- state: 'stopped' as const,
- pid: null,
- lastExitCode: 3,
- installedVersion: observedVersion,
- stateRoot: expectedTarget.rootPath,
- projectDirectoryRoots: [],
- },
- };
+ if (action === 'retire') {
+ assert.equal(
+ args.includes('--allow-interrupt-active-tasks'),
+ expectAllowInterruptActiveTasks,
+ );
}
if (operatorFailure) return operatorFailure;
return {
@@ -1461,19 +1521,33 @@ describe('managed Runtime Host service', () => {
};
},
verifyReady: async () => {
- readyChecks += 1;
if (readyFailure) throw new Error('Host is active but not ready');
},
manage: async (input: Parameters[0]) => {
+ if (input.action === 'stop') {
+ assert.equal(insideLifecycle, true);
+ order.push('stop');
+ return service(observedVersion, 'stopped', observedCliPath);
+ }
assert.equal(input.action, 'status');
statusReads += 1;
if (statusReads > 1) assert.equal(insideLifecycle, true);
- return service(observedVersion, statusReads === 1 ? observedState : 'stopped');
+ return service(
+ observedVersion,
+ statusReads === 1 ? observedState : 'stopped',
+ observedCliPath,
+ );
},
- replace: async () => {
+ replace: async (input: Parameters[0]) => {
assert.equal(insideLifecycle, true);
order.push('replace');
- return service('2.0.0', 'running').service;
+ if (replaceFailure) {
+ throw new RuntimeHostServiceManagerError(
+ 'update_incomplete',
+ 'The replacement did not become ready',
+ );
+ }
+ return service('2.0.0', 'running', input.cliPath).service;
},
writeOutput: (value: string) => {
output += value;
@@ -1516,15 +1590,84 @@ describe('managed Runtime Host service', () => {
observedState = 'running';
output = '';
assert.equal(await runManagedRuntimeHostUpdateCli(options, overrides), 0);
- assert.equal(readyChecks, 1);
+ assert.deepEqual(order, ['cleanup']);
+
+ cleanupFailure = true;
+ order.length = 0;
+ statusReads = 0;
+ output = '';
+ assert.equal(
+ await runManagedRuntimeHostUpdateCli({ ...options, json: true, framed: false }, overrides),
+ 1,
+ );
+ assert.deepEqual(order, ['cleanup']);
+ const cleanupRecovery = JSON.parse(output) as RuntimeHostServiceManagementFrame;
+ assert.equal(
+ cleanupRecovery.kind === 'error' ? cleanupRecovery.error.code : undefined,
+ 'update_incomplete',
+ );
+ cleanupFailure = false;
+
+ const localTargetCliPath = join(deploymentRoot, 'versions', '2.0.0', 'dist', 'cli.js');
+ const packageIntegrity =
+ 'sha512-jUKdo/5dbM94KXq+kOZ1d+obhDLAENfI/QWr1PnXWcdu2PqDyLklJBtiVO6HRwoL1l40z1NE9Rq+hLAxCN0Fyg==';
+ order.length = 0;
+ statusReads = 0;
+ output = '';
+ assert.equal(
+ await runManagedRuntimeHostUpdateCli(
+ {
+ ...options,
+ registrySelection: {
+ integrity: packageIntegrity,
+ current: { version: '2.0.0', cliPath: localTargetCliPath },
+ },
+ },
+ overrides,
+ ),
+ 0,
+ );
+ assert.deepEqual(order, ['retire', 'activate', 'replace', 'cleanup']);
+ const identityUpdate = decodeRuntimeHostServiceManagementFrame(
+ output.trim().split('\n').at(-1) ?? '',
+ );
+ assert.equal(
+ identityUpdate?.kind === 'result' && identityUpdate.action === 'update'
+ ? identityUpdate.update.kind
+ : undefined,
+ 'updated',
+ );
+
+ order.length = 0;
+ statusReads = 0;
+ output = '';
+ observedCliPath = join(deploymentRoot, 'versions', 'other', 'dist', 'cli.js');
+ assert.equal(
+ await runManagedRuntimeHostUpdateCli(
+ {
+ ...options,
+ registrySelection: {
+ integrity: packageIntegrity,
+ current: { version: '2.0.0', cliPath: localTargetCliPath },
+ },
+ },
+ overrides,
+ ),
+ 1,
+ );
assert.deepEqual(order, []);
+ const staleIdentity = decodeRuntimeHostServiceManagementFrame(output.trim());
+ assert.equal(
+ staleIdentity?.kind === 'error' ? staleIdentity.error.code : undefined,
+ 'target_mismatch',
+ );
+ observedCliPath = undefined;
order.length = 0;
statusReads = 0;
output = '';
readyFailure = true;
assert.equal(await runManagedRuntimeHostUpdateCli(options, overrides), 0);
- assert.equal(readyChecks, 2);
assert.deepEqual(order, ['retire', 'activate', 'replace', 'cleanup']);
assert.equal(legacyLeaseCalls, 1);
const activeRecovery = decodeRuntimeHostServiceManagementFrame(
@@ -1537,6 +1680,14 @@ describe('managed Runtime Host service', () => {
'repaired',
);
+ order.length = 0;
+ statusReads = 0;
+ output = '';
+ operatorStatusFailure = true;
+ assert.equal(await runManagedRuntimeHostUpdateCli(options, overrides), 0);
+ assert.deepEqual(order, ['stop', 'activate', 'replace', 'cleanup']);
+ operatorStatusFailure = false;
+
order.length = 0;
statusReads = 0;
output = '';
@@ -1546,6 +1697,24 @@ describe('managed Runtime Host service', () => {
action: 'retire',
error: { code: 'retirement_failed', message: 'The active Host is not reachable' },
};
+ expectAllowInterruptActiveTasks = false;
+ const { allowInterruptActiveTasks: _allowInterruptActiveTasks, ...safeOptions } = options;
+ assert.equal(await runManagedRuntimeHostUpdateCli(safeOptions, overrides), 1);
+ assert.deepEqual(order, ['retire', 'rollback']);
+ const activeTasks = decodeRuntimeHostServiceManagementFrame(
+ output.trim().split('\n').at(-1) ?? '',
+ );
+ assert.equal(
+ activeTasks?.kind === 'result' && activeTasks.action === 'update'
+ ? activeTasks.update.kind
+ : undefined,
+ 'active_tasks',
+ );
+
+ order.length = 0;
+ statusReads = 0;
+ output = '';
+ expectAllowInterruptActiveTasks = true;
assert.equal(await runManagedRuntimeHostUpdateCli(options, overrides), 0);
assert.deepEqual(order, ['retire', 'stop', 'activate', 'replace', 'cleanup']);
assert.equal(legacyLeaseCalls, 1);
@@ -1564,6 +1733,64 @@ describe('managed Runtime Host service', () => {
retirementFailure?.kind === 'error' ? retirementFailure.error.code : undefined,
'retirement_failed',
);
+
+ statusReads = 0;
+ operatorFailure = undefined;
+ replaceFailure = true;
+ output = '';
+ order.length = 0;
+ assert.equal(
+ await runManagedRuntimeHostUpdateCli(
+ {
+ ...options,
+ json: true,
+ framed: false,
+ registrySelection: {
+ integrity: packageIntegrity,
+ current: {
+ version: '1.0.0',
+ cliPath: join(deploymentRoot, 'versions', '1.0.0', 'dist', 'cli.js'),
+ },
+ },
+ },
+ overrides,
+ ),
+ 1,
+ );
+ assert.deepEqual(order, ['retire', 'activate', 'replace']);
+ const incomplete = JSON.parse(output) as RuntimeHostServiceManagementFrame;
+ assert.equal(
+ incomplete.kind === 'error' ? incomplete.error.code : undefined,
+ 'update_incomplete',
+ );
+
+ statusReads = 0;
+ observedVersion = '3.0.0';
+ replaceFailure = false;
+ output = '';
+ order.length = 0;
+ assert.equal(
+ await runManagedRuntimeHostUpdateCli(
+ {
+ ...options,
+ registrySelection: {
+ integrity: packageIntegrity,
+ current: {
+ version: '1.0.0',
+ cliPath: join(deploymentRoot, 'versions', '1.0.0', 'dist', 'cli.js'),
+ },
+ },
+ },
+ overrides,
+ ),
+ 1,
+ );
+ assert.deepEqual(order, []);
+ const staleCandidate = decodeRuntimeHostServiceManagementFrame(output.trim());
+ assert.equal(
+ staleCandidate?.kind === 'error' ? staleCandidate.error.code : undefined,
+ 'target_mismatch',
+ );
});
it('rejects invalid Project roots and temporary npx launch paths before deployment', async (t) => {
diff --git a/packages/cli/src/__tests__/runtime-host-setup.test.ts b/packages/cli/src/__tests__/runtime-host-setup.test.ts
index 43d34f8b34..f68ec7f934 100644
--- a/packages/cli/src/__tests__/runtime-host-setup.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-setup.test.ts
@@ -26,11 +26,12 @@ import {
readFile,
readdir,
realpath,
+ rename,
rm,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
-import { join } from 'node:path';
+import { basename, dirname, join } from 'node:path';
import { test } from 'node:test';
import { promisify } from 'node:util';
import {
@@ -52,6 +53,7 @@ import {
} from '../runtime-host-service-manager.js';
const execFile = promisify(execFileCallback);
+const PACKAGE_INTEGRITY = `sha512-${Buffer.alloc(64, 7).toString('base64')}`;
test('managed setup converges on one exact package and verified Client pairing', async (t) => {
const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-setup-'));
@@ -281,42 +283,164 @@ test('managed setup replaces one exact development package with another', async
assert.deepEqual(await readdir(join(previousDeployment.root, 'versions')), [nextVersion]);
});
-test('managed setup removes a newly copied package when service installation fails', async (t) => {
- const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-setup-failure-'));
+test('registry package identity avoids local content and recovers an interrupted removal', async (t) => {
+ const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-registry-package-'));
t.after(() => rm(base, { recursive: true, force: true }));
- const sourcePackageRoot = await createReleasePackage(base, '0.2.0');
+ const version = '0.2.0';
+ const localPackage = await createReleasePackage(join(base, 'local'), version);
+ const registryPackage = await createReleasePackage(join(base, 'registry'), version);
+ await writeFile(join(localPackage, 'dist', 'cli.js'), 'local package\n');
+ await writeFile(join(registryPackage, 'dist', 'cli.js'), 'registry package\n');
const clientDataRoot = join(base, 'config', 'Maka');
const serviceId = resolveRuntimeHostManagedServiceId(clientDataRoot);
- const deploymentPathOptions = {
+ const pathOptions = {
env: { XDG_DATA_HOME: join(base, 'data') },
homeDir: join(base, 'home'),
platform: 'linux' as const,
};
- const outputs: string[] = [];
- const exitCode = await runRuntimeHostSetupCli(
+ const local = await prepareRuntimeHostManagedPackageDeployment(
+ { serviceId, clientDataRoot, sourcePackageRoot: localPackage, version },
+ pathOptions,
+ );
+ const registry = await prepareRuntimeHostManagedPackageDeployment(
{
- json: true,
+ serviceId,
clientDataRoot,
- defaultRootPath: join(clientDataRoot, 'workspaces', 'default'),
- sourcePackageRoot,
- version: '0.2.0',
- principalId: 'desktop.client-1',
- preset: 'desktop-client',
+ sourcePackageRoot: registryPackage,
+ version,
+ packageIntegrity: PACKAGE_INTEGRITY,
},
+ pathOptions,
+ );
+
+ assert.notEqual(local.cliPath, registry.cliPath);
+ assert.match(registry.cliPath, /\/versions\/registry-[a-f0-9]{64}\/dist\/cli\.js$/u);
+ assert.equal(await readFile(registry.cliPath, 'utf8'), 'registry package\n');
+ await registry.cleanup();
+ assert.deepEqual(await readdir(dirname(dirname(dirname(registry.cliPath)))), [
+ basename(dirname(dirname(registry.cliPath))),
+ ]);
+
+ const registryRoot = dirname(dirname(registry.cliPath));
+ const versionsRoot = dirname(registryRoot);
+ await rename(registryRoot, join(versionsRoot, `.${basename(registryRoot)}.interrupted.deleted`));
+ const recovered = await prepareRuntimeHostManagedPackageDeployment(
{
- createBackend: () => unusedBackend(),
- manageService: async (input: { readonly action: string }) => {
- if (input.action === 'status') return serviceResult('status', null, null);
- throw new RuntimeHostServiceManagerError(
- 'service_manager_operation_failed',
- `Injected service failure ${'x'.repeat(2_000)}`,
- );
- },
- prepareDeployment: (input) =>
- prepareRuntimeHostManagedPackageDeployment(input, deploymentPathOptions),
- writeOutput: (value) => outputs.push(value),
+ serviceId,
+ clientDataRoot,
+ sourcePackageRoot: registryPackage,
+ version,
+ packageIntegrity: PACKAGE_INTEGRITY,
},
+ pathOptions,
);
+ assert.equal(await readFile(recovered.cliPath, 'utf8'), 'registry package\n');
+ assert.deepEqual(await readdir(versionsRoot), [basename(registryRoot)]);
+ await mkdir(join(versionsRoot, 'stale-package'));
+
+ const stateRoot = join(clientDataRoot, 'workspaces', 'default');
+ const config: RuntimeHostManagedServiceConfig = {
+ schemaVersion: 1,
+ managedDeploymentRoot: recovered.root,
+ rootPath: stateRoot,
+ projectDirectoryRoots: [],
+ websocket: { host: '127.0.0.1', port: 42_111, path: '/runtime-host' },
+ launch: { nodePath: process.execPath, cliPath: recovered.cliPath },
+ };
+ let installedCliPath = '';
+ assert.equal(
+ await runRuntimeHostSetupCli(
+ {
+ json: true,
+ clientDataRoot,
+ defaultRootPath: stateRoot,
+ sourcePackageRoot: localPackage,
+ version,
+ principalId: 'desktop.client-1',
+ preset: 'desktop-client',
+ },
+ {
+ createBackend: () => unusedBackend(),
+ manageService: async (input: { readonly action: string; readonly cliPath: string }) => {
+ if (input.action === 'status') return serviceResult('status', config, version);
+ installedCliPath = input.cliPath;
+ return serviceResult('install', config, version);
+ },
+ prepareDeployment: async () => assert.fail('same-version setup must reuse the deployment'),
+ replaceCredential: async () => ({
+ rootId: 'a'.repeat(64),
+ credential: 'new-secret',
+ credentialId: 'new-credential',
+ principalKind: 'remote_owner',
+ principalId: 'desktop.client-1',
+ operationGrants: ['host.status'],
+ canPublishClientCapabilities: true,
+ canUseHostPaths: false,
+ }),
+ verifyCredential: async () => undefined,
+ writeOutput: () => undefined,
+ },
+ ),
+ 0,
+ );
+ assert.equal(installedCliPath, recovered.cliPath);
+ const repairedOperator = await readFile(join(recovered.root, 'operator'), 'utf8');
+ assert.equal(repairedOperator.includes(recovered.cliPath), true);
+ assert.equal(repairedOperator.includes(clientDataRoot), true);
+ assert.deepEqual(await readdir(versionsRoot), [basename(registryRoot)]);
+});
+
+test('managed setup leaves no inactive package when service installation fails', async (t) => {
+ const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-setup-failure-'));
+ t.after(() => rm(base, { recursive: true, force: true }));
+ const sourcePackageRoot = await createReleasePackage(base, '0.2.0');
+ const clientDataRoot = join(base, 'config', 'Maka');
+ const serviceId = resolveRuntimeHostManagedServiceId(clientDataRoot);
+ const deploymentPathOptions = {
+ env: { XDG_DATA_HOME: join(base, 'data') },
+ homeDir: join(base, 'home'),
+ platform: 'linux' as const,
+ };
+ const outputs: string[] = [];
+ let rollbackFails = false;
+ const options = {
+ json: true,
+ clientDataRoot,
+ defaultRootPath: join(clientDataRoot, 'workspaces', 'default'),
+ sourcePackageRoot,
+ version: '0.2.0',
+ principalId: 'desktop.client-1',
+ preset: 'desktop-client',
+ } as const;
+ const overrides = {
+ createBackend: () => unusedBackend(),
+ manageService: async (input: { readonly action: string }) => {
+ if (input.action === 'status') return serviceResult('status', null, null);
+ throw new RuntimeHostServiceManagerError(
+ 'service_manager_operation_failed',
+ `Injected service failure ${'x'.repeat(2_000)}`,
+ );
+ },
+ prepareDeployment: async (
+ input: Parameters[0],
+ ) => {
+ const deployment = await prepareRuntimeHostManagedPackageDeployment(
+ input,
+ deploymentPathOptions,
+ );
+ return rollbackFails
+ ? {
+ ...deployment,
+ rollback: async () => {
+ await deployment.rollback();
+ throw new Error('Injected rollback failure');
+ },
+ }
+ : deployment;
+ },
+ writeOutput: (value: string) => outputs.push(value),
+ };
+ const exitCode = await runRuntimeHostSetupCli(options, overrides);
assert.equal(exitCode, 1);
const failure = decodeRuntimeHostSetupFrame(outputs.at(-1) ?? '');
assert.equal(failure?.kind, 'error');
@@ -333,6 +457,15 @@ test('managed setup removes a newly copied package when service installation fai
),
),
);
+
+ rollbackFails = true;
+ outputs.length = 0;
+ assert.equal(await runRuntimeHostSetupCli(options, overrides), 1);
+ const rollbackFailure = decodeRuntimeHostSetupFrame(outputs.at(-1) ?? '');
+ assert.deepEqual(rollbackFailure?.kind === 'error' ? rollbackFailure.error : undefined, {
+ code: 'deployment_failed',
+ message: 'Runtime Host setup failed and its staged package could not be removed',
+ });
});
test('managed operator binds its Client Data Root and routes deployment cleanup', {
diff --git a/packages/cli/src/__tests__/runtime-host-update-discovery.test.ts b/packages/cli/src/__tests__/runtime-host-update-discovery.test.ts
index 435767bb17..15eee14e88 100644
--- a/packages/cli/src/__tests__/runtime-host-update-discovery.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-update-discovery.test.ts
@@ -140,40 +140,37 @@ describe('managed Runtime Host update discovery', () => {
);
});
- it('admits only newer packages with matching compatibility evidence', () => {
- assert.deepEqual(
- assessRuntimeHostUpdate('1.0.0', undefined, { version: '1.0.0', integrity: INTEGRITY }),
- { kind: 'current' },
- );
+ it('admits only exact current or compatible target identities', () => {
+ const candidate = (version: string, compatibility?: number) => ({
+ version,
+ integrity: INTEGRITY,
+ ...(compatibility === undefined ? {} : { compatibility }),
+ });
+ assert.deepEqual(assessRuntimeHostUpdate('1.0.0', undefined, candidate('1.0.0'), true), {
+ kind: 'current',
+ });
assert.deepEqual(
- assessRuntimeHostUpdate('1.0.0-beta.1', 4, {
- version: '1.0.0-beta.2',
- integrity: INTEGRITY,
- compatibility: 4,
- }),
+ assessRuntimeHostUpdate('1.0.0-beta.1', 4, candidate('1.0.0-beta.2', 4), false),
{ kind: 'unattended_update', compatibility: 4 },
);
- const manual = assessRuntimeHostUpdate('1.0.0', 4, {
- version: '2.0.0',
- integrity: INTEGRITY,
+ assert.deepEqual(assessRuntimeHostUpdate('1.0.0', 4, candidate('1.0.0', 4), false), {
+ kind: 'unattended_update',
+ compatibility: 4,
+ });
+ assert.deepEqual(assessRuntimeHostUpdate('1.0.0', undefined, candidate('1.0.0', 4), false), {
+ kind: 'manual_action',
+ reason: 'current_compatibility_unknown',
});
+ const manual = assessRuntimeHostUpdate('1.0.0', 4, candidate('2.0.0'), false);
assert.deepEqual(manual, { kind: 'manual_action', reason: 'target_compatibility_unknown' });
- assert.deepEqual(
- assessRuntimeHostUpdate('1.0.0', 4, {
- version: '0.9.0',
- integrity: INTEGRITY,
- compatibility: 4,
- }),
- { kind: 'manual_action', reason: 'target_not_newer' },
- );
- assert.deepEqual(
- assessRuntimeHostUpdate('1.0.0', 4, {
- version: '2.0.0',
- integrity: INTEGRITY,
- compatibility: 5,
- }),
- { kind: 'manual_action', reason: 'compatibility_mismatch' },
- );
+ assert.deepEqual(assessRuntimeHostUpdate('1.0.0', 4, candidate('0.9.0', 4), false), {
+ kind: 'manual_action',
+ reason: 'target_not_newer',
+ });
+ assert.deepEqual(assessRuntimeHostUpdate('1.0.0', 4, candidate('2.0.0', 5), false), {
+ kind: 'manual_action',
+ reason: 'compatibility_mismatch',
+ });
assert.match(
formatRuntimeHostUpdateCheck({
...FRAME,
@@ -185,6 +182,12 @@ describe('managed Runtime Host update discovery', () => {
it('rejects malformed or contradictory machine evidence', () => {
assert.doesNotThrow(() => encodeRuntimeHostServiceManagementFrame(FRAME));
+ assert.doesNotThrow(() =>
+ encodeRuntimeHostServiceManagementFrame({
+ ...FRAME,
+ service: { ...FRAME.service, installedVersion: '2.0.0' },
+ }),
+ );
assert.throws(() =>
encodeRuntimeHostServiceManagementFrame({
...FRAME,
diff --git a/packages/cli/src/__tests__/runtime-host-update-package.test.ts b/packages/cli/src/__tests__/runtime-host-update-package.test.ts
new file mode 100644
index 0000000000..c096f2304a
--- /dev/null
+++ b/packages/cli/src/__tests__/runtime-host-update-package.test.ts
@@ -0,0 +1,156 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { createHash } from 'node:crypto';
+import { mkdir, stat, writeFile } from 'node:fs/promises';
+import { join } from 'node:path';
+import { describe, it } from 'node:test';
+import {
+ RuntimeHostUpdatePackageError,
+ withRuntimeHostRegistryUpdatePackage,
+} from '../runtime-host-update-package.js';
+
+const ARCHIVE = Buffer.from('verified release archive');
+const INTEGRITY = `sha512-${createHash('sha512').update(ARCHIVE).digest('base64')}`;
+
+describe('managed Runtime Host update package acquisition', () => {
+ it('binds the official archive to its extracted release evidence', async () => {
+ const calls: string[][] = [];
+ const candidate = {
+ version: '2.0.0',
+ integrity: INTEGRITY,
+ compatibility: 7,
+ };
+ let acquiredRoot = '';
+ await withRuntimeHostRegistryUpdatePackage(
+ candidate,
+ async (root) => {
+ acquiredRoot = root;
+ assert.equal((await stat(root)).isDirectory(), true);
+ },
+ async (args) => {
+ calls.push([...args]);
+ if (args[0] === 'pack') {
+ const destination = args[args.indexOf('--pack-destination') + 1]!;
+ await writeFile(join(destination, 'maka-agent-2.0.0.tgz'), ARCHIVE);
+ return 0;
+ }
+ const prefix = args[args.indexOf('--prefix') + 1]!;
+ const root = join(prefix, 'node_modules', 'maka-agent');
+ await Promise.all([
+ mkdir(join(root, 'dist'), { recursive: true }),
+ mkdir(join(root, 'node_modules', '@maka', 'runtime-host'), {
+ recursive: true,
+ }),
+ ]);
+ await Promise.all([
+ writeFile(
+ join(root, 'package.json'),
+ JSON.stringify({
+ name: 'maka-agent',
+ version: candidate.version,
+ maka: {
+ managedRuntimeHostUpdateCompatibility: candidate.compatibility,
+ },
+ }),
+ ),
+ writeFile(join(root, 'dist', 'cli.js'), ''),
+ writeFile(join(root, 'node_modules', '@maka', 'runtime-host', 'package.json'), '{}'),
+ ]);
+ return 0;
+ },
+ );
+
+ assert.deepEqual(calls[0]?.slice(0, 2), ['pack', 'maka-agent@2.0.0']);
+ assert.equal(calls[0]?.includes('https://registry.npmjs.org/'), true);
+ const downloadCache = calls[0]?.[calls[0].indexOf('--cache') + 1];
+ const installCache = calls[1]?.[calls[1].indexOf('--cache') + 1];
+ assert.match(downloadCache ?? '', /download-cache$/u);
+ assert.match(installCache ?? '', /empty-cache$/u);
+ assert.notEqual(downloadCache, installCache);
+ assert.equal(calls[1]?.includes('--offline'), true);
+ assert.equal(calls[1]?.includes('--ignore-scripts'), true);
+ assert.equal(calls[1]?.includes('http://127.0.0.1:9/'), true);
+ await assert.rejects(stat(acquiredRoot), { code: 'ENOENT' });
+ });
+
+ it('rejects archive or manifest evidence that differs from discovery', async () => {
+ let installed = false;
+ await assert.rejects(
+ withRuntimeHostRegistryUpdatePackage(
+ {
+ version: '2.0.0',
+ integrity: `sha512-${Buffer.alloc(64).toString('base64')}`,
+ },
+ async () => assert.fail('invalid integrity must not expose a package'),
+ async (args) => {
+ if (args[0] === 'pack') {
+ const destination = args[args.indexOf('--pack-destination') + 1]!;
+ await writeFile(join(destination, 'maka-agent-2.0.0.tgz'), ARCHIVE);
+ return 0;
+ }
+ installed = true;
+ return 0;
+ },
+ ),
+ (error: unknown) =>
+ error instanceof RuntimeHostUpdatePackageError &&
+ error.code === 'package_integrity_mismatch',
+ );
+ assert.equal(installed, false);
+
+ await assert.rejects(
+ withRuntimeHostRegistryUpdatePackage(
+ { version: '2.0.0', integrity: INTEGRITY, compatibility: 7 },
+ async () => assert.fail('invalid manifest must not expose a package'),
+ async (args) => {
+ if (args[0] === 'pack') {
+ const destination = args[args.indexOf('--pack-destination') + 1]!;
+ await writeFile(join(destination, 'maka-agent-2.0.0.tgz'), ARCHIVE);
+ return 0;
+ }
+ const prefix = args[args.indexOf('--prefix') + 1]!;
+ const root = join(prefix, 'node_modules', 'maka-agent');
+ await Promise.all([
+ mkdir(join(root, 'dist'), { recursive: true }),
+ mkdir(join(root, 'node_modules', '@maka', 'runtime-host'), {
+ recursive: true,
+ }),
+ ]);
+ await Promise.all([
+ writeFile(
+ join(root, 'package.json'),
+ JSON.stringify({
+ name: 'maka-agent',
+ version: '2.0.0',
+ maka: { managedRuntimeHostUpdateCompatibility: 8 },
+ }),
+ ),
+ writeFile(join(root, 'dist', 'cli.js'), ''),
+ writeFile(join(root, 'node_modules', '@maka', 'runtime-host', 'package.json'), '{}'),
+ ]);
+ return 0;
+ },
+ ),
+ (error: unknown) =>
+ error instanceof RuntimeHostUpdatePackageError && error.code === 'invalid_package',
+ );
+ });
+});
diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts
index 0e931db9a3..42834e336c 100644
--- a/packages/cli/src/cli-core.ts
+++ b/packages/cli/src/cli-core.ts
@@ -123,7 +123,7 @@ function helpText(cliCommand: string): string {
` ${cliCommand} runtime-host service status|start|stop|restart|logs|uninstall [--json]`,
` ${cliCommand} runtime-host service retire --expected-service-id --expected-root-path --expected-root-id [--allow-interrupt-active-tasks]`,
` ${cliCommand} runtime-host service check-update --target [--json]`,
- ` ${cliCommand} runtime-host service update --expected-service-id --expected-root-path --expected-root-id [--allow-interrupt-active-tasks]`,
+ ` ${cliCommand} runtime-host service update [--target ] --expected-service-id --expected-root-path --expected-root-id [--allow-interrupt-active-tasks]`,
` ${cliCommand} runtime-host access issue --principal --grant `,
` ${cliCommand} runtime-host access issue --principal --preset `,
` ${cliCommand} runtime-host access list`,
@@ -278,10 +278,22 @@ export async function runMakaCli(
});
}
case 'runtime-host-service-update': {
- const { runManagedRuntimeHostUpdateCli } = await import('./runtime-host-update-command.js');
+ const { runManagedRuntimeHostSelectedUpdateCli, runManagedRuntimeHostUpdateCli } =
+ await import('./runtime-host-update-command.js');
const serviceDataRoots = command.clientDataRoot
? deriveMakaDataRoots(command.clientDataRoot)
: dataRoots;
+ if (command.selector) {
+ return runManagedRuntimeHostSelectedUpdateCli({
+ json: command.json,
+ framed: command.framed ?? false,
+ clientDataRoot: serviceDataRoots.clientDataRoot,
+ defaultRootPath: serviceDataRoots.workspaceRoot,
+ selector: command.selector,
+ expectedTarget: command.expectedTarget,
+ ...(command.allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}),
+ });
+ }
return runManagedRuntimeHostUpdateCli({
json: command.json,
framed: command.framed ?? false,
diff --git a/packages/cli/src/runtime-host-cli.ts b/packages/cli/src/runtime-host-cli.ts
index c49bd248f7..ff58ffb6ce 100644
--- a/packages/cli/src/runtime-host-cli.ts
+++ b/packages/cli/src/runtime-host-cli.ts
@@ -88,6 +88,7 @@ export type RuntimeHostCliCommand =
framed?: true;
clientDataRoot?: string;
expectedTarget: RuntimeHostManagedServiceTarget;
+ selector?: RuntimeHostUpdateSelector;
allowInterruptActiveTasks?: true;
}
| {
@@ -291,7 +292,7 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand {
if (!isSafeAbsolutePath(value)) return error('--client-data-root must be an absolute path');
clientDataRoot = value;
},
- ...(action === 'check-update'
+ ...(action === 'check-update' || action === 'update'
? {
'--target': (value: string) => {
if (updateTarget !== undefined) return error('Duplicate --target');
@@ -319,12 +320,16 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand {
};
}
if (action === 'update') {
+ const selector =
+ updateTarget === undefined ? undefined : parseUpdateSelector(updateTarget, 'update');
+ if (selector && 'kind' in selector && selector.kind === 'error') return selector;
return {
kind: 'runtime-host-service-update',
json: options.json,
...(options.framed ? { framed: true } : {}),
...(clientDataRoot ? { clientDataRoot } : {}),
expectedTarget: options.expectedTarget!,
+ ...(selector ? { selector } : {}),
...(allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}),
};
}
@@ -340,8 +345,9 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand {
function parseUpdateSelector(
value: string | undefined,
+ action: 'check-update' | 'update' = 'check-update',
): RuntimeHostUpdateSelector | RuntimeHostCliError {
- if (!value) return error('runtime-host service check-update requires --target');
+ if (!value) return error(`runtime-host service ${action} requires --target`);
if (value === 'latest' || value === 'next') {
return { kind: 'channel', channel: value };
}
diff --git a/packages/cli/src/runtime-host-launch-agent-service.ts b/packages/cli/src/runtime-host-launch-agent-service.ts
index edb4156d25..f1414efd99 100644
--- a/packages/cli/src/runtime-host-launch-agent-service.ts
+++ b/packages/cli/src/runtime-host-launch-agent-service.ts
@@ -124,7 +124,12 @@ export function createLaunchAgentRuntimeHostService(
},
replace: async (config) => {
await validateRuntimeHostServiceLaunch(config);
- await applyLaunchAgentDeployment(context, config);
+ const previous = await captureLaunchAgentDeployment(context);
+ try {
+ await applyLaunchAgentDeployment(context, config);
+ } catch (error) {
+ await restoreFailedLaunchAgentDeployment(previous, context, error, 'update_incomplete');
+ }
},
verifyDeployment: async (config) => {
await validateRuntimeHostServiceLaunch(config);
@@ -302,12 +307,15 @@ async function restoreFailedLaunchAgentDeployment(
snapshot: LaunchAgentDeploymentSnapshot,
context: LaunchAgentContext,
originalError: unknown,
+ recoveryFailureCode:
+ | 'service_manager_operation_failed'
+ | 'update_incomplete' = 'service_manager_operation_failed',
): Promise {
try {
await restoreLaunchAgentDeployment(snapshot, context);
} catch (rollbackError) {
throw new RuntimeHostServiceManagerError(
- 'service_manager_operation_failed',
+ recoveryFailureCode,
'Updating the Runtime Host LaunchAgent failed and the previous deployment could not be restored',
{ cause: new AggregateError([originalError, rollbackError]) },
);
diff --git a/packages/cli/src/runtime-host-managed-deployment.ts b/packages/cli/src/runtime-host-managed-deployment.ts
index f5dccc1ad0..fdf0d73152 100644
--- a/packages/cli/src/runtime-host-managed-deployment.ts
+++ b/packages/cli/src/runtime-host-managed-deployment.ts
@@ -17,7 +17,7 @@
* under the License.
*/
-import { randomUUID } from 'node:crypto';
+import { createHash, randomUUID } from 'node:crypto';
import {
cp,
lstat,
@@ -32,6 +32,7 @@ import {
} from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
+import { isSha512PackageIntegrity } from '@maka/runtime-host/operator';
const PACKAGE_NAME = 'maka-agent';
@@ -56,6 +57,16 @@ export interface RuntimeHostManagedPackageDeployment {
rollback(): Promise;
}
+export function resolveRuntimeHostManagedPackageCliPath(
+ deploymentRoot: string,
+ version: string,
+ packageIntegrity?: string,
+): string {
+ assertVersion(version);
+ const packageDirectory = packageIntegrity ? registryPackageDirectory(packageIntegrity) : version;
+ return join(resolve(deploymentRoot), 'versions', packageDirectory, 'dist', 'cli.js');
+}
+
export function isRuntimeHostDevelopmentPackageVersion(value: unknown): value is string {
return typeof value === 'string' && /(?:-|\.)dev-[0-9a-f]{12}$/u.test(value);
}
@@ -66,6 +77,7 @@ export async function prepareRuntimeHostManagedPackageDeployment(
readonly clientDataRoot: string;
readonly sourcePackageRoot: string;
readonly version: string;
+ readonly packageIntegrity?: string;
},
options: RuntimeHostManagedDeploymentPathOptions = {},
): Promise {
@@ -75,16 +87,23 @@ export async function prepareRuntimeHostManagedPackageDeployment(
await mkdir(join(requestedDeploymentRoot, 'versions'), { recursive: true, mode: 0o700 });
const deploymentRoot = await realpath(requestedDeploymentRoot);
const versionsRoot = join(deploymentRoot, 'versions');
- const packageRoot = join(versionsRoot, input.version);
- const cliPath = join(packageRoot, 'dist', 'cli.js');
+ const packageDirectory = input.packageIntegrity
+ ? registryPackageDirectory(input.packageIntegrity)
+ : input.version;
+ const packageRoot = join(versionsRoot, packageDirectory);
+ const cliPath = resolveRuntimeHostManagedPackageCliPath(
+ deploymentRoot,
+ input.version,
+ input.packageIntegrity,
+ );
const clientDataRoot = resolve(input.clientDataRoot);
if (await pathExists(packageRoot)) {
await validatePackage(packageRoot, input.version);
return deployment(input.version, deploymentRoot, packageRoot, cliPath, clientDataRoot, false);
}
- await removeAbandonedStagingPackages(versionsRoot, input.version);
- const stagingRoot = join(versionsRoot, `.${input.version}.${randomUUID()}.tmp`);
+ await removeAbandonedPackageWorkspaces(versionsRoot, packageDirectory);
+ const stagingRoot = join(versionsRoot, `.${packageDirectory}.${randomUUID()}.tmp`);
try {
await cp(sourcePackageRoot, stagingRoot, {
recursive: true,
@@ -113,14 +132,61 @@ export async function prepareRuntimeHostManagedPackageDeployment(
}
}
-async function removeAbandonedStagingPackages(
+export async function openRuntimeHostManagedPackageDeployment(input: {
+ readonly serviceId: string;
+ readonly clientDataRoot: string;
+ readonly deploymentRoot: string;
+ readonly cliPath: string;
+ readonly version: string;
+}): Promise {
+ assertVersion(input.version);
+ let deploymentRoot: string;
+ let cliPath: string;
+ try {
+ deploymentRoot = await realpath(resolve(input.deploymentRoot));
+ cliPath = await realpath(input.cliPath);
+ } catch (error) {
+ throw new RuntimeHostManagedDeploymentError(
+ 'invalid_package',
+ `The managed Maka ${input.version} package is unavailable`,
+ { cause: error },
+ );
+ }
+ if (resolveRuntimeHostManagedDeploymentForCli(input.serviceId, cliPath) !== deploymentRoot) {
+ throw new RuntimeHostManagedDeploymentError(
+ 'invalid_package',
+ 'The configured Runtime Host package does not belong to its managed deployment',
+ );
+ }
+ const packageRoot = await validatePackage(dirname(dirname(cliPath)), input.version);
+ if (cliPath !== join(packageRoot, 'dist', 'cli.js')) {
+ throw new RuntimeHostManagedDeploymentError(
+ 'invalid_package',
+ 'The configured Runtime Host CLI does not match its managed package',
+ );
+ }
+ return deployment(
+ input.version,
+ deploymentRoot,
+ packageRoot,
+ cliPath,
+ resolve(input.clientDataRoot),
+ false,
+ );
+}
+
+async function removeAbandonedPackageWorkspaces(
versionsRoot: string,
- version: string,
+ packageDirectory: string,
): Promise {
- const prefix = `.${version}.`;
+ const prefix = `.${packageDirectory}.`;
await Promise.all(
(await readdir(versionsRoot, { withFileTypes: true }))
- .filter((entry) => entry.name.startsWith(prefix) && entry.name.endsWith('.tmp'))
+ .filter(
+ (entry) =>
+ entry.name.startsWith(prefix) &&
+ (entry.name.endsWith('.tmp') || entry.name.endsWith('.deleted')),
+ )
.map((entry) => rm(join(versionsRoot, entry.name), { recursive: true, force: true })),
);
}
@@ -269,9 +335,11 @@ function deployment(
cliPath,
operatorPath,
activate: () => writeOperatorLauncher(operatorPath, process.execPath, cliPath, clientDataRoot),
- cleanup: () => pruneInactiveDevelopmentPackages(dirname(packageRoot), version),
+ cleanup: () => pruneInactivePackages(dirname(packageRoot), basename(packageRoot)),
rollback: () =>
- created ? rm(packageRoot, { recursive: true, force: true }) : Promise.resolve(),
+ created
+ ? removePackageAtomically(dirname(packageRoot), basename(packageRoot))
+ : Promise.resolve(),
};
}
@@ -319,23 +387,44 @@ function quotePosix(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
-async function pruneInactiveDevelopmentPackages(
- versionsRoot: string,
- retainedVersion: string,
-): Promise {
- if (!isRuntimeHostDevelopmentPackageVersion(retainedVersion)) return;
+async function pruneInactivePackages(versionsRoot: string, retainedPackage: string): Promise {
await Promise.all(
(await readdir(versionsRoot, { withFileTypes: true }))
- .filter(
- (entry) =>
- entry.isDirectory() &&
- entry.name !== retainedVersion &&
- isRuntimeHostDevelopmentPackageVersion(entry.name),
- )
- .map((entry) => rm(join(versionsRoot, entry.name), { recursive: true, force: true })),
+ .filter((entry) => entry.name !== retainedPackage)
+ .map((entry) => removePackageAtomically(versionsRoot, entry.name)),
);
}
+async function removePackageAtomically(versionsRoot: string, packageName: string): Promise {
+ const packageRoot = join(versionsRoot, packageName);
+ try {
+ if (packageName.startsWith('.') && packageName.endsWith('.deleted')) {
+ await rm(packageRoot, { recursive: true, force: true });
+ return;
+ }
+ const tombstone = join(versionsRoot, `.${packageName}.${randomUUID()}.deleted`);
+ await rename(packageRoot, tombstone);
+ await rm(tombstone, { recursive: true, force: true });
+ } catch (error) {
+ if (isNodeError(error, 'ENOENT')) return;
+ throw new RuntimeHostManagedDeploymentError(
+ 'deployment_failed',
+ 'Unable to remove an inactive managed Runtime Host package',
+ { cause: error },
+ );
+ }
+}
+
+function registryPackageDirectory(integrity: string): string {
+ if (!isSha512PackageIntegrity(integrity)) {
+ throw new RuntimeHostManagedDeploymentError(
+ 'invalid_package',
+ 'The managed Runtime Host package integrity is invalid',
+ );
+ }
+ return `registry-${createHash('sha256').update(integrity).digest('hex')}`;
+}
+
function assertVersion(version: string): void {
if (!/^[0-9A-Za-z][0-9A-Za-z.+-]{0,127}$/u.test(version)) {
throw new RuntimeHostManagedDeploymentError(
diff --git a/packages/cli/src/runtime-host-service-manager.ts b/packages/cli/src/runtime-host-service-manager.ts
index d6b73d196a..eff0eae9fd 100644
--- a/packages/cli/src/runtime-host-service-manager.ts
+++ b/packages/cli/src/runtime-host-service-manager.ts
@@ -94,6 +94,7 @@ export interface RuntimeHostServiceBackendStatus {
export interface RuntimeHostServiceBackend {
preflightInstall(): Promise;
install(config: RuntimeHostManagedServiceConfig): Promise;
+ /** A rejected replacement must restore the previous deployment or report update_incomplete. */
replace(config: RuntimeHostManagedServiceConfig): Promise;
verifyDeployment(config: RuntimeHostManagedServiceConfig): Promise;
status(): Promise;
@@ -549,12 +550,46 @@ async function replaceRuntimeHostManagedServiceLocked(
}
try {
await backend.replace(config);
+ } catch (error) {
+ if (error instanceof RuntimeHostServiceManagerError && error.code === 'update_incomplete') {
+ await backend.stop().catch(() => undefined);
+ throw error;
+ }
+ try {
+ await writeRuntimeHostServiceFile(
+ configPath,
+ `${JSON.stringify(service.config, null, 2)}\n`,
+ 0o600,
+ );
+ } catch (restoreError) {
+ await backend.stop().catch(() => undefined);
+ throw new RuntimeHostServiceManagerError(
+ 'update_incomplete',
+ 'Replacing the Runtime Host service failed and its previous configuration could not be restored',
+ { cause: new AggregateError([error, restoreError]) },
+ );
+ }
+ throw new RuntimeHostServiceManagerError(
+ 'update_incomplete',
+ 'Replacing the Runtime Host service failed; the previous deployment was restored and remains stopped',
+ { cause: error },
+ );
+ }
+ try {
await deps.waitForReady(config, backend);
} catch (error) {
- await backend.stop().catch(() => undefined);
+ try {
+ await backend.stop();
+ } catch (stopError) {
+ throw new RuntimeHostServiceManagerError(
+ 'update_incomplete',
+ 'The replacement Runtime Host did not become ready and could not be stopped; inspect the service state before retrying',
+ { cause: new AggregateError([error, stopError]) },
+ );
+ }
throw new RuntimeHostServiceManagerError(
'update_incomplete',
- 'The replacement Runtime Host did not become ready; the previous deployment was retained but was not restarted because its storage compatibility is unknown',
+ 'The replacement Runtime Host did not become ready; the selected deployment was retained but stopped because rolling back across an unknown storage boundary is unsafe',
{ cause: error },
);
}
diff --git a/packages/cli/src/runtime-host-setup-command.ts b/packages/cli/src/runtime-host-setup-command.ts
index b5875bf42c..dac7928d1a 100644
--- a/packages/cli/src/runtime-host-setup-command.ts
+++ b/packages/cli/src/runtime-host-setup-command.ts
@@ -38,7 +38,9 @@ import {
type RuntimeHostAccessPreset,
} from './runtime-host-access-command.js';
import {
+ isRuntimeHostManagedDeploymentCli,
isRuntimeHostDevelopmentPackageVersion,
+ openRuntimeHostManagedPackageDeployment,
prepareRuntimeHostManagedPackageDeployment,
RuntimeHostManagedDeploymentError,
} from './runtime-host-managed-deployment.js';
@@ -75,6 +77,7 @@ export interface RuntimeHostSetupCliOptions {
interface RuntimeHostSetupDeps {
readonly manageService: typeof manageRuntimeHostService;
readonly createBackend: (serviceId: string) => RuntimeHostServiceBackend;
+ readonly openDeployment: typeof openRuntimeHostManagedPackageDeployment;
readonly prepareDeployment: typeof prepareRuntimeHostManagedPackageDeployment;
readonly prepareCredential: typeof prepareRuntimeHostAccessCredential;
readonly replaceCredential: typeof replaceRuntimeHostAccessCredential;
@@ -102,6 +105,7 @@ export async function runRuntimeHostSetupCli(
const deps: RuntimeHostSetupDeps = {
manageService: manageRuntimeHostService,
createBackend: createPlatformRuntimeHostServiceBackend,
+ openDeployment: openRuntimeHostManagedPackageDeployment,
prepareDeployment: prepareRuntimeHostManagedPackageDeployment,
prepareCredential: prepareRuntimeHostAccessCredential,
replaceCredential: replaceRuntimeHostAccessCredential,
@@ -148,14 +152,23 @@ async function runRuntimeHostSetupLocked(
} as const;
const status = await deps.manageService({ ...common, action: 'status' }, backend);
await assertCompatibleExistingVersion(status, options.version);
+ const currentPackage = currentManagedPackage(status, serviceId, options.version);
emit({ kind: 'progress', phase: 'installing_package' });
- const deployment = await deps.prepareDeployment({
- serviceId,
- clientDataRoot: options.clientDataRoot,
- sourcePackageRoot: options.sourcePackageRoot,
- version: options.version,
- });
+ const deployment = currentPackage
+ ? await deps.openDeployment({
+ serviceId,
+ clientDataRoot: options.clientDataRoot,
+ deploymentRoot: currentPackage.deploymentRoot,
+ cliPath: currentPackage.cliPath,
+ version: options.version,
+ })
+ : await deps.prepareDeployment({
+ serviceId,
+ clientDataRoot: options.clientDataRoot,
+ sourcePackageRoot: options.sourcePackageRoot,
+ version: options.version,
+ });
emit({ kind: 'progress', phase: 'installing_service' });
let installed: RuntimeHostManagedServiceResult;
@@ -175,7 +188,15 @@ async function runRuntimeHostSetupLocked(
backend,
);
} catch (error) {
- await deployment.rollback().catch(() => undefined);
+ try {
+ await deployment.rollback();
+ } catch (rollbackError) {
+ throw new RuntimeHostSetupError(
+ 'deployment_failed',
+ 'Runtime Host setup failed and its staged package could not be removed',
+ { cause: new AggregateError([error, rollbackError]) },
+ );
+ }
throw error;
}
const config = installed.service.config;
@@ -221,7 +242,7 @@ async function runRuntimeHostSetupLocked(
});
emit({
kind: 'complete',
- version: deployment.version,
+ version: options.version,
serviceId,
operatorPath: deployment.operatorPath,
rootPath: config.rootPath,
@@ -248,6 +269,34 @@ async function runRuntimeHostSetupLocked(
}
}
+function currentManagedPackage(
+ status: RuntimeHostManagedServiceResult,
+ serviceId: string,
+ version: string,
+):
+ | {
+ readonly deploymentRoot: string;
+ readonly cliPath: string;
+ }
+ | undefined {
+ const config = status.service.config;
+ if (
+ status.service.installedVersion !== version ||
+ !config?.managedDeploymentRoot ||
+ !isRuntimeHostManagedDeploymentCli(
+ config.managedDeploymentRoot,
+ serviceId,
+ config.launch.cliPath,
+ )
+ ) {
+ return undefined;
+ }
+ return {
+ deploymentRoot: config.managedDeploymentRoot,
+ cliPath: config.launch.cliPath,
+ };
+}
+
async function assertCompatibleExistingVersion(
status: RuntimeHostManagedServiceResult,
version: string,
diff --git a/packages/cli/src/runtime-host-systemd-service.ts b/packages/cli/src/runtime-host-systemd-service.ts
index 81b92d4f17..564eec8c4e 100644
--- a/packages/cli/src/runtime-host-systemd-service.ts
+++ b/packages/cli/src/runtime-host-systemd-service.ts
@@ -115,7 +115,12 @@ export function createSystemdUserRuntimeHostService(
},
replace: async (config) => {
await validateRuntimeHostServiceLaunch(config);
- await applySystemdDeployment(context, config);
+ const previous = await captureSystemdDeployment(context.unitPath, readStatus);
+ try {
+ await applySystemdDeployment(context, config);
+ } catch (error) {
+ await restoreFailedSystemdDeployment(previous, context, error, 'update_incomplete');
+ }
},
verifyDeployment: async (config) => {
await validateRuntimeHostServiceLaunch(config);
@@ -292,12 +297,15 @@ async function restoreFailedSystemdDeployment(
snapshot: SystemdDeploymentSnapshot,
context: SystemdUnitContext,
originalError: unknown,
+ recoveryFailureCode:
+ | 'service_manager_operation_failed'
+ | 'update_incomplete' = 'service_manager_operation_failed',
): Promise {
try {
await restoreSystemdDeployment(snapshot, context);
} catch (rollbackError) {
throw new RuntimeHostServiceManagerError(
- 'service_manager_operation_failed',
+ recoveryFailureCode,
'Updating the Runtime Host service failed and the previous systemd deployment could not be restored',
{ cause: new AggregateError([originalError, rollbackError]) },
);
diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts
index 4c5997e917..aca22ad34c 100644
--- a/packages/cli/src/runtime-host-update-command.ts
+++ b/packages/cli/src/runtime-host-update-command.ts
@@ -18,7 +18,7 @@
*/
import { spawn } from 'node:child_process';
-import { join } from 'node:path';
+import { dirname, join, resolve } from 'node:path';
import { truncateUtf8 } from '@maka/core/diagnostic-log';
import {
decodeRuntimeHostServiceManagementFrame,
@@ -33,7 +33,9 @@ import {
type RuntimeHostServiceUpdatePhase,
} from '@maka/runtime-host/operator';
import {
+ openRuntimeHostManagedPackageDeployment,
prepareRuntimeHostManagedPackageDeployment,
+ resolveRuntimeHostManagedPackageCliPath,
RuntimeHostManagedDeploymentError,
type RuntimeHostManagedPackageDeployment,
} from './runtime-host-managed-deployment.js';
@@ -54,6 +56,15 @@ import {
createPlatformRuntimeHostServiceBackend,
runtimeHostServiceSummary,
} from './runtime-host-service-management-command.js';
+import {
+ resolveManagedRuntimeHostUpdateSelection,
+ RuntimeHostUpdateDiscoveryError,
+} from './runtime-host-update-discovery.js';
+import {
+ RuntimeHostUpdatePackageError,
+ withRuntimeHostRegistryUpdatePackage,
+} from './runtime-host-update-package.js';
+import type { RuntimeHostUpdateSelector } from './runtime-host-cli.js';
const OPERATOR_TIMEOUT_MS = 2 * 60_000;
const OPERATOR_OUTPUT_MAX_BYTES = 256 * 1024;
@@ -66,12 +77,25 @@ export interface RuntimeHostUpdateCliOptions {
readonly sourcePackageRoot: string;
readonly version: string;
readonly expectedTarget: RuntimeHostManagedServiceTarget;
+ readonly registrySelection?: {
+ readonly integrity: string;
+ readonly current: {
+ readonly version: string;
+ readonly cliPath: string;
+ };
+ };
readonly allowInterruptActiveTasks?: boolean;
}
+export interface RuntimeHostSelectedUpdateCliOptions
+ extends Omit {
+ readonly selector: RuntimeHostUpdateSelector;
+}
+
interface RuntimeHostUpdateCliDeps {
readonly manage: typeof manageRuntimeHostService;
readonly replace: typeof replaceRuntimeHostManagedService;
+ readonly openDeployment: typeof openRuntimeHostManagedPackageDeployment;
readonly prepareDeployment: typeof prepareRuntimeHostManagedPackageDeployment;
readonly withLifecycleLock: typeof withRuntimeHostManagedServiceLifecycleLock;
readonly withDeploymentLock: typeof withRuntimeHostManagedServiceDeploymentLock;
@@ -87,6 +111,14 @@ interface RuntimeHostUpdateCliDeps {
readonly writeError: (value: string) => unknown;
}
+interface RuntimeHostSelectedUpdateCliDeps {
+ readonly resolveSelection: typeof resolveManagedRuntimeHostUpdateSelection;
+ readonly withPackage: typeof withRuntimeHostRegistryUpdatePackage;
+ readonly update: typeof runManagedRuntimeHostUpdateCli;
+ readonly writeOutput: (value: string) => unknown;
+ readonly writeError: (value: string) => unknown;
+}
+
interface RuntimeHostOperatorInvocation {
readonly inheritedFds?: readonly number[];
readonly capabilityRequest?: RuntimeHostOperatorCapability;
@@ -99,6 +131,7 @@ export async function runManagedRuntimeHostUpdateCli(
const deps: RuntimeHostUpdateCliDeps = {
manage: manageRuntimeHostService,
replace: replaceRuntimeHostManagedService,
+ openDeployment: openRuntimeHostManagedPackageDeployment,
prepareDeployment: prepareRuntimeHostManagedPackageDeployment,
withLifecycleLock: withRuntimeHostManagedServiceLifecycleLock,
withDeploymentLock: withRuntimeHostManagedServiceDeploymentLock,
@@ -111,6 +144,7 @@ export async function runManagedRuntimeHostUpdateCli(
...overrides,
};
let deployment: RuntimeHostManagedPackageDeployment | undefined;
+ let exactTargetObserved = false;
let cutoverStarted = false;
let retired = false;
const emit = (frame: RuntimeHostServiceManagementFrame): void => {
@@ -158,11 +192,44 @@ export async function runManagedRuntimeHostUpdateCli(
'The Runtime Host service is not owned by a Maka managed deployment',
);
}
+ const deploymentRoot = serviceConfig.managedDeploymentRoot;
+ const currentCliPath = resolve(serviceConfig.launch.cliPath);
+ const targetCliPath = resolveRuntimeHostManagedPackageCliPath(
+ deploymentRoot,
+ options.version,
+ options.registrySelection?.integrity,
+ );
+ const expectedCurrent = options.registrySelection?.current;
+ const selectedDeploymentStillCurrent =
+ !expectedCurrent ||
+ (currentVersion === expectedCurrent.version &&
+ currentCliPath === resolve(expectedCurrent.cliPath));
+ const targetDeploymentIsCurrent =
+ currentVersion === options.version && currentCliPath === targetCliPath;
+ exactTargetObserved = targetDeploymentIsCurrent;
+ if (!selectedDeploymentStillCurrent && !targetDeploymentIsCurrent) {
+ throw new RuntimeHostServiceManagerError(
+ 'target_mismatch',
+ 'The managed Runtime Host changed after its update candidate was selected',
+ );
+ }
emit(progress('checking', currentVersion, options.version));
let activeTargetNeedsRepair = false;
- if (currentVersion === options.version && status.service.active) {
+ if (targetDeploymentIsCurrent && status.service.active) {
try {
await deps.verifyReady(serviceConfig, backend);
+ } catch {
+ activeTargetNeedsRepair = true;
+ }
+ if (!activeTargetNeedsRepair) {
+ const currentDeployment = await deps.openDeployment({
+ serviceId,
+ clientDataRoot: options.clientDataRoot,
+ deploymentRoot,
+ cliPath: serviceConfig.launch.cliPath,
+ version: options.version,
+ });
+ await currentDeployment.cleanup();
emit({
schemaVersion: 1,
kind: 'result',
@@ -172,37 +239,59 @@ export async function runManagedRuntimeHostUpdateCli(
update: { kind: 'already_current', version: options.version },
});
return 0;
- } catch {
- activeTargetNeedsRepair = true;
}
}
const currentOperatorPath = join(serviceConfig.managedDeploymentRoot, 'operator');
- const currentOperatorUsesProcessLifetimeLock = status.service.active
- ? operatorUsesProcessLifetimeLock(
+ let currentOperatorUsesProcessLifetimeLock = false;
+ let currentOperatorUnavailable = false;
+ if (status.service.active) {
+ try {
+ currentOperatorUsesProcessLifetimeLock = operatorUsesProcessLifetimeLock(
await deps.runOperator(
currentOperatorPath,
['status', '--framed', ...expectedTargetArgs(options.expectedTarget)],
{ capabilityRequest: RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY },
),
- )
- : false;
+ );
+ } catch (error) {
+ if (!activeTargetNeedsRepair) throw error;
+ currentOperatorUnavailable = true;
+ }
+ }
emit(progress('staging', currentVersion, options.version));
deployment = await deps.withLifecycleLock(options.clientDataRoot, () =>
- deps.prepareDeployment({
- serviceId,
- clientDataRoot: options.clientDataRoot,
- sourcePackageRoot: options.sourcePackageRoot,
- version: options.version,
- }),
+ targetDeploymentIsCurrent
+ ? deps.openDeployment({
+ serviceId,
+ clientDataRoot: options.clientDataRoot,
+ deploymentRoot,
+ cliPath: serviceConfig.launch.cliPath,
+ version: options.version,
+ })
+ : deps.prepareDeployment({
+ serviceId,
+ clientDataRoot: options.clientDataRoot,
+ sourcePackageRoot: options.sourcePackageRoot,
+ version: options.version,
+ ...(options.registrySelection
+ ? { packageIntegrity: options.registrySelection.integrity }
+ : {}),
+ }),
);
- if (deployment.root !== serviceConfig.managedDeploymentRoot) {
+ if (deployment.root !== deploymentRoot) {
throw new RuntimeHostServiceManagerError(
'target_mismatch',
'The staged Runtime Host package belongs to a different managed deployment',
);
}
+ if (resolve(deployment.cliPath) !== targetCliPath) {
+ throw new RuntimeHostServiceManagerError(
+ 'target_mismatch',
+ 'The staged Runtime Host package does not match the selected deployment identity',
+ );
+ }
if (status.service.active) {
emit(progress('retiring', currentVersion, options.version));
@@ -212,12 +301,22 @@ export async function runManagedRuntimeHostUpdateCli(
: deps.withLegacyOperatorLeases(options.clientDataRoot, (inheritedFds) =>
deps.runOperator(currentOperatorPath, args, { inheritedFds }),
);
- let retirement = await runCurrentOperator([
- 'retire',
- '--framed',
- ...expectedTargetArgs(options.expectedTarget),
- ...(options.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []),
- ]);
+ let retirement: RuntimeHostServiceManagementFrame = currentOperatorUnavailable
+ ? {
+ schemaVersion: 1,
+ kind: 'error',
+ action: 'retire',
+ error: {
+ code: 'retirement_failed',
+ message: 'The active Runtime Host operator is unavailable',
+ },
+ }
+ : await runCurrentOperator([
+ 'retire',
+ '--framed',
+ ...expectedTargetArgs(options.expectedTarget),
+ ...(options.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []),
+ ]);
if (
activeTargetNeedsRepair &&
retirement.kind === 'error' &&
@@ -227,28 +326,25 @@ export async function runManagedRuntimeHostUpdateCli(
if (!options.allowInterruptActiveTasks) {
retirement = activeTasksRetirementFrame(status);
} else {
- const forced = await runCurrentOperator([
- 'stop',
- '--framed',
- ...expectedTargetArgs(options.expectedTarget),
- ]);
- if (forced.kind === 'error') {
- emit({ ...forced, action: 'update' });
- return 1;
- }
- if (forced.kind !== 'result' || forced.action !== 'stop') {
- throw new Error(
- 'The current Runtime Host operator returned an invalid stop result',
+ const forced = await deps.withLifecycleLock(options.clientDataRoot, () =>
+ deps.manage({ ...common, action: 'stop' }, backend),
+ );
+ if (
+ forced.service.active ||
+ forced.service.pid !== null ||
+ forced.service.state !== 'stopped'
+ ) {
+ throw new RuntimeHostServiceManagerError(
+ 'retirement_failed',
+ 'The unreachable Runtime Host did not reach a stable stopped state',
);
}
retirement = {
schemaVersion: 1,
kind: 'result',
action: 'retire',
- service: forced.service,
- ...(forced.operatorCapabilities
- ? { operatorCapabilities: forced.operatorCapabilities }
- : {}),
+ service: runtimeHostServiceSummary(forced),
+ ...operatorCapabilities(),
retirement: { kind: 'stopped' },
};
}
@@ -259,8 +355,9 @@ export async function runManagedRuntimeHostUpdateCli(
'The current Runtime Host operator returned an unrelated retirement error',
);
}
- await deployment.rollback().catch(() => undefined);
+ const staged = deployment;
deployment = undefined;
+ await staged.rollback();
emit({ ...retirement, action: 'update' });
return 1;
}
@@ -270,8 +367,9 @@ export async function runManagedRuntimeHostUpdateCli(
);
}
if (retirement.retirement.kind === 'active_tasks') {
- await deployment.rollback().catch(() => undefined);
+ const staged = deployment;
deployment = undefined;
+ await staged.rollback();
emit({
schemaVersion: 1,
kind: 'result',
@@ -310,13 +408,18 @@ export async function runManagedRuntimeHostUpdateCli(
},
backend,
);
- if (updatedService.installedVersion !== options.version || !updatedService.active) {
+ if (
+ updatedService.installedVersion !== options.version ||
+ !updatedService.active ||
+ !updatedService.config ||
+ resolve(updatedService.config.launch.cliPath) !== targetCliPath
+ ) {
throw new RuntimeHostServiceManagerError(
'update_incomplete',
'The replacement Runtime Host did not report the selected package version as ready',
);
}
- await targetDeployment.cleanup().catch(() => undefined);
+ await targetDeployment.cleanup();
return { schemaVersion: 1, action: 'status', service: updatedService } as const;
});
emit({
@@ -325,38 +428,47 @@ export async function runManagedRuntimeHostUpdateCli(
action: 'update',
service: runtimeHostServiceSummary(updated),
...operatorCapabilities(),
- update:
- currentVersion === options.version
- ? { kind: 'repaired', version: options.version }
- : {
- kind: 'updated',
- previousVersion: currentVersion,
- targetVersion: options.version,
- },
+ update: targetDeploymentIsCurrent
+ ? { kind: 'repaired', version: options.version }
+ : {
+ kind: 'updated',
+ previousVersion: currentVersion,
+ targetVersion: options.version,
+ },
});
return 0;
} catch (error) {
- if (deployment && !cutoverStarted) await deployment.rollback().catch(() => undefined);
- if (
- (retired || cutoverStarted) &&
- !(error instanceof RuntimeHostServiceManagerError && error.code === 'update_incomplete')
- ) {
- throw new RuntimeHostServiceManagerError(
- 'update_incomplete',
- `The Runtime Host update may have started its cutover before it failed; retry the exact ${options.version} update to complete recovery`,
- { cause: error },
- );
+ if (deployment && !cutoverStarted) {
+ const staged = deployment;
+ deployment = undefined;
+ try {
+ await staged.rollback();
+ } catch (rollbackError) {
+ throw new RuntimeHostManagedDeploymentError(
+ 'deployment_failed',
+ 'The Runtime Host update failed and its staged package could not be removed',
+ { cause: new AggregateError([error, rollbackError]) },
+ );
+ }
}
throw error;
}
});
} catch (error) {
+ const updateIncomplete = exactTargetObserved || retired || cutoverStarted;
+ const reportedError = updateIncomplete
+ ? new RuntimeHostServiceManagerError(
+ 'update_incomplete',
+ 'The Runtime Host update did not complete; run the update again to reconcile the managed installation',
+ { cause: error },
+ )
+ : error;
const code =
- error instanceof RuntimeHostServiceManagerError ||
- error instanceof RuntimeHostManagedDeploymentError
- ? error.code
+ reportedError instanceof RuntimeHostServiceManagerError ||
+ reportedError instanceof RuntimeHostManagedDeploymentError
+ ? reportedError.code
: 'internal_service_error';
- const message = error instanceof Error ? error.message : String(error);
+ const message = reportedError instanceof Error ? reportedError.message : String(reportedError);
emit({
schemaVersion: 1,
kind: 'error',
@@ -373,6 +485,99 @@ export async function runManagedRuntimeHostUpdateCli(
}
}
+export async function runManagedRuntimeHostSelectedUpdateCli(
+ options: RuntimeHostSelectedUpdateCliOptions,
+ overrides: Partial = {},
+): Promise {
+ const deps: RuntimeHostSelectedUpdateCliDeps = {
+ resolveSelection: resolveManagedRuntimeHostUpdateSelection,
+ withPackage: withRuntimeHostRegistryUpdatePackage,
+ update: runManagedRuntimeHostUpdateCli,
+ writeOutput: (value) => process.stdout.write(value),
+ writeError: (value) => process.stderr.write(value),
+ ...overrides,
+ };
+ const emit = (frame: Exclude): void => {
+ if (options.framed) {
+ deps.writeOutput(encodeRuntimeHostServiceManagementFrame(frame));
+ } else if (options.json) {
+ deps.writeOutput(`${JSON.stringify(frame)}\n`);
+ } else if (frame.kind === 'error') {
+ deps.writeError(`${frame.error.message}\n`);
+ } else {
+ if (frame.action !== 'update') {
+ throw new TypeError('Managed Runtime Host update returned an unrelated result');
+ }
+ deps.writeOutput(`${humanResult(frame)}\n`);
+ }
+ };
+
+ try {
+ const selection = await deps.resolveSelection({
+ clientDataRoot: options.clientDataRoot,
+ defaultRootPath: options.defaultRootPath,
+ selector: options.selector,
+ expectedTarget: options.expectedTarget,
+ });
+ if (selection.outcome.kind === 'manual_action') {
+ emit({
+ schemaVersion: 1,
+ kind: 'error',
+ action: 'update',
+ error: {
+ code: 'update_not_admitted',
+ message: manualUpdateRequiredMessage(
+ selection.candidate.version,
+ selection.outcome.reason,
+ ),
+ },
+ });
+ return 1;
+ }
+
+ const apply = async (packageRoot: string) => {
+ const { selector: _selector, ...updateOptions } = options;
+ return await deps.update({
+ ...updateOptions,
+ sourcePackageRoot: packageRoot,
+ version: selection.candidate.version,
+ registrySelection: {
+ integrity: selection.candidate.integrity,
+ current: {
+ version: selection.service.installedVersion,
+ cliPath: selection.currentCliPath,
+ },
+ },
+ });
+ };
+ return selection.outcome.kind === 'current'
+ ? await apply(dirname(dirname(selection.currentCliPath)))
+ : await deps.withPackage(selection.candidate, apply);
+ } catch (error) {
+ const code =
+ error instanceof RuntimeHostUpdateDiscoveryError ||
+ error instanceof RuntimeHostServiceManagerError ||
+ error instanceof RuntimeHostUpdatePackageError
+ ? error.code
+ : 'update_resolution_failed';
+ const message = error instanceof Error ? error.message : String(error);
+ emit({
+ schemaVersion: 1,
+ kind: 'error',
+ action: 'update',
+ error: {
+ code:
+ truncateUtf8(code, RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES) ||
+ 'update_resolution_failed',
+ message:
+ truncateUtf8(message, RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES) ||
+ 'Unable to prepare the Runtime Host update',
+ },
+ });
+ return 1;
+ }
+}
+
function progress(
phase: RuntimeHostServiceUpdatePhase,
currentVersion: string,
@@ -539,5 +744,28 @@ function humanResult(
if (frame.update.kind === 'repaired') {
return `Runtime Host ${frame.update.version} was restored to a ready state.`;
}
+ if (frame.update.previousVersion === frame.update.targetVersion) {
+ return `Runtime Host package ${frame.update.targetVersion} was updated.`;
+ }
return `Runtime Host was updated from ${frame.update.previousVersion} to ${frame.update.targetVersion}.`;
}
+
+function manualUpdateRequiredMessage(
+ targetVersion: string,
+ reason:
+ | 'target_not_newer'
+ | 'current_compatibility_unknown'
+ | 'target_compatibility_unknown'
+ | 'compatibility_mismatch',
+): string {
+ if (reason === 'target_not_newer') {
+ return `Maka ${targetVersion} is older than the installed Runtime Host and will not be applied automatically`;
+ }
+ if (reason === 'current_compatibility_unknown') {
+ return 'The installed Runtime Host does not publish enough compatibility evidence for an automatic update';
+ }
+ if (reason === 'target_compatibility_unknown') {
+ return `Maka ${targetVersion} does not publish enough compatibility evidence for an automatic update`;
+ }
+ return `Maka ${targetVersion} requires an explicit manual compatibility transition`;
+}
diff --git a/packages/cli/src/runtime-host-update-discovery.ts b/packages/cli/src/runtime-host-update-discovery.ts
index f2b4be91e9..ecb42ff576 100644
--- a/packages/cli/src/runtime-host-update-discovery.ts
+++ b/packages/cli/src/runtime-host-update-discovery.ts
@@ -20,7 +20,7 @@
import { spawn } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { homedir } from 'node:os';
-import { dirname, join } from 'node:path';
+import { dirname, join, resolve } from 'node:path';
import { truncateUtf8 } from '@maka/core/diagnostic-log';
import {
compareProductReleaseVersions,
@@ -41,6 +41,7 @@ import {
createPlatformRuntimeHostServiceBackend,
runtimeHostServiceSummary,
} from './runtime-host-service-management-command.js';
+import { resolveRuntimeHostManagedPackageCliPath } from './runtime-host-managed-deployment.js';
import type { RuntimeHostUpdateSelector } from './runtime-host-cli.js';
const PACKAGE_NAME = 'maka-agent';
@@ -56,22 +57,25 @@ export interface RuntimeHostUpdateCandidate {
readonly compatibility?: number;
}
-type RuntimeHostUpdateCheckFrame = Extract<
+export type RuntimeHostUpdateCheckFrame = Extract<
RuntimeHostServiceManagementFrame,
{ kind: 'result'; action: 'check_update' }
>;
type RuntimeHostUpdateCheck = RuntimeHostUpdateCheckFrame['updateCheck'];
-export interface RuntimeHostUpdateCheckCliOptions {
- readonly json: boolean;
- readonly framed: boolean;
+export interface RuntimeHostUpdateCheckOptions {
readonly clientDataRoot: string;
readonly defaultRootPath: string;
readonly selector: RuntimeHostUpdateSelector;
readonly expectedTarget?: RuntimeHostManagedServiceTarget;
}
-class RuntimeHostUpdateDiscoveryError extends Error {
+export interface RuntimeHostUpdateCheckCliOptions extends RuntimeHostUpdateCheckOptions {
+ readonly json: boolean;
+ readonly framed: boolean;
+}
+
+export class RuntimeHostUpdateDiscoveryError extends Error {
constructor(
readonly code: 'target_unavailable' | 'registry_unavailable' | 'invalid_registry_metadata',
message: string,
@@ -86,51 +90,7 @@ export async function runManagedRuntimeHostUpdateCheckCli(
options: RuntimeHostUpdateCheckCliOptions,
): Promise {
try {
- const serviceId = resolveRuntimeHostManagedServiceId(options.clientDataRoot);
- const backend = createPlatformRuntimeHostServiceBackend(serviceId);
- const status = await manageRuntimeHostService(
- {
- action: 'status',
- clientDataRoot: options.clientDataRoot,
- defaultRootPath: options.defaultRootPath,
- nodePath: process.execPath,
- cliPath: process.argv[1] ?? '',
- ...(options.expectedTarget ? { expectedTarget: options.expectedTarget } : {}),
- },
- backend,
- );
- const currentVersion = status.service.installedVersion;
- const config = status.service.config;
- const service = runtimeHostServiceSummary(status);
- const serviceState = service.state;
- if (
- !status.service.installed ||
- serviceState === 'not_installed' ||
- !currentVersion ||
- !config?.managedDeploymentRoot
- ) {
- throw new RuntimeHostServiceManagerError(
- 'not_installed',
- 'A Maka-managed Runtime Host service is required to check for updates',
- );
- }
- await backend.verifyDeployment(config);
- const [candidate, currentCompatibility] = await Promise.all([
- resolveRuntimeHostRegistryUpdateCandidate(options.selector),
- readPackageCompatibility(config.launch.cliPath, currentVersion),
- ]);
- const assessment = assessRuntimeHostUpdate(currentVersion, currentCompatibility, candidate);
- const frame: RuntimeHostUpdateCheckFrame = {
- schemaVersion: 1,
- kind: 'result',
- action: 'check_update',
- service: { ...service, state: serviceState, installedVersion: currentVersion },
- updateCheck: {
- selector: options.selector,
- candidate: { version: candidate.version, integrity: candidate.integrity },
- outcome: assessment,
- },
- };
+ const frame = await resolveManagedRuntimeHostUpdateCheck(options);
writeSuccess(frame, options);
return 0;
} catch (error) {
@@ -145,13 +105,113 @@ export async function runManagedRuntimeHostUpdateCheckCli(
}
}
+export interface RuntimeHostUpdateSelection {
+ readonly service: RuntimeHostUpdateCheckFrame['service'];
+ readonly currentCliPath: string;
+ readonly selector: RuntimeHostUpdateSelector;
+ readonly candidate: RuntimeHostUpdateCandidate;
+ readonly outcome: RuntimeHostUpdateCheck['outcome'];
+}
+
+export function resolveManagedRuntimeHostUpdateSelection(
+ options: RuntimeHostUpdateCheckOptions,
+): Promise {
+ return resolveManagedRuntimeHostUpdate(options, false);
+}
+
+async function resolveManagedRuntimeHostUpdateCheck(
+ options: RuntimeHostUpdateCheckOptions,
+): Promise {
+ return updateCheckFrame(await resolveManagedRuntimeHostUpdate(options, true));
+}
+
+async function resolveManagedRuntimeHostUpdate(
+ options: RuntimeHostUpdateCheckOptions,
+ verifyDeployment: boolean,
+): Promise {
+ const serviceId = resolveRuntimeHostManagedServiceId(options.clientDataRoot);
+ const backend = createPlatformRuntimeHostServiceBackend(serviceId);
+ const status = await manageRuntimeHostService(
+ {
+ action: 'status',
+ clientDataRoot: options.clientDataRoot,
+ defaultRootPath: options.defaultRootPath,
+ nodePath: process.execPath,
+ cliPath: process.argv[1] ?? '',
+ ...(options.expectedTarget ? { expectedTarget: options.expectedTarget } : {}),
+ },
+ backend,
+ );
+ const currentVersion = status.service.installedVersion;
+ const config = status.service.config;
+ const service = runtimeHostServiceSummary(status);
+ const serviceState = service.state;
+ if (
+ !status.service.installed ||
+ serviceState === 'not_installed' ||
+ !currentVersion ||
+ !config?.managedDeploymentRoot
+ ) {
+ throw new RuntimeHostServiceManagerError(
+ 'not_installed',
+ 'A Maka-managed Runtime Host service is required to check for updates',
+ );
+ }
+ if (verifyDeployment) await backend.verifyDeployment(config);
+ const [candidate, currentCompatibility] = await Promise.all([
+ resolveRuntimeHostRegistryUpdateCandidate(options.selector),
+ readPackageCompatibility(config.launch.cliPath, currentVersion),
+ ]);
+ const currentCliPath = resolve(config.launch.cliPath);
+ const targetCliPath = resolveRuntimeHostManagedPackageCliPath(
+ config.managedDeploymentRoot,
+ candidate.version,
+ candidate.integrity,
+ );
+ const assessment = assessRuntimeHostUpdate(
+ currentVersion,
+ currentCompatibility,
+ candidate,
+ currentCliPath === targetCliPath,
+ );
+ return {
+ service: {
+ ...service,
+ state: serviceState,
+ installedVersion: currentVersion,
+ },
+ currentCliPath,
+ selector: options.selector,
+ candidate,
+ outcome: assessment,
+ };
+}
+
+function updateCheckFrame(selection: RuntimeHostUpdateSelection): RuntimeHostUpdateCheckFrame {
+ return {
+ schemaVersion: 1,
+ kind: 'result',
+ action: 'check_update',
+ service: selection.service,
+ updateCheck: {
+ selector: selection.selector,
+ candidate: {
+ version: selection.candidate.version,
+ integrity: selection.candidate.integrity,
+ },
+ outcome: selection.outcome,
+ },
+ };
+}
+
export function assessRuntimeHostUpdate(
currentVersion: string,
currentCompatibility: number | undefined,
candidate: RuntimeHostUpdateCandidate,
+ currentDeploymentMatchesCandidate: boolean,
): RuntimeHostUpdateCheck['outcome'] {
const relation = compareProductReleaseVersions(candidate.version, currentVersion);
- if (relation === 0) return { kind: 'current' };
+ if (relation === 0 && currentDeploymentMatchesCandidate) return { kind: 'current' };
if (relation < 0) {
return { kind: 'manual_action', reason: 'target_not_newer' };
}
@@ -214,7 +274,11 @@ export async function resolveRuntimeHostRegistryUpdateCandidate(
return invalidMetadata();
}
const compatibility = positiveInteger(metadata[COMPATIBILITY_FIELD]);
- return { version, integrity, ...(compatibility === undefined ? {} : { compatibility }) };
+ return {
+ version,
+ integrity,
+ ...(compatibility === undefined ? {} : { compatibility }),
+ };
}
async function readPackageCompatibility(
diff --git a/packages/cli/src/runtime-host-update-package.ts b/packages/cli/src/runtime-host-update-package.ts
new file mode 100644
index 0000000000..55f626b983
--- /dev/null
+++ b/packages/cli/src/runtime-host-update-package.ts
@@ -0,0 +1,260 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { spawn } from 'node:child_process';
+import { createHash } from 'node:crypto';
+import { createReadStream } from 'node:fs';
+import { lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { isProductReleaseVersion, isSha512PackageIntegrity } from '@maka/runtime-host/operator';
+import type { RuntimeHostUpdateCandidate } from './runtime-host-update-discovery.js';
+
+const PACKAGE_NAME = 'maka-agent';
+const NPM_REGISTRY = 'https://registry.npmjs.org/';
+const OFFLINE_REGISTRY = 'http://127.0.0.1:9/';
+const NPM_TIMEOUT_MS = 5 * 60_000;
+const NPM_OUTPUT_MAX_BYTES = 64 * 1024;
+const ARCHIVE_MAX_BYTES = 256 * 1024 * 1024;
+const MANIFEST_MAX_BYTES = 64 * 1024;
+
+export class RuntimeHostUpdatePackageError extends Error {
+ constructor(
+ readonly code: 'package_download_failed' | 'package_integrity_mismatch' | 'invalid_package',
+ message: string,
+ options?: ErrorOptions,
+ ) {
+ super(message, options);
+ this.name = 'RuntimeHostUpdatePackageError';
+ }
+}
+
+type RunNpm = (args: readonly string[], cwd: string) => Promise;
+
+export async function withRuntimeHostRegistryUpdatePackage(
+ candidate: RuntimeHostUpdateCandidate,
+ use: (packageRoot: string) => Promise,
+ runNpm: RunNpm = runNpmCommand,
+): Promise {
+ if (
+ !isProductReleaseVersion(candidate.version) ||
+ !isSha512PackageIntegrity(candidate.integrity) ||
+ (candidate.compatibility !== undefined &&
+ (!Number.isInteger(candidate.compatibility) || candidate.compatibility <= 0))
+ ) {
+ throw new RuntimeHostUpdatePackageError(
+ 'invalid_package',
+ 'The selected Runtime Host update candidate is invalid',
+ );
+ }
+
+ const temporaryRoot = await mkdtemp(join(tmpdir(), 'maka-runtime-host-update-'));
+ try {
+ let packageRoot: string;
+ try {
+ const downloadRoot = join(temporaryRoot, 'download');
+ const downloadCache = join(temporaryRoot, 'download-cache');
+ const installRoot = join(temporaryRoot, 'install');
+ const emptyCache = join(temporaryRoot, 'empty-cache');
+ await mkdir(downloadRoot, { mode: 0o700 });
+ const packed = await runNpm(
+ [
+ 'pack',
+ `${PACKAGE_NAME}@${candidate.version}`,
+ '--pack-destination',
+ downloadRoot,
+ '--registry',
+ NPM_REGISTRY,
+ '--cache',
+ downloadCache,
+ '--ignore-scripts',
+ ],
+ temporaryRoot,
+ );
+ if (packed !== 0) {
+ throw new RuntimeHostUpdatePackageError(
+ 'package_download_failed',
+ `Unable to download Maka ${candidate.version} from the official npm registry`,
+ );
+ }
+
+ const archive = await requireDownloadedArchive(downloadRoot);
+ if ((await packageIntegrity(archive)) !== candidate.integrity) {
+ throw new RuntimeHostUpdatePackageError(
+ 'package_integrity_mismatch',
+ `The downloaded Maka ${candidate.version} package does not match its registry integrity`,
+ );
+ }
+
+ const installed = await runNpm(
+ [
+ 'install',
+ '--prefix',
+ installRoot,
+ '--ignore-scripts',
+ '--no-audit',
+ '--no-fund',
+ '--package-lock=false',
+ '--offline',
+ '--cache',
+ emptyCache,
+ '--registry',
+ OFFLINE_REGISTRY,
+ archive,
+ ],
+ temporaryRoot,
+ );
+ if (installed !== 0) {
+ throw new RuntimeHostUpdatePackageError(
+ 'invalid_package',
+ `Unable to extract the verified Maka ${candidate.version} package`,
+ );
+ }
+
+ packageRoot = await validateExtractedPackage(installRoot, candidate);
+ } catch (error) {
+ if (error instanceof RuntimeHostUpdatePackageError) throw error;
+ throw new RuntimeHostUpdatePackageError(
+ 'package_download_failed',
+ `Unable to prepare Maka ${candidate.version} for a managed Runtime Host update`,
+ { cause: error },
+ );
+ }
+ return await use(packageRoot);
+ } finally {
+ await rm(temporaryRoot, { recursive: true, force: true }).catch(() => undefined);
+ }
+}
+
+async function requireDownloadedArchive(downloadRoot: string): Promise {
+ const entries = await readdir(downloadRoot, { withFileTypes: true });
+ if (entries.length !== 1 || !entries[0]?.isFile() || !entries[0].name.endsWith('.tgz')) {
+ throw new RuntimeHostUpdatePackageError(
+ 'package_download_failed',
+ 'The npm registry did not return one Maka package archive',
+ );
+ }
+ const archive = join(downloadRoot, entries[0].name);
+ const [metadata, target] = await Promise.all([stat(archive), lstat(archive)]);
+ if (
+ !metadata.isFile() ||
+ target.isSymbolicLink() ||
+ metadata.size <= 0 ||
+ metadata.size > ARCHIVE_MAX_BYTES
+ ) {
+ throw new RuntimeHostUpdatePackageError(
+ 'invalid_package',
+ 'The downloaded Maka package archive is invalid',
+ );
+ }
+ return archive;
+}
+
+async function packageIntegrity(path: string): Promise {
+ const hash = createHash('sha512');
+ for await (const chunk of createReadStream(path)) hash.update(chunk as Buffer);
+ return `sha512-${hash.digest('base64')}`;
+}
+
+async function validateExtractedPackage(
+ installRoot: string,
+ candidate: RuntimeHostUpdateCandidate,
+): Promise {
+ try {
+ const packageRoot = await realpath(join(installRoot, 'node_modules', PACKAGE_NAME));
+ const manifestPath = join(packageRoot, 'package.json');
+ const [manifestMetadata, cli, runtimeHost] = await Promise.all([
+ stat(manifestPath),
+ stat(join(packageRoot, 'dist', 'cli.js')),
+ stat(join(packageRoot, 'node_modules', '@maka', 'runtime-host', 'package.json')),
+ ]);
+ if (
+ !manifestMetadata.isFile() ||
+ manifestMetadata.size > MANIFEST_MAX_BYTES ||
+ !cli.isFile() ||
+ !runtimeHost.isFile()
+ ) {
+ throw new RuntimeHostUpdatePackageError(
+ 'invalid_package',
+ 'The downloaded Maka package is not a self-contained release',
+ );
+ }
+ const manifest: unknown = JSON.parse(await readFile(manifestPath, 'utf8'));
+ const compatibility =
+ isRecord(manifest) && isRecord(manifest.maka)
+ ? positiveInteger(manifest.maka.managedRuntimeHostUpdateCompatibility)
+ : undefined;
+ if (
+ !isRecord(manifest) ||
+ manifest.name !== PACKAGE_NAME ||
+ manifest.version !== candidate.version ||
+ compatibility !== candidate.compatibility
+ ) {
+ throw new RuntimeHostUpdatePackageError(
+ 'invalid_package',
+ 'The downloaded Maka package does not match its registry metadata',
+ );
+ }
+ return packageRoot;
+ } catch (error) {
+ if (error instanceof RuntimeHostUpdatePackageError) throw error;
+ throw new RuntimeHostUpdatePackageError(
+ 'invalid_package',
+ 'The downloaded Maka package manifest is invalid',
+ { cause: error },
+ );
+ }
+}
+
+function runNpmCommand(args: readonly string[], cwd: string): Promise {
+ return new Promise((resolve, reject) => {
+ const child = spawn('npm', [...args], {
+ cwd,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ timeout: NPM_TIMEOUT_MS,
+ killSignal: 'SIGKILL',
+ });
+ let outputBytes = 0;
+ let outputExceeded = false;
+ const observe = (chunk: Buffer) => {
+ outputBytes += chunk.byteLength;
+ if (outputBytes <= NPM_OUTPUT_MAX_BYTES) return;
+ outputExceeded = true;
+ child.kill('SIGKILL');
+ };
+ child.stdout.on('data', observe);
+ child.stderr.on('data', observe);
+ child.once('error', reject);
+ child.once('close', (code) => {
+ if (outputExceeded) {
+ reject(new Error('npm returned too much output while preparing the update package'));
+ } else {
+ resolve(code ?? 1);
+ }
+ });
+ });
+}
+
+function positiveInteger(value: unknown): number | undefined {
+ return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined;
+}
+
+function isRecord(value: unknown): value is Record {
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
+}
diff --git a/packages/runtime-host/src/operator/service-management-frame.ts b/packages/runtime-host/src/operator/service-management-frame.ts
index 2ebe23afe5..c857bef788 100644
--- a/packages/runtime-host/src/operator/service-management-frame.ts
+++ b/packages/runtime-host/src/operator/service-management-frame.ts
@@ -58,6 +58,17 @@ const NON_RETIRE_SERVICE_ACTIONS = [
'logs',
'uninstall',
] as const;
+const NON_UPDATE_SERVICE_ACTIONS = [
+ 'install',
+ 'status',
+ 'start',
+ 'stop',
+ 'restart',
+ 'retire',
+ 'check_update',
+ 'logs',
+ 'uninstall',
+] as const;
const UPDATE_PHASES = ['checking', 'staging', 'retiring', 'replacing'] as const;
const UPDATE_CHANNELS = ['latest', 'next'] as const;
const MANUAL_ACTION_REASONS = [
@@ -174,6 +185,12 @@ const SERVICE_RESULT_COMMON = {
retainedStateRoot: boundedString(PATH_MAX_BYTES).optional(),
logs: boundedString(RUNTIME_HOST_SERVICE_LOG_MAX_BYTES).optional(),
} as const;
+const SERVICE_ERROR_SCHEMA = z
+ .object({
+ code: boundedNonEmptyString(RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES),
+ message: boundedNonEmptyString(RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES),
+ })
+ .strict();
const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.union([
z
@@ -204,10 +221,10 @@ const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.union([
outcome.kind === 'current'
? relation === 0
: outcome.kind === 'unattended_update'
- ? relation > 0
+ ? relation >= 0
: outcome.reason === 'target_not_newer'
? relation < 0
- : relation > 0;
+ : relation >= 0;
if (!consistent) {
context.addIssue({
code: 'custom',
@@ -267,13 +284,16 @@ const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.union([
.object({
schemaVersion: z.literal(1),
kind: z.literal('error'),
- action: z.enum(SERVICE_ACTIONS),
- error: z
- .object({
- code: boundedNonEmptyString(RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES),
- message: boundedNonEmptyString(RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES),
- })
- .strict(),
+ action: z.literal('update'),
+ error: SERVICE_ERROR_SCHEMA,
+ })
+ .strict(),
+ z
+ .object({
+ schemaVersion: z.literal(1),
+ kind: z.literal('error'),
+ action: z.enum(NON_UPDATE_SERVICE_ACTIONS),
+ error: SERVICE_ERROR_SCHEMA,
})
.strict(),
]);