From bf1476d2fdffd0c48f6fa3156d1694ebc6f51118 Mon Sep 17 00:00:00 2001 From: Khairul Hidayat Date: Mon, 13 May 2024 02:19:53 +0700 Subject: [PATCH] chore: initial build --- .dockerignore | 7 ++++ .gitignore | 1 + Dockerfile | 32 +++++++++++++++ README.md | 9 +++++ backend/.gitignore | 1 + backend/package.json | 8 ++-- backend/src/consts.ts | 4 +- backend/src/db/migrate.ts | 22 +++++----- backend/src/lib/database-util.ts | 14 +++++-- backend/src/lib/dbms/base.ts | 8 +++- backend/src/lib/dbms/postgres.ts | 40 +++++++++++++------ backend/src/main.ts | 20 ++++++++-- backend/src/routers/backup.router.ts | 15 ++++++- backend/src/routers/server.router.ts | 2 +- backend/src/schedulers/index.ts | 3 +- backend/src/schedulers/process-backup.ts | 14 ++++--- backend/src/services/backup.service.ts | 6 +++ backend/src/services/server.service.ts | 7 +++- backend/src/types/database.types.ts | 4 ++ backend/src/utility/process.ts | 4 +- docker-compose.yml | 13 ++++++ entrypoint.sh | 11 +++++ frontend/src/lib/api.ts | 3 +- .../pages/home/components/server-section.tsx | 8 +++- .../servers/components/backup-status.tsx | 8 ++-- .../components/server-form-backup-tab.tsx | 6 ++- .../servers/components/server-form-dialog.tsx | 11 ++++- .../view/components/backups-section.tsx | 1 + frontend/src/pages/servers/view/table.tsx | 1 + package.json | 5 ++- 30 files changed, 230 insertions(+), 58 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 entrypoint.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f4540cd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +**/node_modules +**/storage +**/dist +backend/public +.gitignore +.npmrc +*.md diff --git a/.gitignore b/.gitignore index c07ea0f..47210a9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ +storage/ node_modules/ pnpm-lock.yaml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e77071f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +FROM oven/bun:alpine AS build +WORKDIR /app + +ENV VITE_BACKEND_URL=/api + +COPY ["package.json", "bun.lockb", "./"] +COPY ["frontend/package.json", "frontend/bun.lockb", "./frontend/"] +COPY ["backend/package.json", "backend/bun.lockb", "./backend/"] + +RUN cd frontend && bun install && cd ../backend && bun install + +COPY . . + +RUN cd frontend && bun run build +RUN cd backend && bun run build + +FROM oven/bun:alpine AS app +WORKDIR /app + +COPY ["backend/package.json", "backend/bun.lockb", "./"] +RUN bun install --production && rm -rf ~/.bun/install/cache + +# Add db clients +RUN apk --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/main add postgresql16-client + +COPY --from=build /app/backend . +COPY --from=build /app/frontend/dist ./public/ +COPY entrypoint.sh . + +EXPOSE 3000 + +ENTRYPOINT ["sh", "entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..048f4c5 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# DB Backup Tool + +Web-based Database Backup Tool + +## Install + +```bash +~$ docker run --name db-backup -p 3000:3000 -v ./storage:/app/storage khairul169/db-backup +``` diff --git a/backend/.gitignore b/backend/.gitignore index 8078b14..53f814b 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,6 +1,7 @@ dist/ node_modules/ storage/ +public/ package-lock.json bun.lockb .env diff --git a/backend/package.json b/backend/package.json index 1616aed..8d0d6ff 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "bun --watch src/main.ts", "dev:compose": "cp ../bun.lockb . && docker compose -f docker-compose.dev.yml up --build", - "build": "bun build src/main.ts --outdir dist --target bun", + "build": "NODE_ENV=production bun build src/main.ts --outdir dist --target bun", "start": "bun dist/main.js", "generate": "drizzle-kit generate", "migrate": "bun src/db/migrate.ts", @@ -14,10 +14,8 @@ "devDependencies": { "@types/bun": "latest", "@types/node-schedule": "^2.1.7", - "drizzle-kit": "^0.21.0" - }, - "peerDependencies": { - "typescript": "^5.0.0" + "drizzle-kit": "^0.21.0", + "typescript": "^5.4.5" }, "dependencies": { "@hono/zod-validator": "^0.2.1", diff --git a/backend/src/consts.ts b/backend/src/consts.ts index e4b3952..0ec7374 100644 --- a/backend/src/consts.ts +++ b/backend/src/consts.ts @@ -1,6 +1,8 @@ import path from "path"; +export const __PROD = process.env.NODE_ENV === "production"; +export const __DEV = !__PROD; export const DOCKER_HOST = "host.docker.internal"; -export const STORAGE_DIR = path.resolve(__dirname, "../storage"); +export const STORAGE_DIR = path.resolve(process.cwd(), "storage"); export const BACKUP_DIR = STORAGE_DIR + "/backups"; export const DATABASE_PATH = path.join(STORAGE_DIR, "database.db"); diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 408157d..ba8abf6 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -1,17 +1,21 @@ import fs from "fs"; -import { migrate } from "drizzle-orm/bun-sqlite/migrator"; +import { migrate as migrator } from "drizzle-orm/bun-sqlite/migrator"; import { DATABASE_PATH } from "../consts"; import db, { sqlite } from "."; import { seed } from "./seed"; -const initializeData = fs.existsSync(DATABASE_PATH); +const initializeData = !fs.existsSync(DATABASE_PATH); -await migrate(db, { - migrationsFolder: __dirname + "/migrations", -}); +const migrate = async () => { + migrator(db, { + migrationsFolder: __dirname + "/migrations", + }); -if (initializeData) { - await seed(); -} + if (initializeData) { + await seed(); + } -await sqlite.close(); + sqlite.close(); +}; + +migrate(); diff --git a/backend/src/lib/database-util.ts b/backend/src/lib/database-util.ts index 82b721f..806f9c5 100644 --- a/backend/src/lib/database-util.ts +++ b/backend/src/lib/database-util.ts @@ -1,6 +1,10 @@ import BaseDbms from "./dbms/base"; import PostgresDbms from "./dbms/postgres"; -import type { DatabaseConfig, DatabaseListItem } from "../types/database.types"; +import type { + DatabaseConfig, + DatabaseListItem, + DumpOptions, +} from "../types/database.types"; class DatabaseUtil { private db = new BaseDbms(); @@ -19,8 +23,12 @@ class DatabaseUtil { return this.db.getDatabases(); } - async dump(dbName: string, path: string): Promise { - return this.db.dump(dbName, path); + async dump( + dbName: string, + path: string, + options?: DumpOptions + ): Promise { + return this.db.dump(dbName, path, options); } async restore(path: string): Promise { diff --git a/backend/src/lib/dbms/base.ts b/backend/src/lib/dbms/base.ts index 536786e..069d507 100644 --- a/backend/src/lib/dbms/base.ts +++ b/backend/src/lib/dbms/base.ts @@ -1,11 +1,15 @@ -import type { DatabaseListItem } from "../../types/database.types"; +import type { DatabaseListItem, DumpOptions } from "../../types/database.types"; class BaseDbms { async getDatabases(): Promise { return []; } - async dump(_dbName: string, _path: string): Promise { + async dump( + _dbName: string, + _path: string, + _options?: DumpOptions + ): Promise { return ""; } diff --git a/backend/src/lib/dbms/postgres.ts b/backend/src/lib/dbms/postgres.ts index 393bd25..fc41f1e 100644 --- a/backend/src/lib/dbms/postgres.ts +++ b/backend/src/lib/dbms/postgres.ts @@ -1,7 +1,9 @@ import type { DatabaseListItem, + DumpOptions, PostgresConfig, } from "../../types/database.types"; +import path from "path"; import { exec } from "../../utility/process"; import { urlencode } from "../../utility/utils"; import BaseDbms from "./base"; @@ -18,21 +20,33 @@ class PostgresDbms extends BaseDbms { ); } - async dump(dbName: string, path: string) { - return exec(["pg_dump", this.dbUrl + `/${dbName}`, "-Z9", "-f", path]); + async dump(dbName: string, path: string, options: DumpOptions = {}) { + const { compress } = options; + const ext = compress ? ".gz" : ".sql"; + const filename = path + ext; + + await exec([ + "pg_dump", + this.dbUrl + `/${dbName}`, + "-Cc", + compress ? "-Z9" : null, + "-f", + filename, + ]); + + return filename; } - async restore(path: string) { - return exec([ - "pg_restore", - "-d", - this.dbUrl, - "-cC", - "--if-exists", - "--exit-on-error", - // "-Ftar", - path, - ]); + async restore(backupFile: string) { + const ext = path.extname(backupFile); + const isCompressed = ext === ".gz"; + let cmd = `psql ${this.dbUrl} < ${backupFile}`; + + if (isCompressed) { + cmd = `zcat ${backupFile} | psql ${this.dbUrl}`; + } + + return exec(["sh", "-c", cmd]); } private async sql(query: string) { diff --git a/backend/src/main.ts b/backend/src/main.ts index d7e905f..8d99897 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,8 +1,22 @@ +import { Hono } from "hono"; import routers from "./routers"; import { initScheduler } from "./schedulers"; +import { __PROD } from "./consts"; +import { serveStatic } from "hono/bun"; -console.log("Starting app.."); - +const app = new Hono(); initScheduler(); -export default routers; +// Add API routes +app.route(__PROD ? "/api" : "/", routers); + +// Serve frontend +if (__PROD) { + app.use(serveStatic({ root: "./public" })); + app.use("*", serveStatic({ path: "./public/index.html" })); + + const PORT = Number(process.env.PORT) || 3000; + console.log(`App listening on http://localhost:${PORT}`); +} + +export default app; diff --git a/backend/src/routers/backup.router.ts b/backend/src/routers/backup.router.ts index 5f5e1cf..4f2a6e9 100644 --- a/backend/src/routers/backup.router.ts +++ b/backend/src/routers/backup.router.ts @@ -1,3 +1,4 @@ +import { processBackup } from "../schedulers/process-backup"; import { createBackupSchema, getAllBackupQuery, @@ -18,12 +19,22 @@ const router = new Hono() .post("/", zValidator("json", createBackupSchema), async (c) => { const body = c.req.valid("json"); - return c.json(await backupService.create(body)); + const result = await backupService.create(body); + + // start backup scheduler + processBackup(); + + return c.json(result); }) .post("/restore", zValidator("json", restoreBackupSchema), async (c) => { const body = c.req.valid("json"); - return c.json(await backupService.restore(body)); + const result = await backupService.restore(body); + + // start restore scheduler + processBackup(); + + return c.json(result); }); export default router; diff --git a/backend/src/routers/server.router.ts b/backend/src/routers/server.router.ts index 0006750..f656f98 100644 --- a/backend/src/routers/server.router.ts +++ b/backend/src/routers/server.router.ts @@ -46,7 +46,7 @@ const router = new Hono() return c.json({ success: true, databases }); } catch (err) { throw new HTTPException(400, { - message: "Cannot connect to the database.", + message: (err as any).message || "Cannot connect to the database.", }); } }) diff --git a/backend/src/schedulers/index.ts b/backend/src/schedulers/index.ts index 3f661e0..810fc5d 100644 --- a/backend/src/schedulers/index.ts +++ b/backend/src/schedulers/index.ts @@ -4,6 +4,5 @@ import { backupScheduler } from "./backup-scheduler"; export const initScheduler = () => { scheduler.scheduleJob("*/10 * * * * *", processBackup); - // scheduler.scheduleJob("* * * * * *", backupScheduler); - backupScheduler(); + scheduler.scheduleJob("* * * * * *", backupScheduler); }; diff --git a/backend/src/schedulers/process-backup.ts b/backend/src/schedulers/process-backup.ts index c822eb3..1e57bb8 100644 --- a/backend/src/schedulers/process-backup.ts +++ b/backend/src/schedulers/process-backup.ts @@ -25,11 +25,15 @@ const runBackup = async (task: PendingTasks[number]) => { if (task.type === "backup") { const key = path.join(server.connection.host, dbName, `${Date.now()}`); - const outFile = path.join(BACKUP_DIR, key); + let outFile = path.join(BACKUP_DIR, key); mkdir(path.dirname(outFile)); // Run database dump command - const output = await dbUtil.dump(dbName, outFile); + const filename = await dbUtil.dump(dbName, outFile, { + compress: task.server.backup?.compress, + }); + const ext = path.extname(filename); + outFile = outFile + ext; // Get file stats and file checksum const fileStats = fs.statSync(outFile); @@ -40,8 +44,8 @@ const runBackup = async (task: PendingTasks[number]) => { .update(backupModel) .set({ status: "success", - output, - key, + output: "", + key: key + ext, hash: sha256Hash, size: fileStats.size, }) @@ -90,7 +94,7 @@ const getPendingTasks = async () => { orderBy: (i) => asc(i.createdAt), with: { server: { - columns: { connection: true, ssh: true }, + columns: { connection: true, ssh: true, backup: true }, }, database: { columns: { name: true }, diff --git a/backend/src/services/backup.service.ts b/backend/src/services/backup.service.ts index 739a104..2ba3ee3 100644 --- a/backend/src/services/backup.service.ts +++ b/backend/src/services/backup.service.ts @@ -106,6 +106,12 @@ export default class BackupService { const backup = await this.getOrFail(data.backupId); await this.checkPendingBackup(backup.databaseId); + if (backup.status !== "success") { + throw new HTTPException(400, { + message: "Cannot restore backup that is not success.", + }); + } + if (!backup.key) { throw new HTTPException(400, { message: "Cannot restore backup without file key.", diff --git a/backend/src/services/server.service.ts b/backend/src/services/server.service.ts index 380162b..01cc957 100644 --- a/backend/src/services/server.service.ts +++ b/backend/src/services/server.service.ts @@ -82,7 +82,12 @@ export default class ServerService { })) ); - return data; + const server = this.parse(result); + if (server.connection?.pass) { + delete server.connection.pass; + } + + return server; }); } diff --git a/backend/src/types/database.types.ts b/backend/src/types/database.types.ts index f93de9d..8610a35 100644 --- a/backend/src/types/database.types.ts +++ b/backend/src/types/database.types.ts @@ -12,3 +12,7 @@ export type DatabaseListItem = { name: string; size: string; }; + +export type DumpOptions = Partial<{ + compress: boolean; +}>; diff --git a/backend/src/utility/process.ts b/backend/src/utility/process.ts index cce635b..c6f2e1c 100644 --- a/backend/src/utility/process.ts +++ b/backend/src/utility/process.ts @@ -5,10 +5,10 @@ type ExecOptions = { }; export const exec = async ( - cmds: string[], + cmds: (string | null | undefined)[], options: Partial = {} ) => { - const proc = Bun.spawn(cmds, { + const proc = Bun.spawn(cmds.filter((i) => i != null) as string[], { env: options.env, stderr: "pipe", }); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d7bf5d1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +version: "3" + +services: + app: + container_name: db-backup + build: + context: . + volumes: + - ./storage:/app/storage:rw + extra_hosts: + - "host.docker.internal:host-gateway" + ports: + - "3000:3000" diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..f10ccd6 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,11 @@ +#!/bin/sh + +set -e + +# Run migration +bun run migrate & PID=$! +wait $PID + +# Start app +bun start & PID=$! +wait $PID diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4922e53..2929353 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,7 +1,8 @@ import { ClientResponse, hc } from "hono/client"; import type { AppRouter } from "@backend/routers"; -const api = hc("http://localhost:3000/"); +const BACKEND_URL = import.meta.env.VITE_BACKEND_URL; +const api = hc(BACKEND_URL || "http://localhost:3000/"); export const parseJson = async (res: ClientResponse) => { const json = await res.json(); diff --git a/frontend/src/pages/home/components/server-section.tsx b/frontend/src/pages/home/components/server-section.tsx index 3d9e034..0e312e0 100644 --- a/frontend/src/pages/home/components/server-section.tsx +++ b/frontend/src/pages/home/components/server-section.tsx @@ -16,7 +16,13 @@ const ServerSection = () => { return (
- Servers +
+ Servers + + +
{isLoading ? (
Loading...
diff --git a/frontend/src/pages/servers/components/backup-status.tsx b/frontend/src/pages/servers/components/backup-status.tsx index e4e081b..c316774 100644 --- a/frontend/src/pages/servers/components/backup-status.tsx +++ b/frontend/src/pages/servers/components/backup-status.tsx @@ -45,7 +45,7 @@ const BackupStatus = ({ status, output }: Props) => { {

{labels[status]}

- -

{output}

+ +
); diff --git a/frontend/src/pages/servers/components/server-form-backup-tab.tsx b/frontend/src/pages/servers/components/server-form-backup-tab.tsx index f092053..80f299c 100644 --- a/frontend/src/pages/servers/components/server-form-backup-tab.tsx +++ b/frontend/src/pages/servers/components/server-form-backup-tab.tsx @@ -54,7 +54,11 @@ const BackupTab = () => { return ( - + { const { isOpen, data } = serverFormDlg.useState(); + const navigate = useNavigate(); const form = useForm({ resolver: zodResolver(serverFormSchema), defaultValues: data, @@ -44,9 +47,13 @@ const ServerFormDialog = () => { return parseJson(res); } }, - onSuccess: () => { + onSuccess: (data) => { serverFormDlg.onClose(); queryClient.invalidateQueries("servers"); + navigate(`/servers/${data.id}`); + }, + onError: (err) => { + toast.error((err as Error)?.message || "Failed to save server"); }, }); @@ -78,7 +85,7 @@ const ServerFormDialog = () => {