Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions prisma/migrations/20260307152228_migration/migration.sql
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 3 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
11 changes: 9 additions & 2 deletions src/app/project/app/[appId]/app-breadcrumbs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <></>;
}
3 changes: 3 additions & 0 deletions src/app/project/app/[appId]/general/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});
87 changes: 79 additions & 8 deletions src/app/project/app/[appId]/general/app-container-config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof appContainerConfigZodModel>;

Expand All @@ -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,
});
Expand Down Expand Up @@ -150,6 +147,80 @@ export default function GeneralAppContainerConfig({ app, readonly }: {
</Button>
)}
</div>

<div className="space-y-4">
<div>
<p className="text-sm font-medium">Security Context (optional)</p>
<p className="text-sm text-muted-foreground mt-1">
Use this when your app requires specific user/group permissions or needs to run with a specific filesystem group for volume access.
</p>
</div>
<div className="grid grid-cols-3 gap-4">
<FormField
control={form.control}
name="securityContextRunAsUser"
render={({ field }) => (
<FormItem>
<FormLabelWithQuestion hint="The UID to run the container process as. Corresponds to runAsUser in the Kubernetes pod securityContext.">
Run As User
</FormLabelWithQuestion>
<FormControl>
<Input
type="number"
placeholder="e.g., 1001"
{...field}
value={field.value ?? ''}
onChange={e => field.onChange(e.target.value === '' ? null : Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="securityContextRunAsGroup"
render={({ field }) => (
<FormItem>
<FormLabelWithQuestion hint="The GID to run the container process as. Corresponds to runAsGroup in the Kubernetes pod securityContext.">
Run As Group
</FormLabelWithQuestion>
<FormControl>
<Input
type="number"
placeholder="e.g., 1001"
{...field}
value={field.value ?? ''}
onChange={e => field.onChange(e.target.value === '' ? null : Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="securityContextFsGroup"
render={({ field }) => (
<FormItem>
<FormLabelWithQuestion hint="A special supplemental group applied to all containers in the pod. Volume ownership will be set to this GID. Corresponds to fsGroup in the Kubernetes pod securityContext.">
FS Group
</FormLabelWithQuestion>
<FormControl>
<Input
type="number"
placeholder="e.g., 1001"
{...field}
value={field.value ?? ''}
onChange={e => field.onChange(e.target.value === '' ? null : Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</CardContent>
{!readonly && (
<CardFooter className="gap-4">
Expand Down
9 changes: 5 additions & 4 deletions src/app/project/app/[appId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<>
Expand All @@ -35,7 +36,7 @@ export default async function AppPage({
app={app}
nodesInfo={nodesInfo}
tabName={searchParams?.tabName ?? 'overview'} />
<AppBreadcrumbs app={app} />
<AppBreadcrumbs app={app} apps={apps} tabName={searchParams?.tabName} />
</>
)
}
18 changes: 17 additions & 1 deletion src/components/custom/breadcrumbs-generator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,23 @@ export function BreadcrumbsGenerator() {
{breadcrumbs.map((x, index) => (<>
{index > 0 && <BreadcrumbSeparator />}
<BreadcrumbItem key={x.name}>
<BreadcrumbLink href={x.url ?? undefined}>{x.name}</BreadcrumbLink>
{x.dropdownItems ? (
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1 transition-colors hover:text-foreground">
{x.name}
<ChevronDown size={14} />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{x.dropdownItems.map((item) => (
<DropdownMenuItem key={item.url} disabled={item.active} asChild={!item.active}>
{item.active ? <span>{item.name}</span> : <Link href={item.url}>{item.name}</Link>}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<BreadcrumbLink href={x.url ?? undefined}>{x.name}</BreadcrumbLink>
)}
</BreadcrumbItem>
</>))}
</BreadcrumbList>
Expand Down
7 changes: 7 additions & 0 deletions src/frontend/states/zustand.states.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ZustandBreadcrumbsProps>((set) => ({
Expand Down
9 changes: 9 additions & 0 deletions src/server/services/deployment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion src/shared/model/app-container-config.model.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { stringToNumber, stringToOptionalNumber } from "@/shared/utils/zod.utils";
import { stringToOptionalNumber } from "@/shared/utils/zod.utils";
import { z } from "zod";

export const appContainerConfigZodModel = z.object({
containerCommand: z.string().trim().nullish(),
containerArgs: z.array(z.object({
value: z.string().trim()
})).optional(),
securityContextRunAsUser: stringToOptionalNumber,
securityContextRunAsGroup: stringToOptionalNumber,
securityContextFsGroup: stringToOptionalNumber,
});

export type AppContainerConfigModel = z.infer<typeof appContainerConfigZodModel>;
3 changes: 3 additions & 0 deletions src/shared/model/generated-zod/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
5 changes: 3 additions & 2 deletions src/shared/templates/all.templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -58,7 +58,7 @@ export const databaseTemplates: AppTemplateModel[] = [

export const appTemplates: AppTemplateModel[] = [
wordpressAppTemplate,
//n8nAppTemplate,
n8nAppTemplate,
//noderedAppTemplate,
//huginnAppTemplate,
nextcloudAppTemplate,
Expand Down Expand Up @@ -104,6 +104,7 @@ export const postCreateTemplateFunctions: Map<string, (createdApps: AppExtendedM
[redisAppTemplate.name, postCreateRedisAppTemplate],
[docmostAppTemplate.name, postCreateDocmostAppTemplate],
[duplicatiAppTemplate.name, postCreateDuplicatiAppTemplate],
[n8nAppTemplate.name, postCreateN8NAppTemplate],
]);


Expand Down
1 change: 1 addition & 0 deletions src/shared/templates/apps/docmost.template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const docmostAppTemplate: AppTemplateModel = {
healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS,
healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS,
healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD,
securityContextFsGroup: 1000,
},
appDomains: [],
appVolumes: [{
Expand Down
35 changes: 27 additions & 8 deletions src/shared/templates/apps/n8n.template.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { Constants } from "@/shared/utils/constants";
import { AppTemplateModel } from "../../model/app-template.model";
import { AppExtendedModel } from "@/shared/model/app-extended.model";
import { AppTemplateUtils } from "@/server/utils/app-template.utils";
import { EnvVarUtils } from "@/server/utils/env-var.utils";

export const n8nAppTemplate: AppTemplateModel = {
name: "n8n",
Expand All @@ -14,11 +17,11 @@ export const n8nAppTemplate: AppTemplateModel = {
randomGeneratedIfEmpty: false,
},
{
key: "N8N_ENCRYPTION_KEY",
label: "Encryption Key",
value: "",
key: "TZ",
label: "Timezone",
value: "Europe/Zurich",
isEnvVar: true,
randomGeneratedIfEmpty: true,
randomGeneratedIfEmpty: false,
},
],
appModel: {
Expand All @@ -29,13 +32,12 @@ export const n8nAppTemplate: AppTemplateModel = {
replicas: 1,
ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS,
egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS,
envVars: `GENERIC_TIMEZONE=Europe/Zurich
TZ=Europe/Zurich
`,
envVars: ``,
useNetworkPolicy: true,
healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS,
healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS,
healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD,
securityContextFsGroup: 1000,
},
appDomains: [],
appVolumes: [{
Expand All @@ -52,4 +54,21 @@ TZ=Europe/Zurich
}],
};

// todo set the permissions of the volume chown to the n8n user
export const postCreateN8NAppTemplate = async (createdApps: AppExtendedModel[]): Promise<AppExtendedModel[]> => {

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];
};