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(), ]);