mirror of
https://github.com/biersoeckli/QuickStack.git
synced 2026-02-10 13:39:07 -06:00
feat: add HTTP health check configuration for applications (kubernetes probes)
This commit is contained in:
42
prisma/migrations/20260106154856_migration/migration.sql
Normal file
42
prisma/migrations/20260106154856_migration/migration.sql
Normal 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;
|
||||
@@ -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[]
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
286
src/app/project/app/[appId]/advanced/health-check-settings.tsx
Normal file
286
src/app/project/app/[appId]/advanced/health-check-settings.tsx
Normal 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>"HTTP" means that the scheme used will be http://</li>
|
||||
<li>"HTTPS" 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>
|
||||
);
|
||||
}
|
||||
17
src/app/project/app/[appId]/advanced/health-check.model.ts
Normal file
17
src/app/project/app/[appId]/advanced/health-check.model.ts
Normal 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>;
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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})`);
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user