Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,5 @@ apps/desktop/resources/tools/
apps/desktop/release/
apps/desktop/release-sources/
packages/cli/release/
packages/cli/.development/
release/asf/
171 changes: 170 additions & 1 deletion apps/desktop/src/main/__tests__/runtime-host-management.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import type {
DesktopRuntimeHostSshCleanupInput,
DesktopRuntimeHostSshManagementInput,
DesktopRuntimeHostSshUpdateInput,
DesktopRuntimeHostSshUpdatePolicyInput,
DesktopRuntimeHostSshUpdateReconciliationInput,
} from '../runtime-host-ssh-terminal.js';

test('identifies, rotates, and revokes managed credentials without exposing secrets', async () => {
Expand Down Expand Up @@ -390,6 +392,9 @@ test('publishes update progress and waits for the managed profile to reconnect',
update: { kind: 'updated', previousVersion: '1.2.3', targetVersion: '1.3.0' },
};
},
runUpdatePolicy: async () => assert.fail('update policy is not expected'),
runUpdateReconciliation: async () =>
assert.fail('update reconciliation is not expected'),
resolveUpdatePackage: () => ({ kind: 'npm', specifier: 'maka-agent@1.3.0' }),
currentHostEpoch: () => 'host-before-update',
awaitUpdatedConnection: async (...args) => {
Expand All @@ -414,7 +419,10 @@ test('publishes update progress and waits for the managed profile to reconnect',
rootId: profile.rootId,
},
}]);
assert.deepEqual(progress, [{ profileId: profile.id, phase: 'staging' }]);
assert.deepEqual(progress, [
{ profileId: profile.id, phase: 'preparing_cli' },
{ profileId: profile.id, phase: 'staging' },
]);
assert.deepEqual(connectionCompletions, [
[profile.id, profile.rootId, 'host-before-update', true],
]);
Expand Down Expand Up @@ -443,6 +451,151 @@ test('publishes update progress and waits for the managed profile to reconnect',
});
});

test('manages one Host update policy and reconciles it through the bound operator', async () => {
const handlers = new Map<string, (...args: unknown[]) => unknown>();
const policyInputs: DesktopRuntimeHostSshUpdatePolicyInput[] = [];
const reconciliationInputs: DesktopRuntimeHostSshUpdateReconciliationInput[] = [];
const progress: unknown[] = [];
const connections: unknown[] = [];
const profile = {
id: 'office',
name: 'Office',
kind: 'remote' as const,
rootId: 'a'.repeat(64),
transport: {
kind: 'ssh' as const,
destination: 'operator@example.com',
remotePort: 7443,
websocketPath: '/runtime-host',
},
};
const service = {
id: 'b'.repeat(64),
rootPath: '/srv/maka',
operatorPath: '/home/operator/.local/share/maka/operator',
};
createDesktopRuntimeHostManagement({
...unusedUpdateDependencies(),
ipcMain: {
handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown),
removeHandler: (channel) => handlers.delete(channel),
},
profiles: {
resolveManagedService: async () => ({ profile, service, state: 'active' as const }),
resolveManagedAccess: async () => undefined,
rotateManagedCredential: async () => assert.fail('credential rotation is not expected'),
markManagedServiceUninstalling: async (binding) => binding,
markManagedServiceCleanupPending: async (binding) => binding,
clearManagedServiceBinding: async () => undefined,
},
runServiceManagement: async () => assert.fail('ordinary management is not expected'),
runUpdatePolicy: async (input) => {
policyInputs.push(input);
const policy = input.policy ?? { kind: 'manual' as const };
return {
schemaVersion: 1,
kind: 'result',
action: 'update_policy',
updateSchedulerState: 'ready',
updatePolicy: {
policy,
...(policy.kind === 'manual' ? {} : { target: input.expectedTarget! }),
},
};
},
runUpdateReconciliation: async (input, onProgress) => {
reconciliationInputs.push(input);
onProgress('replacing');
return {
schemaVersion: 1,
kind: 'result',
action: 'reconcile_update',
updateSchedulerState: 'ready',
updatePolicy: {
policy: { kind: 'channel', channel: 'latest' },
target: {
serviceId: service.id,
rootPath: service.rootPath,
rootId: profile.rootId,
},
},
service: serviceSummary('1.3.0'),
reconciliation: {
kind: 'updated',
previousVersion: '1.2.3',
targetVersion: '1.3.0',
},
};
},
currentHostEpoch: () => 'host-before-update',
awaitUpdatedConnection: async (...args) => {
connections.push(args);
},
sendProgress: (event) => progress.push(event),
runAccessManagement: async () => assert.fail('access management is not expected'),
cleanupManagedDeployment: async () => assert.fail('cleanup is not expected'),
});

const getPolicy = handlers.get('runtime-host-management:get-update-policy');
const setPolicy = handlers.get('runtime-host-management:set-update-policy');
const reconcile = handlers.get('runtime-host-management:reconcile-update');
assert.ok(getPolicy && setPolicy && reconcile);

assert.deepEqual(await getPolicy({}, profile.id), {
policy: { kind: 'manual' },
schedulingState: 'ready',
});
assert.deepEqual(
await setPolicy({}, profile.id, { kind: 'channel', channel: 'latest' }),
{
policy: { kind: 'channel', channel: 'latest' },
target: {
serviceId: service.id,
rootPath: service.rootPath,
rootId: profile.rootId,
},
schedulingState: 'ready',
},
);
await assert.rejects(
setPolicy({}, profile.id, { kind: 'fixed', version: '' }) as Promise<unknown>,
/update policy is invalid/u,
);
for (const policyInput of policyInputs) {
assert.deepEqual(policyInput.expectedTarget, {
serviceId: service.id,
rootPath: service.rootPath,
rootId: profile.rootId,
});
}
assert.deepEqual(reconciliationInputs, []);

const response = await reconcile({}, profile.id);
assert.equal(
(response as { reconciliation?: { kind: string } }).reconciliation?.kind,
'updated',
);
assert.equal(
(response as { updatePolicy?: { schedulingState: string } }).updatePolicy?.schedulingState,
'ready',
);
assert.deepEqual(
(response as { service?: unknown }).service,
serviceSummary('1.3.0'),
);
assert.deepEqual(reconciliationInputs, [{
destination: profile.transport.destination,
operatorPath: service.operatorPath,
expectedTarget: {
serviceId: service.id,
rootPath: service.rootPath,
rootId: profile.rootId,
},
}]);
assert.deepEqual(progress, [{ profileId: profile.id, phase: 'replacing' }]);
assert.deepEqual(connections, [[profile.id, profile.rootId, 'host-before-update', true]]);
});

test('resumes deployment cleanup without invoking the removed operator', async () => {
const handlers = new Map<string, (...args: unknown[]) => unknown>();
const profile = {
Expand Down Expand Up @@ -603,9 +756,25 @@ function serviceResult(
: { ...result, action };
}

function serviceSummary(installedVersion: string) {
return {
platform: 'linux',
arch: 'x64',
osRelease: '6.8.0',
state: 'running' as const,
pid: 42,
lastExitCode: 0,
installedVersion,
projectDirectoryRoots: [],
};
}

function unusedUpdateDependencies() {
return {
runUpdate: async (): Promise<never> => assert.fail('update is not expected'),
runUpdatePolicy: async (): Promise<never> => assert.fail('update policy is not expected'),
runUpdateReconciliation: async (): Promise<never> =>
assert.fail('update reconciliation is not expected'),
resolveUpdatePackage: () => ({ kind: 'npm', specifier: 'maka-agent@1.2.3' } as const),
currentHostEpoch: () => undefined,
awaitUpdatedConnection: async () => undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ test('finishes Host pairing after the cancellable SSH phase has completed', asyn
while (!pairingStarted) await Promise.resolve();

finishPairing({ profileId: 'office' });
assert.deepEqual(await setup, { kind: 'complete', profileId: 'office', revision: 3 });
assert.deepEqual(await setup, { kind: 'complete', profileId: 'office', revision: 4 });
await harness.onboarding.close();
});

Expand Down
126 changes: 126 additions & 0 deletions apps/desktop/src/main/__tests__/runtime-host-setup-package.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/*
* 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 { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { test } from 'node:test';
import { createRuntimeHostSetupPackageResolver } from '../runtime-host-setup-package.js';

test('development setup lazily builds one local CLI archive unless explicitly overridden', async () => {
const repoRoot = resolve('/workspace');
const archive = join(repoRoot, 'packages', 'cli', 'release', 'maka-agent-dev.tgz');
let builds = 0;
let closes = 0;
const resolvePackage = createRuntimeHostSetupPackageResolver({
isPackaged: false,
appPath: join(repoRoot, 'apps', 'desktop'),
environment: {},
startDevelopmentArchiveBuild: (resolvedRoot) => {
builds += 1;
assert.equal(resolvedRoot, repoRoot);
return {
result: Promise.resolve(archive),
close: async () => {
closes += 1;
},
};
},
});

assert.deepEqual(await Promise.all([resolvePackage.resolve(), resolvePackage.resolve()]), [
{
kind: 'development_archive',
path: archive,
},
{
kind: 'development_archive',
path: archive,
},
]);
assert.equal(builds, 1);

const override = join(tmpdir(), 'explicit.tgz');
const resolveOverride = createRuntimeHostSetupPackageResolver({
isPackaged: false,
appPath: join(repoRoot, 'apps', 'desktop'),
environment: { MAKA_RUNTIME_HOST_SETUP_ARCHIVE: override },
startDevelopmentArchiveBuild: () => assert.fail('override must bypass the local build'),
});
assert.deepEqual(await resolveOverride.resolve(), {
kind: 'development_archive',
path: override,
});
await Promise.all([resolvePackage.close(), resolveOverride.close()]);
assert.equal(closes, 1);
});

test('cancelling the last waiter closes its build before a new setup starts', async () => {
const cancelled = new AbortController();
let builds = 0;
let rejectBuild!: (error: Error) => void;
let releaseClose!: () => void;
let signalClose!: () => void;
let closes = 0;
const closeStarted = new Promise<void>((resolveClose) => {
signalClose = resolveClose;
});
const closeBarrier = new Promise<void>((resolveClose) => {
releaseClose = resolveClose;
});
const resolver = createRuntimeHostSetupPackageResolver({
isPackaged: false,
appPath: '/workspace/apps/desktop',
environment: {},
startDevelopmentArchiveBuild: () => {
builds += 1;
if (builds > 1) {
return { result: Promise.resolve('/workspace/fresh.tgz'), close: async () => undefined };
}
return {
result: new Promise((_resolve, reject) => {
rejectBuild = reject;
}),
close: async () => {
closes += 1;
signalClose();
await closeBarrier;
rejectBuild(new Error('build stopped'));
},
};
},
});

const first = resolver.resolve(cancelled.signal);
cancelled.abort(new Error('setup cancelled'));
await closeStarted;
const second = resolver.resolve();
await Promise.resolve();
assert.equal(builds, 1);

releaseClose();
await assert.rejects(first, /setup cancelled/u);
assert.deepEqual(await second, {
kind: 'development_archive',
path: '/workspace/fresh.tgz',
});
assert.equal(builds, 2);
assert.equal(closes, 1);
await resolver.close();
});
Loading