Files
mcpctl/src/mcpd/src/services/backup/restore-service.ts
Michal ca02340a4c
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
feat: replace profiles with kubernetes-style secrets
Replace the confused Profile abstraction with a dedicated Secret resource
following Kubernetes conventions. Servers now have env entries with inline
values or secretRef references. Env vars are resolved and passed to
containers at startup (fixes existing gap).

- Add Secret CRUD (model, repo, service, routes, CLI commands)
- Server env: {name, value} or {name, valueFrom: {secretRef: {name, key}}}
- Add env-resolver utility shared by instance startup and config generation
- Remove all profile-related code (models, services, routes, CLI, tests)
- Update backup/restore for secrets instead of profiles
- describe secret masks values by default, --show-values to reveal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:40:58 +00:00

186 lines
6.3 KiB
TypeScript

import type { IMcpServerRepository, ISecretRepository } from '../../repositories/interfaces.js';
import type { IProjectRepository } from '../../repositories/project.repository.js';
import { decrypt } from './crypto.js';
import type { BackupBundle } from './backup-service.js';
export type ConflictStrategy = 'skip' | 'overwrite' | 'fail';
export interface RestoreOptions {
password?: string;
conflictStrategy?: ConflictStrategy;
}
export interface RestoreResult {
serversCreated: number;
serversSkipped: number;
secretsCreated: number;
secretsSkipped: number;
projectsCreated: number;
projectsSkipped: number;
errors: string[];
}
export class RestoreService {
constructor(
private serverRepo: IMcpServerRepository,
private projectRepo: IProjectRepository,
private secretRepo: ISecretRepository,
) {}
validateBundle(bundle: unknown): bundle is BackupBundle {
if (typeof bundle !== 'object' || bundle === null) return false;
const b = bundle as Record<string, unknown>;
return (
typeof b['version'] === 'string' &&
Array.isArray(b['servers']) &&
Array.isArray(b['secrets']) &&
Array.isArray(b['projects'])
);
}
async restore(bundle: BackupBundle, options?: RestoreOptions): Promise<RestoreResult> {
const strategy = options?.conflictStrategy ?? 'skip';
const result: RestoreResult = {
serversCreated: 0,
serversSkipped: 0,
secretsCreated: 0,
secretsSkipped: 0,
projectsCreated: 0,
projectsSkipped: 0,
errors: [],
};
// Decrypt secrets if encrypted
let decryptedSecrets: Record<string, string> = {};
if (bundle.encrypted && bundle.encryptedSecrets) {
if (!options?.password) {
result.errors.push('Backup is encrypted but no password provided');
return result;
}
try {
decryptedSecrets = JSON.parse(decrypt(bundle.encryptedSecrets, options.password)) as Record<string, string>;
} catch {
result.errors.push('Failed to decrypt backup - incorrect password or corrupted data');
return result;
}
}
// Restore encrypted values into secret data
for (const secret of bundle.secrets) {
for (const [key, value] of Object.entries(secret.data)) {
if (typeof value === 'string' && value.startsWith('__ENCRYPTED:') && value.endsWith('__')) {
const secretKey = value.slice(12, -2);
const decrypted = decryptedSecrets[secretKey];
if (decrypted !== undefined) {
secret.data[key] = decrypted;
}
}
}
}
// Restore servers
for (const server of bundle.servers) {
try {
const existing = await this.serverRepo.findByName(server.name);
if (existing) {
if (strategy === 'fail') {
result.errors.push(`Server "${server.name}" already exists`);
return result;
}
if (strategy === 'skip') {
result.serversSkipped++;
continue;
}
// overwrite
const updateData: Parameters<IMcpServerRepository['update']>[1] = {
description: server.description,
transport: server.transport as 'STDIO' | 'SSE' | 'STREAMABLE_HTTP',
};
if (server.packageName) updateData.packageName = server.packageName;
if (server.dockerImage) updateData.dockerImage = server.dockerImage;
if (server.repositoryUrl) updateData.repositoryUrl = server.repositoryUrl;
await this.serverRepo.update(existing.id, updateData);
result.serversCreated++;
continue;
}
const createData: Parameters<IMcpServerRepository['create']>[0] = {
name: server.name,
description: server.description,
transport: server.transport as 'STDIO' | 'SSE' | 'STREAMABLE_HTTP',
replicas: (server as { replicas?: number }).replicas ?? 1,
env: (server.env ?? []) as Array<{ name: string; value?: string; valueFrom?: { secretRef: { name: string; key: string } } }>,
};
if (server.packageName) createData.packageName = server.packageName;
if (server.dockerImage) createData.dockerImage = server.dockerImage;
if (server.repositoryUrl) createData.repositoryUrl = server.repositoryUrl;
const created = await this.serverRepo.create(createData);
result.serversCreated++;
} catch (err) {
result.errors.push(`Failed to restore server "${server.name}": ${err instanceof Error ? err.message : String(err)}`);
}
}
// Restore secrets
for (const secret of bundle.secrets) {
try {
const existing = await this.secretRepo.findByName(secret.name);
if (existing) {
if (strategy === 'fail') {
result.errors.push(`Secret "${secret.name}" already exists`);
return result;
}
if (strategy === 'skip') {
result.secretsSkipped++;
continue;
}
// overwrite
await this.secretRepo.update(existing.id, { data: secret.data });
result.secretsCreated++;
continue;
}
await this.secretRepo.create({
name: secret.name,
data: secret.data,
});
result.secretsCreated++;
} catch (err) {
result.errors.push(`Failed to restore secret "${secret.name}": ${err instanceof Error ? err.message : String(err)}`);
}
}
// Restore projects
for (const project of bundle.projects) {
try {
const existing = await this.projectRepo.findByName(project.name);
if (existing) {
if (strategy === 'fail') {
result.errors.push(`Project "${project.name}" already exists`);
return result;
}
if (strategy === 'skip') {
result.projectsSkipped++;
continue;
}
// overwrite
await this.projectRepo.update(existing.id, { description: project.description });
result.projectsCreated++;
continue;
}
await this.projectRepo.create({
name: project.name,
description: project.description,
ownerId: 'system',
});
result.projectsCreated++;
} catch (err) {
result.errors.push(`Failed to restore project "${project.name}": ${err instanceof Error ? err.message : String(err)}`);
}
}
return result;
}
}