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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@
"@types/mime-types": "^3.0.1",
"@types/minimist": "^1.2.5",
"@types/ms": "^2.1.0",
"@types/mustache": "^4.2.6",
"@types/node": "^24",
"@types/tar": "^6.1.13",
"@types/tar-fs": "^2.0.4",
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/openshell-gateway-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ export interface OpenshellGatewayStartOptions {
disableTls?: boolean;
/** Skip CLI registration when restarting an already-registered gateway. */
skipRegistration?: boolean;
/** Override the supervisor container image. When unset, the image tag is pinned to the gateway binary version. */
supervisorImage?: string;
}

export interface GatewaySandboxes {
Expand Down
159 changes: 155 additions & 4 deletions packages/main/src/plugin/openshell-cli/openshell-gateway.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,32 @@

import type { ChildProcess } from 'node:child_process';
import { EventEmitter } from 'node:events';
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';

import type { RunResult } from '@openkaiden/api';
import { beforeEach, describe, expect, test, vi } from 'vitest';

import type { CliToolRegistry } from '/@/plugin/cli-tool-registry.js';
import type { Directories } from '/@/plugin/directories.js';
import type { OpenshellCli } from '/@/plugin/openshell-cli/openshell-cli.js';
import type { Proxy } from '/@/plugin/proxy.js';
import { Exec } from '/@/plugin/util/exec.js';
import type { CliToolInfo } from '/@api/cli-tool-info.js';
import type { GatewayInfo } from '/@api/openshell-gateway-info.js';

import { OpenshellGateway } from './openshell-gateway.js';

vi.mock(import('node:child_process'));
vi.mock(import('node:fs/promises'));
vi.mock(import('/@/plugin/util/exec.js'));

const { spawn } = await import('node:child_process');

const GATEWAY_BINARY = '/usr/local/bin/openshell-gateway';
const KAIDEN_DATA_DIRECTORY = '/home/user/.local/share/kaiden';
const GATEWAY_STORAGE_DIRECTORY = join(KAIDEN_DATA_DIRECTORY, 'openshell-gateway');
const GATEWAY_CONFIG_PATH = join(GATEWAY_STORAGE_DIRECTORY, 'gateway.toml');

function createMockChildProcess(): ChildProcess & { _stdout: EventEmitter; _stderr: EventEmitter } {
const proc = new EventEmitter() as ChildProcess & { _stdout: EventEmitter; _stderr: EventEmitter };
Expand All @@ -44,8 +55,13 @@ function createMockChildProcess(): ChildProcess & { _stdout: EventEmitter; _stde
return proc;
}

function mockExecResult(stdout = ''): RunResult {
return { command: GATEWAY_BINARY, stdout, stderr: '' };
}

let gateway: OpenshellGateway;

const exec = new Exec({} as Proxy);
const cliToolRegistry = {
getCliToolInfos: vi.fn(),
} as unknown as CliToolRegistry;
Expand All @@ -57,12 +73,17 @@ const openshellCli = {
addGateway: vi.fn(),
} as unknown as OpenshellCli;

const directories = {
getDataDirectory: vi.fn(),
} as unknown as Directories;

beforeEach(() => {
vi.resetAllMocks();
vi.mocked(cliToolRegistry.getCliToolInfos).mockReturnValue([
{ name: 'openshell-gateway', path: GATEWAY_BINARY },
] as unknown as CliToolInfo[]);
gateway = new OpenshellGateway(cliToolRegistry, openshellCli);
vi.mocked(directories.getDataDirectory).mockReturnValue(KAIDEN_DATA_DIRECTORY);
gateway = new OpenshellGateway(exec, cliToolRegistry, openshellCli, directories);
});

describe('init', () => {
Expand Down Expand Up @@ -101,6 +122,7 @@ describe('init', () => {
const proc = createMockChildProcess();
vi.mocked(spawn).mockReturnValue(proc);
vi.mocked(openshellCli.checkEndpointStatus).mockResolvedValueOnce(false).mockResolvedValue(true);
vi.mocked(exec.exec).mockResolvedValue(mockExecResult('openshell-gateway 0.0.69'));

await gateway.init();

Expand Down Expand Up @@ -144,6 +166,7 @@ describe('init', () => {
const proc = createMockChildProcess();
vi.mocked(spawn).mockReturnValue(proc);
vi.mocked(openshellCli.checkEndpointStatus).mockResolvedValueOnce(false).mockResolvedValue(true);
vi.mocked(exec.exec).mockResolvedValue(mockExecResult('openshell-gateway 0.0.69'));

await gateway.init();

Expand Down Expand Up @@ -179,6 +202,7 @@ describe('init', () => {
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(false)
.mockResolvedValue(true);
vi.mocked(exec.exec).mockResolvedValue(mockExecResult('openshell-gateway 0.0.69'));

await gateway.init();

Expand Down Expand Up @@ -210,6 +234,7 @@ describe('init', () => {
const proc = createMockChildProcess();
vi.mocked(spawn).mockReturnValue(proc);
vi.mocked(openshellCli.checkEndpointStatus).mockResolvedValueOnce(false).mockResolvedValue(true);
vi.mocked(exec.exec).mockResolvedValue(mockExecResult('openshell-gateway 0.0.69'));

await gateway.init();

Expand All @@ -234,13 +259,14 @@ describe('start', () => {
vi.spyOn(console, 'log').mockImplementation(() => undefined);
const proc = createMockChildProcess();
vi.mocked(spawn).mockReturnValue(proc);
vi.mocked(exec.exec).mockResolvedValue(mockExecResult('openshell-gateway 0.0.69'));
vi.mocked(openshellCli.checkEndpointStatus).mockResolvedValue(true);

await gateway.start();

expect(spawn).toHaveBeenCalledWith(
GATEWAY_BINARY,
['--port', '17670', '--bind-address', '127.0.0.1', '--disable-tls'],
['--config', GATEWAY_CONFIG_PATH, '--port', '17670', '--bind-address', '127.0.0.1', '--disable-tls'],
expect.objectContaining({ detached: false }),
);
});
Expand All @@ -249,13 +275,14 @@ describe('start', () => {
vi.spyOn(console, 'log').mockImplementation(() => undefined);
const proc = createMockChildProcess();
vi.mocked(spawn).mockReturnValue(proc);
vi.mocked(exec.exec).mockResolvedValue(mockExecResult('openshell-gateway 0.0.69'));
vi.mocked(openshellCli.checkEndpointStatus).mockResolvedValue(true);

await gateway.start({ port: 9999, bindAddress: '0.0.0.0' });

expect(spawn).toHaveBeenCalledWith(
GATEWAY_BINARY,
['--port', '9999', '--bind-address', '0.0.0.0', '--disable-tls'],
['--config', GATEWAY_CONFIG_PATH, '--port', '9999', '--bind-address', '0.0.0.0', '--disable-tls'],
expect.objectContaining({ detached: false }),
);
});
Expand All @@ -264,13 +291,14 @@ describe('start', () => {
vi.spyOn(console, 'log').mockImplementation(() => undefined);
const proc = createMockChildProcess();
vi.mocked(spawn).mockReturnValue(proc);
vi.mocked(exec.exec).mockResolvedValue(mockExecResult('openshell-gateway 0.0.69'));
vi.mocked(openshellCli.checkEndpointStatus).mockResolvedValue(true);

await gateway.start({ disableTls: false });

expect(spawn).toHaveBeenCalledWith(
GATEWAY_BINARY,
['--port', '17670', '--bind-address', '127.0.0.1'],
['--config', GATEWAY_CONFIG_PATH, '--port', '17670', '--bind-address', '127.0.0.1'],
expect.objectContaining({ detached: false }),
);
});
Expand All @@ -279,6 +307,7 @@ describe('start', () => {
vi.spyOn(console, 'log').mockImplementation(() => undefined);
const proc = createMockChildProcess();
vi.mocked(spawn).mockReturnValue(proc);
vi.mocked(exec.exec).mockResolvedValue(mockExecResult('openshell-gateway 0.0.69'));
vi.mocked(openshellCli.checkEndpointStatus).mockResolvedValue(true);

await gateway.start();
Expand All @@ -297,6 +326,7 @@ describe('start', () => {
vi.spyOn(console, 'log').mockImplementation(() => undefined);
const proc = createMockChildProcess();
vi.mocked(spawn).mockReturnValue(proc);
vi.mocked(exec.exec).mockResolvedValue(mockExecResult('openshell-gateway 0.0.69'));
vi.mocked(openshellCli.checkEndpointStatus).mockResolvedValue(true);

await gateway.start();
Expand All @@ -308,6 +338,7 @@ describe('start', () => {
vi.spyOn(console, 'log').mockImplementation(() => undefined);
const proc = createMockChildProcess();
vi.mocked(spawn).mockReturnValue(proc);
vi.mocked(exec.exec).mockResolvedValue(mockExecResult('openshell-gateway 0.0.69'));
vi.mocked(openshellCli.checkEndpointStatus).mockResolvedValue(true);

await gateway.start();
Expand All @@ -323,6 +354,7 @@ describe('start', () => {
vi.spyOn(console, 'log').mockImplementation(() => undefined);
const proc = createMockChildProcess();
vi.mocked(spawn).mockReturnValue(proc);
vi.mocked(exec.exec).mockResolvedValue(mockExecResult('openshell-gateway 0.0.69'));
vi.mocked(openshellCli.checkEndpointStatus).mockResolvedValue(true);

await gateway.start({ skipRegistration: true });
Expand All @@ -337,6 +369,7 @@ describe('start', () => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const proc = createMockChildProcess();
vi.mocked(spawn).mockReturnValue(proc);
vi.mocked(exec.exec).mockRejectedValue(new Error('command not found'));
vi.mocked(openshellCli.checkEndpointStatus).mockResolvedValue(false);

let caughtError: unknown;
Expand Down Expand Up @@ -364,6 +397,7 @@ describe('stop', () => {
vi.spyOn(console, 'log').mockImplementation(() => undefined);
const proc = createMockChildProcess();
vi.mocked(spawn).mockReturnValue(proc);
vi.mocked(exec.exec).mockResolvedValue(mockExecResult('openshell-gateway 0.0.69'));
vi.mocked(openshellCli.checkEndpointStatus).mockResolvedValue(true);

await gateway.start();
Expand All @@ -389,6 +423,7 @@ describe('isRunning', () => {
vi.spyOn(console, 'log').mockImplementation(() => undefined);
const proc = createMockChildProcess();
vi.mocked(spawn).mockReturnValue(proc);
vi.mocked(exec.exec).mockResolvedValue(mockExecResult('openshell-gateway 0.0.69'));
vi.mocked(openshellCli.checkEndpointStatus).mockResolvedValue(true);

await gateway.start();
Expand All @@ -402,6 +437,7 @@ describe('dispose', () => {
vi.spyOn(console, 'log').mockImplementation(() => undefined);
const proc = createMockChildProcess();
vi.mocked(spawn).mockReturnValue(proc);
vi.mocked(exec.exec).mockResolvedValue(mockExecResult('openshell-gateway 0.0.69'));
vi.mocked(openshellCli.checkEndpointStatus).mockResolvedValue(true);

await gateway.start();
Expand All @@ -411,3 +447,118 @@ describe('dispose', () => {
expect(proc.kill).toHaveBeenCalledWith('SIGTERM');
});
});

describe('gateway config generation', () => {
let proc: ReturnType<typeof createMockChildProcess>;

beforeEach(() => {
vi.spyOn(console, 'log').mockImplementation(() => undefined);
proc = createMockChildProcess();
vi.mocked(spawn).mockReturnValue(proc);
vi.mocked(openshellCli.checkEndpointStatus).mockResolvedValue(true);
});

test('writes gateway config under the kaiden data directory', async () => {
vi.mocked(exec.exec).mockResolvedValue(mockExecResult('openshell-gateway 0.0.69'));

await gateway.start();

expect(mkdir).toHaveBeenCalledWith(GATEWAY_STORAGE_DIRECTORY, { recursive: true });
expect(writeFile).toHaveBeenCalledWith(
GATEWAY_CONFIG_PATH,
expect.stringContaining('[openshell.drivers.podman]'),
'utf-8',
);
});

test('config only includes podman driver section', async () => {
vi.mocked(exec.exec).mockResolvedValue(mockExecResult('openshell-gateway 0.0.69'));

await gateway.start();

const writtenContent = vi.mocked(writeFile).mock.calls[0]?.[1] as string;
expect(writtenContent).toContain('[openshell.drivers.podman]');
expect(writtenContent).not.toContain('[openshell.drivers.docker]');
});

test('pins supervisor image to detected gateway version', async () => {
vi.mocked(exec.exec).mockResolvedValue(mockExecResult('openshell-gateway 0.0.69'));

await gateway.start();

expect(exec.exec).toHaveBeenCalledWith(GATEWAY_BINARY, ['--version']);
expect(writeFile).toHaveBeenCalledWith(
GATEWAY_CONFIG_PATH,
expect.stringContaining('supervisor_image = "ghcr.io/nvidia/openshell/supervisor:0.0.69"'),
'utf-8',
);
});

test('passes --config flag to spawned gateway process', async () => {
vi.mocked(exec.exec).mockResolvedValue(mockExecResult('openshell-gateway 0.0.69'));

await gateway.start();

expect(spawn).toHaveBeenCalledWith(
GATEWAY_BINARY,
expect.arrayContaining(['--config', GATEWAY_CONFIG_PATH]),
expect.objectContaining({ detached: false }),
);
});

test('uses custom supervisorImage without version detection', async () => {
await gateway.start({ supervisorImage: 'my-registry.io/supervisor:custom' });

expect(exec.exec).not.toHaveBeenCalledWith(GATEWAY_BINARY, ['--version']);
expect(writeFile).toHaveBeenCalledWith(
GATEWAY_CONFIG_PATH,
expect.stringContaining('supervisor_image = "my-registry.io/supervisor:custom"'),
'utf-8',
);
});

test('still generates config when version detection fails', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
vi.mocked(exec.exec).mockRejectedValue(new Error('command not found'));

await gateway.start();

expect(writeFile).toHaveBeenCalledWith(
GATEWAY_CONFIG_PATH,
expect.not.stringContaining('supervisor_image'),
'utf-8',
);
expect(spawn).toHaveBeenCalledWith(
GATEWAY_BINARY,
expect.arrayContaining(['--config', GATEWAY_CONFIG_PATH]),
expect.objectContaining({ detached: false }),
);
});

test('still generates config when version output is unparseable', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
vi.mocked(exec.exec).mockResolvedValue(mockExecResult('unknown-format'));

await gateway.start();

expect(writeFile).toHaveBeenCalledWith(
GATEWAY_CONFIG_PATH,
expect.not.stringContaining('supervisor_image'),
'utf-8',
);
});

test('starts without --config when writeFile fails', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
vi.mocked(exec.exec).mockResolvedValue(mockExecResult('openshell-gateway 0.0.69'));
vi.mocked(writeFile).mockRejectedValueOnce(new Error('permission denied'));

await gateway.start();

expect(spawn).toHaveBeenCalledWith(
GATEWAY_BINARY,
['--port', '17670', '--bind-address', '127.0.0.1', '--disable-tls'],
expect.objectContaining({ detached: false }),
);
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[openshell]
version = 1

[openshell.drivers.podman]
{{#supervisorImage}}
supervisor_image = "{{{supervisorImage}}}"
{{/supervisorImage}}
Loading
Loading