changes for ip propagation and configurable storage class

This commit is contained in:
patrick.wissiak
2025-12-19 12:45:20 +01:00
committed by biersoeckli
parent 65268a53e3
commit 873c659d29
15 changed files with 339 additions and 15 deletions

View File

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

View File

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

View File

@@ -53,7 +53,8 @@ export const saveVolume = async (prevState: any, inputData: z.infer<typeof actio
await appService.saveVolume({
...validatedData,
id: validatedData.id ?? undefined,
accessMode: existingVolume?.accessMode ?? validatedData.accessMode as string
accessMode: existingVolume?.accessMode ?? validatedData.accessMode as string,
storageClassName: existingVolume?.storageClassName ?? validatedData.storageClassName
});
});
@@ -208,4 +209,4 @@ async function validateBackupVolumeWriteAuthorization(backupVolumeId: string) {
}
});
await isAuthorizedWriteForApp(volumeAppId?.volume.appId);
}
}

View File

@@ -45,6 +45,11 @@ const accessModes = [
{ label: "ReadWriteMany", value: "ReadWriteMany" },
] as const
const storageClasses = [
{ label: "Longhorn (HA)", value: "longhorn", description: "Distributed, replicated storage recommended for HA workloads." },
{ label: "Local Path", value: "local-path", description: "Node-local volumes, no replication. Ideal for single-node setups." }
] as const
export default function DialogEditDialog({ children, volume, app }: { children: React.ReactNode; volume?: AppVolume; app: AppExtendedModel; }) {
const [isOpen, setIsOpen] = useState<boolean>(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:
</FormItem>
)}
/>
<FormField
control={form.control}
name="storageClassName"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="flex gap-2">
<div>Storage Class</div>
<div className="self-center">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild><QuestionMarkCircledIcon /></TooltipTrigger>
<TooltipContent>
<p className="max-w-[350px]">
Choose where the volume is provisioned.<br /><br />
<b>Longhorn</b> keeps data replicated across nodes.<br />
<b>Local Path</b> stores data on a single node (no HA).
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!field.value && "text-muted-foreground"
)}
disabled={!!volume}
>
{field.value
? storageClasses.find(
(storageClass) => storageClass.value === field.value
)?.label
: "Select storage class"}
<ChevronsUpDown className="opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[260px] p-0">
<Command>
<CommandList>
<CommandGroup>
{storageClasses.map((storageClass) => (
<CommandItem
value={storageClass.label}
key={storageClass.value}
onSelect={() => {
form.setValue("storageClassName", storageClass.value);
}}
>
<div className="flex flex-col gap-1">
<span>{storageClass.label}</span>
<span className="text-xs text-muted-foreground">{storageClass.description}</span>
</div>
<Check
className={cn(
"ml-auto",
storageClass.value === field.value
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
Longhorn is recommended for HA. Local Path is faster to provision on single-node clusters.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<p className="text-red-500">{state.message}</p>
<SubmitButton>Save</SubmitButton>
</div>
@@ -219,4 +311,4 @@ export default function DialogEditDialog({ children, volume, app }: { children:
}
}

View File

@@ -150,6 +150,7 @@ export default function StorageList({ app, readonly }: {
<TableHead>Mount Path</TableHead>
<TableHead>Storage Size</TableHead>
<TableHead>Storage Used</TableHead>
<TableHead>Storage Class</TableHead>
<TableHead>Access Mode</TableHead>
<TableHead className="w-[100px]">Action</TableHead>
</TableRow>
@@ -168,6 +169,7 @@ export default function StorageList({ app, readonly }: {
</div>
</>}
</TableCell>
<TableCell className="font-medium capitalize">{volume.storageClassName?.replace('-', ' ')}</TableCell>
<TableCell className="font-medium">{volume.accessMode}</TableCell>
<TableCell className="font-medium flex gap-2">
<TooltipProvider>
@@ -247,4 +249,4 @@ export default function StorageList({ app, readonly }: {
</CardFooter>}
</Card >
</>;
}
}

View File

@@ -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<TraefikIpPropagationStatus, TraefikIpPropagationStatus>(async () => {
await getAdminUserSession();
const updatedStatus = await traefikService.applyExternalTrafficPolicy(enableIpPreservation);
return new SuccessActionResult<TraefikIpPropagationStatus>(
updatedStatus,
`Traefik externalTrafficPolicy set to ${enableIpPreservation ? 'Local' : 'Cluster'}.`,
);
});
export const getTraefikIpPropagationStatus = async () =>
simpleAction<TraefikIpPropagationStatus, TraefikIpPropagationStatus>(async () => {
await getAdminUserSession();
return traefikService.getStatus();
});

View File

@@ -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 (
<div className="flex-1 space-y-4 pt-6">
<PageTitle
@@ -27,6 +30,7 @@ export default async function ClusterInfoPage() {
{ name: "Settings", url: "/settings/profile" },
{ name: "Cluster" },
]} />
<TraefikIpPropagationCard initialStatus={traefikStatus} />
<NodeInfo nodeInfos={nodeInfo} />
</div>
)

View File

@@ -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<TraefikIpPropagationStatus>(initialStatus);
const [enabled, setEnabled] = useState<boolean>((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 (
<Card>
<CardHeader>
<CardTitle>Preserve client IP</CardTitle>
<CardDescription>
Toggle Traefik externalTrafficPolicy to <b>Local</b> to keep the original client IP on incoming requests.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Current policy:</span>
<Badge variant={currentEnabled ? "default" : "secondary"}>
{currentEnabled ? 'Local' : 'Cluster'}
</Badge>
</div>
<div className="text-sm text-muted-foreground">{readinessText}</div>
<div className="text-xs text-muted-foreground">Last restart: {lastRestart}</div>
<div className="text-xs text-muted-foreground">
Local policy keeps traffic on a single node; use Cluster if you rely on cross-node load-balancing.
</div>
</div>
<div className="flex flex-col gap-2 sm:items-end">
<div className="flex items-center gap-3">
<Switch disabled={isPending} checked={enabled} onCheckedChange={setEnabled} />
<span className="text-sm">{enabled ? 'Enable Local policy' : 'Use Cluster policy'}</span>
</div>
<Button onClick={handleApply} disabled={isPending}>
Apply
</Button>
</div>
</CardContent>
<CardFooter>
<p className="text-xs text-muted-foreground">
Local policy exposes real client IPs but may limit load-balancing flexibility.
</p>
</CardFooter>
</Card>
);
}

View File

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

View File

@@ -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;
export default secretService;

View File

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

View File

@@ -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<typeof appTemplateZodModel>;
export type AppTemplateModel = z.infer<typeof appTemplateZodModel>;

View File

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

View File

@@ -0,0 +1,6 @@
export type TraefikIpPropagationStatus = {
externalTrafficPolicy?: 'Local' | 'Cluster';
readyReplicas: number;
replicas: number;
restartedAt?: string | null;
};

View File

@@ -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<typeof appVolumeEditZodModel>;
export type AppVolumeEditModel = z.infer<typeof appVolumeEditZodModel>;