diff --git a/setup/setup-canary.sh b/setup/setup-canary.sh index 261d80e..c45b9ca 100644 --- a/setup/setup-canary.sh +++ b/setup/setup-canary.sh @@ -87,10 +87,16 @@ select_network_interface # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! # THIS MUST BE INSTALLED ON ALL NODES --> https://longhorn.io/docs/1.7.2/deploy/install/#installing-nfsv4-client # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -# install nfs-common and open-iscsi +# install nfs-common, open-iscsi and jq echo "Installing nfs-common..." sudo apt-get update -sudo apt-get install open-iscsi nfs-common -y +sudo apt-get install open-iscsi nfs-common jq -y + +echo "Fetching version information..." +K3S_VERSION=$(curl -s https://get.quickstack.dev/k3s-versions.json | jq -r '.canaryInstallVersion') +LONGHORN_VERSION=$(curl -s https://get.quickstack.dev/longhorn-versions.json | jq -r '.canaryInstallVersion') +echo "Using K3s version: $K3S_VERSION" +echo "Using Longhorn version: $LONGHORN_VERSION" # Disable portmapper services --> https://github.com/biersoeckli/QuickStack/issues/18 sudo systemctl stop rpcbind.service rpcbind.socket @@ -100,7 +106,7 @@ sudo systemctl disable rpcbind.service rpcbind.socket #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 - +curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="--flannel-iface=$selected_iface" INSTALL_K3S_VERSION="$K3S_VERSION" sh - # Todo: Check for Ready node, takes ~30 seconds sudo k3s kubectl get node @@ -108,7 +114,7 @@ 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 +sudo kubectl apply -f "https://raw.githubusercontent.com/longhorn/longhorn/${LONGHORN_VERSION}/deploy/longhorn.yaml" echo "Waiting for Longhorn to start..." wait_until_all_pods_running @@ -119,8 +125,7 @@ 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 | sudo bash +sudo curl -sSfL "https://raw.githubusercontent.com/longhorn/longhorn/${LONGHORN_VERSION}/scripts/environment_check.sh" | sudo bash joinTokenForOtherNodes=$(sudo cat /var/lib/rancher/k3s/server/node-token) diff --git a/setup/setup-worker.sh b/setup/setup-worker.sh index 9a57b64..34d2f9d 100644 --- a/setup/setup-worker.sh +++ b/setup/setup-worker.sh @@ -64,9 +64,13 @@ select_network_interface # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! # THIS MUST BE INSTALLED ON ALL NODES --> https://longhorn.io/docs/1.7.2/deploy/install/#installing-nfsv4-client # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -# install nfs-common and open-iscsi +# install nfs-common, open-iscsi and jq sudo apt-get update -sudo apt-get install open-iscsi nfs-common -y +sudo apt-get install open-iscsi nfs-common jq -y + +echo "Fetching version information..." +K3S_VERSION=$(curl -s https://get.quickstack.dev/k3s-versions.json | jq -r '.prodInstallVersion') +echo "Using K3s version: $K3S_VERSION" # Disable portmapper services --> https://github.com/biersoeckli/QuickStack/issues/18 sudo systemctl stop rpcbind.service rpcbind.socket @@ -74,7 +78,7 @@ sudo systemctl disable rpcbind.service rpcbind.socket # Installation of k3s 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" K3S_URL=${K3S_URL} K3S_TOKEN=${JOIN_TOKEN} sh - +curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="--flannel-iface=$selected_iface" INSTALL_K3S_VERSION="$K3S_VERSION" K3S_URL=${K3S_URL} K3S_TOKEN=${JOIN_TOKEN} sh - echo "" echo "-----------------------------------------------------------------------------------------------------------" diff --git a/setup/setup.sh b/setup/setup.sh index 739206a..d08e88c 100644 --- a/setup/setup.sh +++ b/setup/setup.sh @@ -87,9 +87,15 @@ select_network_interface # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! # THIS MUST BE INSTALLED ON ALL NODES --> https://longhorn.io/docs/1.7.2/deploy/install/#installing-nfsv4-client # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -echo "Installing nfs-common..." +echo "Installing nfs-common and jq..." sudo apt-get update -sudo apt-get install open-iscsi nfs-common -y +sudo apt-get install open-iscsi nfs-common jq -y + +echo "Fetching version information..." +K3S_VERSION=$(curl -s https://get.quickstack.dev/k3s-versions.json | jq -r '.prodInstallVersion') +LONGHORN_VERSION=$(curl -s https://get.quickstack.dev/longhorn-versions.json | jq -r '.prodInstallVersion') +echo "Using K3s version: $K3S_VERSION" +echo "Using Longhorn version: $LONGHORN_VERSION" # Disable portmapper services --> https://github.com/biersoeckli/QuickStack/issues/18 sudo systemctl stop rpcbind.service rpcbind.socket @@ -99,7 +105,7 @@ sudo systemctl disable rpcbind.service rpcbind.socket #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 - +curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="--flannel-iface=$selected_iface" INSTALL_K3S_VERSION="$K3S_VERSION" sh - # Todo: Check for Ready node, takes ~30 seconds sudo k3s kubectl get node @@ -107,7 +113,7 @@ 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 +sudo kubectl apply -f "https://raw.githubusercontent.com/longhorn/longhorn/${LONGHORN_VERSION}/deploy/longhorn.yaml" echo "Waiting for Longhorn to start..." wait_until_all_pods_running @@ -117,8 +123,6 @@ echo "Waiting for Cert-Manager to start..." wait_until_all_pods_running sudo kubectl -n cert-manager get pod -sudo apt-get install jq -y - # Use this for checking installation of Longhorn # sudo curl -sSfL https://raw.githubusercontent.com/longhorn/longhorn/v1.7.2/scripts/environment_check.sh | sudo bash diff --git a/src/__tests__/server/k3s-version.utils.test.ts b/src/__tests__/server/k3s-version.utils.test.ts new file mode 100644 index 0000000..27658fb --- /dev/null +++ b/src/__tests__/server/k3s-version.utils.test.ts @@ -0,0 +1,37 @@ +import { K3sVersionUtils } from '@/server/utils/k3s-version.utils'; + +describe('K3sVersionUtils', () => { + describe('getMinorVersion', () => { + it('should extract minor version from full K3s version', () => { + expect(K3sVersionUtils.getMinorVersion('v1.31.3+k3s1')).toBe('v1.31'); + expect(K3sVersionUtils.getMinorVersion('v1.30.5+k3s2')).toBe('v1.30'); + expect(K3sVersionUtils.getMinorVersion('v1.32.0+k3s1')).toBe('v1.32'); + }); + + it('should handle versions without K3s suffix', () => { + expect(K3sVersionUtils.getMinorVersion('v1.31.3')).toBe('v1.31'); + expect(K3sVersionUtils.getMinorVersion('v1.30.5')).toBe('v1.30'); + }); + + it('should handle versions without v prefix', () => { + expect(K3sVersionUtils.getMinorVersion('1.31.3+k3s1')).toBe('v1.31'); + expect(K3sVersionUtils.getMinorVersion('1.30.5')).toBe('v1.30'); + }); + + it('should handle versions with only major.minor', () => { + expect(K3sVersionUtils.getMinorVersion('v1.31')).toBe('v1.31'); + expect(K3sVersionUtils.getMinorVersion('1.30')).toBe('v1.30'); + }); + + it('should throw error for invalid version formats', () => { + expect(() => K3sVersionUtils.getMinorVersion('')).toThrow('Version string is required'); + expect(() => K3sVersionUtils.getMinorVersion('v1')).toThrow('Invalid version format'); + expect(() => K3sVersionUtils.getMinorVersion('invalid')).toThrow('Invalid version format'); + expect(() => K3sVersionUtils.getMinorVersion('v1.x.3')).toThrow('Invalid version format'); + }); + + it('should handle edge cases with multiple dots', () => { + expect(K3sVersionUtils.getMinorVersion('v1.31.3.4+k3s1')).toBe('v1.31'); + }); + }); +}); diff --git a/src/__tests__/shared/utils/domain-dns-provider.utils.test.ts b/src/__tests__/shared/utils/domain-dns-provider.utils.test.ts index 2a032a1..366a471 100644 --- a/src/__tests__/shared/utils/domain-dns-provider.utils.test.ts +++ b/src/__tests__/shared/utils/domain-dns-provider.utils.test.ts @@ -47,15 +47,15 @@ describe('DomainDnsProviderUtils', () => { describe('getHostnameForIpAddress', () => { it('should convert IP address to hostname with hex format', () => { - expect(HostnameDnsProviderUtils.getHexHostanmeForIpAddress('192.168.1.1')).toBe('c0a80101.quickstack.me'); + expect(HostnameDnsProviderUtils.getHexHostnameForIpAddress('192.168.1.1')).toBe('c0a80101.quickstack.me'); }); it('should handle another IP address format', () => { - expect(HostnameDnsProviderUtils.getHexHostanmeForIpAddress('10.0.0.1')).toBe('0a000001.quickstack.me'); + expect(HostnameDnsProviderUtils.getHexHostnameForIpAddress('10.0.0.1')).toBe('0a000001.quickstack.me'); }); it('should handle localhost IP', () => { - expect(HostnameDnsProviderUtils.getHexHostanmeForIpAddress('127.0.0.1')).toBe('7f000001.quickstack.me'); + expect(HostnameDnsProviderUtils.getHexHostnameForIpAddress('127.0.0.1')).toBe('7f000001.quickstack.me'); }); }); diff --git a/src/app/backups/actions.ts b/src/app/backups/actions.ts index c7f4685..c67eba1 100644 --- a/src/app/backups/actions.ts +++ b/src/app/backups/actions.ts @@ -1,13 +1,7 @@ 'use server' -import monitoringService from "@/server/services/monitoring.service"; -import clusterService from "@/server/services/node.service"; -import pvcService from "@/server/services/pvc.service"; import backupService from "@/server/services/standalone-services/backup.service"; -import { getAuthUserSession, isAuthorizedForBackups, simpleAction } from "@/server/utils/action-wrapper.utils"; -import { AppMonitoringUsageModel } from "@/shared/model/app-monitoring-usage.model"; -import { AppVolumeMonitoringUsageModel } from "@/shared/model/app-volume-monitoring-usage.model"; -import { NodeResourceModel } from "@/shared/model/node-resource.model"; +import { isAuthorizedForBackups, simpleAction } from "@/server/utils/action-wrapper.utils"; import { ServerActionResult, SuccessActionResult } from "@/shared/model/server-action-error-return.model"; import { z } from "zod"; diff --git a/src/app/monitoring/actions.ts b/src/app/monitoring/actions.ts index f23a1b6..47b5ba9 100644 --- a/src/app/monitoring/actions.ts +++ b/src/app/monitoring/actions.ts @@ -1,7 +1,7 @@ 'use server' import monitoringService from "@/server/services/monitoring.service"; -import clusterService from "@/server/services/node.service"; +import clusterService from "@/server/services/cluster.service"; import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils"; import { UserGroupUtils } from "@/shared/utils/role.utils"; import { AppMonitoringUsageModel } from "@/shared/model/app-monitoring-usage.model"; diff --git a/src/app/monitoring/page.tsx b/src/app/monitoring/page.tsx index e46dc0e..4958008 100644 --- a/src/app/monitoring/page.tsx +++ b/src/app/monitoring/page.tsx @@ -2,7 +2,7 @@ import { getAuthUserSession } from "@/server/utils/action-wrapper.utils"; import PageTitle from "@/components/custom/page-title"; -import clusterService from "@/server/services/node.service"; +import clusterService from "@/server/services/cluster.service"; import ResourceNodes from "./monitoring-nodes"; import { NodeResourceModel } from "@/shared/model/node-resource.model"; import { AppVolumeMonitoringUsageModel } from "@/shared/model/app-volume-monitoring-usage.model"; diff --git a/src/app/project/app/[appId]/domains/actions.ts b/src/app/project/app/[appId]/domains/actions.ts index cc5bfb0..6fb8b25 100644 --- a/src/app/project/app/[appId]/domains/actions.ts +++ b/src/app/project/app/[appId]/domains/actions.ts @@ -66,5 +66,5 @@ export const getQuickstackDomainSuffix = async () => simpleAction(async () => { if (!publicIpv4) { throw new ServiceException('Please set the main public IPv4 address in the QuickStack settings first.'); } - return HostnameDnsProviderUtils.getHexHostanmeForIpAddress(publicIpv4); + return HostnameDnsProviderUtils.getHexHostnameForIpAddress(publicIpv4); }); \ No newline at end of file diff --git a/src/app/project/app/[appId]/page.tsx b/src/app/project/app/[appId]/page.tsx index 72d4317..b05fc57 100644 --- a/src/app/project/app/[appId]/page.tsx +++ b/src/app/project/app/[appId]/page.tsx @@ -5,7 +5,7 @@ import AppBreadcrumbs from "./app-breadcrumbs"; import s3TargetService from "@/server/services/s3-target.service"; import volumeBackupService from "@/server/services/volume-backup.service"; import { UserGroupUtils } from "@/shared/utils/role.utils"; -import clusterService from "@/server/services/node.service"; +import clusterService from "@/server/services/cluster.service"; export default async function AppPage({ searchParams, diff --git a/src/app/settings/server/actions.ts b/src/app/settings/server/actions.ts index e976f2b..82bc9dc 100644 --- a/src/app/settings/server/actions.ts +++ b/src/app/settings/server/actions.ts @@ -27,8 +27,11 @@ import fs from "fs"; import { z } from "zod"; import { revalidateTag } from "next/cache"; import { Tags } from "@/server/utils/cache-tag-generator.utils"; -import clusterService from "@/server/services/node.service"; +import clusterService from "@/server/services/cluster.service"; import { TraefikIpPropagationStatus } from "@/shared/model/traefik-ip-propagation.model"; +import k3sUpdateService from "@/server/services/upgrade-services/k3s-update.service"; +import longhornUpdateService from "@/server/services/upgrade-services/longhorn-update.service"; +import longhornUiService from "@/server/services/longhorn-ui.service"; export const setNodeStatus = async (nodeName: string, schedulable: boolean) => simpleAction(async () => { @@ -287,8 +290,62 @@ export const downloadSystemBackup = async (backupKey: string) => export const setTraefikIpPropagation = async (prevState: any, inputData: { enableIpPreservation: boolean }) => saveFormAction(inputData, z.object({ enableIpPreservation: z.boolean() }), async (validatedData) => { await getAdminUserSession(); - await traefikService.applyExternalTrafficPolicy(validatedData.enableIpPreservation); - return new SuccessActionResult(undefined, `Traefik externalTrafficPolicy set to ${validatedData.enableIpPreservation ? 'Local' : 'Cluster'}.`); }); + +export const checkK3sUpgradeControllerStatus = async () => + simpleAction(async () => { + await getAdminUserSession(); + return await k3sUpdateService.isSystemUpgradeControllerPresent(); + }); + +export const installK3sUpgradeController = async () => + simpleAction(async () => { + await getAdminUserSession(); + await k3sUpdateService.getCurrentK3sMinorVersion(); // if this succeds alls nodes have the same version and cluster is ready for upgrades + await k3sUpdateService.installSystemUpgradeController(); + return new SuccessActionResult(undefined, 'K3s System Upgrade Controller has been installed successfully.'); + }); + +export const startK3sUpgrade = async () => + simpleAction(async () => { + await getAdminUserSession(); + await k3sUpdateService.createUpgradePlans(); + return new SuccessActionResult(undefined, 'The upgrade process has started.'); + }); + +export const startLonghornUpgrade = async () => + simpleAction(async () => { + await getAdminUserSession(); + await longhornUpdateService.upgrade(); + return new SuccessActionResult(undefined, 'Longhorn upgrade has been initiated. Volume engines will be upgraded automatically.'); + }); + +export const getLonghornUiIngressStatus = async () => + simpleAction(async () => { + await getAdminUserSession(); + const active = await longhornUiService.isIngressActive(); + return new SuccessActionResult(active); + }) as Promise>; + +export const enableLonghornUiIngress = async () => + simpleAction(async () => { + await getAdminUserSession(); + const credentials = await longhornUiService.enable(); + return new SuccessActionResult(credentials, 'Longhorn UI is now accessible.'); + }) as Promise>; + +export const getLonghornUiCredentials = async () => + simpleAction(async () => { + await getAdminUserSession(); + const credentials = await longhornUiService.getCredentials(); + return new SuccessActionResult(credentials); + }) as Promise>; + +export const disableLonghornUiIngress = async () => + simpleAction(async () => { + await getAdminUserSession(); + await longhornUiService.disable(); + return new SuccessActionResult(undefined, 'Longhorn UI access has been disabled.'); + }); diff --git a/src/app/settings/server/k3s-update-info.tsx b/src/app/settings/server/k3s-update-info.tsx new file mode 100644 index 0000000..1f46272 --- /dev/null +++ b/src/app/settings/server/k3s-update-info.tsx @@ -0,0 +1,269 @@ +'use client'; + +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { checkK3sUpgradeControllerStatus, installK3sUpgradeController, startK3sUpgrade } from "./actions"; +import { Button } from "@/components/ui/button"; +import { Toast } from "@/frontend/utils/toast.utils"; +import { useConfirmDialog } from "@/frontend/states/zustand.states"; +import { RefreshCw, ExternalLink, CheckCircle2, AlertCircle } from "lucide-react"; +import React from "react"; +import Link from "next/link"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { toast } from "sonner"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { QuestionMarkCircledIcon, QuestionMarkIcon } from "@radix-ui/react-icons"; +import { K3sReleaseInfo } from "@/server/adapter/qs-versioninfo.adapter"; + +export default function K3sUpdateInfo({ + initialControllerStatus, + k3sCurrentVersionInfo, + k3sNextVersionInfo, + k3sUpgradeIsInProgress, +}: { + initialControllerStatus: boolean; + k3sCurrentVersionInfo?: K3sReleaseInfo; + k3sNextVersionInfo?: K3sReleaseInfo; + k3sUpgradeIsInProgress: boolean; +}) { + + const useConfirm = useConfirmDialog(); + const [loading, setLoading] = React.useState(false); + const [controllerInstalled, setControllerInstalled] = React.useState(initialControllerStatus); + const [upgradeInProgress, setUpgradeInProgress] = React.useState(k3sUpgradeIsInProgress); + + const handleInstallController = async () => { + if (await useConfirm.openConfirmDialog({ + title: 'Install K3s System Upgrade Controller', + description: 'This will install the system-upgrade-controller in the system-upgrade namespace. This controller is required for automated K3s cluster upgrades. Do you want to continue?', + okButton: "Install Controller", + })) { + try { + setLoading(true); + await Toast.fromAction(() => installK3sUpgradeController()); + setControllerInstalled(true); + } finally { + setLoading(false); + } + } + }; + + const handleCheckStatus = async () => { + try { + setLoading(true); + const result = await checkK3sUpgradeControllerStatus(); + if (result.data && result.data !== undefined) { + setControllerInstalled(result.data); + toast.success(result.data ? 'Controller is installed' : 'Controller is not installed'); + } + } finally { + setLoading(false); + } + }; + + const handleUpgrade = async () => { + if (await useConfirm.openConfirmDialog({ + title: 'Start K3s Cluster Upgrade', + description: ( +
+

+ ⚠️ Warning: This will upgrade your K3s cluster to the version {k3sNextVersionInfo?.version} (latest available patch version). +

+

+ Before proceeding, ensure that: +

+
    +
  • All critical data has been backed up
  • +
  • System backups are enabled and working
  • +
  • Volume backups are configured
  • +
  • You have a recovery plan in case of issues
  • +
+

+ The upgrade process will: +

+
    +
  • Upgrade control-plane/master-nodes first
  • +
  • Then upgrade worker nodes (one at a time)
  • +
  • Cordon and drain nodes during the process
  • +
  • Nodes are temporary down during the upgrade so expect some downtime
  • +
+

+ Are you sure you want to proceed with the upgrade? +

+
+ ), + okButton: "Start Upgrade", + })) { + try { + setLoading(true); + await Toast.fromAction(() => startK3sUpgrade()); + setUpgradeInProgress(true); + } finally { + setLoading(false); + } + } + }; + + return ( + + + + K3s Cluster Upgrades + + + QuickStack uses k3s (Kubernetes distribution) under the hood for managing your cluster. + It is recommended to keep your k3s version up-to-date to benefit from the latest features and security patches. + + + + + + +
+

About K3s Upgrades

+

+ K3s supports automated cluster upgrades through the System Upgrade Controller. QuickStack does not install this controller by default. You can install it below to enable automated upgrades. +

+

+ Once installed, the controller can keep your cluster on a chosen minor-version channel (for example v1.32 or v1.33) and will automatically apply the latest patch releases within that channel. Moving between minor versions (for example v1.32 → v1.33) is a manual action you must trigger via the Update workflow (this UI). +

+

+ Before performing any upgrades, ensure QuickStack's System-Backup and Volume-Backup features are enabled to protect your cluster state and data. +

+
+ + + View K3s Documentation + +
+
+
+
+
+
+
+ + + {!controllerInstalled && (<> + + + The System Upgrade Controller is required for automated K3s cluster upgrades. + Install it below to enable k3s upgrades. + + + + +
+
+
+

System Upgrade Controller

+
+ {controllerInstalled ? ( + <> + + Installed and ready + + ) : ( + <> + + Not installed + + )} +
+
+ + + {!controllerInstalled && } +
+
+ )} + + {controllerInstalled && ( +
+
+
+
+

Current K3s Version

+
+ {k3sCurrentVersionInfo && ( +
+

{k3sCurrentVersionInfo.version}

+

+ Channel: {k3sCurrentVersionInfo.channelUrl} +

+
+ )} +
+
+ + {upgradeInProgress ? <> + + + + An upgrade is currently in progress. + You can monitor the progress in the "Cluster" settings tab. + Do not start another upgrade until the current one is complete. + Refresh this page to check the overall completion status. + This message will disappear once the upgrade is finished. + + + : <> + {k3sNextVersionInfo && ( +
+
+
+ +

Next Version Available

+
+
+

{k3sNextVersionInfo.version}

+

+ Channel: {k3sNextVersionInfo.channelUrl} +

+
+ +
+
+ )} + + {k3sNextVersionInfo === undefined && ( + + + Your cluster is running the latest available K3s version wich is compatible with QuickStack. + + + )} + } +
+ )} +
+
+ ); +} diff --git a/src/app/settings/server/longhorn-ui-toggle.tsx b/src/app/settings/server/longhorn-ui-toggle.tsx new file mode 100644 index 0000000..285973e --- /dev/null +++ b/src/app/settings/server/longhorn-ui-toggle.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Switch } from '@/components/ui/switch'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Code } from '@/components/custom/code'; +import LoadingSpinner from '@/components/ui/loading-spinner'; +import { Toast } from '@/frontend/utils/toast.utils'; +import { Actions } from '@/frontend/utils/nextjs-actions.utils'; +import { useConfirmDialog } from '@/frontend/states/zustand.states'; +import { + disableLonghornUiIngress, + enableLonghornUiIngress, + getLonghornUiCredentials, + getLonghornUiIngressStatus, +} from './actions'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { HardDrive } from 'lucide-react'; + +export default function LonghornUiToggle() { + const { openConfirmDialog } = useConfirmDialog(); + const [isActive, setIsActive] = useState(undefined); + const [loading, setLoading] = useState(false); + + const loadStatus = async () => { + const active = await Actions.run(() => getLonghornUiIngressStatus()); + setIsActive(active); + }; + + const showCredentialsDialog = async (credentials: { url: string; username: string; password: string }) => { + await openConfirmDialog({ + title: 'Open Longhorn UI', + description: ( + <> + Longhorn UI is ready and can be opened in a new tab. +
+ Use the following credentials to log in: +
+ +
{credentials.username}
+
+
+ +
{credentials.password}
+
+
+ +
+ + ), + okButton: '', + cancelButton: 'Close', + }); + }; + + const openLonghornUi = async () => { + try { + setLoading(true); + const credentials = await Actions.run(() => getLonghornUiCredentials()); + setLoading(false); + if (credentials) { + await showCredentialsDialog(credentials); + } + } finally { + setLoading(false); + } + }; + + const handleToggle = async (checked: boolean) => { + try { + setLoading(true); + if (checked) { + const result = await Toast.fromAction( + () => enableLonghornUiIngress(), + 'Longhorn UI access enabled', + 'Enabling Longhorn UI access...' + ); + await loadStatus(); + if (result?.data) { + await showCredentialsDialog(result.data); + } + } else { + await Toast.fromAction( + () => disableLonghornUiIngress(), + 'Longhorn UI access disabled', + 'Disabling Longhorn UI access...' + ); + await loadStatus(); + } + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadStatus(); + return () => { + setIsActive(undefined); + }; + }, []); + + return ( + + + + + Longhorn UI Access + + + Enable access to the Longhorn UI via a password authentication. This is only recommended for advanced users. + + + +
+
+ + +
+ {isActive && ( + + )} + {(loading || isActive === undefined) && } +
+
+
+ ); +} diff --git a/src/app/settings/server/longhorn-update-info.tsx b/src/app/settings/server/longhorn-update-info.tsx new file mode 100644 index 0000000..82af43a --- /dev/null +++ b/src/app/settings/server/longhorn-update-info.tsx @@ -0,0 +1,208 @@ +'use client'; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { startLonghornUpgrade } from "./actions"; +import { Button } from "@/components/ui/button"; +import { Toast } from "@/frontend/utils/toast.utils"; +import { useConfirmDialog } from "@/frontend/states/zustand.states"; +import { RefreshCw, ExternalLink, CheckCircle2, AlertCircle, HardDrive } from "lucide-react"; +import React from "react"; +import Link from "next/link"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { QuestionMarkCircledIcon } from "@radix-ui/react-icons"; +import { LonghornReleaseInfo } from "@/server/adapter/qs-versioninfo.adapter"; + +export default function LonghornUpdateInfo({ + longhornInstalled, + longhornCurrentVersionInfo, + longhornNextVersionInfo, + longhornUpgradeIsInProgress, +}: { + longhornInstalled: boolean; + longhornCurrentVersionInfo?: LonghornReleaseInfo; + longhornNextVersionInfo?: LonghornReleaseInfo; + longhornUpgradeIsInProgress: boolean; +}) { + + const useConfirm = useConfirmDialog(); + const [loading, setLoading] = React.useState(false); + const [upgradeInProgress, setUpgradeInProgress] = React.useState(longhornUpgradeIsInProgress); + + const handleUpgrade = async () => { + if (await useConfirm.openConfirmDialog({ + title: 'Start Longhorn Storage Upgrade', + description: ( +
+

+ ⚠️ Warning: This will upgrade Longhorn to version {longhornNextVersionInfo?.version}. +

+

+ Before proceeding, ensure that: +

+
    +
  • All critical data has been backed up
  • +
  • Volume backups are configured and recent
  • +
  • No critical workloads are running that cannot tolerate brief interruptions
  • +
  • You have reviewed the release notes for breaking changes
  • +
+

+ The upgrade process will: +

+
    +
  • Upgrade the Longhorn manager components
  • +
  • Automatically upgrade volume engines (based on settings)
  • +
  • Attached volumes will be live-upgraded
  • +
  • Detached volumes will be offline-upgraded
  • +
+

+ Are you sure you want to proceed with the upgrade? +

+
+ ), + okButton: "Start Upgrade", + })) { + try { + setLoading(true); + await Toast.fromAction(() => startLonghornUpgrade()); + setUpgradeInProgress(true); + } finally { + setLoading(false); + } + } + }; + + if (!longhornInstalled) { + return ( + + + + + Longhorn Storage Upgrades + + + Longhorn is not installed in this cluster. + + + + + + + Longhorn storage system is not detected. It may not be installed or is not accessible. + + + + + ); + } + + return ( + + + + + Longhorn Storage Upgrades + + + Longhorn provides distributed block storage for your Kubernetes cluster. + Keep it up-to-date for improved performance, stability, and new features. + + + + + + +
+

About Longhorn Upgrades

+

+ Longhorn upgrades are performed by applying the new Longhorn manifest to your cluster. + The upgrade process will update all Longhorn components including the manager, engine, and UI. +

+

+ Volume Engine Upgrades: After upgrading Longhorn manager, volume engines + are automatically upgraded. + Attached volumes are live-upgraded while detached volumes are offline-upgraded. +

+
+ + + View Longhorn Upgrade Documentation + +
+
+
+
+
+
+
+ +
+
+
+
+

Current Longhorn Version

+
+ {longhornCurrentVersionInfo ? ( +
+

{longhornCurrentVersionInfo.version}

+
+ ) : ( +
+

Version information not available

+
+ )} +
+
+ + {upgradeInProgress ? ( + + + + A Longhorn upgrade is currently in progress. + The manager pods are being updated. Volume engines will be upgraded automatically afterwards. + Refresh this page to check the completion status. + + + ) : ( + <> + {longhornNextVersionInfo && ( +
+
+
+ +

Next Version Available

+
+
+

{longhornNextVersionInfo.version}

+
+ +
+
+ )} + + {longhornNextVersionInfo === undefined && ( + + + Your cluster is running the latest available Longhorn version which is compatible with QuickStack. + + + )} + + )} +
+
+
+ ); +} diff --git a/src/app/settings/server/page.tsx b/src/app/settings/server/page.tsx index 7f595c3..5350ce1 100644 --- a/src/app/settings/server/page.tsx +++ b/src/app/settings/server/page.tsx @@ -1,6 +1,6 @@ 'use server' -import { getAdminUserSession, getAuthUserSession } from "@/server/utils/action-wrapper.utils"; +import { getAdminUserSession } from "@/server/utils/action-wrapper.utils"; import PageTitle from "@/components/custom/page-title"; import paramService, { ParamService } from "@/server/services/param.service"; import QuickStackIngressSettings from "./qs-ingress-settings"; @@ -15,16 +15,16 @@ import BreadcrumbSetter from "@/components/breadcrumbs-setter"; import traefikService from "@/server/services/traefik.service"; import { Separator } from "@/components/ui/separator"; import { TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import QuickStackVersionInfo from "./qs-version-info"; import QuickStackMaintenanceSettings from "./qs-maintenance-settings"; import podService from "@/server/services/pod.service"; -import quickStackService from "@/server/services/qs.service"; import { ServerSettingsTabs } from "./server-settings-tabs"; import { Settings, Network, HardDrive, Rocket, Wrench } from "lucide-react"; import quickStackUpdateService from "@/server/services/qs-update.service"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; -import clusterService from "@/server/services/node.service"; +import clusterService from "@/server/services/cluster.service"; import NodeInfo from "./nodeInfo"; +import UpdateInfoPage from "./update-info"; +import LonghornUiToggle from "./longhorn-ui-toggle"; export default async function ProjectPage({ searchParams @@ -41,7 +41,6 @@ export default async function ProjectPage({ regitryStorageLocation, ipv4Address, systemBackupLocation, - useCanaryChannel, clusterJoinToken ] = await Promise.all([ paramService.getString(ParamService.QS_SERVER_HOSTNAME, ''), @@ -50,7 +49,6 @@ export default async function ProjectPage({ paramService.getString(ParamService.REGISTRY_SOTRAGE_LOCATION, Constants.INTERNAL_REGISTRY_LOCATION), paramService.getString(ParamService.PUBLIC_IPV4_ADDRESS), paramService.getString(ParamService.QS_SYSTEM_BACKUP_LOCATION, Constants.QS_SYSTEM_BACKUP_DEACTIVATED), - paramService.getBoolean(ParamService.USE_CANARY_CHANNEL, false), paramService.getString(ParamService.K3S_JOIN_TOKEN) ]); @@ -58,14 +56,12 @@ export default async function ProjectPage({ s3Targets, traefikStatus, qsPodInfos, - currentVersion, newVersionInfo, nodeInfo ] = await Promise.all([ s3TargetService.getAll(), traefikService.getStatus(), podService.getPodsForApp(Constants.QS_NAMESPACE, Constants.QS_APP_NAME), - quickStackService.getVersionOfCurrentQuickstackInstance(), quickStackUpdateService.getNewVersionInfo(), clusterService.getNodeInfo() ]); @@ -118,6 +114,7 @@ export default async function ProjectPage({
+
@@ -125,9 +122,7 @@ export default async function ProjectPage({ -
- -
+
diff --git a/src/app/settings/server/update-info.tsx b/src/app/settings/server/update-info.tsx new file mode 100644 index 0000000..28c2af8 --- /dev/null +++ b/src/app/settings/server/update-info.tsx @@ -0,0 +1,89 @@ +'use server' + +import k3sUpdateService from "@/server/services/upgrade-services/k3s-update.service"; +import longhornUpdateService from "@/server/services/upgrade-services/longhorn-update.service"; +import paramService, { ParamService } from "@/server/services/param.service"; +import { getAdminUserSession } from "@/server/utils/action-wrapper.utils"; +import QuickStackVersionInfo from "./qs-version-info"; +import K3sUpdateInfo from "./k3s-update-info"; +import LonghornUpdateInfo from "./longhorn-update-info"; +import quickStackService from "@/server/services/qs.service"; +import quickStackUpdateService from "@/server/services/qs-update.service"; + +export default async function UpdateInfoPage() { + + await getAdminUserSession(); + + const [ + useCanaryChannel, + currentVersion, + newVersionInfo, + k3sControllerStatus, + longhornInstalled, + ] = await Promise.all([ + paramService.getBoolean(ParamService.USE_CANARY_CHANNEL, false), + quickStackService.getVersionOfCurrentQuickstackInstance(), + quickStackUpdateService.getNewVersionInfo(), + k3sUpdateService.isSystemUpgradeControllerPresent(), + longhornUpdateService.isInstalled() + ]); + + // Loading K3s data with sideeffects + let k3sCurrentVersionInfo; + let k3sNextVersionInfo; + let k3sUpgradeIsInProgress = false; + try { + const [ + k3sCurrentVersionInfoLoaded, + k3sNextVersionInfoLoaded, + k3sUpgradeIsInProgressLoaded, + ] = await Promise.all([ + k3sUpdateService.getVersionInfoForCurrentK3sVersion(), + k3sUpdateService.getNextAvailableK3sReleaseVersionInfo(), + k3sUpdateService.isUpgradeInProgress() + ]); + k3sCurrentVersionInfo = k3sCurrentVersionInfoLoaded; + k3sNextVersionInfo = k3sNextVersionInfoLoaded; + k3sUpgradeIsInProgress = k3sUpgradeIsInProgressLoaded; + } catch (error) { + console.error('Error fetching K3s version info:', error); + } + + // Loading Longhorn data with sideeffects + let longhornCurrentVersionInfo; + let longhornNextVersionInfo; + let longhornUpgradeIsInProgress = false; + if (longhornInstalled) { + try { + const [ + longhornCurrentVersionInfoLoaded, + longhornNextVersionInfoLoaded, + longhornUpgradeIsInProgressLoaded, + ] = await Promise.all([ + longhornUpdateService.getVersionInfoForCurrentVersion(), + longhornUpdateService.getNextAvailableVersion(), + longhornUpdateService.isUpgradeInProgress() + ]); + longhornCurrentVersionInfo = longhornCurrentVersionInfoLoaded; + longhornNextVersionInfo = longhornNextVersionInfoLoaded; + longhornUpgradeIsInProgress = longhornUpgradeIsInProgressLoaded; + } catch (error) { + console.error('Error fetching Longhorn version info:', error); + } + } + + + return
+ + + +
; + +} \ No newline at end of file diff --git a/src/server/adapter/kubernetes-api.adapter.ts b/src/server/adapter/kubernetes-api.adapter.ts index 527d4fd..a32ecb2 100644 --- a/src/server/adapter/kubernetes-api.adapter.ts +++ b/src/server/adapter/kubernetes-api.adapter.ts @@ -1,3 +1,4 @@ +import { ServiceException } from '@/shared/model/service.exception.model'; import * as k8s from '@kubernetes/client-node'; class K3sApiAdapter { @@ -69,6 +70,45 @@ class K3sApiAdapter { getMetricsApiClient = () => { return new k8s.Metrics(this.getKubeConfig()); } + + /** + * Applies a single Kubernetes resource to the cluster + * @param kc KubeConfig instance + * @param spec Resource specification + */ + public async applyResource(spec: any, namespace: string): Promise { + if (!spec || !spec.kind) { + console.error('Invalid resource specification:', spec); + throw new Error('Invalid resource specification'); + } + + namespace = spec.metadata.namespace || namespace; + + if (!namespace) { + throw new ServiceException('Namespace is required in resource metadata in method applyResource'); + } + + const name = spec.metadata?.name; + + console.log(`Applying ${spec.kind}/${name} to namespace ${namespace}`); + + try { + const client = k8s.KubernetesObjectApi.makeApiClient(this.getKubeConfig()); + + try { + await client.read(spec); + // If it exists, patch it + await client.patch(spec); + console.log(`Updated ${spec.kind}/${name}`); + } catch (error) { + await client.create(spec); + console.log(`Created ${spec.kind}/${name}`); + } + } catch (error) { + console.error(`Failed to apply ${spec.kind}/${name}:`, error); + throw error; + } + } } const k3s = new K3sApiAdapter(); diff --git a/src/server/adapter/qs-versioninfo.adapter.ts b/src/server/adapter/qs-versioninfo.adapter.ts new file mode 100644 index 0000000..11d617d --- /dev/null +++ b/src/server/adapter/qs-versioninfo.adapter.ts @@ -0,0 +1,85 @@ +import { K3sReleaseResponseSchema, LonghornReleaseResponseSchema } from "@/shared/model/generated-zod/k3s-longhorn-release-schemas"; + +export interface K3sReleaseInfo { + version: string; + channelUrl: string; +} + +export interface LonghornReleaseInfo { + version: string; + yamlUrl: string; +} + +interface ReleaseResponse { + prodInstallVersion: string; + canaryInstallVersion: string; +} + +interface K3sReleaseResponse extends ReleaseResponse { + prod: K3sReleaseInfo[]; + canary: K3sReleaseInfo[]; +} + +interface LonghornReleaseResponse extends ReleaseResponse { + prod: LonghornReleaseInfo[]; + canary: LonghornReleaseInfo[]; +} + +class QsVersionInfoAdapter { + + private readonly API_BASE_URL = 'https://get.quickstack.dev'; + + private async getK3sVersioninfo(): Promise { + const response = await fetch(`${this.API_BASE_URL}/k3s-versions.json`, { + cache: 'no-cache', + method: 'GET', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + }); + if (!response.ok) { + throw new Error(`Failed to fetch latest QuickStack K3s Prod version from API: HTTP ${response.status} ${response.statusText}`); + } + const reponseJson = await response.json(); + return K3sReleaseResponseSchema.parse(reponseJson); + } + + private async getLonghornVersioninfo(): Promise { + const response = await fetch(`${this.API_BASE_URL}/longhorn-versions.json`, { + cache: 'no-cache', + method: 'GET', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + }); + if (!response.ok) { + throw new Error(`Failed to fetch Longhorn version info from API: HTTP ${response.status} ${response.statusText}`); + } + const responseJson = await response.json(); + return LonghornReleaseResponseSchema.parse(responseJson); + } + + public async getProdK3sReleaseInfo(): Promise { + const releaseInfo = await this.getK3sVersioninfo(); + return releaseInfo.prod; + } + + public async getCanaryK3sReleaseInfo(): Promise { + const releaseInfo = await this.getK3sVersioninfo(); + return releaseInfo.canary; + } + + public async getProdLonghornReleaseInfo(): Promise { + const releaseInfo = await this.getLonghornVersioninfo(); + return releaseInfo.prod; + } + + public async getCanaryLonghornReleaseInfo(): Promise { + const releaseInfo = await this.getLonghornVersioninfo(); + return releaseInfo.canary; + } +} + +export const qsVersionInfoAdapter = new QsVersionInfoAdapter(); \ No newline at end of file diff --git a/src/server/services/node.service.ts b/src/server/services/cluster.service.ts similarity index 100% rename from src/server/services/node.service.ts rename to src/server/services/cluster.service.ts diff --git a/src/server/services/hostname-dns-provider.service.ts b/src/server/services/hostname-dns-provider.service.ts index 1c9f1e4..e2a7ad7 100644 --- a/src/server/services/hostname-dns-provider.service.ts +++ b/src/server/services/hostname-dns-provider.service.ts @@ -3,7 +3,8 @@ import paramService, { ParamService } from "./param.service"; import { HostnameDnsProviderUtils } from "@/shared/utils/domain-dns-provider.utils"; /** - * Service for Domaing DNS providers like traefik.me or sslip.io. + * Service for Domaing DNS providers like sslip.io. + * --> QuickStack uses own quickstack.me domain. */ class HostnameDnsProviderService { @@ -17,6 +18,17 @@ class HostnameDnsProviderService { } return `${appId}.${HostnameDnsProviderUtils.getHostnameForIpAdress(publicIpv4)}`; } + + async getHexDomainForApp(appId: string, prefix?: string) { + const publicIpv4 = await paramService.getString(ParamService.PUBLIC_IPV4_ADDRESS); + if (!publicIpv4) { + throw new ServiceException('Please set the main public IPv4 address in the QuickStack settings first.'); + } + if (prefix) { + return `${prefix}-${appId}.${HostnameDnsProviderUtils.getHexHostnameForIpAddress(publicIpv4)}`; + } + return `${appId}.${HostnameDnsProviderUtils.getHexHostnameForIpAddress(publicIpv4)}`; + } } const hostnameDnsProviderService = new HostnameDnsProviderService(); diff --git a/src/server/services/ingress.service.ts b/src/server/services/ingress.service.ts index 25f984f..4c74909 100644 --- a/src/server/services/ingress.service.ts +++ b/src/server/services/ingress.service.ts @@ -6,6 +6,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 { CryptoUtils } from "../utils/crypto.utils"; class IngressService { @@ -159,20 +160,46 @@ class IngressService { await k3s.customObjects.deleteNamespacedCustomObject('traefik.io', 'v1alpha1', namespace, 'middlewares', middlewareName); } - // delete secret + // delete traefik basic auth secret const secretName = `bas-${basicAuthId}`; const existingSecrets = await k3s.core.listNamespacedSecret(namespace); const existingSecret = existingSecrets.body.items.find((item) => item.metadata?.name === secretName); if (existingSecret) { await k3s.core.deleteNamespacedSecret(secretName, namespace); } + + // delete plaintext credentials secret + const plaintextSecretName = `bas-plain-${basicAuthId}`; + const existingPlaintextSecret = existingSecrets.body.items.find((item) => item.metadata?.name === plaintextSecretName); + if (existingPlaintextSecret) { + await k3s.core.deleteNamespacedSecret(plaintextSecretName, namespace); + } + } + + /** + * Reads plaintext credentials from a separate bas-plain-{id} secret created alongside the basic auth middleware. + * @returns { username, password } or undefined if not present + */ + async getPlaintextCredentialsFromSecret(namespace: string, basicAuthId: string): Promise<{ username: string; password: string } | undefined> { + const plaintextSecretName = `bas-plain-${basicAuthId}`; + const existingSecrets = await k3s.core.listNamespacedSecret(namespace); + const secret = existingSecrets.body.items.find((item) => item.metadata?.name === plaintextSecretName); + if (!secret?.data) return undefined; + const usernameB64 = secret.data['username']; + const passwordB64 = secret.data['password']; + if (!usernameB64 || !passwordB64) return undefined; + return { + username: Buffer.from(usernameB64, 'base64').toString('utf-8'), + password: CryptoUtils.decrypt(Buffer.from(passwordB64, 'base64').toString('utf-8')), + }; } /** * Configures a basic auth middleware in a namespace. + * @param storeCredentialsSeparately When true, also stores plainUsername and encryptet plainPassword in the secret for later retrieval. * @returns middleware name for annotation in ingress controller */ - async configureBasicAuthMiddleware(namespace: string, basicAuthId: string, usernamePassword: [string, string][]) { + async configureBasicAuthMiddleware(namespace: string, basicAuthId: string, usernamePassword: [string, string][], storeCredentialsSeparately = false) { const basicAuthNameMiddlewareName = `ba-${basicAuthId}`; // basic auth middleware const basicAuthSecretName = `bas-${basicAuthId}`; // basic auth secret @@ -186,6 +213,7 @@ class IngressService { const usernameAndSha1PasswordStrings = usernamePassword.map(([username, password]) => `${username}:{SHA}${createHash('sha1').update(password).digest('base64')}`); + // Traefik requires the secret to contain only the `users` field const secretManifest: V1Secret = { apiVersion: 'v1', kind: 'Secret', @@ -206,6 +234,28 @@ class IngressService { secretManifest // object manifest ); + // Store plaintext credentials in a separate secret so they can be displayed to the user + if (storeCredentialsSeparately && usernamePassword.length > 0) { + const plaintextSecretName = `bas-plain-${basicAuthId}`; + const existingPlaintextSecret = existingSecrets.body.items.find((item) => item.metadata?.name === plaintextSecretName); + const plaintextSecretManifest: V1Secret = { + apiVersion: 'v1', + kind: 'Secret', + metadata: { + name: plaintextSecretName, + namespace: secretNamespace, + }, + data: { + username: Buffer.from(usernamePassword[0][0]).toString('base64'), + password: Buffer.from(CryptoUtils.encrypt(usernamePassword[0][1])).toString('base64') + } + }; + if (existingPlaintextSecret) { + await k3s.core.deleteNamespacedSecret(plaintextSecretName, secretNamespace); + } + await k3s.core.createNamespacedSecret(secretNamespace, plaintextSecretManifest); + } + // Create a middleware with basic auth const existingBasicAuthMiddlewares = await k3s.customObjects.listNamespacedCustomObject('traefik.io', // group 'v1alpha1', // version @@ -234,7 +284,7 @@ class IngressService { await k3s.customObjects.createNamespacedCustomObject( 'traefik.io', // group 'v1alpha1', // version - middlewareNamespace, // namespace + middlewareNamespace, // namespace 'middlewares', // plural name of the custom resource middlewareManifest // object manifest ); diff --git a/src/server/services/longhorn-ui.service.ts b/src/server/services/longhorn-ui.service.ts new file mode 100644 index 0000000..67bcb75 --- /dev/null +++ b/src/server/services/longhorn-ui.service.ts @@ -0,0 +1,106 @@ +import { randomBytes } from 'crypto'; +import k3s from '../adapter/kubernetes-api.adapter'; +import { KubeObjectNameUtils } from '../utils/kube-object-name.utils'; +import ingressService from './ingress.service'; +import hostnameDnsProviderService from './hostname-dns-provider.service'; +import { V1Ingress } from '@kubernetes/client-node'; +import { AppTemplateUtils } from '../utils/app-template.utils'; +import { CryptoUtils } from '../utils/crypto.utils'; + +class LonghornUiService { + + private readonly NAMESPACE = 'longhorn-system'; + private readonly INGRESS_ID = 'longhorn-ui'; + private readonly LONGHORN_FRONTEND_SERVICE = 'longhorn-frontend'; + private readonly LONGHORN_FRONTEND_PORT = 80; + private readonly USERNAME = 'quickstack'; + + async isIngressActive(): Promise { + const existing = await ingressService.getIngressByName(this.NAMESPACE, this.INGRESS_ID); + return !!existing; + } + + async enable(): Promise<{ url: string; username: string; password: string }> { + const password = CryptoUtils.generateStrongPasswort(35); + const hostname = await hostnameDnsProviderService.getHexDomainForApp(this.INGRESS_ID); + + const basicAuthMiddlewareName = await ingressService.configureBasicAuthMiddleware( + this.NAMESPACE, + this.INGRESS_ID, + [[this.USERNAME, password]], + true // store plaintext credentials in the secret + ); + + const ingressName = KubeObjectNameUtils.getIngressName(this.INGRESS_ID); + const ingressDefinition: V1Ingress = { + apiVersion: 'networking.k8s.io/v1', + kind: 'Ingress', + metadata: { + name: ingressName, + namespace: this.NAMESPACE, + annotations: { + 'cert-manager.io/cluster-issuer': 'letsencrypt-production', + 'traefik.ingress.kubernetes.io/router.middlewares': basicAuthMiddlewareName, + }, + }, + spec: { + ingressClassName: 'traefik', + rules: [ + { + host: hostname, + http: { + paths: [ + { + path: '/', + pathType: 'Prefix', + backend: { + service: { + name: this.LONGHORN_FRONTEND_SERVICE, + port: { + number: this.LONGHORN_FRONTEND_PORT, + }, + }, + }, + }, + ], + }, + }, + ], + tls: [ + { + hosts: [hostname], + secretName: `secret-tls-${this.INGRESS_ID}`, + }, + ], + }, + }; + + const existingIngress = await ingressService.getIngressByName(this.NAMESPACE, this.INGRESS_ID); + if (existingIngress) { + await k3s.network.replaceNamespacedIngress(ingressName, this.NAMESPACE, ingressDefinition); + } else { + await k3s.network.createNamespacedIngress(this.NAMESPACE, ingressDefinition); + } + + return { url: `https://${hostname}`, username: this.USERNAME, password }; + } + + async getCredentials(): Promise<{ url: string; username: string; password: string } | undefined> { + const creds = await ingressService.getPlaintextCredentialsFromSecret(this.NAMESPACE, this.INGRESS_ID); + if (!creds) return undefined; + const hostname = await hostnameDnsProviderService.getHexDomainForApp(this.INGRESS_ID); + return { url: `https://${hostname}`, ...creds }; + } + + async disable(): Promise { + const ingressName = KubeObjectNameUtils.getIngressName(this.INGRESS_ID); + const existingIngress = await ingressService.getIngressByName(this.NAMESPACE, this.INGRESS_ID); + if (existingIngress) { + await k3s.network.deleteNamespacedIngress(ingressName, this.NAMESPACE); + } + await ingressService.deleteUnusedBasicAuthMiddlewares(this.NAMESPACE, this.INGRESS_ID); + } +} + +const longhornUiService = new LonghornUiService(); +export default longhornUiService; diff --git a/src/server/services/monitoring.service.ts b/src/server/services/monitoring.service.ts index 4c1588d..5bb7482 100644 --- a/src/server/services/monitoring.service.ts +++ b/src/server/services/monitoring.service.ts @@ -1,7 +1,7 @@ import k3s from "../adapter/kubernetes-api.adapter"; import * as k8s from '@kubernetes/client-node'; import standalonePodService from "./standalone-services/standalone-pod.service"; -import clusterService from "./node.service"; +import clusterService from "./cluster.service"; import { PodsResourceInfoModel } from "@/shared/model/pods-resource-info.model"; import { KubeSizeConverter } from "../../shared/utils/kubernetes-size-converter.utils"; import { AppVolumeMonitoringUsageModel } from "@/shared/model/app-volume-monitoring-usage.model"; diff --git a/src/server/services/registry.service.ts b/src/server/services/registry.service.ts index d623ef7..ff67d11 100644 --- a/src/server/services/registry.service.ts +++ b/src/server/services/registry.service.ts @@ -7,7 +7,7 @@ import paramService, { ParamService } from "./param.service"; import { Constants } from "@/shared/utils/constants"; import { S3Target } from "@prisma/client"; import s3TargetService from "./s3-target.service"; -import clusterService from "./node.service"; +import clusterService from "./cluster.service"; import { ServiceException } from "@/shared/model/service.exception.model"; const REGISTRY_NODE_PORT = 30100; diff --git a/src/server/services/upgrade-services/k3s-update.service.ts b/src/server/services/upgrade-services/k3s-update.service.ts new file mode 100644 index 0000000..44692fa --- /dev/null +++ b/src/server/services/upgrade-services/k3s-update.service.ts @@ -0,0 +1,344 @@ +import { unstable_cache } from "next/cache"; +import quickStackService from "../qs.service"; +import { githubAdapter } from "../../adapter/github.adapter"; +import { Tags } from "../../utils/cache-tag-generator.utils"; +import k3s from "../../adapter/kubernetes-api.adapter"; +import namespaceService from "../namespace.service"; +import * as k8s from '@kubernetes/client-node'; +import { ServiceException } from "@/shared/model/service.exception.model"; +import clusterService from "../cluster.service"; +import { K3sVersionUtils } from '@/server/utils/k3s-version.utils'; +import { qsVersionInfoAdapter } from "../../adapter/qs-versioninfo.adapter"; +import paramService, { ParamService } from "../param.service"; + +class K3sUpdateService { + + private readonly SYSTEM_UPGRADE_NAMESPACE = 'system-upgrade'; + private readonly SYSTEM_UPGRADE_CONTROLLER_NAME = 'system-upgrade-controller'; + private readonly SYSTEM_UPGRADE_CRD_URL = 'https://github.com/rancher/system-upgrade-controller/releases/latest/download/crd.yaml'; + private readonly SYSTEM_UPGRADE_CONTROLLER_URL = 'https://github.com/rancher/system-upgrade-controller/releases/latest/download/system-upgrade-controller.yaml'; + + /** + * Checks if the system-upgrade-controller deployment exists in the system-upgrade namespace. + * This is required for automated K3s cluster upgrades. + */ + async isSystemUpgradeControllerPresent(): Promise { + try { + await k3s.apps.readNamespacedDeployment( + this.SYSTEM_UPGRADE_CONTROLLER_NAME, + this.SYSTEM_UPGRADE_NAMESPACE + ); + return true; + } catch (error) { + // Deployment not found + return false; + } + } + + /** + * Installs the system-upgrade-controller by applying the yaml manifests from the official docs https://docs.k3s.io/upgrades/automated + * This is required for automated K3s cluster upgrades. + * + * @throws Error if the installation fails + */ + async installSystemUpgradeController(): Promise { + + if (await this.isSystemUpgradeControllerPresent()) { + throw new ServiceException('System Upgrade Controller is already installed.'); + } + + await namespaceService.createNamespaceIfNotExists(this.SYSTEM_UPGRADE_NAMESPACE); + + console.log('Fetching and applying CRD manifest...'); + await this.applyManifestFromUrl(this.SYSTEM_UPGRADE_CRD_URL); + + console.log('Fetching and applying system-upgrade-controller manifest...'); + await this.applyManifestFromUrl(this.SYSTEM_UPGRADE_CONTROLLER_URL); + } + + /** + * Fetches a YAML manifest from a URL and applies it to the cluster + * @param url URL to fetch the manifest from + */ + private async applyManifestFromUrl(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch manifest from ${url}: ${response.statusText}`); + } + const yamlContent = await response.text(); + + const specs = k8s.loadAllYaml(yamlContent); + + // Apply each resource + for (const spec of specs) { + await k3s.applyResource(spec, this.SYSTEM_UPGRADE_NAMESPACE); + } + } + + async getCurrentK3sMinorVersion() { + const nodes = await clusterService.getNodeInfo(); + // check if all k3s versions are the same --> otherwise upgrade may be in progress or cluster is in inconsistent state + const uniqueVersions = Array.from(new Set(nodes.map(n => n.kubeletVersion))); + if (uniqueVersions.length !== 1) { + throw new ServiceException('Not all nodes have the same K3s version installed. Maybe a update is currently in progress. Cannot perform any upgrade operations.'); + } + return K3sVersionUtils.getMinorVersion(uniqueVersions[0]); + } + + async getVersionInfoForCurrentK3sVersion() { + const currentMinorVersion = await this.getCurrentK3sMinorVersion(); + const versionInfo = await this.getVersionInfoForCurrentChannel(); + const matchingChannel = versionInfo.find(channel => channel.version === currentMinorVersion); + if (!matchingChannel) { + throw new ServiceException(`No matching release channel found for current K3s version ${currentMinorVersion}`); + } + return matchingChannel; + } + + async getNextAvailableK3sReleaseVersionInfo() { + const currentMinorVersion = await this.getCurrentK3sMinorVersion(); + const versionInfo = await this.getVersionInfoForCurrentChannel(); + + const currentUpgradePlan = await this.getCurrentUpgradePlans(); + if (!currentUpgradePlan) { + // there are currently no upgrade plans installed --> return the current release channel to install the latest path version of the current minor version + return versionInfo + .find(channel => channel.version === currentMinorVersion); + } + + // find the next higher version + const sortedChannels = versionInfo + .filter(channel => channel.version > currentMinorVersion); // sorting is already correctly provded by the adapter / API source + if (sortedChannels.length === 0) { + return undefined; + } + return sortedChannels[0]; + } + + private async getVersionInfoForCurrentChannel() { + const useCanary = await paramService.getBoolean(ParamService.USE_CANARY_CHANNEL, false) + if (useCanary) { + return await qsVersionInfoAdapter.getCanaryK3sReleaseInfo(); + } + return await qsVersionInfoAdapter.getProdK3sReleaseInfo(); + } + + /** + * Gets the current upgrade plans (server-plan and agent-plan) from the cluster + * @returns Object with serverPlan and agentPlan, or undefined if plans don't exist + */ + async getCurrentUpgradePlans(): Promise<{ serverPlan: any; agentPlan: any } | undefined> { + try { + const kc = k3s.getKubeConfig(); + const client = k8s.KubernetesObjectApi.makeApiClient(kc); + + const serverPlanSpec = { + apiVersion: 'upgrade.cattle.io/v1', + kind: 'Plan', + metadata: { + name: 'server-plan', + namespace: this.SYSTEM_UPGRADE_NAMESPACE + } + }; + + const agentPlanSpec = { + apiVersion: 'upgrade.cattle.io/v1', + kind: 'Plan', + metadata: { + name: 'agent-plan', + namespace: this.SYSTEM_UPGRADE_NAMESPACE + } + }; + + try { + const [serverPlan, agentPlan] = await Promise.all([ + client.read(serverPlanSpec), + client.read(agentPlanSpec) + ]); + + return { + serverPlan: serverPlan.body, + agentPlan: agentPlan.body + }; + } catch (error) { + // Plans don't exist + return undefined; + } + } catch (error) { + console.error('Error fetching upgrade plans:', error); + return undefined; + } + } + + /** + * Checks if a plan has completed successfully + * @param plan The upgrade plan to check + * @returns true if the plan has a Complete condition with status "True" + */ + private isPlanCompleted(plan: any): boolean { + if (!plan?.status?.conditions || !Array.isArray(plan.status.conditions)) { + return false; + } + + const completeCondition = plan.status.conditions.find( + (condition: any) => condition.type === 'Complete' + ); + + return completeCondition?.status === 'True'; + } + + /** + * Determines if a K3s upgrade is currently in progress + * @returns true if an upgrade is in progress, false otherwise + * + * An upgrade is considered in progress if: + * - No upgrade plans exist (no upgrade has been initiated) + * - Either the server-plan or agent-plan is not completed + */ + async isUpgradeInProgress(): Promise { + const plans = await this.getCurrentUpgradePlans(); + + // No plans exist - no upgrade in progress + if (!plans) { + return false; + } + + // Check if both plans are completed + const serverPlanCompleted = this.isPlanCompleted(plans.serverPlan); + const agentPlanCompleted = this.isPlanCompleted(plans.agentPlan); + + // If either plan is not completed, upgrade is in progress + return !serverPlanCompleted || !agentPlanCompleted; + } + + /** + * Creates upgrade plans for control-plane and worker nodes to upgrade to the next available K3s version calculated by getNextAvailableK3sReleaseVersionInfo. + * This function triggers the start of the upgrade progress. + * If upgrade plans already exist, they will be deleted first. + */ + async createUpgradePlans(): Promise { + if (!await this.isSystemUpgradeControllerPresent()) { + throw new ServiceException('System Upgrade Controller must be installed before creating upgrade plans.'); + } + + const versionInfo = await this.getNextAvailableK3sReleaseVersionInfo(); + if (!versionInfo) { + throw new ServiceException('No next available K3s version found for upgrade.'); + } + + const channelUrl = versionInfo.channelUrl; + + // Delete existing plans if they exist + await this.deleteUpgradePlans(); + + // Server Plan - upgrades control-plane nodes + const serverPlan = { + apiVersion: 'upgrade.cattle.io/v1', + kind: 'Plan', + metadata: { + name: 'server-plan', + namespace: this.SYSTEM_UPGRADE_NAMESPACE + }, + spec: { + concurrency: 1, + cordon: true, + nodeSelector: { + matchExpressions: [ + { + key: 'node-role.kubernetes.io/control-plane', + operator: 'In', + values: ['true'] + } + ] + }, + serviceAccountName: 'system-upgrade', + upgrade: { + image: 'rancher/k3s-upgrade' + }, + channel: channelUrl + } + }; + + // Agent Plan - upgrades worker nodes + const agentPlan = { + apiVersion: 'upgrade.cattle.io/v1', + kind: 'Plan', + metadata: { + name: 'agent-plan', + namespace: this.SYSTEM_UPGRADE_NAMESPACE + }, + spec: { + concurrency: 1, + cordon: true, + nodeSelector: { + matchExpressions: [ + { + key: 'node-role.kubernetes.io/control-plane', + operator: 'DoesNotExist' + } + ] + }, + prepare: { + args: ['prepare', 'server-plan'], + image: 'rancher/k3s-upgrade' + }, + serviceAccountName: 'system-upgrade', + upgrade: { + image: 'rancher/k3s-upgrade' + }, + channel: channelUrl + } + }; + + console.log('Creating server-plan...'); + await k3s.applyResource(serverPlan, this.SYSTEM_UPGRADE_NAMESPACE); + + console.log('Creating agent-plan...'); + await k3s.applyResource(agentPlan, this.SYSTEM_UPGRADE_NAMESPACE); + + console.log('Upgrade plans created successfully'); + } + + async deleteUpgradePlans(): Promise { + const plans = await this.getCurrentUpgradePlans(); + + if (!plans) { + // No plans to delete + return; + } + + const kc = k3s.getKubeConfig(); + const client = k8s.KubernetesObjectApi.makeApiClient(kc); + + console.log('Deleting existing upgrade plans...'); + + if (plans.agentPlan) { + // Delete agent-plan first (it depends on server-plan) + await client.delete({ + apiVersion: 'upgrade.cattle.io/v1', + kind: 'Plan', + metadata: { + name: 'agent-plan', + namespace: this.SYSTEM_UPGRADE_NAMESPACE + } + }); + console.log('Deleted agent-plan'); + } + + if (plans.serverPlan) { + // Delete server-plan + await client.delete({ + apiVersion: 'upgrade.cattle.io/v1', + kind: 'Plan', + metadata: { + name: 'server-plan', + namespace: this.SYSTEM_UPGRADE_NAMESPACE + } + }); + console.log('Deleted server-plan'); + } + } +} + +const k3sUpdateService = new K3sUpdateService(); +export default k3sUpdateService; \ No newline at end of file diff --git a/src/server/services/upgrade-services/longhorn-update.service.ts b/src/server/services/upgrade-services/longhorn-update.service.ts new file mode 100644 index 0000000..f37ccd1 --- /dev/null +++ b/src/server/services/upgrade-services/longhorn-update.service.ts @@ -0,0 +1,238 @@ +import k3s from "../../adapter/kubernetes-api.adapter"; +import * as k8s from '@kubernetes/client-node'; +import { ServiceException } from "@/shared/model/service.exception.model"; +import { qsVersionInfoAdapter, LonghornReleaseInfo, K3sReleaseInfo } from "../../adapter/qs-versioninfo.adapter"; +import paramService, { ParamService } from "../param.service"; + +class LonghornUpdateService { + + private readonly LONGHORN_NAMESPACE = 'longhorn-system'; + private readonly LONGHORN_MANAGER_NAME = 'longhorn-manager'; + + /** + * Gets the currently installed Longhorn version by reading the longhorn-manager DaemonSet image tag + * @returns The current version string (e.g., "v1.7.2") or undefined if Longhorn is not installed + */ + async getCurrentVersion(): Promise { + try { + const daemonSet = await k3s.apps.readNamespacedDaemonSet( + this.LONGHORN_MANAGER_NAME, + this.LONGHORN_NAMESPACE + ); + + const image = daemonSet.body.spec?.template?.spec?.containers?.[0]?.image; + if (!image) { + return undefined; + } + + // Image format: longhornio/longhorn-manager:v1.7.2 + const version = image.split(':')[1]; + return version; + } catch (error) { + // Longhorn not installed or error reading + console.error('Error getting current Longhorn version:', error); + return undefined; + } + } + + /** + * Checks if Longhorn is installed in the cluster + */ + async isInstalled(): Promise { + try { + await k3s.apps.readNamespacedDaemonSet( + this.LONGHORN_MANAGER_NAME, + this.LONGHORN_NAMESPACE + ); + return true; + } catch (error) { + return false; + } + } + + /** + * Gets the version info for the currently installed Longhorn version + */ + async getVersionInfoForCurrentVersion(): Promise { + const currentVersion = await this.getCurrentVersion(); + if (!currentVersion) { + return undefined; + } + + const versionInfo = await this.getVersionInfoForCurrentChannel(); + return versionInfo.find(v => v.version === currentVersion); + } + + /** + * Gets the next available Longhorn version for upgrade + */ + async getNextAvailableVersion(): Promise { + const currentVersion = await this.getCurrentVersion(); + if (!currentVersion) { + return undefined; + } + + const versionInfo = await this.getVersionInfoForCurrentChannel(); + + // Find versions newer than current using semantic version comparison + const currentIndex = versionInfo.findIndex(v => v.version === currentVersion); + if (currentIndex === -1) { + // Current version not in list, return the latest compatible version + return versionInfo.length > 0 ? versionInfo[versionInfo.length - 1] : undefined; + } + + // Return next version if available (list is ordered oldest to newest) + if (currentIndex < versionInfo.length - 1) { + return versionInfo[currentIndex + 1]; + } + + // Already on latest + return undefined; + } + + /** + * Gets available Longhorn versions based on channel preference + */ + private async getVersionInfoForCurrentChannel(): Promise { + const useCanary = await paramService.getBoolean(ParamService.USE_CANARY_CHANNEL, false); + if (useCanary) { + return await qsVersionInfoAdapter.getCanaryLonghornReleaseInfo(); + } + return await qsVersionInfoAdapter.getProdLonghornReleaseInfo(); + } + + /** + * Checks if a Longhorn upgrade is currently in progress + * An upgrade is considered in progress if any pod in the longhorn-system namespace + * is not in Running or Succeeded state + */ + async isUpgradeInProgress(): Promise { + try { + const podsResponse = await k3s.core.listNamespacedPod(this.LONGHORN_NAMESPACE); + const pods = podsResponse.body.items; + + if (pods.length === 0) { + // No pods found, consider this as not upgrading + return false; + } + + // Check if all pods are either Running or Succeeded + for (const pod of pods) { + const podPhase = pod.status?.phase; + + // If any pod is not in Running or Succeeded state, upgrade is in progress or there's an issue + if (podPhase !== 'Running' && podPhase !== 'Succeeded') { + console.log(`Pod ${pod.metadata?.name} is in ${podPhase} state - upgrade/issue in progress`); + return true; + } + } + + // All pods are Running or Succeeded + return false; + } catch (error) { + console.error('Error checking Longhorn upgrade status:', error); + return false; + } + } + + /** + * Upgrades Longhorn to the next available version by applying the YAML manifest + */ + async upgrade(): Promise { + if (!await this.isInstalled()) { + throw new ServiceException('Longhorn is not installed. Cannot perform upgrade.'); + } + + if (await this.isUpgradeInProgress()) { + throw new ServiceException('A Longhorn upgrade is already in progress. Please wait for it to complete.'); + } + + const nextVersion = await this.getNextAvailableVersion(); + if (!nextVersion) { + throw new ServiceException('No newer Longhorn version available for upgrade.'); + } + + const yamlUrl = nextVersion.yamlUrl; + + console.log(`Starting Longhorn upgrade to ${nextVersion.version}...`); + console.log(`Fetching manifest from ${yamlUrl}...`); + + await this.applyManifestFromUrl(yamlUrl); + + console.log(`Longhorn upgrade to ${nextVersion.version} initiated successfully.`); + console.log('Note: Volumes will be automatically upgraded based on the "Concurrent Automatic Engine Upgrade Per Node Limit" setting.'); + } + + /** + * Fetches a YAML manifest from a URL and applies it to the cluster + * @param url URL to fetch the manifest from + */ + private async applyManifestFromUrl(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch manifest from ${url}: ${response.statusText}`); + } + const yamlContent = await response.text(); + + const specs = k8s.loadAllYaml(yamlContent); + + // Apply each resource + for (const spec of specs) { + if (spec && spec.kind) { + try { + await k3s.applyResource(spec, spec.metadata?.namespace || this.LONGHORN_NAMESPACE); + } catch (error) { + console.error(`Error applying ${spec.kind}/${spec.metadata?.name}:`, error); + // Continue with other resources + } + } + } + } + + /** + * Gets detailed upgrade status including pod states + */ + async getUpgradeStatus(): Promise<{ + isUpgrading: boolean; + desiredPods: number; + readyPods: number; + updatedPods: number; + currentVersion: string | undefined; + }> { + const [isUpgrading, currentVersion] = await Promise.all([ + this.isUpgradeInProgress(), + this.getCurrentVersion() + ]); + + let desiredPods = 0; + let readyPods = 0; + let updatedPods = 0; + + try { + const daemonSet = await k3s.apps.readNamespacedDaemonSet( + this.LONGHORN_MANAGER_NAME, + this.LONGHORN_NAMESPACE + ); + + const status = daemonSet.body.status; + if (status) { + desiredPods = status.desiredNumberScheduled || 0; + readyPods = status.numberReady || 0; + updatedPods = status.updatedNumberScheduled || 0; + } + } catch (error) { + console.error('Error getting Longhorn upgrade status:', error); + } + + return { + isUpgrading, + desiredPods, + readyPods, + updatedPods, + currentVersion + }; + } +} + +const longhornUpdateService = new LonghornUpdateService(); +export default longhornUpdateService; diff --git a/src/server/utils/crypto.utils.ts b/src/server/utils/crypto.utils.ts new file mode 100644 index 0000000..ad7320c --- /dev/null +++ b/src/server/utils/crypto.utils.ts @@ -0,0 +1,87 @@ +import crypto from "crypto"; + +export class CryptoUtils { + + private static readonly ALGORITHM = 'aes-256-gcm'; + + /** + * Derives a 32-byte key from the NEXTAUTH_SECRET env var using SHA-256. + */ + private static getKey(): Buffer { + const secret = process.env.NEXTAUTH_SECRET; + if (!secret) { + throw new Error('NEXTAUTH_SECRET environment variable is not set.'); + } + return crypto.createHash('sha256').update(secret).digest(); + } + + /** + * Encrypts a plaintext string using AES-256-GCM. + * @returns base64-encoded string in the format: iv:authTag:ciphertext + */ + static encrypt(plaintext: string): string { + const key = this.getKey(); + const iv = crypto.randomBytes(12); // 96-bit IV recommended for GCM + const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + return [ + iv.toString('base64'), + authTag.toString('base64'), + encrypted.toString('base64'), + ].join(':'); + } + + /** + * Decrypts a string produced by {@link encrypt}. + */ + static decrypt(ciphertext: string): string { + const key = this.getKey(); + const [ivB64, authTagB64, encryptedB64] = ciphertext.split(':'); + if (!ivB64 || !authTagB64 || !encryptedB64) { + throw new Error('Invalid ciphertext format.'); + } + const iv = Buffer.from(ivB64, 'base64'); + const authTag = Buffer.from(authTagB64, 'base64'); + const encrypted = Buffer.from(encryptedB64, 'base64'); + const decipher = crypto.createDecipheriv(this.ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + return decipher.update(encrypted) + decipher.final('utf-8'); + } + + + /** + * Generates a strong password that contains at least + * one uppercase letter, one lowercase letter, one number, and one special character. + * Valid length range: 10-72 characters. + */ + static generateStrongPasswort(length = 35): string { + const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + const lowercase = 'abcdefghijklmnopqrstuvwxyz'; + const numbers = '0123456789'; + const special = '!$%*()-_+[]{},.'; + const all = uppercase + lowercase + numbers + special; + + // Guarantee at least one character from each required category + const required = [ + uppercase[crypto.randomInt(uppercase.length)], + lowercase[crypto.randomInt(lowercase.length)], + numbers[crypto.randomInt(numbers.length)], + special[crypto.randomInt(special.length)], + ]; + + const remaining = Array.from({ length: length - required.length }, () => + all[crypto.randomInt(all.length)] + ); + + const combined = [...required, ...remaining]; + + // Fisher-Yates shuffle to avoid predictable positions + for (let i = combined.length - 1; i > 0; i--) { + const j = crypto.randomInt(i + 1); + [combined[i], combined[j]] = [combined[j], combined[i]]; + } + + return combined.join(''); + } +} \ No newline at end of file diff --git a/src/server/utils/k3s-version.utils.ts b/src/server/utils/k3s-version.utils.ts new file mode 100644 index 0000000..b112787 --- /dev/null +++ b/src/server/utils/k3s-version.utils.ts @@ -0,0 +1,46 @@ +/** + * Utility class for parsing and manipulating K3s version strings + */ +export class K3sVersionUtils { + /** + * Extracts the major.minor version from a full K3s version string + * @param fullVersion Full K3s version string (e.g., "v1.31.3+k3s1") + * @returns Minor version string (e.g., "v1.31") + * @throws Error if the version format is invalid + * + * @example + * K3sVersionUtils.getMinorVersion("v1.31.3+k3s1") // Returns "v1.31" + * K3sVersionUtils.getMinorVersion("v1.30.5+k3s2") // Returns "v1.30" + */ + static getMinorVersion(fullVersion: string): string { + if (!fullVersion) { + throw new Error('Version string is required'); + } + + // Remove leading 'v' if present for processing + const versionWithoutV = fullVersion.startsWith('v') + ? fullVersion.substring(1) + : fullVersion; + + // Split by '+' to remove K3s suffix (e.g., "+k3s1") + const versionWithoutSuffix = versionWithoutV.split('+')[0]; + + // Split by '.' to get version parts + const parts = versionWithoutSuffix.split('.'); + + if (parts.length < 2) { + throw new Error(`Invalid version format: ${fullVersion}`); + } + + // Validate that major and minor are numbers + const major = parseInt(parts[0], 10); + const minor = parseInt(parts[1], 10); + + if (isNaN(major) || isNaN(minor)) { + throw new Error(`Invalid version format: ${fullVersion}`); + } + + // Return major.minor with 'v' prefix + return `v${major}.${minor}`; + } +} diff --git a/src/shared/model/generated-zod/k3s-longhorn-release-schemas.ts b/src/shared/model/generated-zod/k3s-longhorn-release-schemas.ts new file mode 100644 index 0000000..d64bd6a --- /dev/null +++ b/src/shared/model/generated-zod/k3s-longhorn-release-schemas.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +export const K3sReleaseInfoSchema = z.object({ + version: z.string(), + channelUrl: z.string().url(), +}); + +export const LonghornReleaseInfoSchema = z.object({ + version: z.string(), + yamlUrl: z.string().url(), +}); + +export const ReleaseResponseSchema = z.object({ + prodInstallVersion: z.string(), + canaryInstallVersion: z.string(), +}); + +export const K3sReleaseResponseSchema = ReleaseResponseSchema.extend({ + prod: K3sReleaseInfoSchema.array(), + canary: K3sReleaseInfoSchema.array(), +}); + +export const LonghornReleaseResponseSchema = ReleaseResponseSchema.extend({ + prod: LonghornReleaseInfoSchema.array(), + canary: LonghornReleaseInfoSchema.array(), +}); diff --git a/src/shared/templates/apps/wordpress.template.ts b/src/shared/templates/apps/wordpress.template.ts index 7907a68..aba1ea7 100644 --- a/src/shared/templates/apps/wordpress.template.ts +++ b/src/shared/templates/apps/wordpress.template.ts @@ -36,8 +36,8 @@ export const wordpressAppTemplate: AppTemplateModel = { sourceType: 'CONTAINER', containerImageSource: "", replicas: 1, - ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, - egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_DATABASES, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_DATABASES, envVars: `MYSQL_DATABASE=wordpress MYSQL_USER=wordpress `, @@ -76,8 +76,8 @@ MYSQL_USER=wordpress sourceType: 'CONTAINER', containerImageSource: "", replicas: 1, - ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_DATABASES, - egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_DATABASES, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, envVars: `WORDPRESS_DB_HOST={hostname}:{port} WORDPRESS_DB_NAME={databaseName} WORDPRESS_DB_USER={username} diff --git a/src/shared/utils/domain-dns-provider.utils.ts b/src/shared/utils/domain-dns-provider.utils.ts index dc500ec..509193f 100644 --- a/src/shared/utils/domain-dns-provider.utils.ts +++ b/src/shared/utils/domain-dns-provider.utils.ts @@ -13,7 +13,7 @@ export class HostnameDnsProviderUtils { return `${traefikFriendlyIpv4}.${this.PROVIDER_HOSTNAME}`; } - static getHexHostanmeForIpAddress(ipv4Address: string): string { + static getHexHostnameForIpAddress(ipv4Address: string): string { const traefikFriendlyIpv4 = this.ipv4ToHex(ipv4Address) return `${traefikFriendlyIpv4}.${this.PROVIDER_HOSTNAME}`; }