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..444636a 100644
--- a/applicationFE/src/views/softwareCatalog/components/applicationInstallationForm.vue
+++ b/applicationFE/src/views/softwareCatalog/components/applicationInstallationForm.vue
@@ -510,12 +510,45 @@
v-for="storageClass in storageClassList"
:key="storageClass.name"
:value="storageClass.name">
- {{ storageClass.name }}{{ storageClass.defaultClass ? ' (default)' : '' }}
+ {{ storageClass.name }}{{ isRecommendedStorageClass(storageClass) ? ' (recommended)' : '' }}{{ storageClass.defaultClass ? ' (default)' : '' }}
{{ storageClassErrorMessage }}
+ Refresh StorageClasses
+ {{ storageSetupError }}
+
+
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
+
StorageClass name
+
+
Disk type
+
+ General HDD General SSD
+
+
Volumes are created when a Pod uses the claim. Retain policy keeps disks after claim deletion; remove unused disks separately.
+
{{ storageCreating ? 'Creating...' : 'Create NHN StorageClass' }}
+
+
+
+
Notebook volume capacity (GiB)
+
+
Minimum {{ selectedStorageMinimum }} GiB for the known disk limits. Access mode: ReadWriteOnce. Provider quotas and disk availability are checked during provisioning.
+
@@ -827,6 +860,9 @@
+
+
Jupyter installation is unavailable for KT because AM does not support KT Object Storage integration.
+
{{ message }}
{{ message }}
@@ -881,7 +917,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 +1074,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 +1299,7 @@ onMounted(async () => {
const setInit = async () => {
preparationEpoch++
deploymentCompleted.value = false
+ storageRequestSequence++
const loadSequence = ++resourceLoadSequence
clearTargetResources()
selectInfra.value = isTargetLocked.value ? normalizedTargetType.value : "VM"
@@ -1283,6 +1332,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,39 +1619,66 @@ 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].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
- 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 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 () => {
@@ -1738,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')
@@ -2057,10 +2142,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(() => {
@@ -2087,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
@@ -2119,9 +2213,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 ''
})
@@ -2159,6 +2255,7 @@ const objectStorageCheckPassed = computed(() => {
const deployDisabled = computed(() => {
return deploying.value
|| deploymentCompleted.value
+ || jupyterInstallationUnsupported.value
|| specChecking.value
|| Boolean(projectScopeError.value)
|| specCheckFlag.value
@@ -2248,6 +2345,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);
+ }
+}