Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions applicationFE/src/api/softwareCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Original file line number Diff line number Diff line change
Expand Up @@ -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)' : '' }}
</option>
</select>
<p class="text-danger mt-1 mb-0" v-if="storageClassRequired && storageClassErrorMessage">
{{ storageClassErrorMessage }}
</p>
<button type="button" class="btn btn-outline-secondary btn-sm mt-2" :disabled="storageClassLoading || storageCreating" @click="fetchStorageClasses(selectedStorageClass)">Refresh StorageClasses</button>
<p v-if="storageSetupError" class="text-danger mt-2" role="alert">{{ storageSetupError }}</p>
<div v-if="isNhnCluster && !storageClassLoadError && !storageClassList.length" class="border rounded p-2 mt-2">
<div class="fw-bold mb-2">NHN block storage setup</div>
<p role="status" class="mb-2">
{{ storageCapability?.message || (storageSetupError ? 'NHN storage setup is unavailable.' : 'Checking Cinder CSI and AM permissions...') }}
</p>
<div class="mb-2">
<span class="fw-semibold">1. Cinder CSI Plugin add-on</span>
<span v-if="storageCapability" class="badge ms-2" :class="storageCapability.driverReady ? 'bg-success' : 'bg-warning text-dark'">
{{ storageCapability.driverReady ? 'Ready' : 'Required' }}
</span>
<p v-if="storageCapability && !storageCapability.driverReady" class="text-muted mt-1 mb-0">
Install cinder-csi-plugin from the selected cluster's NHN NKS Add-ons page, wait until it is active, and refresh StorageClasses here.
</p>
</div>
<div v-if="storageCapability?.driverReady">
<div class="fw-semibold mb-2">2. Create StorageClass</div>
<label class="form-label">StorageClass name</label>
<input class="form-control" v-model="newStorageClassName" :disabled="storageCreating || !storageCapability.canCreate" maxlength="63">
<label class="form-label mt-2">Disk type</label>
<select class="form-select" v-model="newStorageDiskType" :disabled="storageCreating || !storageCapability.canCreate">
<option>General HDD</option><option>General SSD</option>
</select>
<p class="text-muted mt-2">Volumes are created when a Pod uses the claim. Retain policy keeps disks after claim deletion; remove unused disks separately.</p>
<button type="button" class="btn btn-outline-primary" :disabled="storageCreating || !storageCapability.canCreate || !newStorageClassName" @click="createNotebookStorageClass">{{ storageCreating ? 'Creating...' : 'Create NHN StorageClass' }}</button>
</div>
</div>
<div v-if="isJupyterObjectStorageCatalog" class="mt-2">
<label class="form-label">Notebook volume capacity (GiB)</label>
<input type="number" class="form-control" v-model.number="notebookStorageGi" :min="selectedStorageMinimum" step="1">
<p class="text-muted">Minimum {{ selectedStorageMinimum }} GiB for the known disk limits. Access mode: ReadWriteOnce. Provider quotas and disk availability are checked during provisioning.</p>
</div>
</div>

<div class="mb-3" v-if="modalTitle == 'Application Installation' && ingressData.ingressEnabled">
Expand Down Expand Up @@ -827,6 +860,9 @@
</div>

<!-- Footer -->
<div v-if="jupyterInstallationUnsupported && !deploymentCompleted" class="px-3" role="status">
<p class="alert alert-warning">Jupyter installation is unavailable for KT because AM does not support KT Object Storage integration.</p>
</div>
<div v-if="!deploymentCompleted && (specCheckErrors.length || specCheckWarnings.length)" class="px-3" aria-live="polite">
<div v-for="message in specCheckErrors" :key="message" class="alert alert-danger" role="alert">{{ message }}</div>
<div v-for="message in specCheckWarnings" :key="message" class="alert alert-warning">{{ message }}</div>
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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<any>(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)
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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
Expand Down Expand Up @@ -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 ''
})

Expand Down Expand Up @@ -2159,6 +2255,7 @@ const objectStorageCheckPassed = computed(() => {
const deployDisabled = computed(() => {
return deploying.value
|| deploymentCompleted.value
|| jupyterInstallationUnsupported.value
|| specChecking.value
|| Boolean(projectScopeError.value)
|| specCheckFlag.value
Expand Down Expand Up @@ -2248,6 +2345,10 @@ function buildK8sAdditionalConfig() {
const config = {} as Record<string, any>
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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(kr.co.mcmp.softwarecatalog.kubernetes.service.StorageOperationException.class)
public ResponseEntity<ResponseWrapper<String>> 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<ResponseWrapper<String>> handleProjectScopeException(ProjectScopeException e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -177,6 +179,19 @@ public ResponseEntity<ResponseWrapper<List<K8sStorageClassDTO>>> 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<ResponseWrapper<List<DeploymentHistoryDTO>>> getDeploymentHistories(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,6 @@ public class K8sStorageClassDTO {
private Boolean defaultClass;
private String reclaimPolicy;
private String volumeBindingMode;
private Integer minimumSizeGi;

}
Loading
Loading