From dfd1060ab486eca0b34daca107ac1c6c024bb1e7 Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Fri, 25 Sep 2026 13:01:52 +0000 Subject: [PATCH 1/7] feat: add gVisor as a non-removable runtime add-on with installation and update capabilities --- CONTEXT.md | 28 ++ .../0009-gvisor-runtime-addon-lifecycle.md | 3 + .../updates/cluster-addon-update-info.tsx | 8 +- .../addons/base-cluster-addon.service.ts | 7 +- .../addons/cluster-addon-registry.service.ts | 4 +- ...luster-addon-registry.service.unit.spec.ts | 8 + .../services/addons/gvisor-addon.service.ts | 328 ++++++++++++++++++ 7 files changed, 381 insertions(+), 5 deletions(-) create mode 100644 docs/adr/0009-gvisor-runtime-addon-lifecycle.md create mode 100644 src/server/services/addons/gvisor-addon.service.ts diff --git a/CONTEXT.md b/CONTEXT.md index 5b1cac76..74da9303 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -35,6 +35,34 @@ _Avoid_: runtime support The shared Add-on state reported as `notInstalled`, `installing`, `ready`, `updating`, `uninstalling`, or `failed`, with an optional detail message and installed version. _Avoid_: Add-on-specific status enum +**Non-removable Cluster Add-on**: +A Cluster Add-on that QuickStack can install, inspect, and update but deliberately cannot remove because removal would leave host-level runtime changes behind. Its lifecycle never enters `uninstalling`. +_Avoid_: broken Add-on, partially installed Add-on + +**gVisor Runtime Add-on**: +A Trusted, Non-removable Cluster Add-on that makes the gVisor runtime available on supported cluster nodes. It does not select a runtime for Agent Sandboxes. +_Avoid_: gVisor plugin, Agent runtime selector + +**gVisor Release Version**: +The explicit, date-based gVisor release selected by QuickStack for installation and display, such as `20260921`. It is the version of the gVisor Runtime Add-on. +_Avoid_: latest, unpinned runtime version, Add-on SemVer + +**gVisor RuntimeClass**: +The optional Kubernetes RuntimeClass named `gvisor`, backed by `io.containerd.runsc.v1`. It is available alongside, and never replaces, the cluster's default runtime class. +_Avoid_: default runtime, runsc RuntimeClass + +**gVisor-ready Node**: +A schedulable node on which QuickStack has statically verified the pinned gVisor installation, K3s configuration, active K3s service, and node readiness. QuickStack marks it with `quickstack.dev/gvisor=true` and `quickstack.dev/gvisor-version` after success. +_Avoid_: gVisor-tested node, RuntimeClass-proven node + +**gVisor Node Version**: +The gVisor Release Version actually installed and statically verified on a gVisor-ready Node. It is represented by `quickstack.dev/gvisor-version` and does not change until that node's update succeeds. +_Avoid_: desired gVisor version, planned node version + +**gVisor Add-on Configuration**: +The Add-on-owned Kubernetes ConfigMap that persistently declares the desired gVisor Release Version for the gVisor Runtime Add-on. It is not part of a generic Add-on configuration store. +_Avoid_: global Add-on configuration, runtime version cache + **Agent Sandbox Add-on**: The Cluster Add-on that installs the Agent Sandbox controller and its extension CRDs, including SandboxTemplate and SandboxWarmPool. It does not create user workload resources. _Avoid_: Agent, Agent runtime diff --git a/docs/adr/0009-gvisor-runtime-addon-lifecycle.md b/docs/adr/0009-gvisor-runtime-addon-lifecycle.md new file mode 100644 index 00000000..e807dc09 --- /dev/null +++ b/docs/adr/0009-gvisor-runtime-addon-lifecycle.md @@ -0,0 +1,3 @@ +# Keep gVisor as a non-removable runtime Add-on + +QuickStack provides gVisor as a trusted Cluster Add-on that can be installed, inspected, and explicitly updated but never removed, because its installation changes node hosts and K3s runtime configuration. Its visible and installed version is the explicit, code-defined gVisor date-based release rather than a wrapper SemVer; its Add-on-owned ConfigMap persists that desired version. Each installation or update uses the official, pinned gVisor Debian repository and completes only when every schedulable node is ready. It provides the optional `gvisor` RuntimeClass beside, rather than in place of, the default runtime class. A privileged DaemonSet coordinates host changes through a cluster-wide Lease, preserving existing K3s templates by managing only a marked gVisor block. A new compatible `amd64` or `arm64` Ubuntu/Debian node changes the Add-on to `updating` while it is brought to the pinned version automatically and serially; an incompatible node or failed rollout changes it to `failed`. An explicit retry only processes unverified or divergent nodes, leaving a failed node cordoned without automatic rollback. Cordon never drains workloads. Readiness is based on static host and Kubernetes checks rather than a test workload, and successful nodes receive QuickStack gVisor labels whose version is the node's actual installed version; later static checks report manual drift without repairing it. The UI requires confirmation before an install or update because host mutations are irreversible and can briefly interrupt a single-server control plane. diff --git a/src/app/settings/platform/updates/cluster-addon-update-info.tsx b/src/app/settings/platform/updates/cluster-addon-update-info.tsx index 6d6a217e..d981763f 100644 --- a/src/app/settings/platform/updates/cluster-addon-update-info.tsx +++ b/src/app/settings/platform/updates/cluster-addon-update-info.tsx @@ -50,9 +50,9 @@ export default function ClusterAddonUpdateInfo({ addon }: { addon: ClusterAddonU title: `${actionLabel} ${addon.displayName}`, description: operation === 'remove' ? `Do you want to remove ${addon.displayName}? QuickStack will delete the add-on's Kubernetes resources from this cluster. This cannot be undone.` - : operation === 'update' && addon.updateWarning + : addon.updateWarning ?
-

{`Do you want to update ${addon.displayName}${version}? QuickStack will apply the add-on's Kubernetes resources to this cluster.`}

+

{`Do you want to ${operation} ${addon.displayName}${version}? QuickStack will apply the add-on's Kubernetes resources to this cluster.`}

{addon.updateWarning.title}

    @@ -63,7 +63,9 @@ export default function ClusterAddonUpdateInfo({ addon }: { addon: ClusterAddonU : `Do you want to ${operation} ${addon.displayName}${version}? QuickStack will apply the add-on's Kubernetes resources to this cluster.`, okButton: actionLabel, }); - if (!confirmed) return; + if (!confirmed) { + return; + } try { setLoading(true); diff --git a/src/server/services/addons/base-cluster-addon.service.ts b/src/server/services/addons/base-cluster-addon.service.ts index c14674b3..dfeeb968 100644 --- a/src/server/services/addons/base-cluster-addon.service.ts +++ b/src/server/services/addons/base-cluster-addon.service.ts @@ -17,6 +17,7 @@ globalThis.quickStackAddonOperations = addonOperations; * Add-on-specific services retain their manifest, status and lifecycle logic. */ export abstract class BaseClusterAddon { + protected constructor(private readonly addonId: string) {} protected getActiveOperation(): ActiveAddonOperation | undefined { @@ -49,7 +50,11 @@ export abstract class BaseClusterAddon { await k3s.applyResource(spec, spec.metadata?.namespace); return resource; } catch (error) { - return { ...resource, status: 'failed', error: AddonKubernetesUtils.errorMessage(error) }; + return { + ...resource, + status: 'failed', + error: AddonKubernetesUtils.errorMessage(error) + }; } } diff --git a/src/server/services/addons/cluster-addon-registry.service.ts b/src/server/services/addons/cluster-addon-registry.service.ts index 89318a92..7dd9c974 100644 --- a/src/server/services/addons/cluster-addon-registry.service.ts +++ b/src/server/services/addons/cluster-addon-registry.service.ts @@ -1,13 +1,15 @@ import { ClusterAddon } from './cluster-addon.interface'; import agentSandboxAddonService from './agent-sandbox-addon.service'; import certManagerAddonService from './cert-manager-addon.service'; +import gvisorAddonService from './gvisor-addon.service'; import longhornAddonService from './longhorn-addon.service'; class ClusterAddonRegistryService { private readonly addons: readonly ClusterAddon[] = [ longhornAddonService, agentSandboxAddonService, - certManagerAddonService + certManagerAddonService, + gvisorAddonService, ]; getAll(): readonly ClusterAddon[] { diff --git a/src/server/services/addons/cluster-addon-registry.service.unit.spec.ts b/src/server/services/addons/cluster-addon-registry.service.unit.spec.ts index 7a685d02..e7c9b47a 100644 --- a/src/server/services/addons/cluster-addon-registry.service.unit.spec.ts +++ b/src/server/services/addons/cluster-addon-registry.service.unit.spec.ts @@ -10,6 +10,10 @@ vi.mock('./cert-manager-addon.service', () => ({ default: { metadata: { id: 'cert-manager' } }, })); +vi.mock('./gvisor-addon.service', () => ({ + default: { metadata: { id: 'gvisor' } }, +})); + import clusterAddonRegistryService from './cluster-addon-registry.service'; describe('ClusterAddonRegistryService', () => { @@ -20,4 +24,8 @@ describe('ClusterAddonRegistryService', () => { it('registers CertManager as a trusted Cluster Add-on', () => { expect(clusterAddonRegistryService.getById('cert-manager')).toMatchObject({ metadata: { id: 'cert-manager' } }); }); + + it('registers gVisor as a trusted Cluster Add-on', () => { + expect(clusterAddonRegistryService.getById('gvisor')).toMatchObject({ metadata: { id: 'gvisor' } }); + }); }); diff --git a/src/server/services/addons/gvisor-addon.service.ts b/src/server/services/addons/gvisor-addon.service.ts new file mode 100644 index 00000000..fee3340e --- /dev/null +++ b/src/server/services/addons/gvisor-addon.service.ts @@ -0,0 +1,328 @@ +import k3s from '@/server/adapter/kubernetes-api.adapter'; +import { AddonMetadata, AddonOperationResult, AddonRelease, AddonResourceOperation, AddonStatus } from '@/shared/model/cluster-addon.model'; +import { ServiceException } from '@/shared/model/service.exception.model'; +import { AddonKubernetesUtils } from '@/server/utils/addon-kubernetes.utils'; +import { BaseClusterAddon } from './base-cluster-addon.service'; +import { ClusterAddon } from './cluster-addon.interface'; + +class GvisorAddonService extends BaseClusterAddon implements ClusterAddon { + private static readonly NAMESPACE = 'quickstack-gvisor-system'; + private static readonly CONFIG_NAME = 'quickstack-gvisor-config'; + private static readonly DAEMON_SET_NAME = 'quickstack-gvisor-installer'; + private static readonly RELEASES: readonly AddonRelease[] = [ + { + version: '20260921', + manifestUrl: 'https://storage.googleapis.com/gvisor/releases/20260921' + }, + ]; + + readonly metadata: AddonMetadata = { + id: 'gvisor', displayName: 'gVisor', + description: 'Provides the optional gVisor runtime on supported Ubuntu and Debian cluster nodes.', + documentationUrl: 'https://gvisor.dev/docs/user_guide/install/', + managedNamespaces: [GvisorAddonService.NAMESPACE], canUninstall: false, + updateWarning: { + title: 'This changes every schedulable node:', + items: [ + 'gVisor cannot be removed through QuickStack once installed.', + 'Each node is cordoned and K3s is restarted serially. Workloads are not drained.', + 'A single-server control plane is briefly unavailable while its K3s service restarts.', + ] + }, + }; + + constructor() { super('gvisor'); } + + async getStatus(): Promise { + const active = this.getActiveOperation(); + if (active) { + return { status: active }; + } + try { + const config = await k3s.core.readNamespacedConfigMap({ + name: GvisorAddonService.CONFIG_NAME, + namespace: GvisorAddonService.NAMESPACE + }); + const desired = config.data?.version; + if (!desired) { + return { + status: 'failed', + message: 'The gVisor desired-version ConfigMap is invalid.' + }; + } + const daemonSet = await k3s.apps.readNamespacedDaemonSet({ + name: GvisorAddonService.DAEMON_SET_NAME, + namespace: GvisorAddonService.NAMESPACE + }); + const nodes = (await k3s.core.listNode()).items.filter((node) => !node.spec?.unschedulable); + + const missing = nodes.find((node) => node.metadata?.labels?.['quickstack.dev/gvisor'] !== 'true' || + node.metadata?.labels?.['quickstack.dev/gvisor-version'] !== desired); + if (missing) { + return { + status: 'updating', + installedVersion: desired, + message: `Waiting for gVisor on node ${missing.metadata?.name ?? ''}.` + }; + } + if ((daemonSet.status?.numberUnavailable ?? 0) > 0) { + return { + status: 'updating', + installedVersion: desired, + message: 'Waiting for the gVisor installer DaemonSet.' + }; + } + return { + status: 'ready', + installedVersion: desired + }; + } catch (error) { + if (AddonKubernetesUtils.isNotFound(error)) { + return { status: 'notInstalled' }; + } + return { + status: 'failed', + message: AddonKubernetesUtils.errorMessage(error) + }; + } + } + + async install(): Promise { + return this.runExclusive('installing', async () => { + const status = await this.getStatus(); + if (status.status !== 'notInstalled') { + throw new ServiceException('gVisor is already installed or installation is in progress.'); + } + return this.reconcile(GvisorAddonService.RELEASES[0]); + }); + } + + async getAvailableUpdate(): Promise { + const status = await this.getStatus(); + if (!status.installedVersion) { + return undefined; + } + const index = GvisorAddonService.RELEASES.findIndex((release) => release.version === status.installedVersion); + if (index === -1) { + throw new ServiceException(`Installed gVisor version ${status.installedVersion} is not managed by QuickStack.`); + } + return GvisorAddonService.RELEASES[index - 1]; + } + + async update(): Promise { + return this.runExclusive('updating', async () => { + const update = await this.getAvailableUpdate(); + if (!update) { + throw new ServiceException('No newer gVisor version is available.'); + } + return this.reconcile(update); + }); + } + + async uninstall(): Promise { + throw new ServiceException('gVisor cannot be removed through QuickStack.'); + } + + private async reconcile(release: AddonRelease): Promise { + const resources: AddonResourceOperation[] = []; + for (const spec of this.resources(release)) { + resources.push(await this.applyResource(spec)); + } + return AddonKubernetesUtils.operationResult(release, resources); + } + + private resources(release: AddonRelease): any[] { + const namespace = GvisorAddonService.NAMESPACE; + return [ + // Namespace + { apiVersion: 'v1', kind: 'Namespace', metadata: { name: namespace } }, + // Service Account + { apiVersion: 'v1', kind: 'ServiceAccount', metadata: { name: 'quickstack-gvisor-installer', namespace } }, + // Cluster Role (for Service Account) + { + apiVersion: 'rbac.authorization.k8s.io/v1', + kind: 'ClusterRole', + metadata: { + name: 'quickstack-gvisor-installer' + }, + rules: [ + { apiGroups: [''], resources: ['nodes'], verbs: ['get', 'list', 'patch'] }, + { apiGroups: ['coordination.k8s.io'], resources: ['leases'], verbs: ['get', 'create', 'update', 'patch'] }, + ], + }, + { + apiVersion: 'rbac.authorization.k8s.io/v1', + kind: 'ClusterRoleBinding', + metadata: { + name: 'quickstack-gvisor-installer' + }, + roleRef: { + apiGroup: 'rbac.authorization.k8s.io', + kind: 'ClusterRole', + name: 'quickstack-gvisor-installer' + }, + subjects: [{ + kind: 'ServiceAccount', + name: 'quickstack-gvisor-installer', + namespace + }], + }, + // Config Map for Setup Script and gVisor Version + { + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { + name: GvisorAddonService.CONFIG_NAME, + namespace + }, + data: { + version: release.version, + 'reconcile.sh': this.reconcileScript() + }, + }, + // Runtime Class for gVisor + { + apiVersion: 'node.k8s.io/v1', + kind: 'RuntimeClass', + metadata: { + name: 'gvisor' + }, + handler: 'gvisor' + }, + // DeamonSet for actual installation of gVisor on Nodes + { + apiVersion: 'apps/v1', + kind: 'DaemonSet', + metadata: { + name: GvisorAddonService.DAEMON_SET_NAME, + namespace + }, + spec: { + selector: { + matchLabels: { + app: GvisorAddonService.DAEMON_SET_NAME + } + }, + template: { + metadata: { + labels: { + app: GvisorAddonService.DAEMON_SET_NAME, + 'quickstack.dev/gvisor-release': release.version + } + }, + spec: { + serviceAccountName: 'quickstack-gvisor-installer', + hostPID: true, + containers: [{ + name: 'installer', + image: 'debian:bookworm-slim', + securityContext: { privileged: true }, + env: [ + { name: 'GVISOR_VERSION', value: release.version }, + { name: 'NODE_NAME', valueFrom: { fieldRef: { fieldPath: 'spec.nodeName' } } } + ], + command: ['/bin/bash', '-c', 'apt-get update && apt-get install -y --no-install-recommends bash ca-certificates curl gpg jq util-linux && /scripts/reconcile.sh'], + volumeMounts: [ + { + name: 'host-root', + mountPath: '/host' + }, + { + name: 'scripts', + mountPath: '/scripts', + readOnly: true + } + ], + }], + volumes: [ + { + name: 'host-root', + hostPath: { + path: '/', + type: 'Directory' + } + }, + { + name: 'scripts', + configMap: { + name: GvisorAddonService.CONFIG_NAME, + defaultMode: 493 + } + }, + ], + }, + }, + }, + }, + ]; + } + + private reconcileScript(): string { + return `#!/usr/bin/env bash +set -euo pipefail + +api_server='https://kubernetes.default.svc' +token=$(< /var/run/secrets/kubernetes.io/serviceaccount/token) +ca='/var/run/secrets/kubernetes.io/serviceaccount/ca.crt' +namespace='quickstack-gvisor-system' +lease_name='quickstack-gvisor-installer-lock' + +api() { curl --fail --silent --show-error --cacert "$ca" -H "Authorization: Bearer $token" -H 'Content-Type: application/json' "$@"; } + +acquire_lock() { + while ! api -X POST "$api_server/apis/coordination.k8s.io/v1/namespaces/$namespace/leases" --data "{\\"metadata\\":{\\"name\\":\\"$lease_name\\"},\\"spec\\":{\\"holderIdentity\\":\\"$NODE_NAME\\",\\"leaseDurationSeconds\\":120}}"; do + sleep 10 + done +} + +release_lock() { api -X DELETE "$api_server/apis/coordination.k8s.io/v1/namespaces/$namespace/leases/$lease_name" >/dev/null || true; } +trap release_lock EXIT + +cordon() { api -X PATCH "$api_server/api/v1/nodes/$NODE_NAME" -H 'Content-Type: application/merge-patch+json' --data '{"spec":{"unschedulable":true}}' >/dev/null; } +uncordon() { api -X PATCH "$api_server/api/v1/nodes/$NODE_NAME" -H 'Content-Type: application/merge-patch+json' --data '{"spec":{"unschedulable":null}}' >/dev/null; } +label_ready() { api -X PATCH "$api_server/api/v1/nodes/$NODE_NAME" -H 'Content-Type: application/merge-patch+json' --data "{\\"metadata\\":{\\"labels\\":{\\"quickstack.dev/gvisor\\":\\"true\\",\\"quickstack.dev/gvisor-version\\":\\"$GVISOR_VERSION\\"}}}" >/dev/null; } + +install_host_runtime() { + cat > /host/tmp/quickstack-gvisor-install.sh <<'HOST_SCRIPT' +#!/bin/bash +set -euo pipefail +version="$1" +. /etc/os-release +case "$ID" in debian|ubuntu) ;; *) echo "Unsupported operating system: $ID"; exit 1;; esac +case "$(uname -m)" in x86_64|aarch64) ;; *) echo "Unsupported architecture"; exit 1;; esac +test "$(printf '%s\\n' '5.6' "$(uname -r | cut -d- -f1)" | sort -V | head -n1)" = '5.6' +apt-get update +apt-get install -y --no-install-recommends apt-transport-https ca-certificates curl gnupg +curl -fsSL https://gvisor.dev/archive.key | gpg --dearmor -o /usr/share/keyrings/gvisor-archive-keyring.gpg +echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/gvisor-archive-keyring.gpg] https://storage.googleapis.com/gvisor/releases $version main" > /etc/apt/sources.list.d/gvisor.list +apt-get update +apt-get install -y runsc +config_dir=/var/lib/rancher/k3s/agent/etc/containerd +template="$config_dir/config-v3.toml.tmpl" +test -f "$template" || template="$config_dir/config.toml.tmpl" +mkdir -p "$config_dir" +touch "$template" +grep -q 'quickstack-gvisor-start' "$template" || cat >> "$template" <<'TOML' +# quickstack-gvisor-start +[plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.gvisor] + runtime_type = "io.containerd.runsc.v1" +# quickstack-gvisor-end +TOML +systemctl restart k3s || systemctl restart k3s-agent +HOST_SCRIPT + chmod 700 /host/tmp/quickstack-gvisor-install.sh + chroot /host /bin/bash /tmp/quickstack-gvisor-install.sh "$GVISOR_VERSION" +} + +acquire_lock +cordon +install_host_runtime +label_ready +uncordon +sleep infinity +`; + } +} + +const gvisorAddonService = new GvisorAddonService(); +export default gvisorAddonService; From 2734faa3b272b33fe7d4b1ddfd0d845ad310bf8e Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Mon, 28 Sep 2026 06:43:55 +0000 Subject: [PATCH 2/7] fix: issues in gvisor install process and created manual integration test --- .../gvisor-addon.live.integration.spec.ts | 63 ++++++++ .../services/addons/gvisor-addon.service.ts | 142 +++++++++++++++--- 2 files changed, 181 insertions(+), 24 deletions(-) create mode 100644 src/__tests__/integration/server/services/addons/gvisor-addon.live.integration.spec.ts diff --git a/src/__tests__/integration/server/services/addons/gvisor-addon.live.integration.spec.ts b/src/__tests__/integration/server/services/addons/gvisor-addon.live.integration.spec.ts new file mode 100644 index 00000000..6c64f598 --- /dev/null +++ b/src/__tests__/integration/server/services/addons/gvisor-addon.live.integration.spec.ts @@ -0,0 +1,63 @@ +import { execFileSync } from 'node:child_process'; + +const runLiveIntegration = process.env.RUN_GVISOR_LIVE_INTEGRATION === '1'; +const describeLive = runLiveIntegration ? describe : describe.skip; + +describeLive('gVisor live cluster installation', () => { + const kubectl = process.env.KUBECTL_PATH ?? '/home/node/.local/bin/kubectl'; + const kubeconfig = process.env.KUBECONFIG ?? '/workspace/kube-config.config'; + const namespace = 'quickstack-gvisor-system'; + + const kubectlOutput = (...args: string[]): string => execFileSync(kubectl, ['--kubeconfig', kubeconfig, ...args], { + encoding: 'utf8', + timeout: 30_000, + }); + + it('installs and verifies gVisor on every schedulable node', async () => { + const { default: gvisorAddonService } = await import('@/server/services/addons/gvisor-addon.service'); + const initialNodes = JSON.parse(kubectlOutput('get', 'nodes', '-o', 'json')) as { + items: Array<{ metadata: { name: string }; spec?: { unschedulable?: boolean } }>; + }; + const targetNodeNames = initialNodes.items + .filter((node) => !node.spec?.unschedulable) + .map((node) => node.metadata.name); + expect(targetNodeNames.length).toBeGreaterThan(0); + + const initialStatus = await gvisorAddonService.getStatus(); + if (initialStatus.status === 'notInstalled') { + const result = await gvisorAddonService.install(); + expect(result.status).toBe('succeeded'); + } + + const deadline = Date.now() + 10 * 60_000; + while (Date.now() < deadline) { + const nodes = JSON.parse(kubectlOutput('get', 'nodes', '-o', 'json')) as { + items: Array<{ metadata: { name: string; labels?: Record }; spec?: { unschedulable?: boolean } }>; + }; + const targetsReady = targetNodeNames.every((name) => { + const node = nodes.items.find((candidate) => candidate.metadata.name === name); + return node && !node.spec?.unschedulable && node.metadata.labels?.['quickstack.dev/gvisor'] === 'true' && + node.metadata.labels?.['quickstack.dev/gvisor-version'] === '20260921'; + }); + if (targetsReady) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 5_000)); + } + + await expect(gvisorAddonService.getStatus()).resolves.toMatchObject({ status: 'ready' }); + expect(kubectlOutput('-n', namespace, 'get', 'daemonset', 'quickstack-gvisor-installer')).toContain('quickstack-gvisor-installer'); + expect(kubectlOutput('get', 'runtimeclass', 'gvisor', '-o', 'jsonpath={.handler}')).toBe('gvisor'); + + const nodes = JSON.parse(kubectlOutput('get', 'nodes', '-o', 'json')) as { + items: Array<{ metadata: { name: string; labels?: Record }; spec?: { unschedulable?: boolean } }>; + }; + const targetNodes = nodes.items.filter((node) => targetNodeNames.includes(node.metadata.name)); + expect(targetNodes).toHaveLength(targetNodeNames.length); + for (const node of targetNodes) { + expect(node.spec?.unschedulable).not.toBe(true); + expect(node.metadata.labels?.['quickstack.dev/gvisor']).toBe('true'); + expect(node.metadata.labels?.['quickstack.dev/gvisor-version']).toBe('20260921'); + } + }, 11 * 60_000); +}); diff --git a/src/server/services/addons/gvisor-addon.service.ts b/src/server/services/addons/gvisor-addon.service.ts index fee3340e..ccf0c40c 100644 --- a/src/server/services/addons/gvisor-addon.service.ts +++ b/src/server/services/addons/gvisor-addon.service.ts @@ -38,6 +38,11 @@ class GvisorAddonService extends BaseClusterAddon implements ClusterAddon { if (active) { return { status: active }; } + + return this.getStatusRaw(); + } + + private async getStatusRaw(): Promise { try { const config = await k3s.core.readNamespacedConfigMap({ name: GvisorAddonService.CONFIG_NAME, @@ -54,6 +59,15 @@ class GvisorAddonService extends BaseClusterAddon implements ClusterAddon { name: GvisorAddonService.DAEMON_SET_NAME, namespace: GvisorAddonService.NAMESPACE }); + const desiredPods = daemonSet.status?.desiredNumberScheduled ?? 0; + const availablePods = daemonSet.status?.numberAvailable ?? 0; + if (availablePods < desiredPods) { + return { + status: 'updating', + installedVersion: desired, + message: 'Waiting for the gVisor installer DaemonSet.' + }; + } const nodes = (await k3s.core.listNode()).items.filter((node) => !node.spec?.unschedulable); const missing = nodes.find((node) => node.metadata?.labels?.['quickstack.dev/gvisor'] !== 'true' || @@ -65,13 +79,6 @@ class GvisorAddonService extends BaseClusterAddon implements ClusterAddon { message: `Waiting for gVisor on node ${missing.metadata?.name ?? ''}.` }; } - if ((daemonSet.status?.numberUnavailable ?? 0) > 0) { - return { - status: 'updating', - installedVersion: desired, - message: 'Waiting for the gVisor installer DaemonSet.' - }; - } return { status: 'ready', installedVersion: desired @@ -89,7 +96,7 @@ class GvisorAddonService extends BaseClusterAddon implements ClusterAddon { async install(): Promise { return this.runExclusive('installing', async () => { - const status = await this.getStatus(); + const status = await this.getStatusRaw(); if (status.status !== 'notInstalled') { throw new ServiceException('gVisor is already installed or installation is in progress.'); } @@ -98,7 +105,7 @@ class GvisorAddonService extends BaseClusterAddon implements ClusterAddon { } async getAvailableUpdate(): Promise { - const status = await this.getStatus(); + const status = await this.getStatusRaw(); if (!status.installedVersion) { return undefined; } @@ -147,7 +154,7 @@ class GvisorAddonService extends BaseClusterAddon implements ClusterAddon { }, rules: [ { apiGroups: [''], resources: ['nodes'], verbs: ['get', 'list', 'patch'] }, - { apiGroups: ['coordination.k8s.io'], resources: ['leases'], verbs: ['get', 'create', 'update', 'patch'] }, + { apiGroups: ['coordination.k8s.io'], resources: ['leases'], verbs: ['get', 'create', 'update', 'patch', 'delete'] }, ], }, { @@ -266,17 +273,68 @@ token=$(< /var/run/secrets/kubernetes.io/serviceaccount/token) ca='/var/run/secrets/kubernetes.io/serviceaccount/ca.crt' namespace='quickstack-gvisor-system' lease_name='quickstack-gvisor-installer-lock' +lease_url="$api_server/apis/coordination.k8s.io/v1/namespaces/$namespace/leases/$lease_name" -api() { curl --fail --silent --show-error --cacert "$ca" -H "Authorization: Bearer $token" -H 'Content-Type: application/json' "$@"; } +api() { curl --fail --silent --show-error --cacert "$ca" -H "Authorization: Bearer $token" "$@"; } acquire_lock() { - while ! api -X POST "$api_server/apis/coordination.k8s.io/v1/namespaces/$namespace/leases" --data "{\\"metadata\\":{\\"name\\":\\"$lease_name\\"},\\"spec\\":{\\"holderIdentity\\":\\"$NODE_NAME\\",\\"leaseDurationSeconds\\":120}}"; do + while true; do + now=$(date --utc +%Y-%m-%dT%H:%M:%S.000000Z) + lease="{\\"metadata\\":{\\"name\\":\\"$lease_name\\"},\\"spec\\":{\\"holderIdentity\\":\\"$NODE_NAME\\",\\"leaseDurationSeconds\\":120,\\"acquireTime\\":\\"$now\\",\\"renewTime\\":\\"$now\\"}}" + create_status=$(curl --silent --output /tmp/quickstack-gvisor-lease-response --write-out '%{http_code}' --cacert "$ca" -H "Authorization: Bearer $token" -H 'Content-Type: application/json' -X POST "$api_server/apis/coordination.k8s.io/v1/namespaces/$namespace/leases" --data "$lease") + if [[ "$create_status" == '201' ]]; then + return + fi + + if [[ "$create_status" != '409' ]]; then + cat /tmp/quickstack-gvisor-lease-response >&2 + exit 1 + fi + + existing=$(api "$lease_url") + holder=$(jq -r '.spec.holderIdentity // empty' <<< "$existing") + renew_time=$(jq -r '.spec.renewTime // empty' <<< "$existing") + creation_time=$(jq -r '.metadata.creationTimestamp // empty' <<< "$existing") + duration=$(jq -r '.spec.leaseDurationSeconds // 120' <<< "$existing") + + if [[ "$holder" == "$NODE_NAME" ]]; then + return + fi + + lease_time="$renew_time" + if [[ -z "$lease_time" ]]; then + lease_time="$creation_time" + fi + + expires_at=$(date -u -d "$lease_time + $duration seconds" +%s 2>/dev/null || echo 0) + if (( expires_at > 0 && expires_at < $(date +%s) )); then + api -X DELETE "$lease_url" >/dev/null || true + fi + sleep 10 done } release_lock() { api -X DELETE "$api_server/apis/coordination.k8s.io/v1/namespaces/$namespace/leases/$lease_name" >/dev/null || true; } -trap release_lock EXIT +renew_lock() { + while true; do + sleep 30 + now=$(date --utc +%Y-%m-%dT%H:%M:%S.000000Z) + api -X PATCH "$lease_url" -H 'Content-Type: application/merge-patch+json' --data "{\\"spec\\":{\\"holderIdentity\\":\\"$NODE_NAME\\",\\"leaseDurationSeconds\\":120,\\"renewTime\\":\\"$now\\"}}" >/dev/null + done +} + +renew_pid='' +holds_lock=false +cleanup() { + if [[ -n "$renew_pid" ]]; then + kill "$renew_pid" 2>/dev/null || true + fi + if [[ "$holds_lock" == true ]]; then + release_lock + fi +} +trap cleanup EXIT cordon() { api -X PATCH "$api_server/api/v1/nodes/$NODE_NAME" -H 'Content-Type: application/merge-patch+json' --data '{"spec":{"unschedulable":true}}' >/dev/null; } uncordon() { api -X PATCH "$api_server/api/v1/nodes/$NODE_NAME" -H 'Content-Type: application/merge-patch+json' --data '{"spec":{"unschedulable":null}}' >/dev/null; } @@ -291,30 +349,66 @@ version="$1" case "$ID" in debian|ubuntu) ;; *) echo "Unsupported operating system: $ID"; exit 1;; esac case "$(uname -m)" in x86_64|aarch64) ;; *) echo "Unsupported architecture"; exit 1;; esac test "$(printf '%s\\n' '5.6' "$(uname -r | cut -d- -f1)" | sort -V | head -n1)" = '5.6' -apt-get update -apt-get install -y --no-install-recommends apt-transport-https ca-certificates curl gnupg -curl -fsSL https://gvisor.dev/archive.key | gpg --dearmor -o /usr/share/keyrings/gvisor-archive-keyring.gpg -echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/gvisor-archive-keyring.gpg] https://storage.googleapis.com/gvisor/releases $version main" > /etc/apt/sources.list.d/gvisor.list -apt-get update -apt-get install -y runsc config_dir=/var/lib/rancher/k3s/agent/etc/containerd template="$config_dir/config-v3.toml.tmpl" -test -f "$template" || template="$config_dir/config.toml.tmpl" -mkdir -p "$config_dir" -touch "$template" -grep -q 'quickstack-gvisor-start' "$template" || cat >> "$template" <<'TOML' +if [[ ! -f "$template" ]]; then + template="$config_dir/config.toml.tmpl" +fi + +ensure_base_template() { + if [[ ! -f "$template" ]]; then + mkdir -p "$config_dir" + printf '{{ template "base" . }}\\n' > "$template" + return + fi + + if grep -q 'quickstack-gvisor-start' "$template" && ! grep -q '{{ template "base" . }}' "$template"; then + sed -i '1i {{ template "base" . }}' "$template" + fi +} + +runtime_block() { + if [[ "$template" == *config-v3.toml.tmpl ]]; then + cat <<'TOML' # quickstack-gvisor-start [plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.gvisor] runtime_type = "io.containerd.runsc.v1" # quickstack-gvisor-end TOML + return + fi + + cat <<'TOML' +# quickstack-gvisor-start +[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.gvisor] + runtime_type = "io.containerd.runsc.v1" +# quickstack-gvisor-end +TOML +} + +ensure_base_template +if runsc --version 2>/dev/null | grep -q "$version" && grep -q 'quickstack-gvisor-start' "$template"; then + exit 0 +fi +apt-get update +apt-get install -y --no-install-recommends apt-transport-https ca-certificates curl gnupg +curl -fsSL https://gvisor.dev/archive.key | gpg --dearmor -o /usr/share/keyrings/gvisor-archive-keyring.gpg +echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/gvisor-archive-keyring.gpg] https://storage.googleapis.com/gvisor/releases $version main" > /etc/apt/sources.list.d/gvisor.list +apt-get update +apt-get install -y runsc +if ! grep -q 'quickstack-gvisor-start' "$template"; then + runtime_block >> "$template" +fi systemctl restart k3s || systemctl restart k3s-agent HOST_SCRIPT chmod 700 /host/tmp/quickstack-gvisor-install.sh - chroot /host /bin/bash /tmp/quickstack-gvisor-install.sh "$GVISOR_VERSION" + nsenter --target 1 --mount --pid --uts --ipc --net /bin/bash /tmp/quickstack-gvisor-install.sh "$GVISOR_VERSION" } acquire_lock +holds_lock=true +renew_lock & +renew_pid=$! cordon install_host_runtime label_ready From cae46e7053a0fef9ccd517d1632100adac7f87c9 Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Mon, 28 Sep 2026 07:25:07 +0000 Subject: [PATCH 3/7] feat: enhance gVisor installation script with logging and lease management improvements --- .../services/addons/gvisor-addon.service.ts | 60 ++++++++++++++++--- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/src/server/services/addons/gvisor-addon.service.ts b/src/server/services/addons/gvisor-addon.service.ts index ccf0c40c..c0ce169c 100644 --- a/src/server/services/addons/gvisor-addon.service.ts +++ b/src/server/services/addons/gvisor-addon.service.ts @@ -268,6 +268,7 @@ class GvisorAddonService extends BaseClusterAddon implements ClusterAddon { return `#!/usr/bin/env bash set -euo pipefail +# Kubernetes API connection for this DaemonSet's ServiceAccount. api_server='https://kubernetes.default.svc' token=$(< /var/run/secrets/kubernetes.io/serviceaccount/token) ca='/var/run/secrets/kubernetes.io/serviceaccount/ca.crt' @@ -275,14 +276,21 @@ namespace='quickstack-gvisor-system' lease_name='quickstack-gvisor-installer-lock' lease_url="$api_server/apis/coordination.k8s.io/v1/namespaces/$namespace/leases/$lease_name" +log() { echo "[$(date --utc +%Y-%m-%dT%H:%M:%SZ)] [gvisor-installer] [$NODE_NAME] $*"; } + +# All Kubernetes API calls authenticate with the mounted ServiceAccount token. api() { curl --fail --silent --show-error --cacert "$ca" -H "Authorization: Bearer $token" "$@"; } +# Only one node may change its host runtime at a time. +# A Lease survives a Pod restart long enough for another Installer Pod to recover it. acquire_lock() { + log 'Waiting to acquire the cluster-wide installation Lease.' while true; do now=$(date --utc +%Y-%m-%dT%H:%M:%S.000000Z) - lease="{\\"metadata\\":{\\"name\\":\\"$lease_name\\"},\\"spec\\":{\\"holderIdentity\\":\\"$NODE_NAME\\",\\"leaseDurationSeconds\\":120,\\"acquireTime\\":\\"$now\\",\\"renewTime\\":\\"$now\\"}}" + lease="{\\"metadata\\":{\\"name\\":\\"$lease_name\\"},\\"spec\\":{\\"holderIdentity\\":\\"$NODE_NAME\\",\\"leaseDurationSeconds\\":45,\\"acquireTime\\":\\"$now\\",\\"renewTime\\":\\"$now\\"}}" create_status=$(curl --silent --output /tmp/quickstack-gvisor-lease-response --write-out '%{http_code}' --cacert "$ca" -H "Authorization: Bearer $token" -H 'Content-Type: application/json' -X POST "$api_server/apis/coordination.k8s.io/v1/namespaces/$namespace/leases" --data "$lease") if [[ "$create_status" == '201' ]]; then + log 'Acquired installation Lease.' return fi @@ -291,13 +299,15 @@ acquire_lock() { exit 1 fi + # Another Pod owns the Lease. Inspect it and take over only after expiry. existing=$(api "$lease_url") holder=$(jq -r '.spec.holderIdentity // empty' <<< "$existing") renew_time=$(jq -r '.spec.renewTime // empty' <<< "$existing") creation_time=$(jq -r '.metadata.creationTimestamp // empty' <<< "$existing") - duration=$(jq -r '.spec.leaseDurationSeconds // 120' <<< "$existing") + duration=$(jq -r '.spec.leaseDurationSeconds // 45' <<< "$existing") if [[ "$holder" == "$NODE_NAME" ]]; then + log 'Recovered the Lease held by this Node.' return fi @@ -308,22 +318,25 @@ acquire_lock() { expires_at=$(date -u -d "$lease_time + $duration seconds" +%s 2>/dev/null || echo 0) if (( expires_at > 0 && expires_at < $(date +%s) )); then + log "Removing expired Lease held by $holder." api -X DELETE "$lease_url" >/dev/null || true fi - sleep 10 + sleep 5 done } +# Renew while apt and K3s work may take longer than the Lease duration. release_lock() { api -X DELETE "$api_server/apis/coordination.k8s.io/v1/namespaces/$namespace/leases/$lease_name" >/dev/null || true; } renew_lock() { while true; do - sleep 30 + sleep 15 now=$(date --utc +%Y-%m-%dT%H:%M:%S.000000Z) - api -X PATCH "$lease_url" -H 'Content-Type: application/merge-patch+json' --data "{\\"spec\\":{\\"holderIdentity\\":\\"$NODE_NAME\\",\\"leaseDurationSeconds\\":120,\\"renewTime\\":\\"$now\\"}}" >/dev/null + api -X PATCH "$lease_url" -H 'Content-Type: application/merge-patch+json' --data "{\\"spec\\":{\\"holderIdentity\\":\\"$NODE_NAME\\",\\"leaseDurationSeconds\\":45,\\"renewTime\\":\\"$now\\"}}" >/dev/null done } +# Cleanup releases only a Lease owned by this Pod. renew_pid='' holds_lock=false cleanup() { @@ -331,21 +344,34 @@ cleanup() { kill "$renew_pid" 2>/dev/null || true fi if [[ "$holds_lock" == true ]]; then + log 'Releasing installation Lease during cleanup.' release_lock fi } trap cleanup EXIT -cordon() { api -X PATCH "$api_server/api/v1/nodes/$NODE_NAME" -H 'Content-Type: application/merge-patch+json' --data '{"spec":{"unschedulable":true}}' >/dev/null; } -uncordon() { api -X PATCH "$api_server/api/v1/nodes/$NODE_NAME" -H 'Content-Type: application/merge-patch+json' --data '{"spec":{"unschedulable":null}}' >/dev/null; } +# Cordon prevents new workloads during the runtime and K3s changes. It never drains Pods. +cordon() { log 'Cordoning Node.'; api -X PATCH "$api_server/api/v1/nodes/$NODE_NAME" -H 'Content-Type: application/merge-patch+json' --data '{"spec":{"unschedulable":true}}' >/dev/null; } +uncordon() { log 'Uncordoning Node.'; api -X PATCH "$api_server/api/v1/nodes/$NODE_NAME" -H 'Content-Type: application/merge-patch+json' --data '{"spec":{"unschedulable":null}}' >/dev/null; } label_ready() { api -X PATCH "$api_server/api/v1/nodes/$NODE_NAME" -H 'Content-Type: application/merge-patch+json' --data "{\\"metadata\\":{\\"labels\\":{\\"quickstack.dev/gvisor\\":\\"true\\",\\"quickstack.dev/gvisor-version\\":\\"$GVISOR_VERSION\\"}}}" >/dev/null; } +set_phase() { api -X PATCH "$api_server/api/v1/nodes/$NODE_NAME" -H 'Content-Type: application/merge-patch+json' --data "{\\"metadata\\":{\\"annotations\\":{\\"quickstack.dev/gvisor-phase\\":\\"$1\\"}}}" >/dev/null; } +mark_failed() { set_phase failed || true; } +# Copy the host script into the mounted host filesystem, then execute it in the host namespaces. install_host_runtime() { + log 'Starting host runtime installation.' cat > /host/tmp/quickstack-gvisor-install.sh <<'HOST_SCRIPT' #!/bin/bash set -euo pipefail + +host_log() { echo "[$(date --utc +%Y-%m-%dT%H:%M:%SZ)] [gvisor-host-installer] $*"; } + +# This section runs on the node host, not inside the Installer container. version="$1" . /etc/os-release +host_log "Starting preflight for gVisor release $version on $ID." + +# gVisor supports only the chosen operating systems, architectures, and kernel baseline. case "$ID" in debian|ubuntu) ;; *) echo "Unsupported operating system: $ID"; exit 1;; esac case "$(uname -m)" in x86_64|aarch64) ;; *) echo "Unsupported architecture"; exit 1;; esac test "$(printf '%s\\n' '5.6' "$(uname -r | cut -d- -f1)" | sort -V | head -n1)" = '5.6' @@ -355,6 +381,7 @@ if [[ ! -f "$template" ]]; then template="$config_dir/config.toml.tmpl" fi +# K3s templates must include the generated base configuration. Never overwrite an admin template. ensure_base_template() { if [[ ! -f "$template" ]]; then mkdir -p "$config_dir" @@ -367,6 +394,7 @@ ensure_base_template() { fi } +# Containerd 2.x and 1.x use different plugin paths. runtime_block() { if [[ "$template" == *config-v3.toml.tmpl ]]; then cat <<'TOML' @@ -386,33 +414,51 @@ TOML TOML } +# A restarted Installer Pod reaches this point after K3s restarted. Avoid a second restart. ensure_base_template if runsc --version 2>/dev/null | grep -q "$version" && grep -q 'quickstack-gvisor-start' "$template"; then + host_log 'Pinned runtime and K3s template already match; skipping installation and restart.' exit 0 fi +# Configure the official, pinned gVisor apt repository. Do not run apt upgrade. apt-get update apt-get install -y --no-install-recommends apt-transport-https ca-certificates curl gnupg curl -fsSL https://gvisor.dev/archive.key | gpg --dearmor -o /usr/share/keyrings/gvisor-archive-keyring.gpg echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/gvisor-archive-keyring.gpg] https://storage.googleapis.com/gvisor/releases $version main" > /etc/apt/sources.list.d/gvisor.list apt-get update apt-get install -y runsc +host_log 'Installed pinned runsc package.' if ! grep -q 'quickstack-gvisor-start' "$template"; then runtime_block >> "$template" fi +# K3s owns containerd; restart the matching K3s service to load the template. +host_log 'Restarting K3s to load the gVisor runtime configuration.' systemctl restart k3s || systemctl restart k3s-agent HOST_SCRIPT chmod 700 /host/tmp/quickstack-gvisor-install.sh nsenter --target 1 --mount --pid --uts --ipc --net /bin/bash /tmp/quickstack-gvisor-install.sh "$GVISOR_VERSION" } +# Reconciliation phases are persisted as Node annotations for the UI and retry diagnostics. acquire_lock holds_lock=true renew_lock & renew_pid=$! +trap mark_failed ERR +set_phase preflight cordon +set_phase installing +set_phase restarting install_host_runtime +set_phase verifying label_ready uncordon +set_phase ready +kill "$renew_pid" +renew_pid='' +release_lock +holds_lock=false +log "Installation completed successfully for gVisor release $GVISOR_VERSION." sleep infinity `; } From 667aa5723d63a4a4ec8abe8614d1ee3a995d3b00 Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Wed, 30 Sep 2026 06:01:30 +0000 Subject: [PATCH 4/7] feat: add runtimeClassName support to Agent model and related components --- .../migration.sql | 2 + prisma/schema.prisma | 1 + .../agent/[agentId]/agent-detail-client.tsx | 5 ++- .../agent/[agentId]/general/actions.ts | 9 ++++ .../general/agent-container-config-card.tsx | 44 ++++++++++++++++++- src/app/project/agent/[agentId]/page.tsx | 4 +- .../services/addons/gvisor-addon.service.ts | 3 +- .../agent-sandbox-template-builder.service.ts | 2 + src/server/services/agent.service.ts | 1 + .../services/agent.service.unit.spec.ts | 11 +++++ src/server/services/cluster.service.ts | 39 ++++++++++++++++ src/server/utils/cache-tag-generator.utils.ts | 4 ++ src/shared/model/agent-config.model.ts | 8 ++++ src/shared/model/generated-zod/agent.ts | 1 + 14 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 prisma/migrations/20260928120000_add_agent_runtime_class/migration.sql diff --git a/prisma/migrations/20260928120000_add_agent_runtime_class/migration.sql b/prisma/migrations/20260928120000_add_agent_runtime_class/migration.sql new file mode 100644 index 00000000..e91af4bb --- /dev/null +++ b/prisma/migrations/20260928120000_add_agent_runtime_class/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Agent" ADD COLUMN "runtimeClassName" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fdbe8df8..abd54f99 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -289,6 +289,7 @@ model Agent { containerCommand String? containerArgs String? workingDir String? + runtimeClassName String? warmPoolReplicas Int @default(0) deployFileBrowser Boolean @default(false) diff --git a/src/app/project/agent/[agentId]/agent-detail-client.tsx b/src/app/project/agent/[agentId]/agent-detail-client.tsx index 366b3f7b..0abb793a 100644 --- a/src/app/project/agent/[agentId]/agent-detail-client.tsx +++ b/src/app/project/agent/[agentId]/agent-detail-client.tsx @@ -62,11 +62,12 @@ const buildAgentTabParams = (tab: string, section: ConfigurationSection) => { return params; }; -export default function AgentDetailClient({ agent, role, templateInfo, storageClasses }: { +export default function AgentDetailClient({ agent, role, templateInfo, storageClasses, runtimeClasses }: { agent: AgentExtendedModel; templateInfo?: AgentSandboxTemplateInfo; role: RolePermissionEnum | null; storageClasses: string[]; + runtimeClasses: string[]; }) { const searchParams = useSearchParams(); const rawTabName = searchParams.get('tabName') || 'sandboxes'; @@ -127,7 +128,7 @@ export default function AgentDetailClient({ agent, role, templateInfo, storageCl case 'container': return (
    - +
    diff --git a/src/app/project/agent/[agentId]/general/actions.ts b/src/app/project/agent/[agentId]/general/actions.ts index 89e631ed..efa03019 100644 --- a/src/app/project/agent/[agentId]/general/actions.ts +++ b/src/app/project/agent/[agentId]/general/actions.ts @@ -39,6 +39,7 @@ import { ServiceException } from "@/shared/model/service.exception.model"; import agentGitSshKeyService from "@/server/services/agent-git-ssh-key.service"; import gitService from "@/server/services/git.service"; import { ContainerCommangArgsUtils } from "@/shared/utils/container-command-args.utils"; +import clusterService from "@/server/services/cluster.service"; import { z } from "zod"; import { HealthCheckModel, healthCheckZodModel } from "@/shared/model/health-check.model"; @@ -176,6 +177,14 @@ export const saveAgentRateLimits = async (prevState: any, inputData: AgentRateLi export const saveAgentContainerConfig = async (prevState: any, inputData: AgentContainerConfigModel, agentId: string) => saveFormAction(inputData, agentContainerConfigZodModel, async (validatedData) => { await isAuthorizedWriteForWorkload(agentId); + if (validatedData.runtimeClassName) { + const runtimeClasses = await clusterService.getRuntimeClasses(); + if (!runtimeClasses.includes(validatedData.runtimeClassName)) { + throw new FormValidationException('The selected RuntimeClass is no longer available in the cluster.', { + runtimeClassName: ['Select an available RuntimeClass.'], + }); + } + } await agentService.saveAgent({ ...validatedData, containerCommand: ContainerCommangArgsUtils.serializeContainerCommandItems(validatedData.containerCommand), diff --git a/src/app/project/agent/[agentId]/general/agent-container-config-card.tsx b/src/app/project/agent/[agentId]/general/agent-container-config-card.tsx index a7ce5fe7..bc92bed2 100644 --- a/src/app/project/agent/[agentId]/general/agent-container-config-card.tsx +++ b/src/app/project/agent/[agentId]/general/agent-container-config-card.tsx @@ -10,6 +10,7 @@ import { SubmitButton } from "@/components/custom/submit-button"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { TooltipProvider } from "@/components/ui/tooltip"; import { FormUtils } from "@/frontend/utils/form.utilts"; @@ -22,9 +23,12 @@ import { ServerActionResult } from "@/shared/model/server-action-error-return.mo import { saveAgentContainerConfig } from "./actions"; import { ContainerCommangArgsUtils } from "@/shared/utils/container-command-args.utils"; -export default function AgentContainerConfigCard({ agent, readonly }: { +const DEFAULT_RUNTIME_CLASS = '__quickstack_default__'; + +export default function AgentContainerConfigCard({ agent, readonly, runtimeClasses }: { agent: AgentExtendedModel; readonly: boolean; + runtimeClasses: string[]; }) { const inputValue = (value: unknown) => typeof value === 'string' || typeof value === 'number' ? value : ''; const form = useForm, unknown, z.output>({ @@ -35,6 +39,7 @@ export default function AgentContainerConfigCard({ agent, readonly }: { ? JSON.parse(agent.containerArgs).map((arg: string) => ({ value: arg })) : [], workingDir: agent.workingDir ?? '', + runtimeClassName: agent.runtimeClassName ?? '', warmPoolReplicas: agent.warmPoolReplicas ?? 0, deployFileBrowser: agent.deployFileBrowser, }, @@ -81,6 +86,43 @@ export default function AgentContainerConfigCard({ agent, readonly }: { />
+ ( + + Runtime Class + +

+ Selects the Kubernetes RuntimeClass for new agent sandboxes. +

+ +
+ )} + /> + agentService.getSandboxTemplateDeployInfo(agent.id)), clusterService.getStorageClasses(), + clusterService.getRuntimeClasses(), agentService.getAllByProjectId(agent.projectId), ]); const relevantAgents = agents.filter((projectAgent) => @@ -56,6 +57,7 @@ export default async function AgentDetailPage({ role={role} templateInfo={templateDeploymentDetails ?? undefined} storageClasses={storageClasses} + runtimeClasses={runtimeClasses} />
); diff --git a/src/server/services/addons/gvisor-addon.service.ts b/src/server/services/addons/gvisor-addon.service.ts index c0ce169c..1e573e45 100644 --- a/src/server/services/addons/gvisor-addon.service.ts +++ b/src/server/services/addons/gvisor-addon.service.ts @@ -18,7 +18,7 @@ class GvisorAddonService extends BaseClusterAddon implements ClusterAddon { readonly metadata: AddonMetadata = { id: 'gvisor', displayName: 'gVisor', - description: 'Provides the optional gVisor runtime on supported Ubuntu and Debian cluster nodes.', + description: 'Provides the optional gVisor runtime on supported cluster nodes.', documentationUrl: 'https://gvisor.dev/docs/user_guide/install/', managedNamespaces: [GvisorAddonService.NAMESPACE], canUninstall: false, updateWarning: { @@ -27,6 +27,7 @@ class GvisorAddonService extends BaseClusterAddon implements ClusterAddon { 'gVisor cannot be removed through QuickStack once installed.', 'Each node is cordoned and K3s is restarted serially. Workloads are not drained.', 'A single-server control plane is briefly unavailable while its K3s service restarts.', + 'You have to restart ALL servers in your cluster after successfull installation.', ] }, }; diff --git a/src/server/services/agent-sandbox-template-builder.service.ts b/src/server/services/agent-sandbox-template-builder.service.ts index 7a97b8a6..7101cd66 100644 --- a/src/server/services/agent-sandbox-template-builder.service.ts +++ b/src/server/services/agent-sandbox-template-builder.service.ts @@ -34,6 +34,7 @@ export type AgentSandboxTemplateConfig = { containerCommand?: string | null; containerArgs?: string | null; workingDir?: string | null; + runtimeClassName?: string | null; deployFileBrowser: boolean; healthChechHttpGetPath?: string | null; healthCheckHttpScheme?: string | null; @@ -167,6 +168,7 @@ class AgentSandboxTemplateBuilder { }, spec: { volumes, + ...(agent.runtimeClassName ? { runtimeClassName: agent.runtimeClassName } : {}), ...(deploymentInfo?.dockerPullSecretName ? { imagePullSecrets: [{ name: deploymentInfo.dockerPullSecretName }] } : {}), containers: [{ name: 'agent', diff --git a/src/server/services/agent.service.ts b/src/server/services/agent.service.ts index a1489d34..4e44b7f3 100644 --- a/src/server/services/agent.service.ts +++ b/src/server/services/agent.service.ts @@ -410,6 +410,7 @@ class AgentService { containerCommand: agent.containerCommand ?? null, containerArgs: agent.containerArgs ?? null, workingDir: agent.workingDir ?? null, + runtimeClassName: agent.runtimeClassName ?? null, deployFileBrowser: agent.deployFileBrowser, healthChechHttpGetPath: agent.healthChechHttpGetPath ?? null, healthCheckHttpScheme: agent.healthCheckHttpScheme ?? null, diff --git a/src/server/services/agent.service.unit.spec.ts b/src/server/services/agent.service.unit.spec.ts index 154286e5..cf4e5c1b 100644 --- a/src/server/services/agent.service.unit.spec.ts +++ b/src/server/services/agent.service.unit.spec.ts @@ -440,6 +440,17 @@ describe('agent.service', () => { expect(resource.spec.podTemplate.spec.containers[0].name).toBe('agent'); }); + it('sets the selected RuntimeClass on the SandboxTemplate pod', async () => { + vi.mocked(dataAccess.client.agent.findFirstOrThrow).mockResolvedValue(mockAgentWithRelations('agent-1', 'Agent One', 'proj-test-agent', { + runtimeClassName: 'gvisor', + }) as any); + + await agentService.deploy('agent-1'); + + const { resource } = getSandboxTemplateResourceFromTemplateCall(); + expect(resource.spec.podTemplate.spec.runtimeClassName).toBe('gvisor'); + }); + it('adds HTTP startup and readiness probes to the agent container', async () => { vi.mocked(dataAccess.client.agent.findFirstOrThrow).mockResolvedValue(mockAgentWithRelations('agent-1', 'Agent One', 'proj-test-agent', { healthChechHttpGetPath: '/healthz', diff --git a/src/server/services/cluster.service.ts b/src/server/services/cluster.service.ts index af81d8f6..bfaf5b02 100644 --- a/src/server/services/cluster.service.ts +++ b/src/server/services/cluster.service.ts @@ -65,6 +65,45 @@ class ClusterService { })(); } + async getRuntimeClasses(): Promise { + + const allowedRuntimes = ['gvisor', 'kata']; + + const runtimeClasses = await k3s.customObjects.listClusterCustomObject({ + group: 'node.k8s.io', + version: 'v1', + plural: 'runtimeclasses', + }) as { items?: { metadata?: { name?: string } }[] }; + + const runtimeNames = (runtimeClasses.items ?? []) + .map((runtimeClass) => runtimeClass.metadata?.name) + .filter((name): name is string => !!name) + .sort((a, b) => a.localeCompare(b)); + + return runtimeNames.filter(rn => allowedRuntimes.includes(rn.toLocaleLowerCase())); + /*return await unstable_cache(async () => { + + const allowedRuntimes = ['gVisor', 'kata']; + + const runtimeClasses = await k3s.customObjects.listClusterCustomObject({ + group: 'node.k8s.io', + version: 'v1', + plural: 'runtimeclasses', + }) as { items?: { metadata?: { name?: string } }[] }; + + const runtimeNames = (runtimeClasses.items ?? []) + .map((runtimeClass) => runtimeClass.metadata?.name) + .filter((name): name is string => !!name) + .sort((a, b) => a.localeCompare(b)); + + return runtimeNames.filter(rn => allowedRuntimes.includes(rn)); + }, + [Tags.runtimeClasses()], { + revalidate: 60, + tags: [Tags.runtimeClasses()] + })();*/ + } + async setNodeStatus(nodeName: string, schedulable: boolean) { try { await k3s.core.patchNode( diff --git a/src/server/utils/cache-tag-generator.utils.ts b/src/server/utils/cache-tag-generator.utils.ts index 416da485..c6fa28e7 100644 --- a/src/server/utils/cache-tag-generator.utils.ts +++ b/src/server/utils/cache-tag-generator.utils.ts @@ -68,6 +68,10 @@ export class Tags { return `storage-classes`; } + static runtimeClasses() { + return `runtime-classes`; + } + static quickStackVersionInfo() { return `quickstack-version-info`; } diff --git a/src/shared/model/agent-config.model.ts b/src/shared/model/agent-config.model.ts index e423937c..4ffca5f5 100644 --- a/src/shared/model/agent-config.model.ts +++ b/src/shared/model/agent-config.model.ts @@ -53,6 +53,13 @@ export const agentConfigZodModel = AgentModel.extend(z.object({ } return val; }, z.string().startsWith('/', 'Working directory must be an absolute path.').nullish()), + runtimeClassName: z.preprocess((val) => { + if (typeof val === 'string') { + const trimmed = val.trim(); + return trimmed || null; + } + return val; + }, z.string().min(1).nullish()), warmPoolReplicas: z.preprocess((val) => { if (val === null || val === undefined || val === '') { return 0; @@ -158,6 +165,7 @@ export const agentContainerConfigZodModel = agentConfigZodModel.pick({ containerCommand: true, containerArgs: true, workingDir: true, + runtimeClassName: true, warmPoolReplicas: true, deployFileBrowser: true, }); diff --git a/src/shared/model/generated-zod/agent.ts b/src/shared/model/generated-zod/agent.ts index 1b18cd69..1ac393d9 100644 --- a/src/shared/model/generated-zod/agent.ts +++ b/src/shared/model/generated-zod/agent.ts @@ -27,6 +27,7 @@ export const AgentModel = z.object({ containerCommand: z.string().nullish(), containerArgs: z.string().nullish(), workingDir: z.string().nullish(), + runtimeClassName: z.string().nullish(), warmPoolReplicas: z.number().int(), deployFileBrowser: z.boolean(), healthChechHttpGetPath: z.string().nullish(), From 5d7f1c41991568544ba33a79b4061a2d5d93feb5 Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Wed, 30 Sep 2026 07:31:17 +0000 Subject: [PATCH 5/7] feat: implement ClusterAddonDetailsDialog and update ClusterAddonUpdateInfo --- .../updates/cluster-addon-details-dialog.tsx | 188 ++++++++++++++++++ .../updates/cluster-addon-update-info.tsx | 187 ++++++++--------- .../settings/platform/updates/update-info.tsx | 6 +- 3 files changed, 273 insertions(+), 108 deletions(-) create mode 100644 src/app/settings/platform/updates/cluster-addon-details-dialog.tsx diff --git a/src/app/settings/platform/updates/cluster-addon-details-dialog.tsx b/src/app/settings/platform/updates/cluster-addon-details-dialog.tsx new file mode 100644 index 00000000..8701a3db --- /dev/null +++ b/src/app/settings/platform/updates/cluster-addon-details-dialog.tsx @@ -0,0 +1,188 @@ +'use client'; + +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { useDialogContext } from '@/frontend/states/dialog-context'; +import { useConfirmDialog } from '@/frontend/states/zustand.states'; +import { Toast } from '@/frontend/utils/toast.utils'; +import { + CheckCircle2, + CircleAlert, + Download, + ExternalLink, + LoaderCircle, + MoreHorizontal, + RefreshCw, + Trash2, +} from 'lucide-react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { + installClusterAddon, + uninstallClusterAddon, + updateClusterAddon, +} from '../../actions'; +import type { ClusterAddonUpdateInfo } from './cluster-addon-update-info'; + +const statusLabel: Record = { + notInstalled: 'Not installed', + installing: 'Installing', + ready: 'Installed', + updating: 'Updating', + uninstalling: 'Uninstalling', + failed: 'Error', +}; + +export function ClusterAddonDetailsDialog({ addon }: { addon: ClusterAddonUpdateInfo }) { + const { closeDialog } = useDialogContext(); + const { openConfirmDialog } = useConfirmDialog(); + const router = useRouter(); + const [loading, setLoading] = useState(false); + + const busy = ['installing', 'updating', 'uninstalling'].includes(addon.status); + const canInstall = addon.status === 'notInstalled' + || (addon.status === 'failed' && !addon.installedVersion); + const canUpdate = addon.status === 'ready' && !!addon.availableVersion; + const canRemove = addon.canUninstall + && (addon.status === 'ready' || addon.status === 'failed') + && !!addon.installedVersion; + + const runOperation = async (operation: 'install' | 'update' | 'remove') => { + const version = operation === 'update' ? ` to ${addon.availableVersion}` : ''; + const actionLabel = operation === 'remove' + ? 'Remove' + : operation === 'install' + ? 'Install' + : 'Update'; + + const confirmed = await openConfirmDialog({ + title: `${actionLabel} ${addon.displayName}`, + description: operation === 'remove' + ? `Do you want to remove ${addon.displayName}? QuickStack will delete the add-on's Kubernetes resources from this cluster. This cannot be undone.` + : addon.updateWarning + ?
+

{`Do you want to ${operation} ${addon.displayName}${version}? QuickStack will apply the add-on's Kubernetes resources to this cluster.`}

+
+

+ {addon.updateWarning.title} +

+
    + {addon.updateWarning.items.map((item) =>
  • {item}
  • )} +
+
+
+ : `Do you want to ${operation} ${addon.displayName}${version}? QuickStack will apply the add-on's Kubernetes resources to this cluster.`, + okButton: actionLabel, + }); + + if (!confirmed) return; + + try { + setLoading(true); + await Toast.fromAction( + () => operation === 'install' + ? installClusterAddon(addon.id) + : operation === 'update' + ? updateClusterAddon(addon.id) + : uninstallClusterAddon(addon.id), + undefined, + `${operation === 'install' ? 'Installing' : operation === 'update' ? 'Updating' : 'Removing'} ${addon.displayName}...`, + ); + router.refresh(); + closeDialog(); + } finally { + setLoading(false); + } + }; + + return ( + <> + +
+
+ {addon.displayName} + {addon.description} +
+ + {busy && } + {statusLabel[addon.status]} + +
+
+
+
+

Installed version

+

{addon.installedVersion ?? '—'}

+
+ {addon.message && ( + + + {addon.message} + + )} + {canUpdate && ( +
+
+
+ +

Next Version Available

+
+

{addon.availableVersion}

+ +
+
+ )} +
+ + +
+ {canInstall && ( + + )} + {addon.status === 'ready' && !addon.availableVersion && ( + + + Up to date + + )} + {canRemove && ( + + + + Add-on actions + } /> + + runOperation('remove')}> + + Remove + + + + )} +
+
+ + ); +} diff --git a/src/app/settings/platform/updates/cluster-addon-update-info.tsx b/src/app/settings/platform/updates/cluster-addon-update-info.tsx index d981763f..450717e3 100644 --- a/src/app/settings/platform/updates/cluster-addon-update-info.tsx +++ b/src/app/settings/platform/updates/cluster-addon-update-info.tsx @@ -1,17 +1,18 @@ 'use client'; -import { Alert, AlertDescription } from '@/components/ui/alert'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; -import { useConfirmDialog } from '@/frontend/states/zustand.states'; -import { Toast } from '@/frontend/utils/toast.utils'; +import { Card, CardContent } from '@/components/ui/card'; +import { useDialog } from '@/frontend/states/zustand.states'; import { AddonLifecycleStatus } from '@/shared/model/cluster-addon.model'; -import { CheckCircle2, CircleAlert, Download, ExternalLink, LoaderCircle, MoreHorizontal, RefreshCw, Trash2 } from 'lucide-react'; -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; -import { useState } from 'react'; -import { installClusterAddon, uninstallClusterAddon, updateClusterAddon } from '../../actions'; +import { + CheckCircle2, + CircleAlert, + Download, + LoaderCircle, + Package, + RefreshCw, +} from 'lucide-react'; +import { ClusterAddonDetailsDialog } from './cluster-addon-details-dialog'; +import { cn } from 'cn'; export type ClusterAddonUpdateInfo = { id: string; @@ -29,107 +30,81 @@ export type ClusterAddonUpdateInfo = { message?: string; }; -const statusLabel: Record = { - notInstalled: 'Not installed', installing: 'Installing', ready: 'Installed', - updating: 'Updating', uninstalling: 'Uninstalling', failed: 'Error', +export const statusLabel: Record = { + notInstalled: 'Not installed', + installing: 'Installing', + ready: 'Installed', + updating: 'Updating', + uninstalling: 'Uninstalling', + failed: 'Error', }; export default function ClusterAddonUpdateInfo({ addon }: { addon: ClusterAddonUpdateInfo }) { - const { openConfirmDialog } = useConfirmDialog(); - const router = useRouter(); - const [loading, setLoading] = useState(false); + const { openDialog } = useDialog(); const busy = ['installing', 'updating', 'uninstalling'].includes(addon.status); - const canInstall = addon.status === 'notInstalled' || (addon.status === 'failed' && !addon.installedVersion); - const canUpdate = addon.status === 'ready' && !!addon.availableVersion; - const canRemove = addon.canUninstall && (addon.status === 'ready' || addon.status === 'failed') && !!addon.installedVersion; + const hasAvailableUpdate = addon.status === 'ready' && !!addon.availableVersion; - const runOperation = async (operation: 'install' | 'update' | 'remove') => { - const version = operation === 'update' ? ` to ${addon.availableVersion}` : ''; - const actionLabel = operation === 'remove' ? 'Remove' : operation === 'install' ? 'Install' : 'Update'; - const confirmed = await openConfirmDialog({ - title: `${actionLabel} ${addon.displayName}`, - description: operation === 'remove' - ? `Do you want to remove ${addon.displayName}? QuickStack will delete the add-on's Kubernetes resources from this cluster. This cannot be undone.` - : addon.updateWarning - ?
-

{`Do you want to ${operation} ${addon.displayName}${version}? QuickStack will apply the add-on's Kubernetes resources to this cluster.`}

-
-

{addon.updateWarning.title}

-
    - {addon.updateWarning.items.map((item) =>
  • {item}
  • )} -
-
-
- : `Do you want to ${operation} ${addon.displayName}${version}? QuickStack will apply the add-on's Kubernetes resources to this cluster.`, - okButton: actionLabel, - }); - if (!confirmed) { - return; - } + const statusIcon = hasAvailableUpdate + ? + : busy + ? + : addon.status === 'failed' + ? + : addon.status === 'ready' + ? + : ; - try { - setLoading(true); - await Toast.fromAction( - () => operation === 'install' ? installClusterAddon(addon.id) : operation === 'update' ? updateClusterAddon(addon.id) : uninstallClusterAddon(addon.id), - undefined, - `${operation === 'install' ? 'Installing' : operation === 'update' ? 'Updating' : 'Removing'} ${addon.displayName}...`, - ); - router.refresh(); - } finally { - setLoading(false); - } - }; + const updateLabel = addon.availableVersion + ? `Update ${addon.availableVersion} available` + : addon.status === 'ready' + ? 'Up to date' + : 'No update available'; - return - -
-
- {addon.displayName} - {addon.description} -
- - {busy && } - {statusLabel[addon.status]} - -
-
- -
-

Installed version

-

{addon.installedVersion ?? '—'}

-
- {addon.message && {addon.message}} - {canUpdate &&
-
-
- -

Next Version Available

+ return ( + + -
-
} -
-
- {canInstall && } - {addon.status === 'ready' && !addon.availableVersion && Up to date} - -
- {canRemove && - - - Add-on actions - } /> - - runOperation('remove')}> - Remove - - - } -
- - ; + + + + ); } diff --git a/src/app/settings/platform/updates/update-info.tsx b/src/app/settings/platform/updates/update-info.tsx index 601bd01f..77d5cc17 100644 --- a/src/app/settings/platform/updates/update-info.tsx +++ b/src/app/settings/platform/updates/update-info.tsx @@ -49,7 +49,7 @@ export default async function UpdateInfoPage() { } const addons: ClusterAddonUpdateInfoModel[] = await Promise.all(clusterAddonRegistryService.getAll() - .filter((addon) => useCanaryChannel || addon.metadata.id !== 'agent-sandbox') + .filter((addon) => useCanaryChannel || !['agent-sandbox', 'gvisor'].includes(addon.metadata.id)) .map(async (addon) => { try { const status = await addon.getStatus(); @@ -83,7 +83,9 @@ export default async function UpdateInfoPage() {

Install and keep optional cluster components up to date.

- {addons.map((addon) => )} +
+ {addons.map((addon) => )} +
; } From a6102223b65bfc6d979886604e3f233c5b626b7e Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Wed, 30 Sep 2026 07:47:30 +0000 Subject: [PATCH 6/7] feat: enhance AppSourceWizardDialog with loading state and improve sidebar settings navigation --- .../app-source-wizard-dialog.tsx | 47 ++++-- .../overview/deployments-grid-view.tsx | 2 +- src/app/sidebar-client.tsx | 158 +++++++++++------- 3 files changed, 132 insertions(+), 75 deletions(-) diff --git a/src/app/project/app/[appId]/general/app-source-wizard/app-source-wizard-dialog.tsx b/src/app/project/app/[appId]/general/app-source-wizard/app-source-wizard-dialog.tsx index cd80e95e..d76875e8 100644 --- a/src/app/project/app/[appId]/general/app-source-wizard/app-source-wizard-dialog.tsx +++ b/src/app/project/app/[appId]/general/app-source-wizard/app-source-wizard-dialog.tsx @@ -27,11 +27,11 @@ import { SourceTypeStep } from "./source-type-step"; import { defaultDockerfilePath, sourceTypeLabels, SourceType, StepId } from "./types"; import { WizardProgress } from "./wizard-progress"; import { deploy } from "../../actions"; +import LoadingSpinner from "@/components/ui/loading-spinner"; -export function AppSourceWizardDialog({ app, gitSshPublicKey, redirectOnDeploy = true }: { +export function AppSourceWizardDialog({ app, gitSshPublicKey }: { app: AppExtendedModel; gitSshPublicKey?: string; - redirectOnDeploy?: boolean; }) { const router = useRouter(); const { closeDialog } = useDialogContext(); @@ -48,6 +48,7 @@ export function AppSourceWizardDialog({ app, gitSshPublicKey, redirectOnDeploy = const [dockerfileDetectionResult, setDockerfileDetectionResult] = useState<'detected' | 'not-found' | null>(null); const [showGitToken, setShowGitToken] = useState(false); const [showRegistryPassword, setShowRegistryPassword] = useState(false); + const [showButtonLoading, setShowButtonLoading] = useState(false); const canUseGitSources = app.appType === 'APP'; const isGitSource = formData.sourceType === 'GIT' || formData.sourceType === 'GIT_SSH'; @@ -243,19 +244,21 @@ export function AppSourceWizardDialog({ app, gitSshPublicKey, redirectOnDeploy = }; const save = async (deployAfterSave: boolean) => { - await Toast.fromAction(() => saveGeneralAppSourceInfo(null, formData, app.id), 'Source saved', 'Saving source...'); - if (deployAfterSave) { - await Toast.fromAction(() => deploy(app.id, true), 'Deployment started', 'Staring deployment...'); + try { + setShowButtonLoading(true); + await Toast.fromAction(() => saveGeneralAppSourceInfo(null, formData, app.id), 'Source saved', 'Saving source...'); + if (deployAfterSave) { + await Toast.fromAction(() => deploy(app.id, true), 'Deployment started', 'Staring deployment...'); + closeDialog(true); + router.refresh(); + return; + } + closeDialog(true); router.refresh(); - if (redirectOnDeploy) { - router.push(`/project/app/${app.id}?tabName=overview`); - } - return; + } finally { + setShowButtonLoading(false); } - - closeDialog(true); - router.refresh(); }; const nextDisabled = getNextDisabled(step, formData, publicKey, isLoadingBranches, isEnsuringKey, isDetectingDockerfile); @@ -373,13 +376,21 @@ export function AppSourceWizardDialog({ app, gitSshPublicKey, redirectOnDeploy =
{step === 'summary' ? ( <> - - ) : step === 'branch' || step === 'build-method' || step === 'framework-selection' ? ( diff --git a/src/app/project/app/[appId]/overview/deployments-grid-view.tsx b/src/app/project/app/[appId]/overview/deployments-grid-view.tsx index 31ebdfc6..41f38caf 100644 --- a/src/app/project/app/[appId]/overview/deployments-grid-view.tsx +++ b/src/app/project/app/[appId]/overview/deployments-grid-view.tsx @@ -40,7 +40,7 @@ export function DeploymentsGridView({
diff --git a/src/app/sidebar-client.tsx b/src/app/sidebar-client.tsx index 81b0bfbc..be88b768 100644 --- a/src/app/sidebar-client.tsx +++ b/src/app/sidebar-client.tsx @@ -1,6 +1,8 @@ 'use client' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +import { Button } from "@/components/ui/button" +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet" import { Sidebar, SidebarContent, @@ -30,7 +32,75 @@ import { useEffect, useState } from "react" import QuickStackLogo from "@/components/custom/quickstack-logo" import { UserGroupUtils } from "@/shared/utils/role.utils" import { QuickStackReleaseInfo } from "@/server/adapter/qs-versioninfo.adapter" -import { developerSettingsNavigation, serverSettingsHref, serverSettingsNavigation, settingsNavigation } from "@/shared/utils/settings-navigation" +import { developerSettingsNavigation, serverSettingsHref, serverSettingsNavigation, settingsNavigation, type SettingsNavigationGroup } from "@/shared/utils/settings-navigation" + +function SettingsNavigationContent({ + path, + visibleSettingsGroups, + isAdmin, + newVersionInfo, + onNavigate, +}: { + path: string + visibleSettingsGroups: SettingsNavigationGroup[] + isAdmin: boolean + newVersionInfo?: QuickStackReleaseInfo + onNavigate?: () => void +}) { + return + {visibleSettingsGroups.map((group) => + {group.title} + + + {group.items.map((item) => { + const Icon = item.icon + return + + + {item.title} + } /> + + })} + + + )} + + {isAdmin && + Platform + + + {serverSettingsNavigation.map((item) => { + const Icon = item.icon + const href = serverSettingsHref(item.tab) + return + + + {item.title} + {item.tab === "updates" && newVersionInfo && } + } /> + + })} + + + } + {isAdmin && + Developer + + + {developerSettingsNavigation.map((item) => { + const Icon = item.icon + return + + + {item.title} + } /> + + })} + + + } + +} export function SidebarCient({ projects, @@ -49,6 +119,7 @@ export function SidebarCient({ const [currentlySelectedProjectId, setCurrentlySelectedProjectId] = useState(null); const [currentlySelectedAppId, setCurrentlySelectedAppId] = useState(null); const [currentlySelectedAgentId, setCurrentlySelectedAgentId] = useState(null); + const [settingsNavigationOpen, setSettingsNavigationOpen] = useState(false) const isAdmin = UserGroupUtils.isAdmin(session) const visibleSettingsGroups = settingsNavigation.map((group) => ({ @@ -286,65 +357,40 @@ export function SidebarCient({ - {showSettingsNavigation && + } ) } From 34988666a9e015d679bd898c0b94f33bb0cdf36c Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Wed, 30 Sep 2026 15:44:37 +0000 Subject: [PATCH 7/7] fix: remove redirectOnDeploy prop from AppSourceWizardDialog --- src/app/project/app/[appId]/general/app-source.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/project/app/[appId]/general/app-source.tsx b/src/app/project/app/[appId]/general/app-source.tsx index a86de24f..f0b6a0d7 100644 --- a/src/app/project/app/[appId]/general/app-source.tsx +++ b/src/app/project/app/[appId]/general/app-source.tsx @@ -30,7 +30,7 @@ export default function GeneralAppSource({ const openSourceWizard = () => { openDialog( - , + , { width: 'calc(100vw - 2rem)', maxWidth: '760px',