diff --git a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts index 9123219a52..cfe37cda97 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts @@ -579,7 +579,7 @@ function serviceResult( operatorAccess = false, ): Exclude< Extract, - { action: 'check_update' | 'update' } + { action: 'check_update' | 'update' | 'update_policy' | 'reconcile_update' } > { const result = { schemaVersion: 1 as const, diff --git a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts index fd688ee6d9..d8458b7250 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts @@ -264,7 +264,9 @@ test('reads a framed service result without projecting it into the SSH terminal' const result = await management; assert.equal(result.kind, 'result'); - if (result.kind !== 'result') assert.fail('expected service management result'); + if (result.kind !== 'result' || result.action !== 'status') { + assert.fail('expected service status result'); + } assert.equal(result.service.installedVersion, '1.2.3'); assert.doesNotMatch(JSON.stringify(harness.events), /MAKA_RUNTIME_HOST_SERVICE/u); assert.match(JSON.stringify(harness.events), /Password/u); diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts index 43c133a6b6..e2a563cb7e 100644 --- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts +++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts @@ -85,7 +85,10 @@ export interface DesktopRuntimeHostSshManagementInput { readonly destination: string; readonly sshPort?: number; readonly operatorPath: string; - readonly action: Exclude; + readonly action: Exclude< + RuntimeHostServiceManagementAction, + 'check_update' | 'update' | 'update_policy' | 'reconcile_update' + >; readonly expectedTarget: { readonly serviceId: string; readonly rootPath: string; diff --git a/packages/cli/README.md b/packages/cli/README.md index 0e2008c1c8..58666b8d2b 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -139,6 +139,20 @@ installs or switches a package. Installation-management callers can pass the sam to the existing exact-package update transaction, and does not mutate a candidate that requires manual review. +The installation owner can persist one update target and reconcile it with the same verified +transaction: + +```sh +maka runtime-host service update-policy --target latest \ + --expected-service-id \ + --expected-root-path \ + --expected-root-id +maka runtime-host service reconcile-update --json +``` + +Use `update-policy --target manual` to disable automatic reconciliation. Reconciliation is a +bounded one-shot command: it never interrupts active work and does not install a scheduler. + ## Uninstall ```sh diff --git a/packages/cli/README.zh-CN.md b/packages/cli/README.zh-CN.md index 244830149a..c6650f46af 100644 --- a/packages/cli/README.zh-CN.md +++ b/packages/cli/README.zh-CN.md @@ -129,6 +129,19 @@ maka runtime-host service check-update --target next --json selector 传给 `service update --target`。该路径会先校验 archive 与解包后的 manifest,再委托给 现有的精确 package 更新事务;需要人工审查的候选不会改变当前 Host。 +Installation owner 可以持久化一个更新目标,并通过同一套已验证事务执行 reconciliation: + +```sh +maka runtime-host service update-policy --target latest \ + --expected-service-id \ + --expected-root-path \ + --expected-root-id +maka runtime-host service reconcile-update --json +``` + +使用 `update-policy --target manual` 关闭自动 reconciliation。Reconciliation 是有界的单次命令: +它不会中断 active work,也不会安装 scheduler。 + ## 卸载 ```sh diff --git a/packages/cli/src/__tests__/runtime-host-selected-update.test.ts b/packages/cli/src/__tests__/runtime-host-selected-update.test.ts index 8ce5ac0194..b758b83a54 100644 --- a/packages/cli/src/__tests__/runtime-host-selected-update.test.ts +++ b/packages/cli/src/__tests__/runtime-host-selected-update.test.ts @@ -21,6 +21,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { decodeRuntimeHostServiceManagementFrame } from '@maka/runtime-host/operator'; import { + runManagedRuntimeHostUpdateCli, runManagedRuntimeHostSelectedUpdateCli, type RuntimeHostSelectedUpdateCliOptions, type RuntimeHostUpdateCliOptions, @@ -45,6 +46,41 @@ const OPTIONS: RuntimeHostSelectedUpdateCliOptions = { }; describe('managed Runtime Host selected update', () => { + it('revalidates selection inside the deployment lock before reading service state', async () => { + let lockHeld = false; + let output = ''; + assert.equal( + await runManagedRuntimeHostUpdateCli( + { + ...OPTIONS, + sourcePackageRoot: '/verified/package', + version: '2.0.0', + }, + { + withDeploymentLock: async (_root, operation) => { + lockHeld = true; + try { + return await operation(); + } finally { + lockHeld = false; + } + }, + revalidateSelection: async () => { + assert.equal(lockHeld, true); + return { code: 'update_policy_changed', message: 'The policy changed' }; + }, + manage: async () => assert.fail('service state must not be read'), + writeOutput: (value) => { + output += value; + }, + }, + ), + 1, + ); + const frame = decodeRuntimeHostServiceManagementFrame(output.trim()); + assert.equal(frame?.kind === 'error' ? frame.error.code : undefined, 'update_policy_changed'); + }); + it('parses an optional target without changing the exact-package command', () => { assert.deepEqual( parseRuntimeHostCommand([ @@ -111,7 +147,7 @@ describe('managed Runtime Host selected update', () => { }); }); - it('lets the exact transaction decide whether a current candidate needs repair', async () => { + it('lets the exact transaction inspect the current deployment without downloading it again', async () => { const selection = updateSelection({ kind: 'current' }); let updateInput: RuntimeHostUpdateCliOptions | undefined; assert.equal( 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 244bdb7c93..a301ba6f54 100644 --- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts +++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts @@ -62,6 +62,10 @@ import { type RuntimeHostManagedServiceResult, type RuntimeHostServiceBackend, } from '../runtime-host-service-manager.js'; +import { + readRuntimeHostManagedUpdatePolicy, + writeRuntimeHostManagedUpdatePolicy, +} from '../runtime-host-update-policy-store.js'; import { createSystemdUserRuntimeHostService, renderSystemdUnit, @@ -263,7 +267,7 @@ describe('managed Runtime Host service', () => { }); it('installs, reports, and cleanly uninstalls while retaining the State Root', async (t) => { - const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-service-')); + const base = await realpath(await mkdtemp(join(tmpdir(), 'maka-runtime-host-service-'))); t.after(() => rm(base, { recursive: true, force: true })); const homeDir = join(base, 'home'); const clientDataRoot = join(base, 'config', 'Maka'); @@ -271,11 +275,14 @@ describe('managed Runtime Host service', () => { const projectPath = join(base, 'projects'); await writeFile(join(base, 'placeholder'), '', 'utf8'); await mkdir(projectPath, { recursive: true }); - const env = { XDG_CONFIG_HOME: join(base, 'xdg-config') }; + const env = { + XDG_CONFIG_HOME: join(base, 'xdg-config'), + XDG_DATA_HOME: join(base, 'xdg-data'), + }; const configPath = resolveRuntimeHostManagedServiceConfigPath(clientDataRoot); const serviceId = resolveRuntimeHostManagedServiceId(clientDataRoot); const deploymentRoot = resolveRuntimeHostManagedDeploymentRoot(serviceId, { - env: { XDG_DATA_HOME: join(base, 'xdg-data') }, + env, homeDir, platform: 'linux', }); @@ -302,6 +309,9 @@ describe('managed Runtime Host service', () => { const managerDeps = { allocateLoopbackPort: async () => 49_999, waitForReady: async () => undefined, + environment: env, + homeDir, + platform: 'linux' as const, } as const; const installed = await manageRuntimeHostService( @@ -394,6 +404,43 @@ describe('managed Runtime Host service', () => { rootPath: root.canonicalPath, rootId: root.rootId, } as const; + const updatePolicy = { + schemaVersion: 1 as const, + policy: { kind: 'channel' as const, channel: 'latest' as const }, + target: expectedTarget, + }; + await writeRuntimeHostManagedUpdatePolicy(deploymentRoot, updatePolicy); + await writeFile(configPath, '{not-json', 'utf8'); + await manageRuntimeHostService( + { + ...common, + cliPath: globalCliPath, + action: 'uninstall', + retainManagedDeployment: true, + expectedTarget, + }, + backend(), + managerDeps, + ); + assert.equal(await readRuntimeHostManagedUpdatePolicy(deploymentRoot), null); + await access(deploymentRoot); + + await manageRuntimeHostService({ ...common, action: 'install' }, backend(), managerDeps); + assert.equal(await readRuntimeHostManagedUpdatePolicy(deploymentRoot), null); + await writeRuntimeHostManagedUpdatePolicy(deploymentRoot, updatePolicy); + await assert.rejects( + manageRuntimeHostService( + { + ...common, + action: 'uninstall', + expectedTarget: { ...expectedTarget, rootId: 'f'.repeat(64) }, + }, + backend(), + ), + (error: unknown) => + error instanceof RuntimeHostServiceManagerError && error.code === 'target_mismatch', + ); + assert.deepEqual(await readRuntimeHostManagedUpdatePolicy(deploymentRoot), updatePolicy); await assert.rejects( cleanupRuntimeHostManagedDeployment( { clientDataRoot, cliPath: canonicalCliPath, expectedTarget }, @@ -412,6 +459,7 @@ describe('managed Runtime Host service', () => { }, backend(), ); + assert.equal(await readRuntimeHostManagedUpdatePolicy(deploymentRoot), null); await cleanupRuntimeHostManagedDeployment( { clientDataRoot, cliPath: canonicalCliPath, expectedTarget }, backend(), @@ -822,7 +870,9 @@ describe('managed Runtime Host service', () => { const legacyFrame = decodeRuntimeHostServiceManagementFrame(await run()); assert.equal(legacyFrame?.kind, 'result'); assert.equal( - legacyFrame?.kind === 'result' ? legacyFrame.operatorCapabilities : undefined, + legacyFrame?.kind === 'result' && legacyFrame.action === 'status' + ? legacyFrame.operatorCapabilities + : undefined, undefined, ); @@ -830,7 +880,9 @@ describe('managed Runtime Host service', () => { RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY; const frame = decodeRuntimeHostServiceManagementFrame(await run()); assert.equal(frame?.kind, 'result'); - if (frame?.kind !== 'result') assert.fail('Expected a service result frame'); + if (frame?.kind !== 'result' || frame.action !== 'status') { + assert.fail('Expected a service status result frame'); + } assert.equal(frame.service.installedVersion, '1.2.3'); assert.deepEqual(frame.operatorCapabilities, ['access-management-v1']); assert.equal(frame.service.stateRoot, '/srv/maka'); @@ -839,9 +891,12 @@ describe('managed Runtime Host service', () => { process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV] = RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY; const lockFrame = decodeRuntimeHostServiceManagementFrame(await run()); - assert.deepEqual(lockFrame?.kind === 'result' ? lockFrame.operatorCapabilities : undefined, [ - 'process-lifetime-lock-v1', - ]); + assert.deepEqual( + lockFrame?.kind === 'result' && lockFrame.action === 'status' + ? lockFrame.operatorCapabilities + : undefined, + ['process-lifetime-lock-v1'], + ); }); it('reads service logs when an interrupted install left no config', async (t) => { diff --git a/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts b/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts new file mode 100644 index 0000000000..5f668a0a3b --- /dev/null +++ b/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts @@ -0,0 +1,422 @@ +/* + * 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 { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import { + decodeRuntimeHostServiceManagementFrame, + type RuntimeHostServiceManagementFrame, +} from '@maka/runtime-host/operator'; +import { parseRuntimeHostCommand } from '../runtime-host-cli.js'; +import { + runManagedRuntimeHostUpdatePolicyCli, + runManagedRuntimeHostUpdateReconcileCli, +} from '../runtime-host-update-reconciliation.js'; +import { + readRuntimeHostManagedUpdatePolicy, + resolveRuntimeHostManagedUpdatePolicyPath, + RuntimeHostUpdatePolicyError, + writeRuntimeHostManagedUpdatePolicy, +} from '../runtime-host-update-policy-store.js'; + +const INTEGRITY = + 'sha512-jUKdo/5dbM94KXq+kOZ1d+obhDLAENfI/QWr1PnXWcdu2PqDyLklJBtiVO6HRwoL1l40z1NE9Rq+hLAxCN0Fyg=='; +const TARGET = { + serviceId: 'b'.repeat(64), + rootPath: '/srv/maka-link', + rootId: 'a'.repeat(64), +}; +const SERVICE = { + platform: 'linux', + arch: 'x64', + osRelease: 'test', + state: 'running' as const, + pid: 42, + lastExitCode: null, + installedVersion: '1.0.0', + stateRoot: '/srv/maka', + projectDirectoryRoots: [], +}; + +describe('managed Runtime Host update reconciliation', () => { + it('parses one mutually exclusive policy and a target-free reconcile command', () => { + assert.deepEqual( + parseRuntimeHostCommand([ + 'service', + 'update-policy', + '--target', + 'next', + '--expected-service-id', + TARGET.serviceId, + '--expected-root-path', + TARGET.rootPath, + '--expected-root-id', + TARGET.rootId, + ]), + { + kind: 'runtime-host-service-update-policy', + json: false, + policy: { kind: 'channel', channel: 'next' }, + expectedTarget: TARGET, + }, + ); + assert.deepEqual(parseRuntimeHostCommand(['service', 'reconcile-update', '--json']), { + kind: 'runtime-host-service-reconcile-update', + json: true, + }); + assert.equal( + parseRuntimeHostCommand(['service', 'update-policy', '--target', 'latest']).kind, + 'error', + ); + }); + + it('persists an automatic policy against the canonical managed target and removes manual state', async (t) => { + const clientDataRoot = await mkdtemp(join(tmpdir(), 'maka-update-policy-')); + t.after(() => rm(clientDataRoot, { recursive: true, force: true })); + const deploymentRoot = join(clientDataRoot, 'managed'); + const common = { + json: true, + framed: false, + clientDataRoot, + defaultRootPath: '/workspace', + }; + let output = ''; + const manage = async () => managedStatus(deploymentRoot); + assert.equal( + await runManagedRuntimeHostUpdatePolicyCli( + { + ...common, + policy: { kind: 'fixed', version: '2.0.0' }, + expectedTarget: TARGET, + }, + { + withDeploymentLock: async (_root, operation) => operation(), + createBackend: () => unusedBackend(), + manage, + writeOutput: (value) => { + output += value; + }, + }, + ), + 0, + ); + assert.deepEqual(await readRuntimeHostManagedUpdatePolicy(deploymentRoot), { + schemaVersion: 1, + policy: { kind: 'fixed', version: '2.0.0' }, + target: { ...TARGET, rootPath: '/srv/maka' }, + }); + assert.equal(JSON.parse(output).updatePolicy.policy.kind, 'fixed'); + + assert.equal( + await runManagedRuntimeHostUpdatePolicyCli( + { ...common, policy: { kind: 'manual' } }, + { + withDeploymentLock: async (_root, operation) => operation(), + manage, + createBackend: () => unusedBackend(), + writeOutput: () => undefined, + }, + ), + 0, + ); + assert.equal(await readRuntimeHostManagedUpdatePolicy(deploymentRoot), null); + let manualOutput = ''; + assert.equal( + await runManagedRuntimeHostUpdateReconcileCli( + { ...common, json: false, framed: true }, + { + manage, + createBackend: () => unusedBackend(), + resolveSelection: async () => assert.fail('manual policy must not resolve a target'), + writeOutput: (value) => { + manualOutput += value; + }, + }, + ), + 0, + ); + const manualFrame = decodeRuntimeHostServiceManagementFrame(manualOutput.trim()); + assert.equal( + manualFrame?.kind === 'result' && manualFrame.action === 'reconcile_update' + ? manualFrame.reconciliation.kind + : undefined, + 'disabled', + ); + }); + + it('distinguishes an uncertain policy commit and makes an absent-policy retry durable', async (t) => { + const deploymentRoot = await mkdtemp(join(tmpdir(), 'maka-update-policy-commit-')); + t.after(() => rm(deploymentRoot, { recursive: true, force: true })); + const record = { + schemaVersion: 1 as const, + policy: { kind: 'fixed' as const, version: '2.0.0' }, + target: TARGET, + }; + const failSync = async () => { + throw new Error('directory sync failed'); + }; + + await assert.rejects( + writeRuntimeHostManagedUpdatePolicy(deploymentRoot, record, { syncDirectory: failSync }), + (error: unknown) => + error instanceof RuntimeHostUpdatePolicyError && + error.code === 'update_policy_commit_outcome_unknown', + ); + assert.deepEqual(await readRuntimeHostManagedUpdatePolicy(deploymentRoot), record); + + await assert.rejects( + writeRuntimeHostManagedUpdatePolicy(deploymentRoot, null, { syncDirectory: failSync }), + (error: unknown) => + error instanceof RuntimeHostUpdatePolicyError && + error.code === 'update_policy_commit_outcome_unknown', + ); + assert.equal(await readRuntimeHostManagedUpdatePolicy(deploymentRoot), null); + let syncs = 0; + await writeRuntimeHostManagedUpdatePolicy(deploymentRoot, null, { + syncDirectory: async () => { + syncs += 1; + }, + }); + assert.equal(syncs, 1); + }); + + it('resolves one policy snapshot and delegates an admitted exact target to the update transaction', async (t) => { + const clientDataRoot = await mkdtemp(join(tmpdir(), 'maka-update-reconcile-')); + t.after(() => rm(clientDataRoot, { recursive: true, force: true })); + const deploymentRoot = join(clientDataRoot, 'managed'); + await writeRuntimeHostManagedUpdatePolicy(deploymentRoot, { + schemaVersion: 1, + policy: { kind: 'fixed', version: '2.0.0' }, + target: TARGET, + }); + let output = ''; + let applied = false; + const exitCode = await runManagedRuntimeHostUpdateReconcileCli( + { + json: true, + framed: false, + clientDataRoot, + defaultRootPath: '/workspace', + }, + { + manage: async () => managedStatus(deploymentRoot), + createBackend: () => unusedBackend(), + resolveSelection: async (options) => { + assert.deepEqual(options.selector, { kind: 'exact', version: '2.0.0' }); + assert.deepEqual(options.expectedTarget, TARGET); + return { + selector: options.selector, + candidate: { version: '2.0.0', integrity: INTEGRITY, compatibility: 1 }, + outcome: { kind: 'unattended_update', compatibility: 1 }, + currentCliPath: '/managed/current/cli.js', + service: SERVICE, + }; + }, + applySelection: async (_options, _selection, overrides, emit) => { + assert.equal(await overrides?.revalidateSelection?.(), undefined); + applied = true; + emit?.({ + schemaVersion: 1, + kind: 'progress', + action: 'update', + phase: 'checking', + currentVersion: '1.0.0', + targetVersion: '2.0.0', + }); + emit?.({ + schemaVersion: 1, + kind: 'result', + action: 'update', + service: { ...SERVICE, installedVersion: '2.0.0' }, + update: { + kind: 'updated', + previousVersion: '1.0.0', + targetVersion: '2.0.0', + }, + }); + return 0; + }, + writeOutput: (value) => { + output += value; + }, + }, + ); + assert.equal(exitCode, 0); + assert.equal(applied, true); + const frame = JSON.parse(output) as RuntimeHostServiceManagementFrame; + assert.equal(frame.action, 'reconcile_update'); + assert.deepEqual( + frame.kind === 'result' && frame.action === 'reconcile_update' + ? frame.reconciliation + : undefined, + { kind: 'updated', previousVersion: '1.0.0', targetVersion: '2.0.0' }, + ); + }); + + it('revokes a stale reconcile when automatic policy changes to manual', async (t) => { + const clientDataRoot = await mkdtemp(join(tmpdir(), 'maka-update-policy-race-')); + t.after(() => rm(clientDataRoot, { recursive: true, force: true })); + const deploymentRoot = join(clientDataRoot, 'managed'); + await writeRuntimeHostManagedUpdatePolicy(deploymentRoot, { + schemaVersion: 1, + policy: { kind: 'channel', channel: 'latest' }, + target: TARGET, + }); + let output = ''; + assert.equal( + await runManagedRuntimeHostUpdateReconcileCli( + { json: true, framed: false, clientDataRoot, defaultRootPath: '/workspace' }, + { + manage: async () => managedStatus(deploymentRoot), + createBackend: () => unusedBackend(), + resolveSelection: async (options) => { + await writeRuntimeHostManagedUpdatePolicy(deploymentRoot, null); + return { + selector: options.selector, + candidate: { version: '2.0.0', integrity: INTEGRITY, compatibility: 1 }, + outcome: { kind: 'unattended_update', compatibility: 1 }, + currentCliPath: '/managed/current/cli.js', + service: SERVICE, + }; + }, + applySelection: async (_options, _selection, overrides, emit) => { + const rejection = await overrides?.revalidateSelection?.(); + assert.equal(rejection?.code, 'update_policy_changed'); + emit?.({ + schemaVersion: 1, + kind: 'error', + action: 'update', + error: rejection ?? assert.fail('policy revalidation is required'), + }); + return 1; + }, + writeOutput: (value) => { + output += value; + }, + }, + ), + 1, + ); + assert.equal(JSON.parse(output).error.code, 'update_policy_changed'); + assert.equal(await readRuntimeHostManagedUpdatePolicy(deploymentRoot), null); + }); + + it('fails closed on corrupt policy and returns manual-action candidates without mutation', async (t) => { + const clientDataRoot = await mkdtemp(join(tmpdir(), 'maka-update-reconcile-')); + t.after(() => rm(clientDataRoot, { recursive: true, force: true })); + const deploymentRoot = join(clientDataRoot, 'managed'); + await mkdir(deploymentRoot, { recursive: true }); + await writeFile(resolveRuntimeHostManagedUpdatePolicyPath(deploymentRoot), '{"bad":true}\n'); + let errorOutput = ''; + assert.equal( + await runManagedRuntimeHostUpdateReconcileCli( + { json: true, framed: false, clientDataRoot, defaultRootPath: '/workspace' }, + { + manage: async () => managedStatus(deploymentRoot), + createBackend: () => unusedBackend(), + writeOutput: (value) => { + errorOutput += value; + }, + }, + ), + 1, + ); + assert.equal(JSON.parse(errorOutput).error.code, 'invalid_update_policy'); + + await writeRuntimeHostManagedUpdatePolicy(deploymentRoot, { + schemaVersion: 1, + policy: { kind: 'channel', channel: 'latest' }, + target: TARGET, + }); + let output = ''; + assert.equal( + await runManagedRuntimeHostUpdateReconcileCli( + { json: true, framed: false, clientDataRoot, defaultRootPath: '/workspace' }, + { + manage: async () => managedStatus(deploymentRoot), + createBackend: () => unusedBackend(), + resolveSelection: async (options) => ({ + selector: options.selector, + candidate: { version: '2.0.0', integrity: INTEGRITY }, + outcome: { kind: 'manual_action', reason: 'target_compatibility_unknown' }, + currentCliPath: '/managed/current/cli.js', + service: SERVICE, + }), + applySelection: async () => assert.fail('update transaction is not expected'), + writeOutput: (value) => { + output += value; + }, + }, + ), + 1, + ); + assert.equal(JSON.parse(output).reconciliation.kind, 'manual_action'); + }); +}); + +function managedStatus(managedDeploymentRoot: string) { + return { + schemaVersion: 1 as const, + action: 'status' as const, + service: { + manager: 'systemd_user' as const, + installed: true, + enabled: true, + active: true, + state: 'running' as const, + pid: 42, + lastExitCode: null, + installedVersion: '1.0.0', + config: { + schemaVersion: 1 as const, + managedDeploymentRoot, + rootPath: '/srv/maka', + projectDirectoryRoots: [], + websocket: { host: '127.0.0.1' as const, port: 7443, path: '/runtime-host' }, + launch: { nodePath: '/node', cliPath: join(managedDeploymentRoot, 'cli.js') }, + }, + }, + }; +} + +function unusedBackend() { + return { + preflightInstall: async () => undefined, + install: async () => ({ rollback: async () => undefined }), + replace: async () => undefined, + verifyDeployment: async () => undefined, + status: async () => ({ + manager: 'systemd_user' as const, + installed: false, + enabled: false, + active: false, + state: 'not_installed' as const, + pid: null, + lastExitCode: null, + }), + start: async () => undefined, + stop: async () => undefined, + restart: async () => undefined, + logs: async () => '', + uninstall: async () => undefined, + }; +} diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index 7b341013b4..b9892392cf 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -124,6 +124,8 @@ function helpText(cliCommand: string): string { ` ${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 [--target ] --expected-service-id --expected-root-path --expected-root-id [--allow-interrupt-active-tasks]`, + ` ${cliCommand} runtime-host service update-policy [--target ] [--json]`, + ` ${cliCommand} runtime-host service reconcile-update [--json]`, ` ${cliCommand} runtime-host access issue --principal --grant `, ` ${cliCommand} runtime-host access issue --principal --preset `, ` ${cliCommand} runtime-host access list`, @@ -321,6 +323,30 @@ export async function runMakaCli( ...(command.expectedTarget ? { expectedTarget: command.expectedTarget } : {}), }); } + case 'runtime-host-service-update-policy': + case 'runtime-host-service-reconcile-update': { + const { runManagedRuntimeHostUpdatePolicyCli, runManagedRuntimeHostUpdateReconcileCli } = + await import('./runtime-host-update-reconciliation.js'); + const serviceDataRoots = command.clientDataRoot + ? deriveMakaDataRoots(command.clientDataRoot) + : dataRoots; + if (command.kind === 'runtime-host-service-update-policy') { + return runManagedRuntimeHostUpdatePolicyCli({ + json: command.json, + framed: command.framed ?? false, + clientDataRoot: serviceDataRoots.clientDataRoot, + defaultRootPath: serviceDataRoots.workspaceRoot, + ...(command.policy ? { policy: command.policy } : {}), + ...(command.expectedTarget ? { expectedTarget: command.expectedTarget } : {}), + }); + } + return runManagedRuntimeHostUpdateReconcileCli({ + json: command.json, + framed: command.framed ?? false, + clientDataRoot: serviceDataRoots.clientDataRoot, + defaultRootPath: serviceDataRoots.workspaceRoot, + }); + } case 'runtime-host-managed-deployment-cleanup': { const { runManagedRuntimeHostDeploymentCleanupCli } = await import( './runtime-host-service-management-command.js' diff --git a/packages/cli/src/runtime-host-cli.ts b/packages/cli/src/runtime-host-cli.ts index ce50ad595f..9153e1e703 100644 --- a/packages/cli/src/runtime-host-cli.ts +++ b/packages/cli/src/runtime-host-cli.ts @@ -19,6 +19,7 @@ import { isAbsolute } from 'node:path'; import { isProductReleaseVersion } from '@maka/runtime-host/operator'; +import type { RuntimeHostManagedUpdatePolicy } from '@maka/runtime-host/operator'; import { isCanonicalRuntimeHostWebSocketPath, PROJECT_DIRECTORY_MAX_ROOTS, @@ -91,6 +92,20 @@ export type RuntimeHostCliCommand = selector?: RuntimeHostUpdateSelector; allowInterruptActiveTasks?: true; } + | { + kind: 'runtime-host-service-update-policy'; + json: boolean; + framed?: true; + clientDataRoot?: string; + policy?: RuntimeHostManagedUpdatePolicy; + expectedTarget?: RuntimeHostManagedServiceTarget; + } + | { + kind: 'runtime-host-service-reconcile-update'; + json: boolean; + framed?: true; + clientDataRoot?: string; + } | { kind: 'runtime-host-managed-deployment-cleanup'; clientDataRoot?: string; @@ -251,13 +266,15 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { action !== 'retire' && action !== 'check-update' && action !== 'update' && + action !== 'update-policy' && + action !== 'reconcile-update' && action !== 'logs' && action !== 'uninstall' ) { return error( action ? `Unexpected runtime-host service command: ${action}` - : 'runtime-host service requires install, status, start, stop, restart, retire, check-update, update, logs, or uninstall', + : 'runtime-host service requires install, status, start, stop, restart, retire, check-update, update, update-policy, reconcile-update, logs, or uninstall', ); } @@ -292,7 +309,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 === 'update' + ...(action === 'check-update' || action === 'update' || action === 'update-policy' ? { '--target': (value: string) => { if (updateTarget !== undefined) return error('Duplicate --target'); @@ -307,6 +324,38 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { if ((action === 'retire' || action === 'update') && !options.expectedTarget) { return error(`runtime-host service ${action} requires an expected target`); } + if (action === 'update-policy') { + const policy = updateTarget === undefined ? undefined : parseUpdatePolicy(updateTarget); + if (policy && 'exitCode' in policy) return policy; + if (policy?.kind === 'manual' && options.expectedTarget) { + return error('runtime-host service update-policy manual does not accept an expected target'); + } + if (policy && policy.kind !== 'manual' && !options.expectedTarget) { + return error('runtime-host service update-policy requires an expected target'); + } + if (!policy && options.expectedTarget) { + return error('runtime-host service update-policy requires --target when setting a target'); + } + return { + kind: 'runtime-host-service-update-policy', + json: options.json, + ...(options.framed ? { framed: true } : {}), + ...(clientDataRoot ? { clientDataRoot } : {}), + ...(policy ? { policy } : {}), + ...(options.expectedTarget ? { expectedTarget: options.expectedTarget } : {}), + }; + } + if (action === 'reconcile-update') { + if (options.expectedTarget) { + return error('runtime-host service reconcile-update does not accept an expected target'); + } + return { + kind: 'runtime-host-service-reconcile-update', + json: options.json, + ...(options.framed ? { framed: true } : {}), + ...(clientDataRoot ? { clientDataRoot } : {}), + }; + } if (action === 'check-update') { const selector = parseUpdateSelector(updateTarget); if ('kind' in selector && selector.kind === 'error') return selector; @@ -343,9 +392,16 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { }; } +function parseUpdatePolicy(value: string): RuntimeHostManagedUpdatePolicy | RuntimeHostCliError { + if (value === 'manual') return { kind: 'manual' }; + const selector = parseUpdateSelector(value, 'update-policy'); + if ('exitCode' in selector) return selector; + return selector.kind === 'exact' ? { kind: 'fixed', version: selector.version } : selector; +} + function parseUpdateSelector( value: string | undefined, - action: 'check-update' | 'update' = 'check-update', + action: 'check-update' | 'update' | 'update-policy' = 'check-update', ): RuntimeHostUpdateSelector | RuntimeHostCliError { if (!value) return error(`runtime-host service ${action} requires --target`); if (value === 'latest' || value === 'next') { diff --git a/packages/cli/src/runtime-host-managed-deployment.ts b/packages/cli/src/runtime-host-managed-deployment.ts index fdf0d73152..33a27d1dcb 100644 --- a/packages/cli/src/runtime-host-managed-deployment.ts +++ b/packages/cli/src/runtime-host-managed-deployment.ts @@ -245,14 +245,14 @@ export function resolveRuntimeHostManagedDeploymentForCli( return isRuntimeHostManagedDeploymentCli(root, serviceId, cliPath) ? root : undefined; } -export async function removeRuntimeHostManagedDeployment( +export async function resolveExistingRuntimeHostManagedDeploymentRoot( root: string, serviceId: string, -): Promise { +): Promise { if (!isRuntimeHostManagedDeploymentRoot(root, serviceId)) { throw new RuntimeHostManagedDeploymentError( 'deployment_failed', - 'Refusing to remove an invalid managed Runtime Host deployment path', + 'Refusing to inspect an invalid managed Runtime Host deployment path', ); } const requestedRoot = resolve(root); @@ -260,10 +260,10 @@ export async function removeRuntimeHostManagedDeployment( try { inspected = await Promise.all([realpath(requestedRoot), lstat(requestedRoot)]); } catch (error) { - if (isNodeError(error, 'ENOENT')) return; + if (isNodeError(error, 'ENOENT')) return undefined; throw new RuntimeHostManagedDeploymentError( 'deployment_failed', - 'Unable to inspect the managed Runtime Host deployment before removal', + 'Unable to inspect the managed Runtime Host deployment', { cause: error }, ); } @@ -271,9 +271,18 @@ export async function removeRuntimeHostManagedDeployment( if (canonicalRoot !== requestedRoot || !target.isDirectory() || target.isSymbolicLink()) { throw new RuntimeHostManagedDeploymentError( 'deployment_failed', - 'Refusing to remove a redirected managed Runtime Host deployment path', + 'Refusing to use a redirected managed Runtime Host deployment path', ); } + return canonicalRoot; +} + +export async function removeRuntimeHostManagedDeployment( + root: string, + serviceId: string, +): Promise { + const requestedRoot = await resolveExistingRuntimeHostManagedDeploymentRoot(root, serviceId); + if (!requestedRoot) return; const operatorPath = join(requestedRoot, 'operator'); for (const entry of await readdir(requestedRoot)) { if (entry === 'operator') continue; diff --git a/packages/cli/src/runtime-host-service-management-command.ts b/packages/cli/src/runtime-host-service-management-command.ts index 81078c06e3..867fabf23a 100644 --- a/packages/cli/src/runtime-host-service-management-command.ts +++ b/packages/cli/src/runtime-host-service-management-command.ts @@ -78,14 +78,16 @@ export async function runManagedRuntimeHostServiceCli( const { json: _json, framed: _framed, ...input } = options; const serviceId = resolveRuntimeHostManagedServiceId(options.clientDataRoot); const manage = () => deps.manage(input, deps.createBackend(serviceId)); + const mutate = () => + deps.withDeploymentLock(options.clientDataRoot, () => + deps.withLifecycleLock(options.clientDataRoot, manage), + ); const result = options.action === 'status' || options.action === 'logs' ? await manage() : options.action === 'retire' ? await deps.withLifecycleLock(options.clientDataRoot, manage) - : await deps.withDeploymentLock(options.clientDataRoot, () => - deps.withLifecycleLock(options.clientDataRoot, manage), - ); + : await mutate(); const blocked = result.action === 'retire' && result.retirement.kind === 'active_tasks'; if (options.framed) { deps.writeOutput(encodeRuntimeHostServiceManagementFrame(successFrame(result))); diff --git a/packages/cli/src/runtime-host-service-manager.ts b/packages/cli/src/runtime-host-service-manager.ts index 4444b43a4f..48507c07ed 100644 --- a/packages/cli/src/runtime-host-service-manager.ts +++ b/packages/cli/src/runtime-host-service-manager.ts @@ -47,8 +47,11 @@ import { import { isRuntimeHostManagedDeploymentCli, removeRuntimeHostManagedDeployment, + resolveExistingRuntimeHostManagedDeploymentRoot, resolveRuntimeHostManagedDeploymentForCli, + resolveRuntimeHostManagedDeploymentRoot, } from './runtime-host-managed-deployment.js'; +import { writeRuntimeHostManagedUpdatePolicy } from './runtime-host-update-policy-store.js'; const SERVICE_CONFIG_FILE = 'runtime-host-service.json'; const SERVICE_LIFECYCLE_LOCK_FILE = 'runtime-host-setup'; @@ -201,6 +204,7 @@ interface RuntimeHostServiceManagerDeps { >; readonly environment: NodeJS.ProcessEnv; readonly homeDir: string; + readonly platform: NodeJS.Platform; } export class RuntimeHostServiceManagerError extends Error { @@ -237,6 +241,7 @@ export async function manageRuntimeHostService( prepareRetirement: prepareRuntimeHostRetirement, environment: process.env, homeDir: homedir(), + platform: process.platform, ...overrides, }; const configPath = resolveRuntimeHostManagedServiceConfigPath(input.clientDataRoot); @@ -309,6 +314,7 @@ export async function replaceRuntimeHostManagedService( prepareRetirement: prepareRuntimeHostRetirement, environment: process.env, homeDir: homedir(), + platform: process.platform, ...overrides, }; const configPath = resolveRuntimeHostManagedServiceConfigPath(input.clientDataRoot); @@ -402,6 +408,33 @@ async function manageRuntimeHostServiceLocked( const managedDeploymentRoot = before?.managedDeploymentRoot ?? resolveRuntimeHostManagedDeploymentForCli(serviceId, input.cliPath); + let policyDeploymentRoot: string | undefined; + try { + policyDeploymentRoot = await resolveExistingRuntimeHostManagedDeploymentRoot( + managedDeploymentRoot ?? + resolveRuntimeHostManagedDeploymentRoot(serviceId, { + env: deps.environment, + homeDir: deps.homeDir, + platform: deps.platform, + }), + serviceId, + ); + } catch (error) { + throw new RuntimeHostServiceManagerError( + 'uninstall_incomplete', + 'Unable to safely inspect the managed Runtime Host deployment before revoking automatic update policy', + { cause: error }, + ); + } + if (!policyDeploymentRoot && invalidConfig && !managedDeploymentRoot) { + throw new RuntimeHostServiceManagerError( + 'uninstall_incomplete', + 'Unable to confirm automatic update policy revocation because the service config is invalid and its managed deployment could not be located', + ); + } + if (policyDeploymentRoot) { + await writeRuntimeHostManagedUpdatePolicy(policyDeploymentRoot, null); + } await backend.uninstall(); await removeRuntimeHostServiceFile(configPath, 'service config'); if (managedDeploymentRoot && !input.retainManagedDeployment) { diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts index aca22ad34c..18a634b51a 100644 --- a/packages/cli/src/runtime-host-update-command.ts +++ b/packages/cli/src/runtime-host-update-command.ts @@ -59,6 +59,7 @@ import { import { resolveManagedRuntimeHostUpdateSelection, RuntimeHostUpdateDiscoveryError, + type RuntimeHostUpdateSelection, } from './runtime-host-update-discovery.js'; import { RuntimeHostUpdatePackageError, @@ -93,6 +94,7 @@ export interface RuntimeHostSelectedUpdateCliOptions } interface RuntimeHostUpdateCliDeps { + readonly revalidateSelection: () => Promise; readonly manage: typeof manageRuntimeHostService; readonly replace: typeof replaceRuntimeHostManagedService; readonly openDeployment: typeof openRuntimeHostManagedPackageDeployment; @@ -111,24 +113,52 @@ interface RuntimeHostUpdateCliDeps { readonly writeError: (value: string) => unknown; } -interface RuntimeHostSelectedUpdateCliDeps { - readonly resolveSelection: typeof resolveManagedRuntimeHostUpdateSelection; +interface RuntimeHostResolvedUpdateCliDeps { + readonly revalidateSelection: () => Promise; readonly withPackage: typeof withRuntimeHostRegistryUpdatePackage; readonly update: typeof runManagedRuntimeHostUpdateCli; +} + +interface RuntimeHostSelectedUpdateCliDeps extends RuntimeHostResolvedUpdateCliDeps { + readonly resolveSelection: typeof resolveManagedRuntimeHostUpdateSelection; readonly writeOutput: (value: string) => unknown; readonly writeError: (value: string) => unknown; } +export type RuntimeHostUpdateFrame = Extract< + RuntimeHostServiceManagementFrame, + { action: 'update' } +>; + +export type RuntimeHostUpdateFrameSink = (frame: RuntimeHostUpdateFrame) => void; + interface RuntimeHostOperatorInvocation { readonly inheritedFds?: readonly number[]; readonly capabilityRequest?: RuntimeHostOperatorCapability; } +interface RuntimeHostUpdateSelectionRejection { + readonly code: string; + readonly message: string; +} + +class RuntimeHostUpdateSelectionError extends Error { + constructor( + readonly code: string, + message: string, + ) { + super(message); + this.name = 'RuntimeHostUpdateSelectionError'; + } +} + export async function runManagedRuntimeHostUpdateCli( options: RuntimeHostUpdateCliOptions, overrides: Partial = {}, + frameSink?: RuntimeHostUpdateFrameSink, ): Promise { const deps: RuntimeHostUpdateCliDeps = { + revalidateSelection: async () => undefined, manage: manageRuntimeHostService, replace: replaceRuntimeHostManagedService, openDeployment: openRuntimeHostManagedPackageDeployment, @@ -147,27 +177,15 @@ export async function runManagedRuntimeHostUpdateCli( let exactTargetObserved = false; let cutoverStarted = false; let retired = false; - const emit = (frame: RuntimeHostServiceManagementFrame): void => { - if (options.framed) { - deps.writeOutput(encodeRuntimeHostServiceManagementFrame(frame)); - return; - } - if (frame.kind === 'progress') { - if (!options.json) deps.writeError(`${humanPhase(frame.phase)}\n`); - return; - } - 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`); - } - }; + const emit = + frameSink ?? ((frame: RuntimeHostUpdateFrame) => presentUpdateFrame(frame, options, deps)); try { return await deps.withDeploymentLock(options.clientDataRoot, async () => { try { + const rejection = await deps.revalidateSelection(); + if (rejection) { + throw new RuntimeHostUpdateSelectionError(rejection.code, rejection.message); + } const serviceId = resolveRuntimeHostManagedServiceId(options.clientDataRoot); if (serviceId !== options.expectedTarget.serviceId) { throw new RuntimeHostServiceManagerError( @@ -465,7 +483,8 @@ export async function runManagedRuntimeHostUpdateCli( : error; const code = reportedError instanceof RuntimeHostServiceManagerError || - reportedError instanceof RuntimeHostManagedDeploymentError + reportedError instanceof RuntimeHostManagedDeploymentError || + reportedError instanceof RuntimeHostUpdateSelectionError ? reportedError.code : 'internal_service_error'; const message = reportedError instanceof Error ? reportedError.message : String(reportedError); @@ -488,8 +507,10 @@ export async function runManagedRuntimeHostUpdateCli( export async function runManagedRuntimeHostSelectedUpdateCli( options: RuntimeHostSelectedUpdateCliOptions, overrides: Partial = {}, + frameSink?: RuntimeHostUpdateFrameSink, ): Promise { const deps: RuntimeHostSelectedUpdateCliDeps = { + revalidateSelection: async () => undefined, resolveSelection: resolveManagedRuntimeHostUpdateSelection, withPackage: withRuntimeHostRegistryUpdatePackage, update: runManagedRuntimeHostUpdateCli, @@ -497,20 +518,8 @@ export async function runManagedRuntimeHostSelectedUpdateCli( 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`); - } - }; + const emit = + frameSink ?? ((frame: RuntimeHostUpdateFrame) => presentUpdateFrame(frame, options, deps)); try { const selection = await deps.resolveSelection({ @@ -519,8 +528,48 @@ export async function runManagedRuntimeHostSelectedUpdateCli( selector: options.selector, expectedTarget: options.expectedTarget, }); + return await runManagedRuntimeHostResolvedUpdateCli(options, selection, deps, emit); + } 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; + } +} + +export async function runManagedRuntimeHostResolvedUpdateCli( + options: RuntimeHostSelectedUpdateCliOptions, + selection: RuntimeHostUpdateSelection, + overrides: Partial, + frameSink: RuntimeHostUpdateFrameSink, +): Promise { + const deps: RuntimeHostResolvedUpdateCliDeps = { + revalidateSelection: async () => undefined, + withPackage: withRuntimeHostRegistryUpdatePackage, + update: runManagedRuntimeHostUpdateCli, + ...overrides, + }; + + try { if (selection.outcome.kind === 'manual_action') { - emit({ + frameSink({ schemaVersion: 1, kind: 'error', action: 'update', @@ -537,18 +586,22 @@ export async function runManagedRuntimeHostSelectedUpdateCli( 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 await deps.update( + { + ...updateOptions, + sourcePackageRoot: packageRoot, + version: selection.candidate.version, + registrySelection: { + integrity: selection.candidate.integrity, + current: { + version: selection.service.installedVersion, + cliPath: selection.currentCliPath, + }, }, }, - }); + { revalidateSelection: deps.revalidateSelection }, + frameSink, + ); }; return selection.outcome.kind === 'current' ? await apply(dirname(dirname(selection.currentCliPath))) @@ -561,7 +614,7 @@ export async function runManagedRuntimeHostSelectedUpdateCli( ? error.code : 'update_resolution_failed'; const message = error instanceof Error ? error.message : String(error); - emit({ + frameSink({ schemaVersion: 1, kind: 'error', action: 'update', @@ -578,11 +631,32 @@ export async function runManagedRuntimeHostSelectedUpdateCli( } } +function presentUpdateFrame( + frame: RuntimeHostUpdateFrame, + options: Pick, + deps: Pick, +): void { + if (options.framed) { + deps.writeOutput(encodeRuntimeHostServiceManagementFrame(frame)); + } else if (frame.kind === 'progress') { + if (!options.json) deps.writeError(`${humanPhase(frame.phase)}\n`); + } 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`); + } +} + function progress( phase: RuntimeHostServiceUpdatePhase, currentVersion: string, targetVersion: string, -): RuntimeHostServiceManagementFrame { +): RuntimeHostUpdateFrame { return { schemaVersion: 1, kind: 'progress', diff --git a/packages/cli/src/runtime-host-update-policy-store.ts b/packages/cli/src/runtime-host-update-policy-store.ts new file mode 100644 index 0000000000..06f851c54d --- /dev/null +++ b/packages/cli/src/runtime-host-update-policy-store.ts @@ -0,0 +1,226 @@ +/* + * 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 { randomUUID } from 'node:crypto'; +import { mkdir, open, readFile, rename, rm, unlink } from 'node:fs/promises'; +import { dirname, isAbsolute, join } from 'node:path'; +import { + isProductReleaseVersion, + type RuntimeHostManagedUpdatePolicy, +} from '@maka/runtime-host/operator'; +import type { RuntimeHostManagedServiceTarget } from './runtime-host-service-manager.js'; + +const UPDATE_POLICY_FILE = 'runtime-host-update-policy.json'; +const UPDATE_POLICY_MAX_BYTES = 16 * 1024; + +interface RuntimeHostUpdatePolicyStoreDeps { + readonly syncDirectory: (path: string) => Promise; +} + +type AutomaticUpdatePolicy = Exclude; + +export interface RuntimeHostManagedUpdatePolicyRecord { + readonly schemaVersion: 1; + readonly policy: AutomaticUpdatePolicy; + readonly target: RuntimeHostManagedServiceTarget; +} + +export class RuntimeHostUpdatePolicyError extends Error { + constructor( + readonly code: + | 'invalid_update_policy' + | 'update_policy_write_failed' + | 'update_policy_commit_outcome_unknown', + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'RuntimeHostUpdatePolicyError'; + } +} + +export function resolveRuntimeHostManagedUpdatePolicyPath(managedDeploymentRoot: string): string { + return join(managedDeploymentRoot, UPDATE_POLICY_FILE); +} + +export async function readRuntimeHostManagedUpdatePolicy( + managedDeploymentRoot: string, +): Promise { + const path = resolveRuntimeHostManagedUpdatePolicyPath(managedDeploymentRoot); + let raw: string; + try { + raw = await readFile(path, 'utf8'); + } catch (error) { + if (isNodeError(error, 'ENOENT')) return null; + throw new RuntimeHostUpdatePolicyError( + 'invalid_update_policy', + `Unable to read the managed Runtime Host update policy at ${path}`, + { cause: error }, + ); + } + try { + if (Buffer.byteLength(raw, 'utf8') > UPDATE_POLICY_MAX_BYTES) { + throw new TypeError('Update policy exceeds its size limit'); + } + const parsed: unknown = JSON.parse(raw); + assertUpdatePolicyRecord(parsed); + return parsed; + } catch (error) { + throw new RuntimeHostUpdatePolicyError( + 'invalid_update_policy', + `Invalid managed Runtime Host update policy at ${path}`, + { cause: error }, + ); + } +} + +export async function writeRuntimeHostManagedUpdatePolicy( + managedDeploymentRoot: string, + record: RuntimeHostManagedUpdatePolicyRecord | null, + overrides: Partial = {}, +): Promise { + const deps: RuntimeHostUpdatePolicyStoreDeps = { + syncDirectory, + ...overrides, + }; + const path = resolveRuntimeHostManagedUpdatePolicyPath(managedDeploymentRoot); + if (record === null) { + let removed = false; + try { + await unlink(path); + removed = true; + } catch (error) { + if (!isNodeError(error, 'ENOENT')) { + throw new RuntimeHostUpdatePolicyError( + 'update_policy_write_failed', + `Unable to remove the managed Runtime Host update policy at ${path}`, + { cause: error }, + ); + } + } + try { + await deps.syncDirectory(dirname(path)); + return; + } catch (error) { + if (isNodeError(error, 'ENOENT')) return; + throw new RuntimeHostUpdatePolicyError( + 'update_policy_commit_outcome_unknown', + removed + ? `The managed Runtime Host update policy was removed at ${path}, but its durability could not be confirmed; inspect the current policy before retrying` + : `The managed Runtime Host update policy is absent at ${path}, but its removal could not be confirmed durable; inspect the current policy before retrying`, + { cause: error }, + ); + } + } + assertUpdatePolicyRecord(record); + const directory = dirname(path); + const temporaryPath = `${path}.${randomUUID()}.tmp`; + let published = false; + try { + await mkdir(directory, { recursive: true, mode: 0o700 }); + const file = await open(temporaryPath, 'wx', 0o600); + try { + await file.writeFile(`${JSON.stringify(record, null, 2)}\n`, 'utf8'); + await file.sync(); + } finally { + await file.close(); + } + await rename(temporaryPath, path); + published = true; + await deps.syncDirectory(directory); + } catch (error) { + throw new RuntimeHostUpdatePolicyError( + published ? 'update_policy_commit_outcome_unknown' : 'update_policy_write_failed', + published + ? `The managed Runtime Host update policy was published at ${path}, but its durability could not be confirmed; inspect the current policy before retrying` + : `Unable to persist the managed Runtime Host update policy at ${path}`, + { cause: error }, + ); + } finally { + await rm(temporaryPath, { force: true }).catch(() => undefined); + } +} + +async function syncDirectory(path: string): Promise { + const directory = await open(path, 'r'); + try { + await directory.sync(); + } finally { + await directory.close(); + } +} + +function assertUpdatePolicyRecord( + value: unknown, +): asserts value is RuntimeHostManagedUpdatePolicyRecord { + if ( + !isRecord(value) || + value.schemaVersion !== 1 || + !hasOnlyKeys(value, ['schemaVersion', 'policy', 'target']) || + !isAutomaticUpdatePolicy(value.policy) || + !isManagedServiceTarget(value.target) + ) { + throw new TypeError('Invalid managed Runtime Host update policy record'); + } +} + +function isAutomaticUpdatePolicy(value: unknown): value is AutomaticUpdatePolicy { + if (!isRecord(value)) return false; + if (value.kind === 'channel') { + return ( + hasOnlyKeys(value, ['kind', 'channel']) && + (value.channel === 'latest' || value.channel === 'next') + ); + } + return ( + value.kind === 'fixed' && + hasOnlyKeys(value, ['kind', 'version']) && + typeof value.version === 'string' && + isProductReleaseVersion(value.version) + ); +} + +function isManagedServiceTarget(value: unknown): value is RuntimeHostManagedServiceTarget { + return ( + isRecord(value) && + hasOnlyKeys(value, ['serviceId', 'rootPath', 'rootId']) && + typeof value.serviceId === 'string' && + /^[a-f0-9]{64}$/u.test(value.serviceId) && + typeof value.rootId === 'string' && + /^[a-f0-9]{64}$/u.test(value.rootId) && + typeof value.rootPath === 'string' && + isAbsolute(value.rootPath) && + value.rootPath.length > 0 && + Buffer.byteLength(value.rootPath, 'utf8') <= 4 * 1024 && + !/[\u0000-\u001f\u007f]/u.test(value.rootPath) + ); +} + +function hasOnlyKeys(value: Record, keys: readonly string[]): boolean { + const actual = Object.keys(value); + return actual.length === keys.length && actual.every((key) => keys.includes(key)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNodeError(error: unknown, code: string): boolean { + return error instanceof Error && 'code' in error && error.code === code; +} diff --git a/packages/cli/src/runtime-host-update-reconciliation.ts b/packages/cli/src/runtime-host-update-reconciliation.ts new file mode 100644 index 0000000000..4e81190a1d --- /dev/null +++ b/packages/cli/src/runtime-host-update-reconciliation.ts @@ -0,0 +1,433 @@ +/* + * 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 { truncateUtf8 } from '@maka/core/diagnostic-log'; +import { isDeepStrictEqual } from 'node:util'; +import { + encodeRuntimeHostServiceManagementFrame, + RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES, + RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES, + type RuntimeHostManagedUpdatePolicy, + type RuntimeHostServiceManagementFrame, +} from '@maka/runtime-host/operator'; +import { + manageRuntimeHostService, + resolveRuntimeHostManagedServiceId, + RuntimeHostServiceManagerError, + withRuntimeHostManagedServiceDeploymentLock, + type RuntimeHostManagedServiceTarget, + type RuntimeHostServiceBackend, +} from './runtime-host-service-manager.js'; +import { + createPlatformRuntimeHostServiceBackend, + runtimeHostServiceSummary, +} from './runtime-host-service-management-command.js'; +import { + readRuntimeHostManagedUpdatePolicy, + RuntimeHostUpdatePolicyError, + writeRuntimeHostManagedUpdatePolicy, + type RuntimeHostManagedUpdatePolicyRecord, +} from './runtime-host-update-policy-store.js'; +import { + runManagedRuntimeHostResolvedUpdateCli, + type RuntimeHostSelectedUpdateCliOptions, + type RuntimeHostUpdateFrame, +} from './runtime-host-update-command.js'; +import { + resolveManagedRuntimeHostUpdateSelection, + RuntimeHostUpdateDiscoveryError, +} from './runtime-host-update-discovery.js'; +import type { RuntimeHostUpdateSelector } from './runtime-host-cli.js'; + +type UpdatePolicyFrame = Extract; +type ReconcileUpdateFrame = Extract< + RuntimeHostServiceManagementFrame, + { action: 'reconcile_update' } +>; + +interface RuntimeHostUpdatePolicyCliOptions { + readonly json: boolean; + readonly framed: boolean; + readonly clientDataRoot: string; + readonly defaultRootPath: string; + readonly policy?: RuntimeHostManagedUpdatePolicy; + readonly expectedTarget?: RuntimeHostManagedServiceTarget; +} + +interface RuntimeHostUpdateReconcileCliOptions { + readonly json: boolean; + readonly framed: boolean; + readonly clientDataRoot: string; + readonly defaultRootPath: string; +} + +interface RuntimeHostUpdateReconciliationDeps { + readonly withDeploymentLock: typeof withRuntimeHostManagedServiceDeploymentLock; + readonly readPolicy: typeof readRuntimeHostManagedUpdatePolicy; + readonly writePolicy: typeof writeRuntimeHostManagedUpdatePolicy; + readonly manage: typeof manageRuntimeHostService; + readonly createBackend: (serviceId: string) => RuntimeHostServiceBackend; + readonly resolveSelection: typeof resolveManagedRuntimeHostUpdateSelection; + readonly applySelection: typeof runManagedRuntimeHostResolvedUpdateCli; + readonly writeOutput: (value: string) => unknown; + readonly writeError: (value: string) => unknown; +} + +export async function runManagedRuntimeHostUpdatePolicyCli( + options: RuntimeHostUpdatePolicyCliOptions, + overrides: Partial = {}, +): Promise { + const deps = reconciliationDeps(overrides); + try { + const requestedPolicy = options.policy; + const record = requestedPolicy + ? await deps.withDeploymentLock(options.clientDataRoot, async () => { + if (requestedPolicy.kind === 'manual') { + const status = await readManagedServiceStatus(options, deps); + const managedDeploymentRoot = status.service.config?.managedDeploymentRoot; + if (managedDeploymentRoot) { + await deps.writePolicy(managedDeploymentRoot, null); + } + return null; + } + const expectedTarget = options.expectedTarget; + if (!expectedTarget) { + throw new RuntimeHostUpdatePolicyError( + 'invalid_update_policy', + 'An automatic Runtime Host update policy requires the expected managed service target', + ); + } + const status = await readManagedServiceStatus(options, deps, expectedTarget); + const managedDeploymentRoot = status.service.config?.managedDeploymentRoot; + if (!status.service.installed || !managedDeploymentRoot) { + throw new RuntimeHostServiceManagerError( + 'not_installed', + 'An automatic update policy requires a Maka-managed Runtime Host service', + ); + } + const next: RuntimeHostManagedUpdatePolicyRecord = { + schemaVersion: 1, + policy: requestedPolicy, + target: { + ...expectedTarget, + rootPath: status.service.config.rootPath, + }, + }; + await deps.writePolicy(managedDeploymentRoot, next); + return next; + }) + : await readCurrentUpdatePolicy(options, deps); + writeFrame(updatePolicyResult(record), options, deps); + return 0; + } catch (error) { + writeFrame(updatePolicyError(error), options, deps); + return 1; + } +} + +export async function runManagedRuntimeHostUpdateReconcileCli( + options: RuntimeHostUpdateReconcileCliOptions, + overrides: Partial = {}, +): Promise { + const deps = reconciliationDeps(overrides); + try { + const status = await readManagedServiceStatus(options, deps); + const managedDeploymentRoot = status.service.config?.managedDeploymentRoot; + const policy = + status.service.installed && managedDeploymentRoot + ? { + root: managedDeploymentRoot, + record: await deps.readPolicy(managedDeploymentRoot), + } + : undefined; + if (!policy?.record) { + writeFrame( + { + schemaVersion: 1, + kind: 'result', + action: 'reconcile_update', + updatePolicy: { policy: { kind: 'manual' } }, + reconciliation: { kind: 'disabled' }, + }, + options, + deps, + ); + return 0; + } + const { root: policyRoot, record } = policy; + const selection = await deps.resolveSelection({ + clientDataRoot: options.clientDataRoot, + defaultRootPath: options.defaultRootPath, + selector: policySelector(record.policy), + expectedTarget: record.target, + }); + const updatePolicy = policyResult(record); + if (selection.outcome.kind === 'manual_action') { + writeFrame( + { + schemaVersion: 1, + kind: 'result', + action: 'reconcile_update', + updatePolicy, + service: selection.service, + reconciliation: { + kind: 'manual_action', + candidate: { + version: selection.candidate.version, + integrity: selection.candidate.integrity, + }, + reason: selection.outcome.reason, + }, + }, + options, + deps, + ); + return 1; + } + + let terminal: ReconcileUpdateFrame | undefined; + const selectedOptions: RuntimeHostSelectedUpdateCliOptions = { + json: false, + framed: false, + clientDataRoot: options.clientDataRoot, + defaultRootPath: options.defaultRootPath, + selector: selection.selector, + expectedTarget: record.target, + }; + const exitCode = await deps.applySelection( + selectedOptions, + selection, + { + revalidateSelection: async () => { + let current: RuntimeHostManagedUpdatePolicyRecord | null; + try { + current = await deps.readPolicy(policyRoot); + } catch (error) { + return boundedError(error, 'Unable to revalidate the managed update policy'); + } + if (!isDeepStrictEqual(current, record)) { + return { + code: 'update_policy_changed', + message: + 'The managed Runtime Host update policy changed while its candidate was prepared; reconciliation made no changes', + }; + } + return undefined; + }, + }, + (frame) => { + const mapped = reconcileFrame(frame, updatePolicy); + writeFrame(mapped, options, deps); + if (mapped.kind !== 'progress') terminal = mapped; + }, + ); + if (!terminal) { + throw new Error('The managed Runtime Host update did not return a terminal result'); + } + return exitCode; + } catch (error) { + writeFrame(reconcileError(error), options, deps); + return 1; + } +} + +async function readCurrentUpdatePolicy( + options: RuntimeHostUpdatePolicyCliOptions, + deps: RuntimeHostUpdateReconciliationDeps, +): Promise { + const status = await readManagedServiceStatus(options, deps); + const managedDeploymentRoot = status.service.config?.managedDeploymentRoot; + return status.service.installed && managedDeploymentRoot + ? await deps.readPolicy(managedDeploymentRoot) + : null; +} + +function readManagedServiceStatus( + options: Pick< + RuntimeHostUpdatePolicyCliOptions | RuntimeHostUpdateReconcileCliOptions, + 'clientDataRoot' | 'defaultRootPath' + >, + deps: RuntimeHostUpdateReconciliationDeps, + expectedTarget?: RuntimeHostManagedServiceTarget, +) { + const serviceId = resolveRuntimeHostManagedServiceId(options.clientDataRoot); + return deps.manage( + { + action: 'status', + clientDataRoot: options.clientDataRoot, + defaultRootPath: options.defaultRootPath, + nodePath: process.execPath, + cliPath: process.argv[1] ?? '', + ...(expectedTarget ? { expectedTarget } : {}), + }, + deps.createBackend(serviceId), + ); +} + +function reconciliationDeps( + overrides: Partial, +): RuntimeHostUpdateReconciliationDeps { + return { + withDeploymentLock: withRuntimeHostManagedServiceDeploymentLock, + readPolicy: readRuntimeHostManagedUpdatePolicy, + writePolicy: writeRuntimeHostManagedUpdatePolicy, + manage: manageRuntimeHostService, + createBackend: createPlatformRuntimeHostServiceBackend, + resolveSelection: resolveManagedRuntimeHostUpdateSelection, + applySelection: runManagedRuntimeHostResolvedUpdateCli, + writeOutput: (value) => process.stdout.write(value), + writeError: (value) => process.stderr.write(value), + ...overrides, + }; +} + +function updatePolicyResult( + record: RuntimeHostManagedUpdatePolicyRecord | null, +): UpdatePolicyFrame { + return { + schemaVersion: 1, + kind: 'result', + action: 'update_policy', + updatePolicy: policyResult(record), + }; +} + +function policyResult(record: RuntimeHostManagedUpdatePolicyRecord | null) { + return record + ? { policy: record.policy, target: record.target } + : { policy: { kind: 'manual' as const } }; +} + +function policySelector( + policy: RuntimeHostManagedUpdatePolicyRecord['policy'], +): RuntimeHostUpdateSelector { + return policy.kind === 'fixed' + ? { kind: 'exact', version: policy.version } + : { kind: 'channel', channel: policy.channel }; +} + +function reconcileFrame( + frame: RuntimeHostUpdateFrame, + updatePolicy: ReturnType, +): ReconcileUpdateFrame { + if (frame.kind === 'progress') { + return { ...frame, action: 'reconcile_update' }; + } + if (frame.kind === 'error') { + return { ...frame, action: 'reconcile_update' }; + } + return { + schemaVersion: 1, + kind: 'result', + action: 'reconcile_update', + updatePolicy, + service: frame.service, + reconciliation: frame.update, + }; +} + +function updatePolicyError(error: unknown): UpdatePolicyFrame { + return { + schemaVersion: 1, + kind: 'error', + action: 'update_policy', + error: boundedError(error, 'Unable to manage the Runtime Host update policy'), + }; +} + +function reconcileError(error: unknown): ReconcileUpdateFrame { + return { + schemaVersion: 1, + kind: 'error', + action: 'reconcile_update', + error: boundedError(error, 'Unable to reconcile the managed Runtime Host update'), + }; +} + +function boundedError(error: unknown, fallback: string): { code: string; message: string } { + const code = + error instanceof RuntimeHostUpdatePolicyError || + error instanceof RuntimeHostUpdateDiscoveryError || + error instanceof RuntimeHostServiceManagerError + ? error.code + : 'update_reconciliation_failed'; + const message = error instanceof Error ? error.message : String(error); + return { + code: + truncateUtf8(code, RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES) || + 'update_reconciliation_failed', + message: truncateUtf8(message, RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES) || fallback, + }; +} + +function writeFrame( + frame: UpdatePolicyFrame | ReconcileUpdateFrame, + options: { readonly json: boolean; readonly framed: boolean }, + deps: Pick, +): void { + if (frame.kind === 'progress') { + if (options.framed) { + deps.writeOutput(encodeRuntimeHostServiceManagementFrame(frame)); + } else if (!options.json) { + deps.writeError( + `Reconciling Maka ${frame.currentVersion} toward ${frame.targetVersion} (${frame.phase})...\n`, + ); + } + return; + } + if (options.framed) { + deps.writeOutput(encodeRuntimeHostServiceManagementFrame(frame)); + return; + } + if (options.json) { + deps.writeOutput(`${JSON.stringify(frame)}\n`); + return; + } + if (frame.kind === 'error') { + deps.writeError(`${frame.error.message}\n`); + return; + } + deps.writeOutput(`${humanResult(frame)}\n`); +} + +function humanResult(frame: Extract) { + if (frame.action === 'update_policy') { + const policy = frame.updatePolicy.policy; + return policy.kind === 'manual' + ? 'Managed Runtime Host updates are manual.' + : policy.kind === 'fixed' + ? `Managed Runtime Host updates are fixed at ${policy.version}.` + : `Managed Runtime Host updates follow the ${policy.channel} channel.`; + } + const result = frame.reconciliation; + if (result.kind === 'disabled') return 'Managed Runtime Host automatic updates are disabled.'; + if (result.kind === 'manual_action') { + return `Maka ${result.candidate.version} requires manual update action (${result.reason}).`; + } + if (result.kind === 'active_tasks') { + return 'The managed Runtime Host still owns active work; the update remains pending.'; + } + if (result.kind === 'already_current') { + return `The managed Runtime Host is current at Maka ${result.version}.`; + } + if (result.kind === 'repaired') { + return `The managed Runtime Host deployment for Maka ${result.version} was repaired.`; + } + return `The managed Runtime Host was updated from Maka ${result.previousVersion} to ${result.targetVersion}.`; +} diff --git a/packages/runtime-host/src/operator/index.ts b/packages/runtime-host/src/operator/index.ts index 9a4f83c791..b9ea599ef7 100644 --- a/packages/runtime-host/src/operator/index.ts +++ b/packages/runtime-host/src/operator/index.ts @@ -40,6 +40,7 @@ export { encodeRuntimeHostServiceManagementFrame, type RuntimeHostServiceManagementAction, type RuntimeHostServiceManagementFrame, + type RuntimeHostManagedUpdatePolicy, type RuntimeHostServiceUpdatePhase, type RuntimeHostOperatorCapability, type RuntimeHostServiceSummary, diff --git a/packages/runtime-host/src/operator/service-management-frame.ts b/packages/runtime-host/src/operator/service-management-frame.ts index c857bef788..27e53ecc34 100644 --- a/packages/runtime-host/src/operator/service-management-frame.ts +++ b/packages/runtime-host/src/operator/service-management-frame.ts @@ -46,6 +46,8 @@ const SERVICE_ACTIONS = [ 'retire', 'check_update', 'update', + 'update_policy', + 'reconcile_update', 'logs', 'uninstall', ] as const; @@ -93,6 +95,23 @@ const boundedNonEmptyString = (maxBytes: number) => .refine((value) => Buffer.byteLength(value, 'utf8') <= maxBytes); const PRODUCT_RELEASE_VERSION_SCHEMA = z.string().refine(isProductReleaseVersion); const PACKAGE_INTEGRITY_SCHEMA = z.string().refine(isSha512PackageIntegrity); +const UPDATE_POLICY_SCHEMA = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('manual') }).strict(), + z + .object({ + kind: z.literal('fixed'), + version: PRODUCT_RELEASE_VERSION_SCHEMA, + }) + .strict(), + z.object({ kind: z.literal('channel'), channel: z.enum(UPDATE_CHANNELS) }).strict(), +]); +const MANAGED_SERVICE_TARGET_SCHEMA = z + .object({ + serviceId: z.string().regex(/^[a-f0-9]{64}$/u), + rootPath: boundedNonEmptyString(PATH_MAX_BYTES), + rootId: z.string().regex(/^[a-f0-9]{64}$/u), + }) + .strict(); const UPDATE_CHECK_SCHEMA = z .object({ @@ -150,6 +169,51 @@ const RETIREMENT_RESULT_SCHEMA = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('stopped') }).strict(), ]); +const UPDATE_RESULT_SCHEMA = z.discriminatedUnion('kind', [ + z + .object({ + kind: z.literal('already_current'), + version: boundedNonEmptyString(FIELD_MAX_BYTES), + }) + .strict(), + z + .object({ + kind: z.literal('active_tasks'), + currentVersion: boundedNonEmptyString(FIELD_MAX_BYTES), + targetVersion: boundedNonEmptyString(FIELD_MAX_BYTES), + }) + .strict(), + z + .object({ + kind: z.literal('updated'), + previousVersion: boundedNonEmptyString(FIELD_MAX_BYTES), + targetVersion: boundedNonEmptyString(FIELD_MAX_BYTES), + }) + .strict(), + z + .object({ + kind: z.literal('repaired'), + version: boundedNonEmptyString(FIELD_MAX_BYTES), + }) + .strict(), +]); + +const UPDATE_POLICY_RESULT_SCHEMA = z + .object({ + policy: UPDATE_POLICY_SCHEMA, + target: MANAGED_SERVICE_TARGET_SCHEMA.optional(), + }) + .strict() + .superRefine((value, context) => { + if ((value.policy.kind === 'manual') !== (value.target === undefined)) { + context.addIssue({ + code: 'custom', + path: ['target'], + message: 'Only an automatic update policy has a managed service target', + }); + } + }); + const SERVICE_SUMMARY_SCHEMA = z .object({ platform: boundedNonEmptyString(FIELD_MAX_BYTES), @@ -203,6 +267,16 @@ const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.union([ targetVersion: boundedNonEmptyString(FIELD_MAX_BYTES), }) .strict(), + z + .object({ + schemaVersion: z.literal(1), + kind: z.literal('progress'), + action: z.literal('reconcile_update'), + phase: z.enum(UPDATE_PHASES), + currentVersion: boundedNonEmptyString(FIELD_MAX_BYTES), + targetVersion: boundedNonEmptyString(FIELD_MAX_BYTES), + }) + .strict(), z .object({ ...SERVICE_RESULT_COMMON, @@ -237,36 +311,51 @@ const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.union([ .object({ ...SERVICE_RESULT_COMMON, action: z.literal('update'), - update: z.discriminatedUnion('kind', [ - z - .object({ - kind: z.literal('already_current'), - version: boundedNonEmptyString(FIELD_MAX_BYTES), - }) - .strict(), - z - .object({ - kind: z.literal('active_tasks'), - currentVersion: boundedNonEmptyString(FIELD_MAX_BYTES), - targetVersion: boundedNonEmptyString(FIELD_MAX_BYTES), - }) - .strict(), - z - .object({ - kind: z.literal('updated'), - previousVersion: boundedNonEmptyString(FIELD_MAX_BYTES), - targetVersion: boundedNonEmptyString(FIELD_MAX_BYTES), - }) - .strict(), + update: UPDATE_RESULT_SCHEMA, + }) + .strict(), + z + .object({ + schemaVersion: z.literal(1), + kind: z.literal('result'), + action: z.literal('update_policy'), + updatePolicy: UPDATE_POLICY_RESULT_SCHEMA, + }) + .strict(), + z + .object({ + schemaVersion: z.literal(1), + kind: z.literal('result'), + action: z.literal('reconcile_update'), + updatePolicy: UPDATE_POLICY_RESULT_SCHEMA, + service: SERVICE_SUMMARY_SCHEMA.optional(), + reconciliation: z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('disabled') }).strict(), z .object({ - kind: z.literal('repaired'), - version: boundedNonEmptyString(FIELD_MAX_BYTES), + kind: z.literal('manual_action'), + candidate: z + .object({ + version: PRODUCT_RELEASE_VERSION_SCHEMA, + integrity: PACKAGE_INTEGRITY_SCHEMA, + }) + .strict(), + reason: z.enum(MANUAL_ACTION_REASONS), }) .strict(), + ...UPDATE_RESULT_SCHEMA.options, ]), }) - .strict(), + .strict() + .superRefine((frame, context) => { + if ((frame.reconciliation.kind === 'disabled') !== (frame.service === undefined)) { + context.addIssue({ + code: 'custom', + path: ['service'], + message: 'Only disabled reconciliation omits the managed service summary', + }); + } + }), z .object({ ...SERVICE_RESULT_COMMON, @@ -288,6 +377,22 @@ const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.union([ error: SERVICE_ERROR_SCHEMA, }) .strict(), + z + .object({ + schemaVersion: z.literal(1), + kind: z.literal('error'), + action: z.literal('reconcile_update'), + error: SERVICE_ERROR_SCHEMA, + }) + .strict(), + z + .object({ + schemaVersion: z.literal(1), + kind: z.literal('error'), + action: z.literal('update_policy'), + error: SERVICE_ERROR_SCHEMA, + }) + .strict(), z .object({ schemaVersion: z.literal(1), @@ -301,6 +406,7 @@ const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.union([ export type RuntimeHostServiceManagementAction = (typeof SERVICE_ACTIONS)[number]; export type RuntimeHostServiceManagementFrame = z.infer; export type RuntimeHostServiceUpdatePhase = (typeof UPDATE_PHASES)[number]; +export type RuntimeHostManagedUpdatePolicy = z.infer; export type RuntimeHostOperatorCapability = (typeof OPERATOR_CAPABILITIES)[number]; export type RuntimeHostServiceSummary = z.infer;