feat: store backup config in DB secret instead of env var

Move backup SSH keys and repo URL from MCPD_BACKUP_REPO env var to a
"backup-ssh" secret in the database. Keys are auto-generated on first
init and stored back into the secret. Also fix ERR_HTTP_HEADERS_SENT
crash caused by reply.send() without return in routes when onSend hook
is registered.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-03-08 13:53:12 +00:00
parent 6bce1431ae
commit af4b3fb702
10 changed files with 193 additions and 136 deletions

View File

@@ -9,6 +9,7 @@ export interface BackupDeps {
interface BackupStatus {
enabled: boolean;
repoUrl: string | null;
publicKey: string | null;
gitReachable: boolean;
lastSyncAt: string | null;
lastPushAt: string | null;
@@ -33,12 +34,12 @@ export function createBackupCommand(deps: BackupDeps): Command {
if (!status.enabled) {
deps.log('Backup: disabled');
deps.log('');
deps.log('To enable, set MCPD_BACKUP_REPO in your mcpd environment:');
deps.log(' 1. Create a bare git repo (e.g. on Gitea, GitHub, or local)');
deps.log(' 2. Add MCPD_BACKUP_REPO=<repo-url> to stack/.env');
deps.log(' 3. Redeploy mcpd (bash deploy.sh)');
deps.log(' 4. Run: mcpctl backup key');
deps.log(' Add the SSH key as a deploy key (with write access) in your git host');
deps.log('To enable, create a backup-ssh secret:');
deps.log(' mcpctl create secret backup-ssh --data repoUrl=ssh://git@host/repo.git');
deps.log('');
deps.log('After creating the secret, restart mcpd. An SSH keypair will be');
deps.log('auto-generated and stored in the secret. Run mcpctl backup to see');
deps.log('the public key, then add it as a deploy key in your git host.');
return;
}
@@ -65,6 +66,10 @@ export function createBackupCommand(deps: BackupDeps): Command {
if (status.lastError) {
deps.log(`Error: ${status.lastError}`);
}
if (status.publicKey) {
deps.log('');
deps.log(`SSH key: ${status.publicKey}`);
}
});
cmd
@@ -109,16 +114,6 @@ export function createBackupCommand(deps: BackupDeps): Command {
}
});
cmd
.command('key')
.description('Show SSH public key for deploy key setup')
.action(async () => {
const { publicKey } = await deps.client.get<{ publicKey: string }>('/api/v1/backup/key');
deps.log(publicKey);
deps.log('');
deps.log('Add this key as a deploy key (with write access) in your Git hosting provider.');
});
// ── Restore subcommand group ──
const restore = new Command('restore')
.description('Restore mcpctl state from backup history');

View File

@@ -73,13 +73,38 @@ describe('backup command', () => {
expect(log).toHaveBeenCalledWith(expect.stringContaining('5 changes pending'));
});
it('shows SSH public key', async () => {
mockClient.get.mockResolvedValue({ publicKey: 'ssh-ed25519 AAAA... mcpd@mcpctl.local' });
it('shows SSH public key in status when enabled', async () => {
mockClient.get.mockResolvedValue({
enabled: true,
repoUrl: 'ssh://git@host/repo.git',
publicKey: 'ssh-ed25519 AAAA... mcpd@mcpctl.local',
gitReachable: true,
lastSyncAt: null,
lastPushAt: null,
lastError: null,
pendingCount: 0,
});
await makeCmd().parseAsync(['key'], { from: 'user' });
await makeCmd().parseAsync([], { from: 'user' });
expect(mockClient.get).toHaveBeenCalledWith('/api/v1/backup/key');
expect(log).toHaveBeenCalledWith('ssh-ed25519 AAAA... mcpd@mcpctl.local');
expect(log).toHaveBeenCalledWith(expect.stringContaining('ssh-ed25519 AAAA... mcpd@mcpctl.local'));
});
it('shows setup instructions when disabled', async () => {
mockClient.get.mockResolvedValue({
enabled: false,
repoUrl: null,
publicKey: null,
gitReachable: false,
lastSyncAt: null,
lastPushAt: null,
lastError: null,
pendingCount: 0,
});
await makeCmd().parseAsync([], { from: 'user' });
expect(log).toHaveBeenCalledWith(expect.stringContaining('mcpctl create secret backup-ssh'));
});
it('shows commit log', async () => {