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:
@@ -235,15 +235,12 @@ _mcpctl() {
|
|||||||
backup)
|
backup)
|
||||||
local backup_sub=$(_mcpctl_get_subcmd $subcmd_pos)
|
local backup_sub=$(_mcpctl_get_subcmd $subcmd_pos)
|
||||||
if [[ -z "$backup_sub" ]]; then
|
if [[ -z "$backup_sub" ]]; then
|
||||||
COMPREPLY=($(compgen -W "log key restore help" -- "$cur"))
|
COMPREPLY=($(compgen -W "log restore help" -- "$cur"))
|
||||||
else
|
else
|
||||||
case "$backup_sub" in
|
case "$backup_sub" in
|
||||||
log)
|
log)
|
||||||
COMPREPLY=($(compgen -W "-n --limit -h --help" -- "$cur"))
|
COMPREPLY=($(compgen -W "-n --limit -h --help" -- "$cur"))
|
||||||
;;
|
;;
|
||||||
key)
|
|
||||||
COMPREPLY=($(compgen -W "-h --help" -- "$cur"))
|
|
||||||
;;
|
|
||||||
restore)
|
restore)
|
||||||
COMPREPLY=($(compgen -W "-h --help" -- "$cur"))
|
COMPREPLY=($(compgen -W "-h --help" -- "$cur"))
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -353,9 +353,8 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create promptrequest" -l content-f
|
|||||||
complete -c mcpctl -n "__mcpctl_subcmd_active create promptrequest" -l priority -d 'Priority 1-10 (default: 5, higher = more important)' -x
|
complete -c mcpctl -n "__mcpctl_subcmd_active create promptrequest" -l priority -d 'Priority 1-10 (default: 5, higher = more important)' -x
|
||||||
|
|
||||||
# backup subcommands
|
# backup subcommands
|
||||||
set -l backup_cmds log key restore
|
set -l backup_cmds log restore
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from backup; and not __fish_seen_subcommand_from $backup_cmds" -a log -d 'Show backup commit history'
|
complete -c mcpctl -n "__fish_seen_subcommand_from backup; and not __fish_seen_subcommand_from $backup_cmds" -a log -d 'Show backup commit history'
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from backup; and not __fish_seen_subcommand_from $backup_cmds" -a key -d 'Show SSH public key for deploy key setup'
|
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from backup; and not __fish_seen_subcommand_from $backup_cmds" -a restore -d 'Restore mcpctl state from backup history'
|
complete -c mcpctl -n "__fish_seen_subcommand_from backup; and not __fish_seen_subcommand_from $backup_cmds" -a restore -d 'Restore mcpctl state from backup history'
|
||||||
|
|
||||||
# backup log options
|
# backup log options
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface BackupDeps {
|
|||||||
interface BackupStatus {
|
interface BackupStatus {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
repoUrl: string | null;
|
repoUrl: string | null;
|
||||||
|
publicKey: string | null;
|
||||||
gitReachable: boolean;
|
gitReachable: boolean;
|
||||||
lastSyncAt: string | null;
|
lastSyncAt: string | null;
|
||||||
lastPushAt: string | null;
|
lastPushAt: string | null;
|
||||||
@@ -33,12 +34,12 @@ export function createBackupCommand(deps: BackupDeps): Command {
|
|||||||
if (!status.enabled) {
|
if (!status.enabled) {
|
||||||
deps.log('Backup: disabled');
|
deps.log('Backup: disabled');
|
||||||
deps.log('');
|
deps.log('');
|
||||||
deps.log('To enable, set MCPD_BACKUP_REPO in your mcpd environment:');
|
deps.log('To enable, create a backup-ssh secret:');
|
||||||
deps.log(' 1. Create a bare git repo (e.g. on Gitea, GitHub, or local)');
|
deps.log(' mcpctl create secret backup-ssh --data repoUrl=ssh://git@host/repo.git');
|
||||||
deps.log(' 2. Add MCPD_BACKUP_REPO=<repo-url> to stack/.env');
|
deps.log('');
|
||||||
deps.log(' 3. Redeploy mcpd (bash deploy.sh)');
|
deps.log('After creating the secret, restart mcpd. An SSH keypair will be');
|
||||||
deps.log(' 4. Run: mcpctl backup key');
|
deps.log('auto-generated and stored in the secret. Run mcpctl backup to see');
|
||||||
deps.log(' Add the SSH key as a deploy key (with write access) in your git host');
|
deps.log('the public key, then add it as a deploy key in your git host.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +66,10 @@ export function createBackupCommand(deps: BackupDeps): Command {
|
|||||||
if (status.lastError) {
|
if (status.lastError) {
|
||||||
deps.log(`Error: ${status.lastError}`);
|
deps.log(`Error: ${status.lastError}`);
|
||||||
}
|
}
|
||||||
|
if (status.publicKey) {
|
||||||
|
deps.log('');
|
||||||
|
deps.log(`SSH key: ${status.publicKey}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
cmd
|
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 ──
|
// ── Restore subcommand group ──
|
||||||
const restore = new Command('restore')
|
const restore = new Command('restore')
|
||||||
.description('Restore mcpctl state from backup history');
|
.description('Restore mcpctl state from backup history');
|
||||||
|
|||||||
@@ -73,13 +73,38 @@ describe('backup command', () => {
|
|||||||
expect(log).toHaveBeenCalledWith(expect.stringContaining('5 changes pending'));
|
expect(log).toHaveBeenCalledWith(expect.stringContaining('5 changes pending'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows SSH public key', async () => {
|
it('shows SSH public key in status when enabled', async () => {
|
||||||
mockClient.get.mockResolvedValue({ publicKey: 'ssh-ed25519 AAAA... mcpd@mcpctl.local' });
|
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(expect.stringContaining('ssh-ed25519 AAAA... mcpd@mcpctl.local'));
|
||||||
expect(log).toHaveBeenCalledWith('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 () => {
|
it('shows commit log', async () => {
|
||||||
|
|||||||
@@ -367,7 +367,7 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
reply.code(403).send({ error: 'Forbidden' });
|
return reply.code(403).send({ error: 'Forbidden' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -395,81 +395,78 @@ async function main(): Promise<void> {
|
|||||||
// ── Git-based backup ──
|
// ── Git-based backup ──
|
||||||
const gitBackup = new GitBackupService(prisma);
|
const gitBackup = new GitBackupService(prisma);
|
||||||
|
|
||||||
// Hook: enqueue backup after successful mutations
|
// Hook: enqueue backup after successful mutations (checks enabled at call time)
|
||||||
if (gitBackup.enabled) {
|
const kindFromSegment: Record<string, BackupKind | undefined> = {
|
||||||
const kindFromSegment: Record<string, BackupKind | undefined> = {
|
servers: 'server', secrets: 'secret', projects: 'project',
|
||||||
servers: 'server', secrets: 'secret', projects: 'project',
|
templates: 'template', users: 'user', groups: 'group',
|
||||||
templates: 'template', users: 'user', groups: 'group',
|
rbac: 'rbac', prompts: 'prompt',
|
||||||
rbac: 'rbac', prompts: 'prompt',
|
};
|
||||||
};
|
app.addHook('onSend', async (request, reply, payload) => {
|
||||||
app.addHook('onSend', async (request, reply, payload) => {
|
if (!gitBackup.enabled) return payload;
|
||||||
if (reply.statusCode >= 400) return payload;
|
if (reply.statusCode >= 400) return payload;
|
||||||
const method = request.method;
|
const method = request.method;
|
||||||
if (method === 'GET' || method === 'HEAD') return payload;
|
if (method === 'GET' || method === 'HEAD') return payload;
|
||||||
|
|
||||||
const urlMatch = request.url.match(/^\/api\/v1\/([a-z-]+)(?:\/([^/?]+))?/);
|
const urlMatch = request.url.match(/^\/api\/v1\/([a-z-]+)(?:\/([^/?]+))?/);
|
||||||
if (!urlMatch) return payload;
|
if (!urlMatch) return payload;
|
||||||
const kind = kindFromSegment[urlMatch[1]!];
|
const kind = kindFromSegment[urlMatch[1]!];
|
||||||
if (!kind) return payload;
|
if (!kind) return payload;
|
||||||
|
|
||||||
let action: 'create' | 'update' | 'delete';
|
let action: 'create' | 'update' | 'delete';
|
||||||
if (method === 'DELETE') action = 'delete';
|
if (method === 'DELETE') action = 'delete';
|
||||||
else if (method === 'POST') action = 'create';
|
else if (method === 'POST') action = 'create';
|
||||||
else action = 'update';
|
else action = 'update';
|
||||||
|
|
||||||
// Get resource name: from URL for update/delete, from response body for create
|
// Get resource name: from URL for update/delete, from response body for create
|
||||||
const nameField = kind === 'user' ? 'email' : 'name';
|
const nameField = kind === 'user' ? 'email' : 'name';
|
||||||
let resourceName = urlMatch[2];
|
let resourceName = urlMatch[2];
|
||||||
if (!resourceName && typeof payload === 'string') {
|
if (!resourceName && typeof payload === 'string') {
|
||||||
try {
|
try {
|
||||||
const body = JSON.parse(payload);
|
const body = JSON.parse(payload);
|
||||||
resourceName = body[nameField];
|
resourceName = body[nameField];
|
||||||
} catch { /* ignore parse errors */ }
|
} catch { /* ignore parse errors */ }
|
||||||
}
|
}
|
||||||
if (!resourceName) return payload;
|
if (!resourceName) return payload;
|
||||||
|
|
||||||
const userName = request.userId ?? 'system';
|
const userName = request.userId ?? 'system';
|
||||||
gitBackup.enqueue(kind, resourceName, action, userName).catch((err) => {
|
gitBackup.enqueue(kind, resourceName, action, userName).catch((err) => {
|
||||||
app.log.error({ err }, `Git backup enqueue failed for ${kind}/${resourceName}`);
|
app.log.error({ err }, `Git backup enqueue failed for ${kind}/${resourceName}`);
|
||||||
});
|
|
||||||
return payload;
|
|
||||||
});
|
});
|
||||||
}
|
return payload;
|
||||||
|
});
|
||||||
|
|
||||||
if (gitBackup.enabled) {
|
// Import/delete callbacks for git restore
|
||||||
// Import callback: apply a parsed YAML doc to the DB via services
|
const importResource = async (kind: BackupKind, _name: string, doc: Record<string, unknown>) => {
|
||||||
const importResource = async (kind: BackupKind, _name: string, doc: Record<string, unknown>) => {
|
const data = { ...doc };
|
||||||
const data = { ...doc };
|
delete data.kind;
|
||||||
delete data.kind; // strip the kind field before passing to service
|
switch (kind) {
|
||||||
switch (kind) {
|
case 'server': await serverService.upsertByName(data); break;
|
||||||
case 'server': await serverService.upsertByName(data); break;
|
case 'secret': await secretService.upsertByName(data); break;
|
||||||
case 'secret': await secretService.upsertByName(data); break;
|
case 'project': await projectService.upsertByName(data, 'system'); break;
|
||||||
case 'project': await projectService.upsertByName(data, 'system'); break;
|
case 'user': await userService.upsertByEmail(data); break;
|
||||||
case 'user': await userService.upsertByEmail(data); break;
|
case 'group': await groupService.upsertByName(data); break;
|
||||||
case 'group': await groupService.upsertByName(data); break;
|
case 'rbac': await rbacDefinitionService.upsertByName(data); break;
|
||||||
case 'rbac': await rbacDefinitionService.upsertByName(data); break;
|
case 'prompt': await promptService.upsertByName(data); break;
|
||||||
case 'prompt': await promptService.upsertByName(data); break;
|
case 'template': await templateService.upsertByName(data); break;
|
||||||
case 'template': await templateService.upsertByName(data); break;
|
}
|
||||||
}
|
};
|
||||||
};
|
const deleteResource = async (kind: BackupKind, name: string) => {
|
||||||
const deleteResource = async (kind: BackupKind, name: string) => {
|
switch (kind) {
|
||||||
switch (kind) {
|
case 'server': await serverService.deleteByName(name); break;
|
||||||
case 'server': await serverService.deleteByName(name); break;
|
case 'secret': await secretService.deleteByName(name); break;
|
||||||
case 'secret': await secretService.deleteByName(name); break;
|
case 'project': await projectService.deleteByName(name); break;
|
||||||
case 'project': await projectService.deleteByName(name); break;
|
case 'user': await userService.deleteByEmail(name); break;
|
||||||
case 'user': await userService.deleteByEmail(name); break;
|
case 'group': await groupService.deleteByName(name); break;
|
||||||
case 'group': await groupService.deleteByName(name); break;
|
case 'rbac': await rbacDefinitionService.deleteByName(name); break;
|
||||||
case 'rbac': await rbacDefinitionService.deleteByName(name); break;
|
case 'prompt': await promptService.deleteByName(name); break;
|
||||||
case 'prompt': await promptService.deleteByName(name); break;
|
case 'template': await templateService.deleteByName(name); break;
|
||||||
case 'template': await templateService.deleteByName(name); break;
|
}
|
||||||
}
|
};
|
||||||
};
|
gitBackup.setCallbacks(importResource, deleteResource);
|
||||||
gitBackup.setCallbacks(importResource, deleteResource);
|
|
||||||
// Init async — don't block server startup
|
// Init async — reads config from backup-ssh secret, don't block startup
|
||||||
gitBackup.init().catch((err) => app.log.error({ err }, 'Git backup init failed'));
|
gitBackup.init().catch((err) => app.log.error({ err }, 'Git backup init failed'));
|
||||||
}
|
|
||||||
|
|
||||||
// Always register backup routes (status shows disabled when no repo configured)
|
|
||||||
registerGitBackupRoutes(app, gitBackup);
|
registerGitBackupRoutes(app, gitBackup);
|
||||||
|
|
||||||
// ── RBAC list filtering hook ──
|
// ── RBAC list filtering hook ──
|
||||||
|
|||||||
@@ -20,21 +20,20 @@ export function registerAuditEventRoutes(app: FastifyInstance, service: AuditEve
|
|||||||
app.post('/api/v1/audit/events', async (request, reply) => {
|
app.post('/api/v1/audit/events', async (request, reply) => {
|
||||||
const body = request.body;
|
const body = request.body;
|
||||||
if (!Array.isArray(body) || body.length === 0) {
|
if (!Array.isArray(body) || body.length === 0) {
|
||||||
reply.code(400).send({ error: 'Request body must be a non-empty array of audit events' });
|
return reply.code(400).send({ error: 'Request body must be a non-empty array of audit events' });
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Basic validation
|
// Basic validation
|
||||||
for (const event of body) {
|
for (const event of body) {
|
||||||
const e = event as Record<string, unknown>;
|
const e = event as Record<string, unknown>;
|
||||||
if (!e['sessionId'] || !e['projectName'] || !e['eventKind'] || !e['source'] || !e['timestamp']) {
|
if (!e['sessionId'] || !e['projectName'] || !e['eventKind'] || !e['source'] || !e['timestamp']) {
|
||||||
reply.code(400).send({ error: 'Each event requires: timestamp, sessionId, projectName, eventKind, source' });
|
return reply.code(400).send({ error: 'Each event requires: timestamp, sessionId, projectName, eventKind, source' });
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const count = await service.createBatch(body as AuditEventCreateInput[]);
|
const count = await service.createBatch(body as AuditEventCreateInput[]);
|
||||||
reply.code(201).send({ inserted: count });
|
reply.code(201);
|
||||||
|
return { inserted: count };
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/v1/audit/events — query with filters
|
// GET /api/v1/audit/events — query with filters
|
||||||
|
|||||||
@@ -31,8 +31,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDeps): v
|
|||||||
app.post('/api/v1/auth/bootstrap', async (request, reply) => {
|
app.post('/api/v1/auth/bootstrap', async (request, reply) => {
|
||||||
const count = await deps.userService.count();
|
const count = await deps.userService.count();
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
reply.code(409).send({ error: 'Users already exist. Use login instead.' });
|
return reply.code(409).send({ error: 'Users already exist. Use login instead.' });
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { email, password, name } = request.body as { email: string; password: string; name?: string };
|
const { email, password, name } = request.body as { email: string; password: string; name?: string };
|
||||||
|
|||||||
@@ -2,20 +2,11 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import type { GitBackupService } from '../services/backup/git-backup.service.js';
|
import type { GitBackupService } from '../services/backup/git-backup.service.js';
|
||||||
|
|
||||||
export function registerGitBackupRoutes(app: FastifyInstance, gitBackup: GitBackupService): void {
|
export function registerGitBackupRoutes(app: FastifyInstance, gitBackup: GitBackupService): void {
|
||||||
// GET /api/v1/backup/status — sync status
|
// GET /api/v1/backup/status — sync status (includes publicKey)
|
||||||
app.get('/api/v1/backup/status', async () => {
|
app.get('/api/v1/backup/status', async () => {
|
||||||
return gitBackup.getStatus();
|
return gitBackup.getStatus();
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/v1/backup/key — SSH public key
|
|
||||||
app.get('/api/v1/backup/key', async (_req, reply) => {
|
|
||||||
const key = await gitBackup.getPublicKey();
|
|
||||||
if (!key) {
|
|
||||||
return reply.code(404).send({ error: 'SSH key not generated yet' });
|
|
||||||
}
|
|
||||||
return { publicKey: key };
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /api/v1/backup/log — commit history
|
// GET /api/v1/backup/log — commit history
|
||||||
app.get<{ Querystring: { limit?: string } }>('/api/v1/backup/log', async (request) => {
|
app.get<{ Querystring: { limit?: string } }>('/api/v1/backup/log', async (request) => {
|
||||||
const limit = parseInt(request.query.limit ?? '50', 10);
|
const limit = parseInt(request.query.limit ?? '50', 10);
|
||||||
|
|||||||
@@ -6,6 +6,11 @@
|
|||||||
*
|
*
|
||||||
* Manual commits (not by mcpd) are detected and imported if they don't conflict
|
* Manual commits (not by mcpd) are detected and imported if they don't conflict
|
||||||
* with pending DB changes. Conflicts are resolved in favor of the DB.
|
* with pending DB changes. Conflicts are resolved in favor of the DB.
|
||||||
|
*
|
||||||
|
* Configuration is stored in a secret named "backup-ssh" in the DB.
|
||||||
|
* The secret data contains: repoUrl, publicKey, privateKey.
|
||||||
|
* If the secret exists with a repoUrl, backup is enabled.
|
||||||
|
* SSH keys are auto-generated if missing from the secret.
|
||||||
*/
|
*/
|
||||||
import { execFile as execFileCb } from 'child_process';
|
import { execFile as execFileCb } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
@@ -25,10 +30,12 @@ const REPO_DIR = join(BACKUP_DIR, 'repo');
|
|||||||
const SSH_KEY_PATH = join(BACKUP_DIR, 'id_ed25519');
|
const SSH_KEY_PATH = join(BACKUP_DIR, 'id_ed25519');
|
||||||
const MCPD_EMAIL = 'mcpd@mcpctl.local';
|
const MCPD_EMAIL = 'mcpd@mcpctl.local';
|
||||||
const SYNC_INTERVAL_MS = 30_000;
|
const SYNC_INTERVAL_MS = 30_000;
|
||||||
|
const BACKUP_SECRET_NAME = 'backup-ssh';
|
||||||
|
|
||||||
export interface BackupStatus {
|
export interface BackupStatus {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
repoUrl: string | null;
|
repoUrl: string | null;
|
||||||
|
publicKey: string | null;
|
||||||
gitReachable: boolean;
|
gitReachable: boolean;
|
||||||
lastSyncAt: string | null;
|
lastSyncAt: string | null;
|
||||||
lastPushAt: string | null;
|
lastPushAt: string | null;
|
||||||
@@ -59,7 +66,8 @@ export type ImportResourceFn = (kind: BackupKind, name: string, doc: Record<stri
|
|||||||
export type DeleteResourceFn = (kind: BackupKind, name: string) => Promise<void>;
|
export type DeleteResourceFn = (kind: BackupKind, name: string) => Promise<void>;
|
||||||
|
|
||||||
export class GitBackupService {
|
export class GitBackupService {
|
||||||
private repoUrl: string | null;
|
private repoUrl: string | null = null;
|
||||||
|
private publicKey: string | null = null;
|
||||||
private initialized = false;
|
private initialized = false;
|
||||||
private gitReachable = false;
|
private gitReachable = false;
|
||||||
private lastSyncAt: Date | null = null;
|
private lastSyncAt: Date | null = null;
|
||||||
@@ -73,10 +81,7 @@ export class GitBackupService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaClient,
|
private readonly prisma: PrismaClient,
|
||||||
repoUrl?: string,
|
) {}
|
||||||
) {
|
|
||||||
this.repoUrl = repoUrl || process.env.MCPD_BACKUP_REPO || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
get enabled(): boolean {
|
get enabled(): boolean {
|
||||||
return this.repoUrl !== null;
|
return this.repoUrl !== null;
|
||||||
@@ -88,10 +93,13 @@ export class GitBackupService {
|
|||||||
this.deleteResource = deleteFn;
|
this.deleteResource = deleteFn;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Initialize: generate SSH key, clone/init repo, initial sync. */
|
/** Initialize: read config from backup-ssh secret, setup SSH key, clone/init repo. */
|
||||||
async init(): Promise<void> {
|
async init(): Promise<void> {
|
||||||
|
// Read config from the backup-ssh secret in the DB
|
||||||
|
await this.loadConfigFromSecret();
|
||||||
|
|
||||||
if (!this.enabled) {
|
if (!this.enabled) {
|
||||||
console.log('[git-backup] Disabled (no MCPD_BACKUP_REPO configured)');
|
console.log('[git-backup] Disabled (no backup-ssh secret with repoUrl)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +159,7 @@ export class GitBackupService {
|
|||||||
return {
|
return {
|
||||||
enabled: this.enabled,
|
enabled: this.enabled,
|
||||||
repoUrl: this.repoUrl,
|
repoUrl: this.repoUrl,
|
||||||
|
publicKey: this.publicKey,
|
||||||
gitReachable: this.gitReachable,
|
gitReachable: this.gitReachable,
|
||||||
lastSyncAt: this.lastSyncAt?.toISOString() ?? null,
|
lastSyncAt: this.lastSyncAt?.toISOString() ?? null,
|
||||||
lastPushAt: this.lastPushAt?.toISOString() ?? null,
|
lastPushAt: this.lastPushAt?.toISOString() ?? null,
|
||||||
@@ -159,15 +168,6 @@ export class GitBackupService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the SSH public key. */
|
|
||||||
async getPublicKey(): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
return (await readFile(`${SSH_KEY_PATH}.pub`, 'utf-8')).trim();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get commit history. */
|
/** Get commit history. */
|
||||||
async getLog(limit = 50): Promise<BackupLogEntry[]> {
|
async getLog(limit = 50): Promise<BackupLogEntry[]> {
|
||||||
if (!this.initialized) return [];
|
if (!this.initialized) return [];
|
||||||
@@ -318,19 +318,75 @@ export class GitBackupService {
|
|||||||
return status.length > 0;
|
return status.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Config from Secret ──
|
||||||
|
|
||||||
|
/** Load repoUrl and SSH keys from the backup-ssh secret. */
|
||||||
|
private async loadConfigFromSecret(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const secret = await this.prisma.secret.findUnique({ where: { name: BACKUP_SECRET_NAME } });
|
||||||
|
if (!secret) return;
|
||||||
|
|
||||||
|
const data = secret.data as Record<string, string>;
|
||||||
|
const repoUrl = data['repoUrl'];
|
||||||
|
if (!repoUrl) return;
|
||||||
|
|
||||||
|
this.repoUrl = repoUrl;
|
||||||
|
this.publicKey = data['publicKey'] ?? null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[git-backup] Failed to read ${BACKUP_SECRET_NAME} secret: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── SSH Key ──
|
// ── SSH Key ──
|
||||||
|
|
||||||
|
/** Ensure SSH key exists on disk (for git operations) and in the secret. */
|
||||||
private async ensureSshKey(): Promise<void> {
|
private async ensureSshKey(): Promise<void> {
|
||||||
|
// Try to load key from secret first
|
||||||
|
const secret = await this.prisma.secret.findUnique({ where: { name: BACKUP_SECRET_NAME } });
|
||||||
|
const data = (secret?.data ?? {}) as Record<string, string>;
|
||||||
|
|
||||||
|
if (data['privateKey'] && data['publicKey']) {
|
||||||
|
// Write keys from secret to disk for git SSH
|
||||||
|
await writeFile(SSH_KEY_PATH, data['privateKey'] + '\n', { mode: 0o600 });
|
||||||
|
await writeFile(`${SSH_KEY_PATH}.pub`, data['publicKey'] + '\n', { mode: 0o644 });
|
||||||
|
this.publicKey = data['publicKey'];
|
||||||
|
console.log('[git-backup] SSH key loaded from secret');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No keys in secret — check disk (migration from old setup)
|
||||||
try {
|
try {
|
||||||
await access(SSH_KEY_PATH);
|
await access(SSH_KEY_PATH);
|
||||||
console.log('[git-backup] SSH key exists');
|
const privateKey = (await readFile(SSH_KEY_PATH, 'utf-8')).trim();
|
||||||
} catch {
|
const pubKey = (await readFile(`${SSH_KEY_PATH}.pub`, 'utf-8')).trim();
|
||||||
console.log('[git-backup] Generating SSH key...');
|
// Store keys back into the secret
|
||||||
await execFile('ssh-keygen', ['-t', 'ed25519', '-f', SSH_KEY_PATH, '-N', '', '-C', MCPD_EMAIL], {
|
await this.prisma.secret.update({
|
||||||
timeout: 10_000,
|
where: { name: BACKUP_SECRET_NAME },
|
||||||
|
data: { data: { ...data, privateKey, publicKey: pubKey } },
|
||||||
});
|
});
|
||||||
console.log('[git-backup] SSH key generated');
|
this.publicKey = pubKey;
|
||||||
|
console.log('[git-backup] SSH key migrated from disk to secret');
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// No key on disk either
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate new keypair
|
||||||
|
console.log('[git-backup] Generating SSH key...');
|
||||||
|
await execFile('ssh-keygen', ['-t', 'ed25519', '-f', SSH_KEY_PATH, '-N', '', '-C', MCPD_EMAIL], {
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const privateKey = (await readFile(SSH_KEY_PATH, 'utf-8')).trim();
|
||||||
|
const pubKey = (await readFile(`${SSH_KEY_PATH}.pub`, 'utf-8')).trim();
|
||||||
|
|
||||||
|
// Store in secret
|
||||||
|
await this.prisma.secret.update({
|
||||||
|
where: { name: BACKUP_SECRET_NAME },
|
||||||
|
data: { data: { ...data, privateKey, publicKey: pubKey } },
|
||||||
|
});
|
||||||
|
this.publicKey = pubKey;
|
||||||
|
console.log('[git-backup] SSH key generated and stored in secret');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Repo Init ──
|
// ── Repo Init ──
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ services:
|
|||||||
MCPD_PYTHON_RUNNER_IMAGE: mysources.co.uk/michal/mcpctl-python-runner:latest
|
MCPD_PYTHON_RUNNER_IMAGE: mysources.co.uk/michal/mcpctl-python-runner:latest
|
||||||
MCPD_RATE_LIMIT_MAX: "2000"
|
MCPD_RATE_LIMIT_MAX: "2000"
|
||||||
MCPD_MCP_NETWORK: mcp-servers
|
MCPD_MCP_NETWORK: mcp-servers
|
||||||
MCPD_BACKUP_REPO: ${MCPD_BACKUP_REPO:-}
|
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
Reference in New Issue
Block a user