From 4aad7a7ec78e37a2d6a906b34cf402e029bddef1 Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Tue, 30 Dec 2025 16:29:07 +0000 Subject: [PATCH 1/7] feat: add K3s upgrade controller management with installation and status check --- src/app/settings/server/actions.ts | 15 ++ src/app/settings/server/k3s-update-info.tsx | 150 ++++++++++++++++++++ src/app/settings/server/page.tsx | 9 +- src/server/services/k3s-update.service.ts | 114 +++++++++++++++ 4 files changed, 286 insertions(+), 2 deletions(-) create mode 100644 src/app/settings/server/k3s-update-info.tsx create mode 100644 src/server/services/k3s-update.service.ts diff --git a/src/app/settings/server/actions.ts b/src/app/settings/server/actions.ts index e976f2b..5beac41 100644 --- a/src/app/settings/server/actions.ts +++ b/src/app/settings/server/actions.ts @@ -292,3 +292,18 @@ export const setTraefikIpPropagation = async (prevState: any, inputData: { enabl return new SuccessActionResult(undefined, `Traefik externalTrafficPolicy set to ${validatedData.enableIpPreservation ? 'Local' : 'Cluster'}.`); }); + +export const checkK3sUpgradeControllerStatus = async () => + simpleAction(async () => { + await getAdminUserSession(); + const k3sUpdateService = (await import('@/server/services/k3s-update.service')).default; + return await k3sUpdateService.isSystemUpgradeControllerPresent(); + }); + +export const installK3sUpgradeController = async () => + simpleAction(async () => { + await getAdminUserSession(); + const k3sUpdateService = (await import('@/server/services/k3s-update.service')).default; + await k3sUpdateService.installSystemUpgradeController(); + return new SuccessActionResult(undefined, 'K3s System Upgrade Controller has been installed successfully.'); + }); 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..cd7427f --- /dev/null +++ b/src/app/settings/server/k3s-update-info.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { checkK3sUpgradeControllerStatus, installK3sUpgradeController } 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"; + +export default function K3sUpdateInfo({ + initialControllerStatus +}: { + initialControllerStatus: boolean; +}) { + + const useConfirm = useConfirmDialog(); + const [loading, setLoading] = React.useState(false); + const [controllerInstalled, setControllerInstalled] = React.useState(initialControllerStatus); + + 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); + } + }; + + return ( + + + + K3s Cluster Upgrades + + + QuickStack uses k3s (Kubernetes distirbution) 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 && } +
+
+
+
+ ); +} diff --git a/src/app/settings/server/page.tsx b/src/app/settings/server/page.tsx index 7f595c3..5b1f506 100644 --- a/src/app/settings/server/page.tsx +++ b/src/app/settings/server/page.tsx @@ -22,9 +22,11 @@ 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 k3sUpdateService from "@/server/services/k3s-update.service"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import clusterService from "@/server/services/node.service"; import NodeInfo from "./nodeInfo"; +import K3sUpdateInfo from "./k3s-update-info"; export default async function ProjectPage({ searchParams @@ -60,14 +62,16 @@ export default async function ProjectPage({ qsPodInfos, currentVersion, newVersionInfo, - nodeInfo + nodeInfo, + k3sControllerStatus ] = await Promise.all([ s3TargetService.getAll(), traefikService.getStatus(), podService.getPodsForApp(Constants.QS_NAMESPACE, Constants.QS_APP_NAME), quickStackService.getVersionOfCurrentQuickstackInstance(), quickStackUpdateService.getNewVersionInfo(), - clusterService.getNodeInfo() + clusterService.getNodeInfo(), + k3sUpdateService.isSystemUpgradeControllerPresent() ]); const qsPodInfo = qsPodInfos.find(p => !!p); @@ -127,6 +131,7 @@ export default async function ProjectPage({
+
diff --git a/src/server/services/k3s-update.service.ts b/src/server/services/k3s-update.service.ts new file mode 100644 index 0000000..d5ade62 --- /dev/null +++ b/src/server/services/k3s-update.service.ts @@ -0,0 +1,114 @@ +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"; + +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.'); + } + + // Create the system-upgrade namespace if it doesn't exist + await namespaceService.createNamespaceIfNotExists(this.SYSTEM_UPGRADE_NAMESPACE); + + // Fetch and apply the CRD manifest + console.log('Fetching and applying CRD manifest...'); + await this.applyManifestFromUrl(this.SYSTEM_UPGRADE_CRD_URL); + + // Fetch and apply the system-upgrade-controller manifest + 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 kc = k3s.getKubeConfig(); + const specs = k8s.loadAllYaml(yamlContent); + + // Apply each resource + for (const spec of specs) { + await this.applyResource(kc, spec); + } + } + + /** + * Applies a single Kubernetes resource to the cluster + * @param kc KubeConfig instance + * @param spec Resource specification + */ + private async applyResource(kc: k8s.KubeConfig, spec: any): Promise { + if (!spec || !spec.kind) { + console.error('Invalid resource specification:', spec); + throw new Error('Invalid resource specification'); + } + + const namespace = spec.metadata?.namespace || this.SYSTEM_UPGRADE_NAMESPACE; + const name = spec.metadata?.name; + + console.log(`Applying ${spec.kind}/${name} to namespace ${namespace}`); + + try { + const client = k8s.KubernetesObjectApi.makeApiClient(kc); + + 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 k3sUpdateService = new K3sUpdateService(); +export default k3sUpdateService; \ No newline at end of file From 1c3973a36fb36d7ddbb83fd3be24e8a9df7005b6 Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Wed, 31 Dec 2025 10:26:12 +0000 Subject: [PATCH 2/7] feat: Implement K3s upgrade functionality and version management - Added K3s upgrade handling in K3sUpdateInfo component, including user confirmation and upgrade process initiation. - Integrated K3s version information retrieval and display, including current and next available versions. - Created a new UpdateInfoPage component to encapsulate QuickStack and K3s version information. - Refactored cluster service to replace node service with a more comprehensive cluster service. - Introduced K3sVersionUtils for version parsing and manipulation. - Added K3s version information fetching from a new JSON source. - Implemented upgrade plans creation and deletion logic in K3sUpdateService. - Enhanced error handling and user feedback during upgrade processes. - Removed deprecated node service and updated related imports across the application. --- setup/k3s-versions.json | 2 - .../server/k3s-version.utils.test.ts | 37 +++ src/app/backups/actions.ts | 8 +- src/app/monitoring/actions.ts | 2 +- src/app/monitoring/page.tsx | 2 +- src/app/project/app/[appId]/page.tsx | 2 +- src/app/settings/server/actions.ts | 15 +- src/app/settings/server/k3s-update-info.tsx | 196 ++++++++++--- src/app/settings/server/page.tsx | 18 +- src/app/settings/server/update-info.tsx | 57 ++++ src/server/adapter/kubernetes-api.adapter.ts | 40 +++ src/server/adapter/qs-versioninfo.adapter.ts | 65 ++++ .../{node.service.ts => cluster.service.ts} | 0 src/server/services/k3s-update.service.ts | 277 ++++++++++++++++-- src/server/services/monitoring.service.ts | 2 +- src/server/services/registry.service.ts | 2 +- src/server/utils/k3s-version.utils.ts | 46 +++ 17 files changed, 678 insertions(+), 93 deletions(-) create mode 100644 src/__tests__/server/k3s-version.utils.test.ts create mode 100644 src/app/settings/server/update-info.tsx create mode 100644 src/server/adapter/qs-versioninfo.adapter.ts rename src/server/services/{node.service.ts => cluster.service.ts} (100%) create mode 100644 src/server/utils/k3s-version.utils.ts diff --git a/setup/k3s-versions.json b/setup/k3s-versions.json index 7a6ad3a..7fcbe9f 100644 --- a/setup/k3s-versions.json +++ b/setup/k3s-versions.json @@ -1,6 +1,4 @@ { - "prodInstallVersion": "v1.31.3+k3s1", - "canaryInstallVersion": "v1.33.4+k3s1", "prod": [ { "version": "v1.31", 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/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]/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 5beac41..a2a22db 100644 --- a/src/app/settings/server/actions.ts +++ b/src/app/settings/server/actions.ts @@ -27,8 +27,9 @@ 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/k3s-update.service"; export const setNodeStatus = async (nodeName: string, schedulable: boolean) => simpleAction(async () => { @@ -287,23 +288,27 @@ 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(); - const k3sUpdateService = (await import('@/server/services/k3s-update.service')).default; return await k3sUpdateService.isSystemUpgradeControllerPresent(); }); export const installK3sUpgradeController = async () => simpleAction(async () => { await getAdminUserSession(); - const k3sUpdateService = (await import('@/server/services/k3s-update.service')).default; + 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.'); + }); diff --git a/src/app/settings/server/k3s-update-info.tsx b/src/app/settings/server/k3s-update-info.tsx index cd7427f..1109771 100644 --- a/src/app/settings/server/k3s-update-info.tsx +++ b/src/app/settings/server/k3s-update-info.tsx @@ -1,7 +1,7 @@ 'use client'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; -import { checkK3sUpgradeControllerStatus, installK3sUpgradeController } from "./actions"; +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"; @@ -12,16 +12,24 @@ 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 + 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({ @@ -52,6 +60,49 @@ export default function K3sUpdateInfo({ } }; + 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 ( @@ -59,7 +110,7 @@ export default function K3sUpdateInfo({ K3s Cluster Upgrades - QuickStack uses k3s (Kubernetes distirbution) under the hood for managing your cluster. + 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. @@ -96,54 +147,121 @@ export default function K3sUpdateInfo({ - {!controllerInstalled && ( + {!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 - + +
+
+
+

System Upgrade Controller

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

Current K3s Version

+
+ {k3sCurrentVersionInfo && ( +
+

{k3sCurrentVersionInfo.version}

+

+ Channel: {k3sCurrentVersionInfo.channelUrl} +

+
)}
- - {!controllerInstalled && } + {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/page.tsx b/src/app/settings/server/page.tsx index 5b1f506..3ac5b0c 100644 --- a/src/app/settings/server/page.tsx +++ b/src/app/settings/server/page.tsx @@ -24,9 +24,10 @@ import { Settings, Network, HardDrive, Rocket, Wrench } from "lucide-react"; import quickStackUpdateService from "@/server/services/qs-update.service"; import k3sUpdateService from "@/server/services/k3s-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 K3sUpdateInfo from "./k3s-update-info"; +import UpdateInfoPage from "./update-info"; export default async function ProjectPage({ searchParams @@ -43,7 +44,6 @@ export default async function ProjectPage({ regitryStorageLocation, ipv4Address, systemBackupLocation, - useCanaryChannel, clusterJoinToken ] = await Promise.all([ paramService.getString(ParamService.QS_SERVER_HOSTNAME, ''), @@ -52,7 +52,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) ]); @@ -60,18 +59,14 @@ export default async function ProjectPage({ s3Targets, traefikStatus, qsPodInfos, - currentVersion, newVersionInfo, - nodeInfo, - k3sControllerStatus + nodeInfo ] = await Promise.all([ s3TargetService.getAll(), traefikService.getStatus(), podService.getPodsForApp(Constants.QS_NAMESPACE, Constants.QS_APP_NAME), - quickStackService.getVersionOfCurrentQuickstackInstance(), quickStackUpdateService.getNewVersionInfo(), - clusterService.getNodeInfo(), - k3sUpdateService.isSystemUpgradeControllerPresent() + clusterService.getNodeInfo() ]); const qsPodInfo = qsPodInfos.find(p => !!p); @@ -129,10 +124,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..9c7b045 --- /dev/null +++ b/src/app/settings/server/update-info.tsx @@ -0,0 +1,57 @@ +'use server' + +import k3sUpdateService from "@/server/services/k3s-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 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, + ] = await Promise.all([ + paramService.getBoolean(ParamService.USE_CANARY_CHANNEL, false), + quickStackService.getVersionOfCurrentQuickstackInstance(), + quickStackUpdateService.getNewVersionInfo(), + k3sUpdateService.isSystemUpgradeControllerPresent() + ]); + + // Loading 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); + } + + + 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..3bf8a59 --- /dev/null +++ b/src/server/adapter/qs-versioninfo.adapter.ts @@ -0,0 +1,65 @@ + +export interface K3sReleaseInfo { + version: string; + channelUrl: string; +} + +interface K3sReleaseResponse { + prod: K3sReleaseInfo[]; + canary: K3sReleaseInfo[]; +} + +class QsVersionInfoAdapter { + + private readonly API_BASE_URL = 'https://get.quickstack.dev'; + + private async getK3sVersioninfo(): Promise { + + return JSON.parse(`{ + "prod": [ + { + "version": "v1.31", + "channelUrl": "https://update.k3s.io/v1-release/channels/v1.31" + } + ], + "canary": [ + { + "version": "v1.31", + "channelUrl": "https://update.k3s.io/v1-release/channels/v1.31" + }, + { + "version": "v1.32", + "channelUrl": "https://update.k3s.io/v1-release/channels/v1.32" + }, + { + "version": "v1.33", + "channelUrl": "https://update.k3s.io/v1-release/channels/v1.33" + } + ] +}`); + 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}`); + } + return await response.json() as K3sReleaseResponse; + } + + public async getProdK3sReleaseInfo(): Promise { + const releaseInfo = await this.getK3sVersioninfo(); + return releaseInfo.prod; + } + + public async getCanaryK3sReleaseInfo(): Promise { + const releaseInfo = await this.getK3sVersioninfo(); + 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/k3s-update.service.ts b/src/server/services/k3s-update.service.ts index d5ade62..17913a2 100644 --- a/src/server/services/k3s-update.service.ts +++ b/src/server/services/k3s-update.service.ts @@ -6,6 +6,10 @@ 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 { @@ -66,46 +70,275 @@ class K3sUpdateService { } const yamlContent = await response.text(); - const kc = k3s.getKubeConfig(); const specs = k8s.loadAllYaml(yamlContent); // Apply each resource for (const spec of specs) { - await this.applyResource(kc, spec); + await k3s.applyResource(spec, this.SYSTEM_UPGRADE_NAMESPACE); } } - /** - * Applies a single Kubernetes resource to the cluster - * @param kc KubeConfig instance - * @param spec Resource specification - */ - private async applyResource(kc: k8s.KubeConfig, spec: any): Promise { - if (!spec || !spec.kind) { - console.error('Invalid resource specification:', spec); - throw new Error('Invalid resource specification'); + async getCurrentK3sMinorVersion() { + const nodes = await clusterService.getNodeInfo(); + // check if all k3s versions are the same + 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); } - const namespace = spec.metadata?.namespace || this.SYSTEM_UPGRADE_NAMESPACE; - const name = spec.metadata?.name; + // 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]; + } - console.log(`Applying ${spec.kind}/${name} to namespace ${namespace}`); + 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 { - await client.read(spec); - // If it exists, patch it - await client.patch(spec); - console.log(`Updated ${spec.kind}/${name}`); + const [serverPlan, agentPlan] = await Promise.all([ + client.read(serverPlanSpec), + client.read(agentPlanSpec) + ]); + + return { + serverPlan: serverPlan.body, + agentPlan: agentPlan.body + }; } catch (error) { - await client.create(spec); - console.log(`Created ${spec.kind}/${name}`); + // Plans don't exist + return undefined; } } catch (error) { - console.error(`Failed to apply ${spec.kind}/${name}:`, error); - throw 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'); } } } 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/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}`; + } +} From 2179ee2a4f8abeb618029c878ff4f2c14914d831 Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Sat, 3 Jan 2026 15:29:26 +0000 Subject: [PATCH 3/7] feat: Update ingress and egress network policies for WordPress template --- src/shared/templates/apps/wordpress.template.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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} From b0fcce26e991e4a36db438274c3b244b668fde03 Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Sat, 3 Jan 2026 16:01:00 +0000 Subject: [PATCH 4/7] feat: Added longhorn upgrade service --- setup/k3s-versions.json | 1 + setup/longhorn-versions.json | 3 +- src/app/settings/server/actions.ts | 10 +- src/app/settings/server/k3s-update-info.tsx | 1 + .../settings/server/longhorn-update-info.tsx | 208 +++++++++++++++ src/app/settings/server/page.tsx | 1 - src/app/settings/server/update-info.tsx | 38 ++- src/server/adapter/qs-versioninfo.adapter.ts | 64 +++++ .../k3s-update.service.ts | 21 +- .../longhorn-update.service.ts | 238 ++++++++++++++++++ 10 files changed, 566 insertions(+), 19 deletions(-) create mode 100644 src/app/settings/server/longhorn-update-info.tsx rename src/server/services/{ => upgrade-services}/k3s-update.service.ts (94%) create mode 100644 src/server/services/upgrade-services/longhorn-update.service.ts diff --git a/setup/k3s-versions.json b/setup/k3s-versions.json index 7fcbe9f..058b96f 100644 --- a/setup/k3s-versions.json +++ b/setup/k3s-versions.json @@ -1,4 +1,5 @@ { + "latestStableVersion": "v1.31", "prod": [ { "version": "v1.31", diff --git a/setup/longhorn-versions.json b/setup/longhorn-versions.json index 8385ce4..7aa22f2 100644 --- a/setup/longhorn-versions.json +++ b/setup/longhorn-versions.json @@ -1,6 +1,5 @@ { - "prodInstallVersion": "v1.7.2", - "canaryInstallVersion": "v1.10.1", + "latestStableVersion": "v1.7.2", "prod": [ { "version": "v1.7.2", diff --git a/src/app/settings/server/actions.ts b/src/app/settings/server/actions.ts index a2a22db..81f3ccd 100644 --- a/src/app/settings/server/actions.ts +++ b/src/app/settings/server/actions.ts @@ -29,7 +29,8 @@ import { revalidateTag } from "next/cache"; import { Tags } from "@/server/utils/cache-tag-generator.utils"; import clusterService from "@/server/services/cluster.service"; import { TraefikIpPropagationStatus } from "@/shared/model/traefik-ip-propagation.model"; -import k3sUpdateService from "@/server/services/k3s-update.service"; +import k3sUpdateService from "@/server/services/upgrade-services/k3s-update.service"; +import longhornUpdateService from "@/server/services/upgrade-services/longhorn-update.service"; export const setNodeStatus = async (nodeName: string, schedulable: boolean) => simpleAction(async () => { @@ -312,3 +313,10 @@ export const startK3sUpgrade = async () => 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.'); + }); diff --git a/src/app/settings/server/k3s-update-info.tsx b/src/app/settings/server/k3s-update-info.tsx index 1109771..1f46272 100644 --- a/src/app/settings/server/k3s-update-info.tsx +++ b/src/app/settings/server/k3s-update-info.tsx @@ -217,6 +217,7 @@ export default function K3sUpdateInfo({ {upgradeInProgress ? <> + An upgrade is currently in progress. You can monitor the progress in the "Cluster" settings tab. 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 3ac5b0c..54a43ac 100644 --- a/src/app/settings/server/page.tsx +++ b/src/app/settings/server/page.tsx @@ -22,7 +22,6 @@ 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 k3sUpdateService from "@/server/services/k3s-update.service"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import clusterService from "@/server/services/cluster.service"; import NodeInfo from "./nodeInfo"; diff --git a/src/app/settings/server/update-info.tsx b/src/app/settings/server/update-info.tsx index 9c7b045..28c2af8 100644 --- a/src/app/settings/server/update-info.tsx +++ b/src/app/settings/server/update-info.tsx @@ -1,10 +1,12 @@ 'use server' -import k3sUpdateService from "@/server/services/k3s-update.service"; +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"; @@ -17,14 +19,16 @@ export default async function UpdateInfoPage() { currentVersion, newVersionInfo, k3sControllerStatus, + longhornInstalled, ] = await Promise.all([ paramService.getBoolean(ParamService.USE_CANARY_CHANNEL, false), quickStackService.getVersionOfCurrentQuickstackInstance(), quickStackUpdateService.getNewVersionInfo(), - k3sUpdateService.isSystemUpgradeControllerPresent() + k3sUpdateService.isSystemUpgradeControllerPresent(), + longhornUpdateService.isInstalled() ]); - // Loading data with sideeffects + // Loading K3s data with sideeffects let k3sCurrentVersionInfo; let k3sNextVersionInfo; let k3sUpgradeIsInProgress = false; @@ -45,6 +49,29 @@ export default async function UpdateInfoPage() { 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
@@ -52,6 +79,11 @@ export default async function UpdateInfoPage() { k3sNextVersionInfo={k3sNextVersionInfo} k3sUpgradeIsInProgress={k3sUpgradeIsInProgress} initialControllerStatus={k3sControllerStatus} /> +
; } \ No newline at end of file diff --git a/src/server/adapter/qs-versioninfo.adapter.ts b/src/server/adapter/qs-versioninfo.adapter.ts index 3bf8a59..c6f0a96 100644 --- a/src/server/adapter/qs-versioninfo.adapter.ts +++ b/src/server/adapter/qs-versioninfo.adapter.ts @@ -4,11 +4,23 @@ export interface K3sReleaseInfo { channelUrl: string; } +export interface LonghornReleaseInfo { + version: string; + yamlUrl: string; +} + interface K3sReleaseResponse { + latestStableVersion: string; prod: K3sReleaseInfo[]; canary: K3sReleaseInfo[]; } +interface LonghornReleaseResponse { + latestStableVersion: string; + prod: LonghornReleaseInfo[]; + canary: LonghornReleaseInfo[]; +} + class QsVersionInfoAdapter { private readonly API_BASE_URL = 'https://get.quickstack.dev'; @@ -51,6 +63,48 @@ class QsVersionInfoAdapter { return await response.json() as K3sReleaseResponse; } + private async getLonghornVersioninfo(): Promise { + // TODO: Replace with actual API call when deployed + return JSON.parse(`{ + "prod": [ + { + "version": "v1.7.2", + "yamlUrl": "https://raw.githubusercontent.com/longhorn/longhorn/v1.7.2/deploy/longhorn.yaml" + } + ], + "canary": [ + { + "version": "v1.7.2", + "yamlUrl": "https://raw.githubusercontent.com/longhorn/longhorn/v1.7.2/deploy/longhorn.yaml" + }, + { + "version": "v1.8.2", + "yamlUrl": "https://raw.githubusercontent.com/longhorn/longhorn/v1.8.2/deploy/longhorn.yaml" + }, + { + "version": "v1.9.2", + "yamlUrl": "https://raw.githubusercontent.com/longhorn/longhorn/v1.9.2/deploy/longhorn.yaml" + }, + { + "version": "v1.10.1", + "yamlUrl": "https://raw.githubusercontent.com/longhorn/longhorn/v1.10.1/deploy/longhorn.yaml" + } + ] +}`); + 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}`); + } + return await response.json() as LonghornReleaseResponse; + } + public async getProdK3sReleaseInfo(): Promise { const releaseInfo = await this.getK3sVersioninfo(); return releaseInfo.prod; @@ -60,6 +114,16 @@ class QsVersionInfoAdapter { 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/k3s-update.service.ts b/src/server/services/upgrade-services/k3s-update.service.ts similarity index 94% rename from src/server/services/k3s-update.service.ts rename to src/server/services/upgrade-services/k3s-update.service.ts index 17913a2..44692fa 100644 --- a/src/server/services/k3s-update.service.ts +++ b/src/server/services/upgrade-services/k3s-update.service.ts @@ -1,15 +1,15 @@ 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 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 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"; +import { qsVersionInfoAdapter } from "../../adapter/qs-versioninfo.adapter"; +import paramService, { ParamService } from "../param.service"; class K3sUpdateService { @@ -47,14 +47,11 @@ class K3sUpdateService { throw new ServiceException('System Upgrade Controller is already installed.'); } - // Create the system-upgrade namespace if it doesn't exist await namespaceService.createNamespaceIfNotExists(this.SYSTEM_UPGRADE_NAMESPACE); - // Fetch and apply the CRD manifest console.log('Fetching and applying CRD manifest...'); await this.applyManifestFromUrl(this.SYSTEM_UPGRADE_CRD_URL); - // Fetch and apply the system-upgrade-controller manifest console.log('Fetching and applying system-upgrade-controller manifest...'); await this.applyManifestFromUrl(this.SYSTEM_UPGRADE_CONTROLLER_URL); } @@ -80,7 +77,7 @@ class K3sUpdateService { async getCurrentK3sMinorVersion() { const nodes = await clusterService.getNodeInfo(); - // check if all k3s versions are the same + // 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.'); 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; From ec7b2cf278e6c2d7635755ed8cd35e38c7b68a2b Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Sat, 3 Jan 2026 16:21:58 +0000 Subject: [PATCH 5/7] feat: Update K3s and Longhorn versions and setup script --- setup/k3s-versions.json | 3 ++- setup/longhorn-versions.json | 3 ++- setup/setup-canary.sh | 17 +++++++----- setup/setup-worker.sh | 10 ++++--- setup/setup.sh | 16 +++++++----- src/server/adapter/qs-versioninfo.adapter.ts | 18 ++++++++----- .../k3s-longhorn-release-schemas.ts | 26 +++++++++++++++++++ 7 files changed, 70 insertions(+), 23 deletions(-) create mode 100644 src/shared/model/generated-zod/k3s-longhorn-release-schemas.ts diff --git a/setup/k3s-versions.json b/setup/k3s-versions.json index 058b96f..7a6ad3a 100644 --- a/setup/k3s-versions.json +++ b/setup/k3s-versions.json @@ -1,5 +1,6 @@ { - "latestStableVersion": "v1.31", + "prodInstallVersion": "v1.31.3+k3s1", + "canaryInstallVersion": "v1.33.4+k3s1", "prod": [ { "version": "v1.31", diff --git a/setup/longhorn-versions.json b/setup/longhorn-versions.json index 7aa22f2..8385ce4 100644 --- a/setup/longhorn-versions.json +++ b/setup/longhorn-versions.json @@ -1,5 +1,6 @@ { - "latestStableVersion": "v1.7.2", + "prodInstallVersion": "v1.7.2", + "canaryInstallVersion": "v1.10.1", "prod": [ { "version": "v1.7.2", 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/server/adapter/qs-versioninfo.adapter.ts b/src/server/adapter/qs-versioninfo.adapter.ts index c6f0a96..21be05b 100644 --- a/src/server/adapter/qs-versioninfo.adapter.ts +++ b/src/server/adapter/qs-versioninfo.adapter.ts @@ -1,3 +1,4 @@ +import { K3sReleaseResponseSchema, LonghornReleaseResponseSchema } from "@/shared/model/generated-zod/k3s-longhorn-release-schemas"; export interface K3sReleaseInfo { version: string; @@ -9,14 +10,17 @@ export interface LonghornReleaseInfo { yamlUrl: string; } -interface K3sReleaseResponse { - latestStableVersion: string; +interface ReleaseResponse { + prodInstallVersion: string; + canaryInstallVersion: string; +} + +interface K3sReleaseResponse extends ReleaseResponse { prod: K3sReleaseInfo[]; canary: K3sReleaseInfo[]; } -interface LonghornReleaseResponse { - latestStableVersion: string; +interface LonghornReleaseResponse extends ReleaseResponse { prod: LonghornReleaseInfo[]; canary: LonghornReleaseInfo[]; } @@ -60,7 +64,8 @@ class QsVersionInfoAdapter { if (!response.ok) { throw new Error(`Failed to fetch latest QuickStack K3s Prod version from API: HTTP ${response.status} ${response.statusText}`); } - return await response.json() as K3sReleaseResponse; + const reponseJson = await response.json(); + return K3sReleaseResponseSchema.parse(reponseJson); } private async getLonghornVersioninfo(): Promise { @@ -102,7 +107,8 @@ class QsVersionInfoAdapter { if (!response.ok) { throw new Error(`Failed to fetch Longhorn version info from API: HTTP ${response.status} ${response.statusText}`); } - return await response.json() as LonghornReleaseResponse; + const responseJson = await response.json(); + return LonghornReleaseResponseSchema.parse(responseJson); } public async getProdK3sReleaseInfo(): Promise { 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(), +}); From b413465a181397b335ae2e9a34fc1a3b0ec5fcf3 Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Sat, 31 Jan 2026 16:43:15 +0000 Subject: [PATCH 6/7] feat: Remove hardcoded K3s and Longhorn version data, implement API calls for version info --- src/server/adapter/qs-versioninfo.adapter.ts | 50 -------------------- 1 file changed, 50 deletions(-) diff --git a/src/server/adapter/qs-versioninfo.adapter.ts b/src/server/adapter/qs-versioninfo.adapter.ts index 21be05b..11d617d 100644 --- a/src/server/adapter/qs-versioninfo.adapter.ts +++ b/src/server/adapter/qs-versioninfo.adapter.ts @@ -30,29 +30,6 @@ class QsVersionInfoAdapter { private readonly API_BASE_URL = 'https://get.quickstack.dev'; private async getK3sVersioninfo(): Promise { - - return JSON.parse(`{ - "prod": [ - { - "version": "v1.31", - "channelUrl": "https://update.k3s.io/v1-release/channels/v1.31" - } - ], - "canary": [ - { - "version": "v1.31", - "channelUrl": "https://update.k3s.io/v1-release/channels/v1.31" - }, - { - "version": "v1.32", - "channelUrl": "https://update.k3s.io/v1-release/channels/v1.32" - }, - { - "version": "v1.33", - "channelUrl": "https://update.k3s.io/v1-release/channels/v1.33" - } - ] -}`); const response = await fetch(`${this.API_BASE_URL}/k3s-versions.json`, { cache: 'no-cache', method: 'GET', @@ -69,33 +46,6 @@ class QsVersionInfoAdapter { } private async getLonghornVersioninfo(): Promise { - // TODO: Replace with actual API call when deployed - return JSON.parse(`{ - "prod": [ - { - "version": "v1.7.2", - "yamlUrl": "https://raw.githubusercontent.com/longhorn/longhorn/v1.7.2/deploy/longhorn.yaml" - } - ], - "canary": [ - { - "version": "v1.7.2", - "yamlUrl": "https://raw.githubusercontent.com/longhorn/longhorn/v1.7.2/deploy/longhorn.yaml" - }, - { - "version": "v1.8.2", - "yamlUrl": "https://raw.githubusercontent.com/longhorn/longhorn/v1.8.2/deploy/longhorn.yaml" - }, - { - "version": "v1.9.2", - "yamlUrl": "https://raw.githubusercontent.com/longhorn/longhorn/v1.9.2/deploy/longhorn.yaml" - }, - { - "version": "v1.10.1", - "yamlUrl": "https://raw.githubusercontent.com/longhorn/longhorn/v1.10.1/deploy/longhorn.yaml" - } - ] -}`); const response = await fetch(`${this.API_BASE_URL}/longhorn-versions.json`, { cache: 'no-cache', method: 'GET', From 9a027f95a92568d2a89eea1af071e152ddf3f4fe Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Tue, 24 Feb 2026 08:08:07 +0000 Subject: [PATCH 7/7] feat: Implement Longhorn UI access management with enable/disable functionality --- .../utils/domain-dns-provider.utils.test.ts | 6 +- .../project/app/[appId]/domains/actions.ts | 2 +- src/app/settings/server/actions.ts | 29 ++++ .../settings/server/longhorn-ui-toggle.tsx | 136 ++++++++++++++++++ src/app/settings/server/page.tsx | 7 +- .../services/hostname-dns-provider.service.ts | 14 +- src/server/services/ingress.service.ts | 56 +++++++- src/server/services/longhorn-ui.service.ts | 106 ++++++++++++++ src/server/utils/crypto.utils.ts | 87 +++++++++++ src/shared/utils/domain-dns-provider.utils.ts | 2 +- 10 files changed, 432 insertions(+), 13 deletions(-) create mode 100644 src/app/settings/server/longhorn-ui-toggle.tsx create mode 100644 src/server/services/longhorn-ui.service.ts create mode 100644 src/server/utils/crypto.utils.ts 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/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/settings/server/actions.ts b/src/app/settings/server/actions.ts index 81f3ccd..82bc9dc 100644 --- a/src/app/settings/server/actions.ts +++ b/src/app/settings/server/actions.ts @@ -31,6 +31,7 @@ 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 () => { @@ -320,3 +321,31 @@ export const startLonghornUpgrade = async () => 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/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/page.tsx b/src/app/settings/server/page.tsx index 54a43ac..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,18 +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/cluster.service"; import NodeInfo from "./nodeInfo"; -import K3sUpdateInfo from "./k3s-update-info"; import UpdateInfoPage from "./update-info"; +import LonghornUiToggle from "./longhorn-ui-toggle"; export default async function ProjectPage({ searchParams @@ -116,6 +114,7 @@ export default async function ProjectPage({
+
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/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/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}`; }