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>
186 lines
6.3 KiB
TypeScript
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;
|
|
}
|
|
}
|