diff --git a/prisma/migrations/20260307152228_migration/migration.sql b/prisma/migrations/20260307152228_migration/migration.sql new file mode 100644 index 0000000..49c9719 --- /dev/null +++ b/prisma/migrations/20260307152228_migration/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "App" ADD COLUMN "securityContextFsGroup" INTEGER; +ALTER TABLE "App" ADD COLUMN "securityContextRunAsGroup" INTEGER; +ALTER TABLE "App" ADD COLUMN "securityContextRunAsUser" INTEGER; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a25484c..73e7bb6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -185,6 +185,9 @@ model App { containerRegistryPassword String? containerCommand String? // Custom command to override container ENTRYPOINT containerArgs String? // Custom args to override container CMD (JSON array string) + securityContextRunAsUser Int? + securityContextRunAsGroup Int? + securityContextFsGroup Int? gitUrl String? gitBranch String? diff --git a/src/app/project/app/[appId]/app-breadcrumbs.tsx b/src/app/project/app/[appId]/app-breadcrumbs.tsx index 8e66fcf..05c615a 100644 --- a/src/app/project/app/[appId]/app-breadcrumbs.tsx +++ b/src/app/project/app/[appId]/app-breadcrumbs.tsx @@ -6,12 +6,19 @@ import { useBreadcrumbs } from "@/frontend/states/zustand.states"; import { useEffect } from "react"; import { AppExtendedModel } from "@/shared/model/app-extended.model"; -export default function AppBreadcrumbs({ app }: { app: AppExtendedModel }) { +export default function AppBreadcrumbs({ app, apps, tabName }: { app: AppExtendedModel; apps: { id: string; name: string }[]; tabName?: string }) { const { setBreadcrumbs } = useBreadcrumbs(); useEffect(() => setBreadcrumbs([ { name: "Projects", url: "/" }, { name: app.project.name, url: "/project/" + app.projectId }, - { name: app.name }, + { + name: app.name, + dropdownItems: apps.map(a => ({ + name: a.name, + url: `/project/app/${a.id}${tabName ? `?tabName=${tabName}` : ''}`, + active: a.id === app.id, + })), + }, ]), []); return <>; } \ No newline at end of file diff --git a/src/app/project/app/[appId]/general/actions.ts b/src/app/project/app/[appId]/general/actions.ts index 9b69a70..e1f2260 100644 --- a/src/app/project/app/[appId]/general/actions.ts +++ b/src/app/project/app/[appId]/general/actions.ts @@ -72,6 +72,9 @@ export const saveGeneralAppContainerConfig = async (prevState: any, inputData: A ...existingApp, containerCommand: validatedData.containerCommand?.trim() || null, containerArgs: containerArgsJson, + securityContextRunAsUser: validatedData.securityContextRunAsUser ?? null, + securityContextRunAsGroup: validatedData.securityContextRunAsGroup ?? null, + securityContextFsGroup: validatedData.securityContextFsGroup ?? null, id: appId, }); }); diff --git a/src/app/project/app/[appId]/general/app-container-config.tsx b/src/app/project/app/[appId]/general/app-container-config.tsx index caef069..a9e4428 100644 --- a/src/app/project/app/[appId]/general/app-container-config.tsx +++ b/src/app/project/app/[appId]/general/app-container-config.tsx @@ -16,14 +16,8 @@ import { toast } from "sonner"; import { AppExtendedModel } from "@/shared/model/app-extended.model"; import { Trash2, Plus } from "lucide-react"; import { z } from "zod"; - -// Zod schema for the container config form -const appContainerConfigZodModel = z.object({ - containerCommand: z.string().trim().nullish(), - containerArgs: z.array(z.object({ - value: z.string().trim() - })).optional(), -}); +import { appContainerConfigZodModel } from "@/shared/model/app-container-config.model"; +import FormLabelWithQuestion from "@/components/custom/form-label-with-question"; export type AppContainerConfigInputModel = z.infer; @@ -41,6 +35,9 @@ export default function GeneralAppContainerConfig({ app, readonly }: { defaultValues: { containerCommand: app.containerCommand || '', containerArgs: initialArgs, + securityContextRunAsUser: app.securityContextRunAsUser ?? undefined, + securityContextRunAsGroup: app.securityContextRunAsGroup ?? undefined, + securityContextFsGroup: app.securityContextFsGroup ?? undefined, }, disabled: readonly, }); @@ -150,6 +147,80 @@ export default function GeneralAppContainerConfig({ app, readonly }: { )} + +
+
+

Security Context (optional)

+

+ Use this when your app requires specific user/group permissions or needs to run with a specific filesystem group for volume access. +

+
+
+ ( + + + Run As User + + + field.onChange(e.target.value === '' ? null : Number(e.target.value))} + /> + + + + )} + /> + ( + + + Run As Group + + + field.onChange(e.target.value === '' ? null : Number(e.target.value))} + /> + + + + )} + /> + ( + + + FS Group + + + field.onChange(e.target.value === '' ? null : Number(e.target.value))} + /> + + + + )} + /> +
+
{!readonly && ( diff --git a/src/app/project/app/[appId]/page.tsx b/src/app/project/app/[appId]/page.tsx index b05fc57..58a86b4 100644 --- a/src/app/project/app/[appId]/page.tsx +++ b/src/app/project/app/[appId]/page.tsx @@ -20,11 +20,12 @@ export default async function AppPage({ } const session = await isAuthorizedReadForApp(appId); const role = UserGroupUtils.getRolePermissionForApp(session, appId); - const [app, s3Targets, volumeBackups, nodesInfo] = await Promise.all([ - appService.getExtendedById(appId), + const app = await appService.getExtendedById(appId); + const [s3Targets, volumeBackups, nodesInfo, apps] = await Promise.all([ s3TargetService.getAll(), volumeBackupService.getForApp(appId), - clusterService.getNodeInfo() + clusterService.getNodeInfo(), + appService.getAllAppsByProjectID(app.projectId), ]); return (<> @@ -35,7 +36,7 @@ export default async function AppPage({ app={app} nodesInfo={nodesInfo} tabName={searchParams?.tabName ?? 'overview'} /> - + ) } diff --git a/src/components/custom/breadcrumbs-generator.tsx b/src/components/custom/breadcrumbs-generator.tsx index d024932..9798015 100644 --- a/src/components/custom/breadcrumbs-generator.tsx +++ b/src/components/custom/breadcrumbs-generator.tsx @@ -39,7 +39,23 @@ export function BreadcrumbsGenerator() { {breadcrumbs.map((x, index) => (<> {index > 0 && } - {x.name} + {x.dropdownItems ? ( + + + {x.name} + + + + {x.dropdownItems.map((item) => ( + + {item.active ? {item.name} : {item.name}} + + ))} + + + ) : ( + {x.name} + )} ))} diff --git a/src/frontend/states/zustand.states.ts b/src/frontend/states/zustand.states.ts index 5dcf351..355db4e 100644 --- a/src/frontend/states/zustand.states.ts +++ b/src/frontend/states/zustand.states.ts @@ -46,9 +46,16 @@ interface ZustandBreadcrumbsProps { setBreadcrumbs: ((result: Breadcrumb[]) => void); } +export interface BreadcrumbDropdownItem { + name: string; + url: string; + active?: boolean; +} + export interface Breadcrumb { name: string; url?: string; + dropdownItems?: BreadcrumbDropdownItem[]; } export const useBreadcrumbs = create((set) => ({ diff --git a/src/server/services/deployment.service.ts b/src/server/services/deployment.service.ts index 9230efd..45b1501 100644 --- a/src/server/services/deployment.service.ts +++ b/src/server/services/deployment.service.ts @@ -251,6 +251,15 @@ class DeploymentService { body.spec!.template!.spec!.imagePullSecrets = [{ name: dockerPullSecretName }]; } + if (app.securityContextRunAsUser != null || app.securityContextRunAsGroup != null || app.securityContextFsGroup != null) { + body.spec!.template!.spec!.securityContext = { + ...(app.securityContextRunAsUser != null ? { runAsUser: app.securityContextRunAsUser } : {}), + ...(app.securityContextRunAsGroup != null ? { runAsGroup: app.securityContextRunAsGroup } : {}), + ...(app.securityContextFsGroup != null ? { fsGroup: app.securityContextFsGroup } : {}), + }; + dlog(deploymentId, `Configured Security Context.`); + } + if (existingDeployment) { dlog(deploymentId, `Replacing existing deployment...`); const res = await k3s.apps.replaceNamespacedDeployment(app.id, app.projectId, body); diff --git a/src/shared/model/app-container-config.model.ts b/src/shared/model/app-container-config.model.ts index aa8d41c..72d7d71 100644 --- a/src/shared/model/app-container-config.model.ts +++ b/src/shared/model/app-container-config.model.ts @@ -1,4 +1,4 @@ -import { stringToNumber, stringToOptionalNumber } from "@/shared/utils/zod.utils"; +import { stringToOptionalNumber } from "@/shared/utils/zod.utils"; import { z } from "zod"; export const appContainerConfigZodModel = z.object({ @@ -6,6 +6,9 @@ export const appContainerConfigZodModel = z.object({ containerArgs: z.array(z.object({ value: z.string().trim() })).optional(), + securityContextRunAsUser: stringToOptionalNumber, + securityContextRunAsGroup: stringToOptionalNumber, + securityContextFsGroup: stringToOptionalNumber, }); export type AppContainerConfigModel = z.infer; \ No newline at end of file diff --git a/src/shared/model/generated-zod/app.ts b/src/shared/model/generated-zod/app.ts index 8d4e055..c2df344 100644 --- a/src/shared/model/generated-zod/app.ts +++ b/src/shared/model/generated-zod/app.ts @@ -13,6 +13,9 @@ export const AppModel = z.object({ containerRegistryPassword: z.string().nullish(), containerCommand: z.string().nullish(), containerArgs: z.string().nullish(), + securityContextRunAsUser: z.number().int().nullish(), + securityContextRunAsGroup: z.number().int().nullish(), + securityContextFsGroup: z.number().int().nullish(), gitUrl: z.string().nullish(), gitBranch: z.string().nullish(), gitUsername: z.string().nullish(), diff --git a/src/shared/templates/all.templates.ts b/src/shared/templates/all.templates.ts index 71f3008..c807c70 100644 --- a/src/shared/templates/all.templates.ts +++ b/src/shared/templates/all.templates.ts @@ -5,7 +5,7 @@ import { mongodbAppTemplate } from "./databases/mongodb.template"; import { mysqlAppTemplate } from "./databases/mysql.template"; import { postgreAppTemplate } from "./databases/postgres.template"; import { postCreateRedisAppTemplate, redisAppTemplate } from "./databases/redis.template"; -import { n8nAppTemplate } from "./apps/n8n.template"; +import { n8nAppTemplate, postCreateN8NAppTemplate } from "./apps/n8n.template"; import { noderedAppTemplate } from "./apps/nodered.template"; import { huginnAppTemplate } from "./apps/huginn.template"; import { nextcloudAppTemplate } from "./apps/nextcloud.template"; @@ -58,7 +58,7 @@ export const databaseTemplates: AppTemplateModel[] = [ export const appTemplates: AppTemplateModel[] = [ wordpressAppTemplate, - //n8nAppTemplate, + n8nAppTemplate, //noderedAppTemplate, //huginnAppTemplate, nextcloudAppTemplate, @@ -104,6 +104,7 @@ export const postCreateTemplateFunctions: Map => { + + const createdN8nApp = createdApps[0]; + if (!createdN8nApp) { + throw new Error('Created n8n app not found.'); + } + + const envVars = EnvVarUtils.parseEnvVariables(createdN8nApp); + + const encryptionKey = AppTemplateUtils.getRandomKey(64); + createdN8nApp.envVars += `N8N_ENCRYPTION_KEY=${encryptionKey}\n`; + + const timeZone = envVars.find(x => x.name === 'TZ')?.value ?? 'Europe/Zurich'; + createdN8nApp.envVars += `GENERIC_TIMEZONE=${timeZone}\n`; + + return [createdN8nApp]; +}; +