added settings page for letsencrypt and ingress configuration for quickstack

This commit is contained in:
biersoeckli
2024-11-21 17:25:11 +00:00
parent b9846f4445
commit 1780f50bde
21 changed files with 507 additions and 27 deletions

View File

@@ -0,0 +1,7 @@
-- CreateTable
CREATE TABLE "Parameter" (
"name" TEXT NOT NULL PRIMARY KEY,
"value" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);

View File

@@ -178,3 +178,11 @@ model AppVolume {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Parameter {
name String @id
value String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@@ -137,6 +137,7 @@ metadata:
name: quickstack-setup-job
namespace: quickstack
spec:
ttlSecondsAfterFinished: 3600
template:
spec:
serviceAccountName: qs-service-account

View File

@@ -1,22 +1,15 @@
import { SimpleDataTable } from "@/components/custom/simple-data-table";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import LoadingSpinner from "@/components/ui/loading-spinner";
import { formatDateTime } from "@/lib/format.utils";
import { AppExtendedModel } from "@/model/app-extended.model";
import { BuildJobModel } from "@/model/build-job";
import { useEffect, useState } from "react";
import { deleteBuild, getDeploymentsAndBuildsForApp } from "./actions";
import { set } from "date-fns";
import FullLoadingSpinner from "@/components/ui/full-loading-spinnter";
import { Item } from "@radix-ui/react-dropdown-menu";
import BuildStatusBadge from "./build-status-badge";
import { Button } from "@/components/ui/button";
import { useConfirmDialog } from "@/lib/zustand.states";
import { Toast } from "@/lib/toast.utils";
import { DeploymentInfoModel } from "@/model/deployment-info.model";
import DeploymentStatusBadge from "./deployment-status-badge";
import { io } from "socket.io-client";
import { podLogsSocket } from "@/lib/sockets";
import { BuildLogsDialog } from "./build-logs-overlay";
import ShortCommitHash from "@/components/custom/short-commit-hash";

View File

@@ -1,10 +1,6 @@
'use server'
import { ServiceException } from "@/model/service.exception.model";
import { ProfilePasswordChangeModel, profilePasswordChangeZodModel } from "@/model/update-password.model";
import userService from "@/server/services/user.service";
import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
import { TotpModel, totpZodModel } from "@/model/update-password.model copy";
import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
import { SuccessActionResult } from "@/model/server-action-error-return.model";
import clusterService from "@/server/services/node.service";

View File

@@ -4,7 +4,7 @@ import { ServiceException } from "@/model/service.exception.model";
import { ProfilePasswordChangeModel, profilePasswordChangeZodModel } from "@/model/update-password.model";
import userService from "@/server/services/user.service";
import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
import { TotpModel, totpZodModel } from "@/model/update-password.model copy";
import { TotpModel, totpZodModel } from "@/model/totp.model";
import { SuccessActionResult } from "@/model/server-action-error-return.model";
export const changePassword = async (prevState: any, inputData: ProfilePasswordChangeModel) =>

View File

@@ -13,7 +13,7 @@ import { toast } from "sonner";
import { createNewTotpToken, verifyTotpToken } from "./actions";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import React from "react";
import { TotpModel, totpZodModel } from "@/model/update-password.model copy";
import { TotpModel, totpZodModel } from "@/model/totp.model";
import { Toast } from "@/lib/toast.utils";
import FullLoadingSpinner from "@/components/ui/full-loading-spinnter";

View File

@@ -0,0 +1,39 @@
'use server'
import { getAuthUserSession, saveFormAction } from "@/server/utils/action-wrapper.utils";
import paramService, { ParamService } from "@/server/services/param.service";
import { QsIngressSettingsModel, qsIngressSettingsZodModel } from "@/model/qs-settings.model";
import { QsLetsEncryptSettingsModel, qsLetsEncryptSettingsZodModel } from "@/model/qs-letsencrypt-settings.model";
import quickStackService from "@/server/services/qs.service";
export const updateIngressSettings = async (prevState: any, inputData: QsIngressSettingsModel) =>
saveFormAction(inputData, qsIngressSettingsZodModel, async (validatedData) => {
await getAuthUserSession();
await paramService.save({
name: ParamService.QS_SERVER_HOSTNAME,
value: validatedData.serverUrl
});
await paramService.save({
name: ParamService.DISABLE_NODEPORT_ACCESS,
value: validatedData.disableNodePortAccess + ''
});
await quickStackService.createOrUpdateService(!validatedData.disableNodePortAccess);
await quickStackService.createOrUpdateIngress(validatedData.serverUrl);
});
export const updateLetsEncryptSettings = async (prevState: any, inputData: QsLetsEncryptSettingsModel) =>
saveFormAction(inputData, qsLetsEncryptSettingsZodModel, async (validatedData) => {
await getAuthUserSession();
await paramService.save({
name: ParamService.LETS_ENCRYPT_MAIL,
value: validatedData.letsEncryptMail
});
await quickStackService.createOrUpdateCertIssuer(validatedData.letsEncryptMail);
// todo update or deploy the cert issuer
});

View File

@@ -9,17 +9,26 @@ import {
} from "@/components/ui/breadcrumb"
import PageTitle from "@/components/custom/page-title";
import userService from "@/server/services/user.service";
import paramService, { ParamService } from "@/server/services/param.service";
import QuickStackIngressSettings from "./qs-ingress-settings";
import QuickStackLetsEncryptSettings from "./qs-letsencrypt-settings";
export default async function ProjectPage() {
const session = await getAuthUserSession();
const data = await userService.getUserByEmail(session.email);
const serverUrl = await paramService.getString(ParamService.QS_SERVER_HOSTNAME, '');
const disableNodePortAccess = await paramService.getBoolean(ParamService.DISABLE_NODEPORT_ACCESS, false);
const letsEncryptMail = await paramService.getString(ParamService.LETS_ENCRYPT_MAIL, session.email);
return (
<div className="flex-1 space-y-4 p-8 pt-6">
<PageTitle
title={'Server Settings'}
subtitle={`View or edit Server Settings`}>
</PageTitle>
<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>
</div>
)
}

View File

@@ -0,0 +1,91 @@
'use client';
import { SubmitButton } from "@/components/custom/submit-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 "@/lib/form.utilts";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useFormState } from "react-dom";
import { ServerActionResult } from "@/model/server-action-error-return.model";
import { Input } from "@/components/ui/input";
import { useEffect } from "react";
import { toast } from "sonner";
import { QsIngressSettingsModel, qsIngressSettingsZodModel } from "@/model/qs-settings.model";
import { updateIngressSettings } from "./actions";
import CheckboxFormField from "@/components/custom/checkbox-form-field";
export default function QuickStackIngressSettings({
serverUrl,
disableNodePortAccess
}: {
serverUrl: string;
disableNodePortAccess: boolean;
}) {
const form = useForm<QsIngressSettingsModel>({
resolver: zodResolver(qsIngressSettingsZodModel),
defaultValues: {
serverUrl,
disableNodePortAccess: !disableNodePortAccess
}
});
const [state, formAction] = useFormState((state: ServerActionResult<any, any>, payload: QsIngressSettingsModel) =>
updateIngressSettings(state, payload), FormUtils.getInitialFormState<typeof qsIngressSettingsZodModel>());
useEffect(() => {
if (state.status === 'success') {
toast.success('Settings updated successfully. It may take a few seconds for the changes to take effect.');
}
FormUtils.mapValidationErrorsToForm<typeof qsIngressSettingsZodModel>(state, form)
}, [state]);
const sourceTypeField = form.watch();
return <>
<Card>
<CardHeader>
<CardTitle>Panel Domain</CardTitle>
<CardDescription>Change the domain settings for your QuickStack instance.</CardDescription>
</CardHeader>
<Form {...form}>
<form action={(e) => form.handleSubmit((data) => {
return formAction({
...data,
disableNodePortAccess: !data.disableNodePortAccess
});
})()}>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="serverUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Domain</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
Make sure the DNS settings of the domain are correctly configured to point to the server IP address.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<CheckboxFormField
form={form}
name="disableNodePortAccess"
label="Serve QuickStack over IP Address and Port 30000"
/>
</CardContent>
<CardFooter className="gap-4">
<SubmitButton>Save</SubmitButton>
<p className="text-red-500">{state?.message}</p>
</CardFooter>
</form>
</Form >
</Card >
</>;
}

View File

@@ -0,0 +1,79 @@
'use client';
import { SubmitButton } from "@/components/custom/submit-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 "@/lib/form.utilts";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useFormState } from "react-dom";
import { ServerActionResult } from "@/model/server-action-error-return.model";
import { Input } from "@/components/ui/input";
import { useEffect } from "react";
import { toast } from "sonner";
import { ProfilePasswordChangeModel, profilePasswordChangeZodModel } from "@/model/update-password.model";
import { QsIngressSettingsModel, qsIngressSettingsZodModel } from "@/model/qs-settings.model";
import { updateIngressSettings, updateLetsEncryptSettings } from "./actions";
import SelectFormField from "@/components/custom/select-form-field";
import CheckboxFormField from "@/components/custom/checkbox-form-field";
import { QsLetsEncryptSettingsModel, qsLetsEncryptSettingsZodModel } from "@/model/qs-letsencrypt-settings.model";
export default function QuickStackLetsEncryptSettings({
letsEncryptMail,
}: {
letsEncryptMail: string;
}) {
const form = useForm<QsLetsEncryptSettingsModel>({
resolver: zodResolver(qsLetsEncryptSettingsZodModel),
defaultValues: {
letsEncryptMail,
}
});
const [state, formAction] = useFormState((state: ServerActionResult<any, any>, payload: QsLetsEncryptSettingsModel) =>
updateLetsEncryptSettings(state, payload), FormUtils.getInitialFormState<typeof qsLetsEncryptSettingsZodModel>());
useEffect(() => {
if (state.status === 'success') {
toast.success('Settings updated successfully. It may take a few seconds for the changes to take effect.');
}
FormUtils.mapValidationErrorsToForm<typeof qsLetsEncryptSettingsZodModel>(state, form)
}, [state]);
const sourceTypeField = form.watch();
return <>
<Card>
<CardHeader>
<CardTitle>SSL Certificates</CardTitle>
<CardDescription>To issue SSL Certificates to your Apps, provide your Let's Encrypt email address.</CardDescription>
</CardHeader>
<Form {...form}>
<form action={(e) => form.handleSubmit((data) => {
return formAction(data);
})()}>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="letsEncryptMail"
render={({ field }) => (
<FormItem>
<FormLabel>Let's Encrypt Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
<CardFooter className="gap-4">
<SubmitButton>Save</SubmitButton>
<p className="text-red-500">{state?.message}</p>
</CardFooter>
</form>
</Form >
</Card >
</>;
}

View File

@@ -7,3 +7,4 @@ export * from "./project"
export * from "./app"
export * from "./appdomain"
export * from "./appvolume"
export * from "./parameter"

View File

@@ -0,0 +1,9 @@
import * as z from "zod"
export const ParameterModel = z.object({
name: z.string(),
value: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
})

View File

@@ -0,0 +1,8 @@
import { stringToBoolean } from "@/lib/zod.utils";
import { z } from "zod";
export const qsLetsEncryptSettingsZodModel = z.object({
letsEncryptMail: z.string().trim().email(),
})
export type QsLetsEncryptSettingsModel = z.infer<typeof qsLetsEncryptSettingsZodModel>;

View File

@@ -0,0 +1,9 @@
import { stringToBoolean } from "@/lib/zod.utils";
import { z } from "zod";
export const qsIngressSettingsZodModel = z.object({
serverUrl: z.string().trim().min(1),
disableNodePortAccess: stringToBoolean,
})
export type QsIngressSettingsModel = z.infer<typeof qsIngressSettingsZodModel>;

View File

@@ -2,7 +2,7 @@ import { createServer } from 'http'
import { parse } from 'url'
import next from 'next'
import socketIoServer from './socket-io.server'
import initService from './server/services/init.service'
import quickStackService from './server/services/qs.service'
import { CommandExecutorUtils } from './server/utils/command-executor.utils'
import k3s from './server/adapter/kubernetes-api.adapter'
@@ -19,7 +19,7 @@ if (process.env.NODE_ENV === 'production') {
async function setupQuickStack() {
console.log('Setting up QuickStack...');
await initService.initializeQuickStack();
await quickStackService.initializeQuickStack();
}
async function initializeNextJs() {

View File

@@ -15,12 +15,11 @@ class IngressService {
return res.body.items.filter((item) => item.metadata?.annotations?.[Constants.QS_ANNOTATION_APP_ID] === appId);
}
async getIngress(projectId: string, domainId: string, ) {
async getIngress(projectId: string, domainId: string) {
const res = await k3s.network.listNamespacedIngress(projectId);
return res.body.items.find((item) => item.metadata?.name === StringUtils.getIngressName(domainId));
}
async deleteUnusedIngressesOfApp(app: AppExtendedModel) {
const currentDomains = new Set(app.appDomains.map(domainObj => domainObj.hostname));
const existingIngresses = await this.getAllIngressForApp(app.projectId, app.id);

View File

@@ -0,0 +1,130 @@
import { revalidateTag, unstable_cache } from "next/cache";
import dataAccess from "../adapter/db.client";
import { Tags } from "../utils/cache-tag-generator.utils";
import { Parameter, Prisma } from "@prisma/client";
export class ParamService {
static readonly QS_SERVER_HOSTNAME = 'qsServerHostname';
static readonly DISABLE_NODEPORT_ACCESS = 'disableNodePortAccess';
static readonly LETS_ENCRYPT_MAIL = 'letsEncryptMail';
async get(name: string) {
return await unstable_cache(async (name: string) => await dataAccess.client.parameter.findFirstOrThrow({
where: {
name
}
}),
[Tags.parameter()], {
tags: [Tags.parameter()]
})(name);
}
async getOrUndefined(name: string) {
return await unstable_cache(async (name: string) => await dataAccess.client.parameter.findUnique({
where: {
name
}
}),
[Tags.parameter()], {
tags: [Tags.parameter()]
})(name);
}
async getBoolean(name: string, defaultValue?: boolean) {
const param = await this.getOrUndefined(name);
if (param) {
return param.value === 'true';
}
if (defaultValue) {
await this.save({
name,
value: defaultValue.toString()
});
return defaultValue;
}
return undefined;
}
async getString(name: string, defaultValue?: string) {
const param = await this.getOrUndefined(name);
if (param) {
return param.value;
}
if (defaultValue) {
await this.save({
name,
value: defaultValue
});
return defaultValue;
}
return undefined;
}
async getNumber(name: string, defaultValue?: number) {
const param = await this.getOrUndefined(name);
if (param) {
return Number(param.value);
}
if (defaultValue) {
await this.save({
name,
value: defaultValue.toString()
});
return defaultValue;
}
return undefined;
}
async deleteByName(name: string) {
const existingParam = await this.get(name);
if (!existingParam) {
return;
}
try {
await dataAccess.client.parameter.delete({
where: {
name
}
});
} finally {
revalidateTag(Tags.parameter());
}
}
async getAllParams() {
return await unstable_cache(async () => await dataAccess.client.parameter.findMany(),
[Tags.parameter()], {
tags: [Tags.parameter()]
})();
}
async save(item: Prisma.ParameterUncheckedCreateInput | Prisma.ParameterUncheckedUpdateInput) {
let savedItem: Parameter;
try {
const existingParam = await this.getOrUndefined(item.name as string);
if (existingParam) {
savedItem = await dataAccess.client.parameter.update({
where: {
name: item.name as string
},
data: {
}
});
} else {
savedItem = await dataAccess.client.parameter.create({
data: item as Prisma.ParameterUncheckedCreateInput
});
}
} finally {
revalidateTag(Tags.parameter());
}
return savedItem;
}
}
const paramService = new ParamService();
export default paramService;

View File

@@ -1,13 +1,16 @@
import k3s from "../adapter/kubernetes-api.adapter";
import { V1Deployment, V1Service } from "@kubernetes/client-node";
import { V1Deployment, V1Ingress, V1Service } from "@kubernetes/client-node";
import namespaceService from "./namespace.service";
import { StringUtils } from "../utils/string.utils";
import crypto from "crypto";
import paramService, { ParamService } from "./param.service";
import { ServiceException } from "@/model/service.exception.model";
class InitService {
class QuickStackService {
private readonly QUICKSTACK_NAMESPACE = 'quickstack';
private readonly QUICKSTACK_DEPLOYMENT_NAME = 'quickstack';
private readonly QUICKSTACK_PORT_NUMBER = 3000;
private readonly QUICKSTACK_SERVICEACCOUNT_NAME = 'qs-service-account';
@@ -20,6 +23,102 @@ class InitService {
console.log('QuickStack successfully initialized');
}
async createOrUpdateIngress(hostname: string) {
const ingressName = StringUtils.getIngressName(this.QUICKSTACK_NAMESPACE);
const existingIngresses = await k3s.network.listNamespacedIngress(this.QUICKSTACK_NAMESPACE);
const existingIngress = existingIngresses.body.items.find((item) => item.metadata?.name === ingressName);
const ingressDefinition: V1Ingress = {
apiVersion: 'networking.k8s.io/v1',
kind: 'Ingress',
metadata: {
name: ingressName,
namespace: this.QUICKSTACK_NAMESPACE,
annotations: {
'cert-manager.io/cluster-issuer': 'letsencrypt-production',
'traefik.ingress.kubernetes.io/router.middlewares': 'kube-system-redirect-to-https@kubernetescrd' // activate redirect middleware for https
},
},
spec: {
ingressClassName: 'traefik',
rules: [
{
host: hostname,
http: {
paths: [
{
path: '/',
pathType: 'Prefix',
backend: {
service: {
name: StringUtils.toServiceName(this.QUICKSTACK_DEPLOYMENT_NAME),
port: {
number: this.QUICKSTACK_PORT_NUMBER,
},
},
},
},
],
},
},
],
tls: [
{
hosts: [hostname],
secretName: `secret-tls-${hostname}`,
},
],
},
};
if (existingIngress) {
await k3s.network.replaceNamespacedIngress(ingressName, this.QUICKSTACK_NAMESPACE, ingressDefinition);
console.log(`Ingress QuickStack for domain ${hostname} successfully updated.`);
} else {
await k3s.network.createNamespacedIngress(this.QUICKSTACK_NAMESPACE, ingressDefinition);
console.log(`Ingress QuickStack for domain ${hostname} successfully created.`);
}
}
async createOrUpdateCertIssuer(letsencryptMail: string) {
const issuerName = 'letsencrypt-production';
const issuerDefinition = {
apiVersion: 'cert-manager.io/v1',
kind: 'ClusterIssuer',
metadata: {
name: issuerName,
namespace: 'default'
},
spec: {
acme: {
email: letsencryptMail,
server: 'https://acme-v02.api.letsencrypt.org/directory',
privateKeySecretRef: {
name: 'letsencrypt-production'
},
solvers: [
{
http01: {
ingress: {
class: 'traefik'
}
}
}
]
}
}
};
// todo
/* const allIssuers = await k3s.network.clus();
const existingIssuer = allIssuers.body.items.find(i => i.metadata!.name === issuerName);
if (existingIssuer) {
await k3s.certManager.replaceClusterIssuer(issuerName, issuerDefinition);
console.log('Cert Issuer updated');
} else {
await k3s.certManager.createClusterIssuer(issuerDefinition);
console.log('Cert Issuer created');*/
}
async createOrUpdateService(openNodePort = false) {
const serviceName = StringUtils.toServiceName(this.QUICKSTACK_DEPLOYMENT_NAME);
const body: V1Service = {
@@ -36,8 +135,8 @@ class InitService {
ports: [
{
protocol: 'TCP',
port: 3000,
targetPort: 3000,
port: this.QUICKSTACK_PORT_NUMBER,
targetPort: this.QUICKSTACK_PORT_NUMBER,
nodePort: openNodePort ? 30000 : undefined,
}
],
@@ -51,8 +150,6 @@ class InitService {
console.warn('Service already exists, deleting and recreating it');
await k3s.core.deleteNamespacedService(serviceName, this.QUICKSTACK_NAMESPACE);
console.log('Existing service deleted');
//await k3s.core.replaceNamespacedService(serviceName, this.QUICKSTACK_NAMESPACE, body);
// console.log('Service created');
} else {
console.warn('Service does not exist, creating');
}
@@ -175,5 +272,5 @@ class InitService {
}
}
const initService = new InitService();
export default initService;
const quickStackService = new QuickStackService();
export default quickStackService;

View File

@@ -12,6 +12,10 @@ export class Tags {
return `apps-${projectId}`;
}
static parameter() {
return `parameter`;
}
static app(appId: string) {
return `app-${appId}`;
}