Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,11 @@ maka runtime-host service check-update --target next --json
```

The result pins the selected channel to an exact version and package integrity. It also reports
whether the package carries enough compatibility evidence for a future unattended update; this
command never installs or switches a package. An updater must still verify the downloaded archive
against that integrity and confirm the compatibility value from its extracted package manifest.
whether the package carries enough compatibility evidence for unattended use; this command never
installs or switches a package. Installation-management callers can pass the same selector to
`service update --target`. That path verifies the archive and extracted manifest before delegating
to the existing exact-package update transaction, and does not mutate a candidate that requires
manual review.

## Uninstall

Expand Down
5 changes: 3 additions & 2 deletions packages/cli/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,9 @@ maka runtime-host service check-update --target next --json
```

结果会把频道固定为精确版本和 package integrity,并说明 package 是否提供足够的兼容性证据,
可供未来的无人值守更新使用;该命令不会安装或切换 package。更新程序仍须以该 integrity
校验下载的 archive,并从解压后的 package manifest 中再次确认兼容性值。
可供无人值守流程使用;该命令不会安装或切换 package。安装管理方可以把同一
selector 传给 `service update --target`。该路径会先校验 archive 与解包后的 manifest,再委托给
现有的精确 package 更新事务;需要人工审查的候选不会改变当前 Host。

## 卸载

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,27 +117,29 @@ test('maps install, stop, start, restart, and uninstall onto one LaunchAgent ser
});
});

test('restores the previous loaded LaunchAgent when replacement bootstrap fails', async () => {
await withFixture(async ({ homeDir, cliPath, launchctl }) => {
const plistPath = resolveLaunchAgentPath(SERVICE_ID, homeDir);
const previousPlist = '<plist>previous</plist>\n';
await writeFile(plistPath, previousPlist, { mode: 0o600 });
launchctl.loaded = true;
launchctl.failNextBootstrap = true;
const backend = createLaunchAgentRuntimeHostService(SERVICE_ID, {
homeDir,
uid: UID,
runLaunchctl: launchctl.run,
isProcessAlive: () => false,
});
test('restores the previous loaded LaunchAgent when deployment bootstrap fails', async () => {
for (const action of ['install', 'replace'] as const) {
await withFixture(async ({ homeDir, cliPath, launchctl }) => {
const plistPath = resolveLaunchAgentPath(SERVICE_ID, homeDir);
const previousPlist = '<plist>previous</plist>\n';
await writeFile(plistPath, previousPlist, { mode: 0o600 });
launchctl.loaded = true;
launchctl.failNextBootstrap = true;
const backend = createLaunchAgentRuntimeHostService(SERVICE_ID, {
homeDir,
uid: UID,
runLaunchctl: launchctl.run,
isProcessAlive: () => false,
});

await assert.rejects(
backend.install(fixtureConfig(process.execPath, cliPath, join(homeDir, 'state'))),
/Starting the Runtime Host LaunchAgent failed/u,
);
assert.equal(await readFile(plistPath, 'utf8'), previousPlist);
assert.equal(launchctl.loaded, true);
});
await assert.rejects(
backend[action](fixtureConfig(process.execPath, cliPath, join(homeDir, 'state'))),
/Starting the Runtime Host LaunchAgent failed/u,
);
assert.equal(await readFile(plistPath, 'utf8'), previousPlist);
assert.equal(launchctl.loaded, true);
});
}
});

interface FakeLaunchctl {
Expand Down
176 changes: 176 additions & 0 deletions packages/cli/src/__tests__/runtime-host-selected-update.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { decodeRuntimeHostServiceManagementFrame } from '@maka/runtime-host/operator';
import {
runManagedRuntimeHostSelectedUpdateCli,
type RuntimeHostSelectedUpdateCliOptions,
type RuntimeHostUpdateCliOptions,
} from '../runtime-host-update-command.js';
import { parseRuntimeHostCommand } from '../runtime-host-cli.js';
import type { RuntimeHostUpdateSelection } from '../runtime-host-update-discovery.js';

const INTEGRITY =
'sha512-jUKdo/5dbM94KXq+kOZ1d+obhDLAENfI/QWr1PnXWcdu2PqDyLklJBtiVO6HRwoL1l40z1NE9Rq+hLAxCN0Fyg==';
const TARGET = {
serviceId: 'b'.repeat(64),
rootPath: '/srv/maka',
rootId: 'a'.repeat(64),
};
const OPTIONS: RuntimeHostSelectedUpdateCliOptions = {
json: false,
framed: true,
clientDataRoot: '/client',
defaultRootPath: '/workspace',
selector: { kind: 'channel', channel: 'next' },
expectedTarget: TARGET,
};

describe('managed Runtime Host selected update', () => {
it('parses an optional target without changing the exact-package command', () => {
assert.deepEqual(
parseRuntimeHostCommand([
'service',
'update',
'--target',
'next',
'--expected-service-id',
TARGET.serviceId,
'--expected-root-path',
TARGET.rootPath,
'--expected-root-id',
TARGET.rootId,
]),
{
kind: 'runtime-host-service-update',
json: false,
selector: { kind: 'channel', channel: 'next' },
expectedTarget: TARGET,
},
);
assert.equal(
'selector' in
parseRuntimeHostCommand([
'service',
'update',
'--expected-service-id',
TARGET.serviceId,
'--expected-root-path',
TARGET.rootPath,
'--expected-root-id',
TARGET.rootId,
]),
false,
);
});

it('hands one verified admitted package to the existing update transaction', async () => {
const selection = updateSelection({
kind: 'unattended_update',
compatibility: 7,
});
let updateInput: RuntimeHostUpdateCliOptions | undefined;
const exitCode = await runManagedRuntimeHostSelectedUpdateCli(OPTIONS, {
resolveSelection: async () => selection,
withPackage: async (candidate, use) => {
assert.deepEqual(candidate, selection.candidate);
return use('/verified/package');
},
update: async (input) => {
updateInput = input;
return 0;
},
});
assert.equal(exitCode, 0);
assert.equal(updateInput?.sourcePackageRoot, '/verified/package');
assert.equal(updateInput?.version, '2.0.0');
assert.deepEqual(updateInput?.registrySelection, {
integrity: INTEGRITY,
current: {
version: '1.0.0',
cliPath: '/managed/versions/1.0.0/dist/cli.js',
},
});
});

it('lets the exact transaction decide whether a current candidate needs repair', async () => {
const selection = updateSelection({ kind: 'current' });
let updateInput: RuntimeHostUpdateCliOptions | undefined;
assert.equal(
await runManagedRuntimeHostSelectedUpdateCli(OPTIONS, {
resolveSelection: async () => selection,
withPackage: async () => assert.fail('the current deployment must not be downloaded'),
update: async (input) => {
updateInput = input;
return 0;
},
}),
0,
);
assert.equal(updateInput?.sourcePackageRoot, '/managed/versions/2.0.0');
});

it('keeps non-admitted candidates outside package acquisition and mutation', async () => {
let output = '';
const exitCode = await runManagedRuntimeHostSelectedUpdateCli(OPTIONS, {
resolveSelection: async () =>
updateSelection({
kind: 'manual_action',
reason: 'compatibility_mismatch',
}),
withPackage: async () => assert.fail('package acquisition is not expected'),
update: async () => assert.fail('the update transaction is not expected'),
writeOutput: (value) => {
output += value;
},
});
const frame = decodeRuntimeHostServiceManagementFrame(output.trim());
assert.equal(exitCode, 1);
assert.equal(frame?.kind === 'error' ? frame.error.code : undefined, 'update_not_admitted');
});
});

function updateSelection(
outcome: RuntimeHostUpdateSelection['outcome'],
): RuntimeHostUpdateSelection {
const candidate = {
version: '2.0.0',
integrity: INTEGRITY,
compatibility: 7,
};
return {
selector: OPTIONS.selector,
candidate,
outcome,
currentCliPath: `/managed/versions/${outcome.kind === 'current' ? candidate.version : '1.0.0'}/dist/cli.js`,
service: {
platform: 'linux',
arch: 'x64',
osRelease: 'test',
state: 'running',
pid: 42,
lastExitCode: null,
installedVersion: outcome.kind === 'current' ? candidate.version : '1.0.0',
stateRoot: TARGET.rootPath,
projectDirectoryRoots: [],
},
};
}
Loading