diff --git a/package.json b/package.json index 6544ea685..944331c6e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/api/src/openshell-gateway-info.ts b/packages/api/src/openshell-gateway-info.ts index 1e4f48c90..4a2785f49 100644 --- a/packages/api/src/openshell-gateway-info.ts +++ b/packages/api/src/openshell-gateway-info.ts @@ -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 { diff --git a/packages/main/src/plugin/openshell-cli/openshell-gateway.spec.ts b/packages/main/src/plugin/openshell-cli/openshell-gateway.spec.ts index 8b8d5e1ef..21b346098 100644 --- a/packages/main/src/plugin/openshell-cli/openshell-gateway.spec.ts +++ b/packages/main/src/plugin/openshell-cli/openshell-gateway.spec.ts @@ -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 }; @@ -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; @@ -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', () => { @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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 }), ); }); @@ -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 }), ); }); @@ -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 }), ); }); @@ -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(); @@ -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(); @@ -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(); @@ -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 }); @@ -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; @@ -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(); @@ -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(); @@ -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(); @@ -411,3 +447,118 @@ describe('dispose', () => { expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); }); }); + +describe('gateway config generation', () => { + let proc: ReturnType; + + 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 }), + ); + }); +}); diff --git a/packages/main/src/plugin/openshell-cli/openshell-gateway.toml.template b/packages/main/src/plugin/openshell-cli/openshell-gateway.toml.template new file mode 100644 index 000000000..b3a5ff002 --- /dev/null +++ b/packages/main/src/plugin/openshell-cli/openshell-gateway.toml.template @@ -0,0 +1,7 @@ +[openshell] +version = 1 + +[openshell.drivers.podman] +{{#supervisorImage}} +supervisor_image = "{{{supervisorImage}}}" +{{/supervisorImage}} diff --git a/packages/main/src/plugin/openshell-cli/openshell-gateway.ts b/packages/main/src/plugin/openshell-cli/openshell-gateway.ts index e83e82928..4f93f3d07 100644 --- a/packages/main/src/plugin/openshell-cli/openshell-gateway.ts +++ b/packages/main/src/plugin/openshell-cli/openshell-gateway.ts @@ -18,19 +18,27 @@ import type { ChildProcess } from 'node:child_process'; import { spawn } from 'node:child_process'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; import type { Disposable } from '@openkaiden/api'; import { inject, injectable, preDestroy } from 'inversify'; +import Mustache from 'mustache'; import { CliToolRegistry } from '/@/plugin/cli-tool-registry.js'; +import { Directories } from '/@/plugin/directories.js'; import { OpenshellCli } from '/@/plugin/openshell-cli/openshell-cli.js'; +import { Exec } from '/@/plugin/util/exec.js'; import type { OpenshellGatewayStartOptions } from '/@api/openshell-gateway-info.js'; +import gatewayConfigTemplate from './openshell-gateway.toml.template?raw'; + const DEFAULT_PORT = 17670; const DEFAULT_BIND_ADDRESS = '127.0.0.1'; const HEALTH_CHECK_INTERVAL_MS = 1000; const MAX_HEALTH_CHECK_ATTEMPTS = 30; const STOP_TIMEOUT_MS = 5000; +const SUPERVISOR_IMAGE_BASE = 'ghcr.io/nvidia/openshell/supervisor'; /** * Manages the `openshell-gateway` server binary lifecycle. @@ -47,10 +55,14 @@ export class OpenshellGateway implements Disposable { #bindAddress: string = DEFAULT_BIND_ADDRESS; constructor( + @inject(Exec) + private readonly exec: Exec, @inject(CliToolRegistry) private readonly cliToolRegistry: CliToolRegistry, @inject(OpenshellCli) private readonly openshellCli: OpenshellCli, + @inject(Directories) + private readonly directories: Directories, ) {} async init(): Promise { @@ -130,7 +142,8 @@ export class OpenshellGateway implements Disposable { this.#bindAddress = options.bindAddress; } - const args = this.buildArgs(options?.disableTls ?? true); + const configPath = await this.createGatewayConfig(binaryPath, options?.supervisorImage); + const args = this.buildArgs(options?.disableTls ?? true, configPath); console.log(`[openshell-gateway] starting: ${binaryPath} ${args.join(' ')}`); this.#gatewayProcess = spawn(binaryPath, args, { @@ -207,8 +220,11 @@ export class OpenshellGateway implements Disposable { this.stop().catch((err: unknown) => console.error('[openshell-gateway] failed to stop: ', err)); } - private buildArgs(disableTls: boolean): string[] { + private buildArgs(disableTls: boolean, configPath?: string): string[] { const args: string[] = []; + if (configPath) { + args.push('--config', configPath); + } args.push('--port', String(this.#port)); args.push('--bind-address', this.#bindAddress); if (disableTls) { @@ -217,6 +233,50 @@ export class OpenshellGateway implements Disposable { return args; } + private async getGatewayVersion(binaryPath: string): Promise { + const result = await this.exec.exec(binaryPath, ['--version']); + const output = result.stdout.trim(); + const token = output.split(' ').pop() ?? ''; + const parts = token.split('.'); + if (parts.length !== 3 || parts.some(p => p.length === 0 || !Number.isFinite(Number(p)))) { + throw new Error(`Unable to parse version from: ${output}`); + } + return token; + } + + private async createGatewayConfig(binaryPath: string, supervisorImage?: string): Promise { + try { + let image = supervisorImage; + if (!image) { + try { + const version = await this.getGatewayVersion(binaryPath); + image = `${SUPERVISOR_IMAGE_BASE}:${version}`; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + console.warn(`[openshell-gateway] unable to detect version for supervisor pinning: ${message}`); + } + } + + const storageDirectory = join(this.directories.getDataDirectory(), 'openshell-gateway'); + const configPath = join(storageDirectory, 'gateway.toml'); + const config = Mustache.render(gatewayConfigTemplate, { + supervisorImage: image, + }); + + await mkdir(storageDirectory, { recursive: true }); + await writeFile(configPath, config, 'utf-8'); + if (image) { + console.log(`[openshell-gateway] supervisor image pinned to ${image}`); + } + console.log(`[openshell-gateway] generated local gateway config at ${configPath}`); + return configPath; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + console.warn(`[openshell-gateway] failed to generate gateway config: ${message}`); + return undefined; + } + } + private async waitForReady(): Promise { const endpoint = `http://${this.#bindAddress}:${this.#port}`; console.log(`[openshell-gateway] waiting for server at ${endpoint}`); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d93583eb3..ab792f813 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -270,6 +270,9 @@ importers: '@types/ms': specifier: ^2.1.0 version: 2.1.0 + '@types/mustache': + specifier: ^4.2.6 + version: 4.2.6 '@types/node': specifier: ^24 version: 24.1.0 @@ -2588,6 +2591,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/mustache@4.2.6': + resolution: {integrity: sha512-t+8/QWTAhOFlrF1IVZqKnMRJi84EgkIK5Kh0p2JV4OLywUvCwJPFxbJAl7XAow7DVIHsF+xW9f1MVzg0L6Szjw==} + '@types/node-fetch@2.6.13': resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} @@ -9728,6 +9734,8 @@ snapshots: '@types/ms@2.1.0': {} + '@types/mustache@4.2.6': {} + '@types/node-fetch@2.6.13': dependencies: '@types/node': 24.11.0