feat(settings): add cross-runtime database backup and restore
This commit is contained in:
1 parent
62ca9a5bae
commit
29f3b440d4
2 files changed
+214
-121
No files matched your search
+5
-1
@@ -3,7 +3,11 @@ FROM node:22-bookworm-slim AS base
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl openssl ca-certificates util-linux \
|
||||
&& apt-get install -y --no-install-recommends curl openssl ca-certificates util-linux gnupg \
|
||||
&& curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/postgresql.gpg \
|
||||
&& echo "deb [signed-by=/usr/share/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends postgresql-client-16 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM base AS deps
|
||||
|
||||
+209
-120
@@ -661,39 +661,50 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
const findLocalPostgresBinary = async (
|
||||
command: "pg_dump" | "pg_restore" | "psql",
|
||||
) => {
|
||||
if (process.platform !== "win32") return null;
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"powershell.exe",
|
||||
[
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"Get-Process -Name postgres -ErrorAction SilentlyContinue | ForEach-Object { try { $_.Path } catch { $null } } | Where-Object { $_ } | Sort-Object -Unique | ConvertTo-Json -Compress",
|
||||
],
|
||||
{ windowsHide: true, timeout: 5000, maxBuffer: 1024 * 1024 },
|
||||
);
|
||||
const parsed = JSON.parse(stdout.trim() || "[]") as string | string[];
|
||||
const postgresExecutables = Array.isArray(parsed) ? parsed : [parsed];
|
||||
|
||||
for (const postgresExecutable of postgresExecutables) {
|
||||
if (!postgresExecutable) continue;
|
||||
const candidate = join(
|
||||
postgresExecutable.replace(/[\\/]postgres(?:\.exe)?$/i, ""),
|
||||
`${command}.exe`,
|
||||
if (process.platform === "win32") {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"powershell.exe",
|
||||
[
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"Get-Process -Name postgres -ErrorAction SilentlyContinue | ForEach-Object { try { $_.Path } catch { $null } } | Where-Object { $_ } | Sort-Object -Unique | ConvertTo-Json -Compress",
|
||||
],
|
||||
{ windowsHide: true, timeout: 5000, maxBuffer: 1024 * 1024 },
|
||||
);
|
||||
try {
|
||||
await fs.access(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
// Try another active PostgreSQL process, if more than one exists.
|
||||
const parsed = JSON.parse(stdout.trim() || "[]") as string | string[];
|
||||
const postgresExecutables = Array.isArray(parsed) ? parsed : [parsed];
|
||||
|
||||
for (const postgresExecutable of postgresExecutables) {
|
||||
if (!postgresExecutable) continue;
|
||||
const candidate = join(
|
||||
postgresExecutable.replace(/[\\/]postgres(?:\.exe)?$/i, ""),
|
||||
`${command}.exe`,
|
||||
);
|
||||
try {
|
||||
await fs.access(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
// Try another active PostgreSQL process, if more than one exists.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// PowerShell may be unavailable or PostgreSQL may not be running locally.
|
||||
}
|
||||
} catch {
|
||||
// PowerShell/CIM may be unavailable or PostgreSQL may not be running locally.
|
||||
}
|
||||
|
||||
const executable = process.platform === "win32" ? `${command}.exe` : command;
|
||||
try {
|
||||
await execFileAsync(executable, ["--version"], {
|
||||
windowsHide: true,
|
||||
timeout: 5000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
return executable;
|
||||
} catch {
|
||||
// Continue to the Docker runner when the local client is unavailable.
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -706,105 +717,179 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
if (!databaseUrl) throw new Error("DATABASE_URL is not configured");
|
||||
const databaseCliUrl = getPostgresCliUrl(databaseUrl);
|
||||
|
||||
const run = (executable: string, commandArgs: string[], usePipe = false) => new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(executable, commandArgs, {
|
||||
env: { ...process.env, PGPASSFILE: undefined },
|
||||
stdio: [options.inputPath ? "pipe" : "ignore", usePipe ? "pipe" : "ignore", "pipe"],
|
||||
const run = (executable: string, commandArgs: string[], usePipe = false) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(executable, commandArgs, {
|
||||
env: { ...process.env, PGPASSFILE: undefined },
|
||||
stdio: [
|
||||
options.inputPath ? "pipe" : "ignore",
|
||||
usePipe ? "pipe" : "ignore",
|
||||
"pipe",
|
||||
],
|
||||
});
|
||||
let stderr = "";
|
||||
const output = options.outputPath
|
||||
? createWriteStream(options.outputPath)
|
||||
: null;
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
const childExit = new Promise<number>((resolveExit, rejectExit) => {
|
||||
child.once("error", rejectExit);
|
||||
child.once("close", (code) => resolveExit(code ?? 1));
|
||||
});
|
||||
const streamTasks: Promise<unknown>[] = [];
|
||||
if (options.inputPath)
|
||||
streamTasks.push(
|
||||
pipeline(createReadStream(options.inputPath), child.stdin!),
|
||||
);
|
||||
if (output && child.stdout)
|
||||
streamTasks.push(pipeline(child.stdout, output));
|
||||
void Promise.all([childExit, ...streamTasks])
|
||||
.then(([code]) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(stderr.trim() || `${command} failed`));
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
let stderr = "";
|
||||
const output = options.outputPath ? createWriteStream(options.outputPath) : null;
|
||||
child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); });
|
||||
const childExit = new Promise<number>((resolveExit, rejectExit) => {
|
||||
child.once("error", rejectExit);
|
||||
child.once("close", (code) => resolveExit(code ?? 1));
|
||||
});
|
||||
const streamTasks: Promise<unknown>[] = [];
|
||||
if (options.inputPath) streamTasks.push(pipeline(createReadStream(options.inputPath), child.stdin!));
|
||||
if (output && child.stdout) streamTasks.push(pipeline(child.stdout, output));
|
||||
void Promise.all([childExit, ...streamTasks])
|
||||
.then(([code]) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(stderr.trim() || `${command} failed`));
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
|
||||
const localBinary = await findLocalPostgresBinary(command);
|
||||
if (localBinary) {
|
||||
await run(localBinary, ["--dbname", databaseCliUrl, ...args], Boolean(options.outputPath));
|
||||
return;
|
||||
}
|
||||
|
||||
const databaseContainer =
|
||||
process.env.DATABASE_BACKUP_CONTAINER?.trim() || "doktainer-postgres";
|
||||
const dockerArgs = ["exec", "-i", databaseContainer, command, "--dbname", databaseCliUrl, ...args];
|
||||
try {
|
||||
await run("docker", dockerArgs, Boolean(options.outputPath));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
|
||||
throw new Error(
|
||||
process.platform === "win32"
|
||||
? "PostgreSQL lokal yang aktif tidak ditemukan dan Docker CLI juga tidak tersedia."
|
||||
: "Docker CLI tidak tersedia untuk menjalankan backup PostgreSQL.",
|
||||
);
|
||||
process.env.DATABASE_BACKUP_CONTAINER?.trim() ||
|
||||
`${process.env.COMPOSE_PROJECT_NAME?.trim() || "doktainer"}-postgres`;
|
||||
const dockerArgs = [
|
||||
"exec",
|
||||
"-i",
|
||||
databaseContainer,
|
||||
command,
|
||||
"--dbname",
|
||||
databaseCliUrl,
|
||||
...args,
|
||||
];
|
||||
const localBinary = await findLocalPostgresBinary(command);
|
||||
const runLocal = async () => {
|
||||
if (!localBinary) return false;
|
||||
await run(
|
||||
localBinary,
|
||||
["--dbname", databaseCliUrl, ...args],
|
||||
Boolean(options.outputPath),
|
||||
);
|
||||
return true;
|
||||
};
|
||||
const runDocker = async () => {
|
||||
try {
|
||||
await run("docker", dockerArgs, Boolean(options.outputPath));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") throw error;
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Prefer the PostgreSQL client directly in every runtime. Docker is only
|
||||
// an optional fallback for environments where the client is unavailable.
|
||||
if (await runLocal()) return;
|
||||
if (await runDocker()) return;
|
||||
|
||||
throw new Error(
|
||||
process.platform === "win32"
|
||||
? "No active local PostgreSQL instance or usable Docker runner was found."
|
||||
: "Neither Docker PostgreSQL runner nor local PostgreSQL client tools are available.",
|
||||
);
|
||||
};
|
||||
|
||||
app.get("/database-backup", { preHandler: databaseAccess }, async (_req, reply) => {
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[-:]/g, "")
|
||||
.replace(/\.\d{3}Z$/, "Z")
|
||||
.replace("T", "-");
|
||||
const downloadName = `doktainer-database-${timestamp}.dump`;
|
||||
const filePath = join(tmpdir(), `.${downloadName}`);
|
||||
try {
|
||||
await runDatabaseCommand("pg_dump", ["--format=custom"], { outputPath: filePath });
|
||||
reply.header("Content-Type", "application/octet-stream");
|
||||
reply.header("Content-Disposition", `attachment; filename="${downloadName}"`);
|
||||
const stream = createReadStream(filePath);
|
||||
stream.once("close", () => void fs.rm(filePath, { force: true }));
|
||||
return reply.send(stream);
|
||||
} catch (error) {
|
||||
return reply.status(500).send({ success: false, error: error instanceof Error ? error.message : "Database backup failed" });
|
||||
} finally {
|
||||
// The successful response owns cleanup through the stream close handler.
|
||||
if (!reply.sent) await fs.rm(filePath, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/database-restore", { preHandler: databaseAccess }, async (req, reply) => {
|
||||
const upload = await req.file();
|
||||
if (!upload) return reply.status(400).send({ success: false, error: "Select a PostgreSQL dump file" });
|
||||
const filePath = join(tmpdir(), `doktainer-restore-${Date.now()}.dump`);
|
||||
try {
|
||||
await pipeline(upload.file, createWriteStream(filePath));
|
||||
const header = Buffer.alloc(5);
|
||||
const handle = await fs.open(filePath, "r");
|
||||
app.get(
|
||||
"/database-backup",
|
||||
{ preHandler: databaseAccess },
|
||||
async (_req, reply) => {
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[-:]/g, "")
|
||||
.replace(/\.\d{3}Z$/, "Z")
|
||||
.replace("T", "-");
|
||||
const downloadName = `doktainer-database-${timestamp}.dump`;
|
||||
const filePath = join(tmpdir(), `.${downloadName}`);
|
||||
try {
|
||||
await handle.read(header, 0, header.length, 0);
|
||||
await runDatabaseCommand("pg_dump", ["--format=custom"], {
|
||||
outputPath: filePath,
|
||||
});
|
||||
reply.header("Content-Type", "application/octet-stream");
|
||||
reply.header(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${downloadName}"`,
|
||||
);
|
||||
const stream = createReadStream(filePath);
|
||||
stream.once("close", () => void fs.rm(filePath, { force: true }));
|
||||
return reply.send(stream);
|
||||
} catch (error) {
|
||||
return reply
|
||||
.status(500)
|
||||
.send({
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Database backup failed",
|
||||
});
|
||||
} finally {
|
||||
await handle.close();
|
||||
// The successful response owns cleanup through the stream close handler.
|
||||
if (!reply.sent) await fs.rm(filePath, { force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const isCustomArchive = header.toString("ascii") === "PGDMP";
|
||||
await runDatabaseCommand(
|
||||
isCustomArchive ? "pg_restore" : "psql",
|
||||
isCustomArchive
|
||||
? ["--clean", "--if-exists", "--no-owner", "--exit-on-error"]
|
||||
: ["--set", "ON_ERROR_STOP=1"],
|
||||
{ inputPath: filePath },
|
||||
);
|
||||
await auditLog({ userId: req.userId, organizationId: req.organizationId, action: "DATABASE_RESTORE", category: "SYSTEM", level: "WARNING", message: "Doktainer database restored from uploaded dump" });
|
||||
return reply.send({ success: true, message: "Database restore completed" });
|
||||
} catch (error) {
|
||||
return reply.status(500).send({ success: false, error: error instanceof Error ? error.message : "Database restore failed" });
|
||||
} finally {
|
||||
await fs.rm(filePath, { force: true });
|
||||
}
|
||||
});
|
||||
app.post(
|
||||
"/database-restore",
|
||||
{ preHandler: databaseAccess },
|
||||
async (req, reply) => {
|
||||
const upload = await req.file();
|
||||
if (!upload)
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: "Select a PostgreSQL dump file" });
|
||||
const filePath = join(tmpdir(), `doktainer-restore-${Date.now()}.dump`);
|
||||
try {
|
||||
await pipeline(upload.file, createWriteStream(filePath));
|
||||
const header = Buffer.alloc(5);
|
||||
const handle = await fs.open(filePath, "r");
|
||||
try {
|
||||
await handle.read(header, 0, header.length, 0);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
|
||||
const isCustomArchive = header.toString("ascii") === "PGDMP";
|
||||
await runDatabaseCommand(
|
||||
isCustomArchive ? "pg_restore" : "psql",
|
||||
isCustomArchive
|
||||
? ["--clean", "--if-exists", "--no-owner", "--exit-on-error"]
|
||||
: ["--set", "ON_ERROR_STOP=1"],
|
||||
{ inputPath: filePath },
|
||||
);
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
action: "DATABASE_RESTORE",
|
||||
category: "SYSTEM",
|
||||
level: "WARNING",
|
||||
message: "Doktainer database restored from uploaded dump",
|
||||
});
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: "Database restore completed",
|
||||
});
|
||||
} catch (error) {
|
||||
return reply
|
||||
.status(500)
|
||||
.send({
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Database restore failed",
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(filePath, { force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
app.get("/", { preHandler: [authenticate] }, async (req, reply) => {
|
||||
const bundle = await ensureModularSettings(req.userId!, req.organizationId);
|
||||
return reply.send({ success: true, data: serializeSettings(bundle) });
|
||||
@@ -866,10 +951,12 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Panel domain provisioning failed";
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Panel domain provisioning failed";
|
||||
const statusCode =
|
||||
typeof (error as { statusCode?: unknown })?.statusCode === "number"
|
||||
? ((error as { statusCode: number }).statusCode)
|
||||
? (error as { statusCode: number }).statusCode
|
||||
: 400;
|
||||
|
||||
await auditLog({
|
||||
@@ -882,7 +969,9 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
meta: body.data,
|
||||
}).catch(() => undefined);
|
||||
|
||||
return reply.status(statusCode).send({ success: false, error: message });
|
||||
return reply
|
||||
.status(statusCode)
|
||||
.send({ success: false, error: message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user