(state, form);
+ }, [state]);
+
+ useEffect(() => {
+ form.reset(fileMount);
+ }, [fileMount]);
+
+ return (
+ <>
+ setIsOpen(true)}>
+ {children}
+
+ setIsOpen(false)}>
+
+
+ Edit File Mount
+
+ Configure your custom file mount. The content of the file mount will be available in the container at the specified mount path.
+
+
+
+
+
+
+ >
+ )
+
+
+
+}
\ No newline at end of file
diff --git a/src/app/project/app/[appId]/volumes/file-mount.tsx b/src/app/project/app/[appId]/volumes/file-mount.tsx
new file mode 100644
index 0000000..cf1697d
--- /dev/null
+++ b/src/app/project/app/[appId]/volumes/file-mount.tsx
@@ -0,0 +1,75 @@
+'use client';
+
+import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
+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 { Download, EditIcon, TrashIcon } from "lucide-react";
+import DialogEditDialog from "./storage-edit-overlay";
+import { Toast } from "@/frontend/utils/toast.utils";
+import { deleteFileMount, deleteVolume, downloadPvcData, getPvcUsage } from "./actions";
+import { useConfirmDialog } from "@/frontend/states/zustand.states";
+import { AppVolume } from "@prisma/client";
+import React from "react";
+import { KubeObjectNameUtils } from "@/server/utils/kube-object-name.utils";
+import FileMountEditDialog from "./file-mount-edit-dialog";
+
+type AppVolumeWithCapacity = (AppVolume & { capacity?: string });
+
+export default function FileMount({ app }: {
+ app: AppExtendedModel
+}) {
+
+ const { openConfirmDialog: openDialog } = useConfirmDialog();
+
+ const asyncDeleteFileMount = async (volumeId: string) => {
+ const confirm = await openDialog({
+ title: "Delete File Mount",
+ description: "The file mount will be removed and the Data will be lost. The changes will take effect, after you deploy the app. Are you sure you want to remove this file mount?",
+ okButton: "Delete File Mount",
+ });
+ if (confirm) {
+ await Toast.fromAction(() => deleteFileMount(volumeId));
+ }
+ };
+
+ return <>
+
+
+ File Mount
+ Create files wich are mounted into the container.
+
+
+
+ {app.appFileMounts.length} File Mounts
+
+
+ Mount Path
+ Action
+
+
+
+ {app.appFileMounts.map(fileMount => (
+
+ {fileMount.containerMountPath}
+
+
+
+
+ asyncDeleteFileMount(fileMount.id)}>
+
+
+
+
+ ))}
+
+
+
+
+
+ Add File Mount
+
+
+
+ >;
+}
\ No newline at end of file
diff --git a/src/app/project/app/[appId]/storage/storage-edit-overlay.tsx b/src/app/project/app/[appId]/volumes/storage-edit-overlay.tsx
similarity index 100%
rename from src/app/project/app/[appId]/storage/storage-edit-overlay.tsx
rename to src/app/project/app/[appId]/volumes/storage-edit-overlay.tsx
diff --git a/src/app/project/app/[appId]/storage/storages.tsx b/src/app/project/app/[appId]/volumes/storages.tsx
similarity index 96%
rename from src/app/project/app/[appId]/storage/storages.tsx
rename to src/app/project/app/[appId]/volumes/storages.tsx
index 9fe5be3..01cab36 100644
--- a/src/app/project/app/[appId]/storage/storages.tsx
+++ b/src/app/project/app/[appId]/volumes/storages.tsx
@@ -74,8 +74,8 @@ export default function StorageList({ app }: {
return <>
- Storage
- Add one or more volumes to your application.
+ Volumes
+ Add one or more volumes to to configure persistent storage within your container.
diff --git a/src/server/services/app.service.ts b/src/server/services/app.service.ts
index b42bda8..00b6f97 100644
--- a/src/server/services/app.service.ts
+++ b/src/server/services/app.service.ts
@@ -1,14 +1,13 @@
import { revalidateTag, unstable_cache } from "next/cache";
import dataAccess from "../adapter/db.client";
import { Tags } from "../utils/cache-tag-generator.utils";
-import { App, AppDomain, AppPort, AppVolume, Prisma } from "@prisma/client";
+import { App, AppDomain, AppFileMount, AppPort, AppVolume, Prisma } from "@prisma/client";
import { DefaultArgs } from "@prisma/client/runtime/library";
import { AppExtendedModel } from "@/shared/model/app-extended.model";
import { ServiceException } from "@/shared/model/service.exception.model";
import { KubeObjectNameUtils } from "../utils/kube-object-name.utils";
import deploymentService from "./deployment.service";
import buildService from "./build.service";
-import namespaceService from "./namespace.service";
import ingressService from "./ingress.service";
import pvcService from "./pvc.service";
import svcService from "./svc.service";
@@ -86,7 +85,8 @@ class AppService {
project: true,
appDomains: true,
appVolumes: true,
- appPorts: true
+ appPorts: true,
+ appFileMounts: true,
};
if (cached) {
return await unstable_cache(async (id: string) => await dataAccess.client.app.findFirstOrThrow({
@@ -277,6 +277,64 @@ class AppService {
}
}
+ async saveFileMount(fileMountToBeSaved: Prisma.AppFileMountUncheckedCreateInput | Prisma.AppFileMountUncheckedUpdateInput) {
+ let savedItem: AppFileMount;
+ const existingApp = await this.getExtendedById(fileMountToBeSaved.appId as string);
+ const existingAppWithSameVolumeMountPath = await dataAccess.client.appFileMount.findMany({
+ where: {
+ appId: fileMountToBeSaved.appId as string,
+ }
+ });
+
+ if (existingAppWithSameVolumeMountPath.filter(x => x.id !== fileMountToBeSaved.id)
+ .some(x => x.containerMountPath === fileMountToBeSaved.containerMountPath)) {
+ throw new ServiceException("Mount Path is already configured within the same app.");
+ }
+
+ try {
+ if (fileMountToBeSaved.id) {
+ savedItem = await dataAccess.client.appFileMount.update({
+ where: {
+ id: fileMountToBeSaved.id as string
+ },
+ data: fileMountToBeSaved
+ });
+ } else {
+ savedItem = await dataAccess.client.appFileMount.create({
+ data: fileMountToBeSaved as Prisma.AppFileMountUncheckedCreateInput
+ });
+ }
+
+ } finally {
+ revalidateTag(Tags.apps(existingApp.projectId as string));
+ revalidateTag(Tags.app(existingApp.id as string));
+ }
+ return savedItem;
+ }
+
+ async deleteFileMountById(id: string) {
+ const existingVolume = await dataAccess.client.appFileMount.findFirst({
+ where: {
+ id
+ }, include: {
+ app: true
+ }
+ });
+ if (!existingVolume) {
+ return;
+ }
+ try {
+ await dataAccess.client.appFileMount.delete({
+ where: {
+ id
+ }
+ });
+ } finally {
+ revalidateTag(Tags.app(existingVolume.appId));
+ revalidateTag(Tags.apps(existingVolume.app.projectId));
+ }
+ }
+
async savePort(portToBeSaved: Prisma.AppPortUncheckedCreateInput | Prisma.AppPortUncheckedUpdateInput) {
let savedItem: AppPort;
const existingApp = await this.getExtendedById(portToBeSaved.appId as string);
diff --git a/src/server/services/config-map.service.ts b/src/server/services/config-map.service.ts
new file mode 100644
index 0000000..cbb2bee
--- /dev/null
+++ b/src/server/services/config-map.service.ts
@@ -0,0 +1,91 @@
+import { AppExtendedModel } from "@/shared/model/app-extended.model";
+import k3s from "../adapter/kubernetes-api.adapter";
+import { KubeObjectNameUtils } from "../utils/kube-object-name.utils";
+import { Constants } from "../../shared/utils/constants";
+import { PathUtils } from "../utils/path.utils";
+import * as k8s from '@kubernetes/client-node';
+import { dlog } from "./deployment-logs.service";
+
+class ConfigMapService {
+
+ private async getConfigMapsForApp(projectId: string, appId: string) {
+ const configMaps = await k3s.core.listNamespacedConfigMap(projectId);
+
+ return configMaps.body.items.filter(cm => {
+ return cm.metadata?.annotations?.[Constants.QS_ANNOTATION_APP_ID] === appId;
+ });
+ }
+
+ async createOrUpdateConfigMapForApp(app: AppExtendedModel) {
+
+ const existingConfigMaps = await this.getConfigMapsForApp(app.projectId, app.id);
+
+ if (app.appFileMounts.length === 0) {
+ return { fileVolumeMounts: [], fileVolumes: [] };
+ }
+
+ const fileVolumeMounts: k8s.V1VolumeMount[] = [];
+ const fileVolumes: k8s.V1Volume[] = [];
+
+ for (const fileMount of app.appFileMounts) {
+ const currentConfigMapName = KubeObjectNameUtils.getConfigMapName(fileMount.id);
+
+ let { folderPath, filePath } = PathUtils.splitPath(fileMount.containerMountPath);
+ if (!folderPath) {
+ folderPath = '/';
+ }
+
+ const configMapManifest = {
+ apiVersion: 'v1',
+ kind: 'ConfigMap',
+ metadata: {
+ name: currentConfigMapName,
+ namespace: app.projectId,
+ annotations: {
+ [Constants.QS_ANNOTATION_APP_ID]: app.id,
+ [Constants.QS_ANNOTATION_PROJECT_ID]: app.projectId,
+ 'qs-app-file-mount-id': fileMount.id,
+ }
+ },
+ data: {
+ [filePath]: fileMount.content
+ },
+ };
+
+ if (existingConfigMaps.some(cm => cm.metadata!.name === currentConfigMapName)) {
+ await k3s.core.replaceNamespacedConfigMap(currentConfigMapName, app.projectId, configMapManifest);
+ } else {
+ await k3s.core.createNamespacedConfigMap(app.projectId, configMapManifest);
+ }
+
+ fileVolumeMounts.push({
+ name: currentConfigMapName,
+ mountPath: fileMount.containerMountPath,
+ subPath: filePath,
+ readOnly: true
+ });
+
+ fileVolumes.push({
+ name: currentConfigMapName,
+ configMap: {
+ name: currentConfigMapName,
+ }
+ });
+ }
+
+ return { fileVolumeMounts, fileVolumes };
+ }
+
+
+ async deleteUnusedConfigMaps(app: AppExtendedModel) {
+ const existingConfigMaps = await this.getConfigMapsForApp(app.projectId, app.id);
+ for (const cm of existingConfigMaps) {
+ if (!app.appFileMounts.some(fm => KubeObjectNameUtils.getConfigMapName(fm.id) === cm.metadata?.name)) {
+ await k3s.core.deleteNamespacedConfigMap(cm.metadata!.name!, app.projectId);
+ }
+ }
+ }
+}
+
+const configMapService = new ConfigMapService();
+export default configMapService;
diff --git a/src/server/services/deployment.service.ts b/src/server/services/deployment.service.ts
index df67688..e6c9be4 100644
--- a/src/server/services/deployment.service.ts
+++ b/src/server/services/deployment.service.ts
@@ -14,6 +14,7 @@ import svcService from "./svc.service";
import { dlog } from "./deployment-logs.service";
import registryService from "./registry.service";
import { EnvVarUtils } from "../utils/env-var.utils";
+import configMapService from "./config-map.service";
class DeploymentService {
@@ -58,6 +59,14 @@ class DeploymentService {
dlog(deploymentId, `Configured ${volumes.length} Storage Volumes.`);
}
+ const { fileVolumeMounts, fileVolumes } = await configMapService.createOrUpdateConfigMapForApp(app);
+ if (fileVolumes && fileVolumes.length > 0) {
+ dlog(deploymentId, `Configured ${fileVolumes.length} File Mounts.`);
+ }
+
+ const allVolumes = [...volumes, ...fileVolumes];
+ const allVolumeMounts = [...volumeMounts, ...fileVolumeMounts];
+
const envVars = EnvVarUtils.parseEnvVariables(app);
dlog(deploymentId, `Configured ${envVars.length} Env Variables.`);
@@ -93,10 +102,10 @@ class DeploymentService {
image: !!buildJobName ? registryService.createContainerRegistryUrlForAppId(app.id) : app.containerImageSource as string,
imagePullPolicy: 'Always',
...(envVars.length > 0 ? { env: envVars } : {}),
- ...(volumeMounts.length > 0 ? { volumeMounts: volumeMounts } : {}),
+ ...(allVolumeMounts.length > 0 ? { volumeMounts: allVolumeMounts } : {}),
}
],
- ...(volumes.length > 0 ? { volumes: volumes } : {}),
+ ...(allVolumes.length > 0 ? { volumes: allVolumes } : {}),
}
}
}
@@ -154,6 +163,7 @@ class DeploymentService {
dlog(deploymentId, `Creating deployment...`);
const res = await k3s.apps.createNamespacedDeployment(app.projectId, body);
}
+ await configMapService.deleteUnusedConfigMaps(app);
await pvcService.deleteUnusedPvcOfApp(app);
await svcService.createOrUpdateService(deploymentId, app);
dlog(deploymentId, `Updating ingress...`);
diff --git a/src/server/utils/kube-object-name.utils.ts b/src/server/utils/kube-object-name.utils.ts
index 06ce5a8..b0d1af7 100644
--- a/src/server/utils/kube-object-name.utils.ts
+++ b/src/server/utils/kube-object-name.utils.ts
@@ -56,4 +56,8 @@ export class KubeObjectNameUtils {
static getIngressName(domainId: string): `ingress-${string}` {
return `ingress-${domainId}`;
}
+
+ static getConfigMapName(id: string): `cm-${string}` {
+ return `cm-${id}`;
+ }
}
\ No newline at end of file
diff --git a/src/shared/model/app-extended.model.ts b/src/shared/model/app-extended.model.ts
index 6d8542c..9f6a49a 100644
--- a/src/shared/model/app-extended.model.ts
+++ b/src/shared/model/app-extended.model.ts
@@ -1,5 +1,5 @@
import { z } from "zod";
-import { AppDomainModel, AppModel, AppPortModel, AppVolumeModel, ProjectModel, RelatedAppModel } from "./generated-zod";
+import { AppDomainModel, AppFileMountModel, AppModel, AppPortModel, AppVolumeModel, ProjectModel, RelatedAppModel } from "./generated-zod";
export const AppExtendedZodModel= z.lazy(() => AppModel.extend({
@@ -7,6 +7,7 @@ export const AppExtendedZodModel= z.lazy(() => AppModel.extend({
appDomains: AppDomainModel.array(),
appVolumes: AppVolumeModel.array(),
appPorts: AppPortModel.array(),
+ appFileMounts: AppFileMountModel.array(),
}))
export type AppExtendedModel = z.infer;
\ No newline at end of file
diff --git a/src/shared/model/file-mount-edit.model.ts b/src/shared/model/file-mount-edit.model.ts
new file mode 100644
index 0000000..2694b9a
--- /dev/null
+++ b/src/shared/model/file-mount-edit.model.ts
@@ -0,0 +1,8 @@
+import { z } from "zod";
+
+export const fileMountEditZodModel = z.object({
+ containerMountPath: z.string().trim().min(1),
+ content: z.string().min(1),
+})
+
+export type FileMountEditModel = z.infer;
\ No newline at end of file
diff --git a/src/shared/model/generated-zod/app.ts b/src/shared/model/generated-zod/app.ts
index 67aa909..11d4b08 100644
--- a/src/shared/model/generated-zod/app.ts
+++ b/src/shared/model/generated-zod/app.ts
@@ -1,6 +1,6 @@
import * as z from "zod"
-import { CompleteProject, RelatedProjectModel, CompleteAppDomain, RelatedAppDomainModel, CompleteAppVolume, RelatedAppVolumeModel, CompleteAppPort, RelatedAppPortModel } from "./index"
+import { CompleteProject, RelatedProjectModel, CompleteAppDomain, RelatedAppDomainModel, CompleteAppPort, RelatedAppPortModel, CompleteAppVolume, RelatedAppVolumeModel, CompleteAppFileMount, RelatedAppFileMountModel } from "./index"
export const AppModel = z.object({
id: z.string(),
@@ -27,8 +27,9 @@ export const AppModel = z.object({
export interface CompleteApp extends z.infer {
project: CompleteProject
appDomains: CompleteAppDomain[]
- appVolumes: CompleteAppVolume[]
appPorts: CompleteAppPort[]
+ appVolumes: CompleteAppVolume[]
+ appFileMounts: CompleteAppFileMount[]
}
/**
@@ -39,6 +40,7 @@ export interface CompleteApp extends z.infer {
export const RelatedAppModel: z.ZodSchema = z.lazy(() => AppModel.extend({
project: RelatedProjectModel,
appDomains: RelatedAppDomainModel.array(),
- appVolumes: RelatedAppVolumeModel.array(),
appPorts: RelatedAppPortModel.array(),
+ appVolumes: RelatedAppVolumeModel.array(),
+ appFileMounts: RelatedAppFileMountModel.array(),
}))
diff --git a/src/shared/model/generated-zod/appfilemount.ts b/src/shared/model/generated-zod/appfilemount.ts
new file mode 100644
index 0000000..cb4232a
--- /dev/null
+++ b/src/shared/model/generated-zod/appfilemount.ts
@@ -0,0 +1,25 @@
+import * as z from "zod"
+
+import { CompleteApp, RelatedAppModel } from "./index"
+
+export const AppFileMountModel = z.object({
+ id: z.string(),
+ containerMountPath: z.string(),
+ content: z.string(),
+ appId: z.string(),
+ createdAt: z.date(),
+ updatedAt: z.date(),
+})
+
+export interface CompleteAppFileMount extends z.infer {
+ app: CompleteApp
+}
+
+/**
+ * RelatedAppFileMountModel contains all relations on your model in addition to the scalars
+ *
+ * NOTE: Lazy required in case of potential circular dependencies within schema
+ */
+export const RelatedAppFileMountModel: z.ZodSchema = z.lazy(() => AppFileMountModel.extend({
+ app: RelatedAppModel,
+}))
diff --git a/src/shared/model/generated-zod/index.ts b/src/shared/model/generated-zod/index.ts
index 6cb98cd..fbdccce 100644
--- a/src/shared/model/generated-zod/index.ts
+++ b/src/shared/model/generated-zod/index.ts
@@ -8,4 +8,5 @@ export * from "./app"
export * from "./appport"
export * from "./appdomain"
export * from "./appvolume"
+export * from "./appfilemount"
export * from "./parameter"
diff --git a/src/shared/templates/databases/postgres.template.ts b/src/shared/templates/databases/postgres.template.ts
index 1925f1d..2c907f8 100644
--- a/src/shared/templates/databases/postgres.template.ts
+++ b/src/shared/templates/databases/postgres.template.ts
@@ -45,7 +45,7 @@ export const postgreAppTemplate: AppTemplateModel = {
appDomains: [],
appVolumes: [{
size: 500,
- containerMountPath: '/var/lib/postgresql/data',
+ containerMountPath: '/var/lib/postgresql',
accessMode: 'ReadWriteOnce'
}],
appPorts: [{