mirror of
https://github.com/biersoeckli/QuickStack.git
synced 2026-02-11 05:59:23 -06:00
feat/add public IPv4 address retrieval and integrated file browser
This commit is contained in:
@@ -7,6 +7,7 @@ import paramService, { ParamService } from "@/server/services/param.service";
|
||||
import quickStackService from "@/server/services/qs.service";
|
||||
import userService from "@/server/services/user.service";
|
||||
import { saveFormAction } from "@/server/utils/action-wrapper.utils";
|
||||
import ipAddressFinderAdapter from "@/server/adapter/ip-adress-finder.adapter";
|
||||
|
||||
|
||||
export const registerUser = async (prevState: any, inputData: RegisterFormInputSchema) =>
|
||||
@@ -17,6 +18,13 @@ export const registerUser = async (prevState: any, inputData: RegisterFormInputS
|
||||
}
|
||||
await userService.registerUser(validatedData.email, validatedData.password);
|
||||
await quickStackService.createOrUpdateCertIssuer(validatedData.email);
|
||||
|
||||
try {
|
||||
await paramService.getString(ParamService.PUBLIC_IPV4_ADDRESS, await ipAddressFinderAdapter.getPublicIpOfServer());
|
||||
} catch (e) {
|
||||
// ignore
|
||||
console.error('Failes to evaluate public ip address', e);
|
||||
}
|
||||
if (validatedData.qsHostname) {
|
||||
const url = new URL(validatedData.qsHostname.includes('://') ? validatedData.qsHostname : `https://${validatedData.qsHostname}`);
|
||||
await paramService.save({
|
||||
|
||||
@@ -4,10 +4,9 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
|
||||
import { AppExtendedModel } from "@/shared/model/app-extended.model";
|
||||
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EditIcon, Eye, EyeClosed, EyeOffIcon, TrashIcon } from "lucide-react";
|
||||
import { EditIcon, Eye, TrashIcon } from "lucide-react";
|
||||
import { Toast } from "@/frontend/utils/toast.utils";
|
||||
import { useConfirmDialog } from "@/frontend/states/zustand.states";
|
||||
import { AppVolume } from "@prisma/client";
|
||||
import React from "react";
|
||||
import FileMountEditDialog from "./basic-auth-edit-dialog";
|
||||
import BasicAuthEditDialog from "./basic-auth-edit-dialog";
|
||||
|
||||
@@ -13,6 +13,7 @@ import volumeBackupService from "@/server/services/volume-backup.service";
|
||||
import backupService from "@/server/services/standalone-services/backup.service";
|
||||
import { volumeUploadZodModel } from "@/shared/model/volume-upload.model";
|
||||
import restoreService from "@/server/services/restore.service";
|
||||
import fileBrowserService from "@/server/services/file-browser-service";
|
||||
|
||||
const actionAppVolumeEditZodModel = appVolumeEditZodModel.merge(z.object({
|
||||
appId: z.string(),
|
||||
@@ -121,4 +122,14 @@ export const runBackupVolumeSchedule = async (backupVolumeId: string) =>
|
||||
await getAuthUserSession();
|
||||
await backupService.runBackupForVolume(backupVolumeId);
|
||||
return new SuccessActionResult(undefined, 'Backup created and uploaded successfully');
|
||||
});
|
||||
});
|
||||
|
||||
export const openFileBrowserForVolume = async (volumeId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
const fileBrowserDomain = await fileBrowserService.deployFileBrowserForVolume(volumeId);
|
||||
return new SuccessActionResult(fileBrowserDomain, 'File browser started successfully');
|
||||
}) as Promise<ServerActionResult<any, {
|
||||
url: string;
|
||||
password: string;
|
||||
}>>;
|
||||
@@ -4,21 +4,22 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
|
||||
import { AppExtendedModel } from "@/shared/model/app-extended.model";
|
||||
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Download, EditIcon, TrashIcon, Upload } from "lucide-react";
|
||||
import { Download, EditIcon, Folder, TrashIcon } from "lucide-react";
|
||||
import DialogEditDialog from "./storage-edit-overlay";
|
||||
import { Toast } from "@/frontend/utils/toast.utils";
|
||||
import { deleteVolume, downloadPvcData, getPvcUsage } from "./actions";
|
||||
import { deleteVolume, downloadPvcData, getPvcUsage, openFileBrowserForVolume } from "./actions";
|
||||
import { useConfirmDialog } from "@/frontend/states/zustand.states";
|
||||
import { AppVolume } from "@prisma/client";
|
||||
import React from "react";
|
||||
import { KubeObjectNameUtils } from "@/server/utils/kube-object-name.utils";
|
||||
import StorageRestoreDialog from "./storage-restore-overlay";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { Code } from "@/components/custom/code";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
type AppVolumeWithCapacity = (AppVolume & { capacity?: string });
|
||||
|
||||
@@ -89,6 +90,47 @@ export default function StorageList({ app }: {
|
||||
}
|
||||
}
|
||||
|
||||
const openFileBrowserForVolumeAsync = async (volumeId: string) => {
|
||||
|
||||
try {
|
||||
const confirm = await openDialog({
|
||||
title: "Open File Browser",
|
||||
description: "To view the Files of the volume, your app has to be stopped. The file browser will be opened in a new tab. Are you sure you want to open the file browser?",
|
||||
okButton: "Stop App and Open File Browser"
|
||||
});
|
||||
if (!confirm) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
const fileBrowserStartResult = await Toast.fromAction(() => openFileBrowserForVolume(volumeId), undefined, 'Starting file browser...')
|
||||
if (fileBrowserStartResult.status !== 'success' || !fileBrowserStartResult.data) {
|
||||
return;
|
||||
}
|
||||
await openDialog({
|
||||
title: "File Browser Ready",
|
||||
description: <>
|
||||
The File Browser is ready and can be opened in a new tab. <br />
|
||||
Use the following credentials to login:
|
||||
<div className="pt-3 grid grid-cols-1 gap-1">
|
||||
<Label>Username</Label>
|
||||
<div> <Code>quickstack</Code></div>
|
||||
</div>
|
||||
<div className="pt-3 pb-4 grid grid-cols-1 gap-1">
|
||||
<Label>Password</Label>
|
||||
<div><Code>{fileBrowserStartResult.data.password}</Code></div>
|
||||
</div>
|
||||
<div>
|
||||
<Button variant='outline' onClick={() => window.open(fileBrowserStartResult.data!.url, '_blank')}>Open File Browser</Button>
|
||||
</div>
|
||||
</>,
|
||||
okButton: '',
|
||||
cancelButton: "Close"
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return <>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -127,7 +169,19 @@ export default function StorageList({ app }: {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<StorageRestoreDialog app={app} volume={volume}>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger>
|
||||
<Button variant="ghost" onClick={() => openFileBrowserForVolumeAsync(volume.id)} disabled={isLoading}>
|
||||
<Folder />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>View content of Volume</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{/*<StorageRestoreDialog app={app} volume={volume}>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger>
|
||||
@@ -140,7 +194,7 @@ export default function StorageList({ app }: {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</StorageRestoreDialog>
|
||||
</StorageRestoreDialog>*/}
|
||||
<DialogEditDialog app={app} volume={volume}>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={200}>
|
||||
|
||||
@@ -10,6 +10,8 @@ import registryService from "@/server/services/registry.service";
|
||||
import { StringUtils } from "@/shared/utils/string.utils";
|
||||
import { RegistryStorageLocationSettingsModel, registryStorageLocationSettingsZodModel } from "@/shared/model/registry-storage-location-settings.model";
|
||||
import { Constants } from "@/shared/utils/constants";
|
||||
import { QsPublicIpv4SettingsModel, qsPublicIpv4SettingsZodModel } from "@/shared/model/qs-public-ipv4-settings.model";
|
||||
import ipAddressFinderAdapter from "@/server/adapter/ip-adress-finder.adapter";
|
||||
|
||||
export const updateIngressSettings = async (prevState: any, inputData: QsIngressSettingsModel) =>
|
||||
saveFormAction(inputData, qsIngressSettingsZodModel, async (validatedData) => {
|
||||
@@ -31,6 +33,29 @@ export const updateIngressSettings = async (prevState: any, inputData: QsIngress
|
||||
await quickStackService.createOrUpdateIngress(validatedData.serverUrl);
|
||||
});
|
||||
|
||||
|
||||
export const updatePublicIpv4Settings = async (prevState: any, inputData: QsPublicIpv4SettingsModel) =>
|
||||
saveFormAction(inputData, qsPublicIpv4SettingsZodModel, async (validatedData) => {
|
||||
await getAuthUserSession();
|
||||
|
||||
await paramService.save({
|
||||
name: ParamService.PUBLIC_IPV4_ADDRESS,
|
||||
value: validatedData.publicIpv4
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
export const updatePublicIpv4SettingsAutomatically = async () =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
|
||||
const publicIpv4 = await ipAddressFinderAdapter.getPublicIpOfServer();
|
||||
await paramService.save({
|
||||
name: ParamService.PUBLIC_IPV4_ADDRESS,
|
||||
value: publicIpv4
|
||||
});
|
||||
});
|
||||
|
||||
export const updateLetsEncryptSettings = async (prevState: any, inputData: QsLetsEncryptSettingsModel) =>
|
||||
saveFormAction(inputData, qsLetsEncryptSettingsZodModel, async (validatedData) => {
|
||||
await getAuthUserSession();
|
||||
|
||||
@@ -12,6 +12,7 @@ import ServerBreadcrumbs from "./server-breadcrumbs";
|
||||
import QuickStackVersionInfo from "./qs-version-info";
|
||||
import QuickStackRegistrySettings from "./qs-registry-settings";
|
||||
import s3TargetService from "@/server/services/s3-target.service";
|
||||
import QuickStackPublicIpSettings from "./qs-public-ip-settings";
|
||||
|
||||
export default async function ProjectPage() {
|
||||
|
||||
@@ -22,6 +23,7 @@ export default async function ProjectPage() {
|
||||
const regitryStorageLocation = await paramService.getString(ParamService.REGISTRY_SOTRAGE_LOCATION, Constants.INTERNAL_REGISTRY_LOCATION);
|
||||
const useCanaryChannel = await paramService.getBoolean(ParamService.USE_CANARY_CHANNEL, false);
|
||||
const qsPodInfos = await podService.getPodsForApp(Constants.QS_NAMESPACE, Constants.QS_APP_NAME);
|
||||
const ipv4Address = await paramService.getString(ParamService.PUBLIC_IPV4_ADDRESS);
|
||||
const qsPodInfo = qsPodInfos.find(p => !!p);
|
||||
const s3Targets = await s3TargetService.getAll();
|
||||
|
||||
@@ -35,6 +37,7 @@ export default async function ProjectPage() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div><QuickStackIngressSettings disableNodePortAccess={disableNodePortAccess!} serverUrl={serverUrl!} /></div>
|
||||
<div> <QuickStackLetsEncryptSettings letsEncryptMail={letsEncryptMail!} /></div>
|
||||
<div> <QuickStackPublicIpSettings publicIpv4={ipv4Address} /></div>
|
||||
<div><QuickStackRegistrySettings registryStorageLocation={regitryStorageLocation!} s3Targets={s3Targets} /></div>
|
||||
<div><QuickStackMaintenanceSettings qsPodName={qsPodInfo?.podName} /></div>
|
||||
<div><QuickStackVersionInfo useCanaryChannel={useCanaryChannel!} /></div>
|
||||
|
||||
84
src/app/settings/server/qs-public-ip-settings.tsx
Normal file
84
src/app/settings/server/qs-public-ip-settings.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
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 { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
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 { updatePublicIpv4Settings, updatePublicIpv4SettingsAutomatically } from "./actions";
|
||||
import { QsPublicIpv4SettingsModel, qsPublicIpv4SettingsZodModel } from "@/shared/model/qs-public-ipv4-settings.model";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Toast } from "@/frontend/utils/toast.utils";
|
||||
|
||||
export default function QuickStackPublicIpSettings({
|
||||
publicIpv4,
|
||||
}: {
|
||||
publicIpv4?: string;
|
||||
}) {
|
||||
const form = useForm<QsPublicIpv4SettingsModel>({
|
||||
resolver: zodResolver(qsPublicIpv4SettingsZodModel),
|
||||
defaultValues: {
|
||||
publicIpv4,
|
||||
}
|
||||
});
|
||||
|
||||
const [state, formAction] = useFormState((state: ServerActionResult<any, any>, payload: QsPublicIpv4SettingsModel) =>
|
||||
updatePublicIpv4Settings(state, payload), FormUtils.getInitialFormState<typeof qsPublicIpv4SettingsZodModel>());
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === 'success') {
|
||||
toast.success('Settings updated successfully.');
|
||||
}
|
||||
FormUtils.mapValidationErrorsToForm<typeof qsPublicIpv4SettingsZodModel>(state, form)
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({ publicIpv4 });
|
||||
}, [publicIpv4]);
|
||||
|
||||
return <>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Main Public IPv4 Address</CardTitle>
|
||||
<CardDescription>Your main public IPv4 address is set automatically during the QuickStack setup.
|
||||
If you wish to change it, you can do so here.
|
||||
Make sure that your new IP is assigned to the server and reachable from the internet.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<Form {...form}>
|
||||
<form action={(e) => form.handleSubmit((data) => {
|
||||
return formAction(data);
|
||||
})()}>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="publicIpv4"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>IP Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
</CardContent>
|
||||
<CardFooter className="gap-4">
|
||||
<SubmitButton>Save</SubmitButton>
|
||||
<Button onClick={() => Toast.fromAction(() => updatePublicIpv4SettingsAutomatically())} type="button" variant='ghost'>Evaluate automatically</Button>
|
||||
<p className="text-red-500">{state?.message}</p>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Form >
|
||||
</Card >
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export function ConfirmDialog() {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => closeDialog(true)}>{data.okButton ?? 'OK'}</Button>
|
||||
{data.okButton !== '' && <Button onClick={() => closeDialog(true)}>{data.okButton ?? 'OK'}</Button>}
|
||||
<Button variant="secondary" onClick={() => closeDialog(false)}>{data.cancelButton ?? 'Cancel'}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -11,7 +11,7 @@ interface ZustandConfirmDialogProps {
|
||||
|
||||
export interface DialogProps {
|
||||
title: string;
|
||||
description: string;
|
||||
description: string | JSX.Element;
|
||||
okButton?: string;
|
||||
cancelButton?: string;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ListUtils } from "../../shared/utils/list.utils";
|
||||
|
||||
type clientType = keyof PrismaClient<Prisma.PrismaClientOptions, never | undefined>;
|
||||
|
||||
|
||||
const prismaClientSingleton = () => {
|
||||
return new PrismaClient()
|
||||
}
|
||||
|
||||
12
src/server/adapter/ip-adress-finder.adapter.ts
Normal file
12
src/server/adapter/ip-adress-finder.adapter.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
class IpAddressFinder {
|
||||
|
||||
public async getPublicIpOfServer(): Promise<string> {
|
||||
// source: https://www.ipify.org
|
||||
const response = await fetch('https://api.ipify.org?format=json') // ipv6 is on other domain https://api6.ipify.org?format=json
|
||||
const data = await response.json()
|
||||
return data?.ip || undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const ipAddressFinderAdapter = new IpAddressFinder();
|
||||
export default ipAddressFinderAdapter;
|
||||
@@ -50,7 +50,7 @@ class AppService {
|
||||
}
|
||||
try {
|
||||
await svcService.deleteService(existingApp.projectId, existingApp.id);
|
||||
await deploymentService.deleteDeployment(existingApp.projectId, existingApp.id);
|
||||
await deploymentService.deleteDeploymentIfExists(existingApp.projectId, existingApp.id);
|
||||
await ingressService.deleteAllIngressForApp(existingApp.projectId, existingApp.id);
|
||||
await pvcService.deleteAllPvcOfApp(existingApp.projectId, existingApp.id);
|
||||
await buildService.deleteAllBuildsOfApp(existingApp.id);
|
||||
|
||||
@@ -16,6 +16,8 @@ import registryService from "./registry.service";
|
||||
import { EnvVarUtils } from "../utils/env-var.utils";
|
||||
import configMapService from "./config-map.service";
|
||||
import secretService from "./secret.service";
|
||||
import fileBrowserService from "./file-browser-service";
|
||||
import podService from "./pod.service";
|
||||
|
||||
class DeploymentService {
|
||||
|
||||
@@ -27,7 +29,7 @@ class DeploymentService {
|
||||
}
|
||||
}
|
||||
|
||||
async deleteDeployment(projectId: string, appId: string) {
|
||||
async deleteDeploymentIfExists(projectId: string, appId: string) {
|
||||
const existingDeployment = await this.getDeployment(projectId, appId);
|
||||
if (!existingDeployment) {
|
||||
return;
|
||||
@@ -46,6 +48,11 @@ class DeploymentService {
|
||||
async createDeployment(deploymentId: string, app: AppExtendedModel, buildJobName?: string, gitCommitHash?: string) {
|
||||
await this.validateDeployment(app);
|
||||
|
||||
dlog(deploymentId, `Shutting down FileBrowsers (if active)`);
|
||||
for (let volume of app.appVolumes) {
|
||||
await fileBrowserService.deleteFileBrowserForVolumeIfExists(volume.id);
|
||||
}
|
||||
|
||||
dlog(deploymentId, `Starting deployment of containter...`);
|
||||
|
||||
await namespaceService.createNamespaceIfNotExists(app.projectId);
|
||||
@@ -173,7 +180,7 @@ class DeploymentService {
|
||||
dlog(deploymentId, `Cleanup unused ressources from previous deployments...`);
|
||||
await configMapService.deleteUnusedConfigMaps(app);
|
||||
await pvcService.deleteUnusedPvcOfApp(app);
|
||||
await svcService.createOrUpdateService(deploymentId, app);
|
||||
await svcService.createOrUpdateServiceForApp(deploymentId, app);
|
||||
await secretService.delteUnusedSecrets(app);
|
||||
dlog(deploymentId, `Updating ingress...`);
|
||||
await ingressService.createOrUpdateIngressForApp(deploymentId, app);
|
||||
@@ -189,6 +196,14 @@ class DeploymentService {
|
||||
return k3s.apps.replaceNamespacedDeployment(appId, projectId, existingDeployment);
|
||||
}
|
||||
|
||||
async setReplicasToZeroAndWaitForShutdown(projectId: string, appId: string) {
|
||||
await this.setReplicasForDeployment(projectId, appId, 0);
|
||||
const podNames = await podService.getPodsForApp(projectId, appId);
|
||||
for (const pod of podNames) {
|
||||
await podService.waitUntilPodIsTerminated(projectId, pod.podName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async getDeploymentStatus(projectId: string, appId: string) {
|
||||
const deployment = await this.getDeployment(projectId, appId);
|
||||
|
||||
223
src/server/services/file-browser-service.ts
Normal file
223
src/server/services/file-browser-service.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { V1Deployment, V1Ingress } from "@kubernetes/client-node";
|
||||
import dataAccess from "../adapter/db.client";
|
||||
import traefikMeDomainService from "./traefik-me-domain.service";
|
||||
import { Constants } from "@/shared/utils/constants";
|
||||
import { KubeObjectNameUtils } from "../utils/kube-object-name.utils";
|
||||
import deploymentService from "./deployment.service";
|
||||
import k3s from "../adapter/kubernetes-api.adapter";
|
||||
import ingressService from "./ingress.service";
|
||||
import svcService from "./svc.service";
|
||||
import { randomBytes, randomUUID } from "crypto";
|
||||
import podService from "./pod.service";
|
||||
import bcrypt from "bcrypt";
|
||||
|
||||
class FileBrowserService {
|
||||
|
||||
async deployFileBrowserForVolume(volumeId: string) {
|
||||
const volume = await dataAccess.client.appVolume.findFirstOrThrow({
|
||||
where: {
|
||||
id: volumeId
|
||||
},
|
||||
include: {
|
||||
app: true
|
||||
}
|
||||
});
|
||||
|
||||
const kubeAppName = `fb-${volumeId}`; // filebrowser-app
|
||||
const namespace = volume.app.projectId;
|
||||
const appId = volume.app.id;
|
||||
const projectId = volume.app.projectId;
|
||||
|
||||
console.log('Shutting down application with id: ' + appId);
|
||||
await deploymentService.setReplicasToZeroAndWaitForShutdown(projectId, appId);
|
||||
|
||||
console.log(`Deploying filebrowser for volume ${volumeId}`);
|
||||
const traefikHostname = await traefikMeDomainService.getDomainForApp(volume.appId, volume.id);
|
||||
|
||||
const pvcName = KubeObjectNameUtils.toPvcName(volume.id);
|
||||
|
||||
console.log(`Creating filebrowser deployment for volume ${volumeId}`);
|
||||
|
||||
const randomPassword = randomBytes(15).toString('hex');
|
||||
await this.createOrUpdateFilebrowserDeployment(kubeAppName, appId, projectId, pvcName, randomPassword);
|
||||
|
||||
|
||||
console.log(`Creating service for filebrowser for volume ${volumeId}`);
|
||||
await svcService.createOrUpdateService(projectId, kubeAppName, [{
|
||||
name: 'http',
|
||||
port: 80,
|
||||
targetPort: 80,
|
||||
}]);
|
||||
|
||||
console.log(`Creating ingress for filebrowser for volume ${volumeId}`);
|
||||
await this.createOrUpdateIngress(kubeAppName, namespace, appId, projectId, traefikHostname);
|
||||
|
||||
const fileBrowserPods = await podService.getPodsForApp(projectId, kubeAppName);
|
||||
for (const pod of fileBrowserPods) {
|
||||
await podService.waitUntilPodIsRunningFailedOrSucceded(projectId, pod.podName);
|
||||
}
|
||||
|
||||
// return `https://${randomUsername}:${randomPassword}@${traefikHostname}`;
|
||||
return { url: `https://${traefikHostname}`, password: randomPassword };
|
||||
}
|
||||
|
||||
async deleteFileBrowserForVolumeIfExists(volumeId: string) {
|
||||
const volume = await dataAccess.client.appVolume.findFirst({
|
||||
where: {
|
||||
id: volumeId
|
||||
},
|
||||
include: {
|
||||
app: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!volume) {
|
||||
return;
|
||||
}
|
||||
|
||||
const kubeAppName = `fb-${volumeId}`; // filebrowser-app
|
||||
const projectId = volume.app.projectId;
|
||||
|
||||
const existingDeployment = await deploymentService.getDeployment(projectId, kubeAppName);
|
||||
if (existingDeployment) { await k3s.apps.deleteNamespacedDeployment(kubeAppName, projectId); }
|
||||
|
||||
const existingService = await svcService.getService(projectId, kubeAppName);
|
||||
if (existingService) { await svcService.deleteService(projectId, kubeAppName); }
|
||||
|
||||
|
||||
const existingIngress = await ingressService.getIngressByName(projectId, kubeAppName);
|
||||
if (existingIngress) {
|
||||
await k3s.network.deleteNamespacedIngress(KubeObjectNameUtils.getIngressName(kubeAppName), projectId);
|
||||
}
|
||||
}
|
||||
|
||||
private async createOrUpdateIngress(kubeAppName: string, namespace: string, appId: string, projectId: string, traefikHostname: string) {
|
||||
const ingressDefinition: V1Ingress = {
|
||||
apiVersion: 'networking.k8s.io/v1',
|
||||
kind: 'Ingress',
|
||||
metadata: {
|
||||
name: KubeObjectNameUtils.getIngressName(kubeAppName),
|
||||
namespace: namespace,
|
||||
annotations: {
|
||||
[Constants.QS_ANNOTATION_APP_ID]: appId,
|
||||
[Constants.QS_ANNOTATION_PROJECT_ID]: projectId,
|
||||
...(true && { 'cert-manager.io/cluster-issuer': 'letsencrypt-production' }),
|
||||
// 'traefik.ingress.kubernetes.io/router.middlewares': middlewareName,
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
ingressClassName: 'traefik',
|
||||
rules: [
|
||||
{
|
||||
host: traefikHostname,
|
||||
http: {
|
||||
paths: [
|
||||
{
|
||||
path: '/',
|
||||
pathType: 'Prefix',
|
||||
backend: {
|
||||
service: {
|
||||
name: KubeObjectNameUtils.toServiceName(kubeAppName),
|
||||
port: {
|
||||
number: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
tls: [{
|
||||
hosts: [traefikHostname],
|
||||
secretName: `secret-tls-${kubeAppName}`,
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
const existingIngress = await ingressService.getIngressByName(projectId, kubeAppName);
|
||||
if (existingIngress) {
|
||||
await k3s.network.replaceNamespacedIngress(KubeObjectNameUtils.getIngressName(kubeAppName), projectId, ingressDefinition);
|
||||
} else {
|
||||
await k3s.network.createNamespacedIngress(projectId, ingressDefinition);
|
||||
}
|
||||
}
|
||||
|
||||
private async createOrUpdateFilebrowserDeployment(kubeAppName: string, appId: string, projectId: string, pvcName: string, authPassword: string) {
|
||||
|
||||
const password = authPassword;
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
const body: V1Deployment = {
|
||||
metadata: {
|
||||
name: kubeAppName
|
||||
},
|
||||
spec: {
|
||||
replicas: 1,
|
||||
selector: {
|
||||
matchLabels: {
|
||||
app: kubeAppName
|
||||
}
|
||||
},
|
||||
template: {
|
||||
metadata: {
|
||||
labels: {
|
||||
app: kubeAppName
|
||||
},
|
||||
annotations: {
|
||||
[Constants.QS_ANNOTATION_APP_ID]: appId,
|
||||
[Constants.QS_ANNOTATION_PROJECT_ID]: projectId,
|
||||
deploymentTimestamp: new Date().getTime() + "",
|
||||
"kubernetes.io/change-cause": `Deployment ${new Date().toISOString()}`
|
||||
}
|
||||
},
|
||||
spec: {
|
||||
containers: [
|
||||
{
|
||||
name: kubeAppName,
|
||||
image: 'filebrowser/filebrowser:v2.31.2',
|
||||
imagePullPolicy: 'Always',
|
||||
volumeMounts: [
|
||||
{
|
||||
name: 'fb-data',
|
||||
mountPath: '/srv/volume',
|
||||
}
|
||||
],
|
||||
// source: https://filebrowser.org/cli/filebrowser
|
||||
env: [
|
||||
{
|
||||
name: 'FB_USERNAME',
|
||||
value: 'quickstack'
|
||||
},
|
||||
{
|
||||
name: 'FB_PASSWORD',
|
||||
value: hashedPassword
|
||||
}
|
||||
]
|
||||
|
||||
}
|
||||
],
|
||||
volumes: [
|
||||
{
|
||||
name: 'fb-data',
|
||||
persistentVolumeClaim: {
|
||||
claimName: pvcName
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const existingDeployment = await deploymentService.getDeployment(projectId, kubeAppName);
|
||||
if (existingDeployment) {
|
||||
await k3s.apps.replaceNamespacedDeployment(kubeAppName, projectId, body);
|
||||
} else {
|
||||
await k3s.apps.createNamespacedDeployment(projectId, body);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fileBrowserService = new FileBrowserService();
|
||||
export default fileBrowserService;
|
||||
@@ -15,7 +15,7 @@ class IngressService {
|
||||
return res.body.items.filter((item) => item.metadata?.annotations?.[Constants.QS_ANNOTATION_APP_ID] === appId);
|
||||
}
|
||||
|
||||
async getIngress(projectId: string, domainId: string) {
|
||||
async getIngressByName(projectId: string, domainId: string) {
|
||||
const res = await k3s.network.listNamespacedIngress(projectId);
|
||||
return res.body.items.find((item) => item.metadata?.name === KubeObjectNameUtils.getIngressName(domainId));
|
||||
}
|
||||
@@ -60,10 +60,13 @@ class IngressService {
|
||||
await this.deleteUnusedIngressesOfApp(app);
|
||||
}
|
||||
|
||||
async createOrUpdateIngress(deploymentId: string, app: AppExtendedModel, domain: AppDomain, basicAuthMiddlewareName?: string) {
|
||||
async createOrUpdateIngress(deploymentId: string,
|
||||
app: { id: string, projectId: string },
|
||||
domain: { id: string, hostname: string, port: number, useSsl: boolean, redirectHttps: boolean },
|
||||
basicAuthMiddlewareName?: string) {
|
||||
const hostname = domain.hostname;
|
||||
const ingressName = KubeObjectNameUtils.getIngressName(domain.id);
|
||||
const existingIngress = await this.getIngress(app.projectId, domain.id);
|
||||
const existingIngress = await this.getIngressByName(app.projectId, domain.id);
|
||||
|
||||
const middlewares = [
|
||||
basicAuthMiddlewareName,
|
||||
@@ -129,35 +132,40 @@ class IngressService {
|
||||
}
|
||||
|
||||
async configureBasicAuthForApp(app: AppExtendedModel) {
|
||||
if (app.appBasicAuths.length === 0) {
|
||||
if (!app.appBasicAuths || app.appBasicAuths.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return await this.configureBasicAuthMiddleware(app.projectId, app.id, app.appBasicAuths.map(basicAuth => [basicAuth.username, basicAuth.password]));
|
||||
}
|
||||
|
||||
async deleteUnusedBasicAuthMiddlewaresForApp(app: AppExtendedModel) {
|
||||
if (app.appBasicAuths.length > 0) {
|
||||
if (!app.appBasicAuths || app.appBasicAuths.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.deleteUnusedBasicAuthMiddlewares(app.projectId, app.id);
|
||||
}
|
||||
|
||||
async deleteUnusedBasicAuthMiddlewares(namespace: string, basicAuthId: string) {
|
||||
|
||||
// delete middleware
|
||||
const middlewareName = `basic-auth-${app.id}`;
|
||||
const middlewareName = `ba-${basicAuthId}`;
|
||||
const existingMiddlewares = await k3s.customObjects.listNamespacedCustomObject('traefik.io', // group
|
||||
'v1alpha1', // version
|
||||
app.projectId, // namespace
|
||||
namespace, // namespace
|
||||
'middlewares' // plural name of the custom resource
|
||||
);
|
||||
const existingBasicAuthMiddleware = (existingMiddlewares.body as any).items.find((item: any) => item.metadata?.name === middlewareName);
|
||||
if (existingBasicAuthMiddleware) {
|
||||
await k3s.customObjects.deleteNamespacedCustomObject('traefik.io', 'v1alpha1', app.projectId, 'middlewares', middlewareName);
|
||||
await k3s.customObjects.deleteNamespacedCustomObject('traefik.io', 'v1alpha1', namespace, 'middlewares', middlewareName);
|
||||
}
|
||||
|
||||
// delete secret
|
||||
const secretName = `basic-auth-secret-${app.id}`;
|
||||
const existingSecrets = await k3s.core.listNamespacedSecret(app.projectId);
|
||||
const secretName = `bas-${basicAuthId}`;
|
||||
const existingSecrets = await k3s.core.listNamespacedSecret(namespace);
|
||||
const existingSecret = existingSecrets.body.items.find((item) => item.metadata?.name === secretName);
|
||||
if (existingSecret) {
|
||||
await k3s.core.deleteNamespacedSecret(secretName, app.projectId);
|
||||
await k3s.core.deleteNamespacedSecret(secretName, namespace);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,8 +175,8 @@ class IngressService {
|
||||
*/
|
||||
async configureBasicAuthMiddleware(namespace: string, basicAuthId: string, usernamePassword: [string, string][]) {
|
||||
|
||||
const basicAuthNameMiddlewareName = `basic-auth-${basicAuthId}`;
|
||||
const basicAuthSecretName = `basic-auth-secret-${basicAuthId}`;
|
||||
const basicAuthNameMiddlewareName = `ba-${basicAuthId}`; // basic auth middleware
|
||||
const basicAuthSecretName = `bas-${basicAuthId}`; // basic auth secret
|
||||
|
||||
const secretNamespace = namespace;
|
||||
const middlewareNamespace = namespace;
|
||||
|
||||
@@ -11,6 +11,7 @@ export class ParamService {
|
||||
static readonly LETS_ENCRYPT_MAIL = 'letsEncryptMail';
|
||||
static readonly USE_CANARY_CHANNEL = 'useCanaryChannel';
|
||||
static readonly REGISTRY_SOTRAGE_LOCATION = 'registryStorageLocation';
|
||||
static readonly PUBLIC_IPV4_ADDRESS = 'publicIpv4Address';
|
||||
static readonly K3S_JOIN_TOKEN = Constants.K3S_JOIN_TOKEN;
|
||||
|
||||
async getUncached(name: string) {
|
||||
|
||||
@@ -27,9 +27,7 @@ class SvcService {
|
||||
}
|
||||
}
|
||||
|
||||
async createOrUpdateService(deplyomentId: string, app: AppExtendedModel) {
|
||||
const existingService = await this.getService(app.projectId, app.id);
|
||||
// port configuration with removed duplicates
|
||||
async createOrUpdateServiceForApp(deplyomentId: string, app: AppExtendedModel) {
|
||||
const ports: {
|
||||
name: string;
|
||||
port: number;
|
||||
@@ -51,29 +49,45 @@ class SvcService {
|
||||
|
||||
if (ports.length === 0) {
|
||||
dlog(deplyomentId, `No domain or internal port settings found, service (HTTP) will not be created or updated. The application will run, but will not be accessible via the internal network or the internet.`);
|
||||
}
|
||||
|
||||
await this.createOrUpdateService(app.projectId, app.id, ports);
|
||||
|
||||
dlog(deplyomentId, `Updating service (HTTP) with ports ${ports.map(x => x.port).join(', ')}...`);
|
||||
|
||||
}
|
||||
|
||||
async createOrUpdateService(namespace: string, kubeAppName: string, ports: {
|
||||
name: string;
|
||||
port: number;
|
||||
targetPort: number;
|
||||
}[]) {
|
||||
const existingService = await this.getService(namespace, kubeAppName);
|
||||
// port configuration with removed duplicates
|
||||
|
||||
if (ports.length === 0) {
|
||||
if (existingService) {
|
||||
await this.deleteService(app.projectId, app.id);
|
||||
await this.deleteService(namespace, kubeAppName);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const body = {
|
||||
metadata: {
|
||||
name: KubeObjectNameUtils.toServiceName(app.id)
|
||||
name: KubeObjectNameUtils.toServiceName(kubeAppName)
|
||||
},
|
||||
spec: {
|
||||
selector: {
|
||||
app: app.id
|
||||
app: kubeAppName
|
||||
},
|
||||
ports: ports
|
||||
}
|
||||
};
|
||||
|
||||
dlog(deplyomentId, `Updating service (HTTP) with ports ${ports.map(x => x.port).join(', ')}...`);
|
||||
if (existingService) {
|
||||
await k3s.core.replaceNamespacedService(KubeObjectNameUtils.toServiceName(app.id), app.projectId, body);
|
||||
await k3s.core.replaceNamespacedService(KubeObjectNameUtils.toServiceName(kubeAppName), namespace, body);
|
||||
} else {
|
||||
await k3s.core.createNamespacedService(app.projectId, body);
|
||||
await k3s.core.createNamespacedService(namespace, body);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
19
src/server/services/traefik-me-domain.service.ts
Normal file
19
src/server/services/traefik-me-domain.service.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ServiceException } from "@/shared/model/service.exception.model";
|
||||
import paramService, { ParamService } from "./param.service";
|
||||
|
||||
class TraefikMeDomainService {
|
||||
|
||||
async getDomainForApp(appId: string, prefix?: string) {
|
||||
const publicIpv4 = await paramService.getString(ParamService.PUBLIC_IPV4_ADDRESS);
|
||||
if (!publicIpv4) {
|
||||
throw new ServiceException('Please set the main public IPv4 address in the QuickStack settings first.');
|
||||
}
|
||||
if (prefix) {
|
||||
return `${prefix}.${appId}.${publicIpv4}.traefik.me`;
|
||||
}
|
||||
return `${appId}.${publicIpv4}.traefik.me`;
|
||||
}
|
||||
}
|
||||
|
||||
const traefikMeDomainService = new TraefikMeDomainService();
|
||||
export default traefikMeDomainService;
|
||||
7
src/shared/model/qs-public-ipv4-settings.model.ts
Normal file
7
src/shared/model/qs-public-ipv4-settings.model.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const qsPublicIpv4SettingsZodModel = z.object({
|
||||
publicIpv4: z.string().trim(),
|
||||
})
|
||||
|
||||
export type QsPublicIpv4SettingsModel = z.infer<typeof qsPublicIpv4SettingsZodModel>;
|
||||
Reference in New Issue
Block a user