feat: Git-based backup system replacing JSON bundle backup/restore

DB is source of truth with git as downstream replica. SSH key generated
on first start, all resource mutations committed as apply-compatible YAML.
Supports manual commit import, conflict resolution (DB wins), disaster
recovery (empty DB restores from git), and timeline branches on restore.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-03-08 01:14:28 +00:00
parent 9fc31e5945
commit 7818cb2194
22 changed files with 2011 additions and 127 deletions

View File

@@ -1,5 +1,4 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createBackupCommand, createRestoreCommand } from '../../src/commands/backup.js';
const mockClient = {
@@ -16,61 +15,97 @@ describe('backup command', () => {
vi.resetAllMocks();
});
afterEach(() => {
// Clean up any created files
try { fs.unlinkSync('test-backup.json'); } catch { /* ignore */ }
});
it('creates backup command', () => {
const cmd = createBackupCommand({ client: mockClient as never, log });
expect(cmd.name()).toBe('backup');
});
it('calls API and writes file', async () => {
const bundle = { version: '1', servers: [], profiles: [], projects: [] };
mockClient.post.mockResolvedValue(bundle);
const cmd = createBackupCommand({ client: mockClient as never, log });
await cmd.parseAsync(['-o', 'test-backup.json'], { from: 'user' });
expect(mockClient.post).toHaveBeenCalledWith('/api/v1/backup', {});
expect(fs.existsSync('test-backup.json')).toBe(true);
expect(log).toHaveBeenCalledWith(expect.stringContaining('test-backup.json'));
});
it('passes password when provided', async () => {
mockClient.post.mockResolvedValue({ version: '1', servers: [], profiles: [], projects: [] });
const cmd = createBackupCommand({ client: mockClient as never, log });
await cmd.parseAsync(['-o', 'test-backup.json', '-p', 'secret'], { from: 'user' });
expect(mockClient.post).toHaveBeenCalledWith('/api/v1/backup', { password: 'secret' });
});
it('passes resource filter', async () => {
mockClient.post.mockResolvedValue({ version: '1', servers: [], profiles: [], projects: [] });
const cmd = createBackupCommand({ client: mockClient as never, log });
await cmd.parseAsync(['-o', 'test-backup.json', '-r', 'servers,profiles'], { from: 'user' });
expect(mockClient.post).toHaveBeenCalledWith('/api/v1/backup', {
resources: ['servers', 'profiles'],
it('shows status when enabled', async () => {
mockClient.get.mockResolvedValue({
enabled: true,
repoUrl: 'ssh://git@10.0.0.194:2222/michal/mcp-backup.git',
gitReachable: true,
lastSyncAt: new Date().toISOString(),
lastPushAt: null,
lastError: null,
pendingCount: 0,
});
const cmd = createBackupCommand({ client: mockClient as never, log });
await cmd.parseAsync([], { from: 'user' });
expect(mockClient.get).toHaveBeenCalledWith('/api/v1/backup/status');
expect(log).toHaveBeenCalledWith(expect.stringContaining('ssh://git@10.0.0.194:2222/michal/mcp-backup.git'));
expect(log).toHaveBeenCalledWith(expect.stringContaining('synced'));
});
it('shows disabled when not configured', async () => {
mockClient.get.mockResolvedValue({
enabled: false,
repoUrl: null,
gitReachable: false,
lastSyncAt: null,
lastPushAt: null,
lastError: null,
pendingCount: 0,
});
const cmd = createBackupCommand({ client: mockClient as never, log });
await cmd.parseAsync([], { from: 'user' });
expect(log).toHaveBeenCalledWith(expect.stringContaining('disabled'));
});
it('shows pending count', async () => {
mockClient.get.mockResolvedValue({
enabled: true,
repoUrl: 'ssh://git@host/repo.git',
gitReachable: true,
lastSyncAt: null,
lastPushAt: null,
lastError: null,
pendingCount: 5,
});
const cmd = createBackupCommand({ client: mockClient as never, log });
await cmd.parseAsync([], { from: 'user' });
expect(log).toHaveBeenCalledWith(expect.stringContaining('5 changes pending'));
});
it('shows SSH public key', async () => {
mockClient.get.mockResolvedValue({ publicKey: 'ssh-ed25519 AAAA... mcpd@mcpctl.local' });
const cmd = createBackupCommand({ client: mockClient as never, log });
await cmd.parseAsync(['key'], { from: 'user' });
expect(mockClient.get).toHaveBeenCalledWith('/api/v1/backup/key');
expect(log).toHaveBeenCalledWith('ssh-ed25519 AAAA... mcpd@mcpctl.local');
});
it('shows commit log', async () => {
mockClient.get.mockResolvedValue({
entries: [
{ hash: 'abc1234567890', date: '2026-03-08T10:00:00Z', author: 'mcpd <mcpd@mcpctl.local>', message: 'Update server grafana', manual: false },
{ hash: 'def4567890123', date: '2026-03-07T09:00:00Z', author: 'Michal <michal@test.com>', message: 'Manual fix', manual: true },
],
});
const cmd = createBackupCommand({ client: mockClient as never, log });
await cmd.parseAsync(['log'], { from: 'user' });
expect(mockClient.get).toHaveBeenCalledWith('/api/v1/backup/log?limit=20');
// Header
expect(log).toHaveBeenCalledWith(expect.stringContaining('COMMIT'));
// Entries
expect(log).toHaveBeenCalledWith(expect.stringContaining('abc1234'));
expect(log).toHaveBeenCalledWith(expect.stringContaining('[manual]'));
});
});
describe('restore command', () => {
const testFile = 'test-restore-input.json';
beforeEach(() => {
vi.resetAllMocks();
fs.writeFileSync(testFile, JSON.stringify({
version: '1', servers: [], profiles: [], projects: [],
}));
});
afterEach(() => {
try { fs.unlinkSync(testFile); } catch { /* ignore */ }
});
it('creates restore command', () => {
@@ -78,43 +113,105 @@ describe('restore command', () => {
expect(cmd.name()).toBe('restore');
});
it('reads file and calls API', async () => {
mockClient.post.mockResolvedValue({
serversCreated: 1, serversSkipped: 0,
profilesCreated: 0, profilesSkipped: 0,
projectsCreated: 0, projectsSkipped: 0,
errors: [],
it('lists restore points', async () => {
mockClient.get.mockResolvedValue({
entries: [
{ hash: 'abc1234567890', date: '2026-03-08T10:00:00Z', author: 'mcpd <mcpd@mcpctl.local>', message: 'Sync' },
],
});
const cmd = createRestoreCommand({ client: mockClient as never, log });
await cmd.parseAsync(['-i', testFile], { from: 'user' });
await cmd.parseAsync(['list'], { from: 'user' });
expect(mockClient.post).toHaveBeenCalledWith('/api/v1/restore', expect.objectContaining({
bundle: expect.objectContaining({ version: '1' }),
conflictStrategy: 'skip',
}));
expect(log).toHaveBeenCalledWith('Restore complete:');
expect(mockClient.get).toHaveBeenCalledWith('/api/v1/backup/log?limit=30');
expect(log).toHaveBeenCalledWith(expect.stringContaining('abc1234'));
});
it('reports errors from restore', async () => {
it('shows restore diff preview', async () => {
mockClient.post.mockResolvedValue({
serversCreated: 0, serversSkipped: 0,
profilesCreated: 0, profilesSkipped: 0,
projectsCreated: 0, projectsSkipped: 0,
errors: ['Server "x" already exists'],
targetCommit: 'abc1234567890',
targetDate: '2026-03-08T10:00:00Z',
targetMessage: 'Snapshot',
added: ['servers/new.yaml'],
removed: ['servers/old.yaml'],
modified: ['projects/default.yaml'],
});
const cmd = createRestoreCommand({ client: mockClient as never, log });
await cmd.parseAsync(['-i', testFile], { from: 'user' });
await cmd.parseAsync(['diff', 'abc1234'], { from: 'user' });
expect(log).toHaveBeenCalledWith(expect.stringContaining('Errors'));
expect(mockClient.post).toHaveBeenCalledWith('/api/v1/backup/restore/preview', { commit: 'abc1234' });
expect(log).toHaveBeenCalledWith(expect.stringContaining('+ servers/new.yaml'));
expect(log).toHaveBeenCalledWith(expect.stringContaining('- servers/old.yaml'));
expect(log).toHaveBeenCalledWith(expect.stringContaining('~ projects/default.yaml'));
});
it('logs error for missing file', async () => {
const cmd = createRestoreCommand({ client: mockClient as never, log });
await cmd.parseAsync(['-i', 'nonexistent.json'], { from: 'user' });
it('requires --force for restore', async () => {
mockClient.post.mockResolvedValue({
targetCommit: 'abc1234567890',
targetDate: '2026-03-08T10:00:00Z',
targetMessage: 'Snapshot',
added: ['servers/new.yaml'],
removed: [],
modified: [],
});
expect(log).toHaveBeenCalledWith(expect.stringContaining('not found'));
expect(mockClient.post).not.toHaveBeenCalled();
const cmd = createRestoreCommand({ client: mockClient as never, log });
await cmd.parseAsync(['to', 'abc1234'], { from: 'user' });
// Should show preview but NOT call restore endpoint
expect(mockClient.post).toHaveBeenCalledWith('/api/v1/backup/restore/preview', { commit: 'abc1234' });
expect(mockClient.post).not.toHaveBeenCalledWith('/api/v1/backup/restore', expect.anything());
expect(log).toHaveBeenCalledWith(expect.stringContaining('--force'));
});
it('executes restore with --force', async () => {
// First call: preview, second call: restore
mockClient.post
.mockResolvedValueOnce({
targetCommit: 'abc1234567890',
targetDate: '2026-03-08T10:00:00Z',
targetMessage: 'Snapshot',
added: ['servers/new.yaml'],
removed: [],
modified: [],
})
.mockResolvedValueOnce({
branchName: 'timeline/20260308-100000',
applied: 1,
deleted: 0,
errors: [],
});
const cmd = createRestoreCommand({ client: mockClient as never, log });
await cmd.parseAsync(['to', 'abc1234', '--force'], { from: 'user' });
expect(mockClient.post).toHaveBeenCalledWith('/api/v1/backup/restore', { commit: 'abc1234' });
expect(log).toHaveBeenCalledWith(expect.stringContaining('1 applied'));
expect(log).toHaveBeenCalledWith(expect.stringContaining('timeline/20260308-100000'));
});
it('reports restore errors', async () => {
mockClient.post
.mockResolvedValueOnce({
targetCommit: 'abc1234567890',
targetDate: '2026-03-08T10:00:00Z',
targetMessage: 'Snapshot',
added: [],
removed: [],
modified: ['servers/broken.yaml'],
})
.mockResolvedValueOnce({
branchName: 'timeline/20260308-100000',
applied: 0,
deleted: 0,
errors: ['Failed to apply servers/broken.yaml: invalid YAML'],
});
const cmd = createRestoreCommand({ client: mockClient as never, log });
await cmd.parseAsync(['to', 'abc1234', '--force'], { from: 'user' });
expect(log).toHaveBeenCalledWith('Errors:');
expect(log).toHaveBeenCalledWith(expect.stringContaining('invalid YAML'));
});
});