From 2e44f96b63e462c4064f3a9a88c06295a9d11b50 Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Sun, 5 Jan 2025 15:18:01 +0000 Subject: [PATCH] feat/add backup scheduling functionality --- package.json | 2 + src/app/api/print-schedules-jobs/route.ts | 21 +++ .../project/app/[appId]/volumes/actions.ts | 10 +- .../volumes/volume-backup-edit-overlay.tsx | 2 +- .../s3-targets/s3-target-edit-overlay.tsx | 4 +- src/backup.server.ts | 13 ++ src/server.ts | 3 + src/server/services/monitor-app.service.ts | 4 +- src/server/services/pod.service.ts | 96 +---------- src/server/services/qs.service.ts | 8 +- .../setup-services/setup-pod.service.ts | 44 ------ .../services/standalone-services/00_info.md | 3 + .../standalone-services/backup.service.ts | 98 ++++++++++++ .../standalone-services/schedule.service.ts | 41 +++++ .../standalone-pod.service.ts | 149 ++++++++++++++++++ src/server/services/terminal.service.ts | 4 +- src/server/services/volume-backup.service.ts | 72 --------- src/shared/model/backup-volume-edit.model.ts | 2 +- yarn.lock | 38 +++++ 19 files changed, 393 insertions(+), 221 deletions(-) create mode 100644 src/app/api/print-schedules-jobs/route.ts create mode 100644 src/backup.server.ts delete mode 100644 src/server/services/setup-services/setup-pod.service.ts create mode 100644 src/server/services/standalone-services/00_info.md create mode 100644 src/server/services/standalone-services/backup.service.ts create mode 100644 src/server/services/standalone-services/schedule.service.ts create mode 100644 src/server/services/standalone-services/standalone-pod.service.ts diff --git a/package.json b/package.json index 9f0a41e..dd5fa9f 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@radix-ui/react-tooltip": "^1.1.4", "@tanstack/react-table": "^8.20.5", "@types/bcrypt": "^5.0.2", + "@types/node-schedule": "^2.1.7", "@types/qrcode": "^1.5.5", "@types/ws": "^8.5.13", "@xterm/xterm": "^5.5.0", @@ -55,6 +56,7 @@ "next": "14.2.15", "next-auth": "^4.24.8", "next-themes": "^0.3.0", + "node-schedule": "^2.1.1", "otpauth": "^9.3.4", "prisma": "^5.21.1", "qrcode": "^1.5.4", diff --git a/src/app/api/print-schedules-jobs/route.ts b/src/app/api/print-schedules-jobs/route.ts new file mode 100644 index 0000000..a9f87c4 --- /dev/null +++ b/src/app/api/print-schedules-jobs/route.ts @@ -0,0 +1,21 @@ +import k3s from "@/server/adapter/kubernetes-api.adapter"; +import appService from "@/server/services/app.service"; +import deploymentService from "@/server/services/deployment.service"; +import scheduleService from "@/server/services/standalone-services/schedule.service"; +import { getAuthUserSession, simpleRoute } from "@/server/utils/action-wrapper.utils"; +import { Informer, V1Pod } from "@kubernetes/client-node"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +// Prevents this route's response from being cached +export const dynamic = "force-dynamic"; + + +export async function GET(request: Request) { + return simpleRoute(async () => { + scheduleService.printScheduledJobs(); + return NextResponse.json({ + status: "success" + }); + }) +} \ No newline at end of file diff --git a/src/app/project/app/[appId]/volumes/actions.ts b/src/app/project/app/[appId]/volumes/actions.ts index 4522da1..1991d9c 100644 --- a/src/app/project/app/[appId]/volumes/actions.ts +++ b/src/app/project/app/[appId]/volumes/actions.ts @@ -10,6 +10,7 @@ import pvcService from "@/server/services/pvc.service"; import { fileMountEditZodModel } from "@/shared/model/file-mount-edit.model"; import { VolumeBackupEditModel, volumeBackupEditZodModel } from "@/shared/model/backup-volume-edit.model"; import volumeBackupService from "@/server/services/volume-backup.service"; +import backupService from "@/server/services/standalone-services/backup.service"; const actionAppVolumeEditZodModel = appVolumeEditZodModel.merge(z.object({ appId: z.string(), @@ -81,16 +82,21 @@ export const saveBackupVolume = async (prevState: any, inputData: VolumeBackupEd if (validatedData.retention < 1) { throw new ServiceException('Retention must be at least 1'); } - await volumeBackupService.save({ + if (validatedData.id) { + await backupService.unregisterBackupJob(validatedData.id); + } + const savedVolumeBackup = await volumeBackupService.save({ ...validatedData, id: validatedData.id ?? undefined, }); + await backupService.registerBackupJob(savedVolumeBackup); return new SuccessActionResult(); }); export const deleteBackupVolume = async (backupVolumeId: string) => simpleAction(async () => { await getAuthUserSession(); + await backupService.unregisterBackupJob(backupVolumeId); await volumeBackupService.deleteById(backupVolumeId); return new SuccessActionResult(undefined, 'Successfully deleted backup schedule'); }); @@ -98,6 +104,6 @@ export const deleteBackupVolume = async (backupVolumeId: string) => export const runBackupVolumeSchedule = async (backupVolumeId: string) => simpleAction(async () => { await getAuthUserSession(); - await volumeBackupService.createBackupForVolume(backupVolumeId); + await backupService.runBackupForVolume(backupVolumeId); return new SuccessActionResult(undefined, 'Backup created and uploaded successfully'); }); \ No newline at end of file diff --git a/src/app/project/app/[appId]/volumes/volume-backup-edit-overlay.tsx b/src/app/project/app/[appId]/volumes/volume-backup-edit-overlay.tsx index 563ebdc..42721f5 100644 --- a/src/app/project/app/[appId]/volumes/volume-backup-edit-overlay.tsx +++ b/src/app/project/app/[appId]/volumes/volume-backup-edit-overlay.tsx @@ -68,7 +68,7 @@ export default function VolumeBackupEditDialog({ useEffect(() => { form.reset(volumeBackup); - }, [volumeBackup]); + }, [volumeBackup, volumes, s3Targets]); return ( <> diff --git a/src/app/settings/s3-targets/s3-target-edit-overlay.tsx b/src/app/settings/s3-targets/s3-target-edit-overlay.tsx index 2efd83c..a3d3fff 100644 --- a/src/app/settings/s3-targets/s3-target-edit-overlay.tsx +++ b/src/app/settings/s3-targets/s3-target-edit-overlay.tsx @@ -44,9 +44,7 @@ export default function S3TargetEditOverlay({ children, target }: { children: Re useEffect(() => { if (state.status === 'success') { form.reset(); - toast.success('Volume saved successfully', { - description: "Click \"deploy\" to apply the changes to your app.", - }); + toast.success('S3 Target saved successfully'); setIsOpen(false); } FormUtils.mapValidationErrorsToForm(state, form); diff --git a/src/backup.server.ts b/src/backup.server.ts new file mode 100644 index 0000000..5e90163 --- /dev/null +++ b/src/backup.server.ts @@ -0,0 +1,13 @@ +import dataAccess from "./server/adapter/db.client"; +import backupService from "./server/services/standalone-services/backup.service"; + + +export default async function registreAllBackupSchedules() { + + const volumeBackups = await dataAccess.client.volumeBackup.findMany(); + console.log(`Registering ${volumeBackups.length} backup schedules...`); + for (const volumeBackup of volumeBackups) { + await backupService.registerBackupJob(volumeBackup); + } + console.log('Backup schedules registered.'); +} \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index c4f1722..c9aa2d5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,6 +7,7 @@ import { CommandExecutorUtils } from './server/utils/command-executor.utils' import dataAccess from './server/adapter/db.client' import { FancyConsoleUtils } from './shared/utils/fancy-console.utils' import { Constants } from './shared/utils/constants' +import registreAllBackupSchedules from './backup.server' // Source: https://nextjs.org/docs/app/building-your-application/configuring/custom-server @@ -50,6 +51,8 @@ async function initializeNextJs() { } } + await registreAllBackupSchedules(); + const app = next({ dev }) const handle = app.getRequestHandler() diff --git a/src/server/services/monitor-app.service.ts b/src/server/services/monitor-app.service.ts index 94402eb..8da5c8e 100644 --- a/src/server/services/monitor-app.service.ts +++ b/src/server/services/monitor-app.service.ts @@ -1,6 +1,6 @@ import k3s from "../adapter/kubernetes-api.adapter"; import * as k8s from '@kubernetes/client-node'; -import setupPodService from "./setup-services/setup-pod.service"; +import standalonePodService from "./standalone-services/standalone-pod.service"; import clusterService from "./node.service"; import { PodsResourceInfoModel } from "@/shared/model/pods-resource-info.model"; import { KubernetesSizeConverter } from "../utils/kubernetes-size-converter.utils"; @@ -8,7 +8,7 @@ import { KubernetesSizeConverter } from "../utils/kubernetes-size-converter.util class MonitorAppService { async getMonitoringForApp(projectId: string, appId: string): Promise { const metricsClient = new k8s.Metrics(k3s.getKubeConfig()); - const podsFromApp = await setupPodService.getPodsForApp(projectId, appId); + const podsFromApp = await standalonePodService.getPodsForApp(projectId, appId); const topPods = await k8s.topPods(k3s.core, metricsClient, projectId); const filteredTopPods = topPods.filter((topPod) => diff --git a/src/server/services/pod.service.ts b/src/server/services/pod.service.ts index 6ce93cd..627b713 100644 --- a/src/server/services/pod.service.ts +++ b/src/server/services/pod.service.ts @@ -1,15 +1,12 @@ import { PodsInfoModel } from "@/shared/model/pods-info.model"; import k3s from "../adapter/kubernetes-api.adapter"; import { ServiceException } from "@/shared/model/service.exception.model"; -import setupPodService from "./setup-services/setup-pod.service"; -import fs from 'fs'; -import stream from 'stream'; -import * as k8s from '@kubernetes/client-node'; +import standalonePodService from "./standalone-services/standalone-pod.service"; class PodService { async waitUntilPodIsRunningFailedOrSucceded(projectId: string, podName: string) { - const isPodRunnning = await setupPodService.waitUntilPodIsRunningFailedOrSucceded(projectId, podName); + const isPodRunnning = await standalonePodService.waitUntilPodIsRunningFailedOrSucceded(projectId, podName); if (!isPodRunnning) { throw new ServiceException(`Pod ${podName} did not become ready in time (timeout).`); } @@ -24,7 +21,7 @@ class PodService { } async getPodsForApp(projectId: string, appId: string): Promise { - return setupPodService.getPodsForApp(projectId, appId); + return standalonePodService.getPodsForApp(projectId, appId); } public async runCommandInPod( @@ -33,54 +30,9 @@ class PodService { containerName: string, command: string[], ): Promise { - const writerStream = new stream.PassThrough(); - const stderrStream = new stream.PassThrough(); - return new Promise((resolve, reject) => { - - const exec = new k8s.Exec(k3s.getKubeConfig()); - exec - .exec( - namespace, - podName, - containerName, - command, - writerStream, - stderrStream, - null, - false, - async ({ status }) => { - try { - console.log(`Output for command "${command.join(' ')}": \n ${writerStream.read().toString()}`); - if (status === 'Failure') { - return reject( - new Error( - `Error while running command "${command.join(' ')}": \n ${stderrStream.read().toString()}`, - ), - ); - } - resolve(); - } catch (e) { - reject(e); - } - }, - ) - .catch(reject); - }); + return await standalonePodService.runCommandInPod(namespace, podName, containerName, command); } - /** - * Copied out of Kubernetes SDK because for whatever reason - * cp.fromPod is not working using the sdk bcause of an error with some buffer - * Source: https://github.com/kubernetes-client/javascript/blob/master/src/cp.ts - * - * - * @param {string} namespace - The namespace of the pod to exec the command inside. - * @param {string} podName - The name of the pod to exec the command inside. - * @param {string} containerName - The name of the container in the pod to exec the command inside. - * @param {string} srcPath - The source path in the pod - * @param {string} zipOutputPath - The target path in local - * @param {string} [cwd] - The directory that is used as the parent in the pod when downloading - */ public async cpFromPod( namespace: string, podName: string, @@ -89,45 +41,9 @@ class PodService { zipOutputPath: string, cwd?: string, ): Promise { - const command = ['tar', 'zcf', '-']; - if (cwd) { - command.push('-C', cwd); - } - command.push(srcPath); - const writerStream = fs.createWriteStream(zipOutputPath); - const stderrStream = new stream.PassThrough(); - return new Promise((resolve, reject) => { - - const exec = new k8s.Exec(k3s.getKubeConfig()); - exec - .exec( - namespace, - podName, - containerName, - command, - writerStream, - stderrStream, - null, - false, - async ({ status }) => { - try { - writerStream.close(); - if (status === 'Failure') { - return reject( - new Error( - `Error from cpFromPod - details: \n ${stderrStream.read().toString()}`, - ), - ); - } - resolve(); - } catch (e) { - reject(e); - } - }, - ) - .catch(reject); - }); + return await standalonePodService.cpFromPod(namespace, podName, containerName, srcPath, zipOutputPath, cwd); } + } const podService = new PodService(); diff --git a/src/server/services/qs.service.ts b/src/server/services/qs.service.ts index 22f9344..729af16 100644 --- a/src/server/services/qs.service.ts +++ b/src/server/services/qs.service.ts @@ -4,7 +4,7 @@ import namespaceService from "./namespace.service"; import { KubeObjectNameUtils } from "../utils/kube-object-name.utils"; import crypto from "crypto"; import { FancyConsoleUtils } from "../../shared/utils/fancy-console.utils"; -import setupPodService from "./setup-services/setup-pod.service"; +import standalonePodService from "./standalone-services/standalone-pod.service"; import ingressSetupService from "./setup-services/ingress-setup.service"; class QuickStackService { @@ -43,14 +43,14 @@ class QuickStackService { async waitUntilQuickstackIsRunning() { console.log('Waiting for QuickStack to be running...'); await new Promise((resolve) => setTimeout(resolve, 5000)); - const pods = await setupPodService.getPodsForApp(this.QUICKSTACK_NAMESPACE, this.QUICKSTACK_DEPLOYMENT_NAME); + const pods = await standalonePodService.getPodsForApp(this.QUICKSTACK_NAMESPACE, this.QUICKSTACK_DEPLOYMENT_NAME); const quickStackPod = pods.find(p => p); if (!quickStackPod) { console.error('[ERROR] QuickStack pod was not found'); return; } - await setupPodService.waitUntilPodIsRunningFailedOrSucceded(this.QUICKSTACK_NAMESPACE, quickStackPod.podName); - if (setupPodService) { + await standalonePodService.waitUntilPodIsRunningFailedOrSucceded(this.QUICKSTACK_NAMESPACE, quickStackPod.podName); + if (standalonePodService) { console.log('QuickStack is now running'); } else { console.warn('Could not verify if QuickStack is running, please check manually.'); diff --git a/src/server/services/setup-services/setup-pod.service.ts b/src/server/services/setup-services/setup-pod.service.ts deleted file mode 100644 index 2e36c29..0000000 --- a/src/server/services/setup-services/setup-pod.service.ts +++ /dev/null @@ -1,44 +0,0 @@ -import k3s from "../../adapter/kubernetes-api.adapter"; - -class SetupPodService { - - async waitUntilPodIsRunningFailedOrSucceded(projectId: string, podName: string) { - const timeout = 120000; - const interval = 1000; - const maxTries = timeout / interval; - let tries = 0; - - while (tries < maxTries) { - const pod = await this.getPodOrUndefined(projectId, podName); - if (pod && ['Running', 'Failed', 'Succeeded'].includes(pod.status?.phase!)) { - return true; - } - - await new Promise(resolve => setTimeout(resolve, interval)); - tries++; - } - - return false; - } - - async getPodOrUndefined(projectId: string, podName: string) { - const res = await k3s.core.listNamespacedPod(projectId); - return res.body.items.find((item) => item.metadata?.name === podName); - } - - async getPodsForApp(projectId: string, appId: string): Promise<{ - podName: string; - containerName: string; - uid?: string; - }[]> { - const res = await k3s.core.listNamespacedPod(projectId, undefined, undefined, undefined, undefined, `app=${appId}`); - return res.body.items.map((item) => ({ - podName: item.metadata?.name!, - containerName: item.spec?.containers?.[0].name!, - uid: item.metadata?.uid, - })).filter((item) => !!item.podName && !!item.containerName); - } -} - -const setupPodService = new SetupPodService(); -export default setupPodService; diff --git a/src/server/services/standalone-services/00_info.md b/src/server/services/standalone-services/00_info.md new file mode 100644 index 0000000..d497488 --- /dev/null +++ b/src/server/services/standalone-services/00_info.md @@ -0,0 +1,3 @@ +# What are Standalone Services + +Standalone services are service classes wich can be used within a Next.JS request context or in a standalone context (for example at application startup, without any Next.JS specific features). diff --git a/src/server/services/standalone-services/backup.service.ts b/src/server/services/standalone-services/backup.service.ts new file mode 100644 index 0000000..8d8e861 --- /dev/null +++ b/src/server/services/standalone-services/backup.service.ts @@ -0,0 +1,98 @@ +import dataAccess from "../../adapter/db.client"; +import { ServiceException } from "../../../shared/model/service.exception.model"; +import { PathUtils } from "../../utils/path.utils"; +import { FsUtils } from "../../utils/fs.utils"; +import s3Service from "../aws-s3.service"; +import { VolumeBackup } from "@prisma/client"; +import scheduleService from "./schedule.service"; +import standalonePodService from "./standalone-pod.service"; + +class BackupService { + + async registerBackupJob(volumeBackup: VolumeBackup) { + const cron = volumeBackup.cron; + const jobName = `backup-volume-${volumeBackup.id}`; + scheduleService.scheduleJob(jobName, cron, async () => { + try { + await this.runBackupForVolume(volumeBackup.id); + } catch (e) { + console.error(`Error during backup for volume ${volumeBackup.id}`); + console.error(e); + } + }); + } + + async unregisterBackupJob(volumeBackupId: string) { + const jobName = `backup-volume-${volumeBackupId}`; + scheduleService.cancelJob(jobName); + } + + async runBackupForVolume(backupVolumeId: string) { + + const backupVolume = await dataAccess.client.volumeBackup.findFirstOrThrow({ + where: { + id: backupVolumeId + }, + include: { + volume: { + include: { + app: true + } + }, + target: true + } + }); + + const projectId = backupVolume.volume.app.projectId; + const appId = backupVolume.volume.app.id; + const volume = backupVolume.volume; + + const pod = await standalonePodService.getPodsForApp(projectId, appId); + if (pod.length === 0) { + throw new ServiceException(`There are no running pods for volume id ${volume.id} in app ${volume.app.id}. Make sure the app is running.`); + } + const firstPod = pod[0]; + + // zipping and saving backup data in quickstack pod + const downloadPath = PathUtils.backupVolumeDownloadZipPath(backupVolume.id); + await FsUtils.createDirIfNotExistsAsync(PathUtils.tempBackupDataFolder, true); + + try { + console.log(`Downloading data from pod ${firstPod.podName} ${volume.containerMountPath} to ${downloadPath}`); + await standalonePodService.cpFromPod(projectId, firstPod.podName, firstPod.containerName, volume.containerMountPath, downloadPath); + + // uploac backup + console.log(`Uploading backup to S3`); + const now = new Date(); + const nowString = now.toISOString(); + await s3Service.uploadFile(backupVolume.target, downloadPath, + `${appId}/${backupVolumeId}/${nowString}.tar.gz`, 'application/gzip', 'binary'); + + + // delete files wich are nod needed anymore (by retention) + console.log(`Deleting old backups`); + const files = await s3Service.listFiles(backupVolume.target); + + const filesFromThisBackup = files.filter(f => f.Key?.startsWith(`${appId}/${backupVolumeId}/`)).map(f => ({ + date: new Date((f.Key ?? '') + .replace(`${appId}/${backupVolumeId}/`, '') + .replace('.tar.gz', '')), + key: f.Key + })).filter(f => !isNaN(f.date.getTime()) && !!f.key); + + filesFromThisBackup.sort((a, b) => a.date.getTime() - b.date.getTime()); + + const filesToDelete = filesFromThisBackup.slice(0, -backupVolume.retention); + for (const file of filesToDelete) { + console.log(`Deleting backup ${file.key}`); + await s3Service.deleteFile(backupVolume.target, file.key!); + } + console.log(`Backup finished for volume ${volume.id} and backup ${backupVolume.id}`); + } finally { + await FsUtils.deleteFileIfExists(downloadPath); + } + } +} + +const backupService = new BackupService(); +export default backupService; diff --git a/src/server/services/standalone-services/schedule.service.ts b/src/server/services/standalone-services/schedule.service.ts new file mode 100644 index 0000000..c09b07e --- /dev/null +++ b/src/server/services/standalone-services/schedule.service.ts @@ -0,0 +1,41 @@ +import * as schedule from 'node-schedule'; + + +const globalScheduleInstance = () => { + return schedule +} + +declare const globalThis: { + globalSchedule: ReturnType; +} & typeof global; + +const scheduleInstance = globalThis.globalSchedule ?? globalScheduleInstance() + +if (process.env.NODE_ENV !== 'production') globalThis.globalSchedule = scheduleInstance + + +class ScheduleService { + + schedule = globalThis.globalSchedule; + + scheduleJob(jobName: string, cronExpression: string, callback: schedule.JobCallback) { + const job = new this.schedule.Job(jobName, callback); + job.schedule(cronExpression); + console.log(`[${ScheduleService.name}] Job ${jobName} scheduled with cron ${cronExpression}`); + } + + cancelJob(jobName: string) { + const job = this.schedule.scheduledJobs[jobName]; + if (job) { + job.cancel(); + console.log(`[${ScheduleService.name}] Job ${jobName} cancelled`); + } + } + + printScheduledJobs() { + console.log(`[${ScheduleService.name}] Scheduled jobs: \n- ${Object.keys(this.schedule.scheduledJobs).join('\n- ')}`); + } +} + +const scheduleService = new ScheduleService(); +export default scheduleService; diff --git a/src/server/services/standalone-services/standalone-pod.service.ts b/src/server/services/standalone-services/standalone-pod.service.ts new file mode 100644 index 0000000..7568a35 --- /dev/null +++ b/src/server/services/standalone-services/standalone-pod.service.ts @@ -0,0 +1,149 @@ +import k3s from "../../adapter/kubernetes-api.adapter"; +import fs from 'fs'; +import stream from 'stream'; +import * as k8s from '@kubernetes/client-node'; + +class SetupPodService { + + async waitUntilPodIsRunningFailedOrSucceded(projectId: string, podName: string) { + const timeout = 120000; + const interval = 1000; + const maxTries = timeout / interval; + let tries = 0; + + while (tries < maxTries) { + const pod = await this.getPodOrUndefined(projectId, podName); + if (pod && ['Running', 'Failed', 'Succeeded'].includes(pod.status?.phase!)) { + return true; + } + + await new Promise(resolve => setTimeout(resolve, interval)); + tries++; + } + + return false; + } + + async getPodOrUndefined(projectId: string, podName: string) { + const res = await k3s.core.listNamespacedPod(projectId); + return res.body.items.find((item) => item.metadata?.name === podName); + } + + async getPodsForApp(projectId: string, appId: string): Promise<{ + podName: string; + containerName: string; + uid?: string; + }[]> { + const res = await k3s.core.listNamespacedPod(projectId, undefined, undefined, undefined, undefined, `app=${appId}`); + return res.body.items.map((item) => ({ + podName: item.metadata?.name!, + containerName: item.spec?.containers?.[0].name!, + uid: item.metadata?.uid, + })).filter((item) => !!item.podName && !!item.containerName); + } + + public async runCommandInPod( + namespace: string, + podName: string, + containerName: string, + command: string[], + ): Promise { + const writerStream = new stream.PassThrough(); + const stderrStream = new stream.PassThrough(); + return new Promise((resolve, reject) => { + + const exec = new k8s.Exec(k3s.getKubeConfig()); + exec + .exec( + namespace, + podName, + containerName, + command, + writerStream, + stderrStream, + null, + false, + async ({ status }) => { + try { + console.log(`Output for command "${command.join(' ')}": \n ${writerStream.read().toString()}`); + if (status === 'Failure') { + return reject( + new Error( + `Error while running command "${command.join(' ')}": \n ${stderrStream.read().toString()}`, + ), + ); + } + resolve(); + } catch (e) { + reject(e); + } + }, + ) + .catch(reject); + }); + } + + /** + * Copied out of Kubernetes SDK because for whatever reason + * cp.fromPod is not working using the sdk bcause of an error with some buffer + * Source: https://github.com/kubernetes-client/javascript/blob/master/src/cp.ts + * + * + * @param {string} namespace - The namespace of the pod to exec the command inside. + * @param {string} podName - The name of the pod to exec the command inside. + * @param {string} containerName - The name of the container in the pod to exec the command inside. + * @param {string} srcPath - The source path in the pod + * @param {string} zipOutputPath - The target path in local + * @param {string} [cwd] - The directory that is used as the parent in the pod when downloading + */ + public async cpFromPod( + namespace: string, + podName: string, + containerName: string, + srcPath: string, + zipOutputPath: string, + cwd?: string, + ): Promise { + const command = ['tar', 'zcf', '-']; + if (cwd) { + command.push('-C', cwd); + } + command.push(srcPath); + const writerStream = fs.createWriteStream(zipOutputPath); + const stderrStream = new stream.PassThrough(); + return new Promise((resolve, reject) => { + + const exec = new k8s.Exec(k3s.getKubeConfig()); + exec + .exec( + namespace, + podName, + containerName, + command, + writerStream, + stderrStream, + null, + false, + async ({ status }) => { + try { + writerStream.close(); + if (status === 'Failure') { + return reject( + new Error( + `Error from cpFromPod - details: \n ${stderrStream.read().toString()}`, + ), + ); + } + resolve(); + } catch (e) { + reject(e); + } + }, + ) + .catch(reject); + }); + } +} + +const standalonePodService = new SetupPodService(); +export default standalonePodService; diff --git a/src/server/services/terminal.service.ts b/src/server/services/terminal.service.ts index 8bf22b4..bc2d7de 100644 --- a/src/server/services/terminal.service.ts +++ b/src/server/services/terminal.service.ts @@ -5,7 +5,7 @@ import * as k8s from '@kubernetes/client-node'; import stream from 'stream'; import { StreamUtils } from "../../shared/utils/stream.utils"; import WebSocket from "ws"; -import setupPodService from "./setup-services/setup-pod.service"; +import standalonePodService from "./standalone-services/standalone-pod.service"; interface TerminalStrean { stdoutStream: stream.PassThrough; @@ -34,7 +34,7 @@ export class TerminalService { const streamInputKey = StreamUtils.getInputStreamName(terminalInfo); const streamOutputKey = StreamUtils.getOutputStreamName(terminalInfo); - const podReachable = await setupPodService.waitUntilPodIsRunningFailedOrSucceded(terminalInfo.namespace, terminalInfo.podName); + const podReachable = await standalonePodService.waitUntilPodIsRunningFailedOrSucceded(terminalInfo.namespace, terminalInfo.podName); if (!podReachable) { socket.emit(streamOutputKey, 'Pod is not reachable.'); return; diff --git a/src/server/services/volume-backup.service.ts b/src/server/services/volume-backup.service.ts index 8421099..c9c902c 100644 --- a/src/server/services/volume-backup.service.ts +++ b/src/server/services/volume-backup.service.ts @@ -3,81 +3,9 @@ import dataAccess from "../adapter/db.client"; import { Tags } from "../utils/cache-tag-generator.utils"; import { Prisma, VolumeBackup } from "@prisma/client"; import { VolumeBackupExtendedModel } from "@/shared/model/volume-backup-extended.model"; -import podService from "./pod.service"; -import { ServiceException } from "@/shared/model/service.exception.model"; -import { PathUtils } from "../utils/path.utils"; -import { FsUtils } from "../utils/fs.utils"; -import s3Service from "./aws-s3.service"; class VolumeBackupService { - async createBackupForVolume(backupVolumeId: string) { - - const backupVolume = await dataAccess.client.volumeBackup.findFirstOrThrow({ - where: { - id: backupVolumeId - }, - include: { - volume: { - include: { - app: true - } - }, - target: true - } - }); - - const projectId = backupVolume.volume.app.projectId; - const appId = backupVolume.volume.app.id; - const volume = backupVolume.volume; - - const pod = await podService.getPodsForApp(projectId, appId); - if (pod.length === 0) { - throw new ServiceException(`There are no running pods for volume id ${volume.id} in app ${volume.app.id}. Make sure the app is running.`); - } - const firstPod = pod[0]; - - // zipping and saving backup data in quickstack pod - const downloadPath = PathUtils.backupVolumeDownloadZipPath(backupVolume.id); - await FsUtils.createDirIfNotExistsAsync(PathUtils.tempBackupDataFolder, true); - - try { - console.log(`Downloading data from pod ${firstPod.podName} ${volume.containerMountPath} to ${downloadPath}`); - await podService.cpFromPod(projectId, firstPod.podName, firstPod.containerName, volume.containerMountPath, downloadPath); - - // uploac backup - console.log(`Uploading backup to S3`); - const now = new Date(); - const nowString = now.toISOString(); - await s3Service.uploadFile(backupVolume.target, downloadPath, - `${appId}/${volume.id}/${nowString}.tar.gz`, 'application/gzip', 'binary'); - - - // delete files wich are nod needed anymore (by retention) - console.log(`Deleting old backups`); - const files = await s3Service.listFiles(backupVolume.target); - - const filesFromThisBackup = files.filter(f => f.Key?.startsWith(`${appId}/${volume.id}/`)).map(f => ({ - date: new Date((f.Key ?? '') - .replace(`${appId}/${volume.id}/`, '') - .replace('.tar.gz', '')), - key: f.Key - })).filter(f => !isNaN(f.date.getTime()) && !!f.key); - console.log(filesFromThisBackup) - - filesFromThisBackup.sort((a, b) => a.date.getTime() - b.date.getTime()); - - const filesToDelete = filesFromThisBackup.slice(0, -backupVolume.retention); - for (const file of filesToDelete) { - console.log(`Deleting backup ${file.key}`); - await s3Service.deleteFile(backupVolume.target, file.key!); - } - console.log(`Backup finished for volume ${volume.id}`); - } finally { - await FsUtils.deleteFileIfExists(downloadPath); - } - } - async getAll(): Promise { return await unstable_cache(() => dataAccess.client.volumeBackup.findMany({ orderBy: { diff --git a/src/shared/model/backup-volume-edit.model.ts b/src/shared/model/backup-volume-edit.model.ts index 8859d95..812c925 100644 --- a/src/shared/model/backup-volume-edit.model.ts +++ b/src/shared/model/backup-volume-edit.model.ts @@ -5,7 +5,7 @@ export const volumeBackupEditZodModel = z.object({ id: z.string().nullish(), volumeId: z.string(), targetId: z.string(), - cron: z.string().trim().regex(/^ *(\*|[0-5]?\d) *(\*|[01]?\d) *(\*|[0-2]?\d) *(\*|[0-6]?\d) *(\*|[0-6]?\d) *$/), + cron: z.string().trim().regex(/(@(annually|yearly|monthly|weekly|daily|hourly|reboot))|(@every (\d+(ns|us|µs|ms|s|m|h))+)|((((\d+,)+\d+|(\d+(\/|-)\d+)|\d+|\*) ?){5,7})/), //cron: z.string().trim().min(1), retention: stringToNumber, }); diff --git a/yarn.lock b/yarn.lock index 2b64e63..77af105 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2920,6 +2920,13 @@ resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.34.tgz#10964ba0dee6ac4cd462e2795b6bebd407303433" integrity sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g== +"@types/node-schedule@^2.1.7": + version "2.1.7" + resolved "https://registry.yarnpkg.com/@types/node-schedule/-/node-schedule-2.1.7.tgz#79a1e61adc7bbf8d8eaabcef307e07d76cb40d82" + integrity sha512-G7Z3R9H7r3TowoH6D2pkzUHPhcJrDF4Jz1JOQ80AX0K2DWTHoN9VC94XzFAPNMdbW9TBzMZ3LjpFi7RYdbxtXA== + dependencies: + "@types/node" "*" + "@types/node@*", "@types/node@>=10.0.0", "@types/node@^22.7.9": version "22.10.1" resolved "https://registry.yarnpkg.com/@types/node/-/node-22.10.1.tgz#41ffeee127b8975a05f8c4f83fb89bcb2987d766" @@ -3776,6 +3783,13 @@ create-require@^1.1.0: resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== +cron-parser@^4.2.0: + version "4.9.0" + resolved "https://registry.yarnpkg.com/cron-parser/-/cron-parser-4.9.0.tgz#0340694af3e46a0894978c6f52a6dbb5c0f11ad5" + integrity sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q== + dependencies: + luxon "^3.2.1" + cross-env@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf" @@ -6028,6 +6042,11 @@ lodash@^4.17.21: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== +long-timeout@0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/long-timeout/-/long-timeout-0.1.1.tgz#9721d788b47e0bcb5a24c2e2bee1a0da55dab514" + integrity sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w== + loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" @@ -6059,6 +6078,11 @@ lucide-react@^0.465.0: resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.465.0.tgz#3f98d40f7b7ac5266c055aaf582c303b07f84de2" integrity sha512-uV7WEqbwaCcc+QjAxIhAvkAr3kgwkkYID3XptCHll72/F7NZlk6ONmJYpk+Xqx5Q0r/8wiOjz73H1BYbl8Z8iw== +luxon@^3.2.1: + version "3.5.0" + resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.5.0.tgz#6b6f65c5cd1d61d1fd19dbf07ee87a50bf4b8e20" + integrity sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ== + lz-string@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" @@ -6291,6 +6315,15 @@ node-releases@^2.0.18: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.19.tgz#9e445a52950951ec4d177d843af370b411caf314" integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw== +node-schedule@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/node-schedule/-/node-schedule-2.1.1.tgz#6958b2c5af8834954f69bb0a7a97c62b97185de3" + integrity sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ== + dependencies: + cron-parser "^4.2.0" + long-timeout "0.1.1" + sorted-array-functions "^1.3.0" + nopt@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88" @@ -7261,6 +7294,11 @@ sonner@^1.5.0: resolved "https://registry.yarnpkg.com/sonner/-/sonner-1.7.0.tgz#f59a2a70e049a179b6fbd1bb1bf2619d5ced07c0" integrity sha512-W6dH7m5MujEPyug3lpI2l3TC3Pp1+LTgK0Efg+IHDrBbtEjyCmCHHo6yfNBOsf1tFZ6zf+jceWwB38baC8yO9g== +sorted-array-functions@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz#8605695563294dffb2c9796d602bd8459f7a0dd5" + integrity sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA== + source-map-js@^1.0.2, source-map-js@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"