feat/add S3 backup download functionality and cleanup temp files action

This commit is contained in:
biersoeckli
2025-01-10 11:31:47 +00:00
parent 16c4444056
commit 40a7fe8741
8 changed files with 121 additions and 13 deletions

View File

@@ -0,0 +1,28 @@
'use server'
import monitoringService from "@/server/services/monitoring.service";
import clusterService from "@/server/services/node.service";
import pvcService from "@/server/services/pvc.service";
import backupService from "@/server/services/standalone-services/backup.service";
import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
import { AppMonitoringUsageModel } from "@/shared/model/app-monitoring-usage.model";
import { AppVolumeMonitoringUsageModel } from "@/shared/model/app-volume-monitoring-usage.model";
import { NodeResourceModel } from "@/shared/model/node-resource.model";
import { ServerActionResult, SuccessActionResult } from "@/shared/model/server-action-error-return.model";
import { z } from "zod";
export const downloadBackup = async (s3TargetId: string, s3Key: string) =>
simpleAction(async () => {
await getAuthUserSession();
const validatetData = z.object({
s3TargetId: z.string(),
s3Key: z.string()
}).parse({
s3TargetId,
s3Key
});
const fileNameOfDownloadedFile = await backupService.downloadBackupForS3TargetAndKey(validatetData.s3TargetId, validatetData.s3Key);
return new SuccessActionResult(fileNameOfDownloadedFile, 'Starting download...'); // returns the download path on the server
}) as Promise<ServerActionResult<any, string>>;

View File

@@ -12,6 +12,10 @@ import { ScrollArea } from "@radix-ui/react-scroll-area";
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { KubeSizeConverter } from "@/shared/utils/kubernetes-size-converter.utils";
import { formatDateTime } from "@/frontend/utils/format.utils";
import { downloadBackup } from "./actions";
import { Button } from "@/components/ui/button";
import { Download } from "lucide-react";
import { Toast } from "@/frontend/utils/toast.utils";
export function BackupDetailDialog({
backupInfo,
@@ -22,6 +26,20 @@ export function BackupDetailDialog({
}) {
const [isOpen, setIsOpen] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(false);
const asyncDownloadPvcData = async (s3Key: string) => {
try {
setIsLoading(true);
await Toast.fromAction(() => downloadBackup(backupInfo.s3TargetId, s3Key)).then(x => {
if (x.status === 'success' && x.data) {
window.open('/api/volume-data-download?fileName=' + x.data);
}
});
} finally {
setIsLoading(false);
}
}
return (
<Dialog open={isOpen} onOpenChange={(isO) => {
@@ -46,6 +64,7 @@ export function BackupDetailDialog({
<TableRow>
<TableHead>Time</TableHead>
<TableHead>Size</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -53,6 +72,11 @@ export function BackupDetailDialog({
<TableRow key={index}>
<TableCell>{formatDateTime(item.backupDate, true)}</TableCell>
<TableCell>{item.sizeBytes ? KubeSizeConverter.convertBytesToReadableSize(item.sizeBytes) : 'unknown'}</TableCell>
<TableCell className="flex justify-end">
<Button variant="ghost" size="sm" onClick={() => asyncDownloadPvcData(item.key)} disabled={isLoading}>
<Download />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>

View File

@@ -1,13 +1,13 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cleanupOldBuildJobs, purgeRegistryImages, updateQuickstack, updateRegistry } from "../server/actions";
import { cleanupOldBuildJobs, cleanupOldTmpFiles, purgeRegistryImages, updateRegistry } from "../server/actions";
import { Button } from "@/components/ui/button";
import { Toast } from "@/frontend/utils/toast.utils";
import { useConfirmDialog } from "@/frontend/states/zustand.states";
import { LogsDialog } from "@/components/custom/logs-overlay";
import { Constants } from "@/shared/utils/constants";
import { Rocket, RotateCcw, SquareTerminal, Trash } from "lucide-react";
import { RotateCcw, SquareTerminal, Trash } from "lucide-react";
export default function QuickStackMaintenanceSettings({
qsPodName
@@ -38,12 +38,21 @@ export default function QuickStackMaintenanceSettings({
if (await useConfirm.openConfirmDialog({
title: 'Cleanup Old Build Jobs',
description: 'This action deletes all old build jobs. Use this action to free up disk space.',
okButton: "Cleanup Old Build Jobs"
okButton: "Cleanup"
})) {
Toast.fromAction(() => cleanupOldBuildJobs());
}
}}><Trash /> Cleanup Old Build Jobs</Button>
<Button variant="secondary" onClick={async () => {
if (await useConfirm.openConfirmDialog({
title: 'Cleanup Temp Files',
description: 'This action deletes all temporary files. Use this action to free up disk space.',
okButton: "Cleanup"
})) {
Toast.fromAction(() => cleanupOldTmpFiles());
}
}}><Trash /> Cleanup Temp Files</Button>
</CardContent>
</Card>

View File

@@ -13,6 +13,8 @@ import { QsPublicIpv4SettingsModel, qsPublicIpv4SettingsZodModel } from "@/share
import ipAddressFinderAdapter from "@/server/adapter/ip-adress-finder.adapter";
import { KubeSizeConverter } from "@/shared/utils/kubernetes-size-converter.utils";
import buildService from "@/server/services/build.service";
import { PathUtils } from "@/server/utils/path.utils";
import { FsUtils } from "@/server/utils/fs.utils";
export const updateIngressSettings = async (prevState: any, inputData: QsIngressSettingsModel) =>
saveFormAction(inputData, qsIngressSettingsZodModel, async (validatedData) => {
@@ -76,6 +78,16 @@ export const getConfiguredHostname: () => Promise<ServerActionResult<unknown, st
return await paramService.getString(ParamService.QS_SERVER_HOSTNAME);
});
export const cleanupOldTmpFiles = async () =>
simpleAction(async () => {
await getAuthUserSession();
const tempFilePath = PathUtils.tempDataRoot;
await FsUtils.deleteDirIfExistsAsync(tempFilePath, true);
await FsUtils.createDirIfNotExistsAsync(tempFilePath);
return new SuccessActionResult(undefined, 'Successfully cleaned up temp files.');
});
export const cleanupOldBuildJobs = async () =>
simpleAction(async () => {
await getAuthUserSession();

View File

@@ -1,8 +1,9 @@
import { DeleteObjectCommand, HeadBucketCommand, ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { DeleteObjectCommand, GetObjectCommand, HeadBucketCommand, ListObjectsV2Command, PutObjectCommand } from "@aws-sdk/client-s3";
import { S3Target } from "@prisma/client";
import s3Adapter from "../adapter/aws-s3.adapter";
import { randomUUID } from "crypto";
import { createReadStream } from "fs";
import fsPromises from "fs/promises";
import { ServiceException } from "@/shared/model/service.exception.model";
export class S3Service {
@@ -36,6 +37,22 @@ export class S3Service {
await client.send(command);
}
async downloadFile(s3Target: S3Target, key: string, outputFilePath: string) {
const client = s3Adapter.getS3Client(s3Target);
const command = new GetObjectCommand({
Bucket: s3Target.bucketName,
Key: key,
});
const response = await client.send(command);
const fileStream = await response.Body?.transformToByteArray();
if (!fileStream) {
throw new ServiceException('No file stream found');
}
await fsPromises.writeFile(outputFilePath, fileStream);
}
async uploadFile(s3Target: S3Target,
inputFilePath: string,
fileName: string,

View File

@@ -1,20 +1,15 @@
import { AppExtendedModel } from "@/shared/model/app-extended.model";
import k3s from "../adapter/kubernetes-api.adapter";
import longhornApiAdapter from "../adapter/longhorn-api.adapter";
import { V1PersistentVolumeClaim } from "@kubernetes/client-node";
import { ServiceException } from "@/shared/model/service.exception.model";
import { AppVolume } from "@prisma/client";
import { KubeObjectNameUtils } from "../utils/kube-object-name.utils";
import { Constants } from "../../shared/utils/constants";
import { FsUtils } from "../utils/fs.utils";
import { PathUtils } from "../utils/path.utils";
import * as k8s from '@kubernetes/client-node';
import dataAccess from "../adapter/db.client";
import podService from "./pod.service";
import path from "path";
import { log } from "console";
import { KubeSizeConverter } from "../../shared/utils/kubernetes-size-converter.utils";
import { AppVolumeMonitoringUsageModel } from "@/shared/model/app-volume-monitoring-usage.model";
class PvcService {

View File

@@ -6,7 +6,7 @@ import s3Service from "../aws-s3.service";
import scheduleService from "./schedule.service";
import standalonePodService from "./standalone-pod.service";
import { ListUtils } from "../../../shared/utils/list.utils";
import { S3Target, VolumeBackup } from "@prisma/client";
import { S3Target } from "@prisma/client";
import { BackupEntry, BackupInfoModel } from "../../../shared/model/backup-info.model";
const s3BucketPrefix = 'quickstack-backups';
@@ -48,6 +48,28 @@ class BackupService {
}
}
/**
* Downloads a backup from S3, stores it in temporary download folder and returns the filename
*/
async downloadBackupForS3TargetAndKey(s3TargetId: string, key: string) {
const s3Target = await dataAccess.client.s3Target.findFirstOrThrow({
where: {
id: s3TargetId
}
});
const fileName = key.split('/').join('-');
const downloadPath = PathUtils.volumeDownloadZipPath(fileName);
await FsUtils.createDirIfNotExistsAsync(PathUtils.tempVolumeDownloadPath, true);
await FsUtils.deleteDirIfExistsAsync(downloadPath, true);
console.log(`Downloading data from S3 ${key} to ${downloadPath}...`);
await s3Service.downloadFile(s3Target, key, downloadPath);
console.log(`Download to QuickStack Pod successful`);
return PathUtils.splitPath(downloadPath).filePath;
}
async getBackupsForAllS3Targets() {
const s3Targets = await dataAccess.client.s3Target.findMany();
const returnValFromAllS3Targets = await Promise.all(s3Targets.map(s3Target =>
@@ -104,7 +126,6 @@ class BackupService {
backupEntries.sort((a, b) => b.backupDate.getTime() - a.backupDate.getTime());
backupInfoModels.push({
projectId: volumeBackup?.volume.app.projectId ?? defaultInfoIfAppWasDeleted,
projectName: volumeBackup?.volume.app.project.name ?? defaultInfoIfAppWasDeleted,
@@ -114,7 +135,8 @@ class BackupService {
backupRetention: volumeBackup?.retention ?? 0,
volumeId: volumeBackup?.id ?? defaultInfoIfAppWasDeleted,
mountPath: volumeBackup?.volume.containerMountPath ?? defaultInfoIfAppWasDeleted,
backups: backupEntries
backups: backupEntries,
s3TargetId: s3Target.id
});
}

View File

@@ -4,6 +4,7 @@ export interface BackupInfoModel {
appName: string;
appId: string;
backupVolumeId: string;
s3TargetId: string;
volumeId: string;
mountPath: string;
backupRetention: number;