Add shareable volumes across project apps

This commit is contained in:
biersoeckli
2026-01-29 14:54:58 +01:00
parent 90ba950a4d
commit 5054f4b525
11 changed files with 330 additions and 42 deletions

View File

@@ -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

View File

@@ -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);

View File

@@ -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(

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;

View File

@@ -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 });
}

View File

@@ -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();

View File

@@ -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;

View File

@@ -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(),

View File

@@ -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>;