diff --git a/prisma/migrations/20251219105325_add_storage_class_to_app_volume/migration.sql b/prisma/migrations/20251219105325_add_storage_class_to_app_volume/migration.sql new file mode 100644 index 0000000..b0543aa --- /dev/null +++ b/prisma/migrations/20251219105325_add_storage_class_to_app_volume/migration.sql @@ -0,0 +1,20 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_AppVolume" ( + "id" TEXT NOT NULL PRIMARY KEY, + "containerMountPath" TEXT NOT NULL, + "size" INTEGER NOT NULL, + "accessMode" TEXT NOT NULL DEFAULT 'rwo', + "storageClassName" TEXT NOT NULL DEFAULT 'longhorn', + "appId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "AppVolume_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_AppVolume" ("accessMode", "appId", "containerMountPath", "createdAt", "id", "size", "updatedAt") SELECT "accessMode", "appId", "containerMountPath", "createdAt", "id", "size", "updatedAt" FROM "AppVolume"; +DROP TABLE "AppVolume"; +ALTER TABLE "new_AppVolume" RENAME TO "AppVolume"; +CREATE UNIQUE INDEX "AppVolume_appId_containerMountPath_key" ON "AppVolume"("appId", "containerMountPath"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f440dea..e3a1264 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -245,6 +245,7 @@ model AppVolume { containerMountPath String size Int accessMode String @default("rwo") + storageClassName String @default("longhorn") appId String app App @relation(fields: [appId], references: [id], onDelete: Cascade) volumeBackups VolumeBackup[] diff --git a/src/app/project/app/[appId]/volumes/actions.ts b/src/app/project/app/[appId]/volumes/actions.ts index fb26084..7c5dbae 100644 --- a/src/app/project/app/[appId]/volumes/actions.ts +++ b/src/app/project/app/[appId]/volumes/actions.ts @@ -53,7 +53,8 @@ export const saveVolume = async (prevState: any, inputData: z.infer(false); @@ -54,7 +59,8 @@ export default function DialogEditDialog({ children, volume, app }: { children: resolver: zodResolver(appVolumeEditZodModel), defaultValues: { ...volume, - accessMode: volume?.accessMode ?? (app.replicas > 1 ? "ReadWriteMany" : "ReadWriteOnce") + accessMode: volume?.accessMode ?? (app.replicas > 1 ? "ReadWriteMany" : "ReadWriteOnce"), + storageClassName: volume?.storageClassName ?? "longhorn" } }); @@ -77,7 +83,11 @@ export default function DialogEditDialog({ children, volume, app }: { children: }, [state]); useEffect(() => { - form.reset(volume); + form.reset({ + ...volume, + accessMode: volume?.accessMode ?? (app.replicas > 1 ? "ReadWriteMany" : "ReadWriteOnce"), + storageClassName: volume?.storageClassName ?? "longhorn" + }); }, [volume]); return ( @@ -207,6 +217,88 @@ export default function DialogEditDialog({ children, volume, app }: { children: )} /> + + ( + + +
Storage Class
+
+ + + + +

+ Choose where the volume is provisioned.

+ Longhorn keeps data replicated across nodes.
+ Local Path stores data on a single node (no HA). +

+
+
+
+
+
+ + + + + + + + + + + {storageClasses.map((storageClass) => ( + { + form.setValue("storageClassName", storageClass.value); + }} + > +
+ {storageClass.label} + {storageClass.description} +
+ +
+ ))} +
+
+
+
+
+ + Longhorn is recommended for HA. Local Path is faster to provision on single-node clusters. + + +
+ )} + />

{state.message}

Save @@ -219,4 +311,4 @@ export default function DialogEditDialog({ children, volume, app }: { children: -} \ No newline at end of file +} diff --git a/src/app/project/app/[appId]/volumes/storages.tsx b/src/app/project/app/[appId]/volumes/storages.tsx index 96de9e1..c9fe50f 100644 --- a/src/app/project/app/[appId]/volumes/storages.tsx +++ b/src/app/project/app/[appId]/volumes/storages.tsx @@ -150,6 +150,7 @@ export default function StorageList({ app, readonly }: { Mount Path Storage Size Storage Used + Storage Class Access Mode Action @@ -168,6 +169,7 @@ export default function StorageList({ app, readonly }: { } + {volume.storageClassName?.replace('-', ' ')} {volume.accessMode} @@ -247,4 +249,4 @@ export default function StorageList({ app, readonly }: { } ; -} \ No newline at end of file +} diff --git a/src/app/settings/cluster/actions.ts b/src/app/settings/cluster/actions.ts index 55ef11f..7fd5250 100644 --- a/src/app/settings/cluster/actions.ts +++ b/src/app/settings/cluster/actions.ts @@ -1,8 +1,10 @@ 'use server' -import { getAdminUserSession, getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils"; +import { getAdminUserSession, simpleAction } from "@/server/utils/action-wrapper.utils"; import { SuccessActionResult } from "@/shared/model/server-action-error-return.model"; import clusterService from "@/server/services/node.service"; +import traefikService from "@/server/services/traefik.service"; +import { TraefikIpPropagationStatus } from "@/shared/model/traefik-ip-propagation.model"; export const setNodeStatus = async (nodeName: string, schedulable: boolean) => simpleAction(async () => { @@ -10,3 +12,19 @@ export const setNodeStatus = async (nodeName: string, schedulable: boolean) => await clusterService.setNodeStatus(nodeName, schedulable); return new SuccessActionResult(undefined, 'Successfully updated node status.'); }); + +export const applyTraefikIpPropagation = async (enableIpPreservation: boolean) => + simpleAction(async () => { + await getAdminUserSession(); + const updatedStatus = await traefikService.applyExternalTrafficPolicy(enableIpPreservation); + return new SuccessActionResult( + updatedStatus, + `Traefik externalTrafficPolicy set to ${enableIpPreservation ? 'Local' : 'Cluster'}.`, + ); + }); + +export const getTraefikIpPropagationStatus = async () => + simpleAction(async () => { + await getAdminUserSession(); + return traefikService.getStatus(); + }); diff --git a/src/app/settings/cluster/page.tsx b/src/app/settings/cluster/page.tsx index d123083..15d268e 100644 --- a/src/app/settings/cluster/page.tsx +++ b/src/app/settings/cluster/page.tsx @@ -8,12 +8,15 @@ import AddClusterNodeDialog from "./add-cluster-node-dialog"; import { Button } from "@/components/ui/button"; import paramService, { ParamService } from "@/server/services/param.service"; import BreadcrumbSetter from "@/components/breadcrumbs-setter"; +import TraefikIpPropagationCard from "./traefik-ip-propagation-card"; +import traefikService from "@/server/services/traefik.service"; export default async function ClusterInfoPage() { const session = await getAdminUserSession(); const nodeInfo = await clusterService.getNodeInfo(); const clusterJoinToken = await paramService.getString(ParamService.K3S_JOIN_TOKEN); + const traefikStatus = await traefikService.getStatus(); return (
+
) diff --git a/src/app/settings/cluster/traefik-ip-propagation-card.tsx b/src/app/settings/cluster/traefik-ip-propagation-card.tsx new file mode 100644 index 0000000..90a1ad1 --- /dev/null +++ b/src/app/settings/cluster/traefik-ip-propagation-card.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { Switch } from "@/components/ui/switch"; +import { Button } from "@/components/ui/button"; +import { useState, useTransition } from "react"; +import { TraefikIpPropagationStatus } from "@/shared/model/traefik-ip-propagation.model"; +import { applyTraefikIpPropagation } from "./actions"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; + +export default function TraefikIpPropagationCard({ initialStatus }: { initialStatus: TraefikIpPropagationStatus }) { + const [status, setStatus] = useState(initialStatus); + const [enabled, setEnabled] = useState((initialStatus.externalTrafficPolicy ?? 'Cluster') === 'Local'); + const [isPending, startTransition] = useTransition(); + + const currentEnabled = (status.externalTrafficPolicy ?? 'Cluster') === 'Local'; + + const handleApply = () => { + startTransition(async () => { + const result = await applyTraefikIpPropagation(enabled); + if (result.status === 'success') { + if (result.data) { + setStatus(result.data); + setEnabled((result.data.externalTrafficPolicy ?? 'Cluster') === 'Local'); + } + toast.success('Traefik updated', { + description: result.message ?? `externalTrafficPolicy set to ${enabled ? 'Local' : 'Cluster'}.` + }); + } else { + toast.error(result.message ?? 'Failed to update Traefik externalTrafficPolicy.'); + } + }); + }; + + const readinessText = `${status.readyReplicas ?? 0}/${status.replicas ?? 0} pods ready`; + const lastRestart = status.restartedAt ? new Date(status.restartedAt).toLocaleString() : 'Not restarted yet'; + + return ( + + + Preserve client IP + + Toggle Traefik externalTrafficPolicy to Local to keep the original client IP on incoming requests. + + + +
+
+ Current policy: + + {currentEnabled ? 'Local' : 'Cluster'} + +
+
{readinessText}
+
Last restart: {lastRestart}
+
+ Local policy keeps traffic on a single node; use Cluster if you rely on cross-node load-balancing. +
+
+
+
+ + {enabled ? 'Enable Local policy' : 'Use Cluster policy'} +
+ +
+
+ +

+ Local policy exposes real client IPs but may limit load-balancing flexibility. +

+
+
+ ); +} diff --git a/src/server/services/pvc.service.ts b/src/server/services/pvc.service.ts index 0f75165..92b0ca8 100644 --- a/src/server/services/pvc.service.ts +++ b/src/server/services/pvc.service.ts @@ -115,9 +115,13 @@ class PvcService { for (const appVolume of app.appVolumes) { const pvcName = KubeObjectNameUtils.toPvcName(appVolume.id); const pvcDefinition = this.mapVolumeToPvcDefinition(app.projectId, appVolume); + const desiredStorageClassName = appVolume.storageClassName ?? 'longhorn'; const existingPvc = existingPvcs.find(pvc => pvc.metadata?.name === pvcName); if (existingPvc) { + if (existingPvc.spec?.storageClassName && existingPvc.spec.storageClassName !== desiredStorageClassName) { + console.warn(`PVC ${pvcName} storageClassName differs from requested value (${existingPvc.spec.storageClassName} vs ${desiredStorageClassName}). Storage class changes are not applied automatically.`); + } if (existingPvc.spec!.resources!.requests!.storage === KubeSizeConverter.megabytesToKubeFormat(appVolume.size)) { console.log(`PVC ${pvcName} for app ${app.id} already exists with the same size`); continue; @@ -159,6 +163,7 @@ class PvcService { } private mapVolumeToPvcDefinition(projectId: string, appVolume: AppVolume): V1PersistentVolumeClaim { + const storageClassName = appVolume.storageClassName ?? 'longhorn'; return { apiVersion: 'v1', kind: 'PersistentVolumeClaim', @@ -173,7 +178,7 @@ class PvcService { }, spec: { accessModes: [appVolume.accessMode], - storageClassName: 'longhorn', + storageClassName, resources: { requests: { storage: KubeSizeConverter.megabytesToKubeFormat(appVolume.size), diff --git a/src/server/services/secret.service.ts b/src/server/services/secret.service.ts index 81b3c01..0088efe 100644 --- a/src/server/services/secret.service.ts +++ b/src/server/services/secret.service.ts @@ -58,7 +58,7 @@ class SecretService { } } - private appNeedsNoSecret(app: { id: string; name: string; appType: string; projectId: string; sourceType: string; dockerfilePath: string; replicas: number; envVars: string; createdAt: Date; updatedAt: Date; project: { id: string; name: string; createdAt: Date; updatedAt: Date; }; appDomains: { id: string; createdAt: Date; updatedAt: Date; hostname: string; port: number; useSsl: boolean; redirectHttps: boolean; appId: string; }[]; appVolumes: { id: string; createdAt: Date; updatedAt: Date; appId: string; containerMountPath: string; size: number; accessMode: string; }[]; appPorts: { id: string; createdAt: Date; updatedAt: Date; port: number; appId: string; }[]; appFileMounts: { id: string; createdAt: Date; updatedAt: Date; appId: string; containerMountPath: string; content: string; }[]; containerImageSource?: string | null | undefined; containerRegistryUsername?: string | null | undefined; containerRegistryPassword?: string | null | undefined; gitUrl?: string | null | undefined; gitBranch?: string | null | undefined; gitUsername?: string | null | undefined; gitToken?: string | null | undefined; memoryReservation?: number | null | undefined; memoryLimit?: number | null | undefined; cpuReservation?: number | null | undefined; cpuLimit?: number | null | undefined; }) { + private appNeedsNoSecret(app: { id: string; name: string; appType: string; projectId: string; sourceType: string; dockerfilePath: string; replicas: number; envVars: string; createdAt: Date; updatedAt: Date; project: { id: string; name: string; createdAt: Date; updatedAt: Date; }; appDomains: { id: string; createdAt: Date; updatedAt: Date; hostname: string; port: number; useSsl: boolean; redirectHttps: boolean; appId: string; }[]; appVolumes: { id: string; createdAt: Date; updatedAt: Date; appId: string; containerMountPath: string; size: number; accessMode: string; storageClassName: string; }[]; appPorts: { id: string; createdAt: Date; updatedAt: Date; port: number; appId: string; }[]; appFileMounts: { id: string; createdAt: Date; updatedAt: Date; appId: string; containerMountPath: string; content: string; }[]; containerImageSource?: string | null | undefined; containerRegistryUsername?: string | null | undefined; containerRegistryPassword?: string | null | undefined; gitUrl?: string | null | undefined; gitBranch?: string | null | undefined; gitUsername?: string | null | undefined; gitToken?: string | null | undefined; memoryReservation?: number | null | undefined; memoryLimit?: number | null | undefined; cpuReservation?: number | null | undefined; cpuLimit?: number | null | undefined; }) { return app.sourceType === 'GIT' || !app.containerImageSource || !app.containerRegistryUsername || !app.containerRegistryPassword; } @@ -81,4 +81,4 @@ class SecretService { } const secretService = new SecretService(); -export default secretService; \ No newline at end of file +export default secretService; diff --git a/src/server/services/traefik.service.ts b/src/server/services/traefik.service.ts new file mode 100644 index 0000000..2c7b431 --- /dev/null +++ b/src/server/services/traefik.service.ts @@ -0,0 +1,93 @@ +import { TraefikIpPropagationStatus } from "@/shared/model/traefik-ip-propagation.model"; +import { ServiceException } from "@/shared/model/service.exception.model"; +import k3s from "../adapter/kubernetes-api.adapter"; + +class TraefikService { + private readonly TRAEFIK_NAMESPACE = 'kube-system'; + private readonly TRAEFIK_NAME = 'traefik'; + + async getStatus(): Promise { + const [serviceRes, deploymentRes] = await Promise.all([ + k3s.core.readNamespacedService(this.TRAEFIK_NAME, this.TRAEFIK_NAMESPACE), + k3s.apps.readNamespacedDeployment(this.TRAEFIK_NAME, this.TRAEFIK_NAMESPACE), + ]); + + const deployment = deploymentRes.body; + const restartedAt = deployment.spec?.template?.metadata?.annotations?.['kubectl.kubernetes.io/restartedAt']; + + return { + externalTrafficPolicy: serviceRes.body.spec?.externalTrafficPolicy as TraefikIpPropagationStatus['externalTrafficPolicy'], + readyReplicas: deployment.status?.readyReplicas ?? 0, + replicas: deployment.status?.replicas ?? deployment.spec?.replicas ?? 0, + restartedAt, + }; + } + + async applyExternalTrafficPolicy(useLocal: boolean): Promise { + await this.patchServicePolicy(useLocal ? 'Local' : 'Cluster'); + await this.restartDeployment(); + await this.waitUntilDeploymentReady(); + return this.getStatus(); + } + + private async patchServicePolicy(policy: 'Local' | 'Cluster') { + await k3s.core.patchNamespacedService( + this.TRAEFIK_NAME, + this.TRAEFIK_NAMESPACE, + { spec: { externalTrafficPolicy: policy } }, + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/merge-patch+json' } }, + ); + } + + private async restartDeployment() { + const now = new Date().toISOString(); + await k3s.apps.patchNamespacedDeployment( + this.TRAEFIK_NAME, + this.TRAEFIK_NAMESPACE, + { + spec: { + template: { + metadata: { + annotations: { + 'kubectl.kubernetes.io/restartedAt': now, + }, + }, + }, + }, + }, + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/merge-patch+json' } }, + ); + } + + private async waitUntilDeploymentReady(timeoutMs = 120000) { + const pollIntervalMs = 3000; + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const deployment = await k3s.apps.readNamespacedDeployment(this.TRAEFIK_NAME, this.TRAEFIK_NAMESPACE); + const desiredReplicas = deployment.body.status?.replicas ?? deployment.body.spec?.replicas ?? 0; + const readyReplicas = deployment.body.status?.readyReplicas ?? 0; + + if (desiredReplicas === 0 || readyReplicas >= desiredReplicas) { + return; + } + + await new Promise(resolve => setTimeout(resolve, pollIntervalMs)); + } + + throw new ServiceException('Timeout while waiting for Traefik pods to become ready after restart.'); + } +} + +const traefikService = new TraefikService(); +export default traefikService; diff --git a/src/shared/model/app-template.model.ts b/src/shared/model/app-template.model.ts index 9a926a1..63cda26 100644 --- a/src/shared/model/app-template.model.ts +++ b/src/shared/model/app-template.model.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { AppDomainModel, AppFileMountModel, AppModel, AppPortModel, AppVolumeModel, RelatedAppDomainModel, RelatedAppPortModel, RelatedAppVolumeModel } from "./generated-zod"; import { appSourceTypeZodModel, appTypeZodModel } from "./app-source-info.model"; -import { appVolumeTypeZodModel } from "./volume-edit.model"; +import { appVolumeTypeZodModel, storageClassNameZodModel } from "./volume-edit.model"; const appModelWithRelations = z.lazy(() => AppModel.extend({ projectId: z.undefined(), @@ -28,6 +28,7 @@ export const appTemplateContentZodModel = z.object({ appDomains: AppDomainModel.array(), appVolumes: AppVolumeModel.extend({ accessMode: appVolumeTypeZodModel, + storageClassName: storageClassNameZodModel.default('longhorn'), id: z.undefined(), appId: z.undefined(), createdAt: z.undefined(), @@ -54,4 +55,4 @@ export const appTemplateZodModel = z.object({ templates: appTemplateContentZodModel.array(), }); -export type AppTemplateModel = z.infer; \ No newline at end of file +export type AppTemplateModel = z.infer; diff --git a/src/shared/model/generated-zod/appvolume.ts b/src/shared/model/generated-zod/appvolume.ts index 42e5d5c..f85522a 100644 --- a/src/shared/model/generated-zod/appvolume.ts +++ b/src/shared/model/generated-zod/appvolume.ts @@ -7,6 +7,7 @@ export const AppVolumeModel = z.object({ containerMountPath: z.string(), size: z.number().int(), accessMode: z.string(), + storageClassName: z.string(), appId: z.string(), createdAt: z.date(), updatedAt: z.date(), diff --git a/src/shared/model/traefik-ip-propagation.model.ts b/src/shared/model/traefik-ip-propagation.model.ts new file mode 100644 index 0000000..58f1b8c --- /dev/null +++ b/src/shared/model/traefik-ip-propagation.model.ts @@ -0,0 +1,6 @@ +export type TraefikIpPropagationStatus = { + externalTrafficPolicy?: 'Local' | 'Cluster'; + readyReplicas: number; + replicas: number; + restartedAt?: string | null; +}; diff --git a/src/shared/model/volume-edit.model.ts b/src/shared/model/volume-edit.model.ts index 7245477..0c23dc3 100644 --- a/src/shared/model/volume-edit.model.ts +++ b/src/shared/model/volume-edit.model.ts @@ -2,11 +2,13 @@ import { stringToNumber } from "@/shared/utils/zod.utils"; import { z } from "zod"; export const appVolumeTypeZodModel = z.enum(["ReadWriteOnce", "ReadWriteMany"]); +export const storageClassNameZodModel = z.enum(["longhorn", "local-path"]); export const appVolumeEditZodModel = z.object({ containerMountPath: z.string().trim().min(1), size: stringToNumber, - accessMode: appVolumeTypeZodModel.nullish().or(z.string().nullish()), -}) + accessMode: appVolumeTypeZodModel.nullish().or(z.string().nullish()), + storageClassName: storageClassNameZodModel.default("longhorn"), +}); -export type AppVolumeEditModel = z.infer; \ No newline at end of file +export type AppVolumeEditModel = z.infer;