From b74f30d9a0bfa9860dcd19c5833b9cd8bb597282 Mon Sep 17 00:00:00 2001 From: LeeKyungBeom Date: Mon, 14 Sep 2026 15:56:56 +0900 Subject: [PATCH 1/3] fix: handle Jupyter PVC and StorageClass requirements for NHN and Alibaba --- applicationFE/src/api/softwareCatalog.ts | 5 + .../applicationInstallationForm.vue | 122 ++++++++++++++---- .../exception/GlobalExceptionHandler.java | 6 + .../controller/ApplicationController.java | 15 +++ .../application/dto/K8sStorageClassDTO.java | 2 + .../service/JupyterStorageValidation.java | 45 +++++++ .../kubernetes/service/K8sJupyterService.java | 42 ++++-- .../KubernetesStorageClassService.java | 9 +- .../service/NhnStorageClassService.java | 65 ++++++++++ .../service/StorageOperationException.java | 25 ++++ ...ApplicationIngressCheckControllerTest.java | 2 +- ...ApplicationVmDeploymentControllerTest.java | 2 +- .../service/JupyterStorageValidationTest.java | 52 ++++++++ .../service/K8sJupyterServiceTest.java | 16 +++ .../service/NhnStorageClassServiceTest.java | 86 ++++++++++++ 15 files changed, 457 insertions(+), 37 deletions(-) create mode 100644 src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/JupyterStorageValidation.java create mode 100644 src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/NhnStorageClassService.java create mode 100644 src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/StorageOperationException.java create mode 100644 src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/JupyterStorageValidationTest.java create mode 100644 src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/NhnStorageClassServiceTest.java diff --git a/applicationFE/src/api/softwareCatalog.ts b/applicationFE/src/api/softwareCatalog.ts index c1f7a83..783a8bf 100644 --- a/applicationFE/src/api/softwareCatalog.ts +++ b/applicationFE/src/api/softwareCatalog.ts @@ -293,3 +293,8 @@ export function getPolicyRecommendation(deploymentId: number) { export function getReasonList(operation: string) { return request.get(`/catalog/selectbox/options?type=${operation}`) } + +export const getNhnStorageCapability = (params: { namespace: string, clusterName: string }) => + request.get('/applications/k8s/storage-classes/nhn-capability', { params }) +export const createNhnStorageClass = (params: { namespace: string, clusterName: string }, body: { name: string, diskType: string }) => + request.post('/applications/k8s/storage-classes/nhn', body, { params }) diff --git a/applicationFE/src/views/softwareCatalog/components/applicationInstallationForm.vue b/applicationFE/src/views/softwareCatalog/components/applicationInstallationForm.vue index 08121f3..6b2f292 100644 --- a/applicationFE/src/views/softwareCatalog/components/applicationInstallationForm.vue +++ b/applicationFE/src/views/softwareCatalog/components/applicationInstallationForm.vue @@ -516,6 +516,39 @@

{{ storageClassErrorMessage }}

+ + +
+
NHN block storage setup
+

+ {{ storageCapability?.message || (storageSetupError ? 'NHN storage setup is unavailable.' : 'Checking Cinder CSI and AM permissions...') }} +

+
+ 1. Cinder CSI Plugin add-on + + {{ storageCapability.driverReady ? 'Ready' : 'Required' }} + +

+ Install cinder-csi-plugin from the selected cluster's NHN NKS Add-ons page, wait until it is active, and refresh StorageClasses here. +

+
+
+
2. Create StorageClass
+ + + + +

Volumes are created when a Pod uses the claim. Retain policy keeps disks after claim deletion; remove unused disks separately.

+ +
+
+
+ + +

Minimum {{ selectedStorageMinimum }} GiB for the known disk limits. Access mode: ReadWriteOnce. Provider quotas and disk availability are checked during provisioning.

+
@@ -881,7 +914,7 @@ import { Modal } from 'bootstrap'; // @ts-ignore import _ from 'lodash'; import { getNsInfo, getMciInfo, getVmInfo, getClusterInfo } from '@/api/tumblebug' -import { getK8sStorageClasses, getRegisteredObjectStorages, getSoftwareCatalogList, k8sSpecCheck, k8sIngressCheck, objectStorageSmokeCheck, runK8SInstall, runAction, runVmInstall, vmSpecCheck } from '@/api/softwareCatalog' +import { getNhnStorageCapability, createNhnStorageClass, getK8sStorageClasses, getRegisteredObjectStorages, getSoftwareCatalogList, k8sSpecCheck, k8sIngressCheck, objectStorageSmokeCheck, runK8SInstall, runAction, runVmInstall, vmSpecCheck } from '@/api/softwareCatalog' import { type SoftwareCatalog } from '@/views/type/type' import { useUserStore } from '@/stores/user' import { isVmClusteringCatalog } from '@/utils/vmClustering' @@ -1038,6 +1071,18 @@ const storageClassList = ref([] as any[]) const selectedStorageClass = ref("" as string) const storageClassLoading = ref(false as boolean) const storageClassLoadError = ref(false as boolean) +const storageClassFailure = ref('') +const storageCapability = ref(null) +const storageSetupError = ref('') +const storageCreating = ref(false) +const newStorageClassName = ref('mcmp-nhn-notebooks') +const newStorageDiskType = ref('General HDD') +const notebookStorageGi = ref(10) +let storageRequestSequence = 0 +const storageErrorDetail = (error: any) => String(error?.response?.data?.detail || error?.message || 'Storage API request failed. Check cluster connectivity and permissions.') +const selectedStorageMinimum = computed(() => Number(storageClassList.value.find(s => s.name === selectedStorageClass.value)?.minimumSizeGi || 1)) +watch(selectedStorageClass, () => { notebookStorageGi.value = Math.max(notebookStorageGi.value, selectedStorageMinimum.value) }) +watch(notebookStorageGi, () => { onChangeForm() }) const clusterList = ref([] as any) const selectCluster = ref("" as string) @@ -1251,6 +1296,7 @@ onMounted(async () => { const setInit = async () => { preparationEpoch++ deploymentCompleted.value = false + storageRequestSequence++ const loadSequence = ++resourceLoadSequence clearTargetResources() selectInfra.value = isTargetLocked.value ? normalizedTargetType.value : "VM" @@ -1283,6 +1329,11 @@ const setInit = async () => { selectedStorageClass.value = "" storageClassLoading.value = false storageClassLoadError.value = false + storageClassFailure.value = '' + storageCapability.value = null + storageSetupError.value = '' + storageCreating.value = false + notebookStorageGi.value = 10 selectedResourceType.value = "GENERAL_PURPOSE" inputServicePort.value = "" vmNetworkExposureMode.value = 'PRIVATE' @@ -1565,34 +1616,52 @@ const fetchRegisteredObjectStorages = async () => { } } -const fetchStorageClasses = async () => { +const fetchStorageClasses = async (preferred = '') => { + const sequence = ++storageRequestSequence storageClassList.value = [] - selectedStorageClass.value = "" + selectedStorageClass.value = '' storageClassLoadError.value = false - - if ( - selectInfra.value !== 'K8S' - || !supportsStorageClassConfig.value - || _.isEmpty(selectNsId.value) - || _.isEmpty(selectCluster.value) - ) { - return - } - + storageClassFailure.value = '' + storageCapability.value = null + storageSetupError.value = '' + storageClassLoading.value = false + if (selectInfra.value !== 'K8S' || !supportsStorageClassConfig.value || !selectNsId.value || !selectCluster.value) return + const target = { namespace: selectNsId.value, clusterName: selectCluster.value } storageClassLoading.value = true try { - const { data } = await getK8sStorageClasses({ - namespace: selectNsId.value, - clusterName: selectCluster.value - }) - storageClassList.value = Array.isArray(data) ? data : [] - selectedStorageClass.value = getInitialStorageClass(storageClassList.value) + const { data } = await getK8sStorageClasses(target) + if (sequence !== storageRequestSequence) return + if (!Array.isArray(data)) throw new Error('Invalid StorageClass API response. Retry the lookup.') + storageClassList.value = data + selectedStorageClass.value = data.some(s => s.name === preferred) ? preferred : getInitialStorageClass(data) } catch (error) { + if (sequence !== storageRequestSequence) return storageClassLoadError.value = true - selectedStorageClass.value = "" + storageClassFailure.value = storageErrorDetail(error) } finally { - storageClassLoading.value = false + if (sequence === storageRequestSequence) storageClassLoading.value = false } + if (sequence !== storageRequestSequence || storageClassLoadError.value || storageClassList.value.length || !isNhnCluster.value) return + try { + const { data } = await getNhnStorageCapability(target) + if (sequence === storageRequestSequence) storageCapability.value = data + } catch (error) { + if (sequence === storageRequestSequence) storageSetupError.value = storageErrorDetail(error) + } +} + +const createNotebookStorageClass = async () => { + if (storageCreating.value || !storageCapability.value?.canCreate) return + const sequence = storageRequestSequence + const target = { namespace: selectNsId.value, clusterName: selectCluster.value } + storageCreating.value = true + storageSetupError.value = '' + try { + const { data } = await createNhnStorageClass(target, { name: newStorageClassName.value, diskType: newStorageDiskType.value }) + if (sequence === storageRequestSequence) await fetchStorageClasses(data.name) + } catch (error) { + if (sequence === storageRequestSequence) storageSetupError.value = storageErrorDetail(error) + } finally { storageCreating.value = false } } const getInitialStorageClass = (items: any[]) => { @@ -2057,10 +2126,11 @@ watch(canSelectClustering, (allowed) => { const selectedClusterProvider = computed(() => { const cluster = clusterList.value.find((item: any) => item.id === selectCluster.value || item.name === selectCluster.value) - return cluster?.connectionConfig?.providerName || cluster?.connectionName || '' + return String(cluster?.connectionConfig?.providerName || '').trim().toLowerCase() }) const isIbmCluster = computed(() => /^(ibm|ibmcloud|ibm-cloud|ibm-vpc|ibmvpc)(-|$)/i.test(selectedClusterProvider.value)) +const isNhnCluster = computed(() => selectedClusterProvider.value === 'nhn') const effectiveIngressClass = computed(() => isIbmCluster.value ? 'public-iks-k8s-nginx' : 'nginx') const selectedVmProvider = computed(() => { @@ -2119,9 +2189,11 @@ const storageClassPlaceholder = computed(() => { const storageClassErrorMessage = computed(() => { if (!storageClassRequired.value) return '' if (storageClassLoading.value) return 'StorageClass list is loading.' - if (storageClassLoadError.value) return 'StorageClass list could not be loaded.' + if (storageClassLoadError.value) return storageClassFailure.value || 'StorageClass list could not be loaded.' if (storageClassList.value.length === 0) return 'This application requires a StorageClass, but none was found.' if (_.isEmpty(selectedStorageClass.value)) return 'This application requires a StorageClass.' + if (isJupyterObjectStorageCatalog.value && (!Number.isInteger(notebookStorageGi.value) || notebookStorageGi.value < selectedStorageMinimum.value)) + return 'Enter a whole-number notebook capacity of at least ' + selectedStorageMinimum.value + ' GiB.' return '' }) @@ -2248,6 +2320,10 @@ function buildK8sAdditionalConfig() { const config = {} as Record if (storageClassRequired.value && !_.isEmpty(selectedStorageClass.value)) { config.storageClass = selectedStorageClass.value + if (isJupyterObjectStorageCatalog.value) { + config.storageSize = notebookStorageGi.value + 'Gi' + config.storageAccessMode = 'ReadWriteOnce' + } } if (showObjectStorageConfig.value && objectStorageData.value.enabled) { config.objectStorage = buildObjectStorageConfig() diff --git a/src/main/java/kr/co/mcmp/exception/GlobalExceptionHandler.java b/src/main/java/kr/co/mcmp/exception/GlobalExceptionHandler.java index f93456b..0d1decc 100644 --- a/src/main/java/kr/co/mcmp/exception/GlobalExceptionHandler.java +++ b/src/main/java/kr/co/mcmp/exception/GlobalExceptionHandler.java @@ -16,6 +16,12 @@ @Slf4j @RestControllerAdvice public class GlobalExceptionHandler { + @ExceptionHandler(kr.co.mcmp.softwarecatalog.kubernetes.service.StorageOperationException.class) + public ResponseEntity> handleStorageOperation( + kr.co.mcmp.softwarecatalog.kubernetes.service.StorageOperationException e) { + return ResponseEntity.status(e.getStatus()).body(new ResponseWrapper<>(e.getStatus(), e.getCode(), e.getMessage())); + } + @ExceptionHandler(ProjectScopeException.class) public ResponseEntity> handleProjectScopeException(ProjectScopeException e) { diff --git a/src/main/java/kr/co/mcmp/softwarecatalog/application/controller/ApplicationController.java b/src/main/java/kr/co/mcmp/softwarecatalog/application/controller/ApplicationController.java index e544248..3bb8af3 100644 --- a/src/main/java/kr/co/mcmp/softwarecatalog/application/controller/ApplicationController.java +++ b/src/main/java/kr/co/mcmp/softwarecatalog/application/controller/ApplicationController.java @@ -38,6 +38,7 @@ import kr.co.mcmp.softwarecatalog.application.constants.DeploymentType; import kr.co.mcmp.softwarecatalog.kubernetes.service.KubernetesStorageClassService; import kr.co.mcmp.softwarecatalog.kubernetes.service.KubernetesIngressPreflightService; +import kr.co.mcmp.softwarecatalog.kubernetes.service.NhnStorageClassService; import kr.co.mcmp.security.project.ProjectScopeAuthorizationService; import org.springframework.web.bind.annotation.PathVariable; import lombok.RequiredArgsConstructor; @@ -55,6 +56,7 @@ public class ApplicationController { private final ObjectStorageSmokeTestService objectStorageSmokeTestService; private final ObjectStorageRegistryService objectStorageRegistryService; private final KubernetesStorageClassService kubernetesStorageClassService; + private final NhnStorageClassService nhnStorageClassService; private final ProjectScopeAuthorizationService projectScopeAuthorizationService; private final KubernetesIngressPreflightService kubernetesIngressPreflightService; @@ -177,6 +179,19 @@ public ResponseEntity>> getK8sStorageCl return ResponseEntity.ok(new ResponseWrapper<>(result)); } + @GetMapping("/k8s/storage-classes/nhn-capability") + public ResponseEntity nhnStorageCapability(@RequestParam String namespace, @RequestParam String clusterName, HttpServletRequest httpRequest) { + projectScopeAuthorizationService.authorizeNamespace(httpRequest, namespace); + return ResponseEntity.ok(new ResponseWrapper<>(nhnStorageClassService.capability(namespace, clusterName))); + } + + @PostMapping("/k8s/storage-classes/nhn") + public ResponseEntity createNhnStorageClass(@RequestParam String namespace, @RequestParam String clusterName, + @RequestBody NhnStorageClassService.CreateRequest body, HttpServletRequest httpRequest) { + projectScopeAuthorizationService.authorizeNamespace(httpRequest, namespace); + return ResponseEntity.ok(new ResponseWrapper<>(nhnStorageClassService.create(namespace, clusterName, body))); + } + @Operation(summary = "Get deployment history", description = "Retrieve deployment history for a specific catalog ID.") @GetMapping("/history") public ResponseEntity>> getDeploymentHistories( diff --git a/src/main/java/kr/co/mcmp/softwarecatalog/application/dto/K8sStorageClassDTO.java b/src/main/java/kr/co/mcmp/softwarecatalog/application/dto/K8sStorageClassDTO.java index 7972d14..f44a8be 100644 --- a/src/main/java/kr/co/mcmp/softwarecatalog/application/dto/K8sStorageClassDTO.java +++ b/src/main/java/kr/co/mcmp/softwarecatalog/application/dto/K8sStorageClassDTO.java @@ -15,4 +15,6 @@ public class K8sStorageClassDTO { private Boolean defaultClass; private String reclaimPolicy; private String volumeBindingMode; + private Integer minimumSizeGi; + } diff --git a/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/JupyterStorageValidation.java b/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/JupyterStorageValidation.java new file mode 100644 index 0000000..dc17b5f --- /dev/null +++ b/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/JupyterStorageValidation.java @@ -0,0 +1,45 @@ +package kr.co.mcmp.softwarecatalog.kubernetes.service; + +import java.math.BigDecimal; +import java.util.*; +import io.fabric8.kubernetes.api.model.Quantity; +import io.fabric8.kubernetes.api.model.storage.StorageClass; +import io.fabric8.kubernetes.client.KubernetesClient; + +public final class JupyterStorageValidation { + private JupyterStorageValidation() { } + public static String size(Map config) { + String value = Objects.toString(config == null ? null : config.get("storageSize"), "10Gi"); + try { + if (Quantity.getAmountInBytes(Quantity.parse(value)).signum() <= 0) throw new IllegalArgumentException(); + } catch (RuntimeException e) { throw invalid("Enter a positive PVC capacity such as 20Gi."); } + return value; + } + // Only enforce documented limits when every fallback type has that minimum. + public static int minimumSizeGi(StorageClass sc) { + if (!"diskplugin.csi.alibabacloud.com".equals(sc.getProvisioner())) return 1; + String type = sc.getParameters() == null ? "" : sc.getParameters().getOrDefault("type", ""); + var types = Arrays.stream(type.split(",")).map(String::trim).toList(); + return !types.isEmpty() && types.stream().allMatch(Set.of("cloud_efficiency", "cloud_ssd")::contains) ? 20 : 1; + } + public static StorageClass validate(KubernetesClient client, Map config) { + String name = Objects.toString(config == null ? null : config.get("storageClass"), ""); + if (name.isBlank()) throw invalid("Select a StorageClass for the Jupyter notebook volume."); + String mode = Objects.toString(config.get("storageAccessMode"), "ReadWriteOnce"); + if (!"ReadWriteOnce".equals(mode)) throw invalid("Jupyter uses a single replica and requires ReadWriteOnce access mode."); + String capacity = size(config); + try { + StorageClass sc = client.storage().v1().storageClasses().withName(name).get(); + if (sc == null) throw invalid("The selected StorageClass no longer exists. Refresh the list."); + if (sc.getProvisioner() == null || sc.getProvisioner().isBlank() || "kubernetes.io/no-provisioner".equals(sc.getProvisioner())) + throw invalid("Select a StorageClass with dynamic provisioning for Jupyter."); + if (Quantity.getAmountInBytes(Quantity.parse(capacity)).compareTo( + BigDecimal.valueOf(minimumSizeGi(sc)).multiply(BigDecimal.valueOf(1073741824L))) < 0) + throw invalid("This StorageClass requires at least " + minimumSizeGi(sc) + "Gi. Increase the notebook volume capacity."); + return sc; + } catch (RuntimeException e) { throw StorageOperationException.translate(e); } + } + private static StorageOperationException invalid(String message) { + return new StorageOperationException(400, "INVALID_JUPYTER_STORAGE", message); + } +} diff --git a/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/K8sJupyterService.java b/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/K8sJupyterService.java index 23cc5a0..06de52a 100644 --- a/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/K8sJupyterService.java +++ b/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/K8sJupyterService.java @@ -91,6 +91,11 @@ static void validate(DeploymentRequest r, String gateway, boolean allowHttp) { } public synchronized DeploymentHistory deploy(DeploymentRequest request, SoftwareCatalog catalog) { + boolean prepareCinderVolume; + try (var client = clients.getClient(request.getNamespace(), request.getClusterName())) { + var storageClass = JupyterStorageValidation.validate(client, request.getAdditionalConfig()); + prepareCinderVolume = NhnStorageClassService.DRIVER.equals(storageClass.getProvisioner()); + } catch (RuntimeException e) { throw StorageOperationException.translate(e); } access.resolveTarget(request, catalog); boolean sshTunnel = useSshTunnel(); // Routing/CIDR requirements are identical; only DIRECT needs an external URL. @@ -140,7 +145,7 @@ public synchronized DeploymentHistory deploy(DeploymentRequest request, Software } var issued = grants.issue(history.getId(), workloadId(request.getClusterName()), request.getNamespace(), storage); Secret sshSecret = sshTunnel ? tunnels.credentials(request.getNamespace(), name) : null; - var resources = resources(request, catalog, name, issued.token(), storage.getJupyterToken(), sshSecret); + var resources = resources(request, catalog, name, issued.token(), storage.getJupyterToken(), sshSecret, prepareCinderVolume); // Ingress is last: no external route until the Pod has passed gateway checks. for (HasMetadata resource : resources) { if (resource instanceof Ingress) continue; @@ -177,10 +182,15 @@ public synchronized DeploymentHistory deploy(DeploymentRequest request, Software } List resources(DeploymentRequest r, SoftwareCatalog catalog, String name, String token, String login) throws Exception { - return resources(r, catalog, name, token, login, null); + return resources(r, catalog, name, token, login, null, false); } List resources(DeploymentRequest r, SoftwareCatalog catalog, String name, String token, String login, Secret sshSecret) throws Exception { + return resources(r, catalog, name, token, login, sshSecret, false); + } + + List resources(DeploymentRequest r, SoftwareCatalog catalog, String name, String token, String login, + Secret sshSecret, boolean prepareCinderVolume) throws Exception { String ns = r.getNamespace(); Map labels = Map.of(OWNER, name, "app.kubernetes.io/instance", name, "app.kubernetes.io/name", "jupyter"); Map metadata = Map.of("name", name, "namespace", ns, "labels", labels); @@ -197,7 +207,7 @@ List resources(DeploymentRequest r, SoftwareCatalog catalog, String var config = r.getAdditionalConfig() == null ? Map.of() : r.getAdditionalConfig(); String storageClass = Objects.toString(config.get("storageClass"), ""); Map pvcSpec = new LinkedHashMap<>(Map.of("accessModes", List.of("ReadWriteOnce"), - "resources", Map.of("requests", Map.of("storage", "10Gi")))); + "resources", Map.of("requests", Map.of("storage", JupyterStorageValidation.size(r.getAdditionalConfig()))))); if (!storageClass.isBlank()) pvcSpec.put("storageClassName", storageClass); result.add(mapper.convertValue(Map.of("apiVersion", "v1", "kind", "PersistentVolumeClaim", "metadata", metadata, "spec", pvcSpec), PersistentVolumeClaim.class)); String image = catalog.getPackageInfo().getPackageName() + ":" + catalog.getPackageInfo().getPackageVersion(); @@ -211,13 +221,29 @@ List resources(DeploymentRequest r, SoftwareCatalog catalog, String container.put("readinessProbe", Map.of("tcpSocket", Map.of("port", "http"), "periodSeconds", 5)); container.put("resources", Map.of("requests", Map.of("cpu", "500m", "memory", "1Gi"), "limits", Map.of("cpu", Objects.toString(catalog.getRecommendedCpu(), "2"), "memory", "4Gi"))); + Map podSpec = new LinkedHashMap<>(); + podSpec.put("automountServiceAccountToken", false); + podSpec.put("securityContext", Map.of("fsGroup", 100)); + if (prepareCinderVolume) { + Map volumePermissions = new LinkedHashMap<>(); + volumePermissions.put("name", "prepare-notebook-volume"); + volumePermissions.put("image", image); + volumePermissions.put("command", List.of("sh", "-c", + "chown 1000:100 /home/jovyan/work && chmod 2770 /home/jovyan/work")); + volumePermissions.put("securityContext", Map.of("runAsUser", 0, "runAsGroup", 0, + "allowPrivilegeEscalation", false, "seccompProfile", Map.of("type", "RuntimeDefault"), + "capabilities", Map.of("drop", List.of("ALL"), "add", List.of("CHOWN", "FOWNER", "DAC_OVERRIDE")))); + volumePermissions.put("resources", Map.of("requests", Map.of("cpu", "10m", "memory", "16Mi"), + "limits", Map.of("cpu", "100m", "memory", "64Mi"))); + volumePermissions.put("volumeMounts", List.of(Map.of("name", "work", "mountPath", "/home/jovyan/work"))); + podSpec.put("initContainers", List.of(volumePermissions)); + } + podSpec.put("containers", List.of(container)); + podSpec.put("volumes", List.of(Map.of("name", "work", "persistentVolumeClaim", Map.of("claimName", name)), + Map.of("name", "templates", "configMap", Map.of("name", name)))); result.add(mapper.convertValue(Map.of("apiVersion", "apps/v1", "kind", "Deployment", "metadata", metadata, "spec", Map.of("replicas", 1, "strategy", Map.of("type", "Recreate"), "selector", Map.of("matchLabels", Map.of(OWNER, name)), - "template", Map.of("metadata", Map.of("labels", labels), "spec", Map.of( - "automountServiceAccountToken", false, "securityContext", Map.of("fsGroup", 100), - "containers", List.of(container), "volumes", List.of( - Map.of("name", "work", "persistentVolumeClaim", Map.of("claimName", name)), - Map.of("name", "templates", "configMap", Map.of("name", name))))))), Deployment.class)); + "template", Map.of("metadata", Map.of("labels", labels), "spec", podSpec))), Deployment.class)); result.add(mapper.convertValue(Map.of("apiVersion", "v1", "kind", "Service", "metadata", metadata, "spec", Map.of("type", "ClusterIP", "selector", Map.of(OWNER, name), "ports", List.of(Map.of("name", "http", "port", 8888, "targetPort", "http")))), io.fabric8.kubernetes.api.model.Service.class)); diff --git a/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/KubernetesStorageClassService.java b/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/KubernetesStorageClassService.java index b353188..4107d90 100644 --- a/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/KubernetesStorageClassService.java +++ b/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/KubernetesStorageClassService.java @@ -24,12 +24,12 @@ public class KubernetesStorageClassService { public List getStorageClasses(String namespace, String clusterName) { try (KubernetesClient client = kubernetesClientFactory.getClient(namespace, clusterName)) { return client.storage().v1().storageClasses().list().getItems().stream() - .map(this::toDto) + .map(KubernetesStorageClassService::toDto) .sorted(Comparator .comparing(K8sStorageClassDTO::getDefaultClass, Comparator.nullsLast(Comparator.reverseOrder())) .thenComparing(K8sStorageClassDTO::getName, Comparator.nullsLast(String::compareToIgnoreCase))) .toList(); - } + } catch (RuntimeException e) { throw StorageOperationException.translate(e); } } public boolean exists(String namespace, String clusterName, String storageClassName) { @@ -40,17 +40,18 @@ public boolean exists(String namespace, String clusterName, String storageClassN .anyMatch(storageClass -> storageClassName.equals(storageClass.getName())); } - private K8sStorageClassDTO toDto(StorageClass storageClass) { + static K8sStorageClassDTO toDto(StorageClass storageClass) { return K8sStorageClassDTO.builder() .name(storageClass.getMetadata() != null ? storageClass.getMetadata().getName() : null) .provisioner(storageClass.getProvisioner()) .defaultClass(isDefault(storageClass)) .reclaimPolicy(storageClass.getReclaimPolicy()) .volumeBindingMode(storageClass.getVolumeBindingMode()) + .minimumSizeGi(JupyterStorageValidation.minimumSizeGi(storageClass)) .build(); } - private boolean isDefault(StorageClass storageClass) { + private static boolean isDefault(StorageClass storageClass) { if (storageClass.getMetadata() == null) { return false; } diff --git a/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/NhnStorageClassService.java b/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/NhnStorageClassService.java new file mode 100644 index 0000000..569111d --- /dev/null +++ b/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/NhnStorageClassService.java @@ -0,0 +1,65 @@ +package kr.co.mcmp.softwarecatalog.kubernetes.service; + +import java.util.*; +import org.springframework.stereotype.Service; +import io.fabric8.kubernetes.api.model.authorization.v1.SelfSubjectAccessReviewBuilder; +import io.fabric8.kubernetes.api.model.storage.StorageClassBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import kr.co.mcmp.ape.cbtumblebug.api.CbtumblebugRestApi; +import kr.co.mcmp.softwarecatalog.application.dto.K8sStorageClassDTO; +import kr.co.mcmp.softwarecatalog.kubernetes.config.KubernetesClientFactory; +import lombok.RequiredArgsConstructor; + +@Service @RequiredArgsConstructor +public class NhnStorageClassService { + static final String DRIVER = "cinder.csi.openstack.org"; + private final KubernetesClientFactory clients; + private final CbtumblebugRestApi tumblebug; + public record Capability(boolean supported, boolean driverReady, boolean canCreate, String message) { } + public record CreateRequest(String name, String diskType) { } + + public Capability capability(String namespace, String clusterName) { + try { + var cluster = tumblebug.getK8sClusterByName(namespace, clusterName); + String provider = cluster == null || cluster.getConnectionConfig() == null ? "" : + Objects.toString(cluster.getConnectionConfig().getProviderName(), ""); + if (!"nhn".equalsIgnoreCase(provider)) + return new Capability(false, false, false, "StorageClass creation is available for NHN clusters."); + try (var client = clients.getClient(namespace, clusterName)) { return inspect(client); } + } catch (RuntimeException e) { throw StorageOperationException.translate(e); } + } + static Capability inspect(KubernetesClient client) { + if (client.storage().v1().csiDrivers().withName(DRIVER).get() == null) + return new Capability(true, false, false, + "cinder-csi-plugin is not registered. Install the managed add-on in NHN NKS, then refresh StorageClasses."); + var csiNodes = client.storage().v1().csiNodes().list().getItems(); + boolean registered = !csiNodes.isEmpty() && csiNodes.stream() + .allMatch(n -> n.getSpec() != null && n.getSpec().getDrivers() != null && + n.getSpec().getDrivers().stream().anyMatch(d -> DRIVER.equals(d.getName()))); + if (!registered) return new Capability(true, false, false, + "The Cinder CSI driver is still registering on worker nodes. Refresh after the add-on is ready."); + var review = client.authorization().v1().selfSubjectAccessReview().create(new SelfSubjectAccessReviewBuilder() + .withNewSpec().withNewResourceAttributes().withGroup("storage.k8s.io") + .withResource("storageclasses").withVerb("create").endResourceAttributes().endSpec().build()); + boolean allowed = review.getStatus() != null && Boolean.TRUE.equals(review.getStatus().getAllowed()); + return new Capability(true, true, allowed, allowed ? "Cinder CSI is ready. Create an NHN notebook StorageClass (ReadWriteOnce)." : + "AM needs cluster-level permission to create storageclasses.storage.k8s.io."); + } + public K8sStorageClassDTO create(String namespace, String clusterName, CreateRequest request) { + if (request == null || request.name() == null || request.name().length() > 63 || + !request.name().matches("[a-z0-9](?:[a-z0-9-]*[a-z0-9])?")) + throw new StorageOperationException(400, "INVALID_STORAGE_CLASS", "Use a name of up to 63 lowercase letters, numbers and hyphens."); + if (!Set.of("General HDD", "General SSD").contains(Objects.toString(request.diskType(), ""))) + throw new StorageOperationException(400, "INVALID_STORAGE_CLASS", "Select General HDD or General SSD."); + Capability capability = capability(namespace, clusterName); + if (!capability.canCreate()) throw new StorageOperationException(400, "STORAGE_CLASS_SETUP_REQUIRED", capability.message()); + try (var client = clients.getClient(namespace, clusterName)) { + // Create only: never replace existing classes or change the cluster default. + var sc = client.storage().v1().storageClasses().resource(new StorageClassBuilder() + .withNewMetadata().withName(request.name()).endMetadata() + .withProvisioner(DRIVER).withParameters(Map.of("type", request.diskType())) + .withVolumeBindingMode("WaitForFirstConsumer").withReclaimPolicy("Retain").build()).create(); + return KubernetesStorageClassService.toDto(sc); + } catch (RuntimeException e) { throw StorageOperationException.translate(e); } + } +} diff --git a/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/StorageOperationException.java b/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/StorageOperationException.java new file mode 100644 index 0000000..d2f4a99 --- /dev/null +++ b/src/main/java/kr/co/mcmp/softwarecatalog/kubernetes/service/StorageOperationException.java @@ -0,0 +1,25 @@ +package kr.co.mcmp.softwarecatalog.kubernetes.service; + +import io.fabric8.kubernetes.client.KubernetesClientException; + +/** Safe, actionable errors; never return kubeconfig or upstream response bodies. */ +public class StorageOperationException extends RuntimeException { + private final int status; + private final String code; + public StorageOperationException(int status, String code, String message) { + super(message); this.status = status; this.code = code; + } + public int getStatus() { return status; } + public String getCode() { return code; } + public static StorageOperationException translate(RuntimeException failure) { + if (failure instanceof StorageOperationException e) return e; + for (Throwable t = failure; t != null; t = t.getCause()) { + if (t instanceof KubernetesClientException e) { + if (e.getCode() == 401) return new StorageOperationException(401, "K8S_AUTHENTICATION_FAILED", "Kubernetes authentication failed. Refresh the cluster credentials."); + if (e.getCode() == 403) return new StorageOperationException(403, "K8S_STORAGE_FORBIDDEN", "Kubernetes denied the storage operation. Check AM cluster RBAC permissions."); + if (e.getCode() == 409) return new StorageOperationException(409, "STORAGE_CLASS_CONFLICT", "A StorageClass with this name already exists. Refresh the list and select it, or use another name."); + } + } + return new StorageOperationException(502, "K8S_STORAGE_API_UNAVAILABLE", "Unable to query the cluster storage API. Check the API endpoint, network access and cluster credentials, then retry."); + } +} diff --git a/src/test/java/kr/co/mcmp/softwarecatalog/application/controller/ApplicationIngressCheckControllerTest.java b/src/test/java/kr/co/mcmp/softwarecatalog/application/controller/ApplicationIngressCheckControllerTest.java index 099448f..aaffa77 100644 --- a/src/test/java/kr/co/mcmp/softwarecatalog/application/controller/ApplicationIngressCheckControllerTest.java +++ b/src/test/java/kr/co/mcmp/softwarecatalog/application/controller/ApplicationIngressCheckControllerTest.java @@ -23,7 +23,7 @@ class ApplicationIngressCheckControllerTest { private final KubernetesIngressPreflightService ingress = mock(KubernetesIngressPreflightService.class); private final ApplicationOrchestrationService orchestration = mock(ApplicationOrchestrationService.class); private final MockMvc mvc = MockMvcBuilders.standaloneSetup(new ApplicationController( - null, orchestration, null, null, null, scope, ingress)).build(); + null, orchestration, null, null, null, null, scope, ingress)).build(); @Test void checksProjectAuthorizationBeforePreflightAndReturnsBlockingErrorsSeparately() throws Exception { when(ingress.check(any())).thenReturn(new K8sIngressCheckResult(false, List.of("Host/Path conflict"), List.of())); diff --git a/src/test/java/kr/co/mcmp/softwarecatalog/application/controller/ApplicationVmDeploymentControllerTest.java b/src/test/java/kr/co/mcmp/softwarecatalog/application/controller/ApplicationVmDeploymentControllerTest.java index 9f744a9..82f54e5 100644 --- a/src/test/java/kr/co/mcmp/softwarecatalog/application/controller/ApplicationVmDeploymentControllerTest.java +++ b/src/test/java/kr/co/mcmp/softwarecatalog/application/controller/ApplicationVmDeploymentControllerTest.java @@ -34,7 +34,7 @@ void setUp() { orchestration = mock(ApplicationOrchestrationService.class); when(orchestration.deployApplication(any())).thenReturn(DeploymentHistory.builder().build()); mvc = MockMvcBuilders.standaloneSetup(new ApplicationController( - null, orchestration, null, null, null, scope, null)).build(); + null, orchestration, null, null, null, null, scope, null)).build(); } @Test diff --git a/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/JupyterStorageValidationTest.java b/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/JupyterStorageValidationTest.java new file mode 100644 index 0000000..33bc18c --- /dev/null +++ b/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/JupyterStorageValidationTest.java @@ -0,0 +1,52 @@ +package kr.co.mcmp.softwarecatalog.kubernetes.service; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Map; +import io.fabric8.kubernetes.api.model.storage.StorageClassBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.server.mock.KubernetesServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class JupyterStorageValidationTest { + private final KubernetesServer server = new KubernetesServer(false, true); + private KubernetesClient client; + + @BeforeEach void start() { server.before(); client = server.getClient(); } + @AfterEach void stop() { server.after(); } + + @Test void distinguishesMissingClassFromApiFailureAndRequiresDynamicProvisioning() { + assertThatThrownBy(() -> JupyterStorageValidation.validate(client, + Map.of("storageClass", "missing", "storageSize", "10Gi"))) + .isInstanceOf(StorageOperationException.class) + .hasMessageContaining("no longer exists"); + + client.storage().v1().storageClasses().resource(new StorageClassBuilder() + .withNewMetadata().withName("local").endMetadata() + .withProvisioner("kubernetes.io/no-provisioner").build()).create(); + assertThatThrownBy(() -> JupyterStorageValidation.validate(client, + Map.of("storageClass", "local", "storageSize", "10Gi"))) + .hasMessageContaining("dynamic provisioning"); + } + + @Test void enforcesKnownAlibabaMinimumAndAcceptsNhnTenGi() { + client.storage().v1().storageClasses().resource(new StorageClassBuilder() + .withNewMetadata().withName("alibaba").endMetadata() + .withProvisioner("diskplugin.csi.alibabacloud.com") + .withParameters(Map.of("type", "cloud_efficiency")).build()).create(); + assertThatThrownBy(() -> JupyterStorageValidation.validate(client, + Map.of("storageClass", "alibaba", "storageSize", "10Gi"))) + .hasMessageContaining("at least 20Gi"); + assertThatCode(() -> JupyterStorageValidation.validate(client, + Map.of("storageClass", "alibaba", "storageSize", "20Gi"))).doesNotThrowAnyException(); + + client.storage().v1().storageClasses().resource(new StorageClassBuilder() + .withNewMetadata().withName("nhn-hdd").endMetadata() + .withProvisioner("cinder.csi.openstack.org") + .withParameters(Map.of("type", "General HDD")).build()).create(); + assertThatCode(() -> JupyterStorageValidation.validate(client, + Map.of("storageClass", "nhn-hdd", "storageSize", "10Gi"))).doesNotThrowAnyException(); + } +} diff --git a/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/K8sJupyterServiceTest.java b/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/K8sJupyterServiceTest.java index f243471..be27ceb 100644 --- a/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/K8sJupyterServiceTest.java +++ b/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/K8sJupyterServiceTest.java @@ -142,6 +142,7 @@ void rejectsInvalidOrWildcardHost(String host) { assertThat(deployment.getSpec().getReplicas()).isEqualTo(1); assertThat(deployment.getSpec().getTemplate().getSpec().getAutomountServiceAccountToken()).isFalse(); assertThat(deployment.toString()).doesNotContain("grant-secret","login-secret"); + assertThat(deployment.getSpec().getTemplate().getSpec().getInitContainers()).isEmpty(); assertThat(deployment.getSpec().getTemplate().getSpec().getContainers().get(0).getEnvFrom().get(0).getSecretRef().getName()).isEqualTo("mcmp-jupyter-41"); var svc=(Service)resources.stream().filter(Service.class::isInstance).findFirst().orElseThrow(); assertThat(svc.getSpec().getType()).isEqualTo("ClusterIP"); @@ -157,4 +158,19 @@ void rejectsInvalidOrWildcardHost(String host) { var pvc=(PersistentVolumeClaim)resources.stream().filter(PersistentVolumeClaim.class::isInstance).findFirst().orElseThrow(); assertThat(pvc.getSpec().getStorageClassName()).isEqualTo("standard"); } + + @Test void cinderManifestPreparesNotebookVolumeOwnership() throws Exception { + var service=new K8sJupyterService(null,null,null,null,null,null,new ObjectMapper(), null,IbmIngressAutomationTestSupport.legacy()); + ReflectionTestUtils.setField(service,"gatewayUrl","https://am.example.test/applications/object-storage-gateway"); + var catalog=new SoftwareCatalog(); + catalog.setPackageInfo(PackageInfo.builder().packageName("quay.io/jupyter/scipy-notebook").packageVersion("2026-07-28").build()); + var resources=service.resources(request(),catalog,"mcmp-jupyter-41","grant-secret","login-secret",null,true); + var deployment=(Deployment)resources.stream().filter(Deployment.class::isInstance).findFirst().orElseThrow(); + var volumePermissions=deployment.getSpec().getTemplate().getSpec().getInitContainers().get(0); + assertThat(volumePermissions.getName()).isEqualTo("prepare-notebook-volume"); + assertThat(volumePermissions.getCommand()).containsExactly("sh", "-c", + "chown 1000:100 /home/jovyan/work && chmod 2770 /home/jovyan/work"); + assertThat(volumePermissions.getSecurityContext().getRunAsUser()).isZero(); + assertThat(volumePermissions.getVolumeMounts().get(0).getMountPath()).isEqualTo("/home/jovyan/work"); + } } diff --git a/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/NhnStorageClassServiceTest.java b/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/NhnStorageClassServiceTest.java new file mode 100644 index 0000000..e87e8de --- /dev/null +++ b/src/test/java/kr/co/mcmp/softwarecatalog/kubernetes/service/NhnStorageClassServiceTest.java @@ -0,0 +1,86 @@ +package kr.co.mcmp.softwarecatalog.kubernetes.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.fabric8.kubernetes.api.model.authorization.v1.SelfSubjectAccessReviewBuilder; +import io.fabric8.kubernetes.api.model.storage.CSIDriverBuilder; +import io.fabric8.kubernetes.api.model.storage.CSINodeBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.server.mock.KubernetesServer; +import kr.co.mcmp.ape.cbtumblebug.api.CbtumblebugRestApi; +import kr.co.mcmp.ape.cbtumblebug.dto.K8sClusterDto; +import kr.co.mcmp.softwarecatalog.kubernetes.config.KubernetesClientFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class NhnStorageClassServiceTest { + private final KubernetesServer server = new KubernetesServer(false, true); + private KubernetesClient client; + + @BeforeEach void start() { server.before(); client = server.getClient(); } + @AfterEach void stop() { server.after(); } + + @Test void blocksCreationWhenCinderDriverIsMissing() { + var result = NhnStorageClassService.inspect(client); + assertThat(result.supported()).isTrue(); + assertThat(result.driverReady()).isFalse(); + assertThat(result.canCreate()).isFalse(); + assertThat(result.message()).contains("cinder-csi-plugin"); + } + + @Test void reportsCreatePermissionOnlyAfterDriverIsRegistered() { + client.storage().v1().csiDrivers().resource(new CSIDriverBuilder() + .withNewMetadata().withName(NhnStorageClassService.DRIVER).endMetadata() + .withNewSpec().withAttachRequired(true).endSpec().build()).create(); + client.storage().v1().csiNodes().resource(new CSINodeBuilder() + .withNewMetadata().withName("worker-1").endMetadata().withNewSpec() + .addNewDriver().withName(NhnStorageClassService.DRIVER).withNodeID("worker-1").endDriver() + .endSpec().build()).create(); + server.expect().post().withPath("/apis/authorization.k8s.io/v1/selfsubjectaccessreviews") + .andReturn(201, new SelfSubjectAccessReviewBuilder() + .withNewStatus().withAllowed(true).endStatus().build()).once(); + + var result = NhnStorageClassService.inspect(client); + assertThat(result.supported()).isTrue(); + assertThat(result.driverReady()).isTrue(); + assertThat(result.canCreate()).isTrue(); + } + + @Test void waitsUntilCinderIsRegisteredOnEveryWorker() { + client.storage().v1().csiDrivers().resource(new CSIDriverBuilder() + .withNewMetadata().withName(NhnStorageClassService.DRIVER).endMetadata() + .withNewSpec().withAttachRequired(true).endSpec().build()).create(); + client.storage().v1().csiNodes().resource(new CSINodeBuilder() + .withNewMetadata().withName("worker-1").endMetadata().withNewSpec() + .addNewDriver().withName(NhnStorageClassService.DRIVER).withNodeID("worker-1").endDriver() + .endSpec().build()).create(); + client.storage().v1().csiNodes().resource(new CSINodeBuilder() + .withNewMetadata().withName("worker-2").endMetadata().withNewSpec().endSpec().build()).create(); + + var result = NhnStorageClassService.inspect(client); + + assertThat(result.driverReady()).isFalse(); + assertThat(result.canCreate()).isFalse(); + assertThat(result.message()).contains("registering"); + } + + @Test void rejectsNonNhnClusterUsingTumblebugProviderMetadata() { + var clients = mock(KubernetesClientFactory.class); + var tumblebug = mock(CbtumblebugRestApi.class); + var cluster = new K8sClusterDto(); + cluster.setConnectionName("nhn-looking-connection-name"); + var config = new K8sClusterDto.ConnectionConfig(); + config.setProviderName("aws"); + cluster.setConnectionConfig(config); + when(tumblebug.getK8sClusterByName("default", "cluster-a")).thenReturn(cluster); + + var result = new NhnStorageClassService(clients, tumblebug).capability("default", "cluster-a"); + + assertThat(result.supported()).isFalse(); + assertThat(result.driverReady()).isFalse(); + assertThat(result.canCreate()).isFalse(); + verifyNoInteractions(clients); + } +} From 490912b16c0157dbc43e36f30fce3cc1482f5a6c Mon Sep 17 00:00:00 2001 From: LeeKyungBeom Date: Tue, 15 Sep 2026 10:55:48 +0900 Subject: [PATCH 2/3] fix: prefer topology-aware storage and default to 20Gi for Alibaba Jupyter --- .../components/applicationInstallationForm.vue | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/applicationFE/src/views/softwareCatalog/components/applicationInstallationForm.vue b/applicationFE/src/views/softwareCatalog/components/applicationInstallationForm.vue index 6b2f292..44b7a29 100644 --- a/applicationFE/src/views/softwareCatalog/components/applicationInstallationForm.vue +++ b/applicationFE/src/views/softwareCatalog/components/applicationInstallationForm.vue @@ -510,7 +510,7 @@ v-for="storageClass in storageClassList" :key="storageClass.name" :value="storageClass.name"> - {{ storageClass.name }}{{ storageClass.defaultClass ? ' (default)' : '' }} + {{ storageClass.name }}{{ isRecommendedStorageClass(storageClass) ? ' (recommended)' : '' }}{{ storageClass.defaultClass ? ' (default)' : '' }}

@@ -1632,8 +1632,11 @@ const fetchStorageClasses = async (preferred = '') => { const { data } = await getK8sStorageClasses(target) if (sequence !== storageRequestSequence) return if (!Array.isArray(data)) throw new Error('Invalid StorageClass API response. Retry the lookup.') - storageClassList.value = data + storageClassList.value = [...data].sort((a, b) => Number(isRecommendedStorageClass(b)) - Number(isRecommendedStorageClass(a))) selectedStorageClass.value = data.some(s => s.name === preferred) ? preferred : getInitialStorageClass(data) + if (!preferred && selectedClusterProvider.value === 'alibaba') { + notebookStorageGi.value = Math.max(notebookStorageGi.value, 20) + } } catch (error) { if (sequence !== storageRequestSequence) return storageClassLoadError.value = true @@ -1664,9 +1667,15 @@ const createNotebookStorageClass = async () => { } finally { storageCreating.value = false } } +const isRecommendedStorageClass = (item: any) => + selectedClusterProvider.value === 'alibaba' && + item.name === 'alicloud-disk-topology-alltype' && + item.provisioner === 'diskplugin.csi.alibabacloud.com' + const getInitialStorageClass = (items: any[]) => { + const recommendedClass = items.find(isRecommendedStorageClass) const defaultClass = items.find((item: any) => item.defaultClass) - return defaultClass?.name || items[0]?.name || "" + return recommendedClass?.name || defaultClass?.name || items[0]?.name || "" } const onChangeNsId = async () => { From 6a682ded2816937abe1ce7ed085c8023c301e2fd Mon Sep 17 00:00:00 2001 From: LeeKyungBeom Date: Tue, 15 Sep 2026 16:48:10 +0900 Subject: [PATCH 3/3] fix: disable Jupyter installation for kt cloud --- .../components/applicationInstallationForm.vue | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/applicationFE/src/views/softwareCatalog/components/applicationInstallationForm.vue b/applicationFE/src/views/softwareCatalog/components/applicationInstallationForm.vue index 44b7a29..444636a 100644 --- a/applicationFE/src/views/softwareCatalog/components/applicationInstallationForm.vue +++ b/applicationFE/src/views/softwareCatalog/components/applicationInstallationForm.vue @@ -860,6 +860,9 @@

+
+

Jupyter installation is unavailable for KT because AM does not support KT Object Storage integration.

+
{{ message }}
@@ -1816,6 +1819,10 @@ const getDeploymentId = (responseData: any) => { const runInstall = async () => { if (deploying.value || deploymentCompleted.value) return + if (jupyterInstallationUnsupported.value) { + toast.error('Jupyter installation is unavailable for KT because AM does not support KT Object Storage integration.') + return + } if (modalTitle.value === 'Application Installation' && selectInfra.value === 'VM' && selectDeploymentType.value === 'Clustering' && !canSelectClustering.value) { toast.error('Clustering is available only for Redis and Elasticsearch on individually selected VMs') @@ -2166,6 +2173,14 @@ const isJupyterObjectStorageCatalog = computed(() => { return packageName.includes('jupyter') && hasObjectStorageCapability(selectedCatalogInfo.value as SoftwareCatalog) }) +const jupyterInstallationUnsupported = computed(() => { + if (modalTitle.value !== 'Application Installation' || !isJupyterObjectStorageCatalog.value) return false + const isKtProvider = (provider: unknown) => /^kt(?:classic|cloud|-cloud)?(?:[-_]|$)/i.test(String(provider || '').trim()) + if (selectInfra.value === 'K8S') return isKtProvider(selectedClusterProvider.value) + return originalVmList.value.some((vm: any) => selectedVmList.value.includes(getVmValue(vm)) + && isKtProvider(vm?.connectionConfig?.providerName || vm?.connectionName)) +}) + const supportsStorageClassConfig = computed(() => { if (selectInfra.value !== 'K8S') return false if (isJupyterObjectStorageCatalog.value) return true @@ -2240,6 +2255,7 @@ const objectStorageCheckPassed = computed(() => { const deployDisabled = computed(() => { return deploying.value || deploymentCompleted.value + || jupyterInstallationUnsupported.value || specChecking.value || Boolean(projectScopeError.value) || specCheckFlag.value