483 lines
16 KiB
TypeScript
483 lines
16 KiB
TypeScript
import express from "express";
|
|
import {
|
|
Body,
|
|
Controller,
|
|
Delete,
|
|
Get,
|
|
Path,
|
|
Post,
|
|
Put,
|
|
Query,
|
|
Request,
|
|
Route,
|
|
Security,
|
|
} from "tsoa";
|
|
import HttpError from "../interfaces/http-error";
|
|
import HttpStatus from "../interfaces/http-status";
|
|
import { randomUUID } from "crypto";
|
|
import { getFile } from "../services/minio";
|
|
import { sendWebSocket } from "../services/socket";
|
|
|
|
function getEnvVar(environmentName: string) {
|
|
const environmentValue = process.env[environmentName];
|
|
if (!environmentValue) throw new Error(`${environmentName} is required.`);
|
|
return environmentValue;
|
|
}
|
|
|
|
const WINDMILL_URL = getEnvVar("WINDMILL_URL");
|
|
const WINDMILL_WORKSPACE = getEnvVar("WINDMILL_WORKSPACE");
|
|
const WINDMILL_BACKUP_FLOW_PATH = getEnvVar("WINDMILL_BACKUP_FLOW_PATH");
|
|
const WINDMILL_RESTORE_FLOW_PATH = getEnvVar("WINDMILL_RESTORE_FLOW_PATH");
|
|
const WINDMILL_BACKUP_DELETE_SCRIPT_PATH = getEnvVar("WINDMILL_BACKUP_DELETE_SCRIPT_PATH");
|
|
const WINDMILL_BACKUP_LIST_SCRIPT_PATH = getEnvVar("WINDMILL_BACKUP_LIST_SCRIPT_PATH");
|
|
const WINDMILL_API_KEY = getEnvVar("WINDMILL_API_KEY");
|
|
const DB_HOST = getEnvVar("DB_HOST");
|
|
const DB_PORT = process.env.DB_PORT;
|
|
const DB_USERNAME = getEnvVar("DB_USERNAME");
|
|
const DB_PASSWORD = getEnvVar("DB_PASSWORD");
|
|
const DB_LIST = process.env.DB_LIST;
|
|
const MAIN_MINIO_USE_SSL = getEnvVar("MAIN_MINIO_USE_SSL");
|
|
const MAIN_MINIO_HOST = getEnvVar("MAIN_MINIO_HOST");
|
|
const MAIN_MINIO_PORT = process.env.MAIN_MINIO_PORT;
|
|
const MAIN_MINIO_ACCESS_KEY = getEnvVar("MAIN_MINIO_ACCESS_KEY");
|
|
const MAIN_MINIO_SECRET_KEY = getEnvVar("MAIN_MINIO_SECRET_KEY");
|
|
const MAIN_MINIO_BUCKET = getEnvVar("MAIN_MINIO_BUCKET");
|
|
const BACKUP_MINIO_BUCKET = getEnvVar("BACKUP_MINIO_BUCKET");
|
|
|
|
const s3TargetUrl = `${MAIN_MINIO_USE_SSL === "true" ? "https://" : "http://"}${MAIN_MINIO_HOST}${(MAIN_MINIO_PORT && ":" + MAIN_MINIO_PORT) || ""}`;
|
|
const s3BackupUrl = `${MAIN_MINIO_USE_SSL === "true" ? "https://" : "http://"}${MAIN_MINIO_HOST}${(MAIN_MINIO_PORT && ":" + MAIN_MINIO_PORT) || ""}`;
|
|
|
|
function jsonParseOrPlainText(str: string) {
|
|
try {
|
|
return JSON.parse(str);
|
|
} catch (_) {
|
|
return str;
|
|
}
|
|
}
|
|
|
|
@Route("/api/v1/backup")
|
|
export class BackupController extends Controller {
|
|
@Get()
|
|
@Security("keycloak")
|
|
async listBackup() {
|
|
const data = await fetch(
|
|
`${WINDMILL_URL}/api/w/${WINDMILL_WORKSPACE}/jobs/run_wait_result/p/${WINDMILL_BACKUP_LIST_SCRIPT_PATH}`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${WINDMILL_API_KEY}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
s3_backup_endpoint: s3BackupUrl,
|
|
s3_backup_access: MAIN_MINIO_ACCESS_KEY,
|
|
s3_backup_secret: MAIN_MINIO_SECRET_KEY,
|
|
s3_backup_bucket: BACKUP_MINIO_BUCKET,
|
|
}),
|
|
},
|
|
).then(async (r) => {
|
|
const data = await r.json();
|
|
if (typeof data === "object" && "error" in data) {
|
|
console.error(data);
|
|
throw new Error("Cannot get status.");
|
|
}
|
|
return JSON.parse(data) as { database: Record<string, any>[]; bucket: Record<string, any>[] };
|
|
});
|
|
|
|
return data.database
|
|
.flatMap((a) =>
|
|
a.type === "file"
|
|
? {
|
|
name: a.key.replace(".sql.gz", "") as string,
|
|
databaseSize: a.size as number,
|
|
storageSize: data.bucket.find((b) => a.key.replace(".sql.gz", "") === b.prefix)
|
|
?.size as number,
|
|
timestamp: a.lastModified as string,
|
|
}
|
|
: [],
|
|
)
|
|
.reverse();
|
|
}
|
|
|
|
@Get("backup-running-list")
|
|
@Security("keycloak")
|
|
async runningBackupStatus() {
|
|
return await fetch(
|
|
`${WINDMILL_URL}/api/w/${WINDMILL_WORKSPACE}/jobs/list?running=true&script_path_exact=${WINDMILL_BACKUP_FLOW_PATH}`,
|
|
{
|
|
headers: { Authorization: `Bearer ${WINDMILL_API_KEY}` },
|
|
},
|
|
).then(async (r) => {
|
|
const data = await r.json();
|
|
if (typeof data === "object" && "error" in data) {
|
|
console.error(data);
|
|
throw new Error("Cannot get status.");
|
|
}
|
|
return data as Record<string, any>[];
|
|
});
|
|
}
|
|
|
|
@Get("restore-running-list")
|
|
@Security("keycloak")
|
|
async runningRestoreStatus() {
|
|
return await fetch(
|
|
`${WINDMILL_URL}/api/w/${WINDMILL_WORKSPACE}/jobs/list?running=true&script_path_exact=${WINDMILL_RESTORE_FLOW_PATH}`,
|
|
{
|
|
headers: { Authorization: `Bearer ${WINDMILL_API_KEY}` },
|
|
},
|
|
).then(async (r) => jsonParseOrPlainText(await r.text()));
|
|
}
|
|
|
|
@Post("create")
|
|
@Security("keycloak")
|
|
async runBackup(
|
|
@Request() req: Request & { user: { sub: string; preferred_username: string } },
|
|
@Body() body?: { name?: string },
|
|
) {
|
|
const timestamp = Math.round(Date.now() / 1000);
|
|
const name =
|
|
body?.name && body.name !== "auto-backup"
|
|
? `${timestamp}-${body.name}`
|
|
: `${timestamp}-manual`;
|
|
|
|
const listRunning = await this.runningBackupStatus();
|
|
|
|
if (!listRunning || listRunning.length > 0) {
|
|
throw new HttpError(HttpStatus.NOT_ACCEPTABLE, "Cannot create two backup at the same time.");
|
|
}
|
|
|
|
return await fetch(
|
|
`${WINDMILL_URL}/api/w/${WINDMILL_WORKSPACE}/jobs/run/f/${WINDMILL_BACKUP_FLOW_PATH}`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${WINDMILL_API_KEY}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
triggerUserId: req.user.sub,
|
|
backup_name: name,
|
|
storage: {
|
|
s3_source_endpoint: s3TargetUrl,
|
|
s3_source_access: MAIN_MINIO_ACCESS_KEY,
|
|
s3_source_secret: MAIN_MINIO_SECRET_KEY,
|
|
s3_source_bucket: MAIN_MINIO_BUCKET.replaceAll(",", " "),
|
|
s3_dest_endpoint: s3BackupUrl,
|
|
s3_dest_access: MAIN_MINIO_ACCESS_KEY,
|
|
s3_dest_secret: MAIN_MINIO_SECRET_KEY,
|
|
},
|
|
database: {
|
|
s3_endpoint: s3BackupUrl,
|
|
s3_access: MAIN_MINIO_ACCESS_KEY,
|
|
s3_secret: MAIN_MINIO_SECRET_KEY,
|
|
s3_bucket: BACKUP_MINIO_BUCKET,
|
|
db_host: DB_HOST,
|
|
db_port: DB_PORT,
|
|
db_user: DB_USERNAME,
|
|
db_password: DB_PASSWORD,
|
|
db_list: DB_LIST?.replaceAll(",", " "),
|
|
},
|
|
metadata: {
|
|
triggered_by: req.user.preferred_username,
|
|
triggered_by_id: req.user.sub,
|
|
},
|
|
}),
|
|
},
|
|
).then(async (r) => jsonParseOrPlainText(await r.text()));
|
|
}
|
|
|
|
@Get("download/{name}")
|
|
@Security("keycloak")
|
|
async downloadBackup(
|
|
@Request() req: express.Request,
|
|
@Path() name: string,
|
|
@Query() redirect?: boolean,
|
|
) {
|
|
const url = await getFile(`${name}.sql.gz`);
|
|
if (redirect) {
|
|
return req.res?.redirect(url);
|
|
}
|
|
return url;
|
|
}
|
|
|
|
@Post("restore")
|
|
@Security("keycloak")
|
|
async restoreBackup(
|
|
@Request() req: Request & { user: { sub: string; preferred_username: string } },
|
|
@Body() body: { name: string },
|
|
) {
|
|
const listRunning = await this.runningRestoreStatus();
|
|
|
|
if (!listRunning || listRunning.length > 0) {
|
|
throw new HttpError(HttpStatus.NOT_ACCEPTABLE, "Cannot restore two backup at the same time.");
|
|
}
|
|
|
|
return await fetch(
|
|
`${WINDMILL_URL}/api/w/${WINDMILL_WORKSPACE}/jobs/run/f/${WINDMILL_RESTORE_FLOW_PATH}`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${WINDMILL_API_KEY}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
backup_name: body.name,
|
|
storage: {
|
|
s3_restore_endpoint: s3TargetUrl,
|
|
s3_restore_access: MAIN_MINIO_ACCESS_KEY,
|
|
s3_restore_secret: MAIN_MINIO_SECRET_KEY,
|
|
s3_restore_bucket: MAIN_MINIO_BUCKET.replaceAll(",", " "),
|
|
s3_backup_endpoint: s3BackupUrl,
|
|
s3_backup_access: MAIN_MINIO_ACCESS_KEY,
|
|
s3_backup_secret: MAIN_MINIO_SECRET_KEY,
|
|
},
|
|
database: {
|
|
s3_endpoint: s3BackupUrl,
|
|
s3_access: MAIN_MINIO_ACCESS_KEY,
|
|
s3_secret: MAIN_MINIO_SECRET_KEY,
|
|
s3_bucket: BACKUP_MINIO_BUCKET,
|
|
db_host: DB_HOST,
|
|
db_port: DB_PORT,
|
|
db_user: DB_USERNAME,
|
|
db_password: DB_PASSWORD,
|
|
},
|
|
metadata: {
|
|
triggered_by: req.user.preferred_username,
|
|
triggered_by_id: req.user.sub,
|
|
},
|
|
}),
|
|
},
|
|
).then(async (r) => jsonParseOrPlainText(await r.text()));
|
|
}
|
|
|
|
@Delete("delete")
|
|
@Security("keycloak")
|
|
async deleteBackup(@Body() body: { name: string }) {
|
|
await fetch(
|
|
`${WINDMILL_URL}/api/w/${WINDMILL_WORKSPACE}/jobs/run/p/${WINDMILL_BACKUP_DELETE_SCRIPT_PATH}`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${WINDMILL_API_KEY}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
backup_name: body.name,
|
|
s3_backup_endpoint: s3BackupUrl,
|
|
s3_backup_access: MAIN_MINIO_ACCESS_KEY,
|
|
s3_backup_secret: MAIN_MINIO_SECRET_KEY,
|
|
s3_backup_bucket: BACKUP_MINIO_BUCKET,
|
|
}),
|
|
},
|
|
).then(async (r) => {
|
|
return jsonParseOrPlainText(await r.text());
|
|
});
|
|
}
|
|
|
|
@Get("schedule")
|
|
@Security("keycloak")
|
|
async listSchedule() {
|
|
const result = await fetch(
|
|
`${WINDMILL_URL}/api/w/${WINDMILL_WORKSPACE}/schedules/list?path=${WINDMILL_BACKUP_FLOW_PATH}`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${WINDMILL_API_KEY}`,
|
|
},
|
|
},
|
|
).then(async (r) => {
|
|
const data = await r.json();
|
|
if (typeof data === "object" && "error" in data) {
|
|
console.error(data);
|
|
throw new Error("Cannot get status.");
|
|
}
|
|
return data as Record<string, any>[];
|
|
});
|
|
|
|
return result.map((v) => ({
|
|
id: v.path.replace("f/backup_schedule/", ""),
|
|
name: v.summary,
|
|
schedule: v.schedule,
|
|
enabled: v.enabled,
|
|
startAt: v.paused_until,
|
|
}));
|
|
}
|
|
|
|
@Post("schedule")
|
|
@Security("keycloak")
|
|
async createSchedule(
|
|
@Body() body: { name: string; schedule: string; timezone?: string; startAt?: Date },
|
|
) {
|
|
if (!/^[a-zA-Z0-9\-]+$/.test(body.name)) {
|
|
throw new HttpError(HttpStatus.BAD_REQUEST, "Invalid name.");
|
|
}
|
|
|
|
return await fetch(`${WINDMILL_URL}/api/w/${WINDMILL_WORKSPACE}/schedules/create`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${WINDMILL_API_KEY}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
schedule: body.schedule,
|
|
summary: body.name,
|
|
path: `f/backup_schedule/${randomUUID()}`,
|
|
enabled: true,
|
|
is_flow: true,
|
|
script_path: WINDMILL_BACKUP_FLOW_PATH,
|
|
timezone: body.timezone || "Asia/Bangkok",
|
|
paused_until: body.startAt,
|
|
args: {
|
|
backup_name: body.name,
|
|
prefix_timestamp: true,
|
|
storage: {
|
|
s3_source_endpoint: s3TargetUrl,
|
|
s3_source_access: MAIN_MINIO_ACCESS_KEY,
|
|
s3_source_secret: MAIN_MINIO_SECRET_KEY,
|
|
s3_source_bucket: MAIN_MINIO_BUCKET.replaceAll(",", " "),
|
|
s3_dest_endpoint: s3BackupUrl,
|
|
s3_dest_access: MAIN_MINIO_ACCESS_KEY,
|
|
s3_dest_secret: MAIN_MINIO_SECRET_KEY,
|
|
},
|
|
database: {
|
|
s3_endpoint: s3BackupUrl,
|
|
s3_access: MAIN_MINIO_ACCESS_KEY,
|
|
s3_secret: MAIN_MINIO_SECRET_KEY,
|
|
s3_bucket: BACKUP_MINIO_BUCKET,
|
|
db_host: DB_HOST,
|
|
db_port: DB_PORT,
|
|
db_user: DB_USERNAME,
|
|
db_password: DB_PASSWORD,
|
|
db_list: DB_LIST?.replaceAll(",", " "),
|
|
},
|
|
},
|
|
}),
|
|
}).then(async (r) => jsonParseOrPlainText(await r.text()));
|
|
}
|
|
|
|
@Put("schedule/{id}")
|
|
@Security("keycloak")
|
|
async updateSchedule(
|
|
@Path() id: string,
|
|
@Body()
|
|
body: { name: string; schedule: string; enabled?: boolean; timezone?: string; startAt?: Date },
|
|
) {
|
|
if (!/^[a-zA-Z0-9\-]+$/.test(body.name)) {
|
|
throw new HttpError(HttpStatus.BAD_REQUEST, "Invalid name.");
|
|
}
|
|
|
|
return await fetch(
|
|
`${WINDMILL_URL}/api/w/${WINDMILL_WORKSPACE}/schedules/update/f/backup_schedule/${id}`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${WINDMILL_API_KEY}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
summary: body.name,
|
|
schedule: body.schedule,
|
|
timezone: body.timezone || "Asia/Bangkok",
|
|
paused_until: body.startAt,
|
|
args: {
|
|
backup_name: body.name,
|
|
prefix_timestamp: true,
|
|
storage: {
|
|
s3_source_endpoint: s3TargetUrl,
|
|
s3_source_access: MAIN_MINIO_ACCESS_KEY,
|
|
s3_source_secret: MAIN_MINIO_SECRET_KEY,
|
|
s3_source_bucket: MAIN_MINIO_BUCKET.replaceAll(",", " "),
|
|
s3_dest_endpoint: s3BackupUrl,
|
|
s3_dest_access: MAIN_MINIO_ACCESS_KEY,
|
|
s3_dest_secret: MAIN_MINIO_SECRET_KEY,
|
|
},
|
|
database: {
|
|
s3_endpoint: s3BackupUrl,
|
|
s3_access: MAIN_MINIO_ACCESS_KEY,
|
|
s3_secret: MAIN_MINIO_SECRET_KEY,
|
|
s3_bucket: BACKUP_MINIO_BUCKET,
|
|
db_host: DB_HOST,
|
|
db_port: DB_PORT,
|
|
db_user: DB_USERNAME,
|
|
db_password: DB_PASSWORD,
|
|
db_list: DB_LIST?.replaceAll(",", " "),
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
).then(async (r) => {
|
|
const body = jsonParseOrPlainText(await r.text());
|
|
|
|
if (r.status === 200) {
|
|
await fetch(
|
|
`${WINDMILL_URL}/api/w/${WINDMILL_WORKSPACE}/schedules/setenabled/f/backup_schedule/${id}`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${WINDMILL_API_KEY}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ enabled: body.enabled }),
|
|
},
|
|
);
|
|
}
|
|
|
|
return body;
|
|
});
|
|
}
|
|
|
|
@Post("schedule/{id}/toggle")
|
|
@Security("keycloak")
|
|
async toggleSchedule(@Path() id: string) {
|
|
return await fetch(
|
|
`${WINDMILL_URL}/api/w/${WINDMILL_WORKSPACE}/schedules/get/f/backup_schedule/${id}`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${WINDMILL_API_KEY}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
},
|
|
).then(async (r) => {
|
|
const body = jsonParseOrPlainText(await r.text());
|
|
|
|
if (typeof body === "object") {
|
|
const result = await fetch(
|
|
`${WINDMILL_URL}/api/w/${WINDMILL_WORKSPACE}/schedules/setenabled/f/backup_schedule/${id}`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${WINDMILL_API_KEY}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ enabled: !body.enabled }),
|
|
},
|
|
);
|
|
return jsonParseOrPlainText(await result.text());
|
|
}
|
|
});
|
|
}
|
|
|
|
@Delete("schedule/{id}")
|
|
@Security("keycloak")
|
|
async deleteSchedule(@Path() id: string) {
|
|
return await fetch(
|
|
`${WINDMILL_URL}/api/w/${WINDMILL_WORKSPACE}/schedules/delete/f/backup_schedule/${id}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: {
|
|
Authorization: `Bearer ${WINDMILL_API_KEY}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
},
|
|
).then(async (r) => jsonParseOrPlainText(await r.text()));
|
|
}
|
|
|
|
@Post("notify")
|
|
async notifyBackup(
|
|
@Body() payload: { message: string; userId?: string | string[]; roles?: string | string[] },
|
|
) {
|
|
sendWebSocket("backup-notification", payload.message, {
|
|
roles: payload.roles || [],
|
|
userId: payload.userId || [],
|
|
});
|
|
}
|
|
}
|