feat: kubectl-style CLI + Deployment/Pod model for servers/instances
Server = Deployment (defines what to run + desired replicas) Instance = Pod (ephemeral, auto-created by reconciliation) Backend: - Add replicas field to McpServer schema - Add reconcile() to InstanceService (scales instances to match replicas) - Remove manual start/stop/restart - instances are auto-managed - Cascade: deleting server stops all containers then cascades DB - Server create/update auto-triggers reconciliation CLI: - Add top-level delete command (servers, instances, profiles, projects) - Add top-level logs command - Remove instance compound command (use get/delete/logs instead) - Clean up project command (list/show/delete → top-level get/describe/delete) - Enhance describe for instances with container inspect info - Add replicas to apply command's ServerSpec Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -28,7 +28,103 @@ export class InstanceService {
|
||||
return instance;
|
||||
}
|
||||
|
||||
async start(serverId: string, opts?: { env?: Record<string, string>; hostPort?: number }): Promise<McpInstance> {
|
||||
/**
|
||||
* Reconcile instances for a server to match desired replica count.
|
||||
* - If fewer running instances than replicas: start new ones
|
||||
* - If more running instances than replicas: remove excess (oldest first)
|
||||
*/
|
||||
async reconcile(serverId: string): Promise<McpInstance[]> {
|
||||
const server = await this.serverRepo.findById(serverId);
|
||||
if (!server) throw new NotFoundError(`McpServer '${serverId}' not found`);
|
||||
|
||||
const instances = await this.instanceRepo.findAll(serverId);
|
||||
const active = instances.filter((i) => i.status === 'RUNNING' || i.status === 'STARTING');
|
||||
const desired = server.replicas;
|
||||
|
||||
if (active.length < desired) {
|
||||
// Scale up
|
||||
const toStart = desired - active.length;
|
||||
for (let i = 0; i < toStart; i++) {
|
||||
await this.startOne(serverId);
|
||||
}
|
||||
} else if (active.length > desired) {
|
||||
// Scale down — remove oldest first
|
||||
const excess = active
|
||||
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||
.slice(0, active.length - desired);
|
||||
for (const inst of excess) {
|
||||
await this.removeOne(inst);
|
||||
}
|
||||
}
|
||||
|
||||
return this.instanceRepo.findAll(serverId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an instance (stop container + delete DB record).
|
||||
* Does NOT reconcile — caller should reconcile after if needed.
|
||||
*/
|
||||
async remove(id: string): Promise<{ serverId: string }> {
|
||||
const instance = await this.getById(id);
|
||||
|
||||
if (instance.containerId) {
|
||||
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);
|
||||
return { serverId: instance.serverId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all instances for a server (used before server deletion).
|
||||
* Stops all containers so Prisma cascade only cleans up DB records.
|
||||
*/
|
||||
async removeAllForServer(serverId: string): Promise<void> {
|
||||
const instances = await this.instanceRepo.findAll(serverId);
|
||||
for (const inst of instances) {
|
||||
if (inst.containerId) {
|
||||
try {
|
||||
await this.orchestrator.stopContainer(inst.containerId);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
try {
|
||||
await this.orchestrator.removeContainer(inst.containerId, true);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 getLogs(id: string, opts?: { tail?: number }): Promise<{ stdout: string; stderr: string }> {
|
||||
const instance = await this.getById(id);
|
||||
if (!instance.containerId) {
|
||||
return { stdout: '', stderr: '' };
|
||||
}
|
||||
|
||||
return this.orchestrator.getContainerLogs(instance.containerId, opts);
|
||||
}
|
||||
|
||||
/** Start a single instance for a server. */
|
||||
private async startOne(serverId: string): Promise<McpInstance> {
|
||||
const server = await this.serverRepo.findById(serverId);
|
||||
if (!server) throw new NotFoundError(`McpServer '${serverId}' not found`);
|
||||
|
||||
@@ -43,7 +139,6 @@ export class InstanceService {
|
||||
|
||||
const image = server.dockerImage ?? server.packageName ?? server.name;
|
||||
|
||||
// Create DB record first in STARTING state
|
||||
let instance = await this.instanceRepo.create({
|
||||
serverId,
|
||||
status: 'STARTING',
|
||||
@@ -53,7 +148,7 @@ export class InstanceService {
|
||||
const spec: ContainerSpec = {
|
||||
image,
|
||||
name: `mcpctl-${server.name}-${instance.id}`,
|
||||
hostPort: opts?.hostPort ?? null,
|
||||
hostPort: null,
|
||||
labels: {
|
||||
'mcpctl.server-id': serverId,
|
||||
'mcpctl.instance-id': instance.id,
|
||||
@@ -66,9 +161,6 @@ export class InstanceService {
|
||||
if (command) {
|
||||
spec.command = command;
|
||||
}
|
||||
if (opts?.env) {
|
||||
spec.env = opts.env;
|
||||
}
|
||||
|
||||
const containerInfo = await this.orchestrator.createContainer(spec);
|
||||
|
||||
@@ -81,7 +173,6 @@ export class InstanceService {
|
||||
|
||||
instance = await this.instanceRepo.updateStatus(instance.id, 'RUNNING', updateFields);
|
||||
} catch (err) {
|
||||
// Mark as ERROR if container creation fails
|
||||
instance = await this.instanceRepo.updateStatus(instance.id, 'ERROR', {
|
||||
metadata: { error: err instanceof Error ? err.message : String(err) },
|
||||
});
|
||||
@@ -90,78 +181,16 @@ export class InstanceService {
|
||||
return instance;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
await this.instanceRepo.updateStatus(id, 'STOPPING');
|
||||
|
||||
try {
|
||||
await this.orchestrator.stopContainer(instance.containerId);
|
||||
return await this.instanceRepo.updateStatus(id, 'STOPPED');
|
||||
} catch (err) {
|
||||
return await this.instanceRepo.updateStatus(id, 'ERROR', {
|
||||
metadata: { error: err instanceof Error ? err.message : String(err) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
/** Stop and remove a single instance. */
|
||||
private async removeOne(instance: McpInstance): Promise<void> {
|
||||
if (instance.containerId) {
|
||||
try {
|
||||
await this.orchestrator.stopContainer(instance.containerId);
|
||||
} catch { /* best-effort */ }
|
||||
try {
|
||||
await this.orchestrator.removeContainer(instance.containerId, true);
|
||||
} catch {
|
||||
// Container may already be gone, proceed with DB cleanup
|
||||
}
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
await this.instanceRepo.delete(id);
|
||||
}
|
||||
|
||||
async getLogs(id: string, opts?: { tail?: number }): Promise<{ stdout: string; stderr: string }> {
|
||||
const instance = await this.getById(id);
|
||||
if (!instance.containerId) {
|
||||
return { stdout: '', stderr: '' };
|
||||
}
|
||||
|
||||
return this.orchestrator.getContainerLogs(instance.containerId, opts);
|
||||
await this.instanceRepo.delete(instance.id);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user