From 093b0056fbd99c8a4354ce5a61bd0103dd72af40 Mon Sep 17 00:00:00 2001 From: Khairul Hidayat Date: Sat, 11 May 2024 02:09:18 +0700 Subject: [PATCH] feat: add backup & restore task scheduler --- backend/package.json | 4 +- .../db/migrations/0000_square_agent_brand.sql | 44 +++ .../src/db/migrations/meta/0000_snapshot.json | 304 ++++++++++++++++++ backend/src/db/migrations/meta/_journal.json | 13 + backend/src/db/models.ts | 66 +++- backend/src/db/schema.ts | 15 +- backend/src/main.ts | 3 + backend/src/middlewares/error-handler.ts | 19 ++ backend/src/routers/backup.router.ts | 29 ++ backend/src/routers/index.ts | 20 +- backend/src/routers/server.router.ts | 64 ++-- backend/src/schedulers/index.ts | 6 + backend/src/schedulers/process-backup.ts | 118 +++++++ backend/src/schemas/backup.schema.ts | 25 ++ backend/src/schemas/server.schema.ts | 59 ++-- backend/src/services/backup.service.ts | 104 ++++++ backend/src/services/database.service.ts | 23 ++ backend/src/services/server.service.ts | 85 +++++ backend/src/utility/hash.ts | 16 + bun.lockb | Bin 50671 -> 11885 bytes package.json | 4 - 21 files changed, 951 insertions(+), 70 deletions(-) create mode 100644 backend/src/db/migrations/0000_square_agent_brand.sql create mode 100644 backend/src/db/migrations/meta/0000_snapshot.json create mode 100644 backend/src/db/migrations/meta/_journal.json create mode 100644 backend/src/middlewares/error-handler.ts create mode 100644 backend/src/routers/backup.router.ts create mode 100644 backend/src/schedulers/index.ts create mode 100644 backend/src/schedulers/process-backup.ts create mode 100644 backend/src/schemas/backup.schema.ts create mode 100644 backend/src/services/backup.service.ts create mode 100644 backend/src/services/database.service.ts create mode 100644 backend/src/services/server.service.ts create mode 100644 backend/src/utility/hash.ts diff --git a/backend/package.json b/backend/package.json index 701a105..66fb1b0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,10 +9,11 @@ "start": "bun dist/main.js", "generate": "drizzle-kit generate", "migrate": "bun src/db/migrate.ts", - "reset": "rm -f storage/database.db && bun run migrate" + "reset": "rm -rf storage && bun run migrate" }, "devDependencies": { "@types/bun": "latest", + "@types/node-schedule": "^2.1.7", "drizzle-kit": "^0.21.0" }, "peerDependencies": { @@ -23,6 +24,7 @@ "drizzle-orm": "^0.30.10", "hono": "^4.3.4", "nanoid": "^5.0.7", + "node-schedule": "^2.1.1", "zod": "^3.23.8" } } diff --git a/backend/src/db/migrations/0000_square_agent_brand.sql b/backend/src/db/migrations/0000_square_agent_brand.sql new file mode 100644 index 0000000..c935800 --- /dev/null +++ b/backend/src/db/migrations/0000_square_agent_brand.sql @@ -0,0 +1,44 @@ +CREATE TABLE `backups` ( + `id` text PRIMARY KEY NOT NULL, + `server_id` text NOT NULL, + `database_id` text NOT NULL, + `type` text DEFAULT 'backup', + `status` text DEFAULT 'pending', + `output` text, + `key` text, + `hash` text, + `size` integer, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`server_id`) REFERENCES `servers`(`id`) ON UPDATE cascade ON DELETE cascade, + FOREIGN KEY (`database_id`) REFERENCES `databases`(`id`) ON UPDATE cascade ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `databases` ( + `id` text PRIMARY KEY NOT NULL, + `server_id` text NOT NULL, + `name` text NOT NULL, + `is_active` integer DEFAULT true NOT NULL, + `last_backup_at` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`server_id`) REFERENCES `servers`(`id`) ON UPDATE cascade ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `servers` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `type` text NOT NULL, + `connection` text, + `ssh` text, + `is_active` integer DEFAULT true NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE `users` ( + `id` text PRIMARY KEY NOT NULL, + `username` text NOT NULL, + `password` text NOT NULL, + `is_active` integer DEFAULT true NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`); \ No newline at end of file diff --git a/backend/src/db/migrations/meta/0000_snapshot.json b/backend/src/db/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..885ed06 --- /dev/null +++ b/backend/src/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,304 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "96dd8a39-5c64-4bb1-86de-7a81b83ed1db", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "backups": { + "name": "backups", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "database_id": { + "name": "database_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'backup'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "backups_server_id_servers_id_fk": { + "name": "backups_server_id_servers_id_fk", + "tableFrom": "backups", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "backups_database_id_databases_id_fk": { + "name": "backups_database_id_databases_id_fk", + "tableFrom": "backups", + "tableTo": "databases", + "columnsFrom": [ + "database_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "databases": { + "name": "databases", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "last_backup_at": { + "name": "last_backup_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "databases_server_id_servers_id_fk": { + "name": "databases_server_id_servers_id_fk", + "tableFrom": "databases", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "servers": { + "name": "servers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection": { + "name": "connection", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh": { + "name": "ssh", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} \ No newline at end of file diff --git a/backend/src/db/migrations/meta/_journal.json b/backend/src/db/migrations/meta/_journal.json new file mode 100644 index 0000000..7d2711a --- /dev/null +++ b/backend/src/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "6", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1715367813285, + "tag": "0000_square_agent_brand", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/backend/src/db/models.ts b/backend/src/db/models.ts index db246d7..fd95e4b 100644 --- a/backend/src/db/models.ts +++ b/backend/src/db/models.ts @@ -1,6 +1,5 @@ -import type { DatabaseConfig } from "@/types/database.types"; -import { sql } from "drizzle-orm"; -import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { relations, sql, type InferSelectModel } from "drizzle-orm"; +import { blob, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { nanoid } from "nanoid"; export const userModel = sqliteTable("users", { @@ -14,6 +13,7 @@ export const userModel = sqliteTable("users", { .notNull() .default(sql`CURRENT_TIMESTAMP`), }); +export type UserModel = InferSelectModel; export const serverModel = sqliteTable("servers", { id: text("id") @@ -28,6 +28,11 @@ export const serverModel = sqliteTable("servers", { .notNull() .default(sql`CURRENT_TIMESTAMP`), }); +export type ServerModel = InferSelectModel; + +export const serverRelations = relations(serverModel, ({ many }) => ({ + databases: many(databaseModel), +})); export const databaseModel = sqliteTable("databases", { id: text("id") @@ -46,3 +51,58 @@ export const databaseModel = sqliteTable("databases", { .notNull() .default(sql`CURRENT_TIMESTAMP`), }); +export type DatabaseModel = InferSelectModel; + +export const databaseRelations = relations(databaseModel, ({ one }) => ({ + server: one(serverModel, { + fields: [databaseModel.serverId], + references: [serverModel.id], + }), +})); + +export const backupTypeEnum = ["backup", "restore"] as const; + +export const backupStatusEnum = [ + "pending", + "running", + "success", + "failed", +] as const; + +export const backupModel = sqliteTable("backups", { + id: text("id") + .primaryKey() + .$defaultFn(() => nanoid()), + serverId: text("server_id") + .references(() => serverModel.id, { + onUpdate: "cascade", + onDelete: "cascade", + }) + .notNull(), + databaseId: text("database_id") + .references(() => databaseModel.id, { + onUpdate: "cascade", + onDelete: "cascade", + }) + .notNull(), + type: text("type", { enum: backupTypeEnum }).default("backup"), + status: text("status", { enum: backupStatusEnum }).default("pending"), + output: text("output"), + key: text("key"), + hash: text("hash"), + size: integer("size"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const backupRelations = relations(backupModel, ({ one }) => ({ + server: one(serverModel, { + fields: [backupModel.serverId], + references: [serverModel.id], + }), + database: one(databaseModel, { + fields: [backupModel.databaseId], + references: [databaseModel.id], + }), +})); diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 934c779..d2c9d37 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -1,9 +1,22 @@ -import { databaseModel, serverModel, userModel } from "./models"; +import { + backupModel, + backupRelations, + databaseModel, + databaseRelations, + serverModel, + serverRelations, + userModel, +} from "./models"; const schema = { users: userModel, servers: serverModel, database: databaseModel, + backup: backupModel, + + serverRelations, + databaseRelations, + backupRelations, }; export default schema; diff --git a/backend/src/main.ts b/backend/src/main.ts index 25c5d17..d7e905f 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,5 +1,8 @@ import routers from "./routers"; +import { initScheduler } from "./schedulers"; console.log("Starting app.."); +initScheduler(); + export default routers; diff --git a/backend/src/middlewares/error-handler.ts b/backend/src/middlewares/error-handler.ts new file mode 100644 index 0000000..88c4928 --- /dev/null +++ b/backend/src/middlewares/error-handler.ts @@ -0,0 +1,19 @@ +import { type Context } from "hono"; +import { HTTPException } from "hono/http-exception"; + +export const handleError = (err: Error, c: Context) => { + let statusCode: number = 400; + + if (err instanceof HTTPException) { + statusCode = err.status; + } + + return c.json( + { + success: false, + error: err, + message: err.message || "An error occured.", + }, + statusCode as never + ); +}; diff --git a/backend/src/routers/backup.router.ts b/backend/src/routers/backup.router.ts new file mode 100644 index 0000000..1adab5c --- /dev/null +++ b/backend/src/routers/backup.router.ts @@ -0,0 +1,29 @@ +import { + createBackupSchema, + getAllBackupQuery, + restoreBackupSchema, +} from "@/schemas/backup.schema"; +import BackupService from "@/services/backup.service"; +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; + +const backupService = new BackupService(); +const router = new Hono() + + .get("/", zValidator("query", getAllBackupQuery), async (c) => { + const query = c.req.valid("query"); + const result = await backupService.getAll(query); + return c.json(result); + }) + + .post("/", zValidator("json", createBackupSchema), async (c) => { + const body = c.req.valid("json"); + return c.json(await backupService.create(body)); + }) + + .post("/restore", zValidator("json", restoreBackupSchema), async (c) => { + const body = c.req.valid("json"); + return c.json(await backupService.restore(body)); + }); + +export default router; diff --git a/backend/src/routers/index.ts b/backend/src/routers/index.ts index df5cecf..40cb87c 100644 --- a/backend/src/routers/index.ts +++ b/backend/src/routers/index.ts @@ -1,17 +1,17 @@ -import { Hono, type Context } from "hono"; +import { Hono } from "hono"; +import { handleError } from "@/middlewares/error-handler"; import server from "./server.router"; - -const handleError = (err: Error, c: Context) => { - return c.json({ - success: false, - error: err, - message: err.message, - }); -}; +import backup from "./backup.router"; const routers = new Hono() + // Middlewares .onError(handleError) + + // App health check .get("/health-check", (c) => c.text("OK")) - .route("/servers", server); + + // Routes + .route("/servers", server) + .route("/backups", backup); export default routers; diff --git a/backend/src/routers/server.router.ts b/backend/src/routers/server.router.ts index e9de634..9901452 100644 --- a/backend/src/routers/server.router.ts +++ b/backend/src/routers/server.router.ts @@ -1,38 +1,56 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; -import { createServerSchema } from "@/schemas/server.schema"; -import db from "@/db"; -import { asc, eq } from "drizzle-orm"; +import { checkServerSchema, createServerSchema } from "@/schemas/server.schema"; import { HTTPException } from "hono/http-exception"; -import { serverModel } from "@/db/models"; +import DatabaseUtil from "@/lib/database-util"; +import ServerService from "@/services/server.service"; +const serverService = new ServerService(); const router = new Hono() .get("/", async (c) => { - const servers = await db.query.servers.findMany({ - columns: { connection: false, ssh: false }, - orderBy: asc(serverModel.createdAt), - }); - return c.json(servers); + return c.json(await serverService.getAll()); }) .post("/", zValidator("json", createServerSchema), async (c) => { const data = c.req.valid("json"); - const isExist = await db.query.servers.findFirst({ - where: eq(serverModel.name, data.name), - }); - if (isExist) { - throw new HTTPException(400, { message: "Server name already exists" }); - } - - const dataValue = { - ...data, - connection: data.connection ? JSON.stringify(data.connection) : null, - ssh: data.ssh ? JSON.stringify(data.ssh) : null, - }; - const [result] = await db.insert(serverModel).values(dataValue).returning(); - + const result = await serverService.create(data); return c.json(result); + }) + + .post("/check", zValidator("json", checkServerSchema), async (c) => { + const data = c.req.valid("json"); + const db = new DatabaseUtil(data.connection); + + try { + const databases = await db.getDatabases(); + return c.json({ success: true, databases }); + } catch (err) { + throw new HTTPException(400, { + message: "Cannot connect to the database.", + }); + } + }) + + .get("/check/:id", async (c) => { + const { id } = c.req.param(); + const server = await serverService.getOrFail(id); + const db = new DatabaseUtil(server.connection); + + try { + const databases = await db.getDatabases(); + return c.json({ success: true, databases }); + } catch (err) { + throw new HTTPException(400, { + message: "Cannot connect to the database.", + }); + } + }) + + .get("/:id", async (c) => { + const { id } = c.req.param(); + const server = await serverService.getOrFail(id); + return c.json(server); }); export default router; diff --git a/backend/src/schedulers/index.ts b/backend/src/schedulers/index.ts new file mode 100644 index 0000000..abd646b --- /dev/null +++ b/backend/src/schedulers/index.ts @@ -0,0 +1,6 @@ +import scheduler from "node-schedule"; +import { processBackup } from "./process-backup"; + +export const initScheduler = () => { + scheduler.scheduleJob("*/10 * * * * *", processBackup); +}; diff --git a/backend/src/schedulers/process-backup.ts b/backend/src/schedulers/process-backup.ts new file mode 100644 index 0000000..ad11c9b --- /dev/null +++ b/backend/src/schedulers/process-backup.ts @@ -0,0 +1,118 @@ +import db from "@/db"; +import fs from "fs"; +import path from "path"; +import { backupModel, databaseModel } from "@/db/models"; +import DatabaseUtil from "@/lib/database-util"; +import ServerService from "@/services/server.service"; +import { and, asc, eq, sql } from "drizzle-orm"; +import { BACKUP_DIR } from "@/consts"; +import { mkdir } from "@/utility/utils"; +import { hashFile } from "@/utility/hash"; + +let isRunning = false; +const serverService = new ServerService(); + +const runBackup = async (task: PendingTasks[number]) => { + try { + await db + .update(backupModel) + .set({ status: "running" }) + .where(eq(backupModel.id, task.id)); + + const server = serverService.parse(task.server as never); + const dbName = task.database.name; + const dbUtil = new DatabaseUtil(server.connection); + + if (task.type === "backup") { + const key = path.join( + server.connection.host, + dbName, + `${Date.now()}.tar` + ); + const outFile = path.join(BACKUP_DIR, key); + mkdir(path.dirname(outFile)); + + // Run database dump command + const output = await dbUtil.dump(dbName, outFile); + + // Get file stats and file checksum + const fileStats = fs.statSync(outFile); + const sha256Hash = await hashFile(outFile, "sha256"); + + await db.transaction(async (tx) => { + await tx + .update(backupModel) + .set({ + status: "success", + output, + key, + hash: sha256Hash, + size: fileStats.size, + }) + .where(eq(backupModel.id, task.id)); + + await tx + .update(databaseModel) + .set({ lastBackupAt: sql`CURRENT_TIMESTAMP` }) + .where(eq(databaseModel.id, task.databaseId)); + }); + } + + if (task.type === "restore") { + if (!task.key) { + throw new Error("Missing backup file key!"); + } + + const filePath = path.join(BACKUP_DIR, task.key); + if (!fs.existsSync(filePath)) { + throw new Error("Backup file not found!"); + } + + const sha256Hash = await hashFile(filePath, "sha256"); + if (sha256Hash !== task.hash) { + throw new Error("Backup file hash mismatch!"); + } + + const output = await dbUtil.restore(filePath); + await db + .update(backupModel) + .set({ status: "success", output }) + .where(eq(backupModel.id, task.id)); + } + } catch (err) { + const output = (err as Error)?.message || "An error occured."; + await db + .update(backupModel) + .set({ status: "failed", output }) + .where(eq(backupModel.id, task.id)); + } +}; + +const getPendingTasks = async () => { + const queue = await db.query.backup.findMany({ + where: (i) => and(eq(i.status, "pending")), + orderBy: (i) => asc(i.createdAt), + with: { + server: { + columns: { connection: true, ssh: true }, + }, + database: { + columns: { name: true }, + }, + }, + }); + + return queue; +}; + +type PendingTasks = Awaited>; + +export const processBackup = async () => { + if (isRunning) return; + + isRunning = true; + const queue = await getPendingTasks(); + const tasks = queue.map(runBackup); + await Promise.all(tasks); + isRunning = false; +}; diff --git a/backend/src/schemas/backup.schema.ts b/backend/src/schemas/backup.schema.ts new file mode 100644 index 0000000..b4a31e2 --- /dev/null +++ b/backend/src/schemas/backup.schema.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +export const getAllBackupQuery = z + .object({ + page: z.coerce.number().int(), + limit: z.coerce.number().int(), + serverId: z.string().nanoid(), + databaseId: z.string().nanoid(), + }) + .partial() + .optional(); + +export type GetAllBackupQuery = z.infer; + +export const createBackupSchema = z.object({ + databaseId: z.string().nanoid(), +}); + +export type CreateBackupSchema = z.infer; + +export const restoreBackupSchema = z.object({ + backupId: z.string().nanoid(), +}); + +export type RestoreBackupSchema = z.infer; diff --git a/backend/src/schemas/server.schema.ts b/backend/src/schemas/server.schema.ts index e79b077..190e2ce 100644 --- a/backend/src/schemas/server.schema.ts +++ b/backend/src/schemas/server.schema.ts @@ -1,36 +1,39 @@ import { z } from "zod"; -export const serverTypeEnum = ["postgres"] as const; +const sshSchema = z + .object({ + host: z.string(), + port: z.number().optional(), + user: z.string(), + pass: z.string().optional(), + privateKey: z.string().optional(), + }) + .optional() + .nullable(); -export const serverSchema = z.object({ - name: z.string().min(1), - ssh: z - .object({ - host: z.string(), - port: z.number().optional(), - user: z.string(), - pass: z.string().optional(), - privateKey: z.string().optional(), - }) - .optional() - .nullable(), - isActive: z.boolean().optional(), +const postgresSchema = z.object({ + type: z.literal("postgres"), + host: z.string(), + port: z.number().optional(), + user: z.string(), + pass: z.string(), }); -const postgresSchema = serverSchema.merge( - z.object({ - type: z.literal("postgres"), - connection: z.object({ - host: z.string(), - port: z.number().optional(), - user: z.string(), - pass: z.string().optional(), - }), - }) -); +export const connectionSchema = z.discriminatedUnion("type", [postgresSchema]); -export const createServerSchema = z.discriminatedUnion("type", [ - postgresSchema, -]); +export const createServerSchema = z.object({ + name: z.string().min(1), + ssh: sshSchema, + connection: connectionSchema, + isActive: z.boolean().optional(), + databases: z.string().array().min(1), +}); export type CreateServerSchema = z.infer; + +export const checkServerSchema = z.object({ + ssh: sshSchema, + connection: connectionSchema, +}); + +export type CheckServerSchema = z.infer; diff --git a/backend/src/services/backup.service.ts b/backend/src/services/backup.service.ts new file mode 100644 index 0000000..75d1365 --- /dev/null +++ b/backend/src/services/backup.service.ts @@ -0,0 +1,104 @@ +import db from "@/db"; +import { backupModel, serverModel } from "@/db/models"; +import type { + CreateBackupSchema, + GetAllBackupQuery, + RestoreBackupSchema, +} from "@/schemas/backup.schema"; +import { and, desc, eq, inArray } from "drizzle-orm"; +import DatabaseService from "./database.service"; +import { HTTPException } from "hono/http-exception"; + +export default class BackupService { + private databaseService = new DatabaseService(); + + /** + * Get all backups + */ + async getAll(query: GetAllBackupQuery = {}) { + const { serverId, databaseId } = query; + const page = query.page || 1; + const limit = query.limit || 10; + + const backups = await db.query.backup.findMany({ + where: (i) => + and( + serverId ? eq(i.serverId, serverId) : undefined, + databaseId ? eq(i.databaseId, databaseId) : undefined + ), + orderBy: desc(serverModel.createdAt), + limit, + offset: (page - 1) * limit, + }); + + return backups; + } + + async getOrFail(id: string) { + const backup = await db.query.backup.findFirst({ + where: eq(backupModel.id, id), + }); + if (!backup) { + throw new HTTPException(404, { message: "Backup not found." }); + } + return backup; + } + + /** + * Queue new backup + */ + async create(data: CreateBackupSchema) { + const database = await this.databaseService.getOrFail(data.databaseId); + await this.checkPendingBackup(database.id); + + const [result] = await db + .insert(backupModel) + .values({ + type: "backup", + serverId: database.serverId, + databaseId: database.id, + }) + .returning(); + + return result; + } + + async restore(data: RestoreBackupSchema) { + const backup = await this.getOrFail(data.backupId); + await this.checkPendingBackup(backup.databaseId); + + if (!backup.key) { + throw new HTTPException(400, { + message: "Cannot restore backup without file key.", + }); + } + + const [result] = await db + .insert(backupModel) + .values({ + type: "restore", + serverId: backup.serverId, + databaseId: backup.databaseId, + key: backup.key, + hash: backup.hash, + size: backup.size, + }) + .returning(); + + return result; + } + + async checkPendingBackup(databaseId: string) { + const hasOngoingBackup = await db.query.backup.findFirst({ + where: and( + eq(backupModel.databaseId, databaseId), + inArray(backupModel.status, ["pending", "running"]) + ), + }); + if (hasOngoingBackup) { + throw new HTTPException(400, { + message: "There is already an ongoing backup for this database", + }); + } + } +} diff --git a/backend/src/services/database.service.ts b/backend/src/services/database.service.ts new file mode 100644 index 0000000..ba8dfe4 --- /dev/null +++ b/backend/src/services/database.service.ts @@ -0,0 +1,23 @@ +import db from "@/db"; +import { databaseModel } from "@/db/models"; +import { desc, eq } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; + +export default class DatabaseService { + async getAll() { + const servers = await db.query.database.findMany({ + orderBy: desc(databaseModel.createdAt), + }); + return servers; + } + + async getOrFail(id: string) { + const data = await db.query.database.findFirst({ + where: eq(databaseModel.id, id), + }); + if (!data) { + throw new HTTPException(404, { message: "Database not found." }); + } + return data; + } +} diff --git a/backend/src/services/server.service.ts b/backend/src/services/server.service.ts new file mode 100644 index 0000000..2a9408b --- /dev/null +++ b/backend/src/services/server.service.ts @@ -0,0 +1,85 @@ +import db from "@/db"; +import { databaseModel, serverModel, type ServerModel } from "@/db/models"; +import type { CreateServerSchema } from "@/schemas/server.schema"; +import { asc, desc, eq } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; + +export default class ServerService { + async getAll() { + const servers = await db.query.servers.findMany({ + columns: { connection: false, ssh: false }, + orderBy: asc(serverModel.createdAt), + with: { + databases: { + columns: { id: true, name: true, lastBackupAt: true }, + orderBy: desc(databaseModel.createdAt), + }, + }, + }); + return servers; + } + + async getOrFail(id: string) { + const server = await db.query.servers.findFirst({ + where: eq(serverModel.id, id), + }); + if (!server) { + throw new HTTPException(404, { message: "Server not found." }); + } + return this.parse(server); + } + + async getById(id: string) { + const server = await db.query.servers.findFirst({ + where: eq(serverModel.id, id), + with: { + databases: true, + }, + }); + return server; + } + + async create(data: CreateServerSchema) { + return db.transaction(async (tx) => { + const isExist = await tx.query.servers.findFirst({ + where: eq(serverModel.name, data.name), + }); + if (isExist) { + throw new HTTPException(400, { message: "Server name already exists" }); + } + + const dataValue = { + ...data, + type: data.connection.type, + connection: data.connection ? JSON.stringify(data.connection) : null, + ssh: data.ssh ? JSON.stringify(data.ssh) : null, + }; + + // Create server + const [result] = await tx + .insert(serverModel) + .values(dataValue) + .returning(); + + // Create databases + await tx.insert(databaseModel).values( + data.databases.map((i) => ({ + serverId: result.id, + name: i, + })) + ); + + return data; + }); + } + + parse(data: ServerModel) { + const result = { + ...data, + connection: data.connection ? JSON.parse(data.connection) : null, + ssh: data.ssh ? JSON.parse(data.ssh) : null, + }; + + return result; + } +} diff --git a/backend/src/utility/hash.ts b/backend/src/utility/hash.ts new file mode 100644 index 0000000..9a4918e --- /dev/null +++ b/backend/src/utility/hash.ts @@ -0,0 +1,16 @@ +import crypto from "crypto"; +import fs from "fs"; + +export const hashFile = ( + filePath: string, + algorithm: "md5" | "sha256" +): Promise => { + return new Promise((resolve, reject) => { + const hash = crypto.createHash(algorithm); + const stream = fs.createReadStream(filePath); + + stream.on("data", (data) => hash.update(data)); + stream.on("end", () => resolve(hash.digest("hex"))); + stream.on("error", (error) => reject(error)); + }); +}; diff --git a/bun.lockb b/bun.lockb index 270fe91465c44aeb16e056ad40ecc6ba7429cec1..b1f73103cb7dea4f6d040b6ac0e71d57bbc33961 100755 GIT binary patch literal 11885 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