mirror of
https://github.com/biersoeckli/QuickStack.git
synced 2026-02-11 05:59:23 -06:00
feat/add S3 backup download functionality and cleanup temp files action
This commit is contained in:
28
src/app/backups/actions.ts
Normal file
28
src/app/backups/actions.ts
Normal 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>>;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface BackupInfoModel {
|
||||
appName: string;
|
||||
appId: string;
|
||||
backupVolumeId: string;
|
||||
s3TargetId: string;
|
||||
volumeId: string;
|
||||
mountPath: string;
|
||||
backupRetention: number;
|
||||
|
||||
Reference in New Issue
Block a user