diff --git a/api-runtime/common-runtime/ClusterManager.go b/api-runtime/common-runtime/ClusterManager.go index ea05bfc2e..e1d198728 100644 --- a/api-runtime/common-runtime/ClusterManager.go +++ b/api-runtime/common-runtime/ClusterManager.go @@ -960,8 +960,11 @@ func ListCluster(connectionName string, rsType string, kubeconfigType string) ([ if err != nil { clusterSPLock.RUnlock(connectionName, iidInfo.NameId) if checkNotFoundError(err) { - cblog.Error(err) - info = cres.ClusterInfo{IId: cres.IID{NameId: iidInfo.NameId, SystemId: iidInfo.SystemId}} + cblog.Infof("Cluster '%s' not found on CSP, marking as NotFound", iidInfo.NameId) + info = cres.ClusterInfo{ + IId: cres.IID{NameId: iidInfo.NameId, SystemId: iidInfo.SystemId}, + Status: cres.ClusterNotFound, + } infoList2 = append(infoList2, &info) continue } @@ -1078,6 +1081,14 @@ func GetCluster(connectionName string, rsType string, clusterName string, kubeco // (2) get resource(SystemId) info, err := handler.GetCluster(getDriverIID(cres.IID{NameId: iidInfo.NameId, SystemId: iidInfo.SystemId})) if err != nil { + if checkNotFoundError(err) { + cblog.Info("Cluster not found on CSP, returning NotFound status") + notFoundInfo := cres.ClusterInfo{ + IId: getUserIID(cres.IID{NameId: iidInfo.NameId, SystemId: iidInfo.SystemId}), + Status: cres.ClusterNotFound, + } + return ¬FoundInfo, nil + } cblog.Error(err) return nil, err } @@ -1985,17 +1996,102 @@ func DeleteCluster(connectionName string, rsType string, nameID string, force st } } - // (3) delete IID - _, err = infostore.DeleteByConditions(&ClusterIIDInfo{}, CONNECTION_NAME_COLUMN, iidInfo.ConnectionName, NAME_ID_COLUMN, nameID) + return result, nil +} + +// FinalizeDeleteCluster deletes the Spider meta information for a Cluster +// only when the Cluster no longer exists on the CSP. +// (1) Check the Cluster existence in MetaDB +// (2) Check the Cluster existence on CSP via GetCluster() +// (3) If the Cluster does not exist on CSP, delete the meta information +func FinalizeDeleteCluster(connectionName string, rsType string, nameID string) (bool, error) { + cblog.Info("call FinalizeDeleteCluster()") + + // check empty and trim user inputs + connectionName, err := EmptyCheckAndTrim("connectionName", connectionName) if err != nil { cblog.Error(err) - if force != "true" { + return false, err + } + + if err := checkCapability(connectionName, CLUSTER_HANDLER); err != nil { + return false, err + } + + nameID, err = EmptyCheckAndTrim("nameID", nameID) + if err != nil { + cblog.Error(err) + return false, err + } + + cldConn, err := ccm.GetCloudConnection(connectionName) + if err != nil { + cblog.Error(err) + return false, err + } + + handler, err := cldConn.CreateClusterHandler() + if err != nil { + cblog.Error(err) + return false, err + } + + clusterSPLock.Lock(connectionName, nameID) + defer clusterSPLock.Unlock(connectionName, nameID) + + // (1) get spiderIID for creating driverIID + var iidInfo *ClusterIIDInfo + var iidInfoList []*ClusterIIDInfo + if os.Getenv("PERMISSION_BASED_CONTROL_MODE") != "" { + err = getAuthIIDInfoList(connectionName, &iidInfoList) + if err != nil { + cblog.Error(err) return false, err } + } else { + err = infostore.ListByCondition(&iidInfoList, CONNECTION_NAME_COLUMN, connectionName) + if err != nil { + cblog.Error(err) + return false, err + } + } + var found = false + for _, OneIIdInfo := range iidInfoList { + if OneIIdInfo.NameId == nameID { + iidInfo = OneIIdInfo + found = true + break + } + } + if !found { + err := fmt.Errorf("%s '%s' does not exist in Spider's MetaDB for connection '%s'", RSTypeString(rsType), nameID, connectionName) + cblog.Error(err) + return false, err } - // for NodeGroup list - // delete all nodegroups of target Cluster + // (2) Check the Cluster existence on CSP via GetCluster() + driverIId := getDriverIID(cres.IID{NameId: iidInfo.NameId, SystemId: iidInfo.SystemId}) + _, err = handler.(cres.ClusterHandler).GetCluster(driverIId) + if err != nil { + if !checkNotFoundError(err) { + // unexpected error from CSP + cblog.Error(err) + return false, fmt.Errorf("failed to check Cluster existence on CSP: %w", err) + } + // Cluster does not exist on CSP => proceed to finalize + } else { + // Cluster still exists on CSP => cannot finalize + return false, fmt.Errorf("cannot finalize: Cluster '%s' still exists on CSP (status is not %s)", nameID, cres.ClusterNotFound) + } + + // (3) delete IID from MetaDB + _, err = infostore.DeleteByConditions(&ClusterIIDInfo{}, CONNECTION_NAME_COLUMN, iidInfo.ConnectionName, NAME_ID_COLUMN, nameID) + if err != nil { + cblog.Error(err) + return false, err + } + + // delete all nodegroups of target Cluster from MetaDB _, err = infostore.DeleteByConditions(&NodeGroupIIDInfo{}, CONNECTION_NAME_COLUMN, iidInfo.ConnectionName, OWNER_CLUSTER_NAME_COLUMN, iidInfo.NameId) if err != nil { @@ -2003,7 +2099,7 @@ func DeleteCluster(connectionName string, rsType string, nameID string, force st return false, err } - return result, nil + return true, nil } func CountAllClusters() (int64, error) { diff --git a/api-runtime/rest-runtime/CBSpiderRuntime.go b/api-runtime/rest-runtime/CBSpiderRuntime.go index 8a51b07d1..35be7d2ac 100644 --- a/api-runtime/rest-runtime/CBSpiderRuntime.go +++ b/api-runtime/rest-runtime/CBSpiderRuntime.go @@ -473,6 +473,7 @@ func getRoutes() []route { {"GET", "/cluster", ListCluster}, {"GET", "/cluster/:Name", GetCluster}, {"DELETE", "/cluster/:Name", DeleteCluster}, + {"DELETE", "/cluster/:Name/finalize", FinalizeDeleteCluster}, {"GET", "/cluster/:Name/token", GetClusterToken}, //-- for NodeGroup {"POST", "/cluster/:Name/nodegroup", AddNodeGroup}, diff --git a/api-runtime/rest-runtime/ClusterRest.go b/api-runtime/rest-runtime/ClusterRest.go index a206f16ca..e7e3cd160 100644 --- a/api-runtime/rest-runtime/ClusterRest.go +++ b/api-runtime/rest-runtime/ClusterRest.go @@ -549,7 +549,7 @@ func ChangeNodeGroupScaling(c echo.Context) error { // deleteCluster godoc // @ID delete-cluster // @Summary Delete Cluster -// @Description Delete a specified Cluster. +// @Description Delete a specified Cluster from the CSP.
This API only deletes the CSP resource and does not remove Spider meta information.
After deletion, call **DELETE /cluster/{Name}/finalize** to clean up Spider's internal metadata once the CSP resource no longer exists. // @Tags [Cluster Management] // @Accept json // @Produce json @@ -585,6 +585,46 @@ func DeleteCluster(c echo.Context) error { return c.JSON(http.StatusOK, &resultInfo) } +// finalizeDeleteCluster godoc +// @ID finalize-delete-cluster +// @Summary Finalize Delete Cluster +// @Description Finalize the deletion of a Cluster by removing its Spider meta information. +// @Description This API only succeeds when the Cluster no longer exists on the CSP. +// @Description Use this after DeleteCluster to clean up Spider's internal metadata. +// @Tags [Cluster Management] +// @Accept json +// @Produce json +// @Param ConnectionRequest body restruntime.ConnectionRequest true "Request body for finalizing Cluster deletion" +// @Param Name path string true "The name of the Cluster to finalize deletion" +// @Success 200 {object} BooleanInfo "Result of the finalize delete operation" +// @Failure 400 {object} SimpleMsg "Bad Request, possibly due to invalid JSON structure or missing fields" +// @Failure 404 {object} SimpleMsg "Resource Not Found" +// @Failure 500 {object} SimpleMsg "Internal Server Error" +// @Router /cluster/{Name}/finalize [delete] +func FinalizeDeleteCluster(c echo.Context) error { + cblog.Info("call FinalizeDeleteCluster()") + + var req ConnectionRequest + + if err := c.Bind(&req); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + clusterName := c.Param("Name") + + // Call common-runtime API + result, err := cmrt.FinalizeDeleteCluster(req.ConnectionName, CLUSTER, clusterName) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + resultInfo := BooleanInfo{ + Result: strconv.FormatBool(result), + } + + return c.JSON(http.StatusOK, &resultInfo) +} + // deleteCSPCluster godoc // @ID delete-csp-cluster // @Summary Delete CSP Cluster diff --git a/api-runtime/rest-runtime/admin-web/html/cluster.html b/api-runtime/rest-runtime/admin-web/html/cluster.html index e806649b0..e3842e53f 100644 --- a/api-runtime/rest-runtime/admin-web/html/cluster.html +++ b/api-runtime/rest-runtime/admin-web/html/cluster.html @@ -630,8 +630,11 @@

Cluster Management

- - + + + | + +
@@ -655,7 +658,9 @@

Cluster Management

NodeGroups Info Tags Misc - + + + {{range $index, $cluster := .Clusters}} @@ -666,11 +671,11 @@

Cluster Management

{{$cluster.IId.NameId}} • {{$cluster.IId.SystemId}} - {{$cluster.Version}} + {{if ne (printf "%s" $cluster.Status) "NotFound"}}{{$cluster.Version}}{{end}} {{$cluster.Status}} - {{if $cluster.Status}} + {{if and $cluster.Status (ne (printf "%s" $cluster.Status) "NotFound")}} - {{if $cluster.Status}} + {{if and $cluster.Status (ne (printf "%s" $cluster.Status) "NotFound")}} {{if $cluster.Addons.KeyValueList}} {{len $cluster.Addons.KeyValueList}} items View Details @@ -694,7 +699,7 @@

Cluster Management

- {{if $cluster.Status}} + {{if and $cluster.Status (ne (printf "%s" $cluster.Status) "NotFound")}} - {{if $cluster.Status}} + {{if and $cluster.Status (ne (printf "%s" $cluster.Status) "NotFound")}} @@ -731,7 +736,7 @@

Cluster Management

- + {{end}} {{if not .Clusters}} @@ -1107,12 +1114,155 @@

System ID (Managed by CSP)

} - function toggleSelectAll(selectAllCheckbox) { + function toggleSelectAllDelete(selectAllCheckbox) { + // Select only non-NotFound clusters + clearAllSelections(); const checkboxes = document.querySelectorAll('input[name="deleteCheckbox"]'); - checkboxes.forEach((checkbox) => { - checkbox.checked = selectAllCheckbox.checked; + if (checkbox.dataset.status !== 'NotFound') { + checkbox.checked = selectAllCheckbox.checked; + } else { + checkbox.checked = false; + } }); + // Uncheck the finalize select-all + document.getElementById('selectAllFinalize').checked = false; + document.getElementById('selectAllColumn').checked = false; + updateCheckboxStates(); + } + + function toggleSelectAllFinalize(selectAllCheckbox) { + // Select only NotFound clusters + clearAllSelections(); + const checkboxes = document.querySelectorAll('input[name="deleteCheckbox"]'); + checkboxes.forEach((checkbox) => { + if (checkbox.dataset.status === 'NotFound') { + checkbox.checked = selectAllCheckbox.checked; + } else { + checkbox.checked = false; + } + }); + // Uncheck the delete select-all + document.getElementById('selectAllDelete').checked = false; + document.getElementById('selectAllColumn').checked = false; + updateCheckboxStates(); + } + + function toggleSelectAllColumn(selectAllCheckbox) { + // Determine current selection mode from already checked items + const checked = document.querySelectorAll('input[name="deleteCheckbox"]:checked'); + if (checked.length === 0) { + // No selection yet: select all non-NotFound (delete mode) by default + const checkboxes = document.querySelectorAll('input[name="deleteCheckbox"]'); + checkboxes.forEach((checkbox) => { + if (checkbox.dataset.status !== 'NotFound') { + checkbox.checked = selectAllCheckbox.checked; + } + }); + } else { + // Follow current mode + const mode = getSelectionMode(); + const checkboxes = document.querySelectorAll('input[name="deleteCheckbox"]'); + checkboxes.forEach((checkbox) => { + if (mode === 'notfound' && checkbox.dataset.status === 'NotFound') { + checkbox.checked = selectAllCheckbox.checked; + } else if (mode === 'normal' && checkbox.dataset.status !== 'NotFound') { + checkbox.checked = selectAllCheckbox.checked; + } + }); + } + updateCheckboxStates(); + } + + function clearAllSelections() { + const checkboxes = document.querySelectorAll('input[name="deleteCheckbox"]'); + checkboxes.forEach((checkbox) => { + checkbox.checked = false; + }); + } + + // Returns 'notfound', 'normal', or null + function getSelectionMode() { + const checked = document.querySelectorAll('input[name="deleteCheckbox"]:checked'); + if (checked.length === 0) return null; + const firstStatus = checked[0].dataset.status; + return firstStatus === 'NotFound' ? 'notfound' : 'normal'; + } + + function onClusterCheckboxChange(changedCheckbox) { + updateCheckboxStates(); + } + + function updateCheckboxStates() { + const mode = getSelectionMode(); + const checkboxes = document.querySelectorAll('input[name="deleteCheckbox"]'); + const btnDelete = document.getElementById('btnDelete'); + const btnFinalize = document.getElementById('btnFinalize'); + + checkboxes.forEach((checkbox) => { + const isNotFound = checkbox.dataset.status === 'NotFound'; + const row = checkbox.closest('tr'); + + if (mode === null) { + // No selection: all enabled + checkbox.disabled = false; + row.style.opacity = '1'; + row.title = ''; + } else if (mode === 'notfound') { + if (isNotFound) { + checkbox.disabled = false; + row.style.opacity = '1'; + row.title = ''; + } else { + checkbox.disabled = true; + checkbox.checked = false; + row.style.opacity = '0.45'; + row.title = 'Cannot mix: NotFound clusters are selected. Deselect them first to select active clusters.'; + } + } else { + // mode === 'normal' + if (!isNotFound) { + checkbox.disabled = false; + row.style.opacity = '1'; + row.title = ''; + } else { + checkbox.disabled = true; + checkbox.checked = false; + row.style.opacity = '0.45'; + row.title = 'Cannot mix: Active clusters are selected. Deselect them first to select NotFound clusters.'; + } + } + }); + + // Enable/disable header buttons based on selection mode + if (mode === 'notfound') { + btnDelete.disabled = true; + btnDelete.style.opacity = '0.4'; + btnDelete.style.cursor = 'not-allowed'; + btnFinalize.disabled = false; + btnFinalize.style.opacity = '1'; + btnFinalize.style.cursor = 'pointer'; + } else if (mode === 'normal') { + btnDelete.disabled = false; + btnDelete.style.opacity = '1'; + btnDelete.style.cursor = 'pointer'; + btnFinalize.disabled = true; + btnFinalize.style.opacity = '0.4'; + btnFinalize.style.cursor = 'not-allowed'; + } else { + // No selection: both enabled + btnDelete.disabled = false; + btnDelete.style.opacity = '1'; + btnDelete.style.cursor = 'pointer'; + btnFinalize.disabled = false; + btnFinalize.style.opacity = '1'; + btnFinalize.style.cursor = 'pointer'; + } + } + + // Legacy wrapper kept for table header checkbox + function toggleSelectAll(selectAllCheckbox) { + toggleSelectAllColumn(selectAllCheckbox); } @@ -1151,6 +1301,41 @@

System ID (Managed by CSP)

}); } + function finalizeSelectedClusters() { + const checkboxes = document.querySelectorAll('input[name="deleteCheckbox"]:checked'); + if (checkboxes.length === 0) { + alert("Please select clusters to finalize."); + return; + } + + if (!confirm("Are you sure you want to finalize the selected clusters?\n\nThis will remove Spider meta information only if the cluster no longer exists on the CSP.")) { + return; + } + + const finalizePromises = Array.from(checkboxes).map(checkbox => { + const clusterName = checkbox.value; + + return fetchWithProgress(`/spider/cluster/${clusterName}/finalize`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ConnectionName: "{{.ConnectionConfig}}" }) + }).then(response => { + if (!response.ok) { + return response.json().then(error => { throw new Error(error.message); }); + } + return response.json(); + }); + }); + + Promise.all(finalizePromises) + .then(() => { + location.reload(); + }) + .catch(error => { + alert("Error finalizing clusters: " + error.message); + }); + } + function showClusterTagOverlay(event, tag, resourceType, resourceName) { event.stopPropagation(); @@ -1377,7 +1562,7 @@

System ID (Managed by CSP)

switch (providerName.toUpperCase()) { case 'AZURE': - versionInput.value = '1.30.3'; + versionInput.value = '1.34.3'; vmSpecInput.value = 'Standard_B2s'; break; case 'NHNCLOUD': @@ -1542,7 +1727,7 @@

System ID (Managed by CSP)

function showError(message, title) { const errorDiv = document.createElement('div'); errorDiv.className = 'error-message'; - const formattedMessage = message.split('.').join('.
'); + const formattedMessage = message.split('. ').join('.
'); errorDiv.innerHTML = `

${title}

${formattedMessage}

`; document.body.appendChild(errorDiv); document.addEventListener('keydown', handleEscError); diff --git a/api-runtime/rest-runtime/admin-web/html/vm.html b/api-runtime/rest-runtime/admin-web/html/vm.html index db6a6a53a..3a5631e52 100644 --- a/api-runtime/rest-runtime/admin-web/html/vm.html +++ b/api-runtime/rest-runtime/admin-web/html/vm.html @@ -3866,12 +3866,6 @@

MC-Insight API Token Req // Get current connection info and set filters getConnectionInfo().then(info => { - if (!info.zone) { - alert('Cannot determine Zone from selected Subnet. Please select a valid Subnet.'); - closeImageSelectionOverlay(); - return; - } - // For OpenStack and MOCK, skip MC-Insight token check and load filters directly if (info.csp.toLowerCase() === 'openstack' || info.csp.toLowerCase() === 'mock') { loadImageFilterOptions(info.csp, info.region, info.zone); @@ -4042,6 +4036,7 @@

MC-Insight API Token Req // Populate Region/Zone (cloud_hierarchy) if (data.cloud_hierarchy && data.cloud_hierarchy[actualCsp]) { const regions = Object.keys(data.cloud_hierarchy[actualCsp]); + let regionFound = false; regions.forEach(region => { const option = document.createElement('option'); @@ -4049,6 +4044,7 @@

MC-Insight API Token Req option.textContent = region; if (region === currentRegion) { option.selected = true; + regionFound = true; } regionSelect.appendChild(option); }); @@ -4056,6 +4052,10 @@

MC-Insight API Token Req regionSelect.disabled = true; regionSelect.style.backgroundColor = '#f0f0f0'; + if (!regionFound) { + alert(`MC-Insight does not have image data for the current region: ${currentRegion}`); + } + const zones = data.cloud_hierarchy[actualCsp][currentRegion]; if (zones && zones.length > 0) { @@ -4767,12 +4767,6 @@

MC-Insight API Token Req // Get current connection info and set filters getConnectionInfo().then(info => { - if (!info.zone) { - alert('Cannot determine Zone from selected Subnet. Please select a valid Subnet.'); - closeSpecSelectionOverlay(); - return; - } - // For OpenStack and MOCK, skip MC-Insight token check and load filters directly if (info.csp.toLowerCase() === 'openstack' || info.csp.toLowerCase() === 'mock') { loadSpecFilterOptions(info.csp, info.region, info.zone); @@ -5264,6 +5258,7 @@

MC-Insight API Token Req // Populate Region/Zone (cloud_hierarchy) if (data.cloud_hierarchy && data.cloud_hierarchy[actualCsp]) { const regions = Object.keys(data.cloud_hierarchy[actualCsp]); + let regionFound = false; regions.forEach(region => { const option = document.createElement('option'); @@ -5271,6 +5266,7 @@

MC-Insight API Token Req option.textContent = region; if (region === currentRegion) { option.selected = true; + regionFound = true; } regionSelect.appendChild(option); }); @@ -5278,6 +5274,10 @@

MC-Insight API Token Req regionSelect.disabled = true; regionSelect.style.backgroundColor = '#f0f0f0'; + if (!regionFound) { + alert(`MC-Insight does not have spec data for the current region: ${currentRegion}`); + } + const zones = data.cloud_hierarchy[actualCsp][currentRegion]; if (zones && zones.length > 0) { diff --git a/api-runtime/rest-runtime/admin-web/html/vpc-subnet.html b/api-runtime/rest-runtime/admin-web/html/vpc-subnet.html index 30b054f96..1fe3ca879 100644 --- a/api-runtime/rest-runtime/admin-web/html/vpc-subnet.html +++ b/api-runtime/rest-runtime/admin-web/html/vpc-subnet.html @@ -1050,10 +1050,11 @@

System ID (Managed by CSP)

const vpcCIDR = document.getElementById('vpcCIDR').value; const subnetName = document.getElementById('subnetName').value; const subnetCIDR = document.getElementById('subnetCIDR').value; - const subnetZone = document.getElementById('subnetZone').value; + const subnetZoneEl = document.getElementById('subnetZone'); + const subnetZone = subnetZoneEl.value; const vpcCount = document.getElementById('vpcCount').value; - if (!vpcName || !vpcCIDR || !subnetName || !subnetCIDR || !subnetZone || !vpcCount) { + if (!vpcName || !vpcCIDR || !subnetName || !subnetCIDR || !vpcCount || (subnetZoneEl.hasAttribute('required') && !subnetZone)) { alert("Please fill in all the fields."); return false; } @@ -1875,7 +1876,18 @@

System ID (Managed by CSP)

.then(response => response.json()) .then(data => { zoneSelect.innerHTML = ''; - data.ZoneList.forEach(zone => { + const zones = data.ZoneList || []; + if (zones.length === 0) { + zoneSelect.removeAttribute('required'); + const option = document.createElement('option'); + option.value = ''; + option.textContent = 'No Zone Available'; + option.selected = true; + zoneSelect.appendChild(option); + return; + } + zoneSelect.setAttribute('required', 'required'); + zones.forEach(zone => { const option = document.createElement('option'); option.value = zone.Name; option.textContent = `${zone.Name} (${zone.DisplayName})`; diff --git a/api/docs.go b/api/docs.go index 17ae4aba3..d496e4196 100644 --- a/api/docs.go +++ b/api/docs.go @@ -1174,7 +1174,7 @@ const docTemplate = `{ } }, "delete": { - "description": "Delete a specified Cluster.", + "description": "Delete a specified Cluster from the CSP. \u003cbr\u003e This API only deletes the CSP resource and does not remove Spider meta information. \u003cbr\u003e After deletion, call **DELETE /cluster/{Name}/finalize** to clean up Spider's internal metadata once the CSP resource no longer exists.", "consumes": [ "application/json" ], @@ -1238,6 +1238,66 @@ const docTemplate = `{ } } }, + "/cluster/{Name}/finalize": { + "delete": { + "description": "Finalize the deletion of a Cluster by removing its Spider meta information.\nThis API only succeeds when the Cluster no longer exists on the CSP.\nUse this after DeleteCluster to clean up Spider's internal metadata.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "[Cluster Management]" + ], + "summary": "Finalize Delete Cluster", + "operationId": "finalize-delete-cluster", + "parameters": [ + { + "description": "Request body for finalizing Cluster deletion", + "name": "ConnectionRequest", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/spider.ConnectionRequest" + } + }, + { + "type": "string", + "description": "The name of the Cluster to finalize deletion", + "name": "Name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Result of the finalize delete operation", + "schema": { + "$ref": "#/definitions/spider.BooleanInfo" + } + }, + "400": { + "description": "Bad Request, possibly due to invalid JSON structure or missing fields", + "schema": { + "$ref": "#/definitions/spider.SimpleMsg" + } + }, + "404": { + "description": "Resource Not Found", + "schema": { + "$ref": "#/definitions/spider.SimpleMsg" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/spider.SimpleMsg" + } + } + } + } + }, "/cluster/{Name}/nodegroup": { "post": { "description": "Add a new Node Group to an existing Cluster.", @@ -11315,14 +11375,16 @@ const docTemplate = `{ "Active", "Inactive", "Updating", - "Deleting" + "Deleting", + "NotFound" ], "x-enum-varnames": [ "ClusterCreating", "ClusterActive", "ClusterInactive", "ClusterUpdating", - "ClusterDeleting" + "ClusterDeleting", + "ClusterNotFound" ] }, "spider.CronSchedule": { diff --git a/api/swagger.json b/api/swagger.json index b1ff1d94d..010bdd21a 100644 --- a/api/swagger.json +++ b/api/swagger.json @@ -1171,7 +1171,7 @@ } }, "delete": { - "description": "Delete a specified Cluster.", + "description": "Delete a specified Cluster from the CSP. \u003cbr\u003e This API only deletes the CSP resource and does not remove Spider meta information. \u003cbr\u003e After deletion, call **DELETE /cluster/{Name}/finalize** to clean up Spider's internal metadata once the CSP resource no longer exists.", "consumes": [ "application/json" ], @@ -1235,6 +1235,66 @@ } } }, + "/cluster/{Name}/finalize": { + "delete": { + "description": "Finalize the deletion of a Cluster by removing its Spider meta information.\nThis API only succeeds when the Cluster no longer exists on the CSP.\nUse this after DeleteCluster to clean up Spider's internal metadata.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "[Cluster Management]" + ], + "summary": "Finalize Delete Cluster", + "operationId": "finalize-delete-cluster", + "parameters": [ + { + "description": "Request body for finalizing Cluster deletion", + "name": "ConnectionRequest", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/spider.ConnectionRequest" + } + }, + { + "type": "string", + "description": "The name of the Cluster to finalize deletion", + "name": "Name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Result of the finalize delete operation", + "schema": { + "$ref": "#/definitions/spider.BooleanInfo" + } + }, + "400": { + "description": "Bad Request, possibly due to invalid JSON structure or missing fields", + "schema": { + "$ref": "#/definitions/spider.SimpleMsg" + } + }, + "404": { + "description": "Resource Not Found", + "schema": { + "$ref": "#/definitions/spider.SimpleMsg" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/spider.SimpleMsg" + } + } + } + } + }, "/cluster/{Name}/nodegroup": { "post": { "description": "Add a new Node Group to an existing Cluster.", @@ -11312,14 +11372,16 @@ "Active", "Inactive", "Updating", - "Deleting" + "Deleting", + "NotFound" ], "x-enum-varnames": [ "ClusterCreating", "ClusterActive", "ClusterInactive", "ClusterUpdating", - "ClusterDeleting" + "ClusterDeleting", + "ClusterNotFound" ] }, "spider.CronSchedule": { diff --git a/api/swagger.yaml b/api/swagger.yaml index 70a9fbc44..5dc5559cf 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -316,6 +316,7 @@ definitions: - Inactive - Updating - Deleting + - NotFound type: string x-enum-varnames: - ClusterCreating @@ -323,6 +324,7 @@ definitions: - ClusterInactive - ClusterUpdating - ClusterDeleting + - ClusterNotFound spider.CronSchedule: properties: DayOfMonth: @@ -4245,7 +4247,10 @@ paths: delete: consumes: - application/json - description: Delete a specified Cluster. + description: Delete a specified Cluster from the CSP.
This API only deletes + the CSP resource and does not remove Spider meta information.
After deletion, + call **DELETE /cluster/{Name}/finalize** to clean up Spider's internal metadata + once the CSP resource no longer exists. operationId: delete-cluster parameters: - description: Request body for deleting a Cluster @@ -4332,6 +4337,50 @@ paths: summary: Get Cluster tags: - '[Cluster Management]' + /cluster/{Name}/finalize: + delete: + consumes: + - application/json + description: |- + Finalize the deletion of a Cluster by removing its Spider meta information. + This API only succeeds when the Cluster no longer exists on the CSP. + Use this after DeleteCluster to clean up Spider's internal metadata. + operationId: finalize-delete-cluster + parameters: + - description: Request body for finalizing Cluster deletion + in: body + name: ConnectionRequest + required: true + schema: + $ref: '#/definitions/spider.ConnectionRequest' + - description: The name of the Cluster to finalize deletion + in: path + name: Name + required: true + type: string + produces: + - application/json + responses: + "200": + description: Result of the finalize delete operation + schema: + $ref: '#/definitions/spider.BooleanInfo' + "400": + description: Bad Request, possibly due to invalid JSON structure or missing + fields + schema: + $ref: '#/definitions/spider.SimpleMsg' + "404": + description: Resource Not Found + schema: + $ref: '#/definitions/spider.SimpleMsg' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/spider.SimpleMsg' + summary: Finalize Delete Cluster + tags: + - '[Cluster Management]' /cluster/{Name}/nodegroup: post: consumes: diff --git a/cloud-control-manager/cloud-driver/drivers/aws/resources/ClusterHandler.go b/cloud-control-manager/cloud-driver/drivers/aws/resources/ClusterHandler.go index 40caabbba..efe44b839 100644 --- a/cloud-control-manager/cloud-driver/drivers/aws/resources/ClusterHandler.go +++ b/cloud-control-manager/cloud-driver/drivers/aws/resources/ClusterHandler.go @@ -646,7 +646,7 @@ func (ClusterHandler *AwsClusterHandler) GetCluster(clusterIID irs.IID) (irs.Clu if aerr, ok := err.(awserr.Error); ok { switch aerr.Code() { case eks.ErrCodeResourceNotFoundException: - cblogger.Error(eks.ErrCodeResourceNotFoundException, aerr.Error()) + cblogger.Info(eks.ErrCodeResourceNotFoundException, aerr.Error()) case eks.ErrCodeClientException: cblogger.Error(eks.ErrCodeClientException, aerr.Error()) case eks.ErrCodeServerException: diff --git a/cloud-control-manager/cloud-driver/drivers/azure/AzureDriver.go b/cloud-control-manager/cloud-driver/drivers/azure/AzureDriver.go index bab529a48..83d958c6b 100644 --- a/cloud-control-manager/cloud-driver/drivers/azure/AzureDriver.go +++ b/cloud-control-manager/cloud-driver/drivers/azure/AzureDriver.go @@ -15,8 +15,6 @@ import ( "os" "time" - "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns" - "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" @@ -26,6 +24,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v6" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v6" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/subscription/armsubscription" cblogger "github.com/cloud-barista/cb-log" "github.com/sirupsen/logrus" @@ -258,10 +257,6 @@ func (driver *AzureDriver) ConnectCloud(connectionInfo idrv.ConnectionInfo) (ico if err != nil { return nil, err } - Ctx, dnsZoneClient, err := getDnsZoneClient(connectionInfo.CredentialInfo) - if err != nil { - return nil, err - } Ctx, fileShareClient, err := getFileShareClient(connectionInfo.CredentialInfo) if err != nil { return nil, err @@ -300,7 +295,6 @@ func (driver *AzureDriver) ConnectCloud(connectionInfo idrv.ConnectionInfo) (ico ResourceGroupsClient: resourceGroupsClient, TagsClient: tagsClient, ResourceSKUsClient: resourceSKUsClient, - DnsZoneClient: dnsZoneClient, FileShareClient: fileShareClient, AccountsClient: accountsClient, } @@ -698,21 +692,6 @@ func getTagsClient(credential idrv.CredentialInfo) (context.Context, *armresourc return ctx, tagsClient, nil } -func getDnsZoneClient(credential idrv.CredentialInfo) (context.Context, *armdns.ZonesClient, error) { - cred, err := getCred(credential) - if err != nil { - return nil, nil, err - } - - dnsZoneClient, err := armdns.NewZonesClient(credential.SubscriptionId, cred, nil) - if err != nil { - return nil, nil, err - } - - ctx, _ := context.WithTimeout(context.Background(), cspTimeout*time.Second) - return ctx, dnsZoneClient, nil -} - func getFileShareClient(credential idrv.CredentialInfo) (context.Context, *armstorage.FileSharesClient, error) { cred, err := getCred(credential) if err != nil { diff --git a/cloud-control-manager/cloud-driver/drivers/azure/connect/Azure_CloudConnection.go b/cloud-control-manager/cloud-driver/drivers/azure/connect/Azure_CloudConnection.go index 26e941a8c..e7b87c181 100644 --- a/cloud-control-manager/cloud-driver/drivers/azure/connect/Azure_CloudConnection.go +++ b/cloud-control-manager/cloud-driver/drivers/azure/connect/Azure_CloudConnection.go @@ -17,7 +17,6 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/monitor/azquery" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v6" - "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v6" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage" @@ -67,7 +66,6 @@ type AzureCloudConnection struct { ResourceGroupsClient *armresources.ResourceGroupsClient ResourceSKUsClient *armcompute.ResourceSKUsClient TagsClient *armresources.TagsClient - DnsZoneClient *armdns.ZonesClient FileShareClient *armstorage.FileSharesClient AccountsClient *armstorage.AccountsClient } @@ -257,7 +255,6 @@ func (cloudConn *AzureCloudConnection) CreateClusterHandler() (irs.ClusterHandle SecurityRulesClient: cloudConn.SecurityGroupRuleClient, VirtualMachineSizesClient: cloudConn.VmSpecClient, SSHPublicKeysClient: cloudConn.SshKeyClient, - DnsZonesClient: cloudConn.DnsZoneClient, } return &clusterHandler, nil } diff --git a/cloud-control-manager/cloud-driver/drivers/azure/resources/ClusterHandler.go b/cloud-control-manager/cloud-driver/drivers/azure/resources/ClusterHandler.go index 2c1253029..e16d8e018 100644 --- a/cloud-control-manager/cloud-driver/drivers/azure/resources/ClusterHandler.go +++ b/cloud-control-manager/cloud-driver/drivers/azure/resources/ClusterHandler.go @@ -17,7 +17,7 @@ import ( "sync" "time" - "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns" + "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v6" @@ -52,7 +52,6 @@ type AzureClusterHandler struct { SecurityRulesClient *armnetwork.SecurityRulesClient VirtualMachineSizesClient *armcompute.VirtualMachineSizesClient SSHPublicKeysClient *armcompute.SSHPublicKeysClient - DnsZonesClient *armdns.ZonesClient } type auth struct { @@ -201,43 +200,16 @@ func (ac *AzureClusterHandler) CreateCluster(clusterReqInfo irs.ClusterInfo) (in LoggingError(hiscallInfo, createErr) return irs.ClusterInfo{}, createErr } - defer func() { - if createErr != nil { - if err := ac.CleanCluster(clusterReqInfo.IId.NameId); err != nil { - cblogger.Error(fmt.Sprintf("failed to clean up cluster %q: %s", clusterReqInfo.IId.NameId, err)) - } - } - }() - baseSecurityGroup, err := waitingClusterBaseSecurityGroup(irs.IID{NameId: clusterReqInfo.IId.NameId}, ac.ManagedClustersClient, ac.SecurityGroupsClient, ac.Ctx, ac.CredentialInfo, ac.Region) - if err != nil { - createErr = errors.New(fmt.Sprintf("Failed to Create Cluster. err = %s", err)) - cblogger.Error(createErr.Error()) - LoggingError(hiscallInfo, createErr) - return irs.ClusterInfo{}, createErr - } - for _, sg := range clusterReqInfo.Network.SecurityGroupIIDs { - err = applySecurityGroup(irs.IID{NameId: clusterReqInfo.IId.NameId}, irs.IID{NameId: sg.NameId}, baseSecurityGroup, ac.ManagedClustersClient, ac.SecurityGroupsClient, ac.SecurityRulesClient, ac.Ctx, ac.CredentialInfo, ac.Region) - if err != nil { - createErr = errors.New(fmt.Sprintf("Failed to Create Cluster. err = %s", err)) - cblogger.Error(createErr.Error()) - LoggingError(hiscallInfo, createErr) - return irs.ClusterInfo{}, createErr - } - } - cluster, err := getRawCluster(clusterReqInfo.IId, ac.ManagedClustersClient, ac.Ctx, ac.CredentialInfo, ac.Region) - if err != nil { - createErr = errors.New(fmt.Sprintf("Failed to Create Cluster. err = %s", err)) - cblogger.Error(createErr.Error()) - LoggingError(hiscallInfo, createErr) - return irs.ClusterInfo{}, createErr - } - info, err = setterClusterInfo(&cluster, ac.ManagedClustersClient, ac.SecurityGroupsClient, ac.VirtualNetworksClient, ac.AgentPoolsClient, ac.VirtualMachineScaleSetsClient, ac.VirtualMachineScaleSetVMsClient, ac.CredentialInfo, ac.Region, ac.Ctx) - if err != nil { - createErr = errors.New(fmt.Sprintf("Failed to Create Cluster. err = %s", err)) - cblogger.Error(createErr.Error()) - LoggingError(hiscallInfo, createErr) - return irs.ClusterInfo{}, createErr + + // Async: Return immediately with Creating status (like AWS EKS pattern) + // The cluster is being provisioned in the background by Azure. + info = irs.ClusterInfo{ + IId: clusterReqInfo.IId, + Version: clusterReqInfo.Version, + Network: clusterReqInfo.Network, + Status: irs.ClusterCreating, } + LoggingInfo(hiscallInfo, start) return info, nil } @@ -281,6 +253,12 @@ func (ac *AzureClusterHandler) GetCluster(clusterIID irs.IID) (info irs.ClusterI cluster, err := getRawCluster(clusterIID, ac.ManagedClustersClient, ac.Ctx, ac.CredentialInfo, ac.Region) if err != nil { + // Check if the error is ResourceNotFound (e.g., async creation not yet visible) + var respErr *azcore.ResponseError + if errors.As(err, &respErr) && respErr.ErrorCode == "ResourceNotFound" { + cblogger.Infof("Cluster '%s' not found on CSP: %s", clusterIID.NameId, err.Error()) + return irs.ClusterInfo{}, err + } getErr = errors.New(fmt.Sprintf("Failed to Get Cluster. err = %s", err)) cblogger.Error(getErr.Error()) LoggingError(hiscallInfo, getErr) @@ -1098,30 +1076,6 @@ func checkValidationNodeGroups(nodeGroups []irs.NodeGroupInfo, virtualMachineSiz return nil } -func (ac *AzureClusterHandler) generateDnsZone(clusterName string) (string, error) { - rg := ac.Region.Region - zoneName := fmt.Sprintf("%s.com", clusterName) - globalLoc := "global" - - resp, err := ac.DnsZonesClient.CreateOrUpdate( - ac.Ctx, - rg, - zoneName, - armdns.Zone{ - Location: &globalLoc, - }, - nil, - ) - if err != nil { - return "", fmt.Errorf("failed to create/update DNS zone %q: %w", zoneName, err) - } - - if resp.Zone.ID == nil { - return "", fmt.Errorf("DNS zone %q created but returned ID is nil", zoneName) - } - return *resp.Zone.ID, nil -} - func checkValidationCreateCluster(clusterReqInfo irs.ClusterInfo, virtualMachineSizesClient *armcompute.VirtualMachineSizesClient, regionInfo idrv.RegionInfo, ctx context.Context) error { // nodegroup 확인 err := checkValidationNodeGroups(clusterReqInfo.NodeGroupList, virtualMachineSizesClient, regionInfo, ctx) @@ -1161,13 +1115,6 @@ func createCluster(clusterReqInfo irs.ClusterInfo, ac *AzureClusterHandler) erro return err } - zoneID, err := ac.generateDnsZone(clusterReqInfo.IId.NameId) - if err != nil { - return fmt.Errorf("failed to create dns zone: %v", err) - } - - ingressProfile := generateIngressProfile(&zoneID) - clusterCreateOpts := armcontainerservice.ManagedCluster{ Location: toStrPtr(ac.Region.Region), SKU: &armcontainerservice.ManagedClusterSKU{ @@ -1186,7 +1133,6 @@ func createCluster(clusterReqInfo irs.ClusterInfo, ac *AzureClusterHandler) erro AgentPoolProfiles: agentPoolProfiles, NetworkProfile: &networkProfile, LinuxProfile: &linuxProfileSSH, - IngressProfile: ingressProfile, }, } if clusterReqInfo.TagList != nil { @@ -1208,23 +1154,12 @@ func createCluster(clusterReqInfo irs.ClusterInfo, ac *AzureClusterHandler) erro } func (ac *AzureClusterHandler) CleanCluster(clusterName string) error { - delPoller, err := ac.ManagedClustersClient.BeginDelete(ac.Ctx, ac.Region.Region, clusterName, nil) + // Async: Begin delete and return immediately (like AWS EKS pattern). + // The cluster deletion is processed in the background by Azure. + _, err := ac.ManagedClustersClient.BeginDelete(ac.Ctx, ac.Region.Region, clusterName, nil) if err != nil { return fmt.Errorf("failed to begin deleting cluster %q: %w", clusterName, err) } - if _, err := delPoller.PollUntilDone(ac.Ctx, nil); err != nil { - return fmt.Errorf("failed to delete cluster %q: %w", clusterName, err) - } - - zoneName := fmt.Sprintf("%s.com", clusterName) - - dnsPoller, err := ac.DnsZonesClient.BeginDelete(ac.Ctx, ac.Region.Region, zoneName, nil) - if err != nil { - return fmt.Errorf("failed to begin deleting DNS zone %q: %w", zoneName, err) - } - if _, err := dnsPoller.PollUntilDone(ac.Ctx, nil); err != nil { - return fmt.Errorf("failed to delete DNS zone %q: %w", zoneName, err) - } return nil } @@ -1402,15 +1337,6 @@ func getSSHKeyIIDByNodeGroups(NodeGroupInfos []irs.NodeGroupInfo) (irs.IID, erro return *key, nil } -func generateIngressProfile(dnsZoneID *string) *armcontainerservice.ManagedClusterIngressProfile { - return &armcontainerservice.ManagedClusterIngressProfile{ - WebAppRouting: &armcontainerservice.ManagedClusterIngressProfileWebAppRouting{ - Enabled: toBoolPtr(true), - DNSZoneResourceIDs: []*string{dnsZoneID}, - }, - } -} - func generateManagedClusterLinuxProfileSSH(clusterReqInfo irs.ClusterInfo, sshPublicKeysClient *armcompute.SSHPublicKeysClient, resourceGroup string, ctx context.Context) (armcontainerservice.LinuxProfile, armcompute.SSHPublicKeyResource, error) { sshkeyId, err := getSSHKeyIIDByNodeGroups(clusterReqInfo.NodeGroupList) if err != nil { diff --git a/cloud-control-manager/cloud-driver/interfaces/resources/ClusterHandler.go b/cloud-control-manager/cloud-driver/interfaces/resources/ClusterHandler.go index f31057bc5..c640b203a 100644 --- a/cloud-control-manager/cloud-driver/interfaces/resources/ClusterHandler.go +++ b/cloud-control-manager/cloud-driver/interfaces/resources/ClusterHandler.go @@ -21,6 +21,7 @@ const ( ClusterInactive ClusterStatus = "Inactive" ClusterUpdating ClusterStatus = "Updating" ClusterDeleting ClusterStatus = "Deleting" + ClusterNotFound ClusterStatus = "NotFound" ) type NodeGroupStatus string diff --git a/go.mod b/go.mod index 6aa0c30ea..caa55215e 100644 --- a/go.mod +++ b/go.mod @@ -47,7 +47,6 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/monitor/azquery v1.1.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6 v6.0.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v6 v6.0.0 - github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v6 v6.0.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.0 diff --git a/go.sum b/go.sum index d8b2d4bf1..d3b6bd0ac 100644 --- a/go.sum +++ b/go.sum @@ -77,8 +77,6 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontai github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v5 v5.0.0/go.mod h1:HcZY0PHPo/7d75p99lB6lK0qYOP4vLRJUBpiehYXtLQ= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v6 v6.0.0 h1:EK0ZY1qKWzaWyRNFDsrwRfgVBMGbs+m71yie+y11+Tc= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v6 v6.0.0/go.mod h1:drbnYtukMoZqUQq9hJASf41w3RB4VoTJPoPpe+XDHPU= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 h1:lpOxwrQ919lCZoNCd69rVt8u1eLZuMORrGXqy8sNf3c= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0/go.mod h1:fSvRkb8d26z9dbL40Uf/OO6Vo9iExtZK3D0ulRV+8M0= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0 h1:PTFGRSlMKCQelWwxUyYVEUqseBJVemLyqWJjvMyt0do= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0/go.mod h1:LRr2FzBTQlONPPa5HREE5+RjSCTXl7BwOvYOaWTqCaI= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw=
Name - {{if $cluster.Status}} + {{if and $cluster.Status (ne (printf "%s" $cluster.Status) "NotFound")}} {{range $tag := $cluster.TagList}}
{{$tag.Key}}: {{$tag.Value}}
{{end}} @@ -742,6 +747,7 @@

Cluster Management

+ {{if ne (printf "%s" $cluster.Status) "NotFound"}}
{{range $kv := $cluster.KeyValueList}} {{$kv.Key}}: {{$kv.Value}}
@@ -750,9 +756,10 @@

Cluster Management

{{if $cluster.KeyValueList}} {{end}} + {{end}}