feat/add public IPv4 address retrieval and integrated file browser

This commit is contained in:
biersoeckli
2025-01-07 13:36:33 +00:00
parent 35e949a1fb
commit 56395c7303
19 changed files with 518 additions and 36 deletions

View File

@@ -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({

View File

@@ -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";

View File

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

View File

@@ -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}>

View File

@@ -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();

View File

@@ -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>

View 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 >
</>;
}

View File

@@ -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>

View File

@@ -11,7 +11,7 @@ interface ZustandConfirmDialogProps {
export interface DialogProps {
title: string;
description: string;
description: string | JSX.Element;
okButton?: string;
cancelButton?: string;
}

View File

@@ -4,7 +4,6 @@ import { ListUtils } from "../../shared/utils/list.utils";
type clientType = keyof PrismaClient<Prisma.PrismaClientOptions, never | undefined>;
const prismaClientSingleton = () => {
return new PrismaClient()
}

View 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;

View File

@@ -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);

View File

@@ -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);

View 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;

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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);
}
}

View 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;

View 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>;