Merge pull request #13 from biersoeckli/canary

25-02-07 Merging features from canary to main branch
This commit is contained in:
Jan Meier
2025-02-07 17:26:18 +01:00
committed by GitHub
62 changed files with 2198 additions and 196 deletions

View File

@@ -52,7 +52,7 @@ jobs:
with:
context: ./
push: true
platforms: linux/amd64 #,linux/arm64
platforms: linux/amd64,linux/arm64
build-args: |
VERSION_ARG=${{ github.ref_name }}
tags: |

View File

@@ -52,7 +52,7 @@ jobs:
with:
context: ./
push: true
platforms: linux/amd64
platforms: linux/amd64 #,linux/arm64
build-args: |
VERSION_ARG=canary-${{ github.run_number }}
tags: |

View File

@@ -1,31 +1,25 @@
FROM node:18-alpine AS base
ARG VERSION_ARG
RUN apk add --no-cache openssl
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat openssl
# Install necessary packages for building
RUN apk add --no-cache libc6-compat python3 make g++
WORKDIR /app
# Install dependencies based on the preferred package manager
# Install dependencies
COPY yarn.lock package.json ./
RUN yarn install
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN yarn run prisma-generate-build
RUN yarn run build
RUN rm -rf ./next/standalone
@@ -34,36 +28,27 @@ RUN rm -rf ./next/standalone
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1
ENV NODE_ENV=production
ENV PYTHON=/usr/bin/python3
ENV QS_VERSION=$VERSION_ARG
RUN apk add --no-cache git
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN mkdir storage
RUN chown nextjs:nodejs storage
RUN mkdir storage tmp-storage
RUN chown nextjs:nodejs storage tmp-storage
RUN mkdir tmp-storage
RUN chown nextjs:nodejs tmp-storage
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder --chown=nextjs:nodejs /app/dist ./dist
COPY --from=builder --chown=nextjs:nodejs /app/dist ./dist
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
USER nextjs
ENV PORT=3000
ENV QS_VERSION=$VERSION_ARG
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD HOSTNAME="0.0.0.0" npm run start-prod
CMD HOSTNAME="0.0.0.0" npm run start-prod

42
setup/reset-password.sh Normal file
View File

@@ -0,0 +1,42 @@
#!/bin/sh
# curl -sfL https://get.quickstack.dev/reset-password.sh | sh -
DEPLOYMENT="quickstack"
NAMESPACE="quickstack"
# Get the first pod name of the deployment
POD_NAME=$(kubectl get pods -n "$NAMESPACE" -l app="$DEPLOYMENT" -o jsonpath="{.items[0].metadata.name}")
if [ -z "$POD_NAME" ]; then
echo "Could not find a running QuickStack instance on your server/cluster."
exit 1
fi
echo "Found QuickStack instance: $POD_NAME"
echo "Initializing password change..."
# Patch the deployment to add or update START_MODE=reset-password
kubectl patch deployment "$DEPLOYMENT" -n "$NAMESPACE" --type='json' -p='[
{
"op": "add",
"path": "/spec/template/spec/containers/0/env/-",
"value": { "name": "START_MODE", "value": "reset-password" }
}
]'
echo "Initialized password change successfully, please wait..."
sleep 2
echo "Waiting for the new pod to be in Running status..."
kubectl wait --for=condition=Ready pod -l app="$DEPLOYMENT" -n "$NAMESPACE" --timeout=300s
# Retreive the new pod name
NEW_POD=""
while [ -z "$NEW_POD" ] || [ "$NEW_POD" = "$OLD_POD" ]; do
sleep 2
NEW_POD=$(kubectl get pods -n "$NAMESPACE" -l app="$DEPLOYMENT" -o jsonpath="{.items[-1].metadata.name}")
done
kubectl logs -f "$NEW_POD" -n "$NAMESPACE"

178
setup/setup-canary.sh Normal file
View File

@@ -0,0 +1,178 @@
#!/bin/bash
# curl -sfL https://get.quickstack.dev/setup-canary.sh | sh -
select_network_interface() {
if [ -z "$INSTALL_K3S_INTERFACE" ]; then
interfaces_with_ips=$(ip -o -4 addr show | awk '!/^[0-9]*: lo:/ {print $2, $4}' | cut -d'/' -f1)
echo "Available network interfaces:"
echo "$interfaces_with_ips"
echo ""
echo "*******************************************************************************************************"
echo ""
echo "If you plan to use QuickStack in a cluster using multiple servers in multiple Networks (private/public),"
echo "choose the network Interface you want to use for the communication between the servers."
echo ""
echo "If you plan to use QuickStack in a single server setup, choose the network Interface with the public IP."
echo ""
i=1
echo "$interfaces_with_ips" | while read -r iface ip; do
printf "%d) %s (%s)\n" "$i" "$iface" "$ip"
i=$((i + 1))
done
printf "Please enter the number of the interface to use: "
# Change read to use /dev/tty explicitly
read -r choice </dev/tty
selected=$(echo "$interfaces_with_ips" | sed -n "${choice}p")
selected_iface=$(echo "$selected" | awk '{print $1}')
selected_ip=$(echo "$selected" | awk '{print $2}')
if [ -n "$selected" ]; then
echo "Selected interface: $selected_iface ($selected_ip)"
else
echo "Invalid selection. Exiting."
exit 1
fi
fi
echo "Using network interface: $selected_iface with IP address: $selected_ip"
}
wait_until_all_pods_running() {
# Waits another 5 seconds to make sure all pods are registered for the first time.
sleep 5
while true; do
OUTPUT=$(sudo k3s kubectl get pods -A --no-headers 2>&1)
# Checks if there are no resources found --> Kubernetes ist still starting up
if echo "$OUTPUT" | grep -q "No resources found"; then
echo "Kubernetes is still starting up..."
else
# Extracts the STATUS column from the kubectl output and filters out the values "Running" and "Completed".
STATUS=$(echo "$OUTPUT" | awk '{print $4}' | grep -vE '^(Running|Completed)$')
# If the STATUS variable is empty, all pods are running and the loop can be exited.
if [ -z "$STATUS" ]; then
echo "Pods started successfully."
break
else
echo "Waiting for all pods to come online..."
fi
fi
# Waits for X seconds before checking the pod status again.
sleep 10
done
# Waits another 5 seconds to make sure all pods are ready.
sleep 5
sudo kubectl get node
sudo kubectl get pods -A
}
# Prompt for network interface
select_network_interface
# install nfs-common and open-iscsi
echo "Installing nfs-common..."
sudo apt-get update
sudo apt-get install open-iscsi nfs-common -y
# Installation of k3s
#curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="--node-ip=192.168.1.2 --advertise-address=192.168.1.2 --node-external-ip=188.245.236.232 --flannel-iface=enp7s0" INSTALL_K3S_VERSION="v1.31.3+k3s1" sh -
echo "Installing k3s with --flannel-iface=$selected_iface"
curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="--flannel-iface=$selected_iface" INSTALL_K3S_VERSION="v1.31.3+k3s1" sh -
# Todo: Check for Ready node, takes ~30 seconds
sudo k3s kubectl get node
echo "Waiting for Kubernetes to start..."
wait_until_all_pods_running
# Installation of Longhorn
sudo kubectl apply -f https://raw.githubusercontent.com/longhorn/longhorn/v1.7.2/deploy/longhorn.yaml
echo "Waiting for Longhorn to start..."
wait_until_all_pods_running
# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
# THIS MUST BE INSTALLED ON ALL NODES --> https://longhorn.io/docs/1.7.2/deploy/install/#installing-nfsv4-client
# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
#sudo kubectl apply -f https://raw.githubusercontent.com/longhorn/longhorn/v1.6.0/deploy/prerequisite/longhorn-nfs-installation.yaml
#wait_until_all_pods_running
# Installation of Cert-Manager
sudo kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.16.1/cert-manager.yaml
echo "Waiting for Cert-Manager to start..."
wait_until_all_pods_running
sudo kubectl -n cert-manager get pod
# Checking installation of Longhorn
sudo apt-get install jq -y
sudo curl -sSfL https://raw.githubusercontent.com/longhorn/longhorn/v1.7.2/scripts/environment_check.sh | bash
joinTokenForOtherNodes=$(sudo cat /var/lib/rancher/k3s/server/node-token)
# deploy QuickStack
cat <<EOF >quickstack-setup-job.yaml
apiVersion: v1
kind: Namespace
metadata:
name: quickstack
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: qs-service-account
namespace: quickstack
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: qs-role-binding
subjects:
- kind: ServiceAccount
name: qs-service-account
namespace: quickstack
roleRef:
kind: ClusterRole
name: cluster-admin
apiGroup: rbac.authorization.k8s.io
---
apiVersion: batch/v1
kind: Job
metadata:
name: quickstack-setup-job
namespace: quickstack
spec:
ttlSecondsAfterFinished: 3600
template:
spec:
serviceAccountName: qs-service-account
containers:
- name: quickstack-container
image: quickstack/quickstack:canary
env:
- name: START_MODE
value: "setup"
- name: K3S_JOIN_TOKEN
value: "$joinTokenForOtherNodes"
imagePullPolicy: Always
restartPolicy: Never
backoffLimit: 0
EOF
sudo kubectl apply -f quickstack-setup-job.yaml
rm quickstack-setup-job.yaml
wait_until_all_pods_running
sudo kubectl logs -f job/quickstack-setup-job -n quickstack
# evaluate url to add node to cluster
# echo "To add an additional node to the cluster, run the following command on the worker node:"
# echo "curl -sfL https://get.quickstack.dev/setup-worker.sh | K3S_URL=https://<IP-ADDRESS-OR-HOSTNAME-OF-MASTERNODE>:6443 JOIN_TOKEN=$joinTokenForOtherNodes sh -"

View File

@@ -35,7 +35,7 @@ select_network_interface() {
i=$((i + 1))
done
printf "Please enter the number of the interface to use (1-%d): " "$((i-1))"
printf "Please enter the number of the interface to use: "
# Change read to use /dev/tty explicitly
read -r choice </dev/tty

View File

@@ -23,7 +23,7 @@ select_network_interface() {
i=$((i + 1))
done
printf "Please enter the number of the interface to use (1-%d): " "$((i - 1))"
printf "Please enter the number of the interface to use: "
# Change read to use /dev/tty explicitly
read -r choice </dev/tty

View File

@@ -0,0 +1,28 @@
import { DateUtils } from '../../../shared/utils/date.utils';
describe('DateUtils', () => {
test('should return true for the same day', () => {
const date1 = new Date(2023, 9, 10);
const date2 = new Date(2023, 9, 10);
expect(DateUtils.isSameDay(date1, date2)).toBe(true);
});
test('should return false for different days', () => {
const date1 = new Date(2023, 9, 10);
const date2 = new Date(2023, 9, 11);
expect(DateUtils.isSameDay(date1, date2)).toBe(false);
});
test('should return false for different months', () => {
const date1 = new Date(2023, 8, 10);
const date2 = new Date(2023, 9, 10);
expect(DateUtils.isSameDay(date1, date2)).toBe(false);
});
test('should return false for different years', () => {
const date1 = new Date(2022, 9, 10);
const date2 = new Date(2023, 9, 10);
expect(DateUtils.isSameDay(date1, date2)).toBe(false);
});
});

View File

@@ -0,0 +1,25 @@
import { TraefikMeUtils } from '../../../shared/utils/traefik-me.utils';
describe('TraefikMeUtils', () => {
describe('isValidTraefikMeDomain', () => {
it('should return true for valid traefik.me domain', () => {
expect(TraefikMeUtils.isValidTraefikMeDomain('example.traefik.me')).toBe(true);
});
it('should return false for domain not ending with .traefik.me', () => {
expect(TraefikMeUtils.isValidTraefikMeDomain('example.com')).toBe(false);
});
it('should return false for domain with more than three parts', () => {
expect(TraefikMeUtils.isValidTraefikMeDomain('sub.example.traefik.me')).toBe(false);
});
it('should return false for domain with less than three parts', () => {
expect(TraefikMeUtils.isValidTraefikMeDomain('traefik.me')).toBe(false);
});
it('should return false for empty string', () => {
expect(TraefikMeUtils.isValidTraefikMeDomain('')).toBe(false);
});
});
});

View File

@@ -0,0 +1,46 @@
import { FsUtils } from "@/server/utils/fs.utils";
import { PathUtils } from "@/server/utils/path.utils";
import { NextRequest, NextResponse } from "next/server";
import fs from 'fs/promises';
import { getAuthUserSession } from "@/server/utils/action-wrapper.utils";
import { ServiceException } from "@/shared/model/service.exception.model";
import { z } from "zod";
import { stringToDate } from "@/shared/utils/zod.utils";
// Prevents this route's response from being cached
export const dynamic = "force-dynamic";
const zodInputModel = z.object({
appId: z.string().min(1),
date: stringToDate
});
export async function GET(request: NextRequest) {
try {
await getAuthUserSession();
const requestUrl = new URL(request.url);
const appId = requestUrl.searchParams.get('appId');
const date = requestUrl.searchParams.get('date');
const validatedData = zodInputModel.parse({ appId, date });
const logsPath = PathUtils.appLogsFile(validatedData.appId, validatedData.date);
if (!await FsUtils.fileExists(logsPath)) {
throw new ServiceException(`Could not find logs for ${appId}.`);
}
const buffer = await fs.readFile(logsPath);
return new NextResponse(buffer, {
headers: {
'Content-Type': 'application/gzip',
'Content-Disposition':
`attachment; filename="${appId}-${validatedData.date.toISOString().split('T')[0]}.tar.gz"`,
},
});
} catch (error) {
console.error('Error while downloading data:', error);
return new Response((error as Error)?.message ?? 'An unknown error occured.', { status: 500 });
}
}

View File

@@ -31,7 +31,7 @@ export async function GET(request: NextRequest) {
return new NextResponse(buffer, {
headers: {
'Content-Type': 'application/pdf',
'Content-Type': 'application/gzip',
'Content-Disposition': `attachment; filename="volume-data.tar.gz"`,
},
});

View File

@@ -8,6 +8,7 @@ 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";
import traefikMeDomainStandaloneService from "@/server/services/standalone-services/traefik-me-domain-standalone.service";
export const registerUser = async (prevState: any, inputData: RegisterFormInputSchema) =>
@@ -25,6 +26,12 @@ export const registerUser = async (prevState: any, inputData: RegisterFormInputS
// ignore
console.error('Failes to evaluate public ip address', e);
}
try {
await traefikMeDomainStandaloneService.updateTraefikMeCertificate();
} catch (e) {
// ignore
console.error('Failed to update traefik me certificate', e);
}
if (validatedData.qsHostname) {
const url = new URL(validatedData.qsHostname.includes('://') ? validatedData.qsHostname : `https://${validatedData.qsHostname}`);
await paramService.save({

View File

@@ -25,4 +25,20 @@ export const downloadBackup = async (s3TargetId: string, s3Key: string) =>
const fileNameOfDownloadedFile = await backupService.downloadBackupForS3TargetAndKey(validatetData.s3TargetId, validatetData.s3Key);
return new SuccessActionResult(fileNameOfDownloadedFile, 'Starting download...'); // returns the download path on the server
}) as Promise<ServerActionResult<any, string>>;
export const deleteBackup = async (s3TargetId: string, s3Key: string) =>
simpleAction(async () => {
await getAuthUserSession();
const validatetData = z.object({
s3TargetId: z.string(),
s3Key: z.string()
}).parse({
s3TargetId,
s3Key
});
await backupService.deleteBackupFromS3(validatetData.s3TargetId, validatetData.s3Key);
return new SuccessActionResult(undefined, 'Backup will be deleted. Refresh the page to see the changes.');
}) as Promise<ServerActionResult<any, string>>;

View File

@@ -12,10 +12,11 @@ import { ScrollArea } from "@radix-ui/react-scroll-area";
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { KubeSizeConverter } from "@/shared/utils/kubernetes-size-converter.utils";
import { formatDateTime } from "@/frontend/utils/format.utils";
import { downloadBackup } from "./actions";
import { deleteBackup, downloadBackup } from "./actions";
import { Button } from "@/components/ui/button";
import { Download } from "lucide-react";
import { Download, Trash2 } from "lucide-react";
import { Toast } from "@/frontend/utils/toast.utils";
import { useConfirmDialog } from "@/frontend/states/zustand.states";
export function BackupDetailDialog({
backupInfo,
@@ -25,6 +26,7 @@ export function BackupDetailDialog({
children: React.ReactNode;
}) {
const { openConfirmDialog } = useConfirmDialog();
const [isOpen, setIsOpen] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(false);
@@ -41,6 +43,16 @@ export function BackupDetailDialog({
}
}
const asyncDeleteBackup = async (s3Key: string) => {
if (await openConfirmDialog({
title: 'Delete Backup',
description: 'This action deletes the backup from the storage. This action cannot be undone.',
okButton: 'Delete'
})) {
await Toast.fromAction(() => deleteBackup(backupInfo.s3TargetId, s3Key));
}
}
return (
<Dialog open={isOpen} onOpenChange={(isO) => {
setIsOpen(isO);
@@ -72,10 +84,13 @@ export function BackupDetailDialog({
<TableRow key={index}>
<TableCell>{formatDateTime(item.backupDate, true)}</TableCell>
<TableCell>{item.sizeBytes ? KubeSizeConverter.convertBytesToReadableSize(item.sizeBytes) : 'unknown'}</TableCell>
<TableCell className="flex justify-end">
<TableCell className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => asyncDownloadPvcData(item.key)} disabled={isLoading}>
<Download />
</Button>
<Button variant="ghost" size="sm" onClick={() => asyncDeleteBackup(item.key)} disabled={isLoading}>
<Trash2 />
</Button>
</TableCell>
</TableRow>
))}

View File

@@ -7,6 +7,10 @@ import { z } from "zod";
import appTemplateService from "@/server/services/app-template.service";
import { AppTemplateModel, appTemplateZodModel } from "@/shared/model/app-template.model";
import { ServiceException } from "@/shared/model/service.exception.model";
import dbGateService from "@/server/services/db-tool-services/dbgate.service";
import fileBrowserService from "@/server/services/file-browser-service";
import phpMyAdminService from "@/server/services/db-tool-services/phpmyadmin.service";
import pgAdminService from "@/server/services/db-tool-services/pgadmin.service";
const createAppSchema = z.object({
appName: z.string().min(1)
@@ -25,7 +29,7 @@ export const createApp = async (appName: string, projectId: string, appId?: stri
return new SuccessActionResult(returnData, "App created successfully.");
});
export const createAppFromTemplate = async(prevState: any, inputData: AppTemplateModel, projectId: string) =>
export const createAppFromTemplate = async (prevState: any, inputData: AppTemplateModel, projectId: string) =>
saveFormAction(inputData, appTemplateZodModel, async (validatedData) => {
await getAuthUserSession();
if (validatedData.templates.some(x => x.inputSettings.some(y => !y.randomGeneratedIfEmpty && !y.value))) {
@@ -38,6 +42,15 @@ export const createAppFromTemplate = async(prevState: any, inputData: AppTemplat
export const deleteApp = async (appId: string) =>
simpleAction(async () => {
await getAuthUserSession();
const app = await appService.getExtendedById(appId);
// First delete external services wich might be running
await dbGateService.deleteToolForAppIfExists(appId);
await phpMyAdminService.deleteToolForAppIfExists(appId);
await pgAdminService.deleteToolForAppIfExists(appId);
for (const volume of app.appVolumes) {
await fileBrowserService.deleteFileBrowserForVolumeIfExists(volume.id);
}
// delete the app drom database and all kubernetes objects
await appService.deleteById(appId);
return new SuccessActionResult(undefined, "App deleted successfully.");
});

View File

@@ -20,6 +20,7 @@ import VolumeBackupList from "./volumes/volume-backup";
import { VolumeBackupExtendedModel } from "@/shared/model/volume-backup-extended.model";
import BasicAuth from "./advanced/basic-auth";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import DbToolsCard from "./credentials/db-tools";
export default function AppTabs({
app,
@@ -60,6 +61,7 @@ export default function AppTabs({
<WebhookDeploymentInfo app={app} />
</TabsContent>
{app.appType !== 'APP' && <TabsContent value="credentials" className="space-y-4">
<DbToolsCard app={app} />
<DbCredentials app={app} />
</TabsContent>}
<TabsContent value="general" className="space-y-4">

View File

@@ -1,10 +1,22 @@
'use server'
import appService from "@/server/services/app.service";
import dbGateService from "@/server/services/db-tool-services/dbgate.service";
import pgAdminService from "@/server/services/db-tool-services/pgadmin.service";
import phpMyAdminService from "@/server/services/db-tool-services/phpmyadmin.service";
import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
import { AppTemplateUtils } from "@/server/utils/app-template.utils";
import { DatabaseTemplateInfoModel } from "@/shared/model/database-template-info.model";
import { ServerActionResult, SuccessActionResult } from "@/shared/model/server-action-error-return.model";
import { ServiceException } from "@/shared/model/service.exception.model";
export type DbToolIds = 'dbgate' | 'phpmyadmin' | 'pgadmin';
const dbToolClasses = new Map([
['dbgate', dbGateService],
['phpmyadmin', phpMyAdminService],
['pgadmin', pgAdminService]
])
export const getDatabaseCredentials = async (appId: string) =>
simpleAction(async () => {
@@ -12,4 +24,59 @@ export const getDatabaseCredentials = async (appId: string) =>
const app = await appService.getExtendedById(appId);
const credentials = AppTemplateUtils.getDatabaseModelFromApp(app);
return new SuccessActionResult(credentials);
}) as Promise<ServerActionResult<unknown, DatabaseTemplateInfoModel>>;
}) as Promise<ServerActionResult<unknown, DatabaseTemplateInfoModel>>;
export const getIsDbToolActive = async (appId: string, dbTool: DbToolIds) =>
simpleAction(async () => {
await getAuthUserSession();
if (!dbToolClasses.has(dbTool)) {
throw new ServiceException('Unknown db tool');
}
const isActive = dbToolClasses.get(dbTool)!.isDbToolRunning(appId);
return new SuccessActionResult(isActive);
}) as Promise<ServerActionResult<unknown, boolean>>;
export const deployDbTool = async (appId: string, dbTool: DbToolIds) =>
simpleAction(async () => {
await getAuthUserSession();
const currentDbTool = dbToolClasses.get(dbTool);
if (!currentDbTool) {
throw new ServiceException('Unknown db tool');
}
await currentDbTool.deploy(appId);
return new SuccessActionResult();
}) as Promise<ServerActionResult<unknown, void>>;
export const getLoginCredentialsForRunningDbTool = async (appId: string, dbTool: DbToolIds) =>
simpleAction(async () => {
await getAuthUserSession();
const currentDbTool = dbToolClasses.get(dbTool);
if (!currentDbTool) {
throw new ServiceException('Unknown db tool');
}
return new SuccessActionResult(await currentDbTool.getLoginCredentialsForRunningDbGate(appId));
}) as Promise<ServerActionResult<unknown, { url: string; username: string, password: string }>>;
export const deleteDbToolDeploymentForAppIfExists = async (appId: string, dbTool: DbToolIds) =>
simpleAction(async () => {
await getAuthUserSession();
const currentDbTool = dbToolClasses.get(dbTool);
if (!currentDbTool) {
throw new ServiceException('Unknown db tool');
}
await currentDbTool.deleteToolForAppIfExists(appId);
return new SuccessActionResult();
}) as Promise<ServerActionResult<unknown, void>>;
export const downloadDbGateFilesForApp = async (appId: string) =>
simpleAction(async () => {
await getAuthUserSession();
const url = await dbGateService.downloadDbGateFilesForApp(appId);
return new SuccessActionResult(url);
}) as Promise<ServerActionResult<unknown, string>>;

View File

@@ -1,15 +1,9 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { AppExtendedModel } from "@/shared/model/app-extended.model";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { useConfirmDialog } from "@/frontend/states/zustand.states";
import { Toast } from "@/frontend/utils/toast.utils";
import { ClipboardCopy } from "lucide-react";
import { toast } from "sonner";
import { DatabaseTemplateInfoModel } from "@/shared/model/database-template-info.model";
import { Actions } from "@/frontend/utils/nextjs-actions.utils";
import { getDatabaseCredentials } from "./actions";
import { Label } from "@/components/ui/label";
import CopyInputField from "@/components/custom/copy-input-field";
import FullLoadingSpinner from "@/components/ui/full-loading-spinnter";
@@ -34,10 +28,6 @@ export default function DbCredentials({
}
}, [app]);
if (!databaseCredentials) {
return <FullLoadingSpinner />;
}
return <>
<Card>
<CardHeader>
@@ -45,37 +35,39 @@ export default function DbCredentials({
<CardDescription>Use these credentials to connect to your database from other apps within the same project.</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<CopyInputField
label="Database Name"
value={databaseCredentials?.databaseName || ''} />
{!databaseCredentials ? <FullLoadingSpinner /> : <>
<div className="grid grid-cols-2 gap-4">
<CopyInputField
label="Database Name"
value={databaseCredentials?.databaseName || ''} />
<div></div>
<div></div>
<CopyInputField
label="Username"
value={databaseCredentials?.username || ''} />
<CopyInputField
label="Username"
value={databaseCredentials?.username || ''} />
<CopyInputField
label="Password"
secret={true}
value={databaseCredentials?.password || ''} />
<CopyInputField
label="Password"
secret={true}
value={databaseCredentials?.password || ''} />
<CopyInputField
label="Internal Hostname"
value={databaseCredentials?.hostname || ''} />
<CopyInputField
label="Internal Hostname"
value={databaseCredentials?.hostname || ''} />
<CopyInputField
label="Internal Port"
value={(databaseCredentials?.port + '')} />
</div>
<div className="grid grid-cols-1 gap-4 pt-4">
<CopyInputField
label="Internal Connection URL"
secret={true}
value={databaseCredentials?.internalConnectionUrl || ''} />
</div>
<CopyInputField
label="Internal Port"
value={(databaseCredentials?.port + '')} />
</div>
<div className="grid grid-cols-1 gap-4 pt-4">
<CopyInputField
label="Internal Connection URL"
secret={true}
value={databaseCredentials?.internalConnectionUrl || ''} />
</div>
</>}
</CardContent>
</Card>
</>;

View File

@@ -0,0 +1,120 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { AppExtendedModel } from "@/shared/model/app-extended.model";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { useConfirmDialog } from "@/frontend/states/zustand.states";
import { Toast } from "@/frontend/utils/toast.utils";
import { Actions } from "@/frontend/utils/nextjs-actions.utils";
import { deleteDbToolDeploymentForAppIfExists, deployDbTool, downloadDbGateFilesForApp, getIsDbToolActive, getLoginCredentialsForRunningDbTool } from "./actions";
import { Label } from "@/components/ui/label";
import FullLoadingSpinner from "@/components/ui/full-loading-spinnter";
import { Switch } from "@/components/ui/switch";
import { Code } from "@/components/custom/code";
import LoadingSpinner from "@/components/ui/loading-spinner";
import { Download } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
export default function DbGateDbTool({
app
}: {
app: AppExtendedModel;
}) {
const { openConfirmDialog } = useConfirmDialog();
const [isDbGateActive, setIsDbGateActive] = useState<boolean | undefined>(undefined);
const [loading, setLoading] = useState(false);
const loadIsDbGateActive = async (appId: string) => {
const response = await Actions.run(() => getIsDbToolActive(appId, 'dbgate'));
setIsDbGateActive(response);
}
const downloadDbGateFilesForAppAsync = async () => {
try {
setLoading(true);
await Toast.fromAction(() => downloadDbGateFilesForApp(app.id)).then(x => {
if (x.status === 'success' && x.data) {
window.open('/api/volume-data-download?fileName=' + x.data);
}
});
} finally {
setLoading(false);
}
}
const openDbGateAsync = async () => {
try {
setLoading(true);
const credentials = await Actions.run(() => getLoginCredentialsForRunningDbTool(app.id, 'dbgate'));
setLoading(false);
await openConfirmDialog({
title: "Open DB Gate",
description: <>
DB Gate 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>{credentials.username}</Code></div>
</div>
<div className="pt-3 pb-4 grid grid-cols-1 gap-1">
<Label>Password</Label>
<div><Code>{credentials.password}</Code></div>
</div>
<div>
<Button variant='outline' onClick={() => window.open(credentials.url, '_blank')}>Open DB Gate</Button>
</div>
</>,
okButton: '',
cancelButton: "Close"
});
} finally {
setLoading(false);
}
}
useEffect(() => {
loadIsDbGateActive(app.id);
return () => {
setIsDbGateActive(undefined);
}
}, [app]);
return <>
<div className="flex gap-4 items-center">
<div className="flex items-center space-x-3">
<Switch id="canary-channel-mode" disabled={loading || isDbGateActive === undefined} checked={isDbGateActive} onCheckedChange={async (checked) => {
try {
setLoading(true);
if (checked) {
await Toast.fromAction(() => deployDbTool(app.id, 'dbgate'), 'DB Gate is now activated', 'Activating DB Gate...');
} else {
await Toast.fromAction(() => deleteDbToolDeploymentForAppIfExists(app.id, 'dbgate'), 'DB Gate has been deactivated', 'Deactivating DB Gate...');
}
await loadIsDbGateActive(app.id);
} finally {
setLoading(false);
}
}} />
<Label htmlFor="airplane-mode">DB Gate</Label>
</div>
{isDbGateActive && <>
<Button variant='outline' onClick={() => openDbGateAsync()}
disabled={loading}>Open DB Gate</Button>
<TooltipProvider>
<Tooltip delayDuration={300}>
<TooltipTrigger>
<Button onClick={() => downloadDbGateFilesForAppAsync()} disabled={!isDbGateActive || loading}
variant="ghost"><Download /></Button>
</TooltipTrigger>
<TooltipContent>
<p>Download the "Files" folder from DB Gate.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</>}
{(loading || isDbGateActive === undefined) && <LoadingSpinner></LoadingSpinner>}
</div>
</>;
}

View File

@@ -0,0 +1,26 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { AppExtendedModel } from "@/shared/model/app-extended.model";
import DbGateDbTool from "./db-gate-db-tool";
import DbToolSwitch from "./phpmyadmin-db-tool";
export default function DbToolsCard({
app
}: {
app: AppExtendedModel;
}) {
return <>
<Card>
<CardHeader>
<CardTitle>Database Access</CardTitle>
<CardDescription>Activate one of the following tools to access the database through your browser.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<DbGateDbTool app={app} />
{['MYSQL', 'MARIADB'].includes(app.appType) && <DbToolSwitch app={app} toolId="phpmyadmin"
toolNameString="PHP My Admin" />}
{app.appType === 'POSTGRES' && <DbToolSwitch app={app} toolId="pgadmin" toolNameString="pgAdmin" />}
</CardContent>
</Card >
</>;
}

View File

@@ -0,0 +1,94 @@
import { AppExtendedModel } from "@/shared/model/app-extended.model";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { useConfirmDialog } from "@/frontend/states/zustand.states";
import { Toast } from "@/frontend/utils/toast.utils";
import { Actions } from "@/frontend/utils/nextjs-actions.utils";
import { DbToolIds, deleteDbToolDeploymentForAppIfExists, deployDbTool, getIsDbToolActive, getLoginCredentialsForRunningDbTool } from "./actions";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Code } from "@/components/custom/code";
import LoadingSpinner from "@/components/ui/loading-spinner";
export default function DbToolSwitch({
app,
toolId,
toolNameString
}: {
app: AppExtendedModel;
toolId: DbToolIds;
toolNameString: string;
}) {
const { openConfirmDialog } = useConfirmDialog();
const [isDbToolActive, setIsDbToolActive] = useState<boolean | undefined>(undefined);
const [loading, setLoading] = useState(false);
const loadIdDbToolActive = async (appId: string) => {
const response = await Actions.run(() => getIsDbToolActive(appId, toolId));
setIsDbToolActive(response);
}
const openDbTool = async () => {
try {
setLoading(true);
const credentials = await Actions.run(() => getLoginCredentialsForRunningDbTool(app.id, toolId));
setLoading(false);
await openConfirmDialog({
title: "Open DB Tool",
description: <>
{toolNameString} 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>{credentials.username}</Code></div>
</div>
<div className="pt-3 pb-4 grid grid-cols-1 gap-1">
<Label>Password</Label>
<div><Code>{credentials.password}</Code></div>
</div>
<div>
<Button variant='outline' onClick={() => window.open(credentials.url, '_blank')}>Open {toolNameString}</Button>
</div>
</>,
okButton: '',
cancelButton: "Close"
});
} finally {
setLoading(false);
}
}
useEffect(() => {
loadIdDbToolActive(app.id);
return () => {
setIsDbToolActive(undefined);
}
}, [app]);
return <>
<div className="flex gap-4 items-center">
<div className="flex items-center space-x-3">
<Switch id="canary-channel-mode" disabled={loading || isDbToolActive === undefined} checked={isDbToolActive} onCheckedChange={async (checked) => {
try {
setLoading(true);
if (checked) {
await Toast.fromAction(() => deployDbTool(app.id, toolId), `${toolNameString} is now activated`, `activating ${toolNameString}...`);
} else {
await Toast.fromAction(() => deleteDbToolDeploymentForAppIfExists(app.id, toolId), `${toolNameString} has been deactivated`, `Deactivating ${toolNameString}...`);
}
await loadIdDbToolActive(app.id);
} finally {
setLoading(false);
}
}} />
<Label htmlFor="airplane-mode">{toolNameString}</Label>
</div>
{isDbToolActive && <>
<Button variant='outline' onClick={() => openDbTool()}
disabled={!isDbToolActive || loading}>Open {toolNameString}</Button>
</>}
{(loading || isDbToolActive === undefined) && <LoadingSpinner></LoadingSpinner>}
</div>
</>;
}

View File

@@ -6,6 +6,8 @@ import { SuccessActionResult } from "@/shared/model/server-action-error-return.m
import appService from "@/server/services/app.service";
import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
import { z } from "zod";
import { TraefikMeUtils } from "@/shared/utils/traefik-me.utils";
import { ServiceException } from "@/shared/model/service.exception.model";
const actionAppDomainEditZodModel = appDomainEditZodModel.merge(z.object({
appId: z.string(),
@@ -20,6 +22,13 @@ export const saveDomain = async (prevState: any, inputData: z.infer<typeof actio
const url = new URL(validatedData.hostname);
validatedData.hostname = url.hostname;
}
if (TraefikMeUtils.containesTraefikMeDomain(validatedData.hostname)) {
if (!TraefikMeUtils.isValidTraefikMeDomain(validatedData.hostname)) {
throw new ServiceException('Invalid traefik.me domain. Subdomain of traefik.me cannot contain dots.');
}
}
await appService.saveDomain({
...validatedData,
id: validatedData.id ?? undefined

View File

@@ -38,26 +38,30 @@ export default function EnvEdit({ app }: {
<Card>
<CardHeader>
<CardTitle>Environment Variables</CardTitle>
<CardDescription>Provide optional environment variables for your application.</CardDescription>
<CardDescription>
Provide optional environment variables for your application.
{app.appType !== 'APP' && <div className="text-sm text-red-500 pt-2">You should not change ENV variables for databases.</div>}
</CardDescription>
</CardHeader>
<Form {...form}>
<form action={(e) => form.handleSubmit((data) => {
return formAction(data);
})()}>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="envVars"
render={({ field }) => (
<FormItem>
<FormLabel>Env Variables</FormLabel>
<FormControl>
<Textarea className="h-96" placeholder="NAME=VALUE..." {...field} value={field.value} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="envVars"
render={({ field }) => (
<FormItem>
<FormLabel>Env Variables</FormLabel>
<FormControl>
<Textarea className="h-96" placeholder="NAME=VALUE..." {...field} value={field.value} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
<CardFooter>
<SubmitButton>Save</SubmitButton>

View File

@@ -1,6 +1,5 @@
'use server'
import { BuildJobModel } from "@/shared/model/build-job";
import { DeploymentInfoModel } from "@/shared/model/deployment-info.model";
import { PodsInfoModel } from "@/shared/model/pods-info.model";
import { ServerActionResult, SuccessActionResult } from "@/shared/model/server-action-error-return.model";
@@ -11,6 +10,9 @@ import monitoringService from "@/server/services/monitoring.service";
import podService from "@/server/services/pod.service";
import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
import { PodsResourceInfoModel } from "@/shared/model/pods-resource-info.model";
import appLogsService from "@/server/services/standalone-services/app-logs.service";
import { DownloadableAppLogsModel } from "@/shared/model/downloadable-app-logs.model";
import { ServiceException } from "@/shared/model/service.exception.model";
export const getDeploymentsAndBuildsForApp = async (appId: string) =>
@@ -44,4 +46,20 @@ export const createNewWebhookUrl = async (appId: string) =>
simpleAction(async () => {
await getAuthUserSession();
await appService.regenerateWebhookId(appId);
});
});
export const getDownloadableLogs = async (appId: string) =>
simpleAction(async () => {
await getAuthUserSession();
return new SuccessActionResult(await appLogsService.getAvailableLogsForApp(appId));
}) as Promise<ServerActionResult<unknown, DownloadableAppLogsModel[]>>;
export const exportLogsToFileForToday = async (appId: string) =>
simpleAction(async () => {
await getAuthUserSession();
const result = await appLogsService.writeAppLogsToDiskForApp(appId);
if (!result) {
throw new ServiceException('There are no logs available for today.');
}
return new SuccessActionResult(result);
}) as Promise<ServerActionResult<unknown, DownloadableAppLogsModel | undefined>>;

View File

@@ -0,0 +1,118 @@
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import React, { useEffect } from "react";
import { formatDate } from "@/frontend/utils/format.utils";
import { DownloadableAppLogsModel } from "@/shared/model/downloadable-app-logs.model";
import { toast } from "sonner";
import { Actions } from "@/frontend/utils/nextjs-actions.utils";
import { exportLogsToFileForToday, getDownloadableLogs } from "./actions";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Download } from "lucide-react";
import FullLoadingSpinner from "@/components/ui/full-loading-spinnter";
import { Toast } from "@/frontend/utils/toast.utils";
import { DateUtils } from "@/shared/utils/date.utils";
export function LogsDownloadOverlay({
children,
appId,
onClose
}: {
children: React.ReactNode;
appId: string;
onClose?: () => void;
}) {
const [logs, setLogs] = React.useState<DownloadableAppLogsModel[] | undefined>(undefined);
const [isLoading, setIsLoading] = React.useState(false);
const getLogsListAsync = async () => {
setIsLoading(true);
try {
let logs = await Actions.run(() => getDownloadableLogs(appId));
const today = new Date();
logs = logs.filter(log => !DateUtils.isSameDay(today, log.date));
logs.unshift({
appId: appId,
date: new Date()
});
setLogs(logs);
} catch (error) {
toast.error('Error while loading log files');
} finally {
setIsLoading(false);
}
}
const downloadLogFile = async (item: DownloadableAppLogsModel) => {
try {
setIsLoading(true);
// check if item.date is today
const today = new Date();
if (DateUtils.isSameDay(today, item.date)) {
const logsToOpen = await Toast.fromAction(() => exportLogsToFileForToday(appId));
if (!logsToOpen.data) {
throw new Error('No logs available for today');
}
item = logsToOpen.data;
}
window.open(`/api/logs-download?appId=${appId}&date=${item.date.toISOString()}`, '_blank');
} finally {
setIsLoading(false);
}
}
useEffect(() => {
getLogsListAsync();
}, [appId]);
return (
<Dialog onOpenChange={(isO) => {
if (!isO) {
onClose?.();
}
}}>
<DialogTrigger asChild>
{children}
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Logs Download</DialogTitle>
<DialogDescription>
Every day a new export of the logs is created. You can download the logs of the running pod(s) or the logs from the past.
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[70vh]">
{logs ? <Table>
<TableCaption>{logs.length} logs</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs.map((item, index) => (
<TableRow key={index}>
<TableCell>{formatDate(item.date)}</TableCell>
<TableCell className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => downloadLogFile(item)} disabled={isLoading}>
<Download />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table> : <FullLoadingSpinner />}
</ScrollArea>
</DialogContent>
</Dialog>
)
}

View File

@@ -9,8 +9,10 @@ import FullLoadingSpinner from "@/components/ui/full-loading-spinnter";
import { toast } from "sonner";
import { LogsDialog } from "@/components/custom/logs-overlay";
import { Button } from "@/components/ui/button";
import { Expand, SquareArrowUp, SquareArrowUpRight, Terminal } from "lucide-react";
import { Download, Expand, Terminal } from "lucide-react";
import { TerminalDialog } from "./terminal-overlay";
import { LogsDownloadOverlay } from "./logs-download-overlay";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
export default function Logs({
app
@@ -70,7 +72,7 @@ export default function Logs({
<SelectValue placeholder="Pod wählen" />
</SelectTrigger>
<SelectContent>
{appPods.map(pod => <SelectItem key={pod.podName} value={pod.podName}>{pod.podName}</SelectItem>)}
{appPods.map(pod => <SelectItem key={pod.podName} value={pod.podName}>{pod.podName} ({pod.status})</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -86,11 +88,34 @@ export default function Logs({
</TerminalDialog>
</div>
<div>
<LogsDialog namespace={app.projectId} podName={selectedPod.podName}>
<Button variant="secondary">
<Expand />
</Button>
</LogsDialog>
<TooltipProvider>
<Tooltip delayDuration={300}>
<TooltipTrigger>
<LogsDownloadOverlay appId={app.id} >
<Button variant="secondary">
<Download />
</Button>
</LogsDownloadOverlay>
</TooltipTrigger>
<TooltipContent>
<p>Download Logs</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div>
<Tooltip delayDuration={300}>
<TooltipTrigger>
<LogsDialog namespace={app.projectId} podName={selectedPod.podName}>
<Button variant="secondary">
<Expand />
</Button>
</LogsDialog>
</TooltipTrigger>
<TooltipContent>
<p>Fullscreen Logs</p>
</TooltipContent>
</Tooltip>
</div>
</div>}
{app.projectId && selectedPod && <LogsStreamed namespace={app.projectId} podName={selectedPod.podName} />}

View File

@@ -24,11 +24,6 @@ import { AppExtendedModel } from "@/shared/model/app-extended.model"
import { FileMountEditModel, fileMountEditZodModel } from "@/shared/model/file-mount-edit.model"
import { Textarea } from "@/components/ui/textarea"
const accessModes = [
{ label: "ReadWriteOnce", value: "ReadWriteOnce" },
{ label: "ReadWriteMany", value: "ReadWriteMany" },
] as const
export default function FileMountEditDialog({ children, fileMount, app }: { children: React.ReactNode; fileMount?: AppFileMount; app: AppExtendedModel; }) {
const [isOpen, setIsOpen] = useState<boolean>(false);

View File

@@ -1,7 +1,7 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cleanupOldBuildJobs, cleanupOldTmpFiles, purgeRegistryImages, updateRegistry } from "../server/actions";
import { cleanupOldBuildJobs, cleanupOldTmpFiles, deleteAllFailedAndSuccededPods, deleteOldAppLogs, purgeRegistryImages, updateRegistry, updateTraefikMeCertificates } from "../server/actions";
import { Button } from "@/components/ui/button";
import { Toast } from "@/frontend/utils/toast.utils";
import { useConfirmDialog } from "@/frontend/states/zustand.states";
@@ -54,6 +54,25 @@ export default function QuickStackMaintenanceSettings({
}
}}><Trash /> Cleanup Temp Files</Button>
<Button variant="secondary" onClick={async () => {
if (await useConfirm.openConfirmDialog({
title: 'Delete old App logs',
description: 'This action deletes all old app logs. Use this action to free up disk space.',
okButton: "Delete old App logs"
})) {
Toast.fromAction(() => deleteOldAppLogs());
}
}}><Trash /> Delete old App logs</Button>
<Button variant="secondary" onClick={async () => {
if (await useConfirm.openConfirmDialog({
title: 'Delete Orphaned Containers',
description: 'This action deletes all unused pods (failed or succeded). Use this action to free up resources.',
okButton: "Delete Orphaned Containers"
})) {
Toast.fromAction(() => deleteAllFailedAndSuccededPods());
}
}}><Trash /> Delete Orphaned Containers</Button>
</CardContent>
</Card>
<Card>
@@ -76,6 +95,18 @@ export default function QuickStackMaintenanceSettings({
}
}}><RotateCcw /> Force Update Registry</Button>
<Button variant="secondary" onClick={async () => {
if (await useConfirm.openConfirmDialog({
title: 'Update Traefik.me SSL Certificates',
description: 'To use SSL with traefik.me domains, wildcard SSL certificates must be provided. Normally, this is done automatically. Use this action to force an update.',
okButton: "Update Certificates"
})) {
Toast.fromAction(() => updateTraefikMeCertificates());
}
}}><RotateCcw />Update Traefik.me SSL Certificates</Button>
</CardContent>
</Card>
</div>;

View File

@@ -13,8 +13,10 @@ import { QsPublicIpv4SettingsModel, qsPublicIpv4SettingsZodModel } from "@/share
import ipAddressFinderAdapter from "@/server/adapter/ip-adress-finder.adapter";
import { KubeSizeConverter } from "@/shared/utils/kubernetes-size-converter.utils";
import buildService from "@/server/services/build.service";
import { PathUtils } from "@/server/utils/path.utils";
import { FsUtils } from "@/server/utils/fs.utils";
import traefikMeDomainStandaloneService from "@/server/services/standalone-services/traefik-me-domain-standalone.service";
import standalonePodService from "@/server/services/standalone-services/standalone-pod.service";
import maintenanceService from "@/server/services/standalone-services/maintenance.service";
import appLogsService from "@/server/services/standalone-services/app-logs.service";
export const updateIngressSettings = async (prevState: any, inputData: QsIngressSettingsModel) =>
saveFormAction(inputData, qsIngressSettingsZodModel, async (validatedData) => {
@@ -82,9 +84,7 @@ export const getConfiguredHostname: () => Promise<ServerActionResult<unknown, st
export const cleanupOldTmpFiles = async () =>
simpleAction(async () => {
await getAuthUserSession();
const tempFilePath = PathUtils.tempDataRoot;
await FsUtils.deleteDirIfExistsAsync(tempFilePath, true);
await FsUtils.createDirIfNotExistsAsync(tempFilePath);
await maintenanceService.deleteAllTempFiles();
return new SuccessActionResult(undefined, 'Successfully cleaned up temp files.');
});
@@ -111,6 +111,20 @@ export const updateRegistry = async () =>
return new SuccessActionResult(undefined, 'Registry will be updated, this might take a few seconds.');
});
export const updateTraefikMeCertificates = async () =>
simpleAction(async () => {
await getAuthUserSession();
await traefikMeDomainStandaloneService.updateTraefikMeCertificate();
return new SuccessActionResult(undefined, 'Certificates will be updated, this might take a few seconds.');
});
export const deleteAllFailedAndSuccededPods = async () =>
simpleAction(async () => {
await getAuthUserSession();
await standalonePodService.deleteAllFailedAndSuccededPods();
return new SuccessActionResult(undefined, 'Successfully deleted all failed and succeeded pods.');
});
export const purgeRegistryImages = async () =>
simpleAction(async () => {
await getAuthUserSession();
@@ -118,6 +132,13 @@ export const purgeRegistryImages = async () =>
return new SuccessActionResult(undefined, `Successfully purged ${KubeSizeConverter.convertBytesToReadableSize(deletedSize)} of images.`);
});
export const deleteOldAppLogs = async () =>
simpleAction(async () => {
await getAuthUserSession();
await appLogsService.deleteOldAppLogs();
return new SuccessActionResult(undefined, `Successfully deletes old app logs.`);
});
export const setCanaryChannel = async (useCanaryChannel: boolean) =>
simpleAction(async () => {
await getAuthUserSession();

View File

@@ -1,20 +1,12 @@
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import React, { useEffect } from "react";
import { set } from "date-fns";
import { DeploymentInfoModel } from "@/shared/model/deployment-info.model";
import React from "react";
import { formatDateTime } from "@/frontend/utils/format.utils";
import LogsStreamed from "@/components/custom/logs-streamed";
export function LogsDialog({
@@ -33,16 +25,16 @@ export function LogsDialog({
const [linesCount, setLinesCount] = React.useState<number>(100);
const [isOpen, setIsOpen] = React.useState(false);
return (
return (<>
<div onClick={() => setIsOpen(true)}>
{children}
</div>
<Dialog open={isOpen} onOpenChange={(isO) => {
setIsOpen(isO);
if (onClose && !isO) {
onClose();
}
}}>
<DialogTrigger asChild>
{children}
</DialogTrigger>
<DialogContent className="sm:max-w-[1300px]">
<DialogHeader>
<DialogTitle>Logs</DialogTitle>
@@ -59,5 +51,6 @@ export function LogsDialog({
</div>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -24,7 +24,7 @@ export function DataTablePagination<TData>({
</div>
<div className="flex items-center space-x-6 lg:space-x-8">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium">Zeilen pro Seite</p>
<p className="text-sm font-medium">Rows per page</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
@@ -44,7 +44,7 @@ export function DataTablePagination<TData>({
</Select>
</div>
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
Seite {table.getState().pagination.pageIndex + 1} von{" "}
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</div>
<div className="flex items-center space-x-2">

View File

@@ -8,6 +8,10 @@ import dataAccess from './server/adapter/db.client'
import { FancyConsoleUtils } from './shared/utils/fancy-console.utils'
import { Constants } from './shared/utils/constants'
import backupService from './server/services/standalone-services/backup.service'
import traefikMeDomainStandaloneService from './server/services/standalone-services/traefik-me-domain-standalone.service'
import maintenanceService from './server/services/standalone-services/maintenance.service'
import passwordChangeService from './server/services/standalone-services/password-change.service'
import appLogsService from './server/services/standalone-services/app-logs.service'
// Source: https://nextjs.org/docs/app/building-your-application/configuring/custom-server
@@ -52,6 +56,9 @@ async function initializeNextJs() {
}
await backupService.registerAllBackups();
traefikMeDomainStandaloneService.configureSchedulingForTraefikMeCertificateUpdate();
maintenanceService.configureMaintenanceCronJobs();
appLogsService.configureCronJobs();
const app = next({ dev });
const handle = app.getRequestHandler();
@@ -75,6 +82,8 @@ async function initializeNextJs() {
if (process.env.NODE_ENV === 'production' && process.env.START_MODE === 'setup') {
setupQuickStack();
} else if (process.env.NODE_ENV === 'production' && process.env.START_MODE === 'reset-password') {
passwordChangeService.changeAdminPasswordAndPrintNewPassword();
} else {
initializeNextJs();
}

View File

@@ -0,0 +1,34 @@
class TraefikMeAdapter {
private traefikMeBaseURL = 'https://traefik.me';
async getCurrentPrivateKey() {
const result = await fetch(`${this.traefikMeBaseURL}/privkey.pem`, {
method: 'GET',
cache: 'no-store',
});
if (!result.ok) {
throw new Error('Failed to get private key from traefik.me');
}
const privateKeyText = result.text();
return privateKeyText;
}
async getFullChainCertificate() {
const result = await fetch(`${this.traefikMeBaseURL}/fullchain.pem`, {
method: 'GET',
cache: 'no-store',
});
if (!result.ok) {
throw new Error('Failed to get full chain from traefik.me');
}
const fullChainText = result.text();
return fullChainText;
}
}
const traefikMeAdapter = new TraefikMeAdapter();
export default traefikMeAdapter;

View File

@@ -57,6 +57,13 @@ class AppTemplateService {
});
}));
const savedFileMounts = await Promise.all(template.appFileMounts.map(async x => {
return await appService.saveFileMount({
...x,
appId: createdApp.id
});
}));
const savedPorts = await Promise.all(template.appPorts.map(async x => {
return await appService.savePort({
...x,

View File

@@ -91,7 +91,7 @@ class BuildService {
}
},
spec: {
ttlSecondsAfterFinished: 2592000, // 30 days
ttlSecondsAfterFinished: 86400, // 1 day
template: {
spec: {
containers: [
@@ -176,7 +176,7 @@ class BuildService {
const jobs = await k3s.batch.listNamespacedJob(BUILD_NAMESPACE);
const jobsToDelete = jobs.body.items.filter((job) => {
const status = this.getJobStatusString(job.status);
return status !== 'RUNNING';
return !status || status !== 'RUNNING';
});
for (const job of jobsToDelete) {
await this.deleteBuild(job.metadata?.name!);
@@ -284,10 +284,15 @@ class BuildService {
if ((status.succeeded ?? 0) > 0) {
return 'SUCCEEDED';
}
if ((status.failed ?? 0) > 0) {
return 'FAILED';
}
if ((status.terminating ?? 0) > 0) {
return 'UNKNOWN';
}
if (!!status.completionTime) {
return 'SUCCEEDED';
}
return 'UNKNOWN';
}
}

View File

@@ -18,8 +18,6 @@ class ConfigMapService {
async createOrUpdateConfigMapForApp(app: AppExtendedModel) {
const existingConfigMaps = await this.getConfigMapsForApp(app.projectId, app.id);
if (app.appFileMounts.length === 0) {
return { fileVolumeMounts: [], fileVolumes: [] };
}
@@ -52,30 +50,49 @@ class ConfigMapService {
},
};
if (existingConfigMaps.some(cm => cm.metadata!.name === currentConfigMapName)) {
await k3s.core.replaceNamespacedConfigMap(currentConfigMapName, app.projectId, configMapManifest);
} else {
await k3s.core.createNamespacedConfigMap(app.projectId, configMapManifest);
}
await this.createOrUpdateConfigMap(app.projectId, configMapManifest);
const containerMountPath = fileMount.containerMountPath;
fileVolumeMounts.push({
name: currentConfigMapName,
mountPath: fileMount.containerMountPath,
subPath: filePath,
readOnly: true
});
const { fileVolumeMount, fileVolume } = this.createFileVolumeConfig(currentConfigMapName, containerMountPath, filePath);
fileVolumes.push({
name: currentConfigMapName,
configMap: {
name: currentConfigMapName,
}
});
fileVolumeMounts.push(fileVolumeMount);
fileVolumes.push(fileVolume);
}
return { fileVolumeMounts, fileVolumes };
}
createFileVolumeConfig(currentConfigMapName: string, containerMountPath: string, fileName: string, readOnly = true) {
const fileVolumeMount = {
name: currentConfigMapName,
mountPath: containerMountPath,
subPath: fileName,
readOnly
} as k8s.V1VolumeMount;
const fileVolume = {
name: currentConfigMapName,
configMap: {
name: currentConfigMapName,
}
} as k8s.V1Volume;
return { fileVolumeMount, fileVolume };
}
async getExistingConfigMap(namespace: string, configMapName: string) {
const configMaps = await k3s.core.listNamespacedConfigMap(namespace);
return configMaps.body.items.find(cm => cm.metadata?.name === configMapName);
}
async createOrUpdateConfigMap(namespace: string, configMapManifest: k8s.V1ConfigMap) {
const currentConfigMapName = configMapManifest.metadata!.name!;
const existingConfigMaps = await this.getExistingConfigMap(namespace, currentConfigMapName);
if (!!existingConfigMaps) {
await k3s.core.replaceNamespacedConfigMap(currentConfigMapName, namespace, configMapManifest);
} else {
await k3s.core.createNamespacedConfigMap(namespace, configMapManifest);
}
}
async deleteUnusedConfigMaps(app: AppExtendedModel) {
const existingConfigMaps = await this.getConfigMapsForApp(app.projectId, app.id);
@@ -85,6 +102,13 @@ class ConfigMapService {
}
}
}
async deleteConfigMapIfExists(namespace: string, configMapName: string) {
const existingConfigMap = await this.getExistingConfigMap(namespace, configMapName);
if (!!existingConfigMap) {
await k3s.core.deleteNamespacedConfigMap(configMapName, namespace);
}
}
}
const configMapService = new ConfigMapService();

View File

@@ -0,0 +1,182 @@
import { ServiceException } from "@/shared/model/service.exception.model";
import dataAccess from "../../adapter/db.client";
import traefikMeDomainService from "../traefik-me-domain.service";
import { KubeObjectNameUtils } from "../../utils/kube-object-name.utils";
import deploymentService from "../deployment.service";
import { V1Deployment, V1Ingress } from "@kubernetes/client-node";
import { Constants } from "@/shared/utils/constants";
import k3s from "../../adapter/kubernetes-api.adapter";
import ingressService from "../ingress.service";
import svcService from "../svc.service";
import podService from "../pod.service";
import appService from "../app.service";
import { AppExtendedModel } from "@/shared/model/app-extended.model";
export class BaseDbToolService {
appIdToToolNameConverter: (appId: string) => string;
constructor(appIdToToolNameConverter: (appId: string) => string) {
this.appIdToToolNameConverter = appIdToToolNameConverter;
}
async isDbToolRunning(appId: string) {
const toolAppName = this.appIdToToolNameConverter(appId);
const app = await appService.getExtendedById(appId);
const projectId = app.projectId;
const existingDeployment = await deploymentService.getDeployment(projectId, toolAppName);
if (!existingDeployment) {
return false;
}
const existingService = await svcService.getService(projectId, toolAppName);
if (!existingService) {
return false;
}
const existingIngress = await ingressService.getIngressByName(projectId, toolAppName);
if (!existingIngress) {
return false;
}
return true;
}
async getLoginCredentialsForRunningTool(appId: string,
searchFunc: (existingDeployment: V1Deployment, app: AppExtendedModel) => { username: string, password: string }) {
const app = await appService.getExtendedById(appId);
const toolAppName = this.appIdToToolNameConverter(appId);
const projectId = app.projectId;
const isDbGateRunning = await this.isDbToolRunning(appId);
if (!isDbGateRunning) {
throw new ServiceException('DB Gate is not running for this database');
}
const existingDeployment = await deploymentService.getDeployment(projectId, toolAppName);
if (!existingDeployment) {
throw new ServiceException('DB Gate is not running for this database');
}
const { username, password } = searchFunc(existingDeployment, app);
const traefikHostname = await traefikMeDomainService.getDomainForApp(toolAppName);
return { url: `https://${traefikHostname}`, username, password };
}
async deployToolForDatabase(appId: string, appPort: number, deplyomentBuilder: (app: AppExtendedModel) => V1Deployment | Promise<V1Deployment>) {
const app = await appService.getExtendedById(appId);
const toolAppName = this.appIdToToolNameConverter(appId);
if (app.appType === 'APP') {
throw new ServiceException(`The DB Tool ${toolAppName} can only be deployed for databases, not for apps`);
}
const namespace = app.projectId;
console.log(`Deploying DB Tool ${toolAppName} for app ${appId}`);
const traefikHostname = await traefikMeDomainService.getDomainForApp(toolAppName);
console.log(`Creating DB Tool ${toolAppName} deployment for app ${appId}`);
await this.createOrUpdateDbGateDeployment(app, deplyomentBuilder);
console.log(`Creating service for DB Tool ${toolAppName} for app ${appId}`);
await svcService.createOrUpdateService(namespace, toolAppName, [{
name: 'http',
port: 80,
targetPort: appPort,
}]);
console.log(`Creating ingress for DB Tool ${toolAppName} for app ${appId}`);
await this.createOrUpdateIngress(toolAppName, namespace, traefikHostname);
const fileBrowserPods = await podService.getPodsForApp(namespace, toolAppName);
for (const pod of fileBrowserPods) {
await podService.waitUntilPodIsRunningFailedOrSucceded(namespace, pod.podName);
}
}
private async createOrUpdateDbGateDeployment(app: AppExtendedModel, deplyomentBuilder: (app: AppExtendedModel) => V1Deployment | Promise<V1Deployment>) {
const body = await deplyomentBuilder(app);
const toolAppName = this.appIdToToolNameConverter(app.id);
await deploymentService.applyDeployment(app.projectId, toolAppName, body);
}
async deleteToolForAppIfExists(appId: string) {
const app = await dataAccess.client.app.findFirst({
where: {
id: appId
}
});
if (!app) {
return;
}
const toolAppName = this.appIdToToolNameConverter(appId);
const projectId = app.projectId;
const existingDeployment = await deploymentService.getDeployment(projectId, toolAppName);
if (existingDeployment) { await k3s.apps.deleteNamespacedDeployment(toolAppName, projectId); }
const existingService = await svcService.getService(projectId, toolAppName);
if (existingService) { await svcService.deleteService(projectId, toolAppName); }
const existingIngress = await ingressService.getIngressByName(projectId, toolAppName);
if (existingIngress) {
await k3s.network.deleteNamespacedIngress(KubeObjectNameUtils.getIngressName(toolAppName), projectId);
}
}
private async createOrUpdateIngress(dbGateAppName: string, namespace: string, traefikHostname: string) {
const ingressDefinition: V1Ingress = {
apiVersion: 'networking.k8s.io/v1',
kind: 'Ingress',
metadata: {
name: KubeObjectNameUtils.getIngressName(dbGateAppName),
namespace: namespace,
// dont annotate, because ingress will be deleted after redeployment of app
/* annotations: {
[Constants.QS_ANNOTATION_APP_ID]: appId,
[Constants.QS_ANNOTATION_PROJECT_ID]: projectId,
},*/
},
spec: {
ingressClassName: 'traefik',
rules: [
{
host: traefikHostname,
http: {
paths: [
{
path: '/',
pathType: 'Prefix',
backend: {
service: {
name: KubeObjectNameUtils.toServiceName(dbGateAppName),
port: {
number: 80,
},
},
},
},
],
},
},
],
tls: [{
hosts: [traefikHostname],
secretName: Constants.TRAEFIK_ME_SECRET_NAME,
}],
},
};
const existingIngress = await ingressService.getIngressByName(namespace, dbGateAppName);
if (existingIngress) {
await k3s.network.replaceNamespacedIngress(KubeObjectNameUtils.getIngressName(dbGateAppName), namespace, ingressDefinition);
} else {
await k3s.network.createNamespacedIngress(namespace, ingressDefinition);
}
}
}

View File

@@ -0,0 +1,139 @@
import { ServiceException } from "@/shared/model/service.exception.model";
import { KubeObjectNameUtils } from "../../utils/kube-object-name.utils";
import { randomBytes } from "crypto";
import { V1Deployment, V1EnvVar } from "@kubernetes/client-node";
import { Constants } from "@/shared/utils/constants";
import podService from "../pod.service";
import { AppTemplateUtils } from "../../utils/app-template.utils";
import appService from "../app.service";
import { PathUtils } from "../../utils/path.utils";
import { FsUtils } from "../../utils/fs.utils";
import path from "path";
import { BaseDbToolService } from "./base-db-tool.service";
class DbGateService extends BaseDbToolService {
constructor() {
super((app) => KubeObjectNameUtils.toDbGateId(app));
}
async downloadDbGateFilesForApp(appId: string) {
const app = await appService.getExtendedById(appId);
const dbGateAppName = KubeObjectNameUtils.toDbGateId(app.id);
const pod = await podService.getPodsForApp(app.projectId, dbGateAppName);
if (pod.length === 0) {
throw new ServiceException(`There are no running pods for DBGate. Make sure the DB Gate is running.`);
}
const firstPod = pod[0];
const continerSourcePath = '/root/.dbgate/files';
const continerRootPath = '/root';
await podService.runCommandInPod(app.projectId, firstPod.podName, firstPod.containerName, ['cp', '-r', continerSourcePath, continerRootPath]);
const downloadPath = path.join(PathUtils.tempVolumeDownloadPath, dbGateAppName + '.tar.gz');
await FsUtils.createDirIfNotExistsAsync(PathUtils.tempVolumeDownloadPath, true);
await FsUtils.deleteDirIfExistsAsync(downloadPath, true);
console.log(`Downloading data from pod ${firstPod.podName} ${continerRootPath} to ${downloadPath}`);
await podService.cpFromPod(app.projectId, firstPod.podName, firstPod.containerName, continerRootPath, downloadPath, continerRootPath);
const fileName = path.basename(downloadPath);
return fileName;
}
async getLoginCredentialsForRunningDbGate(appId: string) {
return await this.getLoginCredentialsForRunningTool(appId, (existingDeployment) => {
const username = existingDeployment.spec?.template.spec?.containers[0].env?.find(e => e.name === 'LOGIN')?.value;
const password = existingDeployment.spec?.template.spec?.containers[0].env?.find(e => e.name === 'PASSWORD')?.value;
if (!username || !password) {
throw new ServiceException('Could not find login credentials for DB Gate, please restart DB Gate');
}
return { username, password };
});
}
async deploy(appId: string) {
await this.deployToolForDatabase(appId, 3000, (app) => {
const authPassword = randomBytes(15).toString('hex');
const dbGateAppName = KubeObjectNameUtils.toDbGateId(app.id);
const projectId = app.projectId;
const dbCredentials = AppTemplateUtils.getDatabaseModelFromApp(app);
const connectionId = 'qsdb';
const envVars: V1EnvVar[] = [
{ name: 'LOGIN', value: 'quickstack' },
{ name: 'PASSWORD', value: authPassword },
{ name: 'CONNECTIONS', value: connectionId },
{ name: `LABEL_${connectionId}`, value: app.name },
{ name: `SERVER_${connectionId}`, value: dbCredentials.hostname },
{ name: `USER_${connectionId}`, value: dbCredentials.username },
{ name: `PORT_${connectionId}`, value: dbCredentials.port + '' },
{ name: `PASSWORD_${connectionId}`, value: dbCredentials.password },
];
if (app.appType === 'POSTGRES') {
envVars.push(...[
{ name: `ENGINE_${connectionId}`, value: 'postgres@dbgate-plugin-postgres' },
]);
} else if (app.appType === 'MYSQL') {
envVars.push(...[
{ name: `ENGINE_${connectionId}`, value: 'mysql@dbgate-plugin-mysql' },
]);
} else if (app.appType === 'MARIADB') {
envVars.push(...[
{ name: `ENGINE_${connectionId}`, value: 'mariadb@dbgate-plugin-mysql' },
]);
} else if (app.appType === 'MONGODB') {
envVars.push(...[
{ name: `ENGINE_${connectionId}`, value: 'mongo@dbgate-plugin-mongo' },
]);
} else {
throw new ServiceException('QuickStack does not support this app type');
}
const body: V1Deployment = {
metadata: {
name: dbGateAppName
},
spec: {
replicas: 1,
selector: {
matchLabels: {
app: dbGateAppName
}
},
template: {
metadata: {
labels: {
app: dbGateAppName
},
annotations: {
[Constants.QS_ANNOTATION_APP_ID]: app.id,
[Constants.QS_ANNOTATION_PROJECT_ID]: projectId,
deploymentTimestamp: new Date().getTime() + "",
"kubernetes.io/change-cause": `Deployment ${new Date().toISOString()}`
}
},
spec: {
containers: [
{
name: dbGateAppName,
image: 'dbgate/dbgate:latest',
imagePullPolicy: 'Always',
env: envVars
}
],
}
}
}
};
return body;
});
}
}
const dbGateService = new DbGateService();
export default dbGateService;

View File

@@ -0,0 +1,171 @@
import { ServiceException } from "@/shared/model/service.exception.model";
import { KubeObjectNameUtils } from "../../utils/kube-object-name.utils";
import { randomBytes } from "crypto";
import { V1Deployment, V1EnvVar } from "@kubernetes/client-node";
import { Constants } from "@/shared/utils/constants";
import { AppTemplateUtils } from "../../utils/app-template.utils";
import appService from "../app.service";
import { BaseDbToolService } from "./base-db-tool.service";
import configMapService from "../config-map.service";
import { AppExtendedModel } from "@/shared/model/app-extended.model";
class PgAdminService extends BaseDbToolService {
readonly pgPassPath = '/pgadmin-config/pgpass';
readonly pgAdminConfigPath = '/pgadmin-config/servers.json';
constructor() {
super((app) => KubeObjectNameUtils.toPgAdminId(app));
}
async getLoginCredentialsForRunningDbGate(appId: string) {
return await this.getLoginCredentialsForRunningTool(appId, (deployment) => {
const username = deployment.spec?.template.spec?.containers[0].env?.find(e => e.name === 'PGADMIN_DEFAULT_EMAIL')?.value;
const password = deployment.spec?.template.spec?.containers[0].env?.find(e => e.name === 'PGADMIN_DEFAULT_PASSWORD')?.value;
if (!username || !password) {
throw new ServiceException('Could not find login credentials for PGAdmin, please restart PGAdmin');
}
return { username, password };
});
}
async deleteToolForAppIfExists(appId: string) {
const app = await appService.getExtendedById(appId);
await configMapService.deleteConfigMapIfExists(app.projectId, KubeObjectNameUtils.getConfigMapName(this.appIdToToolNameConverter(app.id)));
await configMapService.deleteConfigMapIfExists(app.projectId, 'pgpass-' + this.appIdToToolNameConverter(app.id));
await super.deleteToolForAppIfExists(appId);
}
async deploy(appId: string) {
await this.deployToolForDatabase(appId, 80, async (app) => {
const projectId = app.projectId;
const appName = this.appIdToToolNameConverter(app.id);
const configMapName = KubeObjectNameUtils.getConfigMapName(appName);
const volumeConfigServerJsonFile = await this.createServerJsonConfigMap(configMapName, app);
const volumeConfigPgPassFile = await this.createPgPassConfigMap(appName, app);
const authPassword = randomBytes(15).toString('hex');
const body: V1Deployment = {
metadata: {
name: appName
},
spec: {
replicas: 1,
selector: {
matchLabels: {
app: appName
}
},
template: {
metadata: {
labels: {
app: appName
},
annotations: {
[Constants.QS_ANNOTATION_APP_ID]: app.id,
[Constants.QS_ANNOTATION_PROJECT_ID]: projectId,
deploymentTimestamp: new Date().getTime() + "",
"kubernetes.io/change-cause": `Deployment ${new Date().toISOString()}`
}
},
spec: {
containers: [
{
name: appName,
image: 'dpage/pgadmin4:latest',
imagePullPolicy: 'Always',
env: [
{
name: 'PGADMIN_DEFAULT_EMAIL',
value: 'quickstack@quickstack.dev'
},
{
name: 'PGADMIN_DEFAULT_PASSWORD',
value: authPassword
},
{
name: 'PGADMIN_SERVER_JSON_FILE',
value: this.pgAdminConfigPath
},
{
name: 'PGPASS_FILE',
value: this.pgPassPath // todo has to be chmod 0600
},
],
readinessProbe: {
httpGet: {
path: '/misc/ping',
port: 80
},
initialDelaySeconds: 30,
periodSeconds: 15,
failureThreshold: 5,
},
volumeMounts: [volumeConfigServerJsonFile.fileVolumeMount, volumeConfigPgPassFile.fileVolumeMount]
}
],
volumes: [volumeConfigServerJsonFile.fileVolume, volumeConfigPgPassFile.fileVolume]
}
}
}
};
return body;
});
}
private async createServerJsonConfigMap(configMapName: string, app: AppExtendedModel) {
const dbCredentials = AppTemplateUtils.getDatabaseModelFromApp(app);
const configMapManifest = {
apiVersion: 'v1',
kind: 'ConfigMap',
metadata: {
name: configMapName,
namespace: app.projectId,
},
data: {
'servers.json': JSON.stringify({
"Servers": {
"1": {
"Name": app.name,
"Group": "Servers",
"Host": dbCredentials.hostname,
"Port": dbCredentials.port,
"MaintenanceDB": 'postgres',
"Username": dbCredentials.username,
"SSLMode": "prefer",
"PasswordExecCommand": `echo '${dbCredentials.password}'`, // todo does not work?!
}
}
})
},
};
await configMapService.createOrUpdateConfigMap(app.projectId, configMapManifest);
const volumeConfigServerJsonFile = configMapService.createFileVolumeConfig(configMapName, this.pgAdminConfigPath, 'servers.json');
return volumeConfigServerJsonFile;
}
private async createPgPassConfigMap(appName: string, app: AppExtendedModel) {
const dbCredentials = AppTemplateUtils.getDatabaseModelFromApp(app);
const pgPassConfigMapName = 'pgpass-' + appName;
const configMapManifestPgPass = {
apiVersion: 'v1',
kind: 'ConfigMap',
metadata: {
name: pgPassConfigMapName,
namespace: app.projectId,
},
data: {
'pgpass': `${dbCredentials.hostname}:${dbCredentials.port}:postgres:${dbCredentials.username}:${dbCredentials.password}`,
},
};
await configMapService.createOrUpdateConfigMap(app.projectId, configMapManifestPgPass);
const volumeConfigPgPassFile = configMapService.createFileVolumeConfig(pgPassConfigMapName, this.pgPassPath, 'pgpass');
return volumeConfigPgPassFile;
}
}
const pgAdminService = new PgAdminService();
export default pgAdminService;

View File

@@ -0,0 +1,85 @@
import { ServiceException } from "@/shared/model/service.exception.model";
import { KubeObjectNameUtils } from "../../utils/kube-object-name.utils";
import { randomBytes } from "crypto";
import { V1Deployment, V1EnvVar } from "@kubernetes/client-node";
import { Constants } from "@/shared/utils/constants";
import podService from "../pod.service";
import { AppTemplateUtils } from "../../utils/app-template.utils";
import appService from "../app.service";
import { PathUtils } from "../../utils/path.utils";
import { FsUtils } from "../../utils/fs.utils";
import path from "path";
import { BaseDbToolService } from "./base-db-tool.service";
class PhpMyAdminService extends BaseDbToolService {
constructor() {
super((app) => KubeObjectNameUtils.toPhpMyAdminId(app));
}
async getLoginCredentialsForRunningDbGate(appId: string) {
return await this.getLoginCredentialsForRunningTool(appId, (_, app) => {
const dbCredentials = AppTemplateUtils.getDatabaseModelFromApp(app);
return { username: dbCredentials.username, password: dbCredentials.password };
});
}
async deploy(appId: string) {
await this.deployToolForDatabase(appId, 80, (app) => {
const appName = this.appIdToToolNameConverter(app.id);
const projectId = app.projectId;
const dbCredentials = AppTemplateUtils.getDatabaseModelFromApp(app);
const body: V1Deployment = {
metadata: {
name: appName
},
spec: {
replicas: 1,
selector: {
matchLabels: {
app: appName
}
},
template: {
metadata: {
labels: {
app: appName
},
annotations: {
[Constants.QS_ANNOTATION_APP_ID]: app.id,
[Constants.QS_ANNOTATION_PROJECT_ID]: projectId,
deploymentTimestamp: new Date().getTime() + "",
"kubernetes.io/change-cause": `Deployment ${new Date().toISOString()}`
}
},
spec: {
containers: [
{
name: appName,
image: 'phpmyadmin/phpmyadmin:latest',
imagePullPolicy: 'Always',
env: [
{
name: 'PMA_PORT',
value: dbCredentials.port + ''
},
{
name: 'PMA_HOST',
value: dbCredentials.hostname
},
]
}
],
}
}
}
};
return body;
});
}
}
const phpMyAdminService = new PhpMyAdminService();
export default phpMyAdminService;

View File

@@ -21,14 +21,23 @@ import podService from "./pod.service";
class DeploymentService {
async getDeployment(projectId: string, appId: string) {
const allDeployments = await k3s.apps.listNamespacedDeployment(projectId);
if (allDeployments.body?.items?.some((item) => item.metadata?.name === appId)) {
const res = await k3s.apps.readNamespacedDeployment(appId, projectId);
async getDeployment(namespace: string, appName: string) {
const allDeployments = await k3s.apps.listNamespacedDeployment(namespace);
if (allDeployments.body?.items?.some((item) => item.metadata?.name === appName)) {
const res = await k3s.apps.readNamespacedDeployment(appName, namespace);
return res.body;
}
}
async applyDeployment(namespace: string, appName: string, body: V1Deployment) {
const existingDeployment = await this.getDeployment(namespace, appName);
if (existingDeployment) {
await k3s.apps.replaceNamespacedDeployment(appName, namespace, body);
} else {
await k3s.apps.createNamespacedDeployment(namespace, body);
}
}
async deleteDeploymentIfExists(projectId: string, appId: string) {
const existingDeployment = await this.getDeployment(projectId, appId);
if (!existingDeployment) {

View File

@@ -1,6 +1,6 @@
import { V1Deployment, V1Ingress } from "@kubernetes/client-node";
import dataAccess from "../adapter/db.client";
import traefikMeDomainService from "./traefik-me-domain.service";
import traefikMeDomainStandaloneService from "./standalone-services/traefik-me-domain-standalone.service";
import { Constants } from "@/shared/utils/constants";
import { KubeObjectNameUtils } from "../utils/kube-object-name.utils";
import deploymentService from "./deployment.service";
@@ -10,6 +10,8 @@ import svcService from "./svc.service";
import { randomBytes } from "crypto";
import podService from "./pod.service";
import bcrypt from "bcrypt";
import traefikMeDomainService from "./traefik-me-domain.service";
import pvcService from "./pvc.service";
class FileBrowserService {
@@ -31,8 +33,11 @@ class FileBrowserService {
console.log('Shutting down application with id: ' + appId);
await deploymentService.setReplicasToZeroAndWaitForShutdown(projectId, appId);
console.log(`Creating PVC if not already created for volume ${volumeId}`);
await pvcService.createPvcForVolumeIfNotExists(volume.app.projectId, volume);
console.log(`Deploying filebrowser for volume ${volumeId}`);
const traefikHostname = await traefikMeDomainService.getDomainForApp(volume.appId, volume.id);
const traefikHostname = await traefikMeDomainService.getDomainForApp(volume.id);
const pvcName = KubeObjectNameUtils.toPvcName(volume.id);
@@ -41,7 +46,6 @@ class FileBrowserService {
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',
@@ -101,8 +105,6 @@ class FileBrowserService {
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: {
@@ -130,7 +132,7 @@ class FileBrowserService {
],
tls: [{
hosts: [traefikHostname],
secretName: `secret-tls-${kubeAppName}`,
secretName: Constants.TRAEFIK_ME_SECRET_NAME,
}],
},
};

View File

@@ -7,6 +7,7 @@ import { Constants } from "../../shared/utils/constants";
import ingressSetupService from "./setup-services/ingress-setup.service";
import { dlog } from "./deployment-logs.service";
import { createHash } from "crypto";
import { TraefikMeUtils } from "@/shared/utils/traefik-me.utils";
class IngressService {
@@ -67,6 +68,7 @@ class IngressService {
const hostname = domain.hostname;
const ingressName = KubeObjectNameUtils.getIngressName(domain.id);
const existingIngress = await this.getIngressByName(app.projectId, domain.id);
const isATraefikMeDomain = TraefikMeUtils.isValidTraefikMeDomain(hostname);
const middlewares = [
basicAuthMiddlewareName,
@@ -82,7 +84,7 @@ class IngressService {
annotations: {
[Constants.QS_ANNOTATION_APP_ID]: app.id,
[Constants.QS_ANNOTATION_PROJECT_ID]: app.projectId,
...(domain.useSsl === true && { 'cert-manager.io/cluster-issuer': 'letsencrypt-production' }),
...(!isATraefikMeDomain && domain.useSsl === true && { 'cert-manager.io/cluster-issuer': 'letsencrypt-production' }),
...(middlewares && { 'traefik.ingress.kubernetes.io/router.middlewares': middlewares }),
...(domain.useSsl === false && { 'traefik.ingress.kubernetes.io/router.entrypoints': 'web' }), // disable requests from https --> only http
},
@@ -114,7 +116,7 @@ class IngressService {
tls: [
{
hosts: [hostname],
secretName: `secret-tls-${domain.id}`,
secretName: isATraefikMeDomain ? Constants.TRAEFIK_ME_SECRET_NAME : `secret-tls-${domain.id}`,
},
],
}),

View File

@@ -6,6 +6,7 @@ import { KubeObjectNameUtils } from "../utils/kube-object-name.utils";
import deploymentService from "./deployment.service";
import namespaceService from "./namespace.service";
import buildService from "./build.service";
import traefikMeDomainStandaloneService from "./standalone-services/traefik-me-domain-standalone.service";
class ProjectService {
@@ -28,7 +29,7 @@ class ProjectService {
}
async getAllProjects() {
return await unstable_cache(() => dataAccess.client.project.findMany({
return await unstable_cache(() => dataAccess.client.project.findMany({
include: {
apps: true
},
@@ -69,6 +70,7 @@ class ProjectService {
} finally {
revalidateTag(Tags.projects());
}
await traefikMeDomainStandaloneService.updateTraefikMeCertificate();
return savedItem;
}
}

View File

@@ -10,6 +10,7 @@ import dataAccess from "../adapter/db.client";
import podService from "./pod.service";
import path from "path";
import { KubeSizeConverter } from "../../shared/utils/kubernetes-size-converter.utils";
import { AppVolume } from "@prisma/client";
class PvcService {
@@ -62,6 +63,11 @@ class PvcService {
return res.body.items.filter((item) => item.metadata?.annotations?.[Constants.QS_ANNOTATION_APP_ID] === appId);
}
async getExistingPvcByVolumeId(namespace: string, volumeId: string) {
const allVolumes = await k3s.core.listNamespacedPersistentVolumeClaim(namespace);
return allVolumes.body.items.find(pvc => pvc.metadata?.name === KubeObjectNameUtils.toPvcName(volumeId));
}
async getAllPvc() {
const res = await k3s.core.listPersistentVolumeClaimForAllNamespaces();
return res.body.items;
@@ -89,34 +95,26 @@ class PvcService {
}
}
async createPvcForVolumeIfNotExists(projectId: string, app: AppVolume) {
const pvcName = KubeObjectNameUtils.toPvcName(app.id);
const existingPvc = await this.getExistingPvcByVolumeId(projectId, app.id);
if (existingPvc) {
console.log(`PVC ${pvcName} for app ${app.id} already exists, no need to create it`);
return;
}
const pvcDefinition = this.mapVolumeToPvcDefinition(projectId, app);
await k3s.core.createNamespacedPersistentVolumeClaim(projectId, pvcDefinition);
console.log(`Created PVC ${pvcName} for app ${app.id}`);
}
async createOrUpdatePvc(app: AppExtendedModel) {
const existingPvcs = await this.getAllPvcForApp(app.projectId, app.id);
for (const appVolume of app.appVolumes) {
const pvcName = KubeObjectNameUtils.toPvcName(appVolume.id);
const pvcDefinition: V1PersistentVolumeClaim = {
apiVersion: 'v1',
kind: 'PersistentVolumeClaim',
metadata: {
name: pvcName,
namespace: app.projectId,
annotations: {
[Constants.QS_ANNOTATION_APP_ID]: app.id,
[Constants.QS_ANNOTATION_PROJECT_ID]: app.projectId,
'qs-app-volume-id': appVolume.id,
}
},
spec: {
accessModes: [appVolume.accessMode],
storageClassName: 'longhorn',
resources: {
requests: {
storage: KubeSizeConverter.megabytesToKubeFormat(appVolume.size),
},
},
},
};
const pvcDefinition = this.mapVolumeToPvcDefinition(app.projectId, appVolume);
const existingPvc = existingPvcs.find(pvc => pvc.metadata?.name === pvcName);
if (existingPvc) {
@@ -160,6 +158,31 @@ class PvcService {
return { volumes, volumeMounts };
}
private mapVolumeToPvcDefinition(projectId: string, appVolume: AppVolume): V1PersistentVolumeClaim {
return {
apiVersion: 'v1',
kind: 'PersistentVolumeClaim',
metadata: {
name: KubeObjectNameUtils.toPvcName(appVolume.id),
namespace: projectId,
annotations: {
[Constants.QS_ANNOTATION_APP_ID]: appVolume.appId,
[Constants.QS_ANNOTATION_PROJECT_ID]: projectId,
'qs-app-volume-id': appVolume.id,
}
},
spec: {
accessModes: [appVolume.accessMode],
storageClassName: 'longhorn',
resources: {
requests: {
storage: KubeSizeConverter.megabytesToKubeFormat(appVolume.size),
},
},
},
};
}
private async waitUntilPvResized(persistentVolumeName: string, size: number) {
let iterationCount = 0;
let pv = await k3s.core.readPersistentVolume(persistentVolumeName);

View File

@@ -28,7 +28,7 @@ class QuickStackService {
await namespaceService.createNamespaceIfNotExists(this.QUICKSTACK_NAMESPACE)
const nextAuthSecret = await this.deleteExistingDeployment();
await this.createOrUpdatePvc();
await this.createOrUpdateDeployment(nextAuthSecret);
await this.createOrUpdateDeployment(nextAuthSecret, process.env.QS_VERSION?.includes('canary') ? 'canary' : 'latest');
await this.createOrUpdateService(true);
await this.waitUntilQuickstackIsRunning();
console.log('QuickStack successfully initialized');
@@ -361,7 +361,8 @@ class QuickStackService {
const existingDeployments = allDeployments.body.items.find(d => d.metadata!.name === this.QUICKSTACK_DEPLOYMENT_NAME);
const nextAuthSecret = existingDeployments?.spec?.template?.spec?.containers?.[0].env?.find(e => e.name === 'NEXTAUTH_SECRET')?.value;
const nextAuthHostname = existingDeployments?.spec?.template?.spec?.containers?.[0].env?.find(e => e.name === 'NEXTAUTH_URL')?.value;
return { existingDeployments, nextAuthSecret, nextAuthHostname };
const isCanaryDeployment = existingDeployments?.spec?.template?.spec?.containers?.[0].image?.includes('canary');
return { existingDeployments, nextAuthSecret, nextAuthHostname, isCanaryDeployment };
}
}

View File

@@ -44,20 +44,13 @@ class SecretService {
type: 'kubernetes.io/dockerconfigjson',
};
const existingSecret = await this.getExistingSecret(namespace, app.id);
if (existingSecret) {
console.log(`Updating existing Docker registry secret ${secretName}...`);
await k3s.core.replaceNamespacedSecret(secretName, namespace, secretManifest);
} else {
console.log(`Creating new Docker registry secret ${secretName}...`);
await k3s.core.createNamespacedSecret(namespace, secretManifest);
}
await this.saveSecret(namespace, secretName, secretManifest);
return secretName;
}
async delteUnusedSecrets(app: AppExtendedModel) {
if (this.appNeedsNoSecret(app)) {
const existingSecret = await this.getExistingSecret(app.projectId, app.id);
const existingSecret = await this.getExistingSecret(app.projectId, KubeObjectNameUtils.toSecretId(app.id));
if (existingSecret) {
console.log(`Deleting secret ${existingSecret.metadata?.name}...`);
await k3s.core.deleteNamespacedSecret(existingSecret.metadata?.name!, app.projectId);
@@ -69,9 +62,20 @@ class SecretService {
return app.sourceType === 'GIT' || !app.containerImageSource || !app.containerRegistryUsername || !app.containerRegistryPassword;
}
async getExistingSecret(namespace: string, appId: string) {
async saveSecret(namespace: string, secretName: string, secretManifest: V1Secret) {
const existingSecret = await this.getExistingSecret(namespace, secretName);
if (existingSecret) {
console.log(`Updating existing Docker registry secret ${secretName}...`);
await k3s.core.replaceNamespacedSecret(secretName, namespace, secretManifest);
} else {
console.log(`Creating new Docker registry secret ${secretName}...`);
await k3s.core.createNamespacedSecret(namespace, secretManifest);
}
}
async getExistingSecret(namespace: string, secretName: string) {
const existingSecrets = await k3s.core.listNamespacedSecret(namespace);
const existingSecret = existingSecrets.body.items.find(s => s.metadata?.name === KubeObjectNameUtils.toSecretId(appId));
const existingSecret = existingSecrets.body.items.find(s => s.metadata?.name === secretName);
return existingSecret;
}
}

View File

@@ -0,0 +1,209 @@
import stream from "stream";
import dataAccess from "../../adapter/db.client";
import standalonePodService from "./standalone-pod.service";
import k3s from "../../adapter/kubernetes-api.adapter";
import { FsUtils } from "../../utils/fs.utils";
import { PathUtils } from "../../utils/path.utils";
import fsPromises from "fs/promises";
import { App } from "@prisma/client";
import path from "path";
import { create } from 'tar'
import scheduleService from "./schedule.service";
import { DownloadableAppLogsModel } from "../../../shared/model/downloadable-app-logs.model";
import { CommandExecutorUtils } from "../../../server/utils/command-executor.utils";
class AppLogsService {
configureCronJobs() {
scheduleService.scheduleJob('daily-logs-to-file', '10 0 * * *', async () => {
await this.backupLogsForAllRunningAppsForYesterday();
await this.deleteOldAppLogs();
});
}
async getAvailableLogsForApp(appId: string): Promise<DownloadableAppLogsModel[]> {
const appLogsFolder = PathUtils.appLogsFolder(appId);
await FsUtils.createDirIfNotExistsAsync(appLogsFolder, true);
const fileNames = await FsUtils.listFilesInDirAsync(appLogsFolder);
const logFiles = fileNames.map((fileName) => {
const date = this.dateFromAppLogsFileName(fileName);
if (!date) {
return undefined;
}
return {
appId,
date
};
}).filter((logFile) => logFile !== undefined);
// sort logs by date descending
logFiles.sort((a, b) => {
return b.date.getTime() - a.date.getTime();
});
return logFiles;
}
private dateFromAppLogsFileName(fileName: string) {
try {
const dateStr = fileName.replace('.tar.gz', '').split("_")[1];
return new Date(dateStr);
} catch (error) {
console.error("Error parsing date from file name", fileName);
return undefined;
}
}
async deleteOldAppLogs() {
const logDaysThreshold = 20;
const itemsInFolder = await FsUtils.listFilesInDirAsync(PathUtils.persistedLogsPath);
const logFolders = await Promise.all(itemsInFolder.map(async (item) => {
const stat = await fsPromises.stat(PathUtils.appLogsFolder(item));
if (stat.isDirectory()) {
return item;
}
await FsUtils.deleteFileIfExists(PathUtils.appLogsFolder(item));
}));
for (const logFolder of logFolders.filter((folder) => !!folder) as string[]) {
const logsInFolder = await FsUtils.listFilesInDirAsync(PathUtils.appLogsFolder(logFolder));
for (const logFile of logsInFolder) {
const fullLogFilePath = path.join(PathUtils.appLogsFolder(logFolder), logFile);
const stat = await fsPromises.stat(fullLogFilePath);
if (stat.mtimeMs < new Date().getTime() - logDaysThreshold * 24 * 60 * 60 * 1000) {
if (stat.isDirectory()) {
await FsUtils.deleteDirIfExistsAsync(fullLogFilePath);
} else {
await FsUtils.deleteFileIfExists(fullLogFilePath);
}
}
}
}
}
async backupLogsForAllRunningAppsForYesterday() {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
await this.writeLogsToDiskForAllRunningApps(yesterday);
}
async writeLogsToDiskForAllRunningApps(date?: Date) {
const apps = await dataAccess.client.app.findMany();
for (const app of apps) {
try {
await this.writeAppLogsToDiskForApp(app.id, date);
} catch (error) {
console.error(`Error writing logs to disk for app ${app.id}`, error);
}
}
}
async writeAppLogsToDiskForApp(appId: string, date?: Date) {
const startOfDay = date ?? new Date();
startOfDay.setHours(0, 0, 0, 0);
const secondsSinceMidnight = (new Date().getTime() - startOfDay.getTime()) / 1000;
const app = await dataAccess.client.app.findFirstOrThrow({
where: {
id: appId
}
});
const podInfos = await standalonePodService.getPodsForApp(app.projectId, app.id);
if (podInfos.length === 0) {
return;
}
await FsUtils.createDirIfNotExistsAsync(PathUtils.appLogsFolder(app.id), true);
let logPathsWritten = [];
for (const pod of podInfos) {
const logPath = await this.writeLogsToFileForPod(pod, app, startOfDay, secondsSinceMidnight);
if (await FsUtils.fileExists(logPath)) {
logPathsWritten.push(logPath);
} else {
console.error(`Error writing logs to file for pod ${pod.podName} in app ${app.id}. There was no file written!`);
}
}
// create tar.gz file from all log files
const logFilePath = PathUtils.appLogsFile(app.id, startOfDay);
await FsUtils.deleteFileIfExists(logFilePath); // delete existing log file --> new one will be created
if (logPathsWritten.length === 0) {
return;
}
await create({
gzip: true,
file: logFilePath,
cwd: PathUtils.appLogsFolder(app.id)
}, logPathsWritten);
if (!FsUtils.fileExists(logFilePath)) {
throw new Error(`Error creating tar file for logs of app ${app.id} on path ${logFilePath}`);
}
for (const logPath of logPathsWritten) {
await FsUtils.deleteFileIfExists(logPath);
}
return {
appId: app.id,
date: startOfDay
};
}
private async writeLogsToFileForPod(pod: { podName: string; containerName: string; uid?: string; status?: string; },
app: App, startOfDay: Date, secondsSinceMidnight: number) {
console.log(`Fetching logs for pod ${pod.podName} in container ${pod.containerName}`);
const textLogFilePath = path.join(PathUtils.appLogsFolder(app.id),
`${startOfDay.toISOString().split('T')[0]}_${pod.podName}.log`);
await FsUtils.deleteFileIfExists(textLogFilePath); // delete existing log file --> new one will be created
await new Promise<void>(async (resolve, reject) => {
try {
let logStream = new stream.PassThrough();
const requestWebSocket = await k3s.log.log(app.projectId, pod.podName, pod.containerName, logStream, {
follow: false,
sinceSeconds: Math.round(secondsSinceMidnight),
timestamps: true,
pretty: false,
previous: false
});
logStream.on('data', async (chunk) => {
await fsPromises.appendFile(textLogFilePath, chunk.toString(), {
encoding: 'utf-8'
});
});
logStream.on('error', (error) => {
console.error(`Error fetching logs for pod ${pod.podName} in container ${pod.containerName}`, error);
reject(error);
});
logStream.on('end', () => {
console.log(`[END] Log stream ended for ${pod.podName}`);
resolve();
requestWebSocket?.abort();
});
} catch (error) {
console.error(`Error fetching logs for pod ${pod.podName} in container ${pod.containerName}`, error);
reject(error);
}
});
return textLogFilePath;
}
}
const appLogsService = new AppLogsService();
export default appLogsService;

View File

@@ -26,7 +26,7 @@ class BackupService {
const groupedByCron = ListUtils.groupBy(allVolumeBackups, vb => vb.cron);
for (const [cron, volumeBackups] of Array.from(groupedByCron.entries())) {
scheduleService.scheduleJob(cron, cron, async () => {
scheduleService.scheduleJob(`backup-${cron}`, cron, async () => {
console.log(`Running backup for ${volumeBackups.length} volumes...`);
for (const volumeBackup of volumeBackups) {
try {
@@ -43,7 +43,8 @@ class BackupService {
async unregisterAllBackups() {
const allJobs = scheduleService.getAlJobs();
for (const jobName of allJobs) {
const backupJobs = allJobs.filter(j => j.startsWith('backup-'));
for (const jobName of backupJobs) {
scheduleService.cancelJob(jobName);
}
}
@@ -249,6 +250,15 @@ class BackupService {
await FsUtils.deleteFileIfExists(downloadPath);
}
}
async deleteBackupFromS3(s3TargetId: string, key: string) {
const target = await dataAccess.client.s3Target.findFirstOrThrow({
where: {
id: s3TargetId
}
});
return s3Service.deleteFile(target, key);
}
}
const backupService = new BackupService();

View File

@@ -0,0 +1,32 @@
import { FsUtils } from "../../../server/utils/fs.utils";
import { PathUtils } from "../../../server/utils/path.utils";
import path from "path";
import scheduleService from "./schedule.service";
import standalonePodService from "./standalone-pod.service";
class MaintenanceService {
configureMaintenanceCronJobs() {
scheduleService.scheduleJob('daily-maintenance', '0 6 * * *', async () => {
await this.deleteAllTempFiles();
await standalonePodService.deleteAllFailedAndSuccededPods();
});
}
async deleteAllTempFiles() {
const tempFilePath = PathUtils.tempDataRoot;
const allFilesOfDir = await FsUtils.getAllFilesInDir(tempFilePath);
for (const file of allFilesOfDir) {
const fullFilePath = path.join(tempFilePath, file);
const fileStat = await FsUtils.getFileStats(fullFilePath);
if (fileStat.isFile()) {
await FsUtils.deleteFileIfExists(fullFilePath);
} else {
await FsUtils.deleteDirIfExistsAsync(fullFilePath, true);
}
}
}
}
const maintenanceService = new MaintenanceService();
export default maintenanceService;

View File

@@ -0,0 +1,56 @@
import dataAccess from "../../adapter/db.client";
import bcrypt from "bcrypt";
import { randomBytes } from "crypto";
import quickStackService from "../qs.service";
class PasswordChangeService {
async changeAdminPasswordAndPrintNewPassword() {
const firstCreatedUser = await dataAccess.client.user.findFirst({
orderBy: {
createdAt: 'asc'
}
});
if (!firstCreatedUser) {
console.error("No users found. QuickStack is not configured yet. Open your browser to setup quickstack");
return;
}
const generatedPassword = randomBytes(20).toString('hex');
const hashedPassword = await bcrypt.hash(generatedPassword, 10);
await dataAccess.client.user.update({
where: {
id: firstCreatedUser.id
},
data: {
password: hashedPassword,
twoFaSecret: null,
twoFaEnabled: false
}
});
console.log(``);
console.log(``);
console.log('*******************************');
console.log('******* Password change *******');
console.log('*******************************');
console.log(``);
console.log(`New password for user ${firstCreatedUser.email} is: ${generatedPassword}`);
console.log(``);
console.log('*******************************');
console.log('*******************************');
console.log('*******************************');
console.log(``);
console.log(``);
console.log(`Restarting QuickStack, please wait...`);
console.log(``);
console.log(``);
const existingDeployment = await quickStackService.getExistingDeployment();
await quickStackService.createOrUpdateDeployment(existingDeployment.nextAuthSecret, existingDeployment.isCanaryDeployment ? 'canary' : 'latest');
await new Promise(resolve => setTimeout(resolve, 60000)); // wait 60 seconds, so that pod is not restarted and sets the new password again
}
}
const passwordChangeService = new PasswordChangeService();
export default passwordChangeService;

View File

@@ -2,6 +2,7 @@ import k3s from "../../adapter/kubernetes-api.adapter";
import fs from 'fs';
import stream from 'stream';
import * as k8s from '@kubernetes/client-node';
import dataAccess from "../../../server/adapter/db.client";
class SetupPodService {
@@ -14,7 +15,14 @@ class SetupPodService {
while (tries < maxTries) {
const pod = await this.getPodOrUndefined(projectId, podName);
if (pod && ['Running', 'Failed', 'Succeeded'].includes(pod.status?.phase!)) {
return true;
// check if running and ready (when passing readiness probe)
if (pod.status?.phase === 'Running') {
if (pod.status?.containerStatuses?.[0].ready) {
return true;
}
} else {
return true;
}
}
await new Promise(resolve => setTimeout(resolve, interval));
@@ -52,12 +60,14 @@ class SetupPodService {
podName: string;
containerName: string;
uid?: string;
status?: string;
}[]> {
const res = await k3s.core.listNamespacedPod(projectId, undefined, undefined, undefined, undefined, `app=${appId}`);
return res.body.items.map((item) => ({
podName: item.metadata?.name!,
containerName: item.spec?.containers?.[0].name!,
uid: item.metadata?.uid,
status: item.status?.phase,
})).filter((item) => !!item.podName && !!item.containerName);
}
@@ -224,7 +234,18 @@ class SetupPodService {
}, 5000);
});
});
}
async deleteAllFailedAndSuccededPods() {
const projects = await dataAccess.client.project.findMany();
for (const project of projects) {
const podsOfNamespace = await k3s.core.listNamespacedPod(project.id);
const failedPods = podsOfNamespace.body.items.filter((pod) => ['Failed', 'Succeeded'].includes(pod.status?.phase!));
for (const pod of failedPods) {
await k3s.core.deleteNamespacedPod(pod.metadata?.name!, project.id);
}
}
}
}

View File

@@ -0,0 +1,40 @@
import traefikMeAdapter from "../../adapter/traefik-me.adapter";
import { V1Secret } from "@kubernetes/client-node";
import secretService from "../secret.service";
import { Constants } from "../../../shared/utils/constants";
import dataAccess from "../../adapter/db.client";
import scheduleService from "./schedule.service";
class TraefikMeDomainStandaloneService {
async updateTraefikMeCertificate() {
const fullChainCert = await traefikMeAdapter.getFullChainCertificate();
const privateKey = await traefikMeAdapter.getCurrentPrivateKey();
const projects = await dataAccess.client.project.findMany();
const secretName = Constants.TRAEFIK_ME_SECRET_NAME;
for (const project of projects) {
const secretManifest: V1Secret = {
metadata: {
name: secretName,
},
data: {
'tls.crt': Buffer.from(fullChainCert).toString('base64'),
'tls.key': Buffer.from(privateKey).toString('base64'),
},
type: 'kubernetes.io/tls',
};
await secretService.saveSecret(project.id, secretName, secretManifest);
}
}
configureSchedulingForTraefikMeCertificateUpdate() {
scheduleService.scheduleJob('traefik-me-certificate-update', '0 1 * * *', async () => {
await this.updateTraefikMeCertificate();
});
}
}
const traefikMeDomainStandaloneService = new TraefikMeDomainStandaloneService();
export default traefikMeDomainStandaloneService;

View File

@@ -1,6 +1,7 @@
import { ServiceException } from "@/shared/model/service.exception.model";
import paramService, { ParamService } from "./param.service";
class TraefikMeDomainService {
async getDomainForApp(appId: string, prefix?: string) {
@@ -8,10 +9,11 @@ class TraefikMeDomainService {
if (!publicIpv4) {
throw new ServiceException('Please set the main public IPv4 address in the QuickStack settings first.');
}
const traefikFriendlyIpv4 = publicIpv4.split('.').join('-');
if (prefix) {
return `${prefix}.${appId}.${publicIpv4}.traefik.me`;
return `${prefix}-${appId}-${traefikFriendlyIpv4}.traefik.me`;
}
return `${appId}.${publicIpv4}.traefik.me`;
return `${appId}-${traefikFriendlyIpv4}.traefik.me`;
}
}

View File

@@ -2,6 +2,14 @@ import fs from "fs"
export class FsUtils {
static listFilesInDirAsync(appLogsFolder: string) {
return fs.promises.readdir(appLogsFolder);
}
static getFileStats(file: string) {
return fs.promises.stat(file);
}
static async fileExists(pathName: string) {
try {
await fs.promises.access(pathName, fs.constants.F_OK);
@@ -57,6 +65,7 @@ export class FsUtils {
});
}
}
static async deleteDirIfExistsAsync(pathName: string, recursive = false) {
let exists = false;
try {
@@ -71,4 +80,12 @@ export class FsUtils {
recursive
});
}
static async getAllFilesInDir(pathName: string) {
try {
return await fs.promises.readdir(pathName);
} catch (ex) {
return [];
}
}
}

View File

@@ -68,4 +68,16 @@ export class KubeObjectNameUtils {
static toSecretId(id: string): `secret-${string}` {
return `secret-${id}`;
}
static toDbGateId(appId: string): `dbgate-${string}` {
return `dbgate-${appId}`;
}
static toPhpMyAdminId(appId: string): `phpma-${string}` {
return `phpma-${appId}`;
}
static toPgAdminId(appId: string): `pga-${string}` {
return `pga-${appId}`;
}
}

View File

@@ -27,6 +27,19 @@ export class PathUtils {
return path.join(this.tempDataRoot, 'backup-restore');
}
static get persistedLogsPath() {
return path.join(this.internalDataRoot, 'app-logs');
}
static appLogsFolder(appId: string): string {
return path.join(this.persistedLogsPath, `${appId}`);
}
static appLogsFile(appId: string, date: Date): string {
const dateString = date.toISOString().split('T')[0];
return path.join(this.appLogsFolder(appId), `${appId}_${dateString}.tar.gz`);
}
static gitRootPathForApp(appId: string): string {
return path.join(PathUtils.gitRootPath, this.convertIdToFolderFriendlyName(appId));
}

View File

@@ -0,0 +1,4 @@
export interface DownloadableAppLogsModel {
appId: string;
date: Date;
}

View File

@@ -4,6 +4,7 @@ export const podsInfoZodModel = z.object({
podName: z.string(),
containerName: z.string(),
uid: z.string().optional(),
status: z.string().optional(),
});
export type PodsInfoModel = z.infer<typeof podsInfoZodModel>;

View File

@@ -7,4 +7,5 @@ export class Constants {
static readonly QS_NAMESPACE = 'quickstack';
static readonly QS_APP_NAME = 'quickstack';
static readonly INTERNAL_REGISTRY_LOCATION = 'internal-registry-location';
static readonly TRAEFIK_ME_SECRET_NAME = 'traefik-me-tls';
}

View File

@@ -0,0 +1,5 @@
export class DateUtils {
static isSameDay(date1: Date, date2: Date): boolean {
return date1.getDate() === date2.getDate() && date1.getMonth() === date2.getMonth() && date1.getFullYear() === date2.getFullYear();
}
}

View File

@@ -0,0 +1,10 @@
export class TraefikMeUtils {
static isValidTraefikMeDomain(domain: string): boolean {
return this.containesTraefikMeDomain(domain) && domain.split('.').length === 3;
}
static containesTraefikMeDomain(domain: string): boolean {
return domain.includes('.traefik.me');
}
}