mirror of
https://github.com/biersoeckli/QuickStack.git
synced 2026-01-01 01:01:43 -06:00
added download dunctionality
This commit is contained in:
35
src/app/api/volume-data-download/route.ts
Normal file
35
src/app/api/volume-data-download/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { FsUtils } from "@/server/utils/fs.utils";
|
||||
import { PathUtils } from "@/server/utils/path.utils";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import path from "path";
|
||||
import fs from 'fs/promises';
|
||||
|
||||
export const dynamic = 'force-dynamic' // defaults to auto
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const requestUrl = new URL(request.url);
|
||||
const fileName = requestUrl.searchParams.get('fileName');
|
||||
if (!fileName) {
|
||||
throw new Error('No file name provided.');
|
||||
}
|
||||
|
||||
const dirOfTempDoanloadedData = PathUtils.tempVolumeDownloadPath;
|
||||
const tarPath = path.join(dirOfTempDoanloadedData, fileName);
|
||||
if (!await FsUtils.fileExists(tarPath)) {
|
||||
throw new Error(`File ${fileName} does not exist.`);
|
||||
}
|
||||
|
||||
const buffer = await fs.readFile(tarPath);
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="data.tar.gz"`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error while downloading data:', error);
|
||||
return new Response((error as Error)?.message ?? 'An unknown error occured.', { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
'use server'
|
||||
|
||||
import { appVolumeEditZodModel } from "@/shared/model/volume-edit.model";
|
||||
import { SuccessActionResult } from "@/shared/model/server-action-error-return.model";
|
||||
import { ServerActionResult, SuccessActionResult } from "@/shared/model/server-action-error-return.model";
|
||||
import appService from "@/server/services/app.service";
|
||||
import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { z } from "zod";
|
||||
import { ServiceException } from "@/shared/model/service.exception.model";
|
||||
import pvcStatusService from "@/server/services/pvc.status.service";
|
||||
import pvcService from "@/server/services/pvc.service";
|
||||
|
||||
const actionAppVolumeEditZodModel = appVolumeEditZodModel.merge(z.object({
|
||||
appId: z.string(),
|
||||
@@ -39,3 +40,9 @@ export const getPvcUsage = async (pvcName: string, pvcNamespace: string) =>
|
||||
await getAuthUserSession();
|
||||
return await pvcStatusService.getPvcUsageByName(pvcName, pvcNamespace);
|
||||
});
|
||||
|
||||
export const downloadPvcData = async (volumeId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
return await pvcService.downloadPvcData(volumeId); // returns the download path on the server
|
||||
}) as Promise<ServerActionResult<any, string>>;
|
||||
|
||||
@@ -4,10 +4,10 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
|
||||
import { AppExtendedModel } from "@/shared/model/app-extended.model";
|
||||
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EditIcon, TrashIcon } from "lucide-react";
|
||||
import { Download, EditIcon, TrashIcon } from "lucide-react";
|
||||
import DialogEditDialog from "./storage-edit-overlay";
|
||||
import { Toast } from "@/frontend/utils/toast.utils";
|
||||
import { deleteVolume } from "./actions";
|
||||
import { deleteVolume, downloadPvcData } from "./actions";
|
||||
|
||||
|
||||
export default function StorageList({ app }: {
|
||||
@@ -40,6 +40,13 @@ export default function StorageList({ app }: {
|
||||
<DialogEditDialog appId={app.id} volume={volume}>
|
||||
<Button variant="ghost"><EditIcon /></Button>
|
||||
</DialogEditDialog>
|
||||
<Button variant="ghost" onClick={() => Toast.fromAction(() => downloadPvcData(volume.id)).then(x => {
|
||||
if (x.status === 'success' && x.data) {
|
||||
window.open('/api/volume-data-download?fileName=' + x.data);
|
||||
}
|
||||
})}>
|
||||
<Download />
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => Toast.fromAction(() => deleteVolume(volume.id))}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
|
||||
@@ -2,7 +2,13 @@ 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 fsPromises from 'fs/promises';
|
||||
import * as tar from 'tar';
|
||||
import stream from 'stream';
|
||||
import { tmpdir } from 'os';
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as k8s from '@kubernetes/client-node';
|
||||
|
||||
class PodService {
|
||||
|
||||
@@ -24,6 +30,71 @@ class PodService {
|
||||
async getPodsForApp(projectId: string, appId: string): Promise<PodsInfoModel[]> {
|
||||
return setupPodService.getPodsForApp(projectId, appId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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<void>((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' /* || stderrStream.size()*/) {
|
||||
return reject(
|
||||
new Error(
|
||||
`Error from cpFromPod - details: \n ${stderrStream.read().toString()}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
/*await tar.x({
|
||||
file: tmpFileName,
|
||||
cwd: tgtPath,
|
||||
});*/
|
||||
resolve();
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
},
|
||||
)
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const podService = new PodService();
|
||||
|
||||
@@ -6,11 +6,47 @@ import { AppVolume } from "@prisma/client";
|
||||
import { KubeObjectNameUtils } from "../utils/kube-object-name.utils";
|
||||
import { Constants } from "../../shared/utils/constants";
|
||||
import { MemoryCalcUtils } from "../utils/memory-caluclation.utils";
|
||||
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";
|
||||
|
||||
class PvcService {
|
||||
|
||||
static readonly SHARED_PVC_NAME = 'qs-shared-pvc';
|
||||
|
||||
async downloadPvcData(volumeId: string) {
|
||||
|
||||
const volume = await dataAccess.client.appVolume.findFirstOrThrow({
|
||||
where: {
|
||||
id: volumeId
|
||||
},
|
||||
include: {
|
||||
app: true
|
||||
}
|
||||
});
|
||||
|
||||
const pod = await podService.getPodsForApp(volume.app.projectId, volume.app.id);
|
||||
if (pod.length === 0) {
|
||||
throw new ServiceException(`No pod found for volume id ${volumeId} in app ${volume.app.id}`);
|
||||
}
|
||||
const firstPod = pod[0];
|
||||
|
||||
const downloadPath = PathUtils.volumeDownloadZipPath(volumeId);
|
||||
await FsUtils.createDirIfNotExistsAsync(PathUtils.tempVolumeDownloadPath, true);
|
||||
await FsUtils.deleteDirIfExistsAsync(downloadPath, true);
|
||||
|
||||
console.log(`Downloading data from pod ${firstPod.podName} ${volume.containerMountPath} to ${downloadPath}`);
|
||||
await podService.cpFromPod(volume.app.projectId, firstPod.podName, firstPod.containerName, volume.containerMountPath, downloadPath);
|
||||
|
||||
const fileName = path.basename(downloadPath);
|
||||
return fileName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
async doesAppConfigurationIncreaseAnyPvcSize(app: AppExtendedModel) {
|
||||
const existingPvcs = await this.getAllPvcForApp(app.projectId, app.id);
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ export class PathUtils {
|
||||
return path.join(this.tempDataRoot, 'git');
|
||||
}
|
||||
|
||||
static get tempVolumeDownloadPath() {
|
||||
return path.join(this.tempDataRoot, 'volume-downloads');
|
||||
}
|
||||
|
||||
static gitRootPathForApp(appId: string): string {
|
||||
return path.join(PathUtils.gitRootPath, this.convertIdToFolderFriendlyName(appId));
|
||||
}
|
||||
@@ -21,6 +25,14 @@ export class PathUtils {
|
||||
return path.join(this.deploymentLogsPath, `${deploymentId}.log`);
|
||||
}
|
||||
|
||||
static volumeDownloadFolder(volumeId: string): string {
|
||||
return path.join(this.tempVolumeDownloadPath, `${volumeId}-data`);
|
||||
}
|
||||
|
||||
static volumeDownloadZipPath(volumeId: string): string {
|
||||
return path.join(this.tempVolumeDownloadPath, `${volumeId}.tar.gz`);
|
||||
}
|
||||
|
||||
private static convertIdToFolderFriendlyName(id: string): string {
|
||||
// remove all special characters
|
||||
return id.replace(/[^a-zA-Z0-9]/g, '_');
|
||||
|
||||
Reference in New Issue
Block a user