diff --git a/prisma/migrations/20260201125206_migration/migration.sql b/prisma/migrations/20260201125206_migration/migration.sql new file mode 100644 index 0000000..81420b8 --- /dev/null +++ b/prisma/migrations/20260201125206_migration/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "App" ADD COLUMN "containerArgs" TEXT; +ALTER TABLE "App" ADD COLUMN "containerCommand" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5ce1b4d..a25484c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -183,6 +183,8 @@ model App { containerImageSource String? containerRegistryUsername String? containerRegistryPassword String? + containerCommand String? // Custom command to override container ENTRYPOINT + containerArgs String? // Custom args to override container CMD (JSON array string) gitUrl String? gitBranch String? diff --git a/src/__tests__/server/utils/app-template.utils.test.ts b/src/__tests__/server/utils/app-template.utils.test.ts index a30010a..50ffc3c 100644 --- a/src/__tests__/server/utils/app-template.utils.test.ts +++ b/src/__tests__/server/utils/app-template.utils.test.ts @@ -87,7 +87,8 @@ describe('AppTemplateService', () => { username: 'testUser', password: 'testPass', port: 3306, - hostname: 'localhost' + hostname: 'localhost', + internalConnectionUrl: 'mongodb://localhost:3306/testDB' }; AppTemplateUtils.replacePlaceholdersInEnvVariablesWithDatabaseInfo(app, databaseInfo); diff --git a/src/__tests__/shared/templates/template-icons.test.ts b/src/__tests__/shared/templates/template-icons.test.ts new file mode 100644 index 0000000..7c4e197 --- /dev/null +++ b/src/__tests__/shared/templates/template-icons.test.ts @@ -0,0 +1,273 @@ +import { allTemplates, appTemplates, databaseTemplates } from '@/shared/templates/all.templates'; +import { AppTemplateModel } from '@/shared/model/app-template.model'; +import https from 'https'; +import http from 'http'; + +describe('Template Icons', () => { + describe('Icon URL Validation', () => { + const isValidUrl = (urlString: string): boolean => { + try { + const url = new URL(urlString); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } + }; + + const checkTemplateIcon = (template: AppTemplateModel) => { + const { name, iconName } = template; + + // Check if iconName exists + expect(iconName).toBeDefined(); + expect(typeof iconName).toBe('string'); + + if (!iconName) return; + + // If it's a URL (starts with http:// or https://) + if (iconName.startsWith('http://') || iconName.startsWith('https://')) { + // Should be a valid URL + expect(isValidUrl(iconName)).toBe(true); + + // Should use https for security (warn if http) + if (iconName.startsWith('http://')) { + console.warn(`āš ļø Template "${name}" uses HTTP instead of HTTPS: ${iconName}`); + } + + // Should have a valid file extension for images or be from known CDN/repos + const hasValidExtension = /\.(svg|png|jpg|jpeg|gif|ico|webp)$/i.test(iconName); + const isFromTrustedSource = + iconName.includes('github.com') || + iconName.includes('githubusercontent.com') || + iconName.includes('raw.githubusercontent.com') || + iconName.includes('cdn.jsdelivr.net') || + iconName.includes('cdn.simpleicons.org') || + iconName.includes('codeberg.org') || + iconName.includes('hub.docker.com') || + iconName.includes('redis.io') || + iconName.includes('jenkins.io') || + iconName.includes('sonarsource.com') || + iconName.includes('nodered.org') || + iconName.includes('plausible.io') || + iconName.includes('www.adminer.org'); + + if (!hasValidExtension && !isFromTrustedSource) { + console.error(`āŒ Template "${name}" has invalid icon URL: ${iconName}`); + } + + expect(hasValidExtension || isFromTrustedSource).toBe(true); + } else { + // If it's not a URL, it should be a filename + expect(iconName.length).toBeGreaterThan(0); + expect(iconName).toMatch(/\.(svg|png|jpg|jpeg|gif|ico|webp)$/i); + } + }; + + test('All database templates should have valid icon URLs', () => { + databaseTemplates.forEach(template => { + checkTemplateIcon(template); + }); + }); + + test('All app templates should have valid icon URLs', () => { + appTemplates.forEach(template => { + checkTemplateIcon(template); + }); + }); + + test('No duplicate template names', () => { + const names = allTemplates.map(t => t.name); + const uniqueNames = new Set(names); + expect(names.length).toBe(uniqueNames.size); + }); + + test('All templates should have non-empty names', () => { + allTemplates.forEach(template => { + expect(template.name).toBeDefined(); + expect(template.name.length).toBeGreaterThan(0); + }); + }); + }); + + describe('URL Format Validation', () => { + test('All URL-based icons should use valid protocols', () => { + const urlTemplates = allTemplates.filter(t => + t.iconName?.startsWith('http://') || t.iconName?.startsWith('https://') + ); + + urlTemplates.forEach(template => { + expect( + template.iconName?.startsWith('http://') || + template.iconName?.startsWith('https://') + ).toBe(true); + }); + }); + + test('URL-based icons should not have spaces', () => { + const urlTemplates = allTemplates.filter(t => + t.iconName?.startsWith('http://') || t.iconName?.startsWith('https://') + ); + + urlTemplates.forEach(template => { + expect(template.iconName).not.toContain(' '); + }); + }); + + test('URL-based icons should not have line breaks', () => { + const urlTemplates = allTemplates.filter(t => + t.iconName?.startsWith('http://') || t.iconName?.startsWith('https://') + ); + + urlTemplates.forEach(template => { + expect(template.iconName).not.toContain('\n'); + expect(template.iconName).not.toContain('\r'); + }); + }); + }); + + describe('Template Structure', () => { + test('All templates should have at least one template configuration', () => { + allTemplates.forEach(template => { + expect(template.templates).toBeDefined(); + expect(Array.isArray(template.templates)).toBe(true); + expect(template.templates.length).toBeGreaterThan(0); + }); + }); + + test('All template configurations should have required fields', () => { + allTemplates.forEach(template => { + template.templates.forEach((config, index) => { + expect(config.inputSettings).toBeDefined(); + expect(config.appModel).toBeDefined(); + expect(config.appDomains).toBeDefined(); + expect(config.appVolumes).toBeDefined(); + expect(config.appFileMounts).toBeDefined(); + expect(config.appPorts).toBeDefined(); + }); + }); + }); + }); + + describe('Icon URL Accessibility Summary', () => { + test('Generate summary of icon sources', () => { + const urlTemplates = allTemplates.filter(t => + t.iconName?.startsWith('http://') || t.iconName?.startsWith('https://') + ); + + const sources: { [key: string]: number } = {}; + + urlTemplates.forEach(template => { + if (template.iconName) { + const url = new URL(template.iconName); + const hostname = url.hostname; + sources[hostname] = (sources[hostname] || 0) + 1; + } + }); + + console.log('\nšŸ“Š Icon URL Sources Summary:'); + Object.entries(sources) + .sort((a, b) => b[1] - a[1]) + .forEach(([source, count]) => { + console.log(` ${source}: ${count} template(s)`); + }); + + console.log(`\nāœ… Total templates with URL icons: ${urlTemplates.length}`); + console.log(`šŸ“ Total templates with local icons: ${allTemplates.length - urlTemplates.length}`); + console.log(`šŸ“¦ Total templates: ${allTemplates.length}`); + + expect(urlTemplates.length).toBeGreaterThan(0); + }); + }); + + describe('Icon URL Accessibility (HTTP Fetch)', () => { + test('All URL-based icons should be accessible via HTTP', async () => { + const urlTemplates = allTemplates.filter(t => + t.iconName?.startsWith('http://') || t.iconName?.startsWith('https://') + ); + + const failedUrls: { name: string; url: string; error: string }[] = []; + const successfulUrls: string[] = []; + + console.log('\nšŸ” Testing HTTP accessibility for icon URLs...\n'); + + // Helper function to make HEAD request + const testUrl = (url: string): Promise<{ statusCode: number; statusMessage: string }> => { + return new Promise((resolve, reject) => { + const urlObj = new URL(url); + const client = urlObj.protocol === 'https:' ? https : http; + + const options = { + method: 'HEAD', + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; QuickStack-IconTest/1.0)', + }, + timeout: 10000, // 10 second timeout per request + }; + + const req = client.request(url, options, (res) => { + resolve({ + statusCode: res.statusCode || 0, + statusMessage: res.statusMessage || '' + }); + }); + + req.on('error', (error) => { + reject(error); + }); + + req.on('timeout', () => { + req.destroy(); + reject(new Error('Request timeout')); + }); + + req.end(); + }); + }; + + for (const template of urlTemplates) { + if (!template.iconName) continue; + + try { + const { statusCode, statusMessage } = await testUrl(template.iconName); + + if (statusCode >= 200 && statusCode < 400) { + successfulUrls.push(template.iconName); + console.log(` āœ… ${template.name}: ${statusCode}`); + } else { + failedUrls.push({ + name: template.name, + url: template.iconName, + error: `HTTP ${statusCode} ${statusMessage}` + }); + console.error(` āŒ ${template.name}: ${statusCode} ${statusMessage}`); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + failedUrls.push({ + name: template.name, + url: template.iconName, + error: errorMessage + }); + console.error(` āŒ ${template.name}: ${errorMessage}`); + } + + // Add a small delay to avoid rate limiting + await new Promise(resolve => setTimeout(resolve, 100)); + } + + console.log(`\nšŸ“Š Results:`); + console.log(` āœ… Successful: ${successfulUrls.length}`); + console.log(` āŒ Failed: ${failedUrls.length}`); + + if (failedUrls.length > 0) { + console.error('\nāŒ Failed URLs that need to be replaced:'); + failedUrls.forEach(({ name, url, error }) => { + console.error(` - ${name}:`); + console.error(` URL: ${url}`); + console.error(` Error: ${error}`); + }); + } + + expect(failedUrls.length).toBe(0); + }, 60000); // 60 second timeout for all fetches + }); +}); diff --git a/src/app/project/[projectId]/actions.ts b/src/app/project/[projectId]/actions.ts index 4db69f4..9175868 100644 --- a/src/app/project/[projectId]/actions.ts +++ b/src/app/project/[projectId]/actions.ts @@ -43,7 +43,7 @@ export const createAppFromTemplate = async (prevState: any, inputData: AppTempla throw new ServiceException('Please fill out all required fields.'); } await appTemplateService.createAppFromTemplate(projectId, validatedData); - return new SuccessActionResult(undefined, "App created successfully."); + return new SuccessActionResult(undefined, ""); }); export const deleteApp = async (appId: string) => diff --git a/src/app/project/[projectId]/choose-template-dialog.tsx b/src/app/project/[projectId]/choose-template-dialog.tsx index ea93484..6110cc1 100644 --- a/src/app/project/[projectId]/choose-template-dialog.tsx +++ b/src/app/project/[projectId]/choose-template-dialog.tsx @@ -6,6 +6,8 @@ import { AppTemplateModel } from "@/shared/model/app-template.model" import { allTemplates, appTemplates, databaseTemplates } from "@/shared/templates/all.templates" import CreateTemplateAppSetupDialog from "./create-template-app-setup-dialog" import { ScrollArea } from "@/components/ui/scroll-area"; +import { Input } from "@/components/ui/input"; +import { Search } from "lucide-react"; @@ -22,19 +24,25 @@ export default function ChooseTemplateDialog({ const [isOpen, setIsOpen] = useState(false); const [chosenAppTemplate, setChosenAppTemplate] = useState(undefined); const [displayedTemplates, setDisplayedTemplates] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); useEffect(() => { if (templateType) { setIsOpen(true); + setSearchQuery(""); } if (templateType === 'database') { - setDisplayedTemplates(databaseTemplates); + setDisplayedTemplates(databaseTemplates.sort((a, b) => a.name.localeCompare(b.name))); } if (templateType === 'template') { - setDisplayedTemplates(appTemplates); + setDisplayedTemplates(appTemplates.sort((a, b) => a.name.localeCompare(b.name))); } }, [templateType]); + const filteredTemplates = displayedTemplates.filter(template => + template.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + return ( <> - +
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
+
- {displayedTemplates.map((template) => ( -
{ - setIsOpen(false); - setChosenAppTemplate(template); - }} > - {template.iconName && } -

{template.name}

-
- ))} + {filteredTemplates.map((template) => { + const isUrl = template.iconName?.startsWith('http://') || template.iconName?.startsWith('https://'); + const iconSrc = template.iconName ? (isUrl ? template.iconName : `/template-icons/${template.iconName}`) : undefined; + + return ( +
{ + setIsOpen(false); + setChosenAppTemplate(template); + }} > + {iconSrc && } +

{template.name}

+
+ ); + })}
diff --git a/src/app/project/app/[appId]/app-tabs.tsx b/src/app/project/app/[appId]/app-tabs.tsx index a65dbca..ae9e083 100644 --- a/src/app/project/app/[appId]/app-tabs.tsx +++ b/src/app/project/app/[appId]/app-tabs.tsx @@ -4,6 +4,7 @@ import { useRouter } from "next/navigation"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import GeneralAppRateLimits from "./general/app-rate-limits"; import GeneralAppSource from "./general/app-source"; +import GeneralAppContainerConfig from "./general/app-container-config"; import EnvEdit from "./environment/env-edit"; import { S3Target } from "@prisma/client"; import DomainsList from "./domains/domains"; @@ -75,6 +76,7 @@ export default function AppTabs({ + diff --git a/src/app/project/app/[appId]/credentials/db-crendentials.tsx b/src/app/project/app/[appId]/credentials/db-crendentials.tsx index aca4f34..8ddd6a8 100644 --- a/src/app/project/app/[appId]/credentials/db-crendentials.tsx +++ b/src/app/project/app/[appId]/credentials/db-crendentials.tsx @@ -37,21 +37,21 @@ export default function DbCredentials({ {!databaseCredentials ? : <>
- -
+
+ } - + value={databaseCredentials?.username || ''} />} - - + value={databaseCredentials?.password || ''} />} ; + } + return <> diff --git a/src/app/project/app/[appId]/general/actions.ts b/src/app/project/app/[appId]/general/actions.ts index ac8ea24..9b69a70 100644 --- a/src/app/project/app/[appId]/general/actions.ts +++ b/src/app/project/app/[appId]/general/actions.ts @@ -1,13 +1,13 @@ 'use server' import { AppRateLimitsModel, appRateLimitsZodModel } from "@/shared/model/app-rate-limits.model"; -import { appSourceInfoContainerZodModel, appSourceInfoGitZodModel, AppSourceInfoInputModel, appSourceInfoInputZodModel } from "@/shared/model/app-source-info.model"; -import { AuthFormInputSchema, authFormInputSchemaZod } from "@/shared/model/auth-form"; -import { ErrorActionResult, ServerActionResult, SuccessActionResult } from "@/shared/model/server-action-error-return.model"; +import { appSourceInfoContainerZodModel, appSourceInfoGitZodModel, AppSourceInfoInputModel } from "@/shared/model/app-source-info.model"; +import { ServerActionResult } from "@/shared/model/server-action-error-return.model"; import { ServiceException } from "@/shared/model/service.exception.model"; import appService from "@/server/services/app.service"; -import userService from "@/server/services/user.service"; -import { getAuthUserSession, isAuthorizedWriteForApp, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils"; +import { isAuthorizedWriteForApp, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils"; +import { appContainerConfigZodModel } from "@/shared/model/app-container-config.model"; +import { AppContainerConfigInputModel } from "./app-container-config"; export const saveGeneralAppSourceInfo = async (prevState: any, inputData: AppSourceInfoInputModel, appId: string) => { @@ -57,3 +57,21 @@ export const saveGeneralAppRateLimits = async (prevState: any, inputData: AppRat id: appId, }); }); + +export const saveGeneralAppContainerConfig = async (prevState: any, inputData: AppContainerConfigInputModel, appId: string) => + saveFormAction(inputData, appContainerConfigZodModel, async (validatedData) => { + await isAuthorizedWriteForApp(appId); + const existingApp = await appService.getById(appId); + + // Convert args array to JSON string for storage + const containerArgsJson = validatedData.containerArgs && validatedData.containerArgs.length > 0 + ? JSON.stringify(validatedData.containerArgs.map(arg => arg.value)) + : null; + + await appService.save({ + ...existingApp, + containerCommand: validatedData.containerCommand?.trim() || null, + containerArgs: containerArgsJson, + 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 new file mode 100644 index 0000000..caef069 --- /dev/null +++ b/src/app/project/app/[appId]/general/app-container-config.tsx @@ -0,0 +1,164 @@ +'use client'; + +import { SubmitButton } from "@/components/custom/submit-button"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { FormUtils } from "@/frontend/utils/form.utilts"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm, useFieldArray } from "react-hook-form"; +import { saveGeneralAppContainerConfig } from "./actions"; +import { useFormState } from "react-dom"; +import { ServerActionResult } from "@/shared/model/server-action-error-return.model"; +import { Input } from "@/components/ui/input"; +import { useEffect } from "react"; +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(), +}); + +export type AppContainerConfigInputModel = z.infer; + +export default function GeneralAppContainerConfig({ app, readonly }: { + app: AppExtendedModel; + readonly: boolean; +}) { + // Parse containerArgs from JSON string to array + const initialArgs = app.containerArgs + ? JSON.parse(app.containerArgs).map((arg: string) => ({ value: arg })) + : []; + + const form = useForm({ + resolver: zodResolver(appContainerConfigZodModel), + defaultValues: { + containerCommand: app.containerCommand || '', + containerArgs: initialArgs, + }, + disabled: readonly, + }); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "containerArgs", + }); + + const [state, formAction] = useFormState( + (state: ServerActionResult, payload: AppContainerConfigInputModel) => + saveGeneralAppContainerConfig(state, payload, app.id), + FormUtils.getInitialFormState() + ); + + useEffect(() => { + if (state.status === 'success') { + toast.success('Container Configuration Saved', { + description: "Click \"deploy\" to apply the changes to your app.", + }); + } + FormUtils.mapValidationErrorsToForm(state, form) + }, [state]); + + return ( + + + Container Configuration + + Override the container's command and arguments. Leave empty to use the image defaults. + + +
+ form.handleSubmit((data) => { + return formAction(data); + })()}> + + ( + + Command (optional) + + + + + Override the container's ENTRYPOINT. + + + + )} + /> + +
+ Arguments (optional) + + Override the container's CMD. Each argument should be a separate item. + + +
+ {fields.map((field, index) => ( +
+ ( + + + + + + + )} + /> + +
+ ))} +
+ + {!readonly && ( + + )} +
+
+ {!readonly && ( + + Save +

{state?.message}

+
+ )} +
+ +
+ ); +} diff --git a/src/app/project/app/[appId]/general/app-rate-limits.tsx b/src/app/project/app/[appId]/general/app-rate-limits.tsx index 3faccbf..4668655 100644 --- a/src/app/project/app/[appId]/general/app-rate-limits.tsx +++ b/src/app/project/app/[appId]/general/app-rate-limits.tsx @@ -4,17 +4,13 @@ import { SubmitButton } from "@/components/custom/submit-button"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { FormUtils } from "@/frontend/utils/form.utilts"; -import { AppSourceInfoInputModel, appSourceInfoInputZodModel } from "@/shared/model/app-source-info.model"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; -import { saveGeneralAppRateLimits, saveGeneralAppSourceInfo } from "./actions"; +import { saveGeneralAppRateLimits } from "./actions"; import { useFormState } from "react-dom"; import { ServerActionResult } from "@/shared/model/server-action-error-return.model"; import { Input } from "@/components/ui/input"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Label } from "@/components/ui/label"; import { AppRateLimitsModel, appRateLimitsZodModel } from "@/shared/model/app-rate-limits.model"; -import { App } from "@prisma/client"; import { useEffect } from "react"; import { toast } from "sonner"; import { AppExtendedModel } from "@/shared/model/app-extended.model"; @@ -44,7 +40,7 @@ export default function GeneralAppRateLimits({ app, readonly }: { return <> - Container Configuration + Container Rate Limits Provide optional rate Limits per running container instance.
diff --git a/src/server/services/app-template.service.ts b/src/server/services/app-template.service.ts index 3fc2383..7c56f1f 100644 --- a/src/server/services/app-template.service.ts +++ b/src/server/services/app-template.service.ts @@ -1,11 +1,12 @@ import { AppTemplateContentModel, AppTemplateInputSettingsModel, AppTemplateModel } from "@/shared/model/app-template.model"; import { ServiceException } from "@/shared/model/service.exception.model"; import appService from "./app.service"; -import { allTemplates } from "@/shared/templates/all.templates"; +import { allTemplates, postCreateTemplateFunctions } from "@/shared/templates/all.templates"; import { AppTemplateUtils } from "../utils/app-template.utils"; import { DatabaseTemplateInfoModel } from "@/shared/model/database-template-info.model"; -import { revalidateTag } from "next/cache"; -import { Tags } from "../utils/cache-tag-generator.utils"; +import { AppExtendedModel } from "@/shared/model/app-extended.model"; +import dataAccess from "../adapter/db.client"; +import { Prisma } from "@prisma/client"; class AppTemplateService { @@ -14,62 +15,78 @@ class AppTemplateService { throw new ServiceException(`Template with name '${template.name}' not found.`); } - let databaseInfo: DatabaseTemplateInfoModel | undefined; + return await dataAccess.client.$transaction(async (tx) => { + let databaseInfo: DatabaseTemplateInfoModel | undefined; - for (const tmpl of template.templates) { - const createdAppId = await this.createAppFromTemplateContent(projectId, tmpl, tmpl.inputSettings); - const extendedApp = await appService.getExtendedById(createdAppId, false); + const createdTemplates: AppExtendedModel[] = []; - // used for templates with multiple apps and a database - if (databaseInfo) { - AppTemplateUtils.replacePlaceholdersInEnvVariablesWithDatabaseInfo(extendedApp, databaseInfo); - await appService.save({ - id: createdAppId, - envVars: extendedApp.envVars - }); + for (const tmpl of template.templates) { + const createdAppId = await this.createAppFromTemplateContent(projectId, tmpl, tmpl.inputSettings, tx); + let extendedApp = await appService.getExtendedById(createdAppId, false, tx); + + // used for templates with multiple apps and a database + if (databaseInfo) { + AppTemplateUtils.replacePlaceholdersInEnvVariablesWithDatabaseInfo(extendedApp, databaseInfo); + await appService.save({ + id: createdAppId, + envVars: extendedApp.envVars + }, false, tx); + extendedApp = await appService.getExtendedById(createdAppId, false, tx); + } + if (extendedApp.appType !== 'APP') { + databaseInfo = AppTemplateUtils.getDatabaseModelFromApp(extendedApp); + } + createdTemplates.push(extendedApp); } - if (extendedApp.appType !== 'APP') { - databaseInfo = AppTemplateUtils.getDatabaseModelFromApp(extendedApp); + + // run post create function if exists for this template + const postFunctionForTempalte = postCreateTemplateFunctions.get(template.name); + if (postFunctionForTempalte) { + const updatedApps = await postFunctionForTempalte(createdTemplates); + // save updated apps todo + for (const app of updatedApps) { + await appService.saveAppExtendedModel(app, tx); + } } - } + }); } private async createAppFromTemplateContent(projectId: string, template: AppTemplateContentModel, - inputValues: AppTemplateInputSettingsModel[]) { + inputValues: AppTemplateInputSettingsModel[], tx: Prisma.TransactionClient) { const mappedApp = AppTemplateUtils.mapTemplateInputValuesToApp(template, inputValues); const createdApp = await appService.save({ ...mappedApp, projectId - }, false); + }, false, tx); - const savedDomains = await Promise.all(template.appDomains.map(async x => { - return await appService.saveDomain({ - ...x, + for (const domain of template.appDomains) { + await appService.saveDomain({ + ...domain, appId: createdApp.id - }); - })); + }, tx); + } - const savedVolumes = await Promise.all(template.appVolumes.map(async x => { - return await appService.saveVolume({ - ...x, + for (const volume of template.appVolumes) { + await appService.saveVolume({ + ...volume, appId: createdApp.id - }); - })); + }, tx); + } - const savedFileMounts = await Promise.all(template.appFileMounts.map(async x => { - return await appService.saveFileMount({ - ...x, + for (const fileMount of template.appFileMounts) { + await appService.saveFileMount({ + ...fileMount, appId: createdApp.id - }); - })); + }, tx); + } - const savedPorts = await Promise.all(template.appPorts.map(async x => { - return await appService.savePort({ - ...x, + for (const port of template.appPorts) { + await appService.savePort({ + ...port, appId: createdApp.id - }); - })); + }, tx); + } return createdApp.id; } diff --git a/src/server/services/app.service.ts b/src/server/services/app.service.ts index 008b956..31124ec 100644 --- a/src/server/services/app.service.ts +++ b/src/server/services/app.service.ts @@ -13,6 +13,8 @@ import svcService from "./svc.service"; import deploymentLogService, { dlog } from "./deployment-logs.service"; import crypto from "crypto"; import networkPolicyService from "./network-policy.service"; +import { AppBasicAuthModel, AppDomainModel, AppFileMountModel, AppModel, AppPortModel, AppVolumeModel } from "@/shared/model/generated-zod"; +import { z } from "zod"; class AppService { @@ -87,7 +89,7 @@ class AppService { })(projectId as string); } - async getExtendedById(appId: string, cached = true): Promise { + async getExtendedById(appId: string, cached = true, tx?: Prisma.TransactionClient): Promise { const include = { project: true, appDomains: true, @@ -97,8 +99,9 @@ class AppService { appBasicAuths: true }; + const client = tx || dataAccess.client; if (cached) { - return await unstable_cache(async (id: string) => await dataAccess.client.app.findFirstOrThrow({ + return await unstable_cache(async (id: string) => await client.app.findFirstOrThrow({ where: { id }, @@ -108,7 +111,7 @@ class AppService { tags: [Tags.app(appId)] })(appId); } else { - return await dataAccess.client.app.findFirstOrThrow({ + return await client.app.findFirstOrThrow({ where: { id: appId }, include @@ -135,11 +138,12 @@ class AppService { }); } - async save(item: Prisma.AppUncheckedCreateInput | Prisma.AppUncheckedUpdateInput, createDefaultPort = true) { + async save(item: Prisma.AppUncheckedCreateInput | Prisma.AppUncheckedUpdateInput, createDefaultPort = true, tx?: Prisma.TransactionClient) { let savedItem: App; + const client = tx || dataAccess.client; try { if (item.id) { - savedItem = await dataAccess.client.app.update({ + savedItem = await client.app.update({ where: { id: item.id as string }, @@ -147,12 +151,12 @@ class AppService { }); } else { item.id = KubeObjectNameUtils.toAppId(item.name as string); - savedItem = await dataAccess.client.app.create({ + savedItem = await client.app.create({ data: item as Prisma.AppUncheckedCreateInput }); if (createDefaultPort) { // add default port 80 - await dataAccess.client.appPort.create({ + await client.appPort.create({ data: { appId: savedItem.id, port: 80 @@ -170,6 +174,62 @@ class AppService { return savedItem; } + async saveAppExtendedModel(app: AppExtendedModel, tx?: Prisma.TransactionClient) { + + const parsedAppModel = AppModel.parse(app); + await this.save({ + ...parsedAppModel, + id: app.id + }, false, tx); + + // for new objects, make sure some params are optional, wich will be created by prisma + const optionalParam = z.object({ + id: z.string().optional(), + createdAt: z.date().optional(), + updatedAt: z.date().optional(), + }); + + const parsedDomains = AppDomainModel.merge(optionalParam).array().parse(app.appDomains); + for (const domain of parsedDomains) { + await this.saveDomain({ + ...domain, + appId: app.id + }, tx); + } + + const parsedVolumes = AppVolumeModel.merge(optionalParam).array().parse(app.appVolumes); + for (const volume of parsedVolumes) { + await this.saveVolume({ + ...volume, + appId: app.id + }, tx); + } + + const parsedFileMounts = AppFileMountModel.merge(optionalParam).array().parse(app.appFileMounts); + for (const fileMount of parsedFileMounts) { + await this.saveFileMount({ + ...fileMount, + appId: app.id + }, tx); + } + + const parsedPorts = AppPortModel.merge(optionalParam).array().parse(app.appPorts); + for (const port of parsedPorts) { + await this.savePort({ + ...port, + appId: app.id + }, tx); + } + + const parsedBasicAuths = AppBasicAuthModel.merge(optionalParam).array().parse(app.appBasicAuths); + for (const basicAuth of parsedBasicAuths) { + await this.saveBasicAuth({ + ...basicAuth, + appId: app.id + }, tx); + } + } + async regenerateWebhookId(appId: string) { const existingApp = await this.getById(appId); @@ -180,10 +240,11 @@ class AppService { }); } - async saveDomain(domainToBeSaved: Prisma.AppDomainUncheckedCreateInput | Prisma.AppDomainUncheckedUpdateInput) { + async saveDomain(domainToBeSaved: Prisma.AppDomainUncheckedCreateInput | Prisma.AppDomainUncheckedUpdateInput, tx?: Prisma.TransactionClient) { let savedItem: AppDomain; + const client = tx || dataAccess.client; const existingApp = await this.getExtendedById(domainToBeSaved.appId as string); - const existingDomainWithSameHostname = await dataAccess.client.appDomain.findFirst({ + const existingDomainWithSameHostname = await client.appDomain.findFirst({ where: { hostname: domainToBeSaved.hostname as string, } @@ -195,7 +256,7 @@ class AppService { domainToBeSaved.id !== existingDomainWithSameHostname?.id) { throw new ServiceException("Hostname is already in use by this or another app."); } - savedItem = await dataAccess.client.appDomain.update({ + savedItem = await client.appDomain.update({ where: { id: domainToBeSaved.id as string }, @@ -205,7 +266,7 @@ class AppService { if (existingDomainWithSameHostname) { throw new ServiceException("Hostname is already in use by this or another app."); } - savedItem = await dataAccess.client.appDomain.create({ + savedItem = await client.appDomain.create({ data: domainToBeSaved as Prisma.AppDomainUncheckedCreateInput }); } @@ -289,10 +350,11 @@ class AppService { }); } - async saveVolume(volumeToBeSaved: Prisma.AppVolumeUncheckedCreateInput | Prisma.AppVolumeUncheckedUpdateInput) { + async saveVolume(volumeToBeSaved: Prisma.AppVolumeUncheckedCreateInput | Prisma.AppVolumeUncheckedUpdateInput, tx?: Prisma.TransactionClient) { let savedItem: AppVolume; - const existingApp = await this.getExtendedById(volumeToBeSaved.appId as string); - const existingAppWithSameVolumeMountPath = await dataAccess.client.appVolume.findMany({ + const client = tx || dataAccess.client; + const existingApp = await this.getExtendedById(volumeToBeSaved.appId as string, false, client); + const existingAppWithSameVolumeMountPath = await client.appVolume.findMany({ where: { appId: volumeToBeSaved.appId as string, } @@ -306,14 +368,14 @@ class AppService { try { if (volumeToBeSaved.id) { - savedItem = await dataAccess.client.appVolume.update({ + savedItem = await client.appVolume.update({ where: { id: volumeToBeSaved.id as string }, data: volumeToBeSaved }); } else { - savedItem = await dataAccess.client.appVolume.create({ + savedItem = await client.appVolume.create({ data: volumeToBeSaved as Prisma.AppVolumeUncheckedCreateInput }); } @@ -355,10 +417,11 @@ class AppService { } } - async saveFileMount(fileMountToBeSaved: Prisma.AppFileMountUncheckedCreateInput | Prisma.AppFileMountUncheckedUpdateInput) { + async saveFileMount(fileMountToBeSaved: Prisma.AppFileMountUncheckedCreateInput | Prisma.AppFileMountUncheckedUpdateInput, tx?: Prisma.TransactionClient) { let savedItem: AppFileMount; - const existingApp = await this.getExtendedById(fileMountToBeSaved.appId as string); - const existingAppWithSameVolumeMountPath = await dataAccess.client.appFileMount.findMany({ + const client = tx || dataAccess.client; + const existingApp = await this.getExtendedById(fileMountToBeSaved.appId as string, false, client); + const existingAppWithSameVolumeMountPath = await client.appFileMount.findMany({ where: { appId: fileMountToBeSaved.appId as string, } @@ -371,14 +434,14 @@ class AppService { try { if (fileMountToBeSaved.id) { - savedItem = await dataAccess.client.appFileMount.update({ + savedItem = await client.appFileMount.update({ where: { id: fileMountToBeSaved.id as string }, data: fileMountToBeSaved }); } else { - savedItem = await dataAccess.client.appFileMount.create({ + savedItem = await client.appFileMount.create({ data: fileMountToBeSaved as Prisma.AppFileMountUncheckedCreateInput }); } @@ -413,10 +476,11 @@ class AppService { } } - async savePort(portToBeSaved: Prisma.AppPortUncheckedCreateInput | Prisma.AppPortUncheckedUpdateInput) { + async savePort(portToBeSaved: Prisma.AppPortUncheckedCreateInput | Prisma.AppPortUncheckedUpdateInput, tx?: Prisma.TransactionClient) { let savedItem: AppPort; - const existingApp = await this.getExtendedById(portToBeSaved.appId as string); - const allPortsOfApp = await dataAccess.client.appPort.findMany({ + const client = tx || dataAccess.client; + const existingApp = await this.getExtendedById(portToBeSaved.appId as string, false, client); + const allPortsOfApp = await client.appPort.findMany({ where: { appId: portToBeSaved.appId as string, } @@ -427,14 +491,14 @@ class AppService { } try { if (portToBeSaved.id) { - savedItem = await dataAccess.client.appPort.update({ + savedItem = await client.appPort.update({ where: { id: portToBeSaved.id as string }, data: portToBeSaved }); } else { - savedItem = await dataAccess.client.appPort.create({ + savedItem = await client.appPort.create({ data: portToBeSaved as Prisma.AppPortUncheckedCreateInput }); } @@ -477,19 +541,20 @@ class AppService { } } - async saveBasicAuth(itemToBeSaved: Prisma.AppBasicAuthUncheckedCreateInput | Prisma.AppBasicAuthUncheckedUpdateInput) { + async saveBasicAuth(itemToBeSaved: Prisma.AppBasicAuthUncheckedCreateInput | Prisma.AppBasicAuthUncheckedUpdateInput, tx?: Prisma.TransactionClient) { let savedItem: AppBasicAuth; - const existingApp = await this.getExtendedById(itemToBeSaved.appId as string); + const client = tx || dataAccess.client; + const existingApp = await this.getExtendedById(itemToBeSaved.appId as string, false, tx); try { if (itemToBeSaved.id) { - savedItem = await dataAccess.client.appBasicAuth.update({ + savedItem = await client.appBasicAuth.update({ where: { id: itemToBeSaved.id as string }, data: itemToBeSaved }); } else { - savedItem = await dataAccess.client.appBasicAuth.create({ + savedItem = await client.appBasicAuth.create({ data: itemToBeSaved as Prisma.AppBasicAuthUncheckedCreateInput }); } diff --git a/src/server/services/deployment.service.ts b/src/server/services/deployment.service.ts index a709851..9230efd 100644 --- a/src/server/services/deployment.service.ts +++ b/src/server/services/deployment.service.ts @@ -19,6 +19,7 @@ import secretService from "./secret.service"; import fileBrowserService from "./file-browser-service"; import podService from "./pod.service"; import networkPolicyService from "./network-policy.service"; +import { z } from "zod"; class DeploymentService { @@ -58,6 +59,15 @@ class DeploymentService { if (app.replicas > 1 && app.appVolumes.length > 0 && app.appVolumes.every(vol => vol.accessMode === 'ReadWriteOnce')) { throw new ServiceException("Deployment with more than one replica is not possible if access mode of one volume is ReadWriteOnce."); } + + // Validate containerArgs is valid JSON array if provided + if (app.containerArgs) { + const parsed = JSON.parse(app.containerArgs); + const validatedData = z.array(z.string()).safeParse(parsed); + if (!validatedData.success) { + throw new ServiceException("Container arguments must be a valid JSON array, e.g., [\"arg1\", \"arg2\"]"); + } + } } async createDeployment(deploymentId: string, app: AppExtendedModel, buildJobName?: string, gitCommitHash?: string) { @@ -127,6 +137,8 @@ class DeploymentService { name: app.id, image: !!buildJobName ? registryService.createContainerRegistryUrlForAppId(app.id) : app.containerImageSource as string, imagePullPolicy: 'Always', + ...(app.containerCommand ? { command: [app.containerCommand] } : {}), + ...(app.containerArgs ? { args: JSON.parse(app.containerArgs) } : {}), ...(envVars.length > 0 ? { env: envVars } : {}), ...(allVolumeMounts.length > 0 ? { volumeMounts: allVolumeMounts } : {}), } diff --git a/src/server/utils/app-template.utils.ts b/src/server/utils/app-template.utils.ts index 57c88cf..b97a06f 100644 --- a/src/server/utils/app-template.utils.ts +++ b/src/server/utils/app-template.utils.ts @@ -54,6 +54,48 @@ export class AppTemplateUtils { } } + static getRandomKey(hexCharsCount = 32): string { + return crypto.randomBytes(hexCharsCount / 2).toString('hex'); + } + + /** + * Generates a strong password that contains at least + * one uppercase letter, one lowercase letter, one number, and one special character. + * Valid length range: 10-72 characters. + */ + static generateStrongPasswort(length = 25): string { + if (length < 10 || length > 72) { + throw new ServiceException('Password must be 10-72 characters long'); + } + const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + const lowercase = 'abcdefghijklmnopqrstuvwxyz'; + const numbers = '0123456789'; + const special = '!$%*()-_+[]{};,.'; + const all = uppercase + lowercase + numbers + special; + + // Guarantee at least one character from each required category + const required = [ + uppercase[crypto.randomInt(uppercase.length)], + lowercase[crypto.randomInt(lowercase.length)], + numbers[crypto.randomInt(numbers.length)], + special[crypto.randomInt(special.length)], + ]; + + const remaining = Array.from({ length: length - required.length }, () => + all[crypto.randomInt(all.length)] + ); + + const combined = [...required, ...remaining]; + + // Fisher-Yates shuffle to avoid predictable positions + for (let i = combined.length - 1; i > 0; i--) { + const j = crypto.randomInt(i + 1); + [combined[i], combined[j]] = [combined[j], combined[i]]; + } + + return combined.join(''); + } + static getDatabaseModelFromApp(app: AppExtendedModel): DatabaseTemplateInfoModel { if (app.appType === 'APP') { throw new ServiceException('Cannot retreive database infos from app'); @@ -98,6 +140,24 @@ export class AppTemplateUtils { hostname, internalConnectionUrl: `mariadb://${envVars.find(x => x.name === 'MYSQL_USER')?.value!}:${envVars.find(x => x.name === 'MYSQL_PASSWORD')?.value!}@${hostname}:${port}/${envVars.find(x => x.name === 'MYSQL_DATABASE')?.value!}`, }; + } else if (app.appType === 'REDIS') { + let password = ''; + if (app.containerArgs) { + try { + const args = JSON.parse(app.containerArgs); + password = args.find((x: string) => x === '--requirepass') ? args[args.findIndex((x: string) => x === '--requirepass') + 1] : ''; + } catch (e) { + console.error('Error parsing container args for redis password', e); + } + } + returnVal = { + databaseName: '', + username: '', + password, + port, + hostname, + internalConnectionUrl: password ? `redis://:${password}@${hostname}:${port}` : `redis://${hostname}:${port}`, + }; } else { throw new ServiceException('Unknown database type, could not load database information.'); } diff --git a/src/shared/model/app-container-config.model.ts b/src/shared/model/app-container-config.model.ts new file mode 100644 index 0000000..aa8d41c --- /dev/null +++ b/src/shared/model/app-container-config.model.ts @@ -0,0 +1,11 @@ +import { stringToNumber, 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(), +}); + +export type AppContainerConfigModel = z.infer; \ No newline at end of file diff --git a/src/shared/model/app-source-info.model.ts b/src/shared/model/app-source-info.model.ts index 639d683..52805e7 100644 --- a/src/shared/model/app-source-info.model.ts +++ b/src/shared/model/app-source-info.model.ts @@ -1,7 +1,7 @@ import { z } from "zod"; export const appSourceTypeZodModel = z.enum(["GIT", "CONTAINER"]); -export const appTypeZodModel = z.enum(["APP", "POSTGRES", "MYSQL", "MARIADB", "MONGODB"]); +export const appTypeZodModel = z.enum(["APP", "POSTGRES", "MYSQL", "MARIADB", "MONGODB", "REDIS"]); export const appSourceInfoGitZodModel = z.object({ gitUrl: z.string().trim(), diff --git a/src/shared/model/generated-zod/app.ts b/src/shared/model/generated-zod/app.ts index c4872e7..8d4e055 100644 --- a/src/shared/model/generated-zod/app.ts +++ b/src/shared/model/generated-zod/app.ts @@ -11,6 +11,8 @@ export const AppModel = z.object({ containerImageSource: z.string().nullish(), containerRegistryUsername: z.string().nullish(), containerRegistryPassword: z.string().nullish(), + containerCommand: z.string().nullish(), + containerArgs: z.string().nullish(), gitUrl: z.string().nullish(), gitBranch: z.string().nullish(), gitUsername: z.string().nullish(), diff --git a/src/shared/templates/README.md b/src/shared/templates/README.md new file mode 100644 index 0000000..16e9a27 --- /dev/null +++ b/src/shared/templates/README.md @@ -0,0 +1,607 @@ +# QuickStack Application Templates + +This directory contains pre-configured application templates for QuickStack. Templates allow users to quickly deploy common applications and databases with sensible defaults. + +## Overview + +Templates are TypeScript files that define the complete configuration for one or more applications. They specify container images, environment variables, volumes, ports, and any post-creation configuration needed. + +## Template Structure + +Each template file exports an `AppTemplateModel` object and optionally a post-create function. + +### Basic Template Structure + +```typescript +import { AppTemplateModel } from "../../model/app-template.model"; +import { Constants } from "@/shared/utils/constants"; + +export const myAppTemplate: AppTemplateModel = { + name: "My Application", + iconName: "myapp.svg", // or URL: "https://example.com/icon.png" + templates: [ + { + inputSettings: [ /* user inputs */ ], + appModel: { /* app configuration */ }, + appDomains: [ /* domain configuration */ ], + appVolumes: [ /* volume configuration */ ], + appFileMounts: [ /* file mounts */ ], + appPorts: [ /* port configuration */ ] + } + ] +}; +``` + +### Key Properties + +#### 1. `name` (string) +The display name of the template shown in the UI. + +#### 2. `iconName` (string) +Either: +- A filename from `/public/template-icons/` (e.g., `"mysql.svg"`) +- A full URL to an icon (e.g., `"https://avatars.githubusercontent.com/u/158137808"`) + +#### 3. `templates` (array) +An array of template configurations. Use multiple templates when your application requires multiple services (e.g., frontend + backend, app + database). + +### Template Configuration Object + +Each object in the `templates` array contains: + +#### `inputSettings` (array) +User-configurable values that will be prompted during creation: + +```typescript +inputSettings: [ + { + key: "containerImageSource", // Must match a property in appModel + label: "Container Image", // Display label in UI + value: "postgres:16", // Default value + isEnvVar: false, // If true, adds to envVars; if false, sets app property + randomGeneratedIfEmpty: false, // If true, generates random string when empty + }, + { + key: "POSTGRES_PASSWORD", + label: "Database Password", + value: "", + isEnvVar: true, // Will be added to envVars as "POSTGRES_PASSWORD=..." + randomGeneratedIfEmpty: true, // Will generate secure random password if left empty + } +] +``` + +**Key field behavior:** +- If `isEnvVar: false`, the key must match a field in `appModel` (e.g., `containerImageSource`, `name`, `replicas`) +- If `isEnvVar: true`, the key will be used as an environment variable name + +**Random generation:** +- When `randomGeneratedIfEmpty: true`, empty values will be replaced with a secure random string +- Useful for passwords, secret keys, and tokens + +#### `appModel` (object) +Core application configuration: + +```typescript +appModel: { + name: "PostgreSQL", // Default name (user can override) + appType: 'DATABASE' | 'APP', // Type of application + sourceType: 'CONTAINER', // Always 'CONTAINER' for templates + containerImageSource: "", // Will be set from inputSettings + replicas: 1, // Number of replicas + + // Network policies + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_DATABASES, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_DATABASES, + useNetworkPolicy: true, + + // Environment variables (string with KEY=VALUE pairs, one per line) + envVars: `POSTGRES_DB=mydb +POSTGRES_USER=admin`, + + // Health checks + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, +} +``` + +**Important:** +- `envVars` is a multi-line string with KEY=VALUE format +- Values from `inputSettings` with `isEnvVar: true` will be automatically appended +- Use `Constants.DEFAULT_*` values for network policies and health checks + +#### `appDomains` (array) +Domain configurations (usually empty for templates, users configure later): + +```typescript +appDomains: [] +``` + +#### `appVolumes` (array) +Persistent volume configurations: + +```typescript +appVolumes: [ + { + size: 10000, // Size in MB (10GB = 10000MB) + containerMountPath: '/var/lib/postgresql/data', + accessMode: 'ReadWriteOnce', // 'ReadWriteOnce' | 'ReadOnlyMany' | 'ReadWriteMany' + storageClassName: 'longhorn', // Storage class (usually 'longhorn') + shareWithOtherApps: false, // Whether volume can be shared + } +] +``` + +#### `appFileMounts` (array) +File mount configurations (usually empty unless specific files need to be mounted): + +```typescript +appFileMounts: [] +``` + +#### `appPorts` (array) +Port configurations: + +```typescript +appPorts: [ + { + port: 5432, // Container port to expose + } +] +``` + +## Multi-Service Templates + +When an application requires multiple services (e.g., Ollama + Open WebUI), define multiple objects in the `templates` array. + +### Example: Open WebUI with Ollama Backend + +```typescript +export const openwebuiAppTemplate: AppTemplateModel = { + name: "Open WebUI", + iconName: 'https://avatars.githubusercontent.com/u/158137808', + templates: [ + { + // First service: Ollama backend + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "ollama/ollama:latest", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + ], + appModel: { + name: "Ollama", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + envVars: `OLLAMA_HOST=0.0.0.0 +OLLAMA_ORIGINS=*`, + // ... other configuration + }, + appVolumes: [{ + size: 10000, + containerMountPath: '/root/.ollama', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appPorts: [{ + port: 11434, + }] + }, + { + // Second service: Open WebUI frontend + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "ghcr.io/open-webui/open-webui:main", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + { + key: "WEBUI_SECRET_KEY", + label: "Secret Key", + value: "", + isEnvVar: true, + randomGeneratedIfEmpty: true, // Auto-generates if empty + }, + ], + appModel: { + name: "Open WebUI", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + envVars: ``, // Will be populated by post-create function + // ... other configuration + }, + appVolumes: [{ + size: 2000, + containerMountPath: '/app/backend/data', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appPorts: [{ + port: 8080, + }] + } + ] +}; +``` + +## Using Database Templates in Multi-Service Apps + +QuickStack provides reusable database template functions that you can use in your multi-service templates. Instead of manually defining database configurations, use these helper functions with custom parameters. + +### Available Database Template Functions + +All database templates export both: +1. A `getXXXAppTemplate()` function for custom configurations +2. An `xxxAppTemplate` constant for standalone use (which internally uses the function with defaults) + +**Functions available:** + +- `getPostgresAppTemplate(config?)` - PostgreSQL database +- `getMongodbAppTemplate(config?)` - MongoDB database +- `getMysqlAppTemplate(config?)` - MySQL database +- `getMariadbAppTemplate(config?)` - MariaDB database +- `getRedisAppTemplate(config?)` - Redis cache + +**Example of both exports:** +```typescript +// From databases/postgres.template.ts: +export function getPostgresAppTemplate(config?) { /* ... */ } // Function for custom config +export const postgreAppTemplate: AppTemplateModel = { // Constant for standalone use + name: "PostgreSQL", + iconName: 'postgres.svg', + templates: [getPostgresAppTemplate()] // Uses function with defaults +}; +``` + +### Function Parameters + +Each function accepts an optional configuration object to customize the database: + +#### PostgreSQL, MongoDB +```typescript +config?: { + appName?: string, // Custom name (e.g., "My App PostgreSQL") + dbName?: string, // Database name + dbUsername?: string, // Database username + dbPassword?: string // Database password (leave empty to auto-generate) +} +``` + +#### MySQL, MariaDB +```typescript +config?: { + appName?: string, // Custom name + dbName?: string, // Database name + dbUsername?: string, // Database username + dbPassword?: string, // Database password (leave empty to auto-generate) + rootPassword?: string // Root password (leave empty to auto-generate) +} +``` + +#### Redis +```typescript +config?: { + appName?: string // Custom name (e.g., "Cache Redis") +} +``` + +### Example: Docmost with PostgreSQL and Redis + +See the complete implementation in [`apps/docmost.template.ts`](apps/docmost.template.ts). + +**Key highlights:** +- Uses `getPostgresAppTemplate()` with custom database name and username +- Uses `getRedisAppTemplate()` with custom app name +- Implements `postCreateDocmostAppTemplate()` to: + - Call `postCreateRedisAppTemplate()` to set up Redis password + - Use `AppTemplateUtils.getDatabaseModelFromApp()` to extract connection info + - Build connection URLs with `.internalConnectionUrl` property (includes passwords) + - Set environment variables for the main Docmost app + +### Benefits of Using Database Template Functions + +1. **Consistency**: All database configurations use the same tested patterns +2. **Less Code**: No need to manually define volumes, ports, health checks +3. **Flexibility**: Override only the values you need to customize +4. **Maintainability**: Database configuration updates happen in one place +5. **Best Practices**: Functions include proper network policies, health checks, and defaults + +### Extracting Database Connection Information + +Use `AppTemplateUtils.getDatabaseModelFromApp()` to extract database credentials and connection URLs in post-create functions: + +```typescript +import { AppTemplateUtils } from "@/server/utils/app-template.utils"; + +const dbInfo = AppTemplateUtils.getDatabaseModelFromApp(postgresApp); +// Returns: { +// hostname: "svc-app-xyz", +// port: 5432, +// username: "dbuser", +// password: "generated-password", +// databaseName: "mydb", +// internalConnectionUrl: "postgresql://dbuser:password@svc-app-xyz:5432/mydb" +// } + +// Use the connection URL directly +docmostApp.envVars = `DATABASE_URL=${dbInfo.internalConnectionUrl}`; +``` + +**Supported databases:** PostgreSQL, MySQL, MariaDB, MongoDB, Redis + +**Note:** For Redis with password, the password is automatically included in the URL format: `redis://:password@hostname:port` + +### When to Use Database Template Functions + +- āœ… Multi-service templates that need databases (e.g., app + PostgreSQL) +- āœ… Custom database names/credentials for specific applications +- āœ… Reducing boilerplate in template definitions +- āŒ Standalone database templates (use the exported constants instead) + +## Post-Creation Configuration + +When apps need to reference each other (e.g., frontend needs backend URL), use a post-create function. + +### Post-Create Function Structure + +```typescript +import { AppExtendedModel } from "@/shared/model/app-extended.model"; +import { KubeObjectNameUtils } from "@/server/utils/kube-object-name.utils"; + +export const postCreateMyAppTemplate = async ( + createdApps: AppExtendedModel[] +): Promise => { + // createdApps array matches the order of templates array + const backendApp = createdApps[0]; + const frontendApp = createdApps[1]; + + if (!backendApp || !frontendApp) { + throw new Error('Created templates not found.'); + } + + // Get internal Kubernetes service hostname + const backendHostname = KubeObjectNameUtils.toServiceName(backendApp.id); + + // Update frontend app configuration + frontendApp.envVars += `BACKEND_URL=http://${backendHostname}:8080`; + + // Return modified apps (order must match input) + return [backendApp, frontendApp]; +}; +``` + +### Registering Post-Create Functions + +Add your post-create function to `all.templates.ts`: + +```typescript +import { postCreateMyAppTemplate } from "./apps/myapp.template"; + +export const postCreateTemplateFunctions: Map< + string, + (createdApps: AppExtendedModel[]) => Promise +> = new Map([ + [myAppTemplate.name, postCreateMyAppTemplate], // Key is template name +]); +``` + +**Important:** +- The function receives apps in the same order as defined in `templates` array +- Use `KubeObjectNameUtils.toServiceName(appId)` to get internal Kubernetes DNS names +- Format for internal URLs: `http://{serviceName}:{port}` +- Always return the full array of apps, even if some weren't modified +- Validate that all expected apps exist before processing + +## Utility Functions + +### `KubeObjectNameUtils.toServiceName(appId: string)` +Generates the internal Kubernetes service name for inter-app communication. + +```typescript +const serviceName = KubeObjectNameUtils.toServiceName(appId); +// Result: "svc-app-myapp-a1b2c3d4" +// Use in URLs: `http://${serviceName}:8080` +``` + +## Adding a New Template + +1. **Create template file** in `apps/` or `databases/` directory: + ```typescript + // src/shared/templates/apps/myapp.template.ts + export const myAppTemplate: AppTemplateModel = { /* ... */ }; + + // Optional: Post-create function if needed + export const postCreateMyAppTemplate = async (createdApps) => { /* ... */ }; + ``` + +2. **Import in `all.templates.ts`**: + ```typescript + import { myAppTemplate } from "./apps/myapp.template"; + + export const appTemplates: AppTemplateModel[] = [ + // ... existing templates + myAppTemplate + ]; + ``` + +3. **Register post-create function** (if exists): + ```typescript + export const postCreateTemplateFunctions: Map<...> = new Map([ + // ... existing functions + [myAppTemplate.name, postCreateMyAppTemplate] + ]); + ``` + +4. **Add icon** (optional): + - Place SVG/PNG in `/public/template-icons/myapp.svg` + - Or use direct URL in `iconName` field + +## Common Patterns + +### Database Template Pattern + +```typescript +export const mydatabaseAppTemplate: AppTemplateModel = { + name: "MyDatabase", + iconName: "mydatabase.svg", + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "mydatabase:latest", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + { + key: "DB_PASSWORD", + label: "Database Password", + value: "", + isEnvVar: true, + randomGeneratedIfEmpty: true, + }, + ], + appModel: { + name: "MyDatabase", + appType: 'DATABASE', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_DATABASES, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_DATABASES, + envVars: `DB_USER=admin`, + useNetworkPolicy: true, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 5000, + containerMountPath: '/data', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 3306, + }] + }] +}; +``` + +### Application Template Pattern + +```typescript +export const myappAppTemplate: AppTemplateModel = { + name: "My Application", + iconName: "myapp.svg", + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "myapp:latest", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + { + key: "APP_SECRET", + label: "Application Secret", + value: "", + isEnvVar: true, + randomGeneratedIfEmpty: true, + }, + ], + appModel: { + name: "My Application", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: `NODE_ENV=production`, + useNetworkPolicy: true, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 1000, + containerMountPath: '/app/data', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 3000, + }] + }] +}; +``` + +## Best Practices + +1. **Use Database Template Functions**: For multi-service templates, use `getPostgresAppTemplate()`, `getMongodbAppTemplate()`, etc. instead of manually defining database configurations +2. **Use Constants**: Always use `Constants.DEFAULT_*` values for network policies and health checks +3. **Random Passwords**: Use `randomGeneratedIfEmpty: true` for sensitive values like passwords +4. **Clear Labels**: Make `inputSettings` labels user-friendly and descriptive +5. **Sensible Defaults**: Provide good default values for container images and other settings +6. **Volume Sizes**: Choose appropriate default volume sizes (in MB) +7. **Port Configuration**: Always specify the main container port(s) +8. **Post-Create Functions**: Required when apps need to reference each other (see Docmost example) +9. **Error Handling**: Always validate app existence in post-create functions +10. **Internal URLs**: Use `KubeObjectNameUtils.toServiceName()` for inter-service communication +11. **Icon Assets**: Prefer SVG icons in `/public/template-icons/` for consistency + +## Constants Reference + +```typescript +// Network Policies +Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS +Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS +Constants.DEFAULT_INGRESS_NETWORK_POLICY_DATABASES +Constants.DEFAULT_EGRESS_NETWORK_POLICY_DATABASES + +// Health Checks +Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS +Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS +Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD +``` + +## Testing Your Template + +1. Start the QuickStack development server +2. Navigate to a project +3. Click "Create from Template" +4. Select "Database" or "App" tab based on your template type +5. Find and select your template +6. Fill in the configuration form +7. Click "Create" and verify the app(s) are created correctly +8. If using post-create function, verify environment variables are set correctly +9. Deploy the app(s) and verify they start successfully + +## Example Files + +Reference these existing templates for more examples: +- **Standalone Database**: `databases/postgres.template.ts` +- **Database Template Function**: See any `getXXXAppTemplate()` in `databases/` directory +- **Multi-Service with Databases**: `apps/docmost.template.ts` (PostgreSQL + Redis + App) +- **Multi-Service with Post-Create**: `apps/openwebui.template.ts` (Ollama + Open WebUI) +- **Complex Configuration**: `apps/immich.template.ts` diff --git a/src/shared/templates/all.templates.ts b/src/shared/templates/all.templates.ts index 419e614..71f3008 100644 --- a/src/shared/templates/all.templates.ts +++ b/src/shared/templates/all.templates.ts @@ -4,18 +4,107 @@ import { mariadbAppTemplate } from "./databases/mariadb.template"; 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 { noderedAppTemplate } from "./apps/nodered.template"; +import { huginnAppTemplate } from "./apps/huginn.template"; +import { nextcloudAppTemplate } from "./apps/nextcloud.template"; +import { minioAppTemplate } from "./apps/minio.template"; +import { filebrowserAppTemplate } from "./apps/filebrowser.template"; +import { grafanaAppTemplate } from "./apps/grafana.template"; +import { prometheusAppTemplate } from "./apps/prometheus.template"; +import { uptimekumaAppTemplate } from "./apps/uptimekuma.template"; +import { plausibleAppTemplate } from "./apps/plausible.template"; +import { rocketchatAppTemplate } from "./apps/rocketchat.template"; +import { mattermostAppTemplate } from "./apps/mattermost.template"; +import { elementAppTemplate } from "./apps/element.template"; +import { giteaAppTemplate } from "./apps/gitea.template"; +import { forgejopAppTemplate } from "./apps/forgejo.template"; +import { jenkinsAppTemplate } from "./apps/jenkins.template"; +import { droneAppTemplate } from "./apps/drone.template"; +import { sonarqubeAppTemplate } from "./apps/sonarqube.template"; +import { harborAppTemplate } from "./apps/harbor.template"; +import { jellyfinAppTemplate } from "./apps/jellyfin.template"; +import { immichAppTemplate } from "./apps/immich.template"; +import { photoprismAppTemplate } from "./apps/photoprism.template"; +import { navidiomeAppTemplate } from "./apps/navidrome.template"; +import { wikijsAppTemplate } from "./apps/wikijs.template"; +import { outlineAppTemplate } from "./apps/outline.template"; +import { docmostAppTemplate, postCreateDocmostAppTemplate } from "./apps/docmost.template"; +import { hedgedocAppTemplate } from "./apps/hedgedoc.template"; +import { vaultwardenAppTemplate } from "./apps/vaultwarden.template"; +import { ghostAppTemplate } from "./apps/ghost.template"; +import { nginxAppTemplate } from "./apps/nginx.template"; +import { adminerAppTemplate } from "./apps/adminer.template"; +import { drawioAppTemplate } from "./apps/drawio.template"; +import { dozzleAppTemplate } from "./apps/dozzle.template"; +import { homeassistantAppTemplate } from "./apps/homeassistant.template"; +import { duplicatiAppTemplate, postCreateDuplicatiAppTemplate } from "./apps/duplicati.template"; +import { openwebuiAppTemplate, postCreateOpenwebuiAppTemplate } from "./apps/openwebui.template"; +import { AppExtendedModel } from "../model/app-extended.model"; +import { tikaAppTemplate } from "./apps/tika.template"; +import { libredeskAppTemplate, postCreateLibredeskAppTemplate } from "./apps/libredesk.template"; export const databaseTemplates: AppTemplateModel[] = [ postgreAppTemplate, mongodbAppTemplate, mariadbAppTemplate, - mysqlAppTemplate + mysqlAppTemplate, + redisAppTemplate, ]; +// the commented out templates aren't tested yet. + export const appTemplates: AppTemplateModel[] = [ - wordpressAppTemplate + wordpressAppTemplate, + //n8nAppTemplate, + //noderedAppTemplate, + //huginnAppTemplate, + nextcloudAppTemplate, + minioAppTemplate, + //filebrowserAppTemplate, + // grafanaAppTemplate, + //prometheusAppTemplate, + uptimekumaAppTemplate, + //plausibleAppTemplate, + //rocketchatAppTemplate, + //mattermostAppTemplate, + //elementAppTemplate, + giteaAppTemplate, + //forgejopAppTemplate, + //jenkinsAppTemplate, + //droneAppTemplate, + //sonarqubeAppTemplate, + //harborAppTemplate, + //jellyfinAppTemplate, + //immichAppTemplate, + //photoprismAppTemplate, + //navidiomeAppTemplate, + //wikijsAppTemplate, + //outlineAppTemplate, + docmostAppTemplate, + //hedgedocAppTemplate, + //vaultwardenAppTemplate, + //ghostAppTemplate, + nginxAppTemplate, + adminerAppTemplate, + drawioAppTemplate, + //dozzleAppTemplate, + //homeassistantAppTemplate, + duplicatiAppTemplate, + openwebuiAppTemplate, + tikaAppTemplate, + libredeskAppTemplate ]; +export const postCreateTemplateFunctions: Map Promise> = new Map([ + [openwebuiAppTemplate.name, postCreateOpenwebuiAppTemplate], + [libredeskAppTemplate.name, postCreateLibredeskAppTemplate], + [redisAppTemplate.name, postCreateRedisAppTemplate], + [docmostAppTemplate.name, postCreateDocmostAppTemplate], + [duplicatiAppTemplate.name, postCreateDuplicatiAppTemplate], +]); + export const allTemplates: AppTemplateModel[] = databaseTemplates.concat(appTemplates); \ No newline at end of file diff --git a/src/shared/templates/apps/adminer.template.ts b/src/shared/templates/apps/adminer.template.ts new file mode 100644 index 0000000..777c3a2 --- /dev/null +++ b/src/shared/templates/apps/adminer.template.ts @@ -0,0 +1,39 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const adminerAppTemplate: AppTemplateModel = { + name: "Adminer", + iconName: 'https://cdn.simpleicons.org/adminer', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "adminer:latest", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + ], + appModel: { + name: "Adminer", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: `ADMINER_DEFAULT_SERVER=db +`, + useNetworkPolicy: true, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [], + appFileMounts: [], + appPorts: [{ + port: 8080, + }] + }], +}; diff --git a/src/shared/templates/apps/docmost.template.ts b/src/shared/templates/apps/docmost.template.ts new file mode 100644 index 0000000..6c28d52 --- /dev/null +++ b/src/shared/templates/apps/docmost.template.ts @@ -0,0 +1,95 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; +import { getPostgresAppTemplate } from "../databases/postgres.template"; +import { getRedisAppTemplate, postCreateRedisAppTemplate } from "../databases/redis.template"; +import { AppExtendedModel } from "@/shared/model/app-extended.model"; +import { KubeObjectNameUtils } from "@/server/utils/kube-object-name.utils"; +import { AppTemplateUtils } from "@/server/utils/app-template.utils"; + +export const docmostAppTemplate: AppTemplateModel = { + name: "Docmost", + iconName: 'https://cdn-1.webcatalog.io/catalog/docmost/docmost-icon-filled-256.webp', + templates: [ + // PostgreSQL + getPostgresAppTemplate({ + appName: 'Docmost PostgreSQL', + dbName: 'docmost', + dbUsername: 'docmost' + }), + // Redis + getRedisAppTemplate({ + appName: 'Docmost Redis' + }), + // Docmost + { + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "docmost/docmost:0.25", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + { + key: "APP_SECRET", + label: "App Secret", + value: "", + isEnvVar: true, + randomGeneratedIfEmpty: true, + }, + ], + appModel: { + name: "Docmost", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: ``, + useNetworkPolicy: true, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 500, + containerMountPath: '/app/data/storage', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 3000, + }] + }], +}; + + +export const postCreateDocmostAppTemplate = async (createdApps: AppExtendedModel[]): Promise => { + + const createdPostgresApp = createdApps[0]; + const createdRedisApp = createdApps[1]; + const createdDocmostApp = createdApps[2]; + + if (!createdPostgresApp || !createdRedisApp || !createdDocmostApp) { + throw new Error('Created templates for PostgreSQL, Redis or Docmost not found.'); + } + + const redisConnectionInfo = AppTemplateUtils.getDatabaseModelFromApp(createdRedisApp); + const postgresConnectionInfo = AppTemplateUtils.getDatabaseModelFromApp(createdPostgresApp); + + // Update Docmost envVars with correct connection URLs + createdDocmostApp.envVars = `APP_URL=http://localhost:3000 +DATABASE_URL=${postgresConnectionInfo.internalConnectionUrl} +REDIS_URL=${redisConnectionInfo.internalConnectionUrl} +${createdDocmostApp.envVars.split('\n').filter(line => + !line.startsWith('APP_URL=') && + !line.startsWith('DATABASE_URL=') && + !line.startsWith('REDIS_URL=') + ).join('\n')}`; + + return [createdPostgresApp, createdRedisApp, createdDocmostApp]; +}; diff --git a/src/shared/templates/apps/dozzle.template.ts b/src/shared/templates/apps/dozzle.template.ts new file mode 100644 index 0000000..735bcdf --- /dev/null +++ b/src/shared/templates/apps/dozzle.template.ts @@ -0,0 +1,38 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const dozzleAppTemplate: AppTemplateModel = { + name: "Dozzle", + iconName: 'https://raw.githubusercontent.com/amir20/dozzle/master/assets/logo.svg', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "amir20/dozzle:latest", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + ], + appModel: { + name: "Dozzle", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: ``, + useNetworkPolicy: true, + healthCheckPeriodSeconds: 15, + healthCheckTimeoutSeconds: 5, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [], + appFileMounts: [], + appPorts: [{ + port: 8080, + }] + }], +}; diff --git a/src/shared/templates/apps/drawio.template.ts b/src/shared/templates/apps/drawio.template.ts new file mode 100644 index 0000000..f04c695 --- /dev/null +++ b/src/shared/templates/apps/drawio.template.ts @@ -0,0 +1,38 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const drawioAppTemplate: AppTemplateModel = { + name: "draw.io", + iconName: 'https://raw.githubusercontent.com/jgraph/drawio/dev/src/main/webapp/images/drawlogo.svg', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "jgraph/drawio:latest", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + ], + appModel: { + name: "draw.io", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: ``, + useNetworkPolicy: true, + healthCheckPeriodSeconds: 15, + healthCheckTimeoutSeconds: 5, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [], + appFileMounts: [], + appPorts: [{ + port: 8080, + }] + }], +}; diff --git a/src/shared/templates/apps/drone.template.ts b/src/shared/templates/apps/drone.template.ts new file mode 100644 index 0000000..9e2e56d --- /dev/null +++ b/src/shared/templates/apps/drone.template.ts @@ -0,0 +1,61 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const droneAppTemplate: AppTemplateModel = { + name: "Drone CI", + iconName: 'https://cdn.simpleicons.org/drone', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "drone/drone:latest", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + { + key: "DRONE_RPC_SECRET", + label: "RPC Secret", + value: "", + isEnvVar: true, + randomGeneratedIfEmpty: true, + }, + { + key: "DRONE_SERVER_HOST", + label: "Server Host", + value: "localhost", + isEnvVar: true, + randomGeneratedIfEmpty: false, + }, + ], + appModel: { + name: "Drone CI", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: `DRONE_SERVER_PROTO=https +`, + useNetworkPolicy: true, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 500, + containerMountPath: '/data', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 80, + }, { + port: 443, + }] + }], +}; diff --git a/src/shared/templates/apps/duplicati.template.ts b/src/shared/templates/apps/duplicati.template.ts new file mode 100644 index 0000000..d6779ba --- /dev/null +++ b/src/shared/templates/apps/duplicati.template.ts @@ -0,0 +1,65 @@ +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"; + +export const duplicatiAppTemplate: AppTemplateModel = { + name: "Duplicati", + iconName: 'https://avatars.githubusercontent.com/u/2245683?s=200&v=4', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "duplicati/duplicati:latest", + isEnvVar: false, + randomGeneratedIfEmpty: false, + } + ], + appModel: { + name: "Duplicati", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: ``, + useNetworkPolicy: true, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 100, + containerMountPath: '/config', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }, { + size: 800, + containerMountPath: '/backups', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 8200, + }] + }], +}; + + +export const postCreateDuplicatiAppTemplate = async (createdApps: AppExtendedModel[]): Promise => { + const duplicatiApp = createdApps[0]; + + // strong password generator for system user + const encryptionKey = AppTemplateUtils.getRandomKey(32); + duplicatiApp.envVars += `TZ=Europe/London +SETTINGS_ENCRYPTION_KEY=${encryptionKey} +DUPLICATI__WEBSERVICE_ALLOWED_HOSTNAMES=* +`; + return [duplicatiApp]; +}; diff --git a/src/shared/templates/apps/element.template.ts b/src/shared/templates/apps/element.template.ts new file mode 100644 index 0000000..627c2f6 --- /dev/null +++ b/src/shared/templates/apps/element.template.ts @@ -0,0 +1,38 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const elementAppTemplate: AppTemplateModel = { + name: "Element Web", + iconName: 'https://raw.githubusercontent.com/element-hq/element-web/develop/res/vector-icons/1024.png', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "vectorim/element-web:latest", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + ], + appModel: { + name: "Element Web", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: ``, + useNetworkPolicy: true, + healthCheckPeriodSeconds: 15, + healthCheckTimeoutSeconds: 5, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [], + appFileMounts: [], + appPorts: [{ + port: 80, + }] + }], +}; diff --git a/src/shared/templates/apps/filebrowser.template.ts b/src/shared/templates/apps/filebrowser.template.ts new file mode 100644 index 0000000..0372444 --- /dev/null +++ b/src/shared/templates/apps/filebrowser.template.ts @@ -0,0 +1,50 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const filebrowserAppTemplate: AppTemplateModel = { + name: "File Browser", + iconName: 'https://raw.githubusercontent.com/filebrowser/logo/master/banner.svg', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "filebrowser/filebrowser:latest", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + ], + appModel: { + name: "File Browser", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: ``, + useNetworkPolicy: true, + healthCheckPeriodSeconds: 15, + healthCheckTimeoutSeconds: 5, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 5000, + containerMountPath: '/srv', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }, { + size: 50, + containerMountPath: '/database', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 80, + }] + }], +}; diff --git a/src/shared/templates/apps/forgejo.template.ts b/src/shared/templates/apps/forgejo.template.ts new file mode 100644 index 0000000..e4f4e4b --- /dev/null +++ b/src/shared/templates/apps/forgejo.template.ts @@ -0,0 +1,49 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const forgejopAppTemplate: AppTemplateModel = { + name: "Forgejo", + iconName: 'https://codeberg.org/forgejo/forgejo/raw/branch/forgejo/assets/logo.svg', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "codeberg.org/forgejo/forgejo:latest", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + ], + appModel: { + name: "Forgejo", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: `USER_UID=1000 +USER_GID=1000 +FORGEJO__database__DB_TYPE=sqlite3 +`, + useNetworkPolicy: true, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 2000, + containerMountPath: '/data', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 3000, + }, { + port: 22, + }] + }], +}; diff --git a/src/shared/templates/apps/ghost.template.ts b/src/shared/templates/apps/ghost.template.ts new file mode 100644 index 0000000..aa36a2a --- /dev/null +++ b/src/shared/templates/apps/ghost.template.ts @@ -0,0 +1,53 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const ghostAppTemplate: AppTemplateModel = { + name: "Ghost", + iconName: 'https://cdn.simpleicons.org/ghost', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "ghost:latest", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + { + key: "url", + label: "Site URL", + value: "http://localhost:2368", + isEnvVar: true, + randomGeneratedIfEmpty: false, + }, + ], + appModel: { + name: "Ghost", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: `database__client=sqlite3 +database__connection__filename=/var/lib/ghost/content/data/ghost.db +`, + useNetworkPolicy: true, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 1000, + containerMountPath: '/var/lib/ghost/content', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 2368, + }] + }], +}; diff --git a/src/shared/templates/apps/gitea.template.ts b/src/shared/templates/apps/gitea.template.ts new file mode 100644 index 0000000..de74f1f --- /dev/null +++ b/src/shared/templates/apps/gitea.template.ts @@ -0,0 +1,49 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const giteaAppTemplate: AppTemplateModel = { + name: "Gitea", + iconName: 'https://raw.githubusercontent.com/go-gitea/gitea/main/assets/logo.svg', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "gitea/gitea:latest", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + ], + appModel: { + name: "Gitea", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: `USER_UID=1000 +USER_GID=1000 +GITEA__database__DB_TYPE=sqlite3 +`, + useNetworkPolicy: true, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 2000, + containerMountPath: '/data', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 3000, + }, { + port: 22, + }] + }], +}; diff --git a/src/shared/templates/apps/grafana.template.ts b/src/shared/templates/apps/grafana.template.ts new file mode 100644 index 0000000..f5a812a --- /dev/null +++ b/src/shared/templates/apps/grafana.template.ts @@ -0,0 +1,58 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const grafanaAppTemplate: AppTemplateModel = { + name: "Grafana", + iconName: 'https://raw.githubusercontent.com/grafana/grafana/main/public/img/grafana_icon.svg', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "grafana/grafana:latest", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + { + key: "GF_SECURITY_ADMIN_USER", + label: "Admin Username", + value: "admin", + isEnvVar: true, + randomGeneratedIfEmpty: false, + }, + { + key: "GF_SECURITY_ADMIN_PASSWORD", + label: "Admin Password", + value: "", + isEnvVar: true, + randomGeneratedIfEmpty: true, + }, + ], + appModel: { + name: "Grafana", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: ``, + useNetworkPolicy: true, + healthCheckPeriodSeconds: 15, + healthCheckTimeoutSeconds: 5, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 200, + containerMountPath: '/var/lib/grafana', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 3000, + }] + }], +}; diff --git a/src/shared/templates/apps/harbor.template.ts b/src/shared/templates/apps/harbor.template.ts new file mode 100644 index 0000000..7612882 --- /dev/null +++ b/src/shared/templates/apps/harbor.template.ts @@ -0,0 +1,51 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const harborAppTemplate: AppTemplateModel = { + name: "Harbor Registry", + iconName: 'https://raw.githubusercontent.com/goharbor/harbor/main/src/portal/src/images/harbor-logo.svg', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "goharbor/harbor-core:v2", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + { + key: "HARBOR_ADMIN_PASSWORD", + label: "Admin Password", + value: "", + isEnvVar: true, + randomGeneratedIfEmpty: true, + }, + ], + appModel: { + name: "Harbor Registry", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: ``, + useNetworkPolicy: true, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 5000, + containerMountPath: '/data', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 8080, + }] + }], +}; diff --git a/src/shared/templates/apps/hedgedoc.template.ts b/src/shared/templates/apps/hedgedoc.template.ts new file mode 100644 index 0000000..8516064 --- /dev/null +++ b/src/shared/templates/apps/hedgedoc.template.ts @@ -0,0 +1,54 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const hedgedocAppTemplate: AppTemplateModel = { + name: "HedgeDoc", + iconName: 'https://raw.githubusercontent.com/hedgedoc/hedgedoc/master/public/icons/android-chrome-512x512.png', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "quay.io/hedgedoc/hedgedoc:latest", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + { + key: "CMD_SESSION_SECRET", + label: "Session Secret", + value: "", + isEnvVar: true, + randomGeneratedIfEmpty: true, + }, + ], + appModel: { + name: "HedgeDoc", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: `CMD_DB_URL=sqlite:///hedgedoc/database.sqlite +CMD_ALLOW_ANONYMOUS=false +CMD_ALLOW_ANONYMOUS_EDITS=true +`, + useNetworkPolicy: true, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 500, + containerMountPath: '/hedgedoc', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 3000, + }] + }], +}; diff --git a/src/shared/templates/apps/homeassistant.template.ts b/src/shared/templates/apps/homeassistant.template.ts new file mode 100644 index 0000000..8bb2293 --- /dev/null +++ b/src/shared/templates/apps/homeassistant.template.ts @@ -0,0 +1,45 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const homeassistantAppTemplate: AppTemplateModel = { + name: "Home Assistant", + iconName: 'https://cdn.simpleicons.org/homeassistant', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "homeassistant/home-assistant:stable", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + ], + appModel: { + name: "Home Assistant", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: `TZ=Europe/Zurich +`, + useNetworkPolicy: true, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 1000, + containerMountPath: '/config', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 8123, + }] + }], +}; diff --git a/src/shared/templates/apps/huginn.template.ts b/src/shared/templates/apps/huginn.template.ts new file mode 100644 index 0000000..13610f8 --- /dev/null +++ b/src/shared/templates/apps/huginn.template.ts @@ -0,0 +1,59 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const huginnAppTemplate: AppTemplateModel = { + name: "Huginn", + iconName: 'https://raw.githubusercontent.com/huginn/huginn/master/public/favicon.ico', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "ghcr.io/huginn/huginn:latest", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + { + key: "INVITATION_CODE", + label: "Invitation Code", + value: "", + isEnvVar: true, + randomGeneratedIfEmpty: true, + }, + { + key: "APP_SECRET_TOKEN", + label: "Secret Token", + value: "", + isEnvVar: true, + randomGeneratedIfEmpty: true, + }, + ], + appModel: { + name: "Huginn", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: `TIMEZONE=Europe/Zurich +`, + useNetworkPolicy: true, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 500, + containerMountPath: '/var/lib/mysql', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 3000, + }] + }], +}; diff --git a/src/shared/templates/apps/immich.template.ts b/src/shared/templates/apps/immich.template.ts new file mode 100644 index 0000000..5c40165 --- /dev/null +++ b/src/shared/templates/apps/immich.template.ts @@ -0,0 +1,55 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const immichAppTemplate: AppTemplateModel = { + name: "Immich", + iconName: 'https://raw.githubusercontent.com/immich-app/immich/main/design/immich-logo.svg', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "ghcr.io/immich-app/immich-server:release", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + { + key: "DB_PASSWORD", + label: "Database Password", + value: "", + isEnvVar: true, + randomGeneratedIfEmpty: true, + }, + ], + appModel: { + name: "Immich", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: `DB_HOSTNAME=immich_postgres +DB_USERNAME=postgres +DB_DATABASE_NAME=immich +REDIS_HOSTNAME=immich_redis +`, + useNetworkPolicy: true, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 10000, + containerMountPath: '/usr/src/app/upload', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 2283, + }] + }], +}; diff --git a/src/shared/templates/apps/jellyfin.template.ts b/src/shared/templates/apps/jellyfin.template.ts new file mode 100644 index 0000000..509e0b9 --- /dev/null +++ b/src/shared/templates/apps/jellyfin.template.ts @@ -0,0 +1,50 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const jellyfinAppTemplate: AppTemplateModel = { + name: "Jellyfin", + iconName: 'https://raw.githubusercontent.com/jellyfin/jellyfin-ux/master/branding/SVG/icon-transparent.svg', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "jellyfin/jellyfin:latest", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + ], + appModel: { + name: "Jellyfin", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: ``, + useNetworkPolicy: true, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 200, + containerMountPath: '/config', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }, { + size: 10000, + containerMountPath: '/media', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 8096, + }] + }], +}; diff --git a/src/shared/templates/apps/jenkins.template.ts b/src/shared/templates/apps/jenkins.template.ts new file mode 100644 index 0000000..9991704 --- /dev/null +++ b/src/shared/templates/apps/jenkins.template.ts @@ -0,0 +1,47 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const jenkinsAppTemplate: AppTemplateModel = { + name: "Jenkins", + iconName: 'https://www.jenkins.io/images/logos/jenkins/jenkins.svg', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "jenkins/jenkins:lts", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + ], + appModel: { + name: "Jenkins", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: `JAVA_OPTS=-Djenkins.install.runSetupWizard=true +`, + useNetworkPolicy: true, + healthCheckPeriodSeconds: 60, + healthCheckTimeoutSeconds: 30, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 2000, + containerMountPath: '/var/jenkins_home', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 8080, + }, { + port: 50000, + }] + }], +}; diff --git a/src/shared/templates/apps/libredesk.template.ts b/src/shared/templates/apps/libredesk.template.ts new file mode 100644 index 0000000..70ca5f8 --- /dev/null +++ b/src/shared/templates/apps/libredesk.template.ts @@ -0,0 +1,221 @@ +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 { randomBytes } from "crypto"; +import { Prisma } from "@prisma/client"; +import { getPostgresAppTemplate } from "../databases/postgres.template"; +import { getRedisAppTemplate } from "../databases/redis.template"; + +export const libredeskAppTemplate: AppTemplateModel = { + name: "Libredesk", + iconName: 'https://libredesk.io/apple-touch-icon.png', + templates: [ + // PostgreSQL Database + getPostgresAppTemplate({ + appName: 'Libredesk PostgreSQL', + dbName: 'libredesk', + dbUsername: 'libredesk' + }), + // Redis + getRedisAppTemplate({ + appName: 'Libredesk Redis' + }), + // LibreDesk App + { + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "libredesk/libredesk:v1.0.1", + isEnvVar: false, + randomGeneratedIfEmpty: false, + } + ], + appModel: { + name: "Libredesk", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + containerCommand: 'sh', + containerArgs: '["-c", "./libredesk --install --idempotent-install --yes --config /libredesk/config.toml && ./libredesk --upgrade --yes --config /libredesk/config.toml && ./libredesk --config /libredesk/config.toml"]', + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: ``, + useNetworkPolicy: true, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 500, + containerMountPath: '/libredesk/uploads', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 9000, + }] + } + ] +}; + +export const postCreateLibredeskAppTemplate = async (createdApps: AppExtendedModel[]): Promise => { + const postgresApp = createdApps[0]; + const redisApp = createdApps[1]; + const libredeskApp = createdApps[2]; + + if (!postgresApp || !redisApp || !libredeskApp) { + throw new Error('Created templates for LibreDesk (PostgreSQL, Redis, or App) not found.'); + } + + // Extract PostgreSQL credentials from environment variables + const dbModelOfProstgres = AppTemplateUtils.getDatabaseModelFromApp(postgresApp); + const dbModelOfRedis = AppTemplateUtils.getDatabaseModelFromApp(redisApp); + + // inspired by https://github.com/easypanel-io/templates/blob/main/templates/libredesk/index.ts + const encryptionKey = randomBytes(16).toString('hex'); // 16 bytes = 32 hex characters + const configToml = `[app] +# Log level: info, debug, warn, error, fatal +log_level = "debug" +# Environment: dev, prod. +# Setting to "dev" will enable color logging in terminal. +env = "prod" +# Whether to automatically check for application updates on start up, app updates are shown as a banner in the admin panel. +check_updates = true +# Encryption key. Generate using \`openssl rand -hex 16\` must be 32 characters long. +encryption_key = "${encryptionKey}" + +# HTTP server. +[app.server] +# Address to bind the HTTP server to. +address = "0.0.0.0:9000" +# Unix socket path (leave empty to use TCP address instead) +socket = "" +# Do NOT disable secure cookies in production environment if you don't know exactly what you're doing! +disable_secure_cookies = true +# Request read and write timeouts. +read_timeout = "5s" +write_timeout = "5s" +# Maximum request body size in bytes (100MB) +# If you are using proxy, you may need to configure them to allow larger request bodies. +max_body_size = 104857600 +# Size of the read buffer for incoming requests +read_buffer_size = 4096 +# Keepalive settings. +keepalive_timeout = "10s" + +# File upload provider to use, either fs or s3. +[upload] +provider = "fs" + +# Filesystem provider. +[upload.fs] +# Directory where uploaded files are stored, make sure this directory exists and is writable by the application. +upload_path = "uploads" + +# S3 provider. +[upload.s3] +# S3 endpoint URL (required only for non-AWS S3-compatible providers like MinIO). +# Leave empty to use default AWS endpoints. +url = "" + +# AWS S3 credentials, keep empty to use attached IAM roles. +access_key = "" +secret_key = "" + +# AWS region, e.g., "us-east-1", "eu-west-1", etc. +region = "ap-south-1" +# S3 bucket name where files will be stored. +bucket = "bucket-name" +# Optional prefix path within the S3 bucket where files will be stored. +# Example, if set to "uploads/media", files will be stored under that path. +# Useful for organizing files inside a shared bucket. +bucket_path = "" +# S3 signed URL expiry duration (e.g., "30m", "1h") +expiry = "30m" + +# Postgres. +[db] +# If running locally, use localhost. +host = "${dbModelOfProstgres.hostname}" +# Database port, default is 5432. +port = ${dbModelOfProstgres.port} +# Update the following values with your database credentials. +user = "${dbModelOfProstgres.username}" +password = "${dbModelOfProstgres.password}" +database = "${dbModelOfProstgres.databaseName}" +ssl_mode = "disable" +# Maximum number of open database connections +max_open = 30 +# Maximum number of idle connections in the pool +max_idle = 30 +# Maximum time a connection can be reused before being closed +max_lifetime = "300s" + +# Redis. +[redis] +# If running locally, use localhost:6379. +address = "${dbModelOfRedis.hostname}:${dbModelOfRedis.port}" +password = "${dbModelOfRedis.password}" +db = 0 + +[message] +# Number of workers processing outgoing message queue +outgoing_queue_workers = 10 +# Number of workers processing incoming message queue +incoming_queue_workers = 10 +# How often to scan for outgoing messages to process, keep it low to process messages quickly. +message_outgoing_scan_interval = "50ms" +# Maximum number of messages that can be queued for incoming processing +incoming_queue_size = 5000 +# Maximum number of messages that can be queued for outgoing processing +outgoing_queue_size = 5000 + +[notification] +# Number of concurrent notification workers +concurrency = 2 +# Maximum number of notifications that can be queued +queue_size = 2000 + +[automation] +# Number of workers processing automation rules +worker_count = 10 + +[autoassigner] +# How often to run automatic conversation assignment +autoassign_interval = "5m" + +[webhook] +# Number of webhook delivery workers +workers = 5 +# Maximum number of webhook deliveries that can be queued +queue_size = 10000 +# HTTP timeout for webhook requests +timeout = "15s" + +[conversation] +# How often to check for conversations to unsnooze +unsnooze_interval = "5m" + +[sla] +# How often to evaluate SLA compliance for conversations +evaluation_interval = "5m"`; + + const fileMount: Prisma.AppFileMountUncheckedCreateInput = { + containerMountPath: '/libredesk/config.toml', + content: configToml, + appId: libredeskApp.id, + }; + libredeskApp.appFileMounts.push(fileMount as any); + + // strong password generator for system user + const systemUserPassword = AppTemplateUtils.generateStrongPasswort(52); + libredeskApp.envVars += `LIBREDESK_SYSTEM_USER_PASSWORD=${systemUserPassword} +`; + return [postgresApp, redisApp, libredeskApp]; +}; diff --git a/src/shared/templates/apps/mattermost.template.ts b/src/shared/templates/apps/mattermost.template.ts new file mode 100644 index 0000000..e9071ca --- /dev/null +++ b/src/shared/templates/apps/mattermost.template.ts @@ -0,0 +1,52 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const mattermostAppTemplate: AppTemplateModel = { + name: "Mattermost", + iconName: 'https://raw.githubusercontent.com/mattermost/mattermost/master/webapp/channels/src/images/logo.png', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "mattermost/mattermost-team-edition:latest", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + ], + appModel: { + name: "Mattermost", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: `TZ=Europe/Zurich +MM_SQLSETTINGS_DRIVERNAME=postgres +`, + useNetworkPolicy: true, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 2000, + containerMountPath: '/mattermost/data', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }, { + size: 200, + containerMountPath: '/mattermost/config', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 8065, + }] + }], +}; diff --git a/src/shared/templates/apps/minio.template.ts b/src/shared/templates/apps/minio.template.ts new file mode 100644 index 0000000..1ecfa2a --- /dev/null +++ b/src/shared/templates/apps/minio.template.ts @@ -0,0 +1,62 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const minioAppTemplate: AppTemplateModel = { + name: "MinIO", + iconName: 'https://raw.githubusercontent.com/minio/minio/master/.github/logo.svg', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "minio/minio:latest", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + { + key: "MINIO_ROOT_USER", + label: "Root Username", + value: "minioadmin", + isEnvVar: true, + randomGeneratedIfEmpty: false, + }, + { + key: "MINIO_ROOT_PASSWORD", + label: "Root Password", + value: "", + isEnvVar: true, + randomGeneratedIfEmpty: true, + }, + ], + appModel: { + name: "MinIO", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: ``, + useNetworkPolicy: true, + healthCheckPeriodSeconds: 15, + healthCheckTimeoutSeconds: 5, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + containerCommand: 'minio', + containerArgs: '["server", "/data", "--console-address", ":9001"]', + }, + appDomains: [], + appVolumes: [{ + size: 5000, + containerMountPath: '/data', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 9000, + }, { + port: 9001, + }] + }], +}; diff --git a/src/shared/templates/apps/n8n.template.ts b/src/shared/templates/apps/n8n.template.ts new file mode 100644 index 0000000..373c3fb --- /dev/null +++ b/src/shared/templates/apps/n8n.template.ts @@ -0,0 +1,55 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const n8nAppTemplate: AppTemplateModel = { + name: "n8n", + iconName: 'https://avatars.githubusercontent.com/u/45487711', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "n8nio/n8n:latest", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + { + key: "N8N_ENCRYPTION_KEY", + label: "Encryption Key", + value: "", + isEnvVar: true, + randomGeneratedIfEmpty: true, + }, + ], + appModel: { + name: "n8n", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: `GENERIC_TIMEZONE=Europe/Zurich +TZ=Europe/Zurich +`, + useNetworkPolicy: true, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 500, + containerMountPath: '/home/node/.n8n', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 5678, + }] + }], +}; + +// todo set the permissions of the volume chown to the n8n user diff --git a/src/shared/templates/apps/navidrome.template.ts b/src/shared/templates/apps/navidrome.template.ts new file mode 100644 index 0000000..b0dfd2e --- /dev/null +++ b/src/shared/templates/apps/navidrome.template.ts @@ -0,0 +1,53 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const navidiomeAppTemplate: AppTemplateModel = { + name: "Navidrome", + iconName: 'https://raw.githubusercontent.com/navidrome/navidrome/master/resources/logo-192x192.png', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "deluan/navidrome:latest", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + ], + appModel: { + name: "Navidrome", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: `ND_SCANSCHEDULE=1h +ND_LOGLEVEL=info +ND_SESSIONTIMEOUT=24h +`, + useNetworkPolicy: true, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 100, + containerMountPath: '/data', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }, { + size: 5000, + containerMountPath: '/music', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 4533, + }] + }], +}; diff --git a/src/shared/templates/apps/nextcloud.template.ts b/src/shared/templates/apps/nextcloud.template.ts new file mode 100644 index 0000000..e8838b1 --- /dev/null +++ b/src/shared/templates/apps/nextcloud.template.ts @@ -0,0 +1,59 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const nextcloudAppTemplate: AppTemplateModel = { + name: "Nextcloud", + iconName: 'https://avatars.githubusercontent.com/u/19211038', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "nextcloud:stable", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + { + key: "NEXTCLOUD_ADMIN_USER", + label: "Admin Username", + value: "admin", + isEnvVar: true, + randomGeneratedIfEmpty: false, + }, + { + key: "NEXTCLOUD_ADMIN_PASSWORD", + label: "Admin Password", + value: "", + isEnvVar: true, + randomGeneratedIfEmpty: true, + }, + ], + appModel: { + name: "Nextcloud", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: `SQLITE_DATABASE=nextcloud +`, + useNetworkPolicy: true, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 5000, + containerMountPath: '/var/www/html', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 80, + }] + }], +}; diff --git a/src/shared/templates/apps/nginx.template.ts b/src/shared/templates/apps/nginx.template.ts new file mode 100644 index 0000000..77850bf --- /dev/null +++ b/src/shared/templates/apps/nginx.template.ts @@ -0,0 +1,38 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const nginxAppTemplate: AppTemplateModel = { + name: "NGINX", + iconName: 'https://cdn.simpleicons.org/nginx', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "nginx:alpine", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + ], + appModel: { + name: "NGINX", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: ``, + useNetworkPolicy: true, + healthCheckPeriodSeconds: 15, + healthCheckTimeoutSeconds: 5, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [], + appFileMounts: [], + appPorts: [{ + port: 80, + }] + }], +}; diff --git a/src/shared/templates/apps/nodered.template.ts b/src/shared/templates/apps/nodered.template.ts new file mode 100644 index 0000000..34b007d --- /dev/null +++ b/src/shared/templates/apps/nodered.template.ts @@ -0,0 +1,45 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const noderedAppTemplate: AppTemplateModel = { + name: "Node-RED", + iconName: 'https://nodered.org/about/resources/media/node-red-icon-2.svg', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "nodered/node-red:latest", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + ], + appModel: { + name: "Node-RED", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: `TZ=Europe/Zurich +`, + useNetworkPolicy: true, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 200, + containerMountPath: '/data', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 1880, + }] + }], +}; diff --git a/src/shared/templates/apps/openwebui.template.ts b/src/shared/templates/apps/openwebui.template.ts new file mode 100644 index 0000000..9d692d3 --- /dev/null +++ b/src/shared/templates/apps/openwebui.template.ts @@ -0,0 +1,112 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; +import { AppExtendedModel } from "@/shared/model/app-extended.model"; +import { KubeObjectNameUtils } from "@/server/utils/kube-object-name.utils"; + +export const openwebuiAppTemplate: AppTemplateModel = { + name: "Open WebUI", + iconName: 'https://avatars.githubusercontent.com/u/158137808', + templates: [{ + // Ollama Backend + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "ollama/ollama:latest", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + ], + appModel: { + name: "Ollama", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: `OLLAMA_HOST=0.0.0.0 +OLLAMA_ORIGINS=* +`, + useNetworkPolicy: true, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 10000, + containerMountPath: '/root/.ollama', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 11434, + }] + }, + // Open WebUI Frontend + { + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "ghcr.io/open-webui/open-webui:main", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + { + key: "WEBUI_SECRET_KEY", + label: "Secret Key", + value: "", + isEnvVar: true, + randomGeneratedIfEmpty: true, + }, + ], + appModel: { + name: "Open WebUI", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: ``, + useNetworkPolicy: true, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 2000, + containerMountPath: '/app/backend/data', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 8080, + }] + }] +} + + +export const postCreateOpenwebuiAppTemplate = async (createdApps: AppExtendedModel[]): Promise => { + + const createdOllamaApp = createdApps[0]; + const createdWebuiApp = createdApps[1]; + + if (!createdOllamaApp || !createdWebuiApp) { + throw new Error('Created templates for Ollama or Open WebUI not found.'); + } + + const ollamaAppInternalHostname = KubeObjectNameUtils.toServiceName(createdOllamaApp.id); + const webUiInternalHostname = KubeObjectNameUtils.toServiceName(createdWebuiApp.id); + + createdWebuiApp.envVars += `OLLAMA_BASE_URLS=http://${ollamaAppInternalHostname}:11434`; + + return [createdOllamaApp, createdWebuiApp] +}; \ No newline at end of file diff --git a/src/shared/templates/apps/outline.template.ts b/src/shared/templates/apps/outline.template.ts new file mode 100644 index 0000000..fbfce77 --- /dev/null +++ b/src/shared/templates/apps/outline.template.ts @@ -0,0 +1,60 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const outlineAppTemplate: AppTemplateModel = { + name: "Outline", + iconName: 'https://cdn.simpleicons.org/outline', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "outlinewiki/outline:latest", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + { + key: "SECRET_KEY", + label: "Secret Key", + value: "", + isEnvVar: true, + randomGeneratedIfEmpty: true, + }, + { + key: "UTILS_SECRET", + label: "Utils Secret", + value: "", + isEnvVar: true, + randomGeneratedIfEmpty: true, + }, + ], + appModel: { + name: "Outline", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: `NODE_ENV=production +FORCE_HTTPS=false +`, + useNetworkPolicy: true, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 500, + containerMountPath: '/var/lib/outline/data', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 3000, + }] + }], +}; diff --git a/src/shared/templates/apps/photoprism.template.ts b/src/shared/templates/apps/photoprism.template.ts new file mode 100644 index 0000000..68cc316 --- /dev/null +++ b/src/shared/templates/apps/photoprism.template.ts @@ -0,0 +1,61 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const photoprismAppTemplate: AppTemplateModel = { + name: "PhotoPrism", + iconName: 'https://raw.githubusercontent.com/photoprism/photoprism/develop/assets/static/icons/logo.svg', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "photoprism/photoprism:latest", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + { + key: "PHOTOPRISM_ADMIN_PASSWORD", + label: "Admin Password", + value: "", + isEnvVar: true, + randomGeneratedIfEmpty: true, + }, + ], + appModel: { + name: "PhotoPrism", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: `PHOTOPRISM_UPLOAD_NSFW=true +PHOTOPRISM_DETECT_NSFW=false +PHOTOPRISM_EXPERIMENTAL=false +PHOTOPRISM_DATABASE_DRIVER=sqlite +`, + useNetworkPolicy: true, + healthCheckPeriodSeconds: 60, + healthCheckTimeoutSeconds: 30, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 500, + containerMountPath: '/photoprism/storage', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }, { + size: 10000, + containerMountPath: '/photoprism/originals', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 2342, + }] + }], +}; diff --git a/src/shared/templates/apps/plausible.template.ts b/src/shared/templates/apps/plausible.template.ts new file mode 100644 index 0000000..40b6b4c --- /dev/null +++ b/src/shared/templates/apps/plausible.template.ts @@ -0,0 +1,59 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const plausibleAppTemplate: AppTemplateModel = { + name: "Plausible Analytics", + iconName: 'https://plausible.io/assets/images/icon/plausible_logo.svg', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "ghcr.io/plausible/community-edition:v2", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + { + key: "SECRET_KEY_BASE", + label: "Secret Key Base", + value: "", + isEnvVar: true, + randomGeneratedIfEmpty: true, + }, + { + key: "BASE_URL", + label: "Base URL", + value: "http://localhost:8000", + isEnvVar: true, + randomGeneratedIfEmpty: false, + }, + ], + appModel: { + name: "Plausible Analytics", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: `DISABLE_REGISTRATION=invite_only +`, + useNetworkPolicy: true, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 500, + containerMountPath: '/var/lib/plausible', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 8000, + }] + }], +}; diff --git a/src/shared/templates/apps/prometheus.template.ts b/src/shared/templates/apps/prometheus.template.ts new file mode 100644 index 0000000..b3b852b --- /dev/null +++ b/src/shared/templates/apps/prometheus.template.ts @@ -0,0 +1,44 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const prometheusAppTemplate: AppTemplateModel = { + name: "Prometheus", + iconName: 'https://raw.githubusercontent.com/prometheus/prometheus/main/documentation/images/prometheus-logo.svg', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "prom/prometheus:latest", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + ], + appModel: { + name: "Prometheus", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: ``, + useNetworkPolicy: true, + healthCheckPeriodSeconds: 15, + healthCheckTimeoutSeconds: 5, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 500, + containerMountPath: '/prometheus', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 9090, + }] + }], +}; diff --git a/src/shared/templates/apps/rocketchat.template.ts b/src/shared/templates/apps/rocketchat.template.ts new file mode 100644 index 0000000..2a01413 --- /dev/null +++ b/src/shared/templates/apps/rocketchat.template.ts @@ -0,0 +1,53 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const rocketchatAppTemplate: AppTemplateModel = { + name: "Rocket.Chat", + iconName: 'https://raw.githubusercontent.com/RocketChat/Rocket.Chat.Artwork/master/Logos/icon.svg', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "rocket.chat:latest", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + { + key: "ROOT_URL", + label: "Root URL", + value: "http://localhost:3000", + isEnvVar: true, + randomGeneratedIfEmpty: false, + }, + ], + appModel: { + name: "Rocket.Chat", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: `PORT=3000 +DEPLOY_METHOD=docker +`, + useNetworkPolicy: true, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 1000, + containerMountPath: '/app/uploads', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 3000, + }] + }], +}; diff --git a/src/shared/templates/apps/sonarqube.template.ts b/src/shared/templates/apps/sonarqube.template.ts new file mode 100644 index 0000000..43f244d --- /dev/null +++ b/src/shared/templates/apps/sonarqube.template.ts @@ -0,0 +1,51 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const sonarqubeAppTemplate: AppTemplateModel = { + name: "SonarQube", + iconName: 'https://avatars.githubusercontent.com/u/54465?s=200&v=4', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "sonarqube:community", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + ], + appModel: { + name: "SonarQube", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: `SONAR_ES_BOOTSTRAP_CHECKS_DISABLE=true +`, + useNetworkPolicy: true, + healthCheckPeriodSeconds: 60, + healthCheckTimeoutSeconds: 30, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 1000, + containerMountPath: '/opt/sonarqube/data', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }, { + size: 200, + containerMountPath: '/opt/sonarqube/extensions', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 9000, + }] + }], +}; diff --git a/src/shared/templates/apps/tika.template.ts b/src/shared/templates/apps/tika.template.ts new file mode 100644 index 0000000..47a2777 --- /dev/null +++ b/src/shared/templates/apps/tika.template.ts @@ -0,0 +1,38 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const tikaAppTemplate: AppTemplateModel = { + name: "Apache Tika", + iconName: 'https://tika.apache.org/tika.png', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "apache/tika:latest-full", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + ], + appModel: { + name: "Apache Tika", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: ``, + useNetworkPolicy: true, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [], + appFileMounts: [], + appPorts: [{ + port: 9998, + }] + }], +}; diff --git a/src/shared/templates/apps/uptimekuma.template.ts b/src/shared/templates/apps/uptimekuma.template.ts new file mode 100644 index 0000000..d1996bc --- /dev/null +++ b/src/shared/templates/apps/uptimekuma.template.ts @@ -0,0 +1,44 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const uptimekumaAppTemplate: AppTemplateModel = { + name: "Uptime Kuma", + iconName: 'https://raw.githubusercontent.com/louislam/uptime-kuma/master/public/icon.svg', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "louislam/uptime-kuma:latest", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + ], + appModel: { + name: "Uptime Kuma", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: ``, + useNetworkPolicy: true, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 200, + containerMountPath: '/app/data', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 3001, + }] + }], +}; diff --git a/src/shared/templates/apps/vaultwarden.template.ts b/src/shared/templates/apps/vaultwarden.template.ts new file mode 100644 index 0000000..4bf3735 --- /dev/null +++ b/src/shared/templates/apps/vaultwarden.template.ts @@ -0,0 +1,53 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const vaultwardenAppTemplate: AppTemplateModel = { + name: "Vaultwarden", + iconName: 'https://raw.githubusercontent.com/dani-garcia/vaultwarden/main/resources/vaultwarden-icon.svg', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "vaultwarden/server:latest", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + { + key: "ADMIN_TOKEN", + label: "Admin Token", + value: "", + isEnvVar: true, + randomGeneratedIfEmpty: true, + }, + ], + appModel: { + name: "Vaultwarden", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: `SIGNUPS_ALLOWED=true +WEBSOCKET_ENABLED=true +`, + useNetworkPolicy: true, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 200, + containerMountPath: '/data', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 80, + }] + }], +}; diff --git a/src/shared/templates/apps/wikijs.template.ts b/src/shared/templates/apps/wikijs.template.ts new file mode 100644 index 0000000..d2c80d5 --- /dev/null +++ b/src/shared/templates/apps/wikijs.template.ts @@ -0,0 +1,46 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateModel } from "../../model/app-template.model"; + +export const wikijsAppTemplate: AppTemplateModel = { + name: "Wiki.js", + iconName: 'https://cdn.simpleicons.org/wiki.js', + templates: [{ + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "ghcr.io/requarks/wiki:2", + isEnvVar: false, + randomGeneratedIfEmpty: false, + }, + ], + appModel: { + name: "Wiki.js", + appType: 'APP', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + envVars: `DB_TYPE=sqlite +DB_FILEPATH=/wiki/data/database.sqlite +`, + useNetworkPolicy: true, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + }, + appDomains: [], + appVolumes: [{ + size: 500, + containerMountPath: '/wiki/data', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 3000, + }] + }], +}; diff --git a/src/shared/templates/apps/wordpress.template.ts b/src/shared/templates/apps/wordpress.template.ts index 7907a68..dd836a8 100644 --- a/src/shared/templates/apps/wordpress.template.ts +++ b/src/shared/templates/apps/wordpress.template.ts @@ -44,7 +44,7 @@ MYSQL_USER=wordpress useNetworkPolicy: true, healthCheckPeriodSeconds: 15, healthCheckTimeoutSeconds: 5, - healthCheckFailureThreshold: 3, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, }, appDomains: [], appVolumes: [{ @@ -85,9 +85,9 @@ WORDPRESS_DB_PASSWORD={password} WORDPRESS_TABLE_PREFIX=wp_ `, useNetworkPolicy: true, - healthCheckPeriodSeconds: 30, - healthCheckTimeoutSeconds: 10, - healthCheckFailureThreshold: 3, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: Constants.DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, }, appDomains: [], appVolumes: [{ diff --git a/src/shared/templates/databases/mariadb.template.ts b/src/shared/templates/databases/mariadb.template.ts index 79ee5da..f99f581 100644 --- a/src/shared/templates/databases/mariadb.template.ts +++ b/src/shared/templates/databases/mariadb.template.ts @@ -1,10 +1,14 @@ import { Constants } from "@/shared/utils/constants"; -import { AppTemplateModel } from "../../model/app-template.model"; +import { AppTemplateContentModel, AppTemplateModel } from "../../model/app-template.model"; -export const mariadbAppTemplate: AppTemplateModel = { - name: "MariaDB", - iconName: 'mariadb.svg', - templates: [{ +export function getMariadbAppTemplate(config?: { + appName?: string, + dbName?: string, + dbUsername?: string, + dbPassword?: string, + rootPassword?: string +}): AppTemplateContentModel { + return { inputSettings: [ { key: "containerImageSource", @@ -16,34 +20,34 @@ export const mariadbAppTemplate: AppTemplateModel = { { key: "MYSQL_DATABASE", label: "Database Name", - value: "mariadb", + value: config?.dbName || "mariadb", isEnvVar: true, randomGeneratedIfEmpty: false, }, { key: "MYSQL_USER", label: "Database User", - value: "mariadbuser", + value: config?.dbUsername || "mariadbuser", isEnvVar: true, randomGeneratedIfEmpty: false, }, { key: "MYSQL_PASSWORD", label: "Database Passwort", - value: "", + value: config?.dbPassword || "", isEnvVar: true, randomGeneratedIfEmpty: true, }, { key: "MYSQL_ROOT_PASSWORD", label: "Root Password", - value: "", + value: config?.rootPassword || "", isEnvVar: true, randomGeneratedIfEmpty: true, }, ], appModel: { - name: "MariaDb", + name: config?.appName || "MariaDB", appType: 'MARIADB', sourceType: 'CONTAINER', containerImageSource: "", @@ -54,7 +58,7 @@ export const mariadbAppTemplate: AppTemplateModel = { useNetworkPolicy: true, healthCheckPeriodSeconds: 15, healthCheckTimeoutSeconds: 5, - healthCheckFailureThreshold: 3, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, }, appDomains: [], appVolumes: [{ @@ -68,5 +72,13 @@ export const mariadbAppTemplate: AppTemplateModel = { appPorts: [{ port: 3306, }] - }] -} \ No newline at end of file + }; +} + +export const mariadbAppTemplate: AppTemplateModel = { + name: "MariaDB", + iconName: 'mariadb.svg', + templates: [ + getMariadbAppTemplate() + ] +}; \ No newline at end of file diff --git a/src/shared/templates/databases/mongodb.template.ts b/src/shared/templates/databases/mongodb.template.ts index f3d6d27..a9a59dc 100644 --- a/src/shared/templates/databases/mongodb.template.ts +++ b/src/shared/templates/databases/mongodb.template.ts @@ -1,10 +1,13 @@ import { Constants } from "@/shared/utils/constants"; -import { AppTemplateModel } from "../../model/app-template.model"; +import { AppTemplateContentModel, AppTemplateModel } from "../../model/app-template.model"; -export const mongodbAppTemplate: AppTemplateModel = { - name: "MongoDB", - iconName: 'mongodb.svg', - templates: [{ +export function getMongodbAppTemplate(config?: { + appName?: string, + dbName?: string, + dbUsername?: string, + dbPassword?: string +}): AppTemplateContentModel { + return { inputSettings: [ { key: "containerImageSource", @@ -16,38 +19,38 @@ export const mongodbAppTemplate: AppTemplateModel = { { key: "MONGO_INITDB_DATABASE", label: "Database Name", - value: "mongodb", + value: config?.dbName || "mongodb", isEnvVar: true, randomGeneratedIfEmpty: false, }, { key: "MONGO_INITDB_ROOT_USERNAME", label: "Username", - value: "mongodbuser", + value: config?.dbUsername || "mongodbuser", isEnvVar: true, randomGeneratedIfEmpty: false, }, { key: "MONGO_INITDB_ROOT_PASSWORD", label: "Password", - value: "", + value: config?.dbPassword || "", isEnvVar: true, randomGeneratedIfEmpty: true, }, ], appModel: { - name: "MongoDB", + name: config?.appName || "MongoDB", appType: 'MONGODB', sourceType: 'CONTAINER', containerImageSource: "", ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_DATABASES, egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_DATABASES, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, replicas: 1, envVars: ``, useNetworkPolicy: true, healthCheckPeriodSeconds: 15, healthCheckTimeoutSeconds: 5, - healthCheckFailureThreshold: 3, }, appDomains: [], appVolumes: [{ @@ -61,5 +64,13 @@ export const mongodbAppTemplate: AppTemplateModel = { appPorts: [{ port: 27017, }] - }], + }; +} + +export const mongodbAppTemplate: AppTemplateModel = { + name: "MongoDB", + iconName: 'mongodb.svg', + templates: [ + getMongodbAppTemplate() + ], }; \ No newline at end of file diff --git a/src/shared/templates/databases/mysql.template.ts b/src/shared/templates/databases/mysql.template.ts index 184173f..e924150 100644 --- a/src/shared/templates/databases/mysql.template.ts +++ b/src/shared/templates/databases/mysql.template.ts @@ -1,10 +1,14 @@ import { Constants } from "@/shared/utils/constants"; -import { AppTemplateModel } from "../../model/app-template.model"; +import { AppTemplateContentModel, AppTemplateModel } from "../../model/app-template.model"; -export const mysqlAppTemplate: AppTemplateModel = { - name: "MySQL", - iconName: 'mysql.svg', - templates: [{ +export function getMysqlAppTemplate(config?: { + appName?: string, + dbName?: string, + dbUsername?: string, + dbPassword?: string, + rootPassword?: string +}): AppTemplateContentModel { + return { inputSettings: [ { key: "containerImageSource", @@ -16,34 +20,34 @@ export const mysqlAppTemplate: AppTemplateModel = { { key: "MYSQL_DATABASE", label: "Database Name", - value: "mysqldb", + value: config?.dbName || "mysqldb", isEnvVar: true, randomGeneratedIfEmpty: false, }, { key: "MYSQL_USER", label: "Database User", - value: "mysqluser", + value: config?.dbUsername || "mysqluser", isEnvVar: true, randomGeneratedIfEmpty: false, }, { key: "MYSQL_PASSWORD", label: "Database Password", - value: "", + value: config?.dbPassword || "", isEnvVar: true, randomGeneratedIfEmpty: true, }, { key: "MYSQL_ROOT_PASSWORD", label: "Root Password", - value: "", + value: config?.rootPassword || "", isEnvVar: true, randomGeneratedIfEmpty: true, }, ], appModel: { - name: "MySQL", + name: config?.appName || "MySQL", appType: 'MYSQL', sourceType: 'CONTAINER', containerImageSource: "", @@ -52,9 +56,9 @@ export const mysqlAppTemplate: AppTemplateModel = { ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_DATABASES, egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_DATABASES, useNetworkPolicy: true, - healthCheckPeriodSeconds: 15, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, healthCheckTimeoutSeconds: 5, - healthCheckFailureThreshold: 3, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, }, appDomains: [], appVolumes: [{ @@ -68,5 +72,13 @@ export const mysqlAppTemplate: AppTemplateModel = { appPorts: [{ port: 3306, }] - }] -} \ No newline at end of file + }; +} + +export const mysqlAppTemplate: AppTemplateModel = { + name: "MySQL", + iconName: 'mysql.svg', + templates: [ + getMysqlAppTemplate() + ] +}; \ No newline at end of file diff --git a/src/shared/templates/databases/postgres.template.ts b/src/shared/templates/databases/postgres.template.ts index 67b575a..702b134 100644 --- a/src/shared/templates/databases/postgres.template.ts +++ b/src/shared/templates/databases/postgres.template.ts @@ -1,42 +1,45 @@ import { Constants } from "@/shared/utils/constants"; -import { AppTemplateModel } from "../../model/app-template.model"; +import { AppTemplateContentModel, AppTemplateModel } from "../../model/app-template.model"; -export const postgreAppTemplate: AppTemplateModel = { - name: "PostgreSQL", - iconName: 'postgres.svg', - templates: [{ +export function getPostgresAppTemplate(config?: { + appName?: string, + dbName?: string, + dbUsername?: string, + dbPassword?: string +}): AppTemplateContentModel { + return { inputSettings: [ { key: "containerImageSource", label: "Container Image", - value: "postgres:17", + value: "postgres:18-alpine", isEnvVar: false, randomGeneratedIfEmpty: false, }, { key: "POSTGRES_DB", label: "Database Name", - value: "postgresdb", + value: config?.dbName || "postgresdb", isEnvVar: true, randomGeneratedIfEmpty: false, }, { key: "POSTGRES_USER", label: "Database User", - value: "postgresuser", + value: config?.dbUsername || "postgresuser", isEnvVar: true, randomGeneratedIfEmpty: false, }, { key: "POSTGRES_PASSWORD", label: "Database Password", - value: "", + value: config?.dbPassword || "", isEnvVar: true, randomGeneratedIfEmpty: true, }, ], appModel: { - name: "PostgreSQL", + name: config?.appName || "PostgreSQL", appType: 'POSTGRES', sourceType: 'CONTAINER', containerImageSource: "", @@ -46,9 +49,9 @@ export const postgreAppTemplate: AppTemplateModel = { envVars: `PGDATA=/var/lib/qs-postgres/data `, useNetworkPolicy: true, - healthCheckPeriodSeconds: 15, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, healthCheckTimeoutSeconds: 5, - healthCheckFailureThreshold: 3, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, }, appDomains: [], appVolumes: [{ @@ -62,5 +65,13 @@ export const postgreAppTemplate: AppTemplateModel = { appPorts: [{ port: 5432, }] - }], + }; +} + +export const postgreAppTemplate: AppTemplateModel = { + name: "PostgreSQL", + iconName: 'postgres.svg', + templates: [ + getPostgresAppTemplate() + ] }; \ No newline at end of file diff --git a/src/shared/templates/databases/redis.template.ts b/src/shared/templates/databases/redis.template.ts new file mode 100644 index 0000000..7f4d7fb --- /dev/null +++ b/src/shared/templates/databases/redis.template.ts @@ -0,0 +1,65 @@ +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateContentModel, AppTemplateModel } from "../../model/app-template.model"; +import { AppExtendedModel } from "@/shared/model/app-extended.model"; +import { AppTemplateUtils } from "@/server/utils/app-template.utils"; + +export function getRedisAppTemplate(config?: { + appName?: string +}): AppTemplateContentModel { + return { + inputSettings: [ + { + key: "containerImageSource", + label: "Container Image", + value: "redis:8-alpine", + isEnvVar: false, + randomGeneratedIfEmpty: false, + } + ], + appModel: { + name: config?.appName || "Redis", + appType: 'REDIS', + sourceType: 'CONTAINER', + containerImageSource: "", + replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_DATABASES, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_DATABASES, + envVars: ``, + useNetworkPolicy: true, + healthCheckPeriodSeconds: Constants.DEFAULT_HEALTH_CHECK_PERIOD_SECONDS, + healthCheckTimeoutSeconds: 5, + healthCheckFailureThreshold: Constants.DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD, + containerCommand: 'redis-server', + }, + appDomains: [], + appVolumes: [{ + size: 200, + containerMountPath: '/data', + accessMode: 'ReadWriteOnce', + storageClassName: 'longhorn', + shareWithOtherApps: false, + }], + appFileMounts: [], + appPorts: [{ + port: 6379, + }] + }; +} + +export const redisAppTemplate: AppTemplateModel = { + name: "Redis", + iconName: 'https://cdn.simpleicons.org/redis', + templates: [ + getRedisAppTemplate() + ], +}; + +export const postCreateRedisAppTemplate = async (createdApps: AppExtendedModel[]): Promise => { + + const redisApp = createdApps[0]; + + const createdPassword = AppTemplateUtils.generateStrongPasswort(25); + redisApp.containerArgs = `["--requirepass", "${createdPassword}"]`; + + return [redisApp]; +}; diff --git a/src/shared/utils/constants.ts b/src/shared/utils/constants.ts index 54a9d63..a4cf4ad 100644 --- a/src/shared/utils/constants.ts +++ b/src/shared/utils/constants.ts @@ -17,4 +17,7 @@ export class Constants { static readonly DEFAULT_EGRESS_NETWORK_POLICY_APPS = 'ALLOW_ALL'; static readonly DEFAULT_INGRESS_NETWORK_POLICY_DATABASES = 'NAMESPACE_ONLY'; static readonly DEFAULT_EGRESS_NETWORK_POLICY_DATABASES = 'DENY_ALL'; + static readonly DEFAULT_HEALTH_CHECK_PERIOD_SECONDS = 15; + static readonly DEFAULT_HEALTH_CHECK_TIMEOUT_SECONDS = 10; + static readonly DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD = 3; } \ No newline at end of file