mirror of
https://github.com/biersoeckli/QuickStack.git
synced 2026-02-11 05:59:23 -06:00
Merge pull request #13 from biersoeckli/canary
25-02-07 Merging features from canary to main branch
This commit is contained in:
2
.github/workflows/build-release.yml
vendored
2
.github/workflows/build-release.yml
vendored
@@ -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: |
|
||||
|
||||
2
.github/workflows/canary-release.yml
vendored
2
.github/workflows/canary-release.yml
vendored
@@ -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: |
|
||||
|
||||
37
Dockerfile
37
Dockerfile
@@ -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
42
setup/reset-password.sh
Normal 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
178
setup/setup-canary.sh
Normal 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 -"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
28
src/__tests__/shared/utils/date.utils.test.ts
Normal file
28
src/__tests__/shared/utils/date.utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
25
src/__tests__/shared/utils/traefik-me.utils.test.ts
Normal file
25
src/__tests__/shared/utils/traefik-me.utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
46
src/app/api/logs-download/route.ts
Normal file
46
src/app/api/logs-download/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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"`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>>;
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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>>;
|
||||
@@ -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>
|
||||
</>;
|
||||
|
||||
120
src/app/project/app/[appId]/credentials/db-gate-db-tool.tsx
Normal file
120
src/app/project/app/[appId]/credentials/db-gate-db-tool.tsx
Normal 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>
|
||||
</>;
|
||||
}
|
||||
|
||||
26
src/app/project/app/[appId]/credentials/db-tools.tsx
Normal file
26
src/app/project/app/[appId]/credentials/db-tools.tsx
Normal 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 >
|
||||
</>;
|
||||
}
|
||||
@@ -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>
|
||||
</>;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>>;
|
||||
118
src/app/project/app/[appId]/overview/logs-download-overlay.tsx
Normal file
118
src/app/project/app/[appId]/overview/logs-download-overlay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
34
src/server/adapter/traefik-me.adapter.ts
Normal file
34
src/server/adapter/traefik-me.adapter.ts
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
182
src/server/services/db-tool-services/base-db-tool.service.ts
Normal file
182
src/server/services/db-tool-services/base-db-tool.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
139
src/server/services/db-tool-services/dbgate.service.ts
Normal file
139
src/server/services/db-tool-services/dbgate.service.ts
Normal 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;
|
||||
171
src/server/services/db-tool-services/pgadmin.service.ts
Normal file
171
src/server/services/db-tool-services/pgadmin.service.ts
Normal 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;
|
||||
85
src/server/services/db-tool-services/phpmyadmin.service.ts
Normal file
85
src/server/services/db-tool-services/phpmyadmin.service.ts
Normal 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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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}`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
209
src/server/services/standalone-services/app-logs.service.ts
Normal file
209
src/server/services/standalone-services/app-logs.service.ts
Normal 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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
4
src/shared/model/downloadable-app-logs.model.ts
Normal file
4
src/shared/model/downloadable-app-logs.model.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface DownloadableAppLogsModel {
|
||||
appId: string;
|
||||
date: Date;
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
5
src/shared/utils/date.utils.ts
Normal file
5
src/shared/utils/date.utils.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
10
src/shared/utils/traefik-me.utils.ts
Normal file
10
src/shared/utils/traefik-me.utils.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user