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:
@@ -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