feat: add recurring backup scheduler

This commit is contained in:
2024-05-12 19:30:45 +07:00
parent 3d7508816f
commit 449ba1b9d0
33 changed files with 876 additions and 271 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
import { STORAGE_DIR } from "../consts";
import { STORAGE_DIR } from "./src/consts";
import { defineConfig } from "drizzle-kit";
export default defineConfig({
+2 -1
View File
@@ -21,8 +21,9 @@
},
"dependencies": {
"@hono/zod-validator": "^0.2.1",
"dayjs": "^1.11.11",
"drizzle-orm": "^0.30.10",
"hono": "^4.3.4",
"hono": "4.3.5",
"nanoid": "^5.0.7",
"node-schedule": "^2.1.1",
"zod": "^3.23.8"
@@ -30,7 +30,9 @@ CREATE TABLE `servers` (
`connection` text,
`ssh` text,
`is_active` integer DEFAULT true NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
`backup` text,
`next_backup` text
);
--> statement-breakpoint
CREATE TABLE `users` (
@@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
"id": "96dd8a39-5c64-4bb1-86de-7a81b83ed1db",
"id": "242cd56d-c814-44c6-8a5b-4f0814248f31",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"backups": {
@@ -233,6 +233,20 @@
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"backup": {
"name": "backup",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"next_backup": {
"name": "next_backup",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
+2 -2
View File
@@ -5,8 +5,8 @@
{
"idx": 0,
"version": "6",
"when": 1715367813285,
"tag": "0000_square_agent_brand",
"when": 1715513358120,
"tag": "0000_clumsy_doorman",
"breakpoints": true
}
]
+12 -1
View File
@@ -1,6 +1,12 @@
import { relations, sql, type InferSelectModel } from "drizzle-orm";
import {
relations,
sql,
type InferInsertModel,
type InferSelectModel,
} from "drizzle-orm";
import { blob, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { nanoid } from "nanoid";
import type { ServerBackupSchema } from "../schemas/server.schema";
export const userModel = sqliteTable("users", {
id: text("id")
@@ -27,6 +33,8 @@ export const serverModel = sqliteTable("servers", {
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
backup: text("backup", { mode: "json" }).$type<ServerBackupSchema>(),
nextBackup: text("next_backup"),
});
export type ServerModel = InferSelectModel<typeof serverModel>;
@@ -96,6 +104,9 @@ export const backupModel = sqliteTable("backups", {
.default(sql`CURRENT_TIMESTAMP`),
});
export type BackupModel = InferSelectModel<typeof backupModel>;
export type InsertBackupModel = InferInsertModel<typeof backupModel>;
export const backupRelations = relations(backupModel, ({ one }) => ({
server: one(serverModel, {
fields: [backupModel.serverId],
+8
View File
@@ -5,6 +5,7 @@ import ServerService from "../services/server.service";
import {
checkServerSchema,
createServerSchema,
updateServerSchema,
} from "../schemas/server.schema";
import DatabaseUtil from "../lib/database-util";
@@ -54,6 +55,13 @@ const router = new Hono()
const { id } = c.req.param();
const server = await serverService.getById(id);
return c.json(server);
})
.patch("/:id", zValidator("json", updateServerSchema), async (c) => {
const server = await serverService.getOrFail(c.req.param("id"));
const data = c.req.valid("json");
const result = await serverService.update(server, data);
return c.json(result);
});
export default router;
@@ -0,0 +1,55 @@
import { and, eq, ne, gte, sql } from "drizzle-orm";
import db from "../db";
import {
backupModel,
databaseModel,
serverModel,
type InsertBackupModel,
} from "../db/models";
import dayjs from "dayjs";
import ServerService from "../services/server.service";
import type { CreateBackupSchema } from "../schemas/backup.schema";
export const backupScheduler = async () => {
const serverService = new ServerService();
const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
const queue = await db.query.servers.findMany({
where: and(
eq(serverModel.isActive, true),
gte(
sql`strftime('%s', ${now})`,
sql`strftime('%s', ${serverModel.nextBackup})`
)
),
with: {
databases: {
columns: { id: true },
where: eq(databaseModel.isActive, true),
},
},
});
const tasks = queue.map(async (item) => {
console.log("CREATING BACKUP SCHEDULE FOR " + item.name);
try {
const backups: InsertBackupModel[] = item.databases.map((d) => ({
serverId: item.id,
databaseId: d.id,
type: "backup",
}));
await db.insert(backupModel).values(backups).execute();
const nextBackup = serverService.calculateNextBackup(item);
await db
.update(serverModel)
.set({ nextBackup })
.where(eq(serverModel.id, item.id));
} catch (err) {
console.error(err);
}
});
await Promise.all(tasks);
};
+3
View File
@@ -1,6 +1,9 @@
import scheduler from "node-schedule";
import { processBackup } from "./process-backup";
import { backupScheduler } from "./backup-scheduler";
export const initScheduler = () => {
scheduler.scheduleJob("*/10 * * * * *", processBackup);
// scheduler.scheduleJob("* * * * * *", backupScheduler);
backupScheduler();
};
+8 -3
View File
@@ -12,9 +12,14 @@ export const getAllBackupQuery = z
export type GetAllBackupQuery = z.infer<typeof getAllBackupQuery>;
export const createBackupSchema = z.object({
databaseId: z.string().nanoid(),
});
export const createBackupSchema = z
.object({
serverId: z.string().nanoid().optional(),
databaseId: z.string().nanoid().optional(),
})
.refine((i) => i.serverId || i.databaseId, {
message: "Either serverId or databaseId is required.",
});
export type CreateBackupSchema = z.infer<typeof createBackupSchema>;
+29 -1
View File
@@ -16,21 +16,49 @@ const postgresSchema = z.object({
host: z.string(),
port: z.coerce.number().int().optional(),
user: z.string(),
pass: z.string(),
pass: z.string().optional(),
});
export const connectionSchema = z.discriminatedUnion("type", [postgresSchema]);
export const serverBackupSchema = z.object({
compress: z.boolean(),
scheduled: z.boolean(),
every: z.coerce.number().min(1),
interval: z.enum([
"second",
"minute",
"hour",
"day",
"week",
"month",
"year",
]),
time: z
.string()
.regex(/^\d{2}:\d{2}$/)
.optional(),
day: z.coerce.number().min(0).max(6).optional(),
month: z.coerce.number().min(0).max(11).optional(),
});
export type ServerBackupSchema = z.infer<typeof serverBackupSchema>;
export const createServerSchema = z.object({
name: z.string().min(1),
ssh: sshSchema,
connection: connectionSchema,
isActive: z.boolean().optional(),
databases: z.string().array().min(1),
backup: serverBackupSchema.optional().nullable(),
});
export type CreateServerSchema = z.infer<typeof createServerSchema>;
export const updateServerSchema = createServerSchema.partial();
export type UpdateServerSchema = z.infer<typeof updateServerSchema>;
export const checkServerSchema = z.object({
ssh: sshSchema,
connection: connectionSchema,
+41 -11
View File
@@ -1,5 +1,5 @@
import db from "../db";
import { backupModel, serverModel } from "../db/models";
import { backupModel, databaseModel, serverModel } from "../db/models";
import type {
CreateBackupSchema,
GetAllBackupQuery,
@@ -8,6 +8,7 @@ import type {
import { and, count, desc, eq, inArray } from "drizzle-orm";
import DatabaseService from "./database.service";
import { HTTPException } from "hono/http-exception";
import ServerService from "./server.service";
export default class BackupService {
private databaseService = new DatabaseService();
@@ -58,19 +59,48 @@ export default class BackupService {
* Queue new backup
*/
async create(data: CreateBackupSchema) {
const database = await this.databaseService.getOrFail(data.databaseId);
await this.checkPendingBackup(database.id);
if (data.databaseId) {
const database = await this.databaseService.getOrFail(data.databaseId);
await this.checkPendingBackup(database.id);
const [result] = await db
.insert(backupModel)
.values({
const [result] = await db
.insert(backupModel)
.values({
type: "backup",
serverId: database.serverId,
databaseId: database.id,
})
.returning();
return result;
} else if (data.serverId) {
const databases = await db.query.database.findMany({
where: and(
eq(databaseModel.serverId, data.serverId),
eq(databaseModel.isActive, true)
),
});
if (!databases.length) {
throw new HTTPException(400, {
message: "No active databases found for this server.",
});
}
const values = databases.map((d) => ({
type: "backup",
serverId: database.serverId,
databaseId: database.id,
})
.returning();
serverId: d.serverId,
databaseId: d.id,
}));
return result;
const result = await db
.insert(backupModel)
.values(values as never)
.returning();
return result;
}
return null;
}
async restore(data: RestoreBackupSchema) {
+88 -2
View File
@@ -1,8 +1,12 @@
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 type {
CreateServerSchema,
UpdateServerSchema,
} from "../schemas/server.schema";
import { and, asc, desc, eq, ne } from "drizzle-orm";
import { HTTPException } from "hono/http-exception";
import dayjs from "dayjs";
export default class ServerService {
async getAll() {
@@ -61,6 +65,7 @@ export default class ServerService {
type: data.connection.type,
connection: data.connection ? JSON.stringify(data.connection) : null,
ssh: data.ssh ? JSON.stringify(data.ssh) : null,
nextBackup: this.calculateNextBackup(data as never),
};
// Create server
@@ -81,6 +86,47 @@ export default class ServerService {
});
}
async update(
server: Awaited<ReturnType<typeof this.getOrFail>>,
data: UpdateServerSchema
) {
if (data.name) {
const isExist = await db.query.servers.findFirst({
where: and(
ne(serverModel.id, server.id),
eq(serverModel.name, data.name)
),
});
if (isExist) {
throw new HTTPException(400, { message: "Server name already exists" });
}
}
const dataValue = {
...data,
type: data.connection?.type || server.type,
connection: data.connection
? JSON.stringify({
...data.connection,
pass: data.connection.pass || server.connection?.pass,
})
: undefined,
ssh: data.ssh ? JSON.stringify(data.ssh) : undefined,
nextBackup: data.backup
? this.calculateNextBackup(data as never)
: undefined,
};
// Update server
const [result] = await db
.update(serverModel)
.set(dataValue)
.where(eq(serverModel.id, server.id))
.returning();
return result;
}
parse<T extends Pick<ServerModel, "connection" | "ssh">>(data: T) {
const result = {
...data,
@@ -90,4 +136,44 @@ export default class ServerService {
return result;
}
calculateNextBackup(
server: Pick<ServerModel, "backup">,
from?: Date | string | null
) {
if (!server.backup?.scheduled) {
return null;
}
let date = dayjs(from);
const {
interval = "day",
every = 1,
time = "00:00",
day = 0,
month = 0,
} = server.backup || {};
const [hh, mm] = time.split(":").map(Number);
if (Number.isNaN(hh) || Number.isNaN(mm)) {
throw new Error("Invalid time format");
}
date = date.add(every || 1, interval);
if (interval !== "second") {
date = date.set("second", 0).set("millisecond", 0);
}
if (["day", "week", "month", "year"].includes(interval)) {
date = date.set("hour", hh).set("minute", mm);
}
if (["week", "month"].includes(interval)) {
date = date.set("day", day);
}
if (interval === "year") {
date = date.set("month", month).set("date", 1);
}
return date.format("YYYY-MM-DD HH:mm:ss");
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ export type PostgresConfig = {
type: "postgres";
host: string;
user: string;
pass: string;
pass?: string;
port?: number;
};