c1adb94abb
CI has been running `prettier --check` against a tree that was never formatted, so the check reported 63 files and failed every run. Nothing here is a behaviour change: `pnpm lint` and the four typecheck builds pass exactly as before. conduit-bringup-artifacts is added to .prettierignore instead. Those files are captured bring-up reports, not maintained sources; reflowing them would only churn a record of what happened.
249 lines
7.8 KiB
TypeScript
249 lines
7.8 KiB
TypeScript
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,
|
|
},
|
|
};
|
|
}
|