Skip to content

Commit 23b6d26

Browse files
committed
Add shareable volumes across project apps
1 parent 788c90e commit 23b6d26

11 files changed

Lines changed: 330 additions & 42 deletions

File tree

prisma/schema.prisma

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,9 +246,13 @@ model AppVolume {
246246
size Int
247247
accessMode String @default("rwo")
248248
storageClassName String @default("longhorn")
249+
shareWithOtherApps Boolean @default(false)
250+
sharedVolumeId String?
249251
appId String
250252
app App @relation(fields: [appId], references: [id], onDelete: Cascade)
251253
volumeBackups VolumeBackup[]
254+
sharedVolume AppVolume? @relation("SharedVolume", fields: [sharedVolumeId], references: [id], onDelete: Cascade)
255+
sharedVolumes AppVolume[] @relation("SharedVolume")
252256
253257
createdAt DateTime @default(now())
254258
updatedAt DateTime @updatedAt

src/app/project/app/[appId]/volumes/actions.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,39 @@ export const saveVolume = async (prevState: any, inputData: z.infer<typeof actio
4444
await isAuthorizedWriteForApp(validatedData.appId);
4545
const existingApp = await appService.getExtendedById(validatedData.appId);
4646
const existingVolume = validatedData.id ? await appService.getVolumeById(validatedData.id) : undefined;
47+
const sharedVolumeId = existingVolume?.sharedVolumeId ?? validatedData.sharedVolumeId ?? undefined;
48+
49+
if (sharedVolumeId) {
50+
const sharedVolume = await dataAccess.client.appVolume.findFirstOrThrow({
51+
where: {
52+
id: sharedVolumeId
53+
},
54+
include: {
55+
app: true
56+
}
57+
});
58+
if (sharedVolume.app.projectId !== existingApp.projectId) {
59+
throw new ServiceException('Shared volumes must belong to the same project.');
60+
}
61+
if (sharedVolume.appId === validatedData.appId) {
62+
throw new ServiceException('Shared volumes must belong to a different app.');
63+
}
64+
if (!sharedVolume.shareWithOtherApps || sharedVolume.accessMode !== 'ReadWriteMany') {
65+
throw new ServiceException('This volume is not available for sharing.');
66+
}
67+
await appService.saveVolume({
68+
appId: validatedData.appId,
69+
id: validatedData.id ?? undefined,
70+
containerMountPath: validatedData.containerMountPath,
71+
size: sharedVolume.size,
72+
accessMode: sharedVolume.accessMode,
73+
storageClassName: sharedVolume.storageClassName,
74+
shareWithOtherApps: false,
75+
sharedVolumeId: sharedVolume.id
76+
});
77+
return;
78+
}
79+
4780
if (existingVolume && existingVolume.size > validatedData.size) {
4881
throw new ServiceException('Volume size cannot be decreased');
4982
}
@@ -56,11 +89,16 @@ export const saveVolume = async (prevState: any, inputData: z.infer<typeof actio
5689
if (validatedData.accessMode === 'ReadWriteMany' && validatedData.storageClassName === 'local-path') {
5790
throw new ServiceException('The Local Path storage class does not support ReadWriteMany access mode. Please choose another storage class / access mode.');
5891
}
92+
if (validatedData.shareWithOtherApps && (existingVolume?.accessMode ?? validatedData.accessMode) !== 'ReadWriteMany') {
93+
throw new ServiceException('Only ReadWriteMany volumes can be shared with other apps.');
94+
}
5995
await appService.saveVolume({
6096
...validatedData,
6197
id: validatedData.id ?? undefined,
6298
accessMode: existingVolume?.accessMode ?? validatedData.accessMode as string,
63-
storageClassName: existingVolume?.storageClassName ?? validatedData.storageClassName
99+
storageClassName: existingVolume?.storageClassName ?? validatedData.storageClassName,
100+
shareWithOtherApps: validatedData.shareWithOtherApps ?? false,
101+
sharedVolumeId: null
64102
});
65103
});
66104

@@ -77,6 +115,14 @@ export const getPvcUsage = async (appId: string, projectId: string) =>
77115
return monitoringService.getPvcUsageFromApp(appId, projectId);
78116
}) as Promise<ServerActionResult<any, { pvcName: string, usedBytes: number }[]>>;
79117

118+
export const getShareableVolumes = async (appId: string) =>
119+
simpleAction(async () => {
120+
await isAuthorizedReadForApp(appId);
121+
const app = await appService.getExtendedById(appId);
122+
const volumes = await appService.getShareableVolumesByProjectId(app.projectId, appId);
123+
return new SuccessActionResult(volumes);
124+
}) as Promise<ServerActionResult<any, { id: string; containerMountPath: string; size: number; storageClassName: string; accessMode: string; app: { name: string } }[]>>;
125+
80126
export const downloadPvcData = async (volumeId: string) =>
81127
simpleAction(async () => {
82128
await validateVolumeReadAuthorization(volumeId);

src/app/project/app/[appId]/volumes/storage-edit-overlay.tsx

Lines changed: 161 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,19 @@ import { Check, ChevronsUpDown } from "lucide-react"
2828
import { zodResolver } from "@hookform/resolvers/zod"
2929
import { useForm } from "react-hook-form"
3030
import { useFormState } from 'react-dom'
31-
import { useEffect, useState } from "react";
31+
import { useEffect, useMemo, useState } from "react";
3232
import { FormUtils } from "@/frontend/utils/form.utilts";
3333
import { SubmitButton } from "@/components/custom/submit-button";
3434
import { AppVolume } from "@prisma/client"
3535
import { AppVolumeEditModel, appVolumeEditZodModel } from "@/shared/model/volume-edit.model"
3636
import { ServerActionResult } from "@/shared/model/server-action-error-return.model"
37-
import { saveVolume } from "./actions"
37+
import { getShareableVolumes, saveVolume } from "./actions"
3838
import { toast } from "sonner"
3939
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
4040
import { QuestionMarkCircledIcon } from "@radix-ui/react-icons"
4141
import { AppExtendedModel } from "@/shared/model/app-extended.model"
4242
import { NodeInfoModel } from "@/shared/model/node-info.model"
43+
import { Checkbox } from "@/components/ui/checkbox"
4344

4445
const accessModes = [
4546
{ label: "ReadWriteOnce", value: "ReadWriteOnce" },
@@ -51,14 +52,18 @@ const storageClasses = [
5152
{ 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." }
5253
] as const
5354

55+
type AppVolumeWithSharing = AppVolume & { sharedVolumeId?: string | null; shareWithOtherApps?: boolean };
56+
5457
export default function DialogEditDialog({ children, volume, app, nodesInfo }: {
5558
children: React.ReactNode;
56-
volume?: AppVolume;
59+
volume?: AppVolumeWithSharing;
5760
app: AppExtendedModel;
5861
nodesInfo: NodeInfoModel[];
5962
}) {
6063

6164
const [isOpen, setIsOpen] = useState<boolean>(false);
65+
const [useExistingVolume, setUseExistingVolume] = useState(false);
66+
const [shareableVolumes, setShareableVolumes] = useState<{ id: string; containerMountPath: string; size: number; storageClassName: string; accessMode: string; app: { name: string } }[]>([]);
6267

6368

6469
const form = useForm<AppVolumeEditModel>({
@@ -67,9 +72,16 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: {
6772
...volume,
6873
accessMode: volume?.accessMode ?? (app.replicas > 1 ? "ReadWriteMany" : "ReadWriteOnce"),
6974
storageClassName: (volume?.storageClassName ?? "longhorn") as 'longhorn' | 'local-path',
75+
shareWithOtherApps: volume?.shareWithOtherApps ?? false,
76+
sharedVolumeId: volume?.sharedVolumeId ?? null,
7077
}
7178
});
7279

80+
const selectedAccessMode = form.watch("accessMode");
81+
const selectedSharedVolumeId = form.watch("sharedVolumeId");
82+
const selectedSharedVolume = useMemo(() => shareableVolumes.find(item => item.id === selectedSharedVolumeId), [shareableVolumes, selectedSharedVolumeId]);
83+
const hasShareableVolumes = shareableVolumes.length > 0;
84+
7385
const [state, formAction] = useFormState((state: ServerActionResult<any, any>, payload: AppVolumeEditModel) =>
7486
saveVolume(state, {
7587
...payload,
@@ -93,9 +105,49 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: {
93105
...volume,
94106
accessMode: volume?.accessMode ?? (app.replicas > 1 ? "ReadWriteMany" : "ReadWriteOnce"),
95107
storageClassName: (volume?.storageClassName ?? "longhorn") as 'longhorn' | 'local-path',
108+
shareWithOtherApps: volume?.shareWithOtherApps ?? false,
109+
sharedVolumeId: volume?.sharedVolumeId ?? null,
96110
});
111+
setUseExistingVolume(false);
97112
}, [volume]);
98113

114+
useEffect(() => {
115+
if (!isOpen || volume) {
116+
return;
117+
}
118+
const loadShareableVolumes = async () => {
119+
const response = await getShareableVolumes(app.id);
120+
if (response.status === 'success' && response.data) {
121+
setShareableVolumes(response.data);
122+
} else {
123+
setShareableVolumes([]);
124+
}
125+
};
126+
loadShareableVolumes();
127+
}, [app.id, isOpen, volume]);
128+
129+
useEffect(() => {
130+
if (!useExistingVolume) {
131+
form.setValue("sharedVolumeId", null);
132+
return;
133+
}
134+
if (selectedSharedVolume) {
135+
form.setValue("size", selectedSharedVolume.size);
136+
form.setValue("accessMode", selectedSharedVolume.accessMode);
137+
form.setValue("storageClassName", selectedSharedVolume.storageClassName as 'longhorn' | 'local-path');
138+
form.setValue("shareWithOtherApps", false);
139+
}
140+
}, [form, selectedSharedVolume, useExistingVolume]);
141+
142+
useEffect(() => {
143+
if (!useExistingVolume || selectedSharedVolumeId) {
144+
return;
145+
}
146+
if (shareableVolumes.length > 0) {
147+
form.setValue("sharedVolumeId", shareableVolumes[0].id);
148+
}
149+
}, [form, selectedSharedVolumeId, shareableVolumes, useExistingVolume]);
150+
99151
return (
100152
<>
101153
<div onClick={() => setIsOpen(true)}>
@@ -114,6 +166,86 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: {
114166
return formAction(data);
115167
})()}>
116168
<div className="space-y-4">
169+
{!volume && (
170+
<div className="flex items-center gap-2">
171+
<Checkbox
172+
id="use-existing-volume"
173+
checked={useExistingVolume}
174+
onCheckedChange={(checked) => setUseExistingVolume(!!checked)}
175+
disabled={!hasShareableVolumes}
176+
/>
177+
<FormLabel htmlFor="use-existing-volume">Use existing shared volume</FormLabel>
178+
</div>
179+
)}
180+
{!volume && !hasShareableVolumes && (
181+
<p className="text-xs text-muted-foreground">
182+
No shared volumes are available from other apps in this project.
183+
</p>
184+
)}
185+
{!volume && useExistingVolume && (
186+
<FormField
187+
control={form.control}
188+
name="sharedVolumeId"
189+
render={({ field }) => (
190+
<FormItem className="flex flex-col">
191+
<FormLabel>Shared Volume</FormLabel>
192+
<Popover>
193+
<PopoverTrigger asChild>
194+
<FormControl>
195+
<Button
196+
variant="outline"
197+
role="combobox"
198+
className={cn(
199+
"w-full justify-between",
200+
!field.value && "text-muted-foreground"
201+
)}
202+
>
203+
{selectedSharedVolume
204+
? `${selectedSharedVolume.app.name} · ${selectedSharedVolume.containerMountPath}`
205+
: "Select a shared volume"}
206+
<ChevronsUpDown className="opacity-50" />
207+
</Button>
208+
</FormControl>
209+
</PopoverTrigger>
210+
<PopoverContent className="max-w-[320px] p-0">
211+
<Command>
212+
<CommandList>
213+
<CommandGroup>
214+
{shareableVolumes.map((shareableVolume) => (
215+
<CommandItem
216+
value={`${shareableVolume.app.name}-${shareableVolume.containerMountPath}`}
217+
key={shareableVolume.id}
218+
onSelect={() => {
219+
form.setValue("sharedVolumeId", shareableVolume.id);
220+
}}
221+
>
222+
<div className="flex flex-col gap-1">
223+
<span>{shareableVolume.app.name}</span>
224+
<span className="text-xs text-muted-foreground">{shareableVolume.containerMountPath} · {shareableVolume.size} MB</span>
225+
</div>
226+
<Check
227+
className={cn(
228+
"ml-auto",
229+
shareableVolume.id === field.value
230+
? "opacity-100"
231+
: "opacity-0"
232+
)}
233+
/>
234+
</CommandItem>
235+
))}
236+
</CommandGroup>
237+
</CommandList>
238+
</Command>
239+
</PopoverContent>
240+
</Popover>
241+
<FormDescription>
242+
Select a ReadWriteMany volume shared by another app in this project.
243+
</FormDescription>
244+
<FormMessage />
245+
</FormItem>
246+
)}
247+
/>
248+
)}
117249
<FormField
118250
control={form.control}
119251
name="containerMountPath"
@@ -135,7 +267,7 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: {
135267
<FormItem>
136268
<FormLabel>Size in MB</FormLabel>
137269
<FormControl>
138-
<Input type="number" placeholder="ex. 20" {...field} />
270+
<Input type="number" placeholder="ex. 20" {...field} disabled={useExistingVolume || !!volume?.sharedVolumeId} />
139271
</FormControl>
140272
<FormMessage />
141273
</FormItem>
@@ -145,7 +277,7 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: {
145277
<FormField
146278
control={form.control}
147279
name="accessMode"
148-
disabled={!!volume}
280+
disabled={!!volume || useExistingVolume || !!volume?.sharedVolumeId}
149281
render={({ field }) => (
150282
<FormItem className="flex flex-col">
151283
<FormLabel className="flex gap-2">
@@ -223,6 +355,29 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: {
223355
</FormItem>
224356
)}
225357
/>
358+
{!useExistingVolume && !volume?.sharedVolumeId && selectedAccessMode === 'ReadWriteMany' && (
359+
<FormField
360+
control={form.control}
361+
name="shareWithOtherApps"
362+
render={({ field }) => (
363+
<FormItem className="space-y-2">
364+
<div className="flex items-center gap-2">
365+
<FormControl>
366+
<Checkbox
367+
checked={field.value ?? false}
368+
onCheckedChange={(checked) => field.onChange(!!checked)}
369+
/>
370+
</FormControl>
371+
<FormLabel>Share with other apps in project</FormLabel>
372+
</div>
373+
<FormDescription>
374+
Allow other apps in this project to mount this volume at their own paths.
375+
</FormDescription>
376+
<FormMessage />
377+
</FormItem>
378+
)}
379+
/>
380+
)}
226381
{nodesInfo.length === 1 &&
227382
<FormField
228383
control={form.control}
@@ -256,7 +411,7 @@ export default function DialogEditDialog({ children, volume, app, nodesInfo }: {
256411
"w-full justify-between",
257412
!field.value && "text-muted-foreground"
258413
)}
259-
disabled={!!volume}
414+
disabled={!!volume || useExistingVolume || !!volume?.sharedVolumeId}
260415
>
261416
{field.value
262417
? storageClasses.find(

src/app/project/app/[appId]/volumes/storages.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ import { KubeSizeConverter } from "@/shared/utils/kubernetes-size-converter.util
2424
import { Progress } from "@/components/ui/progress";
2525
import { NodeInfoModel } from "@/shared/model/node-info.model";
2626

27-
type AppVolumeWithCapacity = (AppVolume & { usedBytes?: number; capacityBytes?: number; usedPercentage?: number });
27+
type AppVolumeWithSharing = AppVolume & { sharedVolumeId?: string | null; shareWithOtherApps?: boolean };
28+
type AppVolumeWithCapacity = (AppVolumeWithSharing & { usedBytes?: number; capacityBytes?: number; usedPercentage?: number });
2829

2930
export default function StorageList({ app, readonly, nodesInfo }: {
3031
app: AppExtendedModel;
@@ -42,7 +43,8 @@ export default function StorageList({ app, readonly, nodesInfo }: {
4243
if (response.status === 'success' && response.data) {
4344
const mappedVolumeData = [...app.appVolumes] as AppVolumeWithCapacity[];
4445
for (let item of mappedVolumeData) {
45-
const volume = response.data.find(x => x.pvcName === KubeObjectNameUtils.toPvcName(item.id));
46+
const pvcVolumeId = item.sharedVolumeId ?? item.id;
47+
const volume = response.data.find(x => x.pvcName === KubeObjectNameUtils.toPvcName(pvcVolumeId));
4648
if (volume) {
4749
item.usedBytes = volume.usedBytes;
4850
item.capacityBytes = KubeSizeConverter.fromMegabytesToBytes(item.size);

src/server/services/app.service.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,28 @@ class AppService {
267267
});
268268
}
269269

270+
async getShareableVolumesByProjectId(projectId: string, appId: string) {
271+
return await dataAccess.client.appVolume.findMany({
272+
where: {
273+
app: {
274+
projectId
275+
},
276+
appId: {
277+
not: appId
278+
},
279+
shareWithOtherApps: true,
280+
accessMode: 'ReadWriteMany',
281+
sharedVolumeId: null
282+
},
283+
include: {
284+
app: true
285+
},
286+
orderBy: {
287+
createdAt: 'desc'
288+
}
289+
});
290+
}
291+
270292
async saveVolume(volumeToBeSaved: Prisma.AppVolumeUncheckedCreateInput | Prisma.AppVolumeUncheckedUpdateInput) {
271293
let savedItem: AppVolume;
272294
const existingApp = await this.getExtendedById(volumeToBeSaved.appId as string);

0 commit comments

Comments
 (0)