feat: add HTTP health check configuration for applications (kubernetes probes)

This commit is contained in:
biersoeckli
2026-01-06 16:22:25 +00:00
parent 5ea3368b1b
commit f3833a9fb0
8 changed files with 423 additions and 1 deletions

View File

@@ -0,0 +1,42 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_App" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"appType" TEXT NOT NULL DEFAULT 'APP',
"projectId" TEXT NOT NULL,
"sourceType" TEXT NOT NULL DEFAULT 'GIT',
"containerImageSource" TEXT,
"containerRegistryUsername" TEXT,
"containerRegistryPassword" TEXT,
"gitUrl" TEXT,
"gitBranch" TEXT,
"gitUsername" TEXT,
"gitToken" TEXT,
"dockerfilePath" TEXT NOT NULL DEFAULT './Dockerfile',
"replicas" INTEGER NOT NULL DEFAULT 1,
"envVars" TEXT NOT NULL DEFAULT '',
"memoryReservation" INTEGER,
"memoryLimit" INTEGER,
"cpuReservation" INTEGER,
"cpuLimit" INTEGER,
"webhookId" TEXT,
"ingressNetworkPolicy" TEXT NOT NULL DEFAULT 'ALLOW_ALL',
"egressNetworkPolicy" TEXT NOT NULL DEFAULT 'ALLOW_ALL',
"useNetworkPolicy" BOOLEAN NOT NULL DEFAULT true,
"healthChechHttpGetPath" TEXT,
"healthCheckHttpScheme" TEXT,
"healthCheckHttpHeadersJson" TEXT,
"healthCheckHttpPort" INTEGER,
"healthCheckPeriodSeconds" INTEGER NOT NULL DEFAULT 10,
"healthCheckTimeoutSeconds" INTEGER NOT NULL DEFAULT 5,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "App_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_App" ("appType", "containerImageSource", "containerRegistryPassword", "containerRegistryUsername", "cpuLimit", "cpuReservation", "createdAt", "dockerfilePath", "egressNetworkPolicy", "envVars", "gitBranch", "gitToken", "gitUrl", "gitUsername", "id", "ingressNetworkPolicy", "memoryLimit", "memoryReservation", "name", "projectId", "replicas", "sourceType", "updatedAt", "useNetworkPolicy", "webhookId") SELECT "appType", "containerImageSource", "containerRegistryPassword", "containerRegistryUsername", "cpuLimit", "cpuReservation", "createdAt", "dockerfilePath", "egressNetworkPolicy", "envVars", "gitBranch", "gitToken", "gitUrl", "gitUsername", "id", "ingressNetworkPolicy", "memoryLimit", "memoryReservation", "name", "projectId", "replicas", "sourceType", "updatedAt", "useNetworkPolicy", "webhookId" FROM "App";
DROP TABLE "App";
ALTER TABLE "new_App" RENAME TO "App";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -204,6 +204,14 @@ model App {
egressNetworkPolicy String @default("ALLOW_ALL") // ALLOW_ALL, NAMESPACE_ONLY, DENY_ALL, INTERNET_ONLY
useNetworkPolicy Boolean @default(true)
// healthCheck startupProbe, readinessProbe, livenessProbe
healthChechHttpGetPath String?
healthCheckHttpScheme String? // HTTP, HTTPS
healthCheckHttpHeadersJson String? // JSON stringified key-value pairs
healthCheckHttpPort Int?
healthCheckPeriodSeconds Int @default(10)
healthCheckTimeoutSeconds Int @default(5)
appDomains AppDomain[]
appPorts AppPort[]
appVolumes AppVolume[]

View File

@@ -5,6 +5,7 @@ import appService from "@/server/services/app.service";
import { isAuthorizedWriteForApp, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
import { BasicAuthEditModel, basicAuthEditZodModel } from "@/shared/model/basic-auth-edit.model";
import { appNetworkPolicy } from "@/shared/model/network-policy.model";
import { HealthCheckModel, healthCheckZodModel } from "./health-check.model";
export const saveBasicAuth = async (prevState: any, inputData: BasicAuthEditModel) =>
@@ -43,3 +44,43 @@ export const saveNetworkPolicy = async (appId: string, ingressPolicy: string, eg
});
return new SuccessActionResult(undefined, 'Network policy saved');
});
export const saveHealthCheck = async (prevState: any, inputData: HealthCheckModel) =>
saveFormAction(inputData, healthCheckZodModel, async (validatedData) => {
await isAuthorizedWriteForApp(validatedData.appId);
const app = await appService.getById(validatedData.appId);
// Prepare update data
let updateData: Partial<typeof app> = {
healthCheckPeriodSeconds: validatedData.periodSeconds ?? 10,
healthCheckTimeoutSeconds: validatedData.timeoutSeconds ?? 5,
};
if (validatedData.enabled) {
updateData = {
...updateData,
healthChechHttpGetPath: validatedData.path || null,
healthCheckHttpPort: validatedData.port || null,
healthCheckHttpScheme: validatedData.scheme || null,
healthCheckHttpHeadersJson: validatedData.headers && validatedData.headers.length > 0
? JSON.stringify(validatedData.headers)
: null
};
} else {
updateData = {
...updateData,
healthChechHttpGetPath: null,
healthCheckHttpPort: null,
healthCheckHttpScheme: null,
healthCheckHttpHeadersJson: null
};
}
await appService.save({
...app,
...updateData
});
return new SuccessActionResult(undefined, 'Health check settings saved');
});

View File

@@ -0,0 +1,286 @@
'use client'
import { AppExtendedModel } from "@/shared/model/app-extended.model";
import { useForm, useFieldArray } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card";
import { Form, FormField, FormItem, FormControl, FormMessage, FormLabel } from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Trash, Plus } from "lucide-react";
import FormLabelWithQuestion from "@/components/custom/form-label-with-question";
import { useFormState } from "react-dom";
import { saveHealthCheck } from "./actions";
import { useEffect } from "react";
import { toast } from "sonner";
import { SubmitButton } from "@/components/custom/submit-button";
import { FormUtils } from "@/frontend/utils/form.utilts";
import { HealthCheckModel, healthCheckZodModel } from "./health-check.model";
import { ServerActionResult } from "@/shared/model/server-action-error-return.model";
export default function HealthCheckSettings({ app, readonly }: { app: AppExtendedModel, readonly: boolean }) {
const defaultHeaders = app.healthCheckHttpHeadersJson
? JSON.parse(app.healthCheckHttpHeadersJson)
: [];
const isEnabled = !!(app.healthChechHttpGetPath);
const defaultValues: HealthCheckModel = {
appId: app.id,
enabled: isEnabled,
path: app.healthChechHttpGetPath || undefined,
port: app.healthCheckHttpPort || undefined,
scheme: (app.healthCheckHttpScheme as "HTTP" | "HTTPS") || "HTTP",
periodSeconds: app.healthCheckPeriodSeconds ?? 15,
timeoutSeconds: app.healthCheckTimeoutSeconds ?? 5,
headers: defaultHeaders
};
const form = useForm<HealthCheckModel>({
resolver: zodResolver(healthCheckZodModel),
defaultValues,
disabled: readonly,
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "headers"
});
const enabled = form.watch("enabled");
const [state, formAction] = useFormState(
(state: ServerActionResult<any, any>, payload: HealthCheckModel) => saveHealthCheck(state, payload),
FormUtils.getInitialFormState<typeof healthCheckZodModel>()
);
useEffect(() => {
if (state.status === 'success') {
toast.success('Health Check Settings Saved');
}
FormUtils.mapValidationErrorsToForm<typeof healthCheckZodModel>(state, form);
}, [state]);
return (
<Card>
<CardHeader>
<CardTitle>Health Check Settings</CardTitle>
<CardDescription>
Configure healthchecks so that k3s can automatically monitor when your application is fully started up and ready to receive traffic (In kubernetes terms, startup, readiness and liveness probes).
</CardDescription>
</CardHeader>
<Form {...form}>
<form action={(e) => form.handleSubmit((data) => {
formAction(data);
})()}>
<CardContent className="space-y-6">
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Enable Health Check</FormLabel>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={readonly}
/>
</FormControl>
</FormItem>
)}
/>
{enabled && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabelWithQuestion hint="HTTP path to access on the container.">
HTTP Path
</FormLabelWithQuestion>
<FormControl>
<Input placeholder="/healthz" {...field} value={field.value || ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"
render={({ field }) => (
<FormItem>
<FormLabelWithQuestion hint="Name or number of the port to access on the container. Number must be in the range 1 to 65535.">
HTTP Port
</FormLabelWithQuestion>
<FormControl>
<Input type="number" placeholder="80" {...field} value={field.value || ''} onChange={e => field.onChange(e.target.value)} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="scheme"
render={({ field }) => (
<FormItem>
<FormLabelWithQuestion hint={
<div>
<p>Scheme to use for connecting to the container. Defaults to HTTP.</p>
<p>Possible enum values:</p>
<ul className="list-disc pl-4">
<li>&quot;HTTP&quot; means that the scheme used will be http://</li>
<li>&quot;HTTPS&quot; means that the scheme used will be https://</li>
</ul>
</div>
}>
HTTP Scheme
</FormLabelWithQuestion>
<Select onValueChange={field.onChange} defaultValue={field.value} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select scheme" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="HTTP">HTTP</SelectItem>
<SelectItem value="HTTPS">HTTPS</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<div>
<FormLabelWithQuestion hint={
<div>
<p>Custom headers to set in the request. HTTP allows repeated headers.</p>
<p>HTTPHeader describes a custom header to be used in HTTP probes</p>
</div>
}>
HTTP Headers
</FormLabelWithQuestion>
<div className="space-y-2 mt-2">
{fields.map((item, index) => (
<div key={item.id} className="flex gap-2 items-start">
<FormField
control={form.control}
name={`headers.${index}.name`}
render={({ field }) => (
<FormItem className="flex-1">
{index === 0 && <div className="flex items-center gap-1 mb-1">
<FormLabel className="text-xs text-muted-foreground">Name</FormLabel>
<FormLabelWithQuestion hint="The header field name. This will be canonicalized upon output, so case-variant names will be understood as the same header.">
{''}
</FormLabelWithQuestion>
</div>}
<FormControl>
<Input placeholder="X-Custom-Header" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`headers.${index}.value`}
render={({ field }) => (
<FormItem className="flex-1">
{index === 0 && <div className="flex items-center gap-1 mb-1">
<FormLabel className="text-xs text-muted-foreground">Value</FormLabel>
<FormLabelWithQuestion hint="The header field value">
{''}
</FormLabelWithQuestion>
</div>}
<FormControl>
<Input placeholder="value" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
size="icon"
disabled={readonly}
onClick={() => remove(index)}
className={index === 0 ? 'mt-7' : ''}
>
<Trash className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
disabled={readonly}
onClick={() => append({ name: '', value: '' })}
>
<Plus className="mr-2 h-4 w-4" />
Add Header
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="periodSeconds"
render={({ field }) => (
<FormItem>
<FormLabelWithQuestion hint="How often (in seconds) to perform the healthcheck. For example eventy 10 seconds. Minimum value is 1.">
Check Interval (periodSeconds)
</FormLabelWithQuestion>
<FormControl>
<Input type="number" {...field} onChange={e => field.onChange(e.target.value)} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="timeoutSeconds"
render={({ field }) => (
<FormItem>
<FormLabelWithQuestion hint={
<div>
<p>Number of seconds to wait for a HTTP request to complete before timing out. Minimum value is 1.</p>
</div>
}>
Check Timeout (timeoutSeconds)
</FormLabelWithQuestion>
<FormControl>
<Input type="number" {...field} onChange={e => field.onChange(e.target.value)} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</>
)}
</CardContent>
<CardFooter>
<SubmitButton>Save</SubmitButton>
</CardFooter>
</form>
</Form>
</Card>
);
}

View File

@@ -0,0 +1,17 @@
import { z } from "zod";
export const healthCheckZodModel = z.object({
appId: z.string(),
enabled: z.boolean(),
path: z.string().optional(), // If enabled is true, this might be required in reality, but we'll let user save empty
port: z.coerce.number().int().min(1).max(65535).optional(),
scheme: z.enum(["HTTP", "HTTPS"]).optional(),
periodSeconds: z.coerce.number().int().min(1).default(10),
timeoutSeconds: z.coerce.number().int().min(1).default(5),
headers: z.array(z.object({
name: z.string().min(1, "Name is required"),
value: z.string().min(1, "Value is required")
})).optional()
});
export type HealthCheckModel = z.infer<typeof healthCheckZodModel>;

View File

@@ -20,6 +20,7 @@ import VolumeBackupList from "./volumes/volume-backup";
import { VolumeBackupExtendedModel } from "@/shared/model/volume-backup-extended.model";
import BasicAuth from "./advanced/basic-auth";
import NetworkPolicy from "./advanced/network-policy";
import HealthCheckSettings from "./advanced/health-check-settings";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import DbToolsCard from "./credentials/db-tools";
import { RolePermissionEnum } from "@/shared/model/role-extended.model.ts";
@@ -94,6 +95,7 @@ export default function AppTabs({
<TabsContent value="advanced" className="space-y-4">
<BasicAuth readonly={readonly} app={app} />
<NetworkPolicy readonly={readonly} app={app} />
<HealthCheckSettings readonly={readonly} app={app} />
</TabsContent>
</Tabs>
)

View File

@@ -1,6 +1,6 @@
import { AppExtendedModel } from "@/shared/model/app-extended.model";
import k3s from "../adapter/kubernetes-api.adapter";
import { V1Deployment, V1ReplicaSet } from "@kubernetes/client-node";
import { V1Deployment, V1ReplicaSet, V1Probe } from "@kubernetes/client-node";
import buildService from "./build.service";
import { ListUtils } from "../../shared/utils/list.utils";
import { DeploymentInfoModel, DeploymentStatus } from "@/shared/model/deployment-info.model";
@@ -182,6 +182,26 @@ class DeploymentService {
}
}
if (app.healthChechHttpGetPath) {
const probe: V1Probe = {
httpGet: {
path: app.healthChechHttpGetPath,
port: app.healthCheckHttpPort ?? 80,
scheme: app.healthCheckHttpScheme ?? undefined,
...(app.healthCheckHttpHeadersJson ? { httpHeaders: JSON.parse(app.healthCheckHttpHeadersJson) } : {})
},
periodSeconds: app.healthCheckPeriodSeconds,
timeoutSeconds: app.healthCheckTimeoutSeconds
};
// waits until pod is started and before that the other probes are not startet
body.spec!.template!.spec!.containers[0].startupProbe = { ...probe, failureThreshold: 20 }; // allow failures before marking pod as failed --> back off
// checks if traffic can be routed to this pod or not
body.spec!.template!.spec!.containers[0].readinessProbe = { ...probe };
// checks if pod is still alive and if not restarts it
body.spec!.template!.spec!.containers[0].livenessProbe = { ...probe };
dlog(deploymentId, `Configured Health Checks.`);
}
const dockerPullSecretName = await secretService.createOrUpdateDockerPullSecret(app);
if (dockerPullSecretName) {
dlog(deploymentId, `Configured credentials to pull Docker Image (${dockerPullSecretName})`);

View File

@@ -26,6 +26,12 @@ export const AppModel = z.object({
ingressNetworkPolicy: z.string(),
egressNetworkPolicy: z.string(),
useNetworkPolicy: z.boolean(),
healthChechHttpGetPath: z.string().nullish(),
healthCheckHttpScheme: z.string().nullish(),
healthCheckHttpHeadersJson: z.string().nullish(),
healthCheckHttpPort: z.number().int().nullish(),
healthCheckPeriodSeconds: z.number().int(),
healthCheckTimeoutSeconds: z.number().int(),
createdAt: z.date(),
updatedAt: z.date(),
})