mirror of
https://github.com/biersoeckli/QuickStack.git
synced 2026-02-09 21:19:07 -06:00
Add shareable volumes across project apps
This commit is contained in:
@@ -257,9 +257,13 @@ model AppVolume {
|
||||
size Int
|
||||
accessMode String @default("rwo")
|
||||
storageClassName String @default("longhorn")
|
||||
shareWithOtherApps Boolean @default(false)
|
||||
sharedVolumeId String?
|
||||
appId String
|
||||
app App @relation(fields: [appId], references: [id], onDelete: Cascade)
|
||||
volumeBackups VolumeBackup[]
|
||||
sharedVolume AppVolume? @relation("SharedVolume", fields: [sharedVolumeId], references: [id], onDelete: Cascade)
|
||||
sharedVolumes AppVolume[] @relation("SharedVolume")
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -44,6 +44,39 @@ export const saveVolume = async (prevState: any, inputData: z.infer<typeof actio
|
||||
await isAuthorizedWriteForApp(validatedData.appId);
|
||||
const existingApp = await appService.getExtendedById(validatedData.appId);
|
||||
const existingVolume = validatedData.id ? await appService.getVolumeById(validatedData.id) : undefined;
|
||||
const sharedVolumeId = existingVolume?.sharedVolumeId ?? validatedData.sharedVolumeId ?? undefined;
|
||||
|
||||
if (sharedVolumeId) {
|
||||
const sharedVolume = await dataAccess.client.appVolume.findFirstOrThrow({
|
||||
where: {
|
||||
id: sharedVolumeId
|
||||
},
|
||||
include: {
|
||||
app: true
|
||||
}
|
||||
});
|
||||
if (sharedVolume.app.projectId !== existingApp.projectId) {
|
||||
throw new ServiceException('Shared volumes must belong to the same project.');
|
||||
}
|
||||
if (sharedVolume.appId === validatedData.appId) {
|
||||
throw new ServiceException('Shared volumes must belong to a different app.');
|
||||
}
|
||||
if (!sharedVolume.shareWithOtherApps || sharedVolume.accessMode !== 'ReadWriteMany') {
|
||||
throw new ServiceException('This volume is not available for sharing.');
|
||||
}
|
||||
await appService.saveVolume({
|
||||
appId: validatedData.appId,
|
||||
id: validatedData.id ?? undefined,
|
||||
containerMountPath: validatedData.containerMountPath,
|
||||
size: sharedVolume.size,
|
||||
accessMode: sharedVolume.accessMode,
|
||||
storageClassName: sharedVolume.storageClassName,
|
||||
shareWithOtherApps: false,
|
||||
sharedVolumeId: sharedVolume.id
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingVolume && existingVolume.size > validatedData.size) {
|
||||
throw new ServiceException('Volume size cannot be decreased');
|
||||
}
|
||||
@@ -56,11 +89,16 @@ export const saveVolume = async (prevState: any, inputData: z.infer<typeof actio
|
||||
if (validatedData.accessMode === 'ReadWriteMany' && validatedData.storageClassName === 'local-path') {
|
||||
throw new ServiceException('The Local Path storage class does not support ReadWriteMany access mode. Please choose another storage class / access mode.');
|
||||
}
|
||||
if (validatedData.shareWithOtherApps && (existingVolume?.accessMode ?? validatedData.accessMode) !== 'ReadWriteMany') {
|
||||
throw new ServiceException('Only ReadWriteMany volumes can be shared with other apps.');
|
||||
}
|
||||
await appService.saveVolume({
|
||||
...validatedData,
|
||||
id: validatedData.id ?? undefined,
|
||||
accessMode: existingVolume?.accessMode ?? validatedData.accessMode as string,
|
||||
storageClassName: existingVolume?.storageClassName ?? validatedData.storageClassName
|
||||
storageClassName: existingVolume?.storageClassName ?? validatedData.storageClassName,
|
||||
shareWithOtherApps: validatedData.shareWithOtherApps ?? false,
|
||||
sharedVolumeId: null
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,6 +115,14 @@ export const getPvcUsage = async (appId: string, projectId: string) =>
|
||||
return monitoringService.getPvcUsageFromApp(appId, projectId);
|
||||
}) as Promise<ServerActionResult<any, { pvcName: string, usedBytes: number }[]>>;
|
||||
|
||||
export const getShareableVolumes = async (appId: string) =>
|
||||
simpleAction(async () => {
|
||||
await isAuthorizedReadForApp(appId);
|
||||
const app = await appService.getExtendedById(appId);
|
||||
const volumes = await appService.getShareableVolumesByProjectId(app.projectId, appId);
|
||||
return new SuccessActionResult(volumes);
|
||||
}) as Promise<ServerActionResult<any, { id: string; containerMountPath: string; size: number; storageClassName: string; accessMode: string; app: { name: string } }[]>>;
|
||||
|
||||
export const downloadPvcData = async (volumeId: string) =>
|
||||
simpleAction(async () => {
|
||||
await validateVolumeReadAuthorization(volumeId);
|
||||
|
||||
@@ -28,18 +28,19 @@ import { Check, ChevronsUpDown } from "lucide-react"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useFormState } from 'react-dom'
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { FormUtils } from "@/frontend/utils/form.utilts";
|
||||
import { SubmitButton } from "@/components/custom/submit-button";
|
||||
import { AppVolume } from "@prisma/client"
|
||||
import { AppVolumeEditModel, appVolumeEditZodModel } from "@/shared/model/volume-edit.model"
|
||||
import { ServerActionResult } from "@/shared/model/server-action-error-return.model"
|
||||
import { saveVolume } from "./actions"
|
||||
import { getShareableVolumes, saveVolume } from "./actions"
|
||||
import { toast } from "sonner"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { QuestionMarkCircledIcon } from "@radix-ui/react-icons"
|
||||
import { AppExtendedModel } from "@/shared/model/app-extended.model"
|
||||
import { NodeInfoModel } from "@/shared/model/node-info.model"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
|
||||
const accessModes = [
|
||||
{ label: "ReadWriteOnce", value: "ReadWriteOnce" },
|
||||
@@ -51,14 +52,18 @@ const storageClasses = [
|
||||
{ label: "Local Path", value: "local-path", description: "Node-local volumes, no replication. Data is stored on the master node. Only works in a single node setup." }
|
||||
] as const
|
||||
|
||||
type AppVolumeWithSharing = AppVolume & { sharedVolumeId?: string | null; shareWithOtherApps?: boolean };
|
||||
|
||||
export default function DialogEditDialog({ children, volume, app, nodesInfo }: {
|
||||
children: React.ReactNode;
|
||||
volume?: AppVolume;
|
||||
volume?: AppVolumeWithSharing;
|
||||
app: AppExtendedModel;
|
||||
nodesInfo: NodeInfoModel[];
|
||||
}) {
|
||||
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [useExistingVolume, setUseExistingVolume] = useState(false);
|
||||
const [shareableVolumes, setShareableVolumes] = useState<{ id: string; containerMountPath: string; size: number; storageClassName: string; accessMode: string; app: { name: string } }[]>([]);
|
||||
|
||||
|
||||
const form = useForm<AppVolumeEditModel>({
|
||||
@@ -67,9 +72,16 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: {
|
||||
...volume,
|
||||
accessMode: volume?.accessMode ?? (app.replicas > 1 ? "ReadWriteMany" : "ReadWriteOnce"),
|
||||
storageClassName: (volume?.storageClassName ?? "longhorn") as 'longhorn' | 'local-path',
|
||||
shareWithOtherApps: volume?.shareWithOtherApps ?? false,
|
||||
sharedVolumeId: volume?.sharedVolumeId ?? null,
|
||||
}
|
||||
});
|
||||
|
||||
const selectedAccessMode = form.watch("accessMode");
|
||||
const selectedSharedVolumeId = form.watch("sharedVolumeId");
|
||||
const selectedSharedVolume = useMemo(() => shareableVolumes.find(item => item.id === selectedSharedVolumeId), [shareableVolumes, selectedSharedVolumeId]);
|
||||
const hasShareableVolumes = shareableVolumes.length > 0;
|
||||
|
||||
const [state, formAction] = useFormState((state: ServerActionResult<any, any>, payload: AppVolumeEditModel) =>
|
||||
saveVolume(state, {
|
||||
...payload,
|
||||
@@ -93,9 +105,49 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: {
|
||||
...volume,
|
||||
accessMode: volume?.accessMode ?? (app.replicas > 1 ? "ReadWriteMany" : "ReadWriteOnce"),
|
||||
storageClassName: (volume?.storageClassName ?? "longhorn") as 'longhorn' | 'local-path',
|
||||
shareWithOtherApps: volume?.shareWithOtherApps ?? false,
|
||||
sharedVolumeId: volume?.sharedVolumeId ?? null,
|
||||
});
|
||||
setUseExistingVolume(false);
|
||||
}, [volume]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || volume) {
|
||||
return;
|
||||
}
|
||||
const loadShareableVolumes = async () => {
|
||||
const response = await getShareableVolumes(app.id);
|
||||
if (response.status === 'success' && response.data) {
|
||||
setShareableVolumes(response.data);
|
||||
} else {
|
||||
setShareableVolumes([]);
|
||||
}
|
||||
};
|
||||
loadShareableVolumes();
|
||||
}, [app.id, isOpen, volume]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!useExistingVolume) {
|
||||
form.setValue("sharedVolumeId", null);
|
||||
return;
|
||||
}
|
||||
if (selectedSharedVolume) {
|
||||
form.setValue("size", selectedSharedVolume.size);
|
||||
form.setValue("accessMode", selectedSharedVolume.accessMode);
|
||||
form.setValue("storageClassName", selectedSharedVolume.storageClassName as 'longhorn' | 'local-path');
|
||||
form.setValue("shareWithOtherApps", false);
|
||||
}
|
||||
}, [form, selectedSharedVolume, useExistingVolume]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!useExistingVolume || selectedSharedVolumeId) {
|
||||
return;
|
||||
}
|
||||
if (shareableVolumes.length > 0) {
|
||||
form.setValue("sharedVolumeId", shareableVolumes[0].id);
|
||||
}
|
||||
}, [form, selectedSharedVolumeId, shareableVolumes, useExistingVolume]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div onClick={() => setIsOpen(true)}>
|
||||
@@ -114,6 +166,86 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: {
|
||||
return formAction(data);
|
||||
})()}>
|
||||
<div className="space-y-4">
|
||||
{!volume && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="use-existing-volume"
|
||||
checked={useExistingVolume}
|
||||
onCheckedChange={(checked) => setUseExistingVolume(!!checked)}
|
||||
disabled={!hasShareableVolumes}
|
||||
/>
|
||||
<FormLabel htmlFor="use-existing-volume">Use existing shared volume</FormLabel>
|
||||
</div>
|
||||
)}
|
||||
{!volume && !hasShareableVolumes && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No shared volumes are available from other apps in this project.
|
||||
</p>
|
||||
)}
|
||||
{!volume && useExistingVolume && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sharedVolumeId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>Shared Volume</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{selectedSharedVolume
|
||||
? `${selectedSharedVolume.app.name} · ${selectedSharedVolume.containerMountPath}`
|
||||
: "Select a shared volume"}
|
||||
<ChevronsUpDown className="opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="max-w-[320px] p-0">
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{shareableVolumes.map((shareableVolume) => (
|
||||
<CommandItem
|
||||
value={`${shareableVolume.app.name}-${shareableVolume.containerMountPath}`}
|
||||
key={shareableVolume.id}
|
||||
onSelect={() => {
|
||||
form.setValue("sharedVolumeId", shareableVolume.id);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>{shareableVolume.app.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{shareableVolume.containerMountPath} · {shareableVolume.size} MB</span>
|
||||
</div>
|
||||
<Check
|
||||
className={cn(
|
||||
"ml-auto",
|
||||
shareableVolume.id === field.value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormDescription>
|
||||
Select a ReadWriteMany volume shared by another app in this project.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="containerMountPath"
|
||||
@@ -135,7 +267,7 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: {
|
||||
<FormItem>
|
||||
<FormLabel>Size in MB</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="ex. 20" {...field} />
|
||||
<Input type="number" placeholder="ex. 20" {...field} disabled={useExistingVolume || !!volume?.sharedVolumeId} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -145,7 +277,7 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: {
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="accessMode"
|
||||
disabled={!!volume}
|
||||
disabled={!!volume || useExistingVolume || !!volume?.sharedVolumeId}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel className="flex gap-2">
|
||||
@@ -223,6 +355,29 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{!useExistingVolume && !volume?.sharedVolumeId && selectedAccessMode === 'ReadWriteMany' && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="shareWithOtherApps"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value ?? false}
|
||||
onCheckedChange={(checked) => field.onChange(!!checked)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel>Share with other apps in project</FormLabel>
|
||||
</div>
|
||||
<FormDescription>
|
||||
Allow other apps in this project to mount this volume at their own paths.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{nodesInfo.length === 1 &&
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -256,7 +411,7 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: {
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground"
|
||||
)}
|
||||
disabled={!!volume}
|
||||
disabled={!!volume || useExistingVolume || !!volume?.sharedVolumeId}
|
||||
>
|
||||
{field.value
|
||||
? storageClasses.find(
|
||||
|
||||
@@ -24,7 +24,8 @@ import { KubeSizeConverter } from "@/shared/utils/kubernetes-size-converter.util
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { NodeInfoModel } from "@/shared/model/node-info.model";
|
||||
|
||||
type AppVolumeWithCapacity = (AppVolume & { usedBytes?: number; capacityBytes?: number; usedPercentage?: number });
|
||||
type AppVolumeWithSharing = AppVolume & { sharedVolumeId?: string | null; shareWithOtherApps?: boolean };
|
||||
type AppVolumeWithCapacity = (AppVolumeWithSharing & { usedBytes?: number; capacityBytes?: number; usedPercentage?: number });
|
||||
|
||||
export default function StorageList({ app, readonly, nodesInfo }: {
|
||||
app: AppExtendedModel;
|
||||
@@ -42,7 +43,8 @@ export default function StorageList({ app, readonly, nodesInfo }: {
|
||||
if (response.status === 'success' && response.data) {
|
||||
const mappedVolumeData = [...app.appVolumes] as AppVolumeWithCapacity[];
|
||||
for (let item of mappedVolumeData) {
|
||||
const volume = response.data.find(x => x.pvcName === KubeObjectNameUtils.toPvcName(item.id));
|
||||
const pvcVolumeId = item.sharedVolumeId ?? item.id;
|
||||
const volume = response.data.find(x => x.pvcName === KubeObjectNameUtils.toPvcName(pvcVolumeId));
|
||||
if (volume) {
|
||||
item.usedBytes = volume.usedBytes;
|
||||
item.capacityBytes = KubeSizeConverter.fromMegabytesToBytes(item.size);
|
||||
|
||||
@@ -267,6 +267,28 @@ class AppService {
|
||||
});
|
||||
}
|
||||
|
||||
async getShareableVolumesByProjectId(projectId: string, appId: string) {
|
||||
return await dataAccess.client.appVolume.findMany({
|
||||
where: {
|
||||
app: {
|
||||
projectId
|
||||
},
|
||||
appId: {
|
||||
not: appId
|
||||
},
|
||||
shareWithOtherApps: true,
|
||||
accessMode: 'ReadWriteMany',
|
||||
sharedVolumeId: null
|
||||
},
|
||||
include: {
|
||||
app: true
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async saveVolume(volumeToBeSaved: Prisma.AppVolumeUncheckedCreateInput | Prisma.AppVolumeUncheckedUpdateInput) {
|
||||
let savedItem: AppVolume;
|
||||
const existingApp = await this.getExtendedById(volumeToBeSaved.appId as string);
|
||||
|
||||
@@ -39,7 +39,8 @@ class FileBrowserService {
|
||||
console.log(`Deploying filebrowser for volume ${volumeId}`);
|
||||
const traefikHostname = await hostnameDnsProviderService.getDomainForApp(volume.id);
|
||||
|
||||
const pvcName = KubeObjectNameUtils.toPvcName(volume.id);
|
||||
const sharedVolumeId = (volume as { sharedVolumeId?: string | null }).sharedVolumeId;
|
||||
const pvcName = KubeObjectNameUtils.toPvcName(sharedVolumeId ?? volume.id);
|
||||
|
||||
console.log(`Creating filebrowser deployment for volume ${volumeId}`);
|
||||
|
||||
@@ -232,4 +233,4 @@ class FileBrowserService {
|
||||
}
|
||||
|
||||
const fileBrowserService = new FileBrowserService();
|
||||
export default fileBrowserService;
|
||||
export default fileBrowserService;
|
||||
|
||||
@@ -34,10 +34,13 @@ class MonitorService {
|
||||
]);
|
||||
|
||||
const appVolumesWithUsage: AppVolumeMonitoringUsageModel[] = [];
|
||||
const volumeMap = new Map(appVolumes.map(volume => [volume.id, volume]));
|
||||
|
||||
for (const appVolume of appVolumes) {
|
||||
|
||||
const pvc = pvcs.find(pvc => pvc.metadata?.name === KubeObjectNameUtils.toPvcName(appVolume.id));
|
||||
const sharedVolumeId = (appVolume as { sharedVolumeId?: string | null }).sharedVolumeId;
|
||||
const baseVolumeId = sharedVolumeId ?? appVolume.id;
|
||||
const baseVolume = volumeMap.get(baseVolumeId);
|
||||
const pvc = pvcs.find(pvc => pvc.metadata?.name === KubeObjectNameUtils.toPvcName(baseVolumeId));
|
||||
if (!pvc) {
|
||||
continue;
|
||||
}
|
||||
@@ -54,7 +57,7 @@ class MonitorService {
|
||||
appId: appVolume.appId,
|
||||
mountPath: appVolume.containerMountPath,
|
||||
usedBytes: longhornVolume.actualSizeBytes,
|
||||
capacityBytes: KubeSizeConverter.fromMegabytesToBytes(appVolume.size),
|
||||
capacityBytes: KubeSizeConverter.fromMegabytesToBytes(baseVolume?.size ?? appVolume.size),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -156,15 +159,28 @@ class MonitorService {
|
||||
}
|
||||
|
||||
async getPvcUsageFromApp(appId: string, projectId: string): Promise<Array<{ pvcName: string, usedBytes: number }>> {
|
||||
const pvcFromApp = await pvcService.getAllPvcForApp(projectId, appId);
|
||||
const appVolumes = await dataAccess.client.appVolume.findMany({
|
||||
where: {
|
||||
appId
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
sharedVolumeId: true
|
||||
}
|
||||
});
|
||||
if (appVolumes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const baseVolumeIds = Array.from(new Set(appVolumes.map(volume => (volume as { sharedVolumeId?: string | null }).sharedVolumeId ?? volume.id)));
|
||||
const pvcNames = new Set(baseVolumeIds.map(id => KubeObjectNameUtils.toPvcName(id)));
|
||||
const pvcFromProject = await k3s.core.listNamespacedPersistentVolumeClaim(projectId);
|
||||
const pvcUsageData: Array<{ pvcName: string, usedBytes: number }> = [];
|
||||
|
||||
for (const pvc of pvcFromApp) {
|
||||
for (const pvc of pvcFromProject.body.items) {
|
||||
const pvcName = pvc.metadata?.name;
|
||||
const volumeName = pvc.spec?.volumeName;
|
||||
|
||||
if (pvcName && volumeName) {
|
||||
|
||||
if (pvcName && volumeName && pvcNames.has(pvcName)) {
|
||||
const usedBytes = await longhornApiAdapter.getLonghornVolume(volumeName);
|
||||
pvcUsageData.push({ pvcName, usedBytes });
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import path from "path";
|
||||
import { KubeSizeConverter } from "../../shared/utils/kubernetes-size-converter.utils";
|
||||
import { AppVolume } from "@prisma/client";
|
||||
|
||||
type AppVolumeWithSharing = AppVolume & { sharedVolumeId?: string | null };
|
||||
|
||||
class PvcService {
|
||||
|
||||
static readonly SHARED_PVC_NAME = 'qs-shared-pvc';
|
||||
@@ -45,9 +47,11 @@ class PvcService {
|
||||
}
|
||||
|
||||
async doesAppConfigurationIncreaseAnyPvcSize(app: AppExtendedModel) {
|
||||
const existingPvcs = await this.getAllPvcForApp(app.projectId, app.id);
|
||||
const existingPvcsResponse = await k3s.core.listNamespacedPersistentVolumeClaim(app.projectId);
|
||||
const existingPvcs = existingPvcsResponse.body.items;
|
||||
const baseVolumes = await this.getBaseVolumes(app);
|
||||
|
||||
for (const appVolume of app.appVolumes) {
|
||||
for (const appVolume of baseVolumes) {
|
||||
const pvcName = KubeObjectNameUtils.toPvcName(appVolume.id);
|
||||
const existingPvc = existingPvcs.find(pvc => pvc.metadata?.name === pvcName);
|
||||
if (existingPvc && existingPvc.spec!.resources!.requests!.storage !== KubeSizeConverter.megabytesToKubeFormat(appVolume.size)) {
|
||||
@@ -95,24 +99,33 @@ class PvcService {
|
||||
}
|
||||
}
|
||||
|
||||
async createPvcForVolumeIfNotExists(projectId: string, app: AppVolume) {
|
||||
const pvcName = KubeObjectNameUtils.toPvcName(app.id);
|
||||
const existingPvc = await this.getExistingPvcByVolumeId(projectId, app.id);
|
||||
async createPvcForVolumeIfNotExists(projectId: string, app: AppVolumeWithSharing) {
|
||||
const baseVolume = app.sharedVolumeId
|
||||
? await dataAccess.client.appVolume.findFirstOrThrow({
|
||||
where: {
|
||||
id: app.sharedVolumeId
|
||||
}
|
||||
})
|
||||
: app;
|
||||
const pvcName = KubeObjectNameUtils.toPvcName(baseVolume.id);
|
||||
const existingPvc = await this.getExistingPvcByVolumeId(projectId, baseVolume.id);
|
||||
|
||||
if (existingPvc) {
|
||||
console.log(`PVC ${pvcName} for app ${app.id} already exists, no need to create it`);
|
||||
return;
|
||||
}
|
||||
|
||||
const pvcDefinition = this.mapVolumeToPvcDefinition(projectId, app);
|
||||
const pvcDefinition = this.mapVolumeToPvcDefinition(projectId, baseVolume);
|
||||
await k3s.core.createNamespacedPersistentVolumeClaim(projectId, pvcDefinition);
|
||||
console.log(`Created PVC ${pvcName} for app ${app.id}`);
|
||||
}
|
||||
|
||||
async createOrUpdatePvc(app: AppExtendedModel) {
|
||||
const existingPvcs = await this.getAllPvcForApp(app.projectId, app.id);
|
||||
const existingPvcsResponse = await k3s.core.listNamespacedPersistentVolumeClaim(app.projectId);
|
||||
const existingPvcs = existingPvcsResponse.body.items;
|
||||
const baseVolumes = await this.getBaseVolumes(app);
|
||||
|
||||
for (const appVolume of app.appVolumes) {
|
||||
for (const appVolume of baseVolumes) {
|
||||
const pvcName = KubeObjectNameUtils.toPvcName(appVolume.id);
|
||||
const pvcDefinition = this.mapVolumeToPvcDefinition(app.projectId, appVolume);
|
||||
const desiredStorageClassName = appVolume.storageClassName ?? 'longhorn';
|
||||
@@ -143,21 +156,24 @@ class PvcService {
|
||||
}
|
||||
}
|
||||
|
||||
const volumes = app.appVolumes
|
||||
.filter(pvcObj => pvcObj.appId === app.id)
|
||||
.map(pvcObj => ({
|
||||
name: KubeObjectNameUtils.toPvcName(pvcObj.id),
|
||||
persistentVolumeClaim: {
|
||||
claimName: KubeObjectNameUtils.toPvcName(pvcObj.id)
|
||||
},
|
||||
}));
|
||||
const volumesMap = new Map<string, { name: string; persistentVolumeClaim: { claimName: string } }>();
|
||||
for (const pvcObj of app.appVolumes) {
|
||||
const baseVolumeId = pvcObj.sharedVolumeId ?? pvcObj.id;
|
||||
if (!volumesMap.has(baseVolumeId)) {
|
||||
volumesMap.set(baseVolumeId, {
|
||||
name: KubeObjectNameUtils.toPvcName(baseVolumeId),
|
||||
persistentVolumeClaim: {
|
||||
claimName: KubeObjectNameUtils.toPvcName(baseVolumeId)
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
const volumes = Array.from(volumesMap.values());
|
||||
|
||||
const volumeMounts = app.appVolumes
|
||||
.filter(pvcObj => pvcObj.appId === app.id)
|
||||
.map(pvcObj => ({
|
||||
name: KubeObjectNameUtils.toPvcName(pvcObj.id),
|
||||
mountPath: pvcObj.containerMountPath,
|
||||
}));
|
||||
const volumeMounts = app.appVolumes.map(pvcObj => ({
|
||||
name: KubeObjectNameUtils.toPvcName(pvcObj.sharedVolumeId ?? pvcObj.id),
|
||||
mountPath: pvcObj.containerMountPath,
|
||||
}));
|
||||
|
||||
return { volumes, volumeMounts };
|
||||
}
|
||||
@@ -201,6 +217,20 @@ class PvcService {
|
||||
iterationCount++;
|
||||
}
|
||||
}
|
||||
|
||||
private async getBaseVolumes(app: AppExtendedModel): Promise<AppVolume[]> {
|
||||
const baseVolumeIds = Array.from(new Set(app.appVolumes.map(volume => volume.sharedVolumeId ?? volume.id)));
|
||||
if (baseVolumeIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return await dataAccess.client.appVolume.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: baseVolumeIds
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const pvcService = new PvcService();
|
||||
|
||||
@@ -57,8 +57,16 @@ class RestoreService {
|
||||
}
|
||||
|
||||
async startAplineImageInNamespace(namespace: string, volumeId: string) {
|
||||
const volume = await dataAccess.client.appVolume.findFirstOrThrow({
|
||||
where: {
|
||||
id: volumeId
|
||||
},
|
||||
select: {
|
||||
sharedVolumeId: true
|
||||
}
|
||||
});
|
||||
const name = KubeObjectNameUtils.toRestorePodName(volumeId);
|
||||
const pvcName = KubeObjectNameUtils.toPvcName(volumeId);
|
||||
const pvcName = KubeObjectNameUtils.toPvcName(volume.sharedVolumeId ?? volumeId);
|
||||
|
||||
const existingPods = await k3s.core.listNamespacedPod(namespace);
|
||||
const pod = existingPods.body.items.find((item) => item.metadata?.labels?.app === name);
|
||||
@@ -98,4 +106,4 @@ class RestoreService {
|
||||
}
|
||||
|
||||
const restoreService = new RestoreService();
|
||||
export default restoreService;
|
||||
export default restoreService;
|
||||
|
||||
@@ -8,6 +8,8 @@ export const AppVolumeModel = z.object({
|
||||
size: z.number().int(),
|
||||
accessMode: z.string(),
|
||||
storageClassName: z.string(),
|
||||
shareWithOtherApps: z.boolean(),
|
||||
sharedVolumeId: z.string().nullish(),
|
||||
appId: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
|
||||
@@ -9,6 +9,8 @@ export const appVolumeEditZodModel = z.object({
|
||||
size: stringToNumber,
|
||||
accessMode: appVolumeTypeZodModel.nullish().or(z.string().nullish()),
|
||||
storageClassName: storageClassNameZodModel.default("longhorn"),
|
||||
shareWithOtherApps: z.boolean().optional().default(false),
|
||||
sharedVolumeId: z.string().nullish(),
|
||||
});
|
||||
|
||||
export type AppVolumeEditModel = z.infer<typeof appVolumeEditZodModel>;
|
||||
|
||||
Reference in New Issue
Block a user