From c623ed60a5706c1f19b0332992d0757dd282c6c1 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 25 Aug 2026 10:51:22 +0800 Subject: [PATCH] feat(runtime-host): schedule managed update reconciliation Generated-by: Codex --- packages/cli/README.md | 12 +- packages/cli/README.zh-CN.md | 11 +- .../runtime-host-service-manager.test.ts | 2 +- ...runtime-host-update-reconciliation.test.ts | 152 ++++++- .../runtime-host-update-scheduler.test.ts | 378 ++++++++++++++++++ packages/cli/src/cli-core.ts | 3 +- packages/cli/src/runtime-host-cli.ts | 69 +++- .../src/runtime-host-launch-agent-service.ts | 4 +- ...time-host-launch-agent-update-scheduler.ts | 314 +++++++++++++++ ...runtime-host-service-management-command.ts | 17 +- .../cli/src/runtime-host-systemd-service.ts | 2 +- .../runtime-host-systemd-update-scheduler.ts | 363 +++++++++++++++++ .../src/runtime-host-update-policy-store.ts | 61 ++- .../src/runtime-host-update-reconciliation.ts | 36 +- .../cli/src/runtime-host-update-scheduler.ts | 143 +++++++ packages/runtime-host/src/operator/index.ts | 3 + .../src/operator/service-management-frame.ts | 24 +- 17 files changed, 1548 insertions(+), 46 deletions(-) create mode 100644 packages/cli/src/__tests__/runtime-host-update-scheduler.test.ts create mode 100644 packages/cli/src/runtime-host-launch-agent-update-scheduler.ts create mode 100644 packages/cli/src/runtime-host-systemd-update-scheduler.ts create mode 100644 packages/cli/src/runtime-host-update-scheduler.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index 58666b8d2b..3c2a0a5d72 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -143,15 +143,21 @@ The installation owner can persist one update target and reconcile it with the s transaction: ```sh -maka runtime-host service update-policy --target latest \ +maka runtime-host service update-policy --target latest --check-interval 4h \ --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. +Use `update-policy --target manual` to disable automatic reconciliation. Automatic policies accept +an integer `--check-interval` from `1h` through `168h` and default to `6h`. A Maka-managed Linux or +macOS deployment installs an OS-owned hourly tick at an installation-specific minute. The policy +decides whether that tick is due before any registry request; an explicit `reconcile-update` always +checks immediately. The tick remains installed in manual mode, where it returns without network +discovery or mutation. It never interrupts active work. Service repair verifies the schedule, +service logs include its output, and uninstall removes it before deleting the managed deployment. +Services launched from a separate persistent global CLI do not receive this schedule. ## Uninstall diff --git a/packages/cli/README.zh-CN.md b/packages/cli/README.zh-CN.md index c6650f46af..1adbb4e49d 100644 --- a/packages/cli/README.zh-CN.md +++ b/packages/cli/README.zh-CN.md @@ -132,15 +132,20 @@ selector 传给 `service update --target`。该路径会先校验 archive 与解 Installation owner 可以持久化一个更新目标,并通过同一套已验证事务执行 reconciliation: ```sh -maka runtime-host service update-policy --target latest \ +maka runtime-host service update-policy --target latest --check-interval 4h \ --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。 +使用 `update-policy --target manual` 关闭自动 reconciliation。自动策略接受 `1h` 至 `168h` 的 +整数 `--check-interval`,默认 `6h`。Maka 托管的 Linux 或 macOS deployment 会在每个 installation +专属的 minute 安装由操作系统持有的 hourly tick;在发起任何 registry 请求前,由 policy 判断本次 +tick 是否到期。显式执行 `reconcile-update` 始终立即检查。manual 模式仍保留 tick,但它会直接 +返回,不执行网络 discovery 或 mutation,也不会中断 active work。Service repair 会校验 +schedule,service logs 会包含其输出,uninstall 会先移除 schedule,再删除 managed deployment。 +由独立持久全局 CLI 启动的 service 不会安装该 schedule。 ## 卸载 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 a301ba6f54..494a8533c7 100644 --- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts +++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts @@ -406,7 +406,7 @@ describe('managed Runtime Host service', () => { } as const; const updatePolicy = { schemaVersion: 1 as const, - policy: { kind: 'channel' as const, channel: 'latest' as const }, + policy: { kind: 'channel' as const, channel: 'latest' as const, checkIntervalHours: 6 }, target: expectedTarget, }; await writeRuntimeHostManagedUpdatePolicy(deploymentRoot, updatePolicy); diff --git a/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts b/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts index 5f668a0a3b..09a84fc70b 100644 --- a/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts +++ b/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts @@ -65,6 +65,8 @@ describe('managed Runtime Host update reconciliation', () => { 'update-policy', '--target', 'next', + '--check-interval', + '4h', '--expected-service-id', TARGET.serviceId, '--expected-root-path', @@ -75,7 +77,7 @@ describe('managed Runtime Host update reconciliation', () => { { kind: 'runtime-host-service-update-policy', json: false, - policy: { kind: 'channel', channel: 'next' }, + policy: { kind: 'channel', channel: 'next', checkIntervalHours: 4 }, expectedTarget: TARGET, }, ); @@ -83,10 +85,51 @@ describe('managed Runtime Host update reconciliation', () => { kind: 'runtime-host-service-reconcile-update', json: true, }); + assert.deepEqual( + parseRuntimeHostCommand(['service', 'reconcile-update', '--scheduled', '--json']), + { + kind: 'runtime-host-service-reconcile-update', + json: true, + scheduled: true, + }, + ); assert.equal( parseRuntimeHostCommand(['service', 'update-policy', '--target', 'latest']).kind, 'error', ); + assert.equal( + parseRuntimeHostCommand([ + 'service', + 'update-policy', + '--target', + 'manual', + '--check-interval', + '4h', + ]).kind, + 'error', + ); + assert.equal( + parseRuntimeHostCommand([ + 'service', + 'update-policy', + '--target', + 'latest', + '--check-interval', + '0h', + ]).kind, + 'error', + ); + assert.equal( + parseRuntimeHostCommand([ + 'service', + 'update-policy', + '--target', + 'latest', + '--check-interval', + '169h', + ]).kind, + 'error', + ); }); it('persists an automatic policy against the canonical managed target and removes manual state', async (t) => { @@ -105,7 +148,7 @@ describe('managed Runtime Host update reconciliation', () => { await runManagedRuntimeHostUpdatePolicyCli( { ...common, - policy: { kind: 'fixed', version: '2.0.0' }, + policy: { kind: 'fixed', version: '2.0.0', checkIntervalHours: 6 }, expectedTarget: TARGET, }, { @@ -121,7 +164,7 @@ describe('managed Runtime Host update reconciliation', () => { ); assert.deepEqual(await readRuntimeHostManagedUpdatePolicy(deploymentRoot), { schemaVersion: 1, - policy: { kind: 'fixed', version: '2.0.0' }, + policy: { kind: 'fixed', version: '2.0.0', checkIntervalHours: 6 }, target: { ...TARGET, rootPath: '/srv/maka' }, }); assert.equal(JSON.parse(output).updatePolicy.policy.kind, 'fixed'); @@ -163,12 +206,32 @@ describe('managed Runtime Host update reconciliation', () => { ); }); + it('defaults a pre-cadence automatic policy to a six-hour interval', async (t) => { + const deploymentRoot = await mkdtemp(join(tmpdir(), 'maka-update-policy-legacy-')); + t.after(() => rm(deploymentRoot, { recursive: true, force: true })); + await writeFile( + resolveRuntimeHostManagedUpdatePolicyPath(deploymentRoot), + JSON.stringify({ + schemaVersion: 1, + policy: { kind: 'channel', channel: 'latest' }, + target: TARGET, + }), + 'utf8', + ); + + assert.deepEqual(await readRuntimeHostManagedUpdatePolicy(deploymentRoot), { + schemaVersion: 1, + policy: { kind: 'channel', channel: 'latest', checkIntervalHours: 6 }, + target: TARGET, + }); + }); + 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' }, + policy: { kind: 'fixed' as const, version: '2.0.0', checkIntervalHours: 6 }, target: TARGET, }; const failSync = async () => { @@ -199,13 +262,88 @@ describe('managed Runtime Host update reconciliation', () => { assert.equal(syncs, 1); }); + it('checks an automatic target only when its scheduled interval is due', async (t) => { + const clientDataRoot = await mkdtemp(join(tmpdir(), 'maka-update-schedule-')); + t.after(() => rm(clientDataRoot, { recursive: true, force: true })); + const deploymentRoot = join(clientDataRoot, 'managed'); + await writeRuntimeHostManagedUpdatePolicy(deploymentRoot, { + schemaVersion: 1, + policy: { kind: 'channel', channel: 'latest', checkIntervalHours: 4 }, + target: TARGET, + }); + let output = ''; + assert.equal( + await runManagedRuntimeHostUpdateReconcileCli( + { + json: true, + framed: false, + scheduled: true, + clientDataRoot, + defaultRootPath: '/workspace', + }, + { + now: () => 2 * 60 * 60 * 1_000, + manage: async () => managedStatus(deploymentRoot), + createBackend: () => unusedBackend(), + resolveSelection: async () => assert.fail('a not-due tick must not query the registry'), + writeOutput: (value) => { + output += value; + }, + }, + ), + 0, + ); + assert.deepEqual(JSON.parse(output).reconciliation, { + kind: 'not_due', + checkIntervalHours: 4, + }); + + let resolved = false; + await runManagedRuntimeHostUpdateReconcileCli( + { + json: true, + framed: false, + scheduled: true, + clientDataRoot, + defaultRootPath: '/workspace', + }, + { + now: () => 3 * 60 * 60 * 1_000, + manage: async () => managedStatus(deploymentRoot), + createBackend: () => unusedBackend(), + resolveSelection: async (options) => { + resolved = true; + return { + selector: options.selector, + candidate: { version: '1.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) => { + emit?.({ + schemaVersion: 1, + kind: 'result', + action: 'update', + service: SERVICE, + update: { kind: 'already_current', version: '1.0.0' }, + }); + return 0; + }, + writeOutput: () => undefined, + }, + ); + assert.equal(resolved, true); + }); + 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' }, + policy: { kind: 'fixed', version: '2.0.0', checkIntervalHours: 6 }, target: TARGET, }); let output = ''; @@ -278,7 +416,7 @@ describe('managed Runtime Host update reconciliation', () => { const deploymentRoot = join(clientDataRoot, 'managed'); await writeRuntimeHostManagedUpdatePolicy(deploymentRoot, { schemaVersion: 1, - policy: { kind: 'channel', channel: 'latest' }, + policy: { kind: 'channel', channel: 'latest', checkIntervalHours: 6 }, target: TARGET, }); let output = ''; @@ -344,7 +482,7 @@ describe('managed Runtime Host update reconciliation', () => { await writeRuntimeHostManagedUpdatePolicy(deploymentRoot, { schemaVersion: 1, - policy: { kind: 'channel', channel: 'latest' }, + policy: { kind: 'channel', channel: 'latest', checkIntervalHours: 6 }, target: TARGET, }); let output = ''; diff --git a/packages/cli/src/__tests__/runtime-host-update-scheduler.test.ts b/packages/cli/src/__tests__/runtime-host-update-scheduler.test.ts new file mode 100644 index 0000000000..917e06bd2b --- /dev/null +++ b/packages/cli/src/__tests__/runtime-host-update-scheduler.test.ts @@ -0,0 +1,378 @@ +/* + * 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 { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { + createLaunchAgentRuntimeHostUpdateScheduler, + renderLaunchAgentRuntimeHostUpdateSchedulerPlist, + resolveLaunchAgentRuntimeHostUpdateSchedulerPaths, +} from '../runtime-host-launch-agent-update-scheduler.js'; +import { + createSystemdUserRuntimeHostUpdateScheduler, + renderSystemdRuntimeHostUpdateService, + renderSystemdRuntimeHostUpdateTimer, + resolveSystemdUserRuntimeHostUpdateSchedulerPaths, +} from '../runtime-host-systemd-update-scheduler.js'; +import { + isRuntimeHostScheduledUpdateDue, + runtimeHostUpdateSchedule, + runtimeHostUpdateSchedulerArguments, + withRuntimeHostUpdateScheduler, + type RuntimeHostUpdateSchedulerBackend, +} from '../runtime-host-update-scheduler.js'; +import type { + RuntimeHostManagedServiceConfig, + RuntimeHostServiceBackend, +} from '../runtime-host-service-manager.js'; + +const SERVICE_ID = 'aa'.repeat(32); +const UID = 501; + +test('derives one hourly tick, policy phase, and stable operator command', () => { + const config = fixtureConfig('/srv/maka/runtime-host'); + assert.deepEqual(runtimeHostUpdateSchedule(SERVICE_ID), { minute: 50 }); + assert.equal(isRuntimeHostScheduledUpdateDue(SERVICE_ID, 4, 2 * 60 * 60 * 1_000), true); + assert.equal(isRuntimeHostScheduledUpdateDue(SERVICE_ID, 4, 3 * 60 * 60 * 1_000), false); + assert.deepEqual(runtimeHostUpdateSchedulerArguments(config), [ + '/srv/maka/runtime-host/operator', + 'reconcile-update', + '--scheduled', + '--json', + ]); + assert.equal( + runtimeHostUpdateSchedulerArguments({ + ...config, + managedDeploymentRoot: undefined, + }), + null, + ); +}); + +test('installs, verifies, logs, and removes the systemd user update timer', async () => { + await withFixture(async ({ homeDir, config, operatorPath }) => { + const systemctl = fakeSystemctl(); + const backend = createSystemdUserRuntimeHostUpdateScheduler(SERVICE_ID, { + homeDir, + env: {}, + runSystemctl: systemctl.run, + runJournalctl: async () => ({ + exitCode: 0, + stdout: 'scheduler result\n', + stderr: '', + }), + }); + const paths = resolveSystemdUserRuntimeHostUpdateSchedulerPaths(SERVICE_ID, {}, homeDir); + + await rm(operatorPath); + const deployment = await backend.install(config); + await backend.verifyDeployment(config); + await writeOperator(operatorPath); + assert.equal( + await readFile(paths.servicePath, 'utf8'), + renderSystemdRuntimeHostUpdateService([ + join(config.managedDeploymentRoot!, 'operator'), + 'reconcile-update', + '--scheduled', + '--json', + ]), + ); + assert.equal( + await readFile(paths.timerPath, 'utf8'), + renderSystemdRuntimeHostUpdateTimer(SERVICE_ID), + ); + assert.equal(await backend.logs(), 'scheduler result\n'); + assert.equal(systemctl.enabled, true); + assert.equal(systemctl.active, true); + + systemctl.active = false; + await assert.rejects(backend.verifyDeployment(config), /does not match/u); + systemctl.active = true; + + await deployment.rollback(); + assert.equal(await fileExists(paths.servicePath), false); + assert.equal(await fileExists(paths.timerPath), false); + + await backend.install(config); + await backend.uninstall(); + assert.equal(systemctl.enabled, false); + assert.equal(systemctl.active, false); + assert.equal(await fileExists(paths.servicePath), false); + assert.equal(await fileExists(paths.timerPath), false); + }); +}); + +test('installs, verifies, logs, and removes the periodic LaunchAgent', async () => { + await withFixture(async ({ homeDir, config }) => { + const launchctl = fakeLaunchctl(UID); + const backend = createLaunchAgentRuntimeHostUpdateScheduler(SERVICE_ID, { + homeDir, + uid: UID, + runLaunchctl: launchctl.run, + }); + const paths = resolveLaunchAgentRuntimeHostUpdateSchedulerPaths(SERVICE_ID, homeDir); + + const deployment = await backend.install(config); + await backend.verifyDeployment(config); + assert.equal( + await readFile(paths.plistPath, 'utf8'), + renderLaunchAgentRuntimeHostUpdateSchedulerPlist( + SERVICE_ID, + [ + join(config.managedDeploymentRoot!, 'operator'), + 'reconcile-update', + '--scheduled', + '--json', + ], + paths, + ), + ); + assert.match( + await readFile(paths.plistPath, 'utf8'), + /Minute<\/key>\n 50<\/integer>/u, + ); + assert.equal(launchctl.loaded, true); + + await writeFile(paths.stdoutPath, 'disabled\n'); + assert.match(await backend.logs(), /stdout:\ndisabled/u); + await deployment.rollback(); + assert.equal(launchctl.loaded, false); + assert.equal(await fileExists(paths.plistPath), false); + assert.equal(await fileExists(paths.stdoutPath), false); + assert.equal(await fileExists(paths.stderrPath), false); + + await backend.install(config); + await backend.uninstall(); + assert.equal(launchctl.loaded, false); + assert.equal(await fileExists(paths.plistPath), false); + assert.equal(await fileExists(paths.stdoutPath), false); + assert.equal(await fileExists(paths.stderrPath), false); + }); +}); + +test('does not retain a scheduler for a non-managed persistent CLI service', async () => { + await withFixture(async ({ homeDir, config }) => { + const systemctl = fakeSystemctl(); + const backend = createSystemdUserRuntimeHostUpdateScheduler(SERVICE_ID, { + homeDir, + env: {}, + runSystemctl: systemctl.run, + runJournalctl: async () => ({ exitCode: 0, stdout: '', stderr: '' }), + }); + const paths = resolveSystemdUserRuntimeHostUpdateSchedulerPaths(SERVICE_ID, {}, homeDir); + await backend.install(config); + await backend.install({ ...config, managedDeploymentRoot: undefined }); + assert.equal(systemctl.enabled, false); + assert.equal(await fileExists(paths.servicePath), false); + assert.equal(await fileExists(paths.timerPath), false); + }); +}); + +test('composes the scheduler into service lifecycle without replacing it on version cutover', async () => { + const order: string[] = []; + const service = fakeServiceBackend(order); + const scheduler = fakeSchedulerBackend(order); + const backend = withRuntimeHostUpdateScheduler(service, scheduler); + const config = fixtureConfig('/srv/maka/runtime-host'); + + const deployment = await backend.install(config); + await backend.verifyDeployment(config); + assert.equal(await backend.logs(), 'service log\nupdate scheduler:\nscheduler log'); + await backend.replace(config); + await backend.uninstall(); + await deployment.rollback(); + + assert.deepEqual(order, [ + 'service.install', + 'scheduler.install', + 'service.verify', + 'scheduler.verify', + 'service.logs', + 'scheduler.logs', + 'service.replace', + 'scheduler.uninstall', + 'service.uninstall', + 'scheduler.rollback', + 'service.rollback', + ]); +}); + +function fixtureConfig(managedDeploymentRoot: string): RuntimeHostManagedServiceConfig { + return { + schemaVersion: 1, + managedDeploymentRoot, + rootPath: '/srv/maka/state', + projectDirectoryRoots: [], + websocket: { host: '127.0.0.1', port: 23456, path: '/runtime-host' }, + launch: { nodePath: process.execPath, cliPath: '/srv/maka/cli.js' }, + }; +} + +function fakeServiceBackend(order: string[]): RuntimeHostServiceBackend { + const record = (name: string) => async () => { + order.push(name); + }; + return { + preflightInstall: record('service.preflight'), + install: async () => { + order.push('service.install'); + return { rollback: record('service.rollback') }; + }, + replace: record('service.replace'), + verifyDeployment: record('service.verify'), + status: async () => ({ + manager: 'systemd_user', + installed: true, + enabled: true, + active: true, + state: 'running', + pid: 42, + lastExitCode: 0, + }), + start: record('service.start'), + stop: record('service.stop'), + restart: record('service.restart'), + logs: async () => { + order.push('service.logs'); + return 'service log'; + }, + uninstall: record('service.uninstall'), + }; +} + +function fakeSchedulerBackend(order: string[]): RuntimeHostUpdateSchedulerBackend { + const record = (name: string) => async () => { + order.push(name); + }; + return { + install: async () => { + order.push('scheduler.install'); + return { rollback: record('scheduler.rollback') }; + }, + verifyDeployment: record('scheduler.verify'), + logs: async () => { + order.push('scheduler.logs'); + return 'scheduler log'; + }, + uninstall: record('scheduler.uninstall'), + }; +} + +async function withFixture( + operation: (fixture: { + homeDir: string; + config: RuntimeHostManagedServiceConfig; + operatorPath: string; + }) => Promise, +): Promise { + const homeDir = await mkdtemp(join(tmpdir(), 'maka-update-scheduler-test-')); + const deploymentRoot = join(homeDir, 'managed'); + const operatorPath = join(deploymentRoot, 'operator'); + try { + await mkdir(deploymentRoot, { recursive: true }); + await writeOperator(operatorPath); + await mkdir(join(homeDir, 'Library', 'LaunchAgents'), { recursive: true }); + await operation({ homeDir, config: fixtureConfig(deploymentRoot), operatorPath }); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } +} + +async function writeOperator(path: string): Promise { + await writeFile(path, '#!/bin/sh\n', { mode: 0o700 }); + await chmod(path, 0o700); +} + +function fakeSystemctl(): { + enabled: boolean; + active: boolean; + readonly run: ( + args: readonly string[], + ) => Promise<{ exitCode: number; stdout: string; stderr: string }>; +} { + const fake = { + enabled: false, + active: false, + run: async (args: readonly string[]) => { + if (args[0] === 'is-enabled') return result(fake.enabled); + if (args[0] === 'is-active') return result(fake.active); + if (args[0] === 'enable') { + fake.enabled = true; + if (args.includes('--now')) fake.active = true; + } + if (args[0] === 'disable') { + fake.enabled = false; + if (args.includes('--now')) fake.active = false; + } + return result(true); + }, + }; + return fake; +} + +function fakeLaunchctl(uid: number): { + loaded: boolean; + readonly run: ( + args: readonly string[], + ) => Promise<{ exitCode: number; stdout: string; stderr: string }>; +} { + const domain = `gui/${String(uid)}`; + const target = `${domain}/com.maka.runtime-host-update.${SERVICE_ID}`; + const fake = { + loaded: false, + run: async (args: readonly string[]) => { + if (args[0] === 'print') { + assert.equal(args[1], target); + return result(fake.loaded); + } + if (args[0] === 'bootstrap') { + assert.equal(args[1], domain); + fake.loaded = true; + return result(true); + } + if (args[0] === 'bootout') { + assert.equal(args[1], target); + fake.loaded = false; + return result(true); + } + throw new Error(`Unexpected launchctl arguments: ${args.join(' ')}`); + }, + }; + return fake; +} + +function result(ok: boolean): { + exitCode: number; + stdout: string; + stderr: string; +} { + return { exitCode: ok ? 0 : 1, stdout: '', stderr: '' }; +} + +async function fileExists(path: string): Promise { + return readFile(path) + .then(() => true) + .catch((error: unknown) => { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return false; + throw error; + }); +} diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index b9892392cf..9809c87662 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -124,7 +124,7 @@ 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 update-policy [--target ] [--check-interval <1h..168h>] [--json]`, ` ${cliCommand} runtime-host service reconcile-update [--json]`, ` ${cliCommand} runtime-host access issue --principal --grant `, ` ${cliCommand} runtime-host access issue --principal --preset `, @@ -343,6 +343,7 @@ export async function runMakaCli( return runManagedRuntimeHostUpdateReconcileCli({ json: command.json, framed: command.framed ?? false, + scheduled: command.scheduled ?? false, clientDataRoot: serviceDataRoots.clientDataRoot, defaultRootPath: serviceDataRoots.workspaceRoot, }); diff --git a/packages/cli/src/runtime-host-cli.ts b/packages/cli/src/runtime-host-cli.ts index 9153e1e703..c6fff4779e 100644 --- a/packages/cli/src/runtime-host-cli.ts +++ b/packages/cli/src/runtime-host-cli.ts @@ -18,7 +18,12 @@ */ import { isAbsolute } from 'node:path'; -import { isProductReleaseVersion } from '@maka/runtime-host/operator'; +import { + isProductReleaseVersion, + RUNTIME_HOST_UPDATE_CHECK_INTERVAL_HOURS_DEFAULT, + RUNTIME_HOST_UPDATE_CHECK_INTERVAL_HOURS_MAX, + RUNTIME_HOST_UPDATE_CHECK_INTERVAL_HOURS_MIN, +} from '@maka/runtime-host/operator'; import type { RuntimeHostManagedUpdatePolicy } from '@maka/runtime-host/operator'; import { isCanonicalRuntimeHostWebSocketPath, @@ -105,6 +110,7 @@ export type RuntimeHostCliCommand = json: boolean; framed?: true; clientDataRoot?: string; + scheduled?: true; } | { kind: 'runtime-host-managed-deployment-cleanup'; @@ -282,6 +288,8 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { let allowInterruptActiveTasks = false; let clientDataRoot: string | undefined; let updateTarget: string | undefined; + let updateCheckIntervalHours: number | undefined; + let scheduled = false; const flagOptions: Readonly void | RuntimeHostCliError>> = action === 'uninstall' ? { @@ -299,7 +307,14 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { allowInterruptActiveTasks = true; }, } - : {}; + : action === 'reconcile-update' + ? { + '--scheduled': () => { + if (scheduled) return error('Duplicate --scheduled'); + scheduled = true; + }, + } + : {}; const options = parseManagedServiceOptions(argv.slice(1), { allowConfiguration: action === 'install', allowFramed: true, @@ -317,6 +332,18 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { }, } : {}), + ...(action === 'update-policy' + ? { + '--check-interval': (value: string) => { + if (updateCheckIntervalHours !== undefined) { + return error('Duplicate --check-interval'); + } + const parsed = parseUpdateCheckIntervalHours(value); + if ('exitCode' in parsed) return parsed; + updateCheckIntervalHours = parsed.hours; + }, + } + : {}), }, flagOptions, }); @@ -325,8 +352,14 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { return error(`runtime-host service ${action} requires an expected target`); } if (action === 'update-policy') { - const policy = updateTarget === undefined ? undefined : parseUpdatePolicy(updateTarget); + const policy = + updateTarget === undefined + ? undefined + : parseUpdatePolicy(updateTarget, updateCheckIntervalHours); if (policy && 'exitCode' in policy) return policy; + if (policy?.kind === 'manual' && updateCheckIntervalHours !== undefined) { + return error('runtime-host service update-policy manual does not accept --check-interval'); + } if (policy?.kind === 'manual' && options.expectedTarget) { return error('runtime-host service update-policy manual does not accept an expected target'); } @@ -336,6 +369,9 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { if (!policy && options.expectedTarget) { return error('runtime-host service update-policy requires --target when setting a target'); } + if (!policy && updateCheckIntervalHours !== undefined) { + return error('runtime-host service update-policy requires --target when setting an interval'); + } return { kind: 'runtime-host-service-update-policy', json: options.json, @@ -354,6 +390,7 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { json: options.json, ...(options.framed ? { framed: true } : {}), ...(clientDataRoot ? { clientDataRoot } : {}), + ...(scheduled ? { scheduled: true } : {}), }; } if (action === 'check-update') { @@ -392,11 +429,33 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { }; } -function parseUpdatePolicy(value: string): RuntimeHostManagedUpdatePolicy | RuntimeHostCliError { +function parseUpdatePolicy( + value: string, + checkIntervalHours = RUNTIME_HOST_UPDATE_CHECK_INTERVAL_HOURS_DEFAULT, +): 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; + return selector.kind === 'exact' + ? { kind: 'fixed', version: selector.version, checkIntervalHours } + : { ...selector, checkIntervalHours }; +} + +function parseUpdateCheckIntervalHours( + value: string, +): { readonly hours: number } | RuntimeHostCliError { + const match = /^(\d{1,3})h$/u.exec(value); + const hours = match ? Number(match[1]) : Number.NaN; + if ( + !Number.isSafeInteger(hours) || + hours < RUNTIME_HOST_UPDATE_CHECK_INTERVAL_HOURS_MIN || + hours > RUNTIME_HOST_UPDATE_CHECK_INTERVAL_HOURS_MAX + ) { + return error( + `--check-interval must be an integer from ${String(RUNTIME_HOST_UPDATE_CHECK_INTERVAL_HOURS_MIN)}h to ${String(RUNTIME_HOST_UPDATE_CHECK_INTERVAL_HOURS_MAX)}h`, + ); + } + return { hours }; } function parseUpdateSelector( diff --git a/packages/cli/src/runtime-host-launch-agent-service.ts b/packages/cli/src/runtime-host-launch-agent-service.ts index f1414efd99..45a882d5d7 100644 --- a/packages/cli/src/runtime-host-launch-agent-service.ts +++ b/packages/cli/src/runtime-host-launch-agent-service.ts @@ -469,7 +469,7 @@ function managerError( ); } -async function readLogTail(path: string): Promise { +export async function readLogTail(path: string): Promise { let file; try { file = await open(path, 'r'); @@ -521,7 +521,7 @@ function nonNegativeInteger(value: string | undefined): number | null { return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null; } -function escapeXml(value: string): string { +export function escapeXml(value: string): string { if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value)) { throw new TypeError('LaunchAgent strings cannot contain XML control characters'); } diff --git a/packages/cli/src/runtime-host-launch-agent-update-scheduler.ts b/packages/cli/src/runtime-host-launch-agent-update-scheduler.ts new file mode 100644 index 0000000000..5244ce1e24 --- /dev/null +++ b/packages/cli/src/runtime-host-launch-agent-update-scheduler.ts @@ -0,0 +1,314 @@ +/* + * 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 { mkdir, open, readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { + removeRuntimeHostServiceFile, + RuntimeHostServiceManagerError, + type RuntimeHostManagedServiceConfig, + type RuntimeHostServiceDeployment, + writeRuntimeHostServiceFile, +} from './runtime-host-service-manager.js'; +import { + runRuntimeHostServiceManagerCommand, + type RuntimeHostServiceManagerCommandResult, +} from './runtime-host-service-manager-process.js'; +import { escapeXml, readLogTail } from './runtime-host-launch-agent-service.js'; +import { + runtimeHostUpdateSchedule, + runtimeHostUpdateSchedulerArguments, + type RuntimeHostUpdateSchedulerBackend, +} from './runtime-host-update-scheduler.js'; + +type LaunchctlRunner = (args: readonly string[]) => Promise; + +export interface LaunchAgentUpdateSchedulerOptions { + readonly homeDir?: string; + readonly uid?: number; + readonly runLaunchctl?: LaunchctlRunner; +} + +export function createLaunchAgentRuntimeHostUpdateScheduler( + serviceId: string, + options: LaunchAgentUpdateSchedulerOptions = {}, +): RuntimeHostUpdateSchedulerBackend { + const homeDir = options.homeDir ?? homedir(); + const uid = options.uid ?? process.getuid?.(); + if (uid === undefined || !Number.isSafeInteger(uid) || uid < 0) { + throw new RuntimeHostServiceManagerError( + 'service_manager_unavailable', + 'The current macOS user identity could not be determined', + ); + } + const paths = resolveLaunchAgentRuntimeHostUpdateSchedulerPaths(serviceId, homeDir); + const domain = `gui/${String(uid)}`; + const target = `${domain}/${paths.label}`; + const runLaunchctl = options.runLaunchctl ?? defaultRunLaunchctl; + + const isLoaded = async (): Promise => { + const result = await runLaunchctl(['print', target]); + return result.exitCode === 0; + }; + const bootout = async (): Promise => { + if (!(await isLoaded())) return; + await requireLaunchctl( + runLaunchctl, + ['bootout', target], + 'Stopping the Runtime Host update scheduler failed', + ); + }; + const apply = async (config: RuntimeHostManagedServiceConfig): Promise => { + const arguments_ = runtimeHostUpdateSchedulerArguments(config); + await bootout(); + if (!arguments_) { + await removeFiles(paths); + return; + } + await prepareLogs(paths); + await writeRuntimeHostServiceFile( + paths.plistPath, + renderLaunchAgentRuntimeHostUpdateSchedulerPlist(serviceId, arguments_, paths), + 0o600, + ); + await requireLaunchctl( + runLaunchctl, + ['bootstrap', domain, paths.plistPath], + 'Starting the Runtime Host update scheduler failed', + ); + }; + + return { + install: async (config) => { + const snapshot = await capture(paths.plistPath, isLoaded); + try { + await apply(config); + } catch (error) { + await restore(snapshot, paths, domain, target, runLaunchctl, error); + } + let rolledBack = false; + return { + rollback: async () => { + if (rolledBack) return; + rolledBack = true; + await restore(snapshot, paths, domain, target, runLaunchctl); + }, + } satisfies RuntimeHostServiceDeployment; + }, + verifyDeployment: async (config) => { + const arguments_ = runtimeHostUpdateSchedulerArguments(config); + const [plist, loaded] = await Promise.all([readOptional(paths.plistPath), isLoaded()]); + if (!arguments_) { + if (plist !== null || loaded) throw mismatch(); + return; + } + if ( + !loaded || + plist !== renderLaunchAgentRuntimeHostUpdateSchedulerPlist(serviceId, arguments_, paths) + ) { + throw mismatch(); + } + }, + logs: async () => { + const [stdout, stderr] = await Promise.all([ + readLogTail(paths.stdoutPath), + readLogTail(paths.stderrPath), + ]); + return [stdout && `stdout:\n${stdout}`, stderr && `stderr:\n${stderr}`] + .filter(Boolean) + .join('\n'); + }, + uninstall: async () => { + await bootout(); + await removeFiles(paths); + }, + }; +} + +export function resolveLaunchAgentRuntimeHostUpdateSchedulerPaths( + serviceId: string, + homeDir = homedir(), +): { + readonly label: string; + readonly plistPath: string; + readonly stdoutPath: string; + readonly stderrPath: string; +} { + if (!/^[0-9a-f]{64}$/u.test(serviceId)) throw new TypeError('Invalid Runtime Host service ID'); + const label = `com.maka.runtime-host-update.${serviceId}`; + const logRoot = join(homeDir, 'Library', 'Logs', 'Maka', 'runtime-host-services'); + return { + label, + plistPath: join(homeDir, 'Library', 'LaunchAgents', `${label}.plist`), + stdoutPath: join(logRoot, `${label}.stdout.log`), + stderrPath: join(logRoot, `${label}.stderr.log`), + }; +} + +export function renderLaunchAgentRuntimeHostUpdateSchedulerPlist( + serviceId: string, + args: readonly string[], + paths: ReturnType, +): string { + const schedule = runtimeHostUpdateSchedule(serviceId); + const stringEntry = (value: string) => ` ${escapeXml(value)}`; + return [ + '', + '', + '', + '', + ' Label', + ` ${escapeXml(paths.label)}`, + ' ProgramArguments', + ' ', + ...args.map(stringEntry), + ' ', + ' StartCalendarInterval', + ' ', + ' Minute', + ` ${String(schedule.minute)}`, + ' ', + ' ProcessType', + ' Background', + ' Umask', + ' 63', + ' StandardOutPath', + ` ${escapeXml(paths.stdoutPath)}`, + ' StandardErrorPath', + ` ${escapeXml(paths.stderrPath)}`, + '', + '', + '', + ].join('\n'); +} + +interface Snapshot { + readonly plist: string | null; + readonly loaded: boolean; +} + +async function capture(path: string, isLoaded: () => Promise): Promise { + const [plist, loaded] = await Promise.all([readOptional(path), isLoaded()]); + return { plist, loaded }; +} + +async function restore( + snapshot: Snapshot, + paths: ReturnType, + domain: string, + target: string, + runLaunchctl: LaunchctlRunner, + originalError?: unknown, +): Promise { + try { + const loaded = (await runLaunchctl(['print', target])).exitCode === 0; + if (loaded) { + await requireLaunchctl( + runLaunchctl, + ['bootout', target], + 'Stopping the replacement Runtime Host update scheduler failed', + ); + } + if (snapshot.plist === null) { + await removeFiles(paths); + } else { + await writeRuntimeHostServiceFile(paths.plistPath, snapshot.plist, 0o600); + if (snapshot.loaded) { + await requireLaunchctl( + runLaunchctl, + ['bootstrap', domain, paths.plistPath], + 'Restoring the Runtime Host update scheduler failed', + ); + } + } + } catch (rollbackError) { + if (originalError === undefined) throw rollbackError; + throw new RuntimeHostServiceManagerError( + 'service_manager_operation_failed', + 'Installing the Runtime Host update scheduler failed and its previous state could not be restored', + { cause: new AggregateError([originalError, rollbackError]) }, + ); + } + if (originalError !== undefined) throw originalError; +} + +async function prepareLogs( + paths: ReturnType, +): Promise { + for (const path of [paths.stdoutPath, paths.stderrPath]) { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + const file = await open(path, 'a', 0o600); + await file.close(); + } +} + +async function removeFiles( + paths: ReturnType, +): Promise { + await Promise.all([ + removeRuntimeHostServiceFile(paths.plistPath, 'LaunchAgent update scheduler'), + removeRuntimeHostServiceFile(paths.stdoutPath, 'LaunchAgent update scheduler stdout log'), + removeRuntimeHostServiceFile(paths.stderrPath, 'LaunchAgent update scheduler stderr log'), + ]); +} + +async function readOptional(path: string): Promise { + return readFile(path, 'utf8').catch((error: unknown) => { + if (isNodeError(error, 'ENOENT')) return null; + throw error; + }); +} + +async function requireLaunchctl( + runLaunchctl: LaunchctlRunner, + args: readonly string[], + message: string, +): Promise { + let result: RuntimeHostServiceManagerCommandResult; + try { + result = await runLaunchctl(args); + } catch (error) { + throw new RuntimeHostServiceManagerError('service_manager_unavailable', message, { + cause: error, + }); + } + if (result.exitCode !== 0) { + const detail = result.stderr.trim() || result.stdout.trim(); + throw new RuntimeHostServiceManagerError( + 'service_manager_operation_failed', + `${message}${detail ? `: ${detail}` : ''}`, + ); + } +} + +function mismatch(): RuntimeHostServiceManagerError { + return new RuntimeHostServiceManagerError( + 'target_mismatch', + 'The loaded Runtime Host update scheduler does not match its managed deployment', + ); +} + +function isNodeError(error: unknown, code: string): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error && error.code === code; +} + +function defaultRunLaunchctl(args: readonly string[]) { + return runRuntimeHostServiceManagerCommand('launchctl', args); +} diff --git a/packages/cli/src/runtime-host-service-management-command.ts b/packages/cli/src/runtime-host-service-management-command.ts index 867fabf23a..882e54a0f9 100644 --- a/packages/cli/src/runtime-host-service-management-command.ts +++ b/packages/cli/src/runtime-host-service-management-command.ts @@ -43,7 +43,10 @@ import { type RuntimeHostServiceBackend, } from './runtime-host-service-manager.js'; import { createLaunchAgentRuntimeHostService } from './runtime-host-launch-agent-service.js'; +import { createLaunchAgentRuntimeHostUpdateScheduler } from './runtime-host-launch-agent-update-scheduler.js'; import { createSystemdUserRuntimeHostService } from './runtime-host-systemd-service.js'; +import { createSystemdUserRuntimeHostUpdateScheduler } from './runtime-host-systemd-update-scheduler.js'; +import { withRuntimeHostUpdateScheduler } from './runtime-host-update-scheduler.js'; export interface RuntimeHostServiceManagementCliOptions extends Omit { @@ -219,8 +222,18 @@ export function createPlatformRuntimeHostServiceBackend( serviceId: string, platform: NodeJS.Platform = process.platform, ): RuntimeHostServiceBackend { - if (platform === 'linux') return createSystemdUserRuntimeHostService(serviceId); - if (platform === 'darwin') return createLaunchAgentRuntimeHostService(serviceId); + if (platform === 'linux') { + return withRuntimeHostUpdateScheduler( + createSystemdUserRuntimeHostService(serviceId), + createSystemdUserRuntimeHostUpdateScheduler(serviceId), + ); + } + if (platform === 'darwin') { + return withRuntimeHostUpdateScheduler( + createLaunchAgentRuntimeHostService(serviceId), + createLaunchAgentRuntimeHostUpdateScheduler(serviceId), + ); + } throw new RuntimeHostServiceManagerError( 'unsupported_platform', 'Managed Runtime Host services currently require Linux or macOS', diff --git a/packages/cli/src/runtime-host-systemd-service.ts b/packages/cli/src/runtime-host-systemd-service.ts index f3476fc4aa..cd8e7e44f5 100644 --- a/packages/cli/src/runtime-host-systemd-service.ts +++ b/packages/cli/src/runtime-host-systemd-service.ts @@ -541,7 +541,7 @@ function nonNegativeInteger(value: string | undefined): number | null { return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null; } -function quoteSystemdArgument(value: string): string { +export function quoteSystemdArgument(value: string): string { if (/[\u0000-\u001f\u007f]/u.test(value)) { throw new TypeError('systemd arguments cannot contain control characters'); } diff --git a/packages/cli/src/runtime-host-systemd-update-scheduler.ts b/packages/cli/src/runtime-host-systemd-update-scheduler.ts new file mode 100644 index 0000000000..de481b6007 --- /dev/null +++ b/packages/cli/src/runtime-host-systemd-update-scheduler.ts @@ -0,0 +1,363 @@ +/* + * 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 { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { resolveXdgConfigHome } from '@maka/storage/workspace-root'; +import { + removeRuntimeHostServiceFile, + RuntimeHostServiceManagerError, + type RuntimeHostManagedServiceConfig, + type RuntimeHostServiceDeployment, + writeRuntimeHostServiceFile, +} from './runtime-host-service-manager.js'; +import { + runRuntimeHostServiceManagerCommand, + type RuntimeHostServiceManagerCommandResult, +} from './runtime-host-service-manager-process.js'; +import { quoteSystemdArgument } from './runtime-host-systemd-service.js'; +import { + runtimeHostUpdateSchedule, + runtimeHostUpdateSchedulerArguments, + type RuntimeHostUpdateSchedulerBackend, +} from './runtime-host-update-scheduler.js'; + +type CommandRunner = (args: readonly string[]) => Promise; + +export interface SystemdUserUpdateSchedulerOptions { + readonly env?: NodeJS.ProcessEnv; + readonly homeDir?: string; + readonly runSystemctl?: CommandRunner; + readonly runJournalctl?: CommandRunner; +} + +export function createSystemdUserRuntimeHostUpdateScheduler( + serviceId: string, + options: SystemdUserUpdateSchedulerOptions = {}, +): RuntimeHostUpdateSchedulerBackend { + const paths = resolveSystemdUserRuntimeHostUpdateSchedulerPaths( + serviceId, + options.env, + options.homeDir, + ); + const runSystemctl = options.runSystemctl ?? defaultRunSystemctl; + const runJournalctl = options.runJournalctl ?? defaultRunJournalctl; + + const apply = async (config: RuntimeHostManagedServiceConfig) => { + const arguments_ = runtimeHostUpdateSchedulerArguments(config); + if (!arguments_) { + await removeSchedule(paths, runSystemctl); + return; + } + await writeRuntimeHostServiceFile( + paths.servicePath, + renderSystemdRuntimeHostUpdateService(arguments_), + 0o600, + ); + await writeRuntimeHostServiceFile( + paths.timerPath, + renderSystemdRuntimeHostUpdateTimer(serviceId), + 0o600, + ); + await requireCommand(runSystemctl, ['daemon-reload'], 'Reloading systemd failed'); + await requireCommand( + runSystemctl, + ['enable', '--now', paths.timerName], + 'Enabling the Runtime Host update timer failed', + ); + }; + + return { + install: async (config) => { + const snapshot = await captureSchedule(paths, runSystemctl); + try { + await apply(config); + } catch (error) { + await restoreSchedule(snapshot, paths, runSystemctl, error); + } + let rolledBack = false; + return { + rollback: async () => { + if (rolledBack) return; + rolledBack = true; + await restoreSchedule(snapshot, paths, runSystemctl); + }, + } satisfies RuntimeHostServiceDeployment; + }, + verifyDeployment: async (config) => { + const arguments_ = runtimeHostUpdateSchedulerArguments(config); + const [service, timer, enabled, active] = await Promise.all([ + readOptional(paths.servicePath), + readOptional(paths.timerPath), + runSystemctl(['is-enabled', paths.timerName]), + runSystemctl(['is-active', paths.timerName]), + ]); + if (!arguments_) { + if (service !== null || timer !== null || enabled.exitCode === 0 || active.exitCode === 0) { + throw mismatch(); + } + return; + } + if ( + service !== renderSystemdRuntimeHostUpdateService(arguments_) || + timer !== renderSystemdRuntimeHostUpdateTimer(serviceId) || + enabled.exitCode !== 0 || + active.exitCode !== 0 + ) { + throw mismatch(); + } + }, + logs: async () => { + const result = await runJournalctl([ + '--user-unit', + paths.serviceName, + '--no-pager', + '--lines=100', + '--output=short-iso', + ]); + if (result.exitCode !== 0) { + throw commandError('Reading Runtime Host update scheduler logs failed', result); + } + return result.stdout; + }, + uninstall: () => removeSchedule(paths, runSystemctl), + }; +} + +export function resolveSystemdUserRuntimeHostUpdateSchedulerPaths( + serviceId: string, + env: NodeJS.ProcessEnv = process.env, + homeDir = homedir(), +): { + readonly serviceName: string; + readonly servicePath: string; + readonly timerName: string; + readonly timerPath: string; +} { + assertServiceId(serviceId); + const root = join(resolveXdgConfigHome(env, homeDir), 'systemd', 'user'); + const stem = `maka-runtime-host-update-${serviceId}`; + return { + serviceName: `${stem}.service`, + servicePath: join(root, `${stem}.service`), + timerName: `${stem}.timer`, + timerPath: join(root, `${stem}.timer`), + }; +} + +export function renderSystemdRuntimeHostUpdateService(args: readonly string[]): string { + return [ + '[Unit]', + 'Description=Reconcile Maka Runtime Host updates', + '', + '[Service]', + 'Type=oneshot', + `ExecStart=${args.map(quoteSystemdArgument).join(' ')}`, + 'UMask=0077', + '', + ].join('\n'); +} + +export function renderSystemdRuntimeHostUpdateTimer(serviceId: string): string { + const schedule = runtimeHostUpdateSchedule(serviceId); + return [ + '[Unit]', + 'Description=Schedule Maka Runtime Host update reconciliation', + '', + '[Timer]', + `OnCalendar=*-*-* *:${String(schedule.minute).padStart(2, '0')}:00`, + 'Persistent=true', + '', + '[Install]', + 'WantedBy=timers.target', + '', + ].join('\n'); +} + +interface ScheduleSnapshot { + readonly service: string | null; + readonly timer: string | null; + readonly enabled: boolean; + readonly active: boolean; +} + +async function captureSchedule( + paths: ReturnType, + runSystemctl: CommandRunner, +): Promise { + const [service, timer, enabled, active] = await Promise.all([ + readOptional(paths.servicePath), + readOptional(paths.timerPath), + runSystemctl(['is-enabled', paths.timerName]), + runSystemctl(['is-active', paths.timerName]), + ]); + return { + service, + timer, + enabled: enabled.exitCode === 0, + active: active.exitCode === 0, + }; +} + +async function restoreSchedule( + snapshot: ScheduleSnapshot, + paths: ReturnType, + runSystemctl: CommandRunner, + originalError?: unknown, +): Promise { + try { + const [enabled, active] = await Promise.all([ + runSystemctl(['is-enabled', paths.timerName]), + runSystemctl(['is-active', paths.timerName]), + ]); + if (enabled.exitCode === 0 || active.exitCode === 0) { + await requireCommand( + runSystemctl, + ['disable', '--now', paths.timerName], + 'Disabling the replacement Runtime Host update timer failed', + ); + } + await restoreFile(paths.servicePath, snapshot.service); + await restoreFile(paths.timerPath, snapshot.timer); + await requireCommand(runSystemctl, ['daemon-reload'], 'Reloading systemd failed'); + if (snapshot.enabled) { + await requireCommand( + runSystemctl, + snapshot.active ? ['enable', '--now', paths.timerName] : ['enable', paths.timerName], + 'Restoring the Runtime Host update timer failed', + ); + } else if (snapshot.active) { + await requireCommand( + runSystemctl, + ['start', paths.timerName], + 'Restoring the Runtime Host update timer failed', + ); + } + } catch (rollbackError) { + if (originalError === undefined) throw rollbackError; + throw new RuntimeHostServiceManagerError( + 'service_manager_operation_failed', + 'Installing the Runtime Host update timer failed and its previous state could not be restored', + { cause: new AggregateError([originalError, rollbackError]) }, + ); + } + if (originalError !== undefined) throw originalError; +} + +async function removeSchedule( + paths: ReturnType, + runSystemctl: CommandRunner, +): Promise { + const [timerEnabled, timerActive, serviceActive] = await Promise.all([ + runSystemctl(['is-enabled', paths.timerName]), + runSystemctl(['is-active', paths.timerName]), + runSystemctl(['is-active', paths.serviceName]), + ]); + if (timerEnabled.exitCode === 0 || timerActive.exitCode === 0) { + await requireCommand( + runSystemctl, + ['disable', '--now', paths.timerName], + 'Disabling the Runtime Host update timer failed', + ); + } + if (serviceActive.exitCode === 0) { + await requireCommand( + runSystemctl, + ['stop', paths.serviceName], + 'Stopping Runtime Host update reconciliation failed', + ); + } + await Promise.all([ + removeRuntimeHostServiceFile(paths.servicePath, 'systemd update service'), + removeRuntimeHostServiceFile(paths.timerPath, 'systemd update timer'), + ]); + await requireCommand(runSystemctl, ['daemon-reload'], 'Reloading systemd failed'); + await Promise.all([ + runSystemctl(['reset-failed', paths.serviceName]), + runSystemctl(['reset-failed', paths.timerName]), + ]); +} + +async function restoreFile(path: string, contents: string | null): Promise { + if (contents === null) { + await removeRuntimeHostServiceFile(path, 'systemd update schedule'); + } else { + await writeRuntimeHostServiceFile(path, contents, 0o600); + } +} + +async function readOptional(path: string): Promise { + return readFile(path, 'utf8').catch((error: unknown) => { + if (isNodeError(error, 'ENOENT')) return null; + throw error; + }); +} + +function mismatch(): RuntimeHostServiceManagerError { + return new RuntimeHostServiceManagerError( + 'target_mismatch', + 'The loaded Runtime Host update timer does not match its managed deployment', + ); +} + +async function requireCommand( + run: CommandRunner, + args: readonly string[], + message: string, +): Promise { + let result: RuntimeHostServiceManagerCommandResult; + try { + result = await run(args); + } catch (error) { + throw new RuntimeHostServiceManagerError('service_manager_unavailable', message, { + cause: error, + }); + } + if (result.exitCode !== 0) { + throw commandError(message, result); + } +} + +function commandError( + message: string, + result: RuntimeHostServiceManagerCommandResult, +): RuntimeHostServiceManagerError { + const detail = result.stderr.trim() || result.stdout.trim(); + return new RuntimeHostServiceManagerError( + 'service_manager_operation_failed', + `${message}${detail ? `: ${detail}` : ''}`, + ); +} + +function assertServiceId(serviceId: string): void { + if (!/^[0-9a-f]{64}$/u.test(serviceId)) throw new TypeError('Invalid Runtime Host service ID'); +} + +function isNodeError(error: unknown, code: string): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error && error.code === code; +} + +function defaultRunSystemctl(args: readonly string[]) { + return runRuntimeHostServiceManagerCommand('systemctl', ['--user', ...args]); +} + +function defaultRunJournalctl(args: readonly string[]) { + return runRuntimeHostServiceManagerCommand('journalctl', args); +} diff --git a/packages/cli/src/runtime-host-update-policy-store.ts b/packages/cli/src/runtime-host-update-policy-store.ts index 06f851c54d..d376f99153 100644 --- a/packages/cli/src/runtime-host-update-policy-store.ts +++ b/packages/cli/src/runtime-host-update-policy-store.ts @@ -22,6 +22,9 @@ import { mkdir, open, readFile, rename, rm, unlink } from 'node:fs/promises'; import { dirname, isAbsolute, join } from 'node:path'; import { isProductReleaseVersion, + RUNTIME_HOST_UPDATE_CHECK_INTERVAL_HOURS_DEFAULT, + RUNTIME_HOST_UPDATE_CHECK_INTERVAL_HOURS_MAX, + RUNTIME_HOST_UPDATE_CHECK_INTERVAL_HOURS_MIN, type RuntimeHostManagedUpdatePolicy, } from '@maka/runtime-host/operator'; import type { RuntimeHostManagedServiceTarget } from './runtime-host-service-manager.js'; @@ -78,9 +81,7 @@ export async function readRuntimeHostManagedUpdatePolicy( 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; + return parseUpdatePolicyRecord(JSON.parse(raw)); } catch (error) { throw new RuntimeHostUpdatePolicyError( 'invalid_update_policy', @@ -128,7 +129,7 @@ export async function writeRuntimeHostManagedUpdatePolicy( ); } } - assertUpdatePolicyRecord(record); + const validatedRecord = parseUpdatePolicyRecord(record); const directory = dirname(path); const temporaryPath = `${path}.${randomUUID()}.tmp`; let published = false; @@ -136,7 +137,7 @@ export async function writeRuntimeHostManagedUpdatePolicy( 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.writeFile(`${JSON.stringify(validatedRecord, null, 2)}\n`, 'utf8'); await file.sync(); } finally { await file.close(); @@ -166,33 +167,57 @@ async function syncDirectory(path: string): Promise { } } -function assertUpdatePolicyRecord( - value: unknown, -): asserts value is RuntimeHostManagedUpdatePolicyRecord { +function parseUpdatePolicyRecord(value: unknown): 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'); } + return { + schemaVersion: 1, + policy: parseAutomaticUpdatePolicy(value.policy), + target: value.target, + }; } -function isAutomaticUpdatePolicy(value: unknown): value is AutomaticUpdatePolicy { - if (!isRecord(value)) return false; +function parseAutomaticUpdatePolicy(value: unknown): AutomaticUpdatePolicy { + if (!isRecord(value)) throw new TypeError('Invalid managed Runtime Host update policy'); + const checkIntervalHours = + value.checkIntervalHours === undefined + ? RUNTIME_HOST_UPDATE_CHECK_INTERVAL_HOURS_DEFAULT + : value.checkIntervalHours; if (value.kind === 'channel') { - return ( - hasOnlyKeys(value, ['kind', 'channel']) && - (value.channel === 'latest' || value.channel === 'next') - ); + if ( + (hasOnlyKeys(value, ['kind', 'channel']) || + hasOnlyKeys(value, ['kind', 'channel', 'checkIntervalHours'])) && + (value.channel === 'latest' || value.channel === 'next') && + isCheckIntervalHours(checkIntervalHours) + ) { + return { kind: 'channel', channel: value.channel, checkIntervalHours }; + } + throw new TypeError('Invalid managed Runtime Host update policy'); } - return ( + if ( value.kind === 'fixed' && - hasOnlyKeys(value, ['kind', 'version']) && + (hasOnlyKeys(value, ['kind', 'version']) || + hasOnlyKeys(value, ['kind', 'version', 'checkIntervalHours'])) && typeof value.version === 'string' && - isProductReleaseVersion(value.version) + isProductReleaseVersion(value.version) && + isCheckIntervalHours(checkIntervalHours) + ) { + return { kind: 'fixed', version: value.version, checkIntervalHours }; + } + throw new TypeError('Invalid managed Runtime Host update policy'); +} + +function isCheckIntervalHours(value: unknown): value is number { + return ( + Number.isSafeInteger(value) && + Number(value) >= RUNTIME_HOST_UPDATE_CHECK_INTERVAL_HOURS_MIN && + Number(value) <= RUNTIME_HOST_UPDATE_CHECK_INTERVAL_HOURS_MAX ); } diff --git a/packages/cli/src/runtime-host-update-reconciliation.ts b/packages/cli/src/runtime-host-update-reconciliation.ts index 4e81190a1d..a42b6402e1 100644 --- a/packages/cli/src/runtime-host-update-reconciliation.ts +++ b/packages/cli/src/runtime-host-update-reconciliation.ts @@ -54,6 +54,7 @@ import { RuntimeHostUpdateDiscoveryError, } from './runtime-host-update-discovery.js'; import type { RuntimeHostUpdateSelector } from './runtime-host-cli.js'; +import { isRuntimeHostScheduledUpdateDue } from './runtime-host-update-scheduler.js'; type UpdatePolicyFrame = Extract; type ReconcileUpdateFrame = Extract< @@ -75,6 +76,7 @@ interface RuntimeHostUpdateReconcileCliOptions { readonly framed: boolean; readonly clientDataRoot: string; readonly defaultRootPath: string; + readonly scheduled?: boolean; } interface RuntimeHostUpdateReconciliationDeps { @@ -87,6 +89,7 @@ interface RuntimeHostUpdateReconciliationDeps { readonly applySelection: typeof runManagedRuntimeHostResolvedUpdateCli; readonly writeOutput: (value: string) => unknown; readonly writeError: (value: string) => unknown; + readonly now: () => number; } export async function runManagedRuntimeHostUpdatePolicyCli( @@ -171,6 +174,31 @@ export async function runManagedRuntimeHostUpdateReconcileCli( return 0; } const { root: policyRoot, record } = policy; + if ( + options.scheduled && + !isRuntimeHostScheduledUpdateDue( + record.target.serviceId, + record.policy.checkIntervalHours, + deps.now(), + ) + ) { + writeFrame( + { + schemaVersion: 1, + kind: 'result', + action: 'reconcile_update', + updatePolicy: policyResult(record), + service: runtimeHostServiceSummary(status), + reconciliation: { + kind: 'not_due', + checkIntervalHours: record.policy.checkIntervalHours, + }, + }, + options, + deps, + ); + return 0; + } const selection = await deps.resolveSelection({ clientDataRoot: options.clientDataRoot, defaultRootPath: options.defaultRootPath, @@ -293,6 +321,7 @@ function reconciliationDeps( applySelection: runManagedRuntimeHostResolvedUpdateCli, writeOutput: (value) => process.stdout.write(value), writeError: (value) => process.stderr.write(value), + now: Date.now, ...overrides, }; } @@ -412,11 +441,14 @@ function humanResult(frame: Extract; + verifyDeployment(config: RuntimeHostManagedServiceConfig): Promise; + logs(): Promise; + uninstall(): Promise; +} + +export function withRuntimeHostUpdateScheduler( + service: RuntimeHostServiceBackend, + scheduler: RuntimeHostUpdateSchedulerBackend, +): RuntimeHostServiceBackend { + return { + preflightInstall: () => service.preflightInstall(), + install: async (config) => { + const serviceDeployment = await service.install(config); + let schedulerDeployment: RuntimeHostServiceDeployment; + try { + schedulerDeployment = await scheduler.install(config); + } catch (error) { + await rollbackAfterFailure(serviceDeployment, error); + } + return { + rollback: async () => { + const failures: unknown[] = []; + await schedulerDeployment.rollback().catch((error) => failures.push(error)); + await serviceDeployment.rollback().catch((error) => failures.push(error)); + if (failures.length > 0) { + throw new RuntimeHostServiceManagerError( + 'service_manager_operation_failed', + 'Rolling back the Runtime Host service and update schedule failed', + { cause: new AggregateError(failures) }, + ); + } + }, + }; + }, + replace: (config) => service.replace(config), + verifyDeployment: async (config) => { + await Promise.all([service.verifyDeployment(config), scheduler.verifyDeployment(config)]); + }, + status: () => service.status(), + start: () => service.start(), + stop: () => service.stop(), + restart: () => service.restart(), + logs: async () => { + const serviceLogs = await service.logs(); + const schedulerLogs = await scheduler + .logs() + .catch( + (error: unknown) => + `unavailable: ${error instanceof Error ? error.message : String(error)}`, + ); + return [serviceLogs, schedulerLogs && `update scheduler:\n${schedulerLogs}`] + .filter(Boolean) + .join('\n'); + }, + uninstall: async () => { + await scheduler.uninstall(); + await service.uninstall(); + }, + }; +} + +export function runtimeHostUpdateSchedulerArguments( + config: RuntimeHostManagedServiceConfig, +): readonly string[] | null { + if (!config.managedDeploymentRoot) return null; + return [ + join(config.managedDeploymentRoot, 'operator'), + 'reconcile-update', + '--scheduled', + '--json', + ]; +} + +export function runtimeHostUpdateSchedule(serviceId: string): { readonly minute: number } { + if (!/^[0-9a-f]{64}$/u.test(serviceId)) throw new TypeError('Invalid Runtime Host service ID'); + return { minute: Number.parseInt(serviceId.slice(0, 2), 16) % 60 }; +} + +export function isRuntimeHostScheduledUpdateDue( + serviceId: string, + checkIntervalHours: number, + now: number, +): boolean { + if (!/^[0-9a-f]{64}$/u.test(serviceId)) throw new TypeError('Invalid Runtime Host service ID'); + if ( + !Number.isSafeInteger(checkIntervalHours) || + checkIntervalHours < RUNTIME_HOST_UPDATE_CHECK_INTERVAL_HOURS_MIN || + checkIntervalHours > RUNTIME_HOST_UPDATE_CHECK_INTERVAL_HOURS_MAX + ) { + throw new TypeError('Invalid Runtime Host update check interval'); + } + const phase = Number.parseInt(serviceId.slice(2, 10), 16) % checkIntervalHours; + return Math.floor(now / (60 * 60 * 1_000)) % checkIntervalHours === phase; +} + +async function rollbackAfterFailure( + deployment: RuntimeHostServiceDeployment, + originalError: unknown, +): Promise { + try { + await deployment.rollback(); + } catch (rollbackError) { + throw new RuntimeHostServiceManagerError( + 'service_manager_operation_failed', + 'Installing the Runtime Host update schedule failed and the service could not be restored', + { cause: new AggregateError([originalError, rollbackError]) }, + ); + } + throw originalError; +} diff --git a/packages/runtime-host/src/operator/index.ts b/packages/runtime-host/src/operator/index.ts index b9ea599ef7..3229a7d5f3 100644 --- a/packages/runtime-host/src/operator/index.ts +++ b/packages/runtime-host/src/operator/index.ts @@ -36,6 +36,9 @@ export { RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES, RUNTIME_HOST_SERVICE_LOG_MAX_BYTES, RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, + RUNTIME_HOST_UPDATE_CHECK_INTERVAL_HOURS_DEFAULT, + RUNTIME_HOST_UPDATE_CHECK_INTERVAL_HOURS_MAX, + RUNTIME_HOST_UPDATE_CHECK_INTERVAL_HOURS_MIN, decodeRuntimeHostServiceManagementFrame, encodeRuntimeHostServiceManagementFrame, type RuntimeHostServiceManagementAction, diff --git a/packages/runtime-host/src/operator/service-management-frame.ts b/packages/runtime-host/src/operator/service-management-frame.ts index 27e53ecc34..42ebb8ea3f 100644 --- a/packages/runtime-host/src/operator/service-management-frame.ts +++ b/packages/runtime-host/src/operator/service-management-frame.ts @@ -95,15 +95,31 @@ 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); +export const RUNTIME_HOST_UPDATE_CHECK_INTERVAL_HOURS_MIN = 1; +export const RUNTIME_HOST_UPDATE_CHECK_INTERVAL_HOURS_MAX = 7 * 24; +export const RUNTIME_HOST_UPDATE_CHECK_INTERVAL_HOURS_DEFAULT = 6; +const UPDATE_CHECK_INTERVAL_HOURS_SCHEMA = z + .number() + .int() + .min(RUNTIME_HOST_UPDATE_CHECK_INTERVAL_HOURS_MIN) + .max(RUNTIME_HOST_UPDATE_CHECK_INTERVAL_HOURS_MAX) + .default(RUNTIME_HOST_UPDATE_CHECK_INTERVAL_HOURS_DEFAULT); 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, + checkIntervalHours: UPDATE_CHECK_INTERVAL_HOURS_SCHEMA, + }) + .strict(), + z + .object({ + kind: z.literal('channel'), + channel: z.enum(UPDATE_CHANNELS), + checkIntervalHours: UPDATE_CHECK_INTERVAL_HOURS_SCHEMA, }) .strict(), - z.object({ kind: z.literal('channel'), channel: z.enum(UPDATE_CHANNELS) }).strict(), ]); const MANAGED_SERVICE_TARGET_SCHEMA = z .object({ @@ -331,6 +347,12 @@ const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.union([ service: SERVICE_SUMMARY_SCHEMA.optional(), reconciliation: z.discriminatedUnion('kind', [ z.object({ kind: z.literal('disabled') }).strict(), + z + .object({ + kind: z.literal('not_due'), + checkIntervalHours: UPDATE_CHECK_INTERVAL_HOURS_SCHEMA, + }) + .strict(), z .object({ kind: z.literal('manual_action'),