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>
This commit is contained in:
Michal
2026-02-22 18:40:58 +00:00
parent ede9e10990
commit 6d9a9f572c
77 changed files with 1014 additions and 1931 deletions

View File

@@ -4,7 +4,7 @@ import { BackupService } from '../src/services/backup/backup-service.js';
import { RestoreService } from '../src/services/backup/restore-service.js';
import { encrypt, decrypt, isSensitiveKey } from '../src/services/backup/crypto.js';
import { registerBackupRoutes } from '../src/routes/backup.js';
import type { IMcpServerRepository, IMcpProfileRepository } from '../src/repositories/interfaces.js';
import type { IMcpServerRepository, ISecretRepository } from '../src/repositories/interfaces.js';
import type { IProjectRepository } from '../src/repositories/project.repository.js';
// Mock data
@@ -12,19 +12,19 @@ const mockServers = [
{
id: 's1', name: 'github', description: 'GitHub MCP', packageName: '@mcp/github',
dockerImage: null, transport: 'STDIO' as const, repositoryUrl: null,
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
env: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
},
{
id: 's2', name: 'slack', description: 'Slack MCP', packageName: null,
dockerImage: 'mcp/slack:latest', transport: 'SSE' as const, repositoryUrl: null,
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
env: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
},
];
const mockProfiles = [
const mockSecrets = [
{
id: 'p1', name: 'default', serverId: 's1', permissions: ['read'],
envOverrides: { GITHUB_TOKEN: 'ghp_secret123' },
id: 'sec1', name: 'github-secrets',
data: { GITHUB_TOKEN: 'ghp_secret123' },
version: 1, createdAt: new Date(), updatedAt: new Date(),
},
];
@@ -41,19 +41,19 @@ function mockServerRepo(): IMcpServerRepository {
findAll: vi.fn(async () => [...mockServers]),
findById: vi.fn(async (id: string) => mockServers.find((s) => s.id === id) ?? null),
findByName: vi.fn(async (name: string) => mockServers.find((s) => s.name === name) ?? null),
create: vi.fn(async (data) => ({ id: 'new-s', ...data, envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockServers[0])),
create: vi.fn(async (data) => ({ id: 'new-s', ...data, env: [], version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockServers[0])),
update: vi.fn(async (id, data) => ({ ...mockServers.find((s) => s.id === id)!, ...data })),
delete: vi.fn(async () => {}),
};
}
function mockProfileRepo(): IMcpProfileRepository {
function mockSecretRepo(): ISecretRepository {
return {
findAll: vi.fn(async () => [...mockProfiles]),
findById: vi.fn(async (id: string) => mockProfiles.find((p) => p.id === id) ?? null),
findByServerAndName: vi.fn(async () => null),
create: vi.fn(async (data) => ({ id: 'new-p', ...data, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockProfiles[0])),
update: vi.fn(async (id, data) => ({ ...mockProfiles.find((p) => p.id === id)!, ...data })),
findAll: vi.fn(async () => [...mockSecrets]),
findById: vi.fn(async (id: string) => mockSecrets.find((s) => s.id === id) ?? null),
findByName: vi.fn(async (name: string) => mockSecrets.find((s) => s.name === name) ?? null),
create: vi.fn(async (data) => ({ id: 'new-sec', ...data, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockSecrets[0])),
update: vi.fn(async (id, data) => ({ ...mockSecrets.find((s) => s.id === id)!, ...data })),
delete: vi.fn(async () => {}),
};
}
@@ -66,8 +66,6 @@ function mockProjectRepo(): IProjectRepository {
create: vi.fn(async (data) => ({ id: 'new-proj', ...data, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockProjects[0])),
update: vi.fn(async (id, data) => ({ ...mockProjects.find((p) => p.id === id)!, ...data })),
delete: vi.fn(async () => {}),
setProfiles: vi.fn(async () => {}),
getProfileIds: vi.fn(async () => ['p1']),
};
}
@@ -112,7 +110,7 @@ describe('BackupService', () => {
let backupService: BackupService;
beforeEach(() => {
backupService = new BackupService(mockServerRepo(), mockProfileRepo(), mockProjectRepo());
backupService = new BackupService(mockServerRepo(), mockProjectRepo(), mockSecretRepo());
});
it('creates backup with all resources', async () => {
@@ -121,43 +119,43 @@ describe('BackupService', () => {
expect(bundle.version).toBe('1');
expect(bundle.encrypted).toBe(false);
expect(bundle.servers).toHaveLength(2);
expect(bundle.profiles).toHaveLength(1);
expect(bundle.secrets).toHaveLength(1);
expect(bundle.projects).toHaveLength(1);
expect(bundle.servers[0]!.name).toBe('github');
expect(bundle.profiles[0]!.serverName).toBe('github');
expect(bundle.secrets[0]!.name).toBe('github-secrets');
expect(bundle.projects[0]!.name).toBe('my-project');
});
it('filters resources', async () => {
const bundle = await backupService.createBackup({ resources: ['servers'] });
expect(bundle.servers).toHaveLength(2);
expect(bundle.profiles).toHaveLength(0);
expect(bundle.secrets).toHaveLength(0);
expect(bundle.projects).toHaveLength(0);
});
it('encrypts sensitive env values when password provided', async () => {
it('encrypts sensitive secret values when password provided', async () => {
const bundle = await backupService.createBackup({ password: 'test-pass' });
expect(bundle.encrypted).toBe(true);
expect(bundle.encryptedSecrets).toBeDefined();
// The GITHUB_TOKEN should be replaced with placeholder
const overrides = bundle.profiles[0]!.envOverrides as Record<string, string>;
expect(overrides['GITHUB_TOKEN']).toContain('__ENCRYPTED:');
const data = bundle.secrets[0]!.data;
expect(data['GITHUB_TOKEN']).toContain('__ENCRYPTED:');
});
it('handles empty repositories', async () => {
const emptyServerRepo = mockServerRepo();
(emptyServerRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const emptyProfileRepo = mockProfileRepo();
(emptyProfileRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const emptySecretRepo = mockSecretRepo();
(emptySecretRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const emptyProjectRepo = mockProjectRepo();
(emptyProjectRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const service = new BackupService(emptyServerRepo, emptyProfileRepo, emptyProjectRepo);
const service = new BackupService(emptyServerRepo, emptyProjectRepo, emptySecretRepo);
const bundle = await service.createBackup();
expect(bundle.servers).toHaveLength(0);
expect(bundle.profiles).toHaveLength(0);
expect(bundle.secrets).toHaveLength(0);
expect(bundle.projects).toHaveLength(0);
});
});
@@ -165,18 +163,18 @@ describe('BackupService', () => {
describe('RestoreService', () => {
let restoreService: RestoreService;
let serverRepo: IMcpServerRepository;
let profileRepo: IMcpProfileRepository;
let secretRepo: ISecretRepository;
let projectRepo: IProjectRepository;
beforeEach(() => {
serverRepo = mockServerRepo();
profileRepo = mockProfileRepo();
secretRepo = mockSecretRepo();
projectRepo = mockProjectRepo();
// Default: nothing exists yet
(serverRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
(profileRepo.findByServerAndName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
(secretRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
(projectRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
restoreService = new RestoreService(serverRepo, profileRepo, projectRepo);
restoreService = new RestoreService(serverRepo, projectRepo, secretRepo);
});
const validBundle = {
@@ -184,9 +182,9 @@ describe('RestoreService', () => {
mcpctlVersion: '0.1.0',
createdAt: new Date().toISOString(),
encrypted: false,
servers: [{ name: 'github', description: 'GitHub', packageName: null, dockerImage: null, transport: 'STDIO', repositoryUrl: null, envTemplate: [] }],
profiles: [{ name: 'default', serverName: 'github', permissions: ['read'], envOverrides: {} }],
projects: [{ name: 'test-proj', description: 'Test', profileNames: ['default'] }],
servers: [{ name: 'github', description: 'GitHub', packageName: null, dockerImage: null, transport: 'STDIO', repositoryUrl: null, env: [] }],
secrets: [{ name: 'github-secrets', data: { GITHUB_TOKEN: 'ghp_123' } }],
projects: [{ name: 'test-proj', description: 'Test' }],
};
it('validates valid bundle', () => {
@@ -203,11 +201,11 @@ describe('RestoreService', () => {
const result = await restoreService.restore(validBundle);
expect(result.serversCreated).toBe(1);
expect(result.profilesCreated).toBe(1);
expect(result.secretsCreated).toBe(1);
expect(result.projectsCreated).toBe(1);
expect(result.errors).toHaveLength(0);
expect(serverRepo.create).toHaveBeenCalled();
expect(profileRepo.create).toHaveBeenCalled();
expect(secretRepo.create).toHaveBeenCalled();
expect(projectRepo.create).toHaveBeenCalled();
});
@@ -242,17 +240,17 @@ describe('RestoreService', () => {
});
it('restores encrypted bundle with correct password', async () => {
const secrets = { 'profile:default:API_KEY': 'secret-val' };
const encryptedData = { 'secret:github-secrets:GITHUB_TOKEN': 'ghp_decrypted' };
const encBundle = {
...validBundle,
encrypted: true,
encryptedSecrets: encrypt(JSON.stringify(secrets), 'test-pw'),
profiles: [{ ...validBundle.profiles[0]!, envOverrides: { API_KEY: '__ENCRYPTED:profile:default:API_KEY__' } }],
encryptedSecrets: encrypt(JSON.stringify(encryptedData), 'test-pw'),
secrets: [{ name: 'github-secrets', data: { GITHUB_TOKEN: '__ENCRYPTED:secret:github-secrets:GITHUB_TOKEN__' } }],
};
const result = await restoreService.restore(encBundle, { password: 'test-pw' });
expect(result.errors).toHaveLength(0);
expect(result.profilesCreated).toBe(1);
expect(result.secretsCreated).toBe(1);
});
it('fails with wrong decryption password', async () => {
@@ -272,17 +270,17 @@ describe('Backup Routes', () => {
beforeEach(() => {
const sRepo = mockServerRepo();
const pRepo = mockProfileRepo();
const secRepo = mockSecretRepo();
const prRepo = mockProjectRepo();
backupService = new BackupService(sRepo, pRepo, prRepo);
backupService = new BackupService(sRepo, prRepo, secRepo);
const rSRepo = mockServerRepo();
(rSRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
const rPRepo = mockProfileRepo();
(rPRepo.findByServerAndName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
const rSecRepo = mockSecretRepo();
(rSecRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
const rPrRepo = mockProjectRepo();
(rPrRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
restoreService = new RestoreService(rSRepo, rPRepo, rPrRepo);
restoreService = new RestoreService(rSRepo, rPrRepo, rSecRepo);
});
async function buildApp() {
@@ -303,7 +301,7 @@ describe('Backup Routes', () => {
const body = res.json();
expect(body.version).toBe('1');
expect(body.servers).toBeDefined();
expect(body.profiles).toBeDefined();
expect(body.secrets).toBeDefined();
expect(body.projects).toBeDefined();
});