import { Body, Controller, Delete, Get, Path, Post, Put, Route, Security } from "tsoa"; import HttpError from "../interfaces/http-error"; import HttpStatus from "../interfaces/http-status"; import { randomUUID } from "crypto"; 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_USE_SSL = getEnvVar("BACKUP_MINIO_USE_SSL"); const BACKUP_MINIO_HOST = getEnvVar("BACKUP_MINIO_HOST"); const BACKUP_MINIO_PORT = process.env.BACKUP_MINIO_PORT; const BACKUP_MINIO_ACCESS_KEY = getEnvVar("BACKUP_MINIO_ACCESS_KEY"); const BACKUP_MINIO_SECRET_KEY = getEnvVar("BACKUP_MINIO_SECRET_KEY"); const BACKUP_MINIO_BUCKET = getEnvVar("BACKUP_MINIO_BUCKET"); function jsonParseOrPlainText(str: string) { try { return JSON.parse(str); } catch (_) { return str; } } @Route("/api/v1/backup") // @Security("keycloak") export class BackupController extends Controller { @Get() 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: `${BACKUP_MINIO_USE_SSL === "true" ? "https" : "http://"}${BACKUP_MINIO_HOST}${(BACKUP_MINIO_PORT && ":" + BACKUP_MINIO_PORT) || ""}`, s3_backup_access: BACKUP_MINIO_ACCESS_KEY, s3_backup_secret: BACKUP_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[]; bucket: Record[] }; }); 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, } : [], ); } @Get("backup-running-list") 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[]; }); } @Get("restore-running-list") 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") async runBackup(@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({ backup_name: name, storage: { s3_source_endpoint: `${MAIN_MINIO_USE_SSL === "true" ? "https://" : "http://"}${MAIN_MINIO_HOST}${(MAIN_MINIO_PORT && ":" + MAIN_MINIO_PORT) || ""}`, s3_source_access: MAIN_MINIO_ACCESS_KEY, s3_source_secret: MAIN_MINIO_SECRET_KEY, s3_source_bucket: MAIN_MINIO_BUCKET, s3_dest_endpoint: `${BACKUP_MINIO_USE_SSL === "true" ? "https" : "http://"}${BACKUP_MINIO_HOST}${(BACKUP_MINIO_PORT && ":" + BACKUP_MINIO_PORT) || ""}`, s3_dest_access: BACKUP_MINIO_ACCESS_KEY, s3_dest_secret: BACKUP_MINIO_SECRET_KEY, }, database: { s3_endpoint: `${BACKUP_MINIO_USE_SSL === "true" ? "https" : "http://"}${BACKUP_MINIO_HOST}${(BACKUP_MINIO_PORT && ":" + BACKUP_MINIO_PORT) || ""}`, s3_access: BACKUP_MINIO_ACCESS_KEY, s3_secret: BACKUP_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?.replace(",", " "), }, }), }, ).then(async (r) => jsonParseOrPlainText(await r.text())); } @Post("restore") async restoreBackup(@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: `${MAIN_MINIO_USE_SSL === "true" ? "https://" : "http://"}${MAIN_MINIO_HOST}${(MAIN_MINIO_PORT && ":" + MAIN_MINIO_PORT) || ""}`, s3_restore_access: MAIN_MINIO_ACCESS_KEY, s3_restore_secret: MAIN_MINIO_SECRET_KEY, s3_restore_bucket: MAIN_MINIO_BUCKET, s3_backup_endpoint: `${BACKUP_MINIO_USE_SSL === "true" ? "https" : "http://"}${BACKUP_MINIO_HOST}${(BACKUP_MINIO_PORT && ":" + BACKUP_MINIO_PORT) || ""}`, s3_backup_access: BACKUP_MINIO_ACCESS_KEY, s3_backup_secret: BACKUP_MINIO_SECRET_KEY, }, database: { s3_endpoint: `${BACKUP_MINIO_USE_SSL === "true" ? "https" : "http://"}${BACKUP_MINIO_HOST}${(BACKUP_MINIO_PORT && ":" + BACKUP_MINIO_PORT) || ""}`, s3_access: BACKUP_MINIO_ACCESS_KEY, s3_secret: BACKUP_MINIO_SECRET_KEY, s3_bucket: BACKUP_MINIO_BUCKET, db_host: DB_HOST, db_port: DB_PORT, db_user: DB_USERNAME, db_password: DB_PASSWORD, }, }), }, ).then(async (r) => jsonParseOrPlainText(await r.text())); } @Delete("delete") async deleteBackup(@Body() body: { name: string }) { await fetch( `${WINDMILL_URL}/api/w/${WINDMILL_WORKSPACE}/jobs/run_wait_result/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: `${BACKUP_MINIO_USE_SSL === "true" ? "https://" : "http://"}${BACKUP_MINIO_HOST}${(BACKUP_MINIO_PORT && ":" + BACKUP_MINIO_PORT) || ""}`, s3_backup_access: BACKUP_MINIO_ACCESS_KEY, s3_backup_secret: BACKUP_MINIO_SECRET_KEY, s3_backup_bucket: BACKUP_MINIO_BUCKET, }), }, ).then(async (r) => { return jsonParseOrPlainText(await r.text()); }); } @Get("schedule") 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[]; }); 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") 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: `${MAIN_MINIO_USE_SSL === "true" ? "https://" : "http://"}${MAIN_MINIO_HOST}${(MAIN_MINIO_PORT && ":" + MAIN_MINIO_PORT) || ""}`, s3_source_access: MAIN_MINIO_ACCESS_KEY, s3_source_secret: MAIN_MINIO_SECRET_KEY, s3_source_bucket: MAIN_MINIO_BUCKET, s3_dest_endpoint: `${BACKUP_MINIO_USE_SSL === "true" ? "https" : "http://"}${BACKUP_MINIO_HOST}${(BACKUP_MINIO_PORT && ":" + BACKUP_MINIO_PORT) || ""}`, s3_dest_access: BACKUP_MINIO_ACCESS_KEY, s3_dest_secret: BACKUP_MINIO_SECRET_KEY, }, database: { s3_endpoint: `${BACKUP_MINIO_USE_SSL === "true" ? "https" : "http://"}${BACKUP_MINIO_HOST}${(BACKUP_MINIO_PORT && ":" + BACKUP_MINIO_PORT) || ""}`, s3_access: BACKUP_MINIO_ACCESS_KEY, s3_secret: BACKUP_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?.replace(",", " "), }, }, }), }).then(async (r) => jsonParseOrPlainText(await r.text())); } @Put("schedule/{id}") 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: `${MAIN_MINIO_USE_SSL === "true" ? "https://" : "http://"}${MAIN_MINIO_HOST}${(MAIN_MINIO_PORT && ":" + MAIN_MINIO_PORT) || ""}`, s3_source_access: MAIN_MINIO_ACCESS_KEY, s3_source_secret: MAIN_MINIO_SECRET_KEY, s3_source_bucket: MAIN_MINIO_BUCKET, s3_dest_endpoint: `${BACKUP_MINIO_USE_SSL === "true" ? "https" : "http://"}${BACKUP_MINIO_HOST}${(BACKUP_MINIO_PORT && ":" + BACKUP_MINIO_PORT) || ""}`, s3_dest_access: BACKUP_MINIO_ACCESS_KEY, s3_dest_secret: BACKUP_MINIO_SECRET_KEY, }, database: { s3_endpoint: `${BACKUP_MINIO_USE_SSL === "true" ? "https" : "http://"}${BACKUP_MINIO_HOST}${(BACKUP_MINIO_PORT && ":" + BACKUP_MINIO_PORT) || ""}`, s3_access: BACKUP_MINIO_ACCESS_KEY, s3_secret: BACKUP_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?.replace(",", " "), }, }, }), }, ).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") 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}") 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())); } }