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:
Michal
2026-02-21 05:11:48 +00:00
parent 7c07749580
commit 4d796e2aa7
7 changed files with 386 additions and 3 deletions

View File

@@ -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);

View File

@@ -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';

View File

@@ -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);

View File

@@ -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', () => {