feat: add instance lifecycle management with restart, inspect, and CLI commands
Adds restart/inspect methods to InstanceService, state validation for stop, REST endpoints for restart and inspect, and full CLI command suite for instance list/start/stop/restart/remove/logs/inspect. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
123
src/cli/src/commands/instances.ts
Normal file
123
src/cli/src/commands/instances.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Command } from 'commander';
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
|
||||
interface Instance {
|
||||
id: string;
|
||||
serverId: string;
|
||||
status: string;
|
||||
containerId: string | null;
|
||||
port: number | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface InstanceCommandDeps {
|
||||
client: ApiClient;
|
||||
log: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export function createInstanceCommands(deps: InstanceCommandDeps): Command {
|
||||
const { client, log } = deps;
|
||||
const cmd = new Command('instance')
|
||||
.alias('instances')
|
||||
.alias('inst')
|
||||
.description('Manage MCP server instances');
|
||||
|
||||
cmd
|
||||
.command('list')
|
||||
.alias('ls')
|
||||
.description('List running instances')
|
||||
.option('-s, --server <id>', 'Filter by server ID')
|
||||
.option('-o, --output <format>', 'Output format (table, json)', 'table')
|
||||
.action(async (opts: { server?: string; output: string }) => {
|
||||
let url = '/api/v1/instances';
|
||||
if (opts.server) {
|
||||
url += `?serverId=${encodeURIComponent(opts.server)}`;
|
||||
}
|
||||
const instances = await client.get<Instance[]>(url);
|
||||
if (opts.output === 'json') {
|
||||
log(JSON.stringify(instances, null, 2));
|
||||
return;
|
||||
}
|
||||
if (instances.length === 0) {
|
||||
log('No instances found.');
|
||||
return;
|
||||
}
|
||||
log('ID\tSERVER\tSTATUS\tPORT\tCONTAINER');
|
||||
for (const inst of instances) {
|
||||
const cid = inst.containerId ? inst.containerId.slice(0, 12) : '-';
|
||||
const port = inst.port ?? '-';
|
||||
log(`${inst.id}\t${inst.serverId}\t${inst.status}\t${port}\t${cid}`);
|
||||
}
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('start <serverId>')
|
||||
.description('Start a new MCP server instance')
|
||||
.option('-p, --port <port>', 'Host port to bind')
|
||||
.option('-o, --output <format>', 'Output format (table, json)', 'table')
|
||||
.action(async (serverId: string, opts: { port?: string; output: string }) => {
|
||||
const body: Record<string, unknown> = { serverId };
|
||||
if (opts.port !== undefined) {
|
||||
body.hostPort = parseInt(opts.port, 10);
|
||||
}
|
||||
const instance = await client.post<Instance>('/api/v1/instances', body);
|
||||
if (opts.output === 'json') {
|
||||
log(JSON.stringify(instance, null, 2));
|
||||
return;
|
||||
}
|
||||
log(`Instance ${instance.id} started (status: ${instance.status})`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('stop <id>')
|
||||
.description('Stop a running instance')
|
||||
.action(async (id: string) => {
|
||||
const instance = await client.post<Instance>(`/api/v1/instances/${id}/stop`);
|
||||
log(`Instance ${id} stopped (status: ${instance.status})`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('restart <id>')
|
||||
.description('Restart an instance (stop, remove, start fresh)')
|
||||
.action(async (id: string) => {
|
||||
const instance = await client.post<Instance>(`/api/v1/instances/${id}/restart`);
|
||||
log(`Instance restarted as ${instance.id} (status: ${instance.status})`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('remove <id>')
|
||||
.alias('rm')
|
||||
.description('Remove an instance and its container')
|
||||
.action(async (id: string) => {
|
||||
await client.delete(`/api/v1/instances/${id}`);
|
||||
log(`Instance ${id} removed.`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('logs <id>')
|
||||
.description('Get logs from an instance')
|
||||
.option('-t, --tail <lines>', 'Number of lines to show')
|
||||
.action(async (id: string, opts: { tail?: string }) => {
|
||||
let url = `/api/v1/instances/${id}/logs`;
|
||||
if (opts.tail) {
|
||||
url += `?tail=${opts.tail}`;
|
||||
}
|
||||
const logs = await client.get<{ stdout: string; stderr: string }>(url);
|
||||
if (logs.stdout) {
|
||||
log(logs.stdout);
|
||||
}
|
||||
if (logs.stderr) {
|
||||
process.stderr.write(logs.stderr);
|
||||
}
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('inspect <id>')
|
||||
.description('Get detailed container info for an instance')
|
||||
.action(async (id: string) => {
|
||||
const info = await client.get(`/api/v1/instances/${id}/inspect`);
|
||||
log(JSON.stringify(info, null, 2));
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { createConfigCommand } from './commands/config.js';
|
||||
import { createStatusCommand } from './commands/status.js';
|
||||
import { createGetCommand } from './commands/get.js';
|
||||
import { createDescribeCommand } from './commands/describe.js';
|
||||
import { createInstanceCommands } from './commands/instances.js';
|
||||
import { ApiClient } from './api-client.js';
|
||||
import { loadConfig } from './config/index.js';
|
||||
|
||||
@@ -46,6 +47,11 @@ export function createProgram(): Command {
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
|
||||
program.addCommand(createInstanceCommands({
|
||||
client,
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
|
||||
127
src/cli/tests/commands/instances.test.ts
Normal file
127
src/cli/tests/commands/instances.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createInstanceCommands } from '../../src/commands/instances.js';
|
||||
import type { ApiClient } from '../../src/api-client.js';
|
||||
|
||||
function mockClient(): ApiClient {
|
||||
return {
|
||||
get: vi.fn(async () => []),
|
||||
post: vi.fn(async () => ({})),
|
||||
put: vi.fn(async () => ({})),
|
||||
delete: vi.fn(async () => {}),
|
||||
} as unknown as ApiClient;
|
||||
}
|
||||
|
||||
describe('instance commands', () => {
|
||||
let client: ReturnType<typeof mockClient>;
|
||||
let output: string[];
|
||||
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
|
||||
|
||||
beforeEach(() => {
|
||||
client = mockClient();
|
||||
output = [];
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('shows no instances message when empty', async () => {
|
||||
const cmd = createInstanceCommands({ client, log });
|
||||
await cmd.parseAsync(['list'], { from: 'user' });
|
||||
expect(output.join('\n')).toContain('No instances found');
|
||||
});
|
||||
|
||||
it('shows instance table', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue([
|
||||
{ id: 'inst-1', serverId: 'srv-1', status: 'RUNNING', containerId: 'ctr-abc123def', port: 3000, createdAt: '2025-01-01' },
|
||||
]);
|
||||
const cmd = createInstanceCommands({ client, log });
|
||||
await cmd.parseAsync(['list'], { from: 'user' });
|
||||
expect(output.join('\n')).toContain('inst-1');
|
||||
expect(output.join('\n')).toContain('RUNNING');
|
||||
});
|
||||
|
||||
it('filters by server', async () => {
|
||||
const cmd = createInstanceCommands({ client, log });
|
||||
await cmd.parseAsync(['list', '-s', 'srv-1'], { from: 'user' });
|
||||
expect(client.get).toHaveBeenCalledWith(expect.stringContaining('serverId=srv-1'));
|
||||
});
|
||||
|
||||
it('outputs json', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue([{ id: 'inst-1' }]);
|
||||
const cmd = createInstanceCommands({ client, log });
|
||||
await cmd.parseAsync(['list', '-o', 'json'], { from: 'user' });
|
||||
expect(output[0]).toContain('"id"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('start', () => {
|
||||
it('starts an instance', async () => {
|
||||
vi.mocked(client.post).mockResolvedValue({ id: 'inst-new', status: 'RUNNING' });
|
||||
const cmd = createInstanceCommands({ client, log });
|
||||
await cmd.parseAsync(['start', 'srv-1'], { from: 'user' });
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/instances', { serverId: 'srv-1' });
|
||||
expect(output.join('\n')).toContain('started');
|
||||
});
|
||||
|
||||
it('passes host port', async () => {
|
||||
vi.mocked(client.post).mockResolvedValue({ id: 'inst-new', status: 'RUNNING' });
|
||||
const cmd = createInstanceCommands({ client, log });
|
||||
await cmd.parseAsync(['start', 'srv-1', '-p', '8080'], { from: 'user' });
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/instances', { serverId: 'srv-1', hostPort: 8080 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('stop', () => {
|
||||
it('stops an instance', async () => {
|
||||
vi.mocked(client.post).mockResolvedValue({ id: 'inst-1', status: 'STOPPED' });
|
||||
const cmd = createInstanceCommands({ client, log });
|
||||
await cmd.parseAsync(['stop', 'inst-1'], { from: 'user' });
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/instances/inst-1/stop');
|
||||
expect(output.join('\n')).toContain('stopped');
|
||||
});
|
||||
});
|
||||
|
||||
describe('restart', () => {
|
||||
it('restarts an instance', async () => {
|
||||
vi.mocked(client.post).mockResolvedValue({ id: 'inst-2', status: 'RUNNING' });
|
||||
const cmd = createInstanceCommands({ client, log });
|
||||
await cmd.parseAsync(['restart', 'inst-1'], { from: 'user' });
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/instances/inst-1/restart');
|
||||
expect(output.join('\n')).toContain('restarted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('removes an instance', async () => {
|
||||
const cmd = createInstanceCommands({ client, log });
|
||||
await cmd.parseAsync(['remove', 'inst-1'], { from: 'user' });
|
||||
expect(client.delete).toHaveBeenCalledWith('/api/v1/instances/inst-1');
|
||||
expect(output.join('\n')).toContain('removed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logs', () => {
|
||||
it('shows logs', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({ stdout: 'hello world\n', stderr: '' });
|
||||
const cmd = createInstanceCommands({ client, log });
|
||||
await cmd.parseAsync(['logs', 'inst-1'], { from: 'user' });
|
||||
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs');
|
||||
expect(output.join('\n')).toContain('hello world');
|
||||
});
|
||||
|
||||
it('passes tail option', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({ stdout: '', stderr: '' });
|
||||
const cmd = createInstanceCommands({ client, log });
|
||||
await cmd.parseAsync(['logs', 'inst-1', '-t', '50'], { from: 'user' });
|
||||
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs?tail=50');
|
||||
});
|
||||
});
|
||||
|
||||
describe('inspect', () => {
|
||||
it('shows container info as json', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({ containerId: 'ctr-abc', state: 'running' });
|
||||
const cmd = createInstanceCommands({ client, log });
|
||||
await cmd.parseAsync(['inspect', 'inst-1'], { from: 'user' });
|
||||
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/inspect');
|
||||
expect(output[0]).toContain('ctr-abc');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,14 @@ export function registerInstanceRoutes(app: FastifyInstance, service: InstanceSe
|
||||
return service.stop(request.params.id);
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string } }>('/api/v1/instances/:id/restart', async (request) => {
|
||||
return service.restart(request.params.id);
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/v1/instances/:id/inspect', async (request) => {
|
||||
return service.inspect(request.params.id);
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/v1/instances/:id', async (request, reply) => {
|
||||
await service.remove(request.params.id);
|
||||
reply.code(204);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export { McpServerService, NotFoundError, ConflictError } from './mcp-server.service.js';
|
||||
export { McpProfileService } from './mcp-profile.service.js';
|
||||
export { ProjectService } from './project.service.js';
|
||||
export { InstanceService } from './instance.service.js';
|
||||
export { InstanceService, InvalidStateError } from './instance.service.js';
|
||||
export { generateMcpConfig } from './mcp-config-generator.js';
|
||||
export type { McpConfig, McpConfigServer, ProfileWithServer } from './mcp-config-generator.js';
|
||||
export type { McpOrchestrator, ContainerSpec, ContainerInfo, ContainerLogs } from './orchestrator.js';
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import type { McpInstance } from '@prisma/client';
|
||||
import type { IMcpInstanceRepository, IMcpServerRepository } from '../repositories/interfaces.js';
|
||||
import type { McpOrchestrator, ContainerSpec } from './orchestrator.js';
|
||||
import type { McpOrchestrator, ContainerSpec, ContainerInfo } from './orchestrator.js';
|
||||
import { NotFoundError } from './mcp-server.service.js';
|
||||
|
||||
export class InvalidStateError extends Error {
|
||||
readonly statusCode = 409;
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'InvalidStateError';
|
||||
}
|
||||
}
|
||||
|
||||
export class InstanceService {
|
||||
constructor(
|
||||
private instanceRepo: IMcpInstanceRepository,
|
||||
@@ -71,6 +79,9 @@ export class InstanceService {
|
||||
|
||||
async stop(id: string): Promise<McpInstance> {
|
||||
const instance = await this.getById(id);
|
||||
if (instance.status === 'STOPPED') {
|
||||
throw new InvalidStateError(`Instance '${id}' is already stopped`);
|
||||
}
|
||||
if (!instance.containerId) {
|
||||
return this.instanceRepo.updateStatus(id, 'STOPPED');
|
||||
}
|
||||
@@ -87,6 +98,37 @@ export class InstanceService {
|
||||
}
|
||||
}
|
||||
|
||||
async restart(id: string): Promise<McpInstance> {
|
||||
const instance = await this.getById(id);
|
||||
|
||||
// Stop if running
|
||||
if (instance.containerId && (instance.status === 'RUNNING' || instance.status === 'STARTING')) {
|
||||
try {
|
||||
await this.orchestrator.stopContainer(instance.containerId);
|
||||
} catch {
|
||||
// Container may already be stopped
|
||||
}
|
||||
try {
|
||||
await this.orchestrator.removeContainer(instance.containerId, true);
|
||||
} catch {
|
||||
// Container may already be gone
|
||||
}
|
||||
}
|
||||
|
||||
await this.instanceRepo.delete(id);
|
||||
|
||||
// Start a fresh instance for the same server
|
||||
return this.start(instance.serverId);
|
||||
}
|
||||
|
||||
async inspect(id: string): Promise<ContainerInfo> {
|
||||
const instance = await this.getById(id);
|
||||
if (!instance.containerId) {
|
||||
throw new InvalidStateError(`Instance '${id}' has no container`);
|
||||
}
|
||||
return this.orchestrator.inspectContainer(instance.containerId);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const instance = await this.getById(id);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { InstanceService } from '../src/services/instance.service.js';
|
||||
import { InstanceService, InvalidStateError } from '../src/services/instance.service.js';
|
||||
import { NotFoundError } from '../src/services/mcp-server.service.js';
|
||||
import type { IMcpInstanceRepository, IMcpServerRepository } from '../src/repositories/interfaces.js';
|
||||
import type { McpOrchestrator } from '../src/services/orchestrator.js';
|
||||
@@ -195,6 +195,83 @@ describe('InstanceService', () => {
|
||||
expect(orchestrator.stopContainer).not.toHaveBeenCalled();
|
||||
expect(instanceRepo.updateStatus).toHaveBeenCalledWith('inst-1', 'STOPPED');
|
||||
});
|
||||
|
||||
it('throws InvalidStateError when already stopped', async () => {
|
||||
vi.mocked(instanceRepo.findById).mockResolvedValue({
|
||||
id: 'inst-1', containerId: 'ctr-abc', status: 'STOPPED',
|
||||
serverId: 'srv-1', port: null, metadata: {},
|
||||
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await expect(service.stop('inst-1')).rejects.toThrow(InvalidStateError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restart', () => {
|
||||
it('stops, removes, and starts a new instance', async () => {
|
||||
vi.mocked(instanceRepo.findById).mockResolvedValue({
|
||||
id: 'inst-1', containerId: 'ctr-abc', status: 'RUNNING',
|
||||
serverId: 'srv-1', port: 3000, metadata: {},
|
||||
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
vi.mocked(serverRepo.findById).mockResolvedValue({
|
||||
id: 'srv-1', name: 'slack', dockerImage: 'slack:latest',
|
||||
packageName: null, transport: 'STDIO', description: '', repositoryUrl: null,
|
||||
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.restart('inst-1');
|
||||
|
||||
expect(orchestrator.stopContainer).toHaveBeenCalledWith('ctr-abc');
|
||||
expect(orchestrator.removeContainer).toHaveBeenCalledWith('ctr-abc', true);
|
||||
expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1');
|
||||
expect(instanceRepo.create).toHaveBeenCalled();
|
||||
expect(result.status).toBe('RUNNING');
|
||||
});
|
||||
|
||||
it('handles restart when container already stopped', async () => {
|
||||
vi.mocked(instanceRepo.findById).mockResolvedValue({
|
||||
id: 'inst-1', containerId: 'ctr-abc', status: 'STOPPED',
|
||||
serverId: 'srv-1', port: null, metadata: {},
|
||||
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
vi.mocked(serverRepo.findById).mockResolvedValue({
|
||||
id: 'srv-1', name: 'slack', dockerImage: 'slack:latest',
|
||||
packageName: null, transport: 'STDIO', description: '', repositoryUrl: null,
|
||||
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.restart('inst-1');
|
||||
|
||||
// Should not try to stop an already-stopped container
|
||||
expect(orchestrator.stopContainer).not.toHaveBeenCalled();
|
||||
expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1');
|
||||
expect(result.status).toBe('RUNNING');
|
||||
});
|
||||
});
|
||||
|
||||
describe('inspect', () => {
|
||||
it('returns container info', async () => {
|
||||
vi.mocked(instanceRepo.findById).mockResolvedValue({
|
||||
id: 'inst-1', containerId: 'ctr-abc', status: 'RUNNING',
|
||||
serverId: 'srv-1', port: 3000, metadata: {},
|
||||
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.inspect('inst-1');
|
||||
expect(orchestrator.inspectContainer).toHaveBeenCalledWith('ctr-abc');
|
||||
expect(result.containerId).toBe('ctr-abc123');
|
||||
});
|
||||
|
||||
it('throws InvalidStateError when no container', async () => {
|
||||
vi.mocked(instanceRepo.findById).mockResolvedValue({
|
||||
id: 'inst-1', containerId: null, status: 'ERROR',
|
||||
serverId: 'srv-1', port: null, metadata: {},
|
||||
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await expect(service.inspect('inst-1')).rejects.toThrow(InvalidStateError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
|
||||
Reference in New Issue
Block a user