import type { FastifyInstance } from 'fastify'; import { eq, and } from 'drizzle-orm'; import { Type } from '@sinclair/typebox'; import { servers, backups, nodes } from '@source/database'; import { AppError } from '../../lib/errors.js'; import { requirePermission } from '../../lib/permissions.js'; import { createAuditLog } from '../../lib/audit.js'; import { daemonCreateBackup, daemonDeleteBackup, daemonRestoreBackup, type DaemonNodeConnection, } from '../../lib/daemon.js'; const ParamSchema = { params: Type.Object({ orgId: Type.String({ format: 'uuid' }), serverId: Type.String({ format: 'uuid' }), }), }; const BackupParamSchema = { params: Type.Object({ orgId: Type.String({ format: 'uuid' }), serverId: Type.String({ format: 'uuid' }), backupId: Type.String({ format: 'uuid' }), }), }; const CreateBackupBody = Type.Object({ name: Type.String({ minLength: 1, maxLength: 255 }), isLocked: Type.Optional(Type.Boolean({ default: false })), }); export default async function backupRoutes(app: FastifyInstance) { app.addHook('onRequest', app.authenticate); // GET /backups — list all backups for a server app.get('/', { schema: ParamSchema }, async (request) => { const { orgId, serverId } = request.params as { orgId: string; serverId: string }; await requirePermission(request, orgId, 'backup.read'); const server = await app.db.query.servers.findFirst({ where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)), }); if (!server) throw AppError.notFound('Server not found'); const backupList = await app.db.query.backups.findMany({ where: eq(backups.serverId, serverId), orderBy: (b, { desc }) => [desc(b.createdAt)], }); return { backups: backupList }; }); // POST /backups — create a backup app.post('/', { schema: { ...ParamSchema, body: CreateBackupBody } }, async (request, reply) => { const { orgId, serverId } = request.params as { orgId: string; serverId: string }; await requirePermission(request, orgId, 'backup.create'); const body = request.body as { name: string; isLocked?: boolean }; const serverContext = await getServerBackupContext(app, orgId, serverId); // Create backup record (pending — daemon will update when complete) const [backup] = await app.db .insert(backups) .values({ serverId, name: body.name, isLocked: body.isLocked ?? false, }) .returning(); if (!backup) { throw new AppError(500, 'Failed to create backup record', 'BACKUP_CREATE_FAILED'); } let completedBackup = backup; try { const daemonResult = await daemonCreateBackup( serverContext.node, serverContext.serverUuid, backup.id, ); if (!daemonResult.success) { throw new Error('Daemon returned unsuccessful backup response'); } const [updated] = await app.db .update(backups) .set({ sizeBytes: daemonResult.sizeBytes, checksum: daemonResult.checksum || null, completedAt: new Date(), }) .where(eq(backups.id, backup.id)) .returning(); completedBackup = updated ?? completedBackup; } catch (error) { request.log.error( { error, serverId, backupId: backup.id }, 'Failed to create backup on daemon', ); await app.db.delete(backups).where(eq(backups.id, backup.id)); throw new AppError(502, 'Failed to create backup on daemon', 'DAEMON_BACKUP_CREATE_FAILED'); } await createAuditLog(app.db, request, { organizationId: orgId, serverId, action: 'backup.create', metadata: { name: body.name }, }); return reply.code(201).send(completedBackup); }); // POST /backups/:backupId/restore — restore a backup app.post('/:backupId/restore', { schema: BackupParamSchema }, async (request) => { const { orgId, serverId, backupId } = request.params as { orgId: string; serverId: string; backupId: string; }; await requirePermission(request, orgId, 'backup.restore'); const serverContext = await getServerBackupContext(app, orgId, serverId); const backup = await app.db.query.backups.findFirst({ where: and(eq(backups.id, backupId), eq(backups.serverId, serverId)), }); if (!backup) throw AppError.notFound('Backup not found'); if (!backup.completedAt) throw AppError.badRequest('Backup is not yet completed'); try { await daemonRestoreBackup( serverContext.node, serverContext.serverUuid, backup.id, backup.cdnPath, ); } catch (error) { request.log.error({ error, serverId, backupId }, 'Failed to restore backup on daemon'); throw new AppError(502, 'Failed to restore backup on daemon', 'DAEMON_BACKUP_RESTORE_FAILED'); } await createAuditLog(app.db, request, { organizationId: orgId, serverId, action: 'backup.restore', metadata: { backupName: backup.name, backupId }, }); return { success: true, message: 'Restore initiated' }; }); // PATCH /backups/:backupId/lock — toggle backup lock app.patch('/:backupId/lock', { schema: BackupParamSchema }, async (request) => { const { orgId, serverId, backupId } = request.params as { orgId: string; serverId: string; backupId: string; }; await requirePermission(request, orgId, 'backup.manage'); const backup = await app.db.query.backups.findFirst({ where: and(eq(backups.id, backupId), eq(backups.serverId, serverId)), }); if (!backup) throw AppError.notFound('Backup not found'); const [updated] = await app.db .update(backups) .set({ isLocked: !backup.isLocked }) .where(eq(backups.id, backupId)) .returning(); return updated; }); // DELETE /backups/:backupId — delete a backup app.delete('/:backupId', { schema: BackupParamSchema }, async (request, reply) => { const { orgId, serverId, backupId } = request.params as { orgId: string; serverId: string; backupId: string; }; await requirePermission(request, orgId, 'backup.delete'); const backup = await app.db.query.backups.findFirst({ where: and(eq(backups.id, backupId), eq(backups.serverId, serverId)), }); if (!backup) throw AppError.notFound('Backup not found'); if (backup.isLocked) throw AppError.badRequest('Cannot delete a locked backup'); const serverContext = await getServerBackupContext(app, orgId, serverId); try { await daemonDeleteBackup(serverContext.node, serverContext.serverUuid, backup.id); } catch (error) { request.log.error({ error, serverId, backupId }, 'Failed to delete backup on daemon'); throw new AppError(502, 'Failed to delete backup on daemon', 'DAEMON_BACKUP_DELETE_FAILED'); } await app.db.delete(backups).where(eq(backups.id, backupId)); await createAuditLog(app.db, request, { organizationId: orgId, serverId, action: 'backup.delete', metadata: { name: backup.name }, }); return reply.code(204).send(); }); } async function getServerBackupContext( app: FastifyInstance, orgId: string, serverId: string, ): Promise<{ serverUuid: string; node: DaemonNodeConnection }> { const [server] = await app.db .select({ serverUuid: servers.uuid, nodeFqdn: nodes.fqdn, nodeGrpcPort: nodes.grpcPort, nodeDaemonToken: nodes.daemonToken, }) .from(servers) .innerJoin(nodes, eq(servers.nodeId, nodes.id)) .where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId))); if (!server) { throw AppError.notFound('Server not found'); } return { serverUuid: server.serverUuid, node: { fqdn: server.nodeFqdn, grpcPort: server.nodeGrpcPort, daemonToken: server.nodeDaemonToken, }, }; }