mirror of
https://github.com/biersoeckli/QuickStack.git
synced 2026-02-10 13:39:07 -06:00
changes for ip propagation and configurable storage class
This commit is contained in:
committed by
biersoeckli
parent
65268a53e3
commit
873c659d29
@@ -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;
|
||||
@@ -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[]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 >
|
||||
</>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
78
src/app/settings/cluster/traefik-ip-propagation-card.tsx
Normal file
78
src/app/settings/cluster/traefik-ip-propagation-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
93
src/server/services/traefik.service.ts
Normal file
93
src/server/services/traefik.service.ts
Normal 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;
|
||||
@@ -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>;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
6
src/shared/model/traefik-ip-propagation.model.ts
Normal file
6
src/shared/model/traefik-ip-propagation.model.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type TraefikIpPropagationStatus = {
|
||||
externalTrafficPolicy?: 'Local' | 'Cluster';
|
||||
readyReplicas: number;
|
||||
replicas: number;
|
||||
restartedAt?: string | null;
|
||||
};
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user